Update AGENTS.md
This commit is contained in:
@@ -1,363 +1,407 @@
|
|||||||
# portal.Rhemawavetech.com
|
You're right — I gave you the optimized root content earlier, but I did not provide it cleanly as a single copy/paste artifact. Here it is.
|
||||||
|
|
||||||
SvelteKit 2 + Svelte 5 (runes) + Tailwind CSS v4 + Prisma 7 + PostgreSQL + Better-Auth.
|
Save this as:
|
||||||
|
|
||||||
## Quick start
|
```text
|
||||||
|
AGENTS.md
|
||||||
|
```
|
||||||
|
|
||||||
|
at your project root:
|
||||||
|
|
||||||
|
````text
|
||||||
|
# Rhemawave Portal AI Guide
|
||||||
|
|
||||||
|
## Mission
|
||||||
|
|
||||||
|
You are working on portal.Rhemawavetech.com.
|
||||||
|
|
||||||
|
Act as a senior engineer making complete, production-ready changes.
|
||||||
|
|
||||||
|
Do not stop after analysis.
|
||||||
|
Do not provide a plan and wait.
|
||||||
|
Do not describe what should be done instead of doing it.
|
||||||
|
|
||||||
|
When asked to make a change:
|
||||||
|
|
||||||
|
1. Inspect the relevant code.
|
||||||
|
2. Understand the existing implementation.
|
||||||
|
3. Make the change.
|
||||||
|
4. Validate the result.
|
||||||
|
5. Fix any problems introduced.
|
||||||
|
6. Report what changed and what was verified.
|
||||||
|
|
||||||
|
A task is incomplete until the implementation is finished and verified.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Working Style
|
||||||
|
|
||||||
|
## Focused Context
|
||||||
|
|
||||||
|
Do not load the entire repository into context.
|
||||||
|
|
||||||
|
Use a focused approach:
|
||||||
|
|
||||||
|
1. Identify relevant files.
|
||||||
|
2. Read only necessary code.
|
||||||
|
3. Find existing patterns.
|
||||||
|
4. Make the smallest correct change.
|
||||||
|
|
||||||
|
Avoid:
|
||||||
|
- broad rewrites
|
||||||
|
- unrelated cleanup
|
||||||
|
- speculative refactors
|
||||||
|
- introducing unnecessary dependencies
|
||||||
|
|
||||||
|
Prefer:
|
||||||
|
- small diffs
|
||||||
|
- existing conventions
|
||||||
|
- explicit implementations
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Development Loop
|
||||||
|
|
||||||
|
Always follow this workflow:
|
||||||
|
|
||||||
|
## 1. Understand
|
||||||
|
|
||||||
|
Before editing:
|
||||||
|
|
||||||
|
- Locate relevant files.
|
||||||
|
- Inspect current behavior.
|
||||||
|
- Find similar working examples.
|
||||||
|
- Identify constraints.
|
||||||
|
|
||||||
|
## 2. Implement
|
||||||
|
|
||||||
|
Make the smallest correct change.
|
||||||
|
|
||||||
|
Follow existing architecture.
|
||||||
|
|
||||||
|
Do not replace working patterns with new abstractions unless required.
|
||||||
|
|
||||||
|
## 3. Verify
|
||||||
|
|
||||||
|
Run appropriate checks:
|
||||||
|
|
||||||
```sh
|
```sh
|
||||||
npm install # engine-strict=true in .npmrc
|
npm run check
|
||||||
npm run prepare # svelte-kit sync (generate types)
|
npm run lint
|
||||||
npm run dev # vite dev on :5173
|
npm run test
|
||||||
```
|
````
|
||||||
|
|
||||||
Requires `.env` with at least `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`.
|
Use narrower commands when appropriate.
|
||||||
|
|
||||||
## Commands
|
## 4. Complete
|
||||||
|
|
||||||
| npm run ... | What it does |
|
If validation fails:
|
||||||
| -------------------------- | ------------------------------------------------ |
|
|
||||||
| `check` | `svelte-kit sync && svelte-check` (typecheck) |
|
|
||||||
| `lint` | `prettier --check . && eslint .` (order matters) |
|
|
||||||
| `format` | `prettier --write .` |
|
|
||||||
| `test` | `vitest --run` (single run) |
|
|
||||||
| `test:unit` | `vitest` (watch mode) |
|
|
||||||
| `migrate -- --name <name>` | Create a Prisma migration |
|
|
||||||
| `deploy` | Apply pending migrations |
|
|
||||||
| `build` | Production build (adapter-node) |
|
|
||||||
|
|
||||||
## Testing quirks
|
* Diagnose the failure.
|
||||||
|
* Fix the issue.
|
||||||
|
* Run validation again.
|
||||||
|
|
||||||
- **Two Vitest projects**: `client` (Playwright/Chromium headless, `src/**/*.svelte.{test,spec}.{js,ts}`) and `server` (node env, everything else — `src/**/*.{test,spec}.{js,ts}` excluding `.svelte.*`). Tests co-located beside source files.
|
Never finish with known failures.
|
||||||
- **`expect: { requireAssertions: true }`** — every test must assert something. No assertions = test fails.
|
|
||||||
- **All route tests mock `$lib/db`** (Prisma client) and `$lib/auth-client`/`$lib/auth` via `vi.mock()`. Stub factories in `src/lib/test-utils.ts`.
|
|
||||||
- **No `.env.test`** — mocking `$lib/db` makes real DB unnecessary.
|
|
||||||
- **Mock `$app/forms`** for any component using `use:enhance` — provide `enhance: () => () => {}` (action → destroy fn).
|
|
||||||
- **Mock TipTap** for client tests rendering ProclamationForm — the Svelte 5 component mock pattern is a function `($anchor: Element) => { …; $anchor.replaceWith(div); }`.
|
|
||||||
|
|
||||||
## Architecture notes
|
|
||||||
|
|
||||||
- **Route groups**: `(protected)/` requires auth + profile; `(user)/` is user-facing; `admin/` requires roleId <= 3 (STAFF+).
|
|
||||||
- **Admin forms**: Each route has its own form component (e.g. `ProclamationForm.svelte`, `CategoryForm.svelte`). The generic `AdminForm.svelte` / `AdminPage.svelte` were **deleted**. Registry config (`registry.ts`) still provides metadata, list fields, search fields, relation options, etc., but forms are hand-written per route.
|
|
||||||
- **Prisma client** generated to `src/lib/generated/prisma/` (not `node_modules/.prisma/client`). Lazy singleton via Proxy in `src/lib/db.ts`.
|
|
||||||
- **Better-Auth** server instance in `src/lib/auth.ts`, browser client in `src/lib/auth-client.ts`. Session fetched in `src/hooks.server.ts`.
|
|
||||||
- **Roles**: integer hierarchy (SUPER=1, ADMIN=2, STAFF=3, USER=4). `canAccessAdmin` checks roleId <= 3.
|
|
||||||
- **Payments are stubbed** (`src/lib/payments.ts` — setTimeout + console.log).
|
|
||||||
- **API keys**: SHA-256 hashed before storage (`ApiKey` model); only prefix (11 chars) stored for identification. `/api/v1/*` requires `Authorization: Bearer <key>`, except `/api/v1/automations/` (GHL webhook, no API key needed). Rotation: normal (48h grace via `expiresAt`) or emergency (immediate via `revokedAt`). Super-only admin page at `/admin/api-keys`.
|
|
||||||
|
|
||||||
## Formatting conventions
|
|
||||||
|
|
||||||
- Tabs, single quotes, no trailing commas, 100 print width (enforced by Prettier).
|
|
||||||
- Svelte 5 runes only (`$state`, `$derived`, `$effect`, `$props`). No legacy stores (`writable`, `derived`, etc.).
|
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Svelte 5 Runes Gotchas & Patterns
|
# Project Overview
|
||||||
|
|
||||||
### `$state` initialization vs `$effect` sync
|
Application:
|
||||||
|
|
||||||
State variables declared with `$state('')` are set once on component mount. To keep them in sync with a changing `$props()` value, use a **separate `$effect`**:
|
portal.Rhemawavetech.com
|
||||||
|
|
||||||
```svelte
|
Stack:
|
||||||
<script lang="ts">
|
|
||||||
let { record = {} as Record<string, unknown> } = $props();
|
|
||||||
|
|
||||||
let name = $state('');
|
* SvelteKit 2
|
||||||
let freq = $state('');
|
* Svelte 5
|
||||||
|
* TypeScript
|
||||||
|
* Tailwind CSS v4
|
||||||
|
* Prisma 7
|
||||||
|
* PostgreSQL
|
||||||
|
* Better Auth
|
||||||
|
* Vitest
|
||||||
|
* Playwright
|
||||||
|
|
||||||
// Sync local state when prop changes
|
Package manager:
|
||||||
$effect(() => {
|
|
||||||
name = String(record.name ?? '');
|
npm
|
||||||
freq = String(record.solfeggioFrequencyId ?? '');
|
|
||||||
});
|
---
|
||||||
</script>
|
|
||||||
|
# Quick Start
|
||||||
|
|
||||||
|
Install:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install
|
||||||
```
|
```
|
||||||
|
|
||||||
This is the **only correct pattern** for form fields that edit an existing record. Do NOT initialize `$state` directly from the prop — that only captures the initial value and won't re-sync when the parent changes the record (e.g. switching from one row to another in a modal).
|
Generate types:
|
||||||
|
|
||||||
### `$effect` ordering matters
|
```sh
|
||||||
|
npm run prepare
|
||||||
Effects run in declaration order. If you have multiple effects, the first one's writes are applied before the second one runs. This matters for the `dirty` flag:
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
let dirty = $state(false);
|
|
||||||
|
|
||||||
// Runs first — resets dirty on mount / record change
|
|
||||||
$effect(() => {
|
|
||||||
dirty = false;
|
|
||||||
});
|
|
||||||
|
|
||||||
// Runs second — syncs field values
|
|
||||||
$effect(() => {
|
|
||||||
name = String(record.name ?? '');
|
|
||||||
// …
|
|
||||||
});
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Both effects run synchronously during mount, so by the time the browser paints, all state is consistent.
|
Run development:
|
||||||
|
|
||||||
### `bind:value` on `<select>` is broken with `$effect`-driven state
|
```sh
|
||||||
|
npm run dev
|
||||||
**DO NOT USE** `bind:value={stateVar}` on `<select>` elements when the state is set from a `$effect`. Svelte 5's select binding does not respond to `$state` changes triggered inside `$effect`. Instead:
|
|
||||||
|
|
||||||
**Use `selected` on `<option>` + `onchange` on `<select>`:**
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<select name="solfeggioFrequencyId" onchange={(e) => freq = (e.target as HTMLSelectElement).value}>
|
|
||||||
<option value="">-- Select --</option>
|
|
||||||
{#each options as opt (opt.value)}
|
|
||||||
<option value={opt.value} selected={String(opt.value) === freq}>{opt.label}</option>
|
|
||||||
{/each}
|
|
||||||
</select>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
This pattern applies to ALL `<select>` elements in form components. The `value={stateVar}` prop directive also does not work reliably — always use `selected` on options.
|
Required environment:
|
||||||
|
|
||||||
### `bind:value` on `<input>` works fine
|
```text
|
||||||
|
DATABASE_URL
|
||||||
Unlike `<select>`, `bind:value` on `<input type="text">` works correctly with `$effect`-driven state. Feel free to use it for text inputs.
|
BETTER_AUTH_SECRET
|
||||||
|
BETTER_AUTH_URL
|
||||||
### `$props()` destructuring
|
|
||||||
|
|
||||||
Always destructure `$props()` with defaults:
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
let {
|
|
||||||
record = {} as Record<string, unknown>,
|
|
||||||
fieldErrors = {} as Record<string, string>,
|
|
||||||
isSuper = false,
|
|
||||||
}: { record?: Record<string, unknown>; … } = $props();
|
|
||||||
```
|
|
||||||
|
|
||||||
The type annotation on the destructuring object is REQUIRED for Svelte 5 to infer prop types. Default values are used when the parent doesn't pass that prop.
|
|
||||||
|
|
||||||
### Component mocking for client tests
|
|
||||||
|
|
||||||
When mocking a Svelte 5 component in `vi.mock`, use a function that takes `($anchor, props)`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
vi.mock('$lib/components/tiptap/TipTap.svelte', () => ({
|
|
||||||
default: ($anchor: Element, _props: Record<string, unknown>) => {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.setAttribute('data-testid', 'tiptap-mock');
|
|
||||||
$anchor.replaceWith(div);
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Testing Patterns & Gotchas
|
# Architecture Map
|
||||||
|
|
||||||
### What tests to write
|
## Routes
|
||||||
|
|
||||||
For every admin route, write BOTH:
|
Protected routes:
|
||||||
|
|
||||||
1. **Server test** (`page.server.spec.ts`): Test the `load` function and all `actions` (upsert, delete). Cover role-based access control (Super vs non-Super content stripping, rejection of unauthorized mutations). Mock `$lib/db` and `$lib/svelte-admin/crud`.
|
```text
|
||||||
|
(protected)/
|
||||||
2. **Client test** (`Component.svelte.spec.ts`): Test the form component rendering in Playwright/Chromium. Cover:
|
|
||||||
- All fields render
|
|
||||||
- Pre-fill on edit (name input, select values)
|
|
||||||
- Conditional field visibility (e.g. content hidden for non-Super)
|
|
||||||
- Submit button disabled/enabled by dirty state
|
|
||||||
- No visual regression testing — only functional assertions.
|
|
||||||
|
|
||||||
### Available Playwright/Vitest Browser Locator API
|
|
||||||
|
|
||||||
These methods are available on `page` from `vitest/browser`:
|
|
||||||
|
|
||||||
| Method | Works? | Example |
|
|
||||||
| ------------------- | ------ | --------------------------------------------- |
|
|
||||||
| `getByRole` | ✅ | `page.getByRole('textbox', { name: 'Name' })` |
|
|
||||||
| `getByText` | ✅ | `page.getByText('Content')` |
|
|
||||||
| `getByPlaceholder` | ✅ | `page.getByPlaceholder('Email Address')` |
|
|
||||||
| `getByAltText` | ✅ | `page.getByAltText('Sign In with Google')` |
|
|
||||||
| `getByTestId` | ✅ | `page.getByTestId('tiptap-mock')` |
|
|
||||||
| `getByLabel` | ❌ | Not available in this version |
|
|
||||||
| `getByDisplayValue` | ❌ | Not available in this version |
|
|
||||||
|
|
||||||
For label-identified elements, use `getByRole` with `name` option:
|
|
||||||
|
|
||||||
- Text inputs → `getByRole('textbox', { name: /^Name$/ })`
|
|
||||||
- Selects → `getByRole('combobox', { name: /^Frequency$/ })`
|
|
||||||
- Buttons → `getByRole('button', { name: 'Save' })`
|
|
||||||
|
|
||||||
**Always use regex `{ name: /^Exact$/ }`** for role queries to avoid strict-mode violations from substring matches (e.g. `'Category'` matching both `'Category'` and `'Sub-Category'`).
|
|
||||||
|
|
||||||
### Querying select values in client tests
|
|
||||||
|
|
||||||
Since `bind:value` on selects doesn't work with `$effect`-driven state, and we use `selected` on `<option>` instead, you can check the select's value directly:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const el = (await page
|
|
||||||
.getByRole('combobox', { name: /^Frequency$/ })
|
|
||||||
.element()) as HTMLSelectElement;
|
|
||||||
expect(el.value).toBe('2');
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The `.element()` call returns the underlying DOM element. This works synchronously because `selected` is set during render.
|
Require authentication and profile.
|
||||||
|
|
||||||
### Server test patterns
|
User routes:
|
||||||
|
|
||||||
**Always call `vi.clearAllMocks()` in `beforeEach`.**
|
```text
|
||||||
|
(user)/
|
||||||
Mock `$lib/db` with the exact Prisma model methods the code under test uses:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
vi.mock('$lib/db', () => ({
|
|
||||||
prisma: {
|
|
||||||
proclamation: {
|
|
||||||
findMany: vi.fn(),
|
|
||||||
findUnique: vi.fn(),
|
|
||||||
create: vi.fn(),
|
|
||||||
update: vi.fn(),
|
|
||||||
delete: vi.fn()
|
|
||||||
},
|
|
||||||
proclamationCategory: { findMany: vi.fn() }
|
|
||||||
}
|
|
||||||
}));
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Mock `$lib/svelte-admin/crud` when the route uses custom upsert/delete logic:
|
User-facing functionality.
|
||||||
|
|
||||||
```ts
|
Admin routes:
|
||||||
vi.mock('$lib/svelte-admin/crud', () => ({
|
|
||||||
loadRecords: vi.fn(),
|
```text
|
||||||
deleteRecord: vi.fn(),
|
admin/
|
||||||
superFormErrorsToFieldErrors: vi.fn(() => ({ error: '', fieldErrors: {} }))
|
|
||||||
}));
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Use `createLocals(mockUser({ roleId: 4 }) as any)` to simulate different user roles.
|
Require:
|
||||||
Use `createFormRequest({ name: 'test' })` for form submissions.
|
|
||||||
Use `.catch((e) => e)` + `isHttpError()` for error assertions.
|
|
||||||
|
|
||||||
### `createLocals` nuances
|
```text
|
||||||
|
roleId <= 3
|
||||||
- `createLocals()` (no arg) → unauthenticated (session is `undefined`).
|
|
||||||
- `createLocals(mockUser() as any)` → authenticated with the mock user object.
|
|
||||||
- Cast to `any` when passing to load/action functions: `{ locals: createLocals(mockUser()) } as any`.
|
|
||||||
|
|
||||||
### `vi.mock` hoisting
|
|
||||||
|
|
||||||
All `vi.mock()` calls must be at the top level of the test file. Values needed inside `vi.mock` factories must use `vi.hoisted()`:
|
|
||||||
|
|
||||||
```ts
|
|
||||||
const { mockFn } = vi.hoisted(() => ({ mockFn: vi.fn() }));
|
|
||||||
vi.mock('$lib/some-module', () => ({ default: mockFn }));
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Imports of the mocked module must come AFTER `vi.mock()` calls.
|
Roles:
|
||||||
|
|
||||||
|
```text
|
||||||
|
SUPER = 1
|
||||||
|
ADMIN = 2
|
||||||
|
STAFF = 3
|
||||||
|
USER = 4
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Admin Form Architecture
|
# Database
|
||||||
|
|
||||||
### Per-route form components
|
Prisma generated client:
|
||||||
|
|
||||||
Each admin route under `admin/[model]/` has its own form component (e.g. `ProclamationForm.svelte`). These components:
|
```text
|
||||||
|
src/lib/generated/prisma/
|
||||||
- Receive `record`, `fieldErrors`, `formError`, `relationOptionsData`, and model-specific extras (e.g. `categories`, `isSuper`) as props.
|
|
||||||
- Use `$state` + `$effect` for field values (see "Svelte 5 Runes Gotchas" above).
|
|
||||||
- Use `selected` on `<option>` for selects.
|
|
||||||
- Use `oninput` on the `<form>` to track dirty state: `<form oninput={markDirty}>`.
|
|
||||||
- Submit via `action="?/upsert"` with SvelteKit's `use:enhance`.
|
|
||||||
|
|
||||||
### Server actions
|
|
||||||
|
|
||||||
Routes with custom requirements (like proclamation's Super-only restriction) override the generic `upsertRecord`/`deleteRecord` with their own actions. The generic functions in `crud.ts` are still available and used by simpler routes.
|
|
||||||
|
|
||||||
### Super-only proclamation restriction
|
|
||||||
|
|
||||||
- **Load**: content field is stripped from records for non-Super users before returning.
|
|
||||||
- **Upsert**: returns `{ success: false, error: 'Only Super admins can edit proclamations' }` for non-Super.
|
|
||||||
- **Delete**: same early return for non-Super; delegates to `deleteRecord` for Super.
|
|
||||||
- **Content field in form**: wrapped in `{#if isSuper}` block — hidden from non-Super users.
|
|
||||||
- **Row click / New button**: disabled for non-Super in `+page.svelte`.
|
|
||||||
|
|
||||||
### Dirty-state submit pattern
|
|
||||||
|
|
||||||
When editing an existing record (`record.id` is truthy), the submit button is disabled until the user modifies at least one field. The form tracks dirtiness with `let dirty = $state(false)` and a `markDirty()` handler:
|
|
||||||
|
|
||||||
```svelte
|
|
||||||
<form oninput={markDirty}>
|
|
||||||
<button disabled={!!record?.id && !dirty}>Save</button>
|
|
||||||
</form>
|
|
||||||
```
|
```
|
||||||
|
|
||||||
The `$effect` resets `dirty = false` on mount (when the record prop changes).
|
Do not import Prisma from:
|
||||||
|
|
||||||
|
```text
|
||||||
|
node_modules/.prisma/client
|
||||||
|
```
|
||||||
|
|
||||||
|
Database singleton:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/lib/db.ts
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## Generated / build artifacts
|
# Authentication
|
||||||
|
|
||||||
- `.svelte-kit/` — SvelteKit generated types (gitignored)
|
Server:
|
||||||
- `src/lib/generated/prisma/` — Prisma client output (not gitignored; regenerate with `npx prisma generate`)
|
|
||||||
|
```text
|
||||||
|
src/lib/auth.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Browser:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/lib/auth-client.ts
|
||||||
|
```
|
||||||
|
|
||||||
|
Session handling:
|
||||||
|
|
||||||
|
```text
|
||||||
|
src/hooks.server.ts
|
||||||
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
## API v1 Endpoints
|
# Admin Architecture
|
||||||
|
|
||||||
| Route | Method | Auth | Description |
|
Admin forms are route-specific.
|
||||||
| ------------------------------------ | ------ | -------- | ------------------------------------------------ |
|
|
||||||
| `/api/v1/categories` | GET | API key | List all proclamation categories |
|
|
||||||
| `/api/v1/frequencies` | GET | API key | List all solfeggio frequencies |
|
|
||||||
| `/api/v1/proclamations` | GET | API key | List proclamations (with optional `?key` + `?email` filter) |
|
|
||||||
| `/api/v1/license` | POST | API key | Validate key: `{ license_key, email_address }` |
|
|
||||||
| `/api/v1/automations/create/account` | POST | None | GHL webhook: create user + license key + emails |
|
|
||||||
|
|
||||||
## GHL Webhook — Auto Account Creation
|
Example:
|
||||||
|
|
||||||
`POST /api/v1/automations/create/account` — excluded from API key auth in `hooks.server.ts`.
|
```text
|
||||||
|
admin/[model]/
|
||||||
**Payload:**
|
ModelForm.svelte
|
||||||
|
|
||||||
```json
|
|
||||||
{ "email": "...", "name": "...", "subscription_level": "Basic" }
|
|
||||||
```
|
```
|
||||||
|
|
||||||
`subscription_level` must match a `SubscriptionLevel.name` in the database.
|
Do not recreate deleted generic components:
|
||||||
|
|
||||||
**Flow:**
|
```text
|
||||||
1. If user email doesn't exist → `auth.api.signUpEmail()` with random temp password
|
AdminForm.svelte
|
||||||
2. Query `Verification` table for the auto-generated verification token → construct verify URL
|
AdminPage.svelte
|
||||||
3. Send `automated-user-registration` Resend template (variables: `firstName`, `tempPassword`, `verifyUrl`, `loginUrl`)
|
```
|
||||||
4. Create `LicenseKey` record (`active: true`, `level: subscription_level`)
|
|
||||||
5. Send `license-key-delivery` Resend template (variables: `firstName`, `license_key`)
|
|
||||||
6. If user already exists → skip signup, just create key + send delivery email
|
|
||||||
|
|
||||||
**Better Auth email callbacks** (`sendVerificationEmail`, `sendResetPassword`) use `void` + `.catch()` — template send failures are logged, never crash the server.
|
Registry metadata exists for:
|
||||||
|
|
||||||
## Resend Email Templates
|
* columns
|
||||||
|
* searches
|
||||||
|
* relations
|
||||||
|
|
||||||
| Template ID | Variables | Subject (API-level) |
|
Forms are manually implemented per route.
|
||||||
| ------------------------------ | --------------------------------------------------------------- | ------------------------------ |
|
|
||||||
| `automated-user-registration` | `firstName`, `tempPassword`, `verifyUrl`, `loginUrl` | "Your account is ready" |
|
|
||||||
| `license-key-delivery` | `firstName`, `license_key` | "Your Rhemawave License Key" |
|
|
||||||
| `email-verification-email` | `target_email_address`, `verification_link` | "Verify your email address" |
|
|
||||||
|
|
||||||
Variables must be declared in the Resend template editor's Variables section. Undeclared variables render as literal `{{{varName}}}` text.
|
---
|
||||||
|
|
||||||
Controlled by `src/lib/server/resend.ts` (`sendMail`) and `src/lib/server/email-verification.ts`.
|
# Svelte Rules
|
||||||
|
|
||||||
## AdminTable Drag-and-Drop
|
This project uses Svelte 5 runes.
|
||||||
|
|
||||||
AdminTable supports full-`<tr>` drag-and-drop reorder via props:
|
Use:
|
||||||
|
|
||||||
- `draggable: boolean` — enables drag handles on rows
|
```text
|
||||||
- `onReorder: (ids: string[]) => Promise<void>` — called after drop with new row ID order
|
$state()
|
||||||
- `copyableFields: string[]` — field keys to show a copy-to-clipboard icon
|
$derived()
|
||||||
|
$effect()
|
||||||
|
$props()
|
||||||
|
```
|
||||||
|
|
||||||
**Pattern:** `$derived` + `dragOverride` — no `$effect` sync. `handleDragOver` always calls `preventDefault()` for reliable drop targets. `handleDrop` awaits `onReorder` then calls `invalidateAll()`.
|
Do not use:
|
||||||
|
|
||||||
See `src/lib/components/admin/AdminTable.svelte` and `src/routes/(protected)/admin/proclamation-categories/+page.svelte` for example usage.
|
```text
|
||||||
|
writable()
|
||||||
|
derived()
|
||||||
|
legacy stores
|
||||||
|
```
|
||||||
|
|
||||||
## License Key Admin
|
For Svelte-specific patterns, read:
|
||||||
|
|
||||||
- **Create**: `/admin/license-keys` form → `?/createKey` action. Requires email + subscription level. Generates key, persists to DB, emails the key via Resend template.
|
```text
|
||||||
- **Replace**: Table row action → `?/replace` action. Deletes old key, creates new one with same user + level. No email sent.
|
docs/ai/svelte-patterns.md
|
||||||
- **Subscription level** stored as `SubscriptionLevel.name` string (not a FK).
|
```
|
||||||
|
|
||||||
|
Only load that file when modifying Svelte components or Svelte logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Testing Rules
|
||||||
|
|
||||||
|
Testing uses:
|
||||||
|
|
||||||
|
* Vitest
|
||||||
|
* Playwright browser testing
|
||||||
|
|
||||||
|
For testing patterns, read:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/ai/testing-patterns.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Only load that file when modifying tests.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# API Rules
|
||||||
|
|
||||||
|
For API routes, authentication, keys, or integrations, read:
|
||||||
|
|
||||||
|
```text
|
||||||
|
docs/ai/api.md
|
||||||
|
```
|
||||||
|
|
||||||
|
Only load that file when working on API functionality.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Formatting
|
||||||
|
|
||||||
|
Prettier rules:
|
||||||
|
|
||||||
|
* tabs
|
||||||
|
* single quotes
|
||||||
|
* no trailing commas
|
||||||
|
* 80 character print width
|
||||||
|
|
||||||
|
Follow existing formatting.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Important Commands
|
||||||
|
|
||||||
|
Check:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run check
|
||||||
|
```
|
||||||
|
|
||||||
|
Lint:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run lint
|
||||||
|
```
|
||||||
|
|
||||||
|
Format:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run format
|
||||||
|
```
|
||||||
|
|
||||||
|
Tests:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run test
|
||||||
|
```
|
||||||
|
|
||||||
|
Build:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Completion Standard
|
||||||
|
|
||||||
|
Before claiming a task is complete:
|
||||||
|
|
||||||
|
Confirm:
|
||||||
|
|
||||||
|
1. Code changes are implemented.
|
||||||
|
2. Relevant tests/checks were run.
|
||||||
|
3. Failures were fixed.
|
||||||
|
4. The final response explains:
|
||||||
|
|
||||||
|
* what changed
|
||||||
|
* files modified
|
||||||
|
* verification performed
|
||||||
|
|
||||||
|
Do not stop at:
|
||||||
|
|
||||||
|
* "I found the issue"
|
||||||
|
* "Here is a plan"
|
||||||
|
* "The next step would be"
|
||||||
|
|
||||||
|
Finish the work.
|
||||||
|
|
||||||
## Key Generation
|
|
||||||
|
|
||||||
- **License keys**: 16 uppercase hex chars in `XXXX-XXXX-XXXX-XXXX` format. `crypto.randomUUID()` → strip dashes → uppercase → 4x4 chunks. Level stored as `SubscriptionLevel.name` string (not FK).
|
|
||||||
- **API keys**: `sk_` + 64 random hex chars. SHA-256 hashed for storage; first 11 chars stored as `keyPrefix` for identification.
|
|
||||||
|
|||||||
+363
@@ -0,0 +1,363 @@
|
|||||||
|
# portal.Rhemawavetech.com
|
||||||
|
|
||||||
|
SvelteKit 2 + Svelte 5 (runes) + Tailwind CSS v4 + Prisma 7 + PostgreSQL + Better-Auth.
|
||||||
|
|
||||||
|
## Quick start
|
||||||
|
|
||||||
|
```sh
|
||||||
|
npm install # engine-strict=true in .npmrc
|
||||||
|
npm run prepare # svelte-kit sync (generate types)
|
||||||
|
npm run dev # vite dev on :5173
|
||||||
|
```
|
||||||
|
|
||||||
|
Requires `.env` with at least `DATABASE_URL`, `BETTER_AUTH_SECRET`, `BETTER_AUTH_URL`.
|
||||||
|
|
||||||
|
## Commands
|
||||||
|
|
||||||
|
| npm run ... | What it does |
|
||||||
|
| -------------------------- | ------------------------------------------------ |
|
||||||
|
| `check` | `svelte-kit sync && svelte-check` (typecheck) |
|
||||||
|
| `lint` | `prettier --check . && eslint .` (order matters) |
|
||||||
|
| `format` | `prettier --write .` |
|
||||||
|
| `test` | `vitest --run` (single run) |
|
||||||
|
| `test:unit` | `vitest` (watch mode) |
|
||||||
|
| `migrate -- --name <name>` | Create a Prisma migration |
|
||||||
|
| `deploy` | Apply pending migrations |
|
||||||
|
| `build` | Production build (adapter-node) |
|
||||||
|
|
||||||
|
## Testing quirks
|
||||||
|
|
||||||
|
- **Two Vitest projects**: `client` (Playwright/Chromium headless, `src/**/*.svelte.{test,spec}.{js,ts}`) and `server` (node env, everything else — `src/**/*.{test,spec}.{js,ts}` excluding `.svelte.*`). Tests co-located beside source files.
|
||||||
|
- **`expect: { requireAssertions: true }`** — every test must assert something. No assertions = test fails.
|
||||||
|
- **All route tests mock `$lib/db`** (Prisma client) and `$lib/auth-client`/`$lib/auth` via `vi.mock()`. Stub factories in `src/lib/test-utils.ts`.
|
||||||
|
- **No `.env.test`** — mocking `$lib/db` makes real DB unnecessary.
|
||||||
|
- **Mock `$app/forms`** for any component using `use:enhance` — provide `enhance: () => () => {}` (action → destroy fn).
|
||||||
|
- **Mock TipTap** for client tests rendering ProclamationForm — the Svelte 5 component mock pattern is a function `($anchor: Element) => { …; $anchor.replaceWith(div); }`.
|
||||||
|
|
||||||
|
## Architecture notes
|
||||||
|
|
||||||
|
- **Route groups**: `(protected)/` requires auth + profile; `(user)/` is user-facing; `admin/` requires roleId <= 3 (STAFF+).
|
||||||
|
- **Admin forms**: Each route has its own form component (e.g. `ProclamationForm.svelte`, `CategoryForm.svelte`). The generic `AdminForm.svelte` / `AdminPage.svelte` were **deleted**. Registry config (`registry.ts`) still provides metadata, list fields, search fields, relation options, etc., but forms are hand-written per route.
|
||||||
|
- **Prisma client** generated to `src/lib/generated/prisma/` (not `node_modules/.prisma/client`). Lazy singleton via Proxy in `src/lib/db.ts`.
|
||||||
|
- **Better-Auth** server instance in `src/lib/auth.ts`, browser client in `src/lib/auth-client.ts`. Session fetched in `src/hooks.server.ts`.
|
||||||
|
- **Roles**: integer hierarchy (SUPER=1, ADMIN=2, STAFF=3, USER=4). `canAccessAdmin` checks roleId <= 3.
|
||||||
|
- **Payments are stubbed** (`src/lib/payments.ts` — setTimeout + console.log).
|
||||||
|
- **API keys**: SHA-256 hashed before storage (`ApiKey` model); only prefix (11 chars) stored for identification. `/api/v1/*` requires `Authorization: Bearer <key>`, except `/api/v1/automations/` (GHL webhook, no API key needed). Rotation: normal (48h grace via `expiresAt`) or emergency (immediate via `revokedAt`). Super-only admin page at `/admin/api-keys`.
|
||||||
|
|
||||||
|
## Formatting conventions
|
||||||
|
|
||||||
|
- Tabs, single quotes, no trailing commas, 100 print width (enforced by Prettier).
|
||||||
|
- Svelte 5 runes only (`$state`, `$derived`, `$effect`, `$props`). No legacy stores (`writable`, `derived`, etc.).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Svelte 5 Runes Gotchas & Patterns
|
||||||
|
|
||||||
|
### `$state` initialization vs `$effect` sync
|
||||||
|
|
||||||
|
State variables declared with `$state('')` are set once on component mount. To keep them in sync with a changing `$props()` value, use a **separate `$effect`**:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<script lang="ts">
|
||||||
|
let { record = {} as Record<string, unknown> } = $props();
|
||||||
|
|
||||||
|
let name = $state('');
|
||||||
|
let freq = $state('');
|
||||||
|
|
||||||
|
// Sync local state when prop changes
|
||||||
|
$effect(() => {
|
||||||
|
name = String(record.name ?? '');
|
||||||
|
freq = String(record.solfeggioFrequencyId ?? '');
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
```
|
||||||
|
|
||||||
|
This is the **only correct pattern** for form fields that edit an existing record. Do NOT initialize `$state` directly from the prop — that only captures the initial value and won't re-sync when the parent changes the record (e.g. switching from one row to another in a modal).
|
||||||
|
|
||||||
|
### `$effect` ordering matters
|
||||||
|
|
||||||
|
Effects run in declaration order. If you have multiple effects, the first one's writes are applied before the second one runs. This matters for the `dirty` flag:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
let dirty = $state(false);
|
||||||
|
|
||||||
|
// Runs first — resets dirty on mount / record change
|
||||||
|
$effect(() => {
|
||||||
|
dirty = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Runs second — syncs field values
|
||||||
|
$effect(() => {
|
||||||
|
name = String(record.name ?? '');
|
||||||
|
// …
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
Both effects run synchronously during mount, so by the time the browser paints, all state is consistent.
|
||||||
|
|
||||||
|
### `bind:value` on `<select>` is broken with `$effect`-driven state
|
||||||
|
|
||||||
|
**DO NOT USE** `bind:value={stateVar}` on `<select>` elements when the state is set from a `$effect`. Svelte 5's select binding does not respond to `$state` changes triggered inside `$effect`. Instead:
|
||||||
|
|
||||||
|
**Use `selected` on `<option>` + `onchange` on `<select>`:**
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<select name="solfeggioFrequencyId" onchange={(e) => freq = (e.target as HTMLSelectElement).value}>
|
||||||
|
<option value="">-- Select --</option>
|
||||||
|
{#each options as opt (opt.value)}
|
||||||
|
<option value={opt.value} selected={String(opt.value) === freq}>{opt.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
```
|
||||||
|
|
||||||
|
This pattern applies to ALL `<select>` elements in form components. The `value={stateVar}` prop directive also does not work reliably — always use `selected` on options.
|
||||||
|
|
||||||
|
### `bind:value` on `<input>` works fine
|
||||||
|
|
||||||
|
Unlike `<select>`, `bind:value` on `<input type="text">` works correctly with `$effect`-driven state. Feel free to use it for text inputs.
|
||||||
|
|
||||||
|
### `$props()` destructuring
|
||||||
|
|
||||||
|
Always destructure `$props()` with defaults:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
let {
|
||||||
|
record = {} as Record<string, unknown>,
|
||||||
|
fieldErrors = {} as Record<string, string>,
|
||||||
|
isSuper = false,
|
||||||
|
}: { record?: Record<string, unknown>; … } = $props();
|
||||||
|
```
|
||||||
|
|
||||||
|
The type annotation on the destructuring object is REQUIRED for Svelte 5 to infer prop types. Default values are used when the parent doesn't pass that prop.
|
||||||
|
|
||||||
|
### Component mocking for client tests
|
||||||
|
|
||||||
|
When mocking a Svelte 5 component in `vi.mock`, use a function that takes `($anchor, props)`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
vi.mock('$lib/components/tiptap/TipTap.svelte', () => ({
|
||||||
|
default: ($anchor: Element, _props: Record<string, unknown>) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.setAttribute('data-testid', 'tiptap-mock');
|
||||||
|
$anchor.replaceWith(div);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Testing Patterns & Gotchas
|
||||||
|
|
||||||
|
### What tests to write
|
||||||
|
|
||||||
|
For every admin route, write BOTH:
|
||||||
|
|
||||||
|
1. **Server test** (`page.server.spec.ts`): Test the `load` function and all `actions` (upsert, delete). Cover role-based access control (Super vs non-Super content stripping, rejection of unauthorized mutations). Mock `$lib/db` and `$lib/svelte-admin/crud`.
|
||||||
|
|
||||||
|
2. **Client test** (`Component.svelte.spec.ts`): Test the form component rendering in Playwright/Chromium. Cover:
|
||||||
|
- All fields render
|
||||||
|
- Pre-fill on edit (name input, select values)
|
||||||
|
- Conditional field visibility (e.g. content hidden for non-Super)
|
||||||
|
- Submit button disabled/enabled by dirty state
|
||||||
|
- No visual regression testing — only functional assertions.
|
||||||
|
|
||||||
|
### Available Playwright/Vitest Browser Locator API
|
||||||
|
|
||||||
|
These methods are available on `page` from `vitest/browser`:
|
||||||
|
|
||||||
|
| Method | Works? | Example |
|
||||||
|
| ------------------- | ------ | --------------------------------------------- |
|
||||||
|
| `getByRole` | ✅ | `page.getByRole('textbox', { name: 'Name' })` |
|
||||||
|
| `getByText` | ✅ | `page.getByText('Content')` |
|
||||||
|
| `getByPlaceholder` | ✅ | `page.getByPlaceholder('Email Address')` |
|
||||||
|
| `getByAltText` | ✅ | `page.getByAltText('Sign In with Google')` |
|
||||||
|
| `getByTestId` | ✅ | `page.getByTestId('tiptap-mock')` |
|
||||||
|
| `getByLabel` | ❌ | Not available in this version |
|
||||||
|
| `getByDisplayValue` | ❌ | Not available in this version |
|
||||||
|
|
||||||
|
For label-identified elements, use `getByRole` with `name` option:
|
||||||
|
|
||||||
|
- Text inputs → `getByRole('textbox', { name: /^Name$/ })`
|
||||||
|
- Selects → `getByRole('combobox', { name: /^Frequency$/ })`
|
||||||
|
- Buttons → `getByRole('button', { name: 'Save' })`
|
||||||
|
|
||||||
|
**Always use regex `{ name: /^Exact$/ }`** for role queries to avoid strict-mode violations from substring matches (e.g. `'Category'` matching both `'Category'` and `'Sub-Category'`).
|
||||||
|
|
||||||
|
### Querying select values in client tests
|
||||||
|
|
||||||
|
Since `bind:value` on selects doesn't work with `$effect`-driven state, and we use `selected` on `<option>` instead, you can check the select's value directly:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const el = (await page
|
||||||
|
.getByRole('combobox', { name: /^Frequency$/ })
|
||||||
|
.element()) as HTMLSelectElement;
|
||||||
|
expect(el.value).toBe('2');
|
||||||
|
```
|
||||||
|
|
||||||
|
The `.element()` call returns the underlying DOM element. This works synchronously because `selected` is set during render.
|
||||||
|
|
||||||
|
### Server test patterns
|
||||||
|
|
||||||
|
**Always call `vi.clearAllMocks()` in `beforeEach`.**
|
||||||
|
|
||||||
|
Mock `$lib/db` with the exact Prisma model methods the code under test uses:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
vi.mock('$lib/db', () => ({
|
||||||
|
prisma: {
|
||||||
|
proclamation: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn()
|
||||||
|
},
|
||||||
|
proclamationCategory: { findMany: vi.fn() }
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
Mock `$lib/svelte-admin/crud` when the route uses custom upsert/delete logic:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
vi.mock('$lib/svelte-admin/crud', () => ({
|
||||||
|
loadRecords: vi.fn(),
|
||||||
|
deleteRecord: vi.fn(),
|
||||||
|
superFormErrorsToFieldErrors: vi.fn(() => ({ error: '', fieldErrors: {} }))
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
Use `createLocals(mockUser({ roleId: 4 }) as any)` to simulate different user roles.
|
||||||
|
Use `createFormRequest({ name: 'test' })` for form submissions.
|
||||||
|
Use `.catch((e) => e)` + `isHttpError()` for error assertions.
|
||||||
|
|
||||||
|
### `createLocals` nuances
|
||||||
|
|
||||||
|
- `createLocals()` (no arg) → unauthenticated (session is `undefined`).
|
||||||
|
- `createLocals(mockUser() as any)` → authenticated with the mock user object.
|
||||||
|
- Cast to `any` when passing to load/action functions: `{ locals: createLocals(mockUser()) } as any`.
|
||||||
|
|
||||||
|
### `vi.mock` hoisting
|
||||||
|
|
||||||
|
All `vi.mock()` calls must be at the top level of the test file. Values needed inside `vi.mock` factories must use `vi.hoisted()`:
|
||||||
|
|
||||||
|
```ts
|
||||||
|
const { mockFn } = vi.hoisted(() => ({ mockFn: vi.fn() }));
|
||||||
|
vi.mock('$lib/some-module', () => ({ default: mockFn }));
|
||||||
|
```
|
||||||
|
|
||||||
|
Imports of the mocked module must come AFTER `vi.mock()` calls.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Admin Form Architecture
|
||||||
|
|
||||||
|
### Per-route form components
|
||||||
|
|
||||||
|
Each admin route under `admin/[model]/` has its own form component (e.g. `ProclamationForm.svelte`). These components:
|
||||||
|
|
||||||
|
- Receive `record`, `fieldErrors`, `formError`, `relationOptionsData`, and model-specific extras (e.g. `categories`, `isSuper`) as props.
|
||||||
|
- Use `$state` + `$effect` for field values (see "Svelte 5 Runes Gotchas" above).
|
||||||
|
- Use `selected` on `<option>` for selects.
|
||||||
|
- Use `oninput` on the `<form>` to track dirty state: `<form oninput={markDirty}>`.
|
||||||
|
- Submit via `action="?/upsert"` with SvelteKit's `use:enhance`.
|
||||||
|
|
||||||
|
### Server actions
|
||||||
|
|
||||||
|
Routes with custom requirements (like proclamation's Super-only restriction) override the generic `upsertRecord`/`deleteRecord` with their own actions. The generic functions in `crud.ts` are still available and used by simpler routes.
|
||||||
|
|
||||||
|
### Super-only proclamation restriction
|
||||||
|
|
||||||
|
- **Load**: content field is stripped from records for non-Super users before returning.
|
||||||
|
- **Upsert**: returns `{ success: false, error: 'Only Super admins can edit proclamations' }` for non-Super.
|
||||||
|
- **Delete**: same early return for non-Super; delegates to `deleteRecord` for Super.
|
||||||
|
- **Content field in form**: wrapped in `{#if isSuper}` block — hidden from non-Super users.
|
||||||
|
- **Row click / New button**: disabled for non-Super in `+page.svelte`.
|
||||||
|
|
||||||
|
### Dirty-state submit pattern
|
||||||
|
|
||||||
|
When editing an existing record (`record.id` is truthy), the submit button is disabled until the user modifies at least one field. The form tracks dirtiness with `let dirty = $state(false)` and a `markDirty()` handler:
|
||||||
|
|
||||||
|
```svelte
|
||||||
|
<form oninput={markDirty}>
|
||||||
|
<button disabled={!!record?.id && !dirty}>Save</button>
|
||||||
|
</form>
|
||||||
|
```
|
||||||
|
|
||||||
|
The `$effect` resets `dirty = false` on mount (when the record prop changes).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Generated / build artifacts
|
||||||
|
|
||||||
|
- `.svelte-kit/` — SvelteKit generated types (gitignored)
|
||||||
|
- `src/lib/generated/prisma/` — Prisma client output (not gitignored; regenerate with `npx prisma generate`)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API v1 Endpoints
|
||||||
|
|
||||||
|
| Route | Method | Auth | Description |
|
||||||
|
| ------------------------------------ | ------ | -------- | ------------------------------------------------ |
|
||||||
|
| `/api/v1/categories` | GET | API key | List all proclamation categories |
|
||||||
|
| `/api/v1/frequencies` | GET | API key | List all solfeggio frequencies |
|
||||||
|
| `/api/v1/proclamations` | GET | API key | List proclamations (with optional `?key` + `?email` filter) |
|
||||||
|
| `/api/v1/license` | POST | API key | Validate key: `{ license_key, email_address }` |
|
||||||
|
| `/api/v1/automations/create/account` | POST | None | GHL webhook: create user + license key + emails |
|
||||||
|
|
||||||
|
## GHL Webhook — Auto Account Creation
|
||||||
|
|
||||||
|
`POST /api/v1/automations/create/account` — excluded from API key auth in `hooks.server.ts`.
|
||||||
|
|
||||||
|
**Payload:**
|
||||||
|
|
||||||
|
```json
|
||||||
|
{ "email": "...", "name": "...", "subscription_level": "Basic" }
|
||||||
|
```
|
||||||
|
|
||||||
|
`subscription_level` must match a `SubscriptionLevel.name` in the database.
|
||||||
|
|
||||||
|
**Flow:**
|
||||||
|
1. If user email doesn't exist → `auth.api.signUpEmail()` with random temp password
|
||||||
|
2. Query `Verification` table for the auto-generated verification token → construct verify URL
|
||||||
|
3. Send `automated-user-registration` Resend template (variables: `firstName`, `tempPassword`, `verifyUrl`, `loginUrl`)
|
||||||
|
4. Create `LicenseKey` record (`active: true`, `level: subscription_level`)
|
||||||
|
5. Send `license-key-delivery` Resend template (variables: `firstName`, `license_key`)
|
||||||
|
6. If user already exists → skip signup, just create key + send delivery email
|
||||||
|
|
||||||
|
**Better Auth email callbacks** (`sendVerificationEmail`, `sendResetPassword`) use `void` + `.catch()` — template send failures are logged, never crash the server.
|
||||||
|
|
||||||
|
## Resend Email Templates
|
||||||
|
|
||||||
|
| Template ID | Variables | Subject (API-level) |
|
||||||
|
| ------------------------------ | --------------------------------------------------------------- | ------------------------------ |
|
||||||
|
| `automated-user-registration` | `firstName`, `tempPassword`, `verifyUrl`, `loginUrl` | "Your account is ready" |
|
||||||
|
| `license-key-delivery` | `firstName`, `license_key` | "Your Rhemawave License Key" |
|
||||||
|
| `email-verification-email` | `target_email_address`, `verification_link` | "Verify your email address" |
|
||||||
|
|
||||||
|
Variables must be declared in the Resend template editor's Variables section. Undeclared variables render as literal `{{{varName}}}` text.
|
||||||
|
|
||||||
|
Controlled by `src/lib/server/resend.ts` (`sendMail`) and `src/lib/server/email-verification.ts`.
|
||||||
|
|
||||||
|
## AdminTable Drag-and-Drop
|
||||||
|
|
||||||
|
AdminTable supports full-`<tr>` drag-and-drop reorder via props:
|
||||||
|
|
||||||
|
- `draggable: boolean` — enables drag handles on rows
|
||||||
|
- `onReorder: (ids: string[]) => Promise<void>` — called after drop with new row ID order
|
||||||
|
- `copyableFields: string[]` — field keys to show a copy-to-clipboard icon
|
||||||
|
|
||||||
|
**Pattern:** `$derived` + `dragOverride` — no `$effect` sync. `handleDragOver` always calls `preventDefault()` for reliable drop targets. `handleDrop` awaits `onReorder` then calls `invalidateAll()`.
|
||||||
|
|
||||||
|
See `src/lib/components/admin/AdminTable.svelte` and `src/routes/(protected)/admin/proclamation-categories/+page.svelte` for example usage.
|
||||||
|
|
||||||
|
## License Key Admin
|
||||||
|
|
||||||
|
- **Create**: `/admin/license-keys` form → `?/createKey` action. Requires email + subscription level. Generates key, persists to DB, emails the key via Resend template.
|
||||||
|
- **Replace**: Table row action → `?/replace` action. Deletes old key, creates new one with same user + level. No email sent.
|
||||||
|
- **Subscription level** stored as `SubscriptionLevel.name` string (not a FK).
|
||||||
|
|
||||||
|
## Key Generation
|
||||||
|
|
||||||
|
- **License keys**: 16 uppercase hex chars in `XXXX-XXXX-XXXX-XXXX` format. `crypto.randomUUID()` → strip dashes → uppercase → 4x4 chunks. Level stored as `SubscriptionLevel.name` string (not FK).
|
||||||
|
- **API keys**: `sk_` + 64 random hex chars. SHA-256 hashed for storage; first 11 chars stored as `keyPrefix` for identification.
|
||||||
+235
@@ -0,0 +1,235 @@
|
|||||||
|
# API Documentation
|
||||||
|
|
||||||
|
Only read this file when modifying API routes, authentication, keys, or integrations.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# API Location
|
||||||
|
|
||||||
|
API routes:
|
||||||
|
|
||||||
|
src/routes/api/v1/
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Authentication
|
||||||
|
|
||||||
|
Most API endpoints require:
|
||||||
|
|
||||||
|
Authorization: Bearer <API key>
|
||||||
|
|
||||||
|
API keys are:
|
||||||
|
|
||||||
|
- generated randomly
|
||||||
|
- hashed with SHA-256 before storage
|
||||||
|
- displayed using stored prefix only
|
||||||
|
|
||||||
|
Never store plaintext API keys.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# API Key Lifecycle
|
||||||
|
|
||||||
|
Normal rotation:
|
||||||
|
|
||||||
|
- New key generated.
|
||||||
|
- Old key remains valid for 48 hours.
|
||||||
|
- Old key expires using expiresAt.
|
||||||
|
|
||||||
|
Emergency rotation:
|
||||||
|
|
||||||
|
- Old key immediately invalidated.
|
||||||
|
- revokedAt is set.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# API Routes
|
||||||
|
|
||||||
|
## Categories
|
||||||
|
|
||||||
|
GET:
|
||||||
|
|
||||||
|
/api/v1/categories
|
||||||
|
|
||||||
|
Requires API key.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Frequencies
|
||||||
|
|
||||||
|
GET:
|
||||||
|
|
||||||
|
/api/v1/frequencies
|
||||||
|
|
||||||
|
Requires API key.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Proclamations
|
||||||
|
|
||||||
|
GET:
|
||||||
|
|
||||||
|
/api/v1/proclamations
|
||||||
|
|
||||||
|
Supports:
|
||||||
|
|
||||||
|
- optional key filtering
|
||||||
|
- optional email filtering
|
||||||
|
|
||||||
|
Requires API key.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## License Validation
|
||||||
|
|
||||||
|
POST:
|
||||||
|
|
||||||
|
/api/v1/license
|
||||||
|
|
||||||
|
Payload:
|
||||||
|
|
||||||
|
{
|
||||||
|
"license_key": "...",
|
||||||
|
"email_address": "..."
|
||||||
|
}
|
||||||
|
|
||||||
|
Requires API key.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# GHL Account Creation Webhook
|
||||||
|
|
||||||
|
Endpoint:
|
||||||
|
|
||||||
|
POST /api/v1/automations/create/account
|
||||||
|
|
||||||
|
This endpoint does NOT require an API key.
|
||||||
|
|
||||||
|
It is used by GoHighLevel automation.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Account Creation Flow
|
||||||
|
|
||||||
|
When a request arrives:
|
||||||
|
|
||||||
|
1. Check whether the email already exists.
|
||||||
|
|
||||||
|
If user does not exist:
|
||||||
|
|
||||||
|
2. Create user through Better Auth.
|
||||||
|
3. Generate temporary password.
|
||||||
|
4. Retrieve verification token.
|
||||||
|
5. Build verification URL.
|
||||||
|
6. Send registration email.
|
||||||
|
|
||||||
|
Then:
|
||||||
|
|
||||||
|
7. Create LicenseKey.
|
||||||
|
8. Send license key delivery email.
|
||||||
|
|
||||||
|
If user already exists:
|
||||||
|
|
||||||
|
1. Skip signup.
|
||||||
|
2. Create license key.
|
||||||
|
3. Send delivery email.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Email Templates
|
||||||
|
|
||||||
|
Controlled by:
|
||||||
|
|
||||||
|
src/lib/server/resend.ts
|
||||||
|
|
||||||
|
Templates:
|
||||||
|
|
||||||
|
automated-user-registration
|
||||||
|
|
||||||
|
Variables:
|
||||||
|
|
||||||
|
- firstName
|
||||||
|
- tempPassword
|
||||||
|
- verifyUrl
|
||||||
|
- loginUrl
|
||||||
|
|
||||||
|
|
||||||
|
license-key-delivery
|
||||||
|
|
||||||
|
Variables:
|
||||||
|
|
||||||
|
- firstName
|
||||||
|
- license_key
|
||||||
|
|
||||||
|
|
||||||
|
email-verification-email
|
||||||
|
|
||||||
|
Variables:
|
||||||
|
|
||||||
|
- target_email_address
|
||||||
|
- verification_link
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Better Auth
|
||||||
|
|
||||||
|
Server:
|
||||||
|
|
||||||
|
src/lib/auth.ts
|
||||||
|
|
||||||
|
Client:
|
||||||
|
|
||||||
|
src/lib/auth-client.ts
|
||||||
|
|
||||||
|
Email callbacks:
|
||||||
|
|
||||||
|
- sendVerificationEmail
|
||||||
|
- sendResetPassword
|
||||||
|
|
||||||
|
Failures should:
|
||||||
|
|
||||||
|
- log errors
|
||||||
|
- not crash requests
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
void promise.catch()
|
||||||
|
|
||||||
|
pattern.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Security Rules
|
||||||
|
|
||||||
|
Never:
|
||||||
|
|
||||||
|
- expose plaintext API keys
|
||||||
|
- bypass authorization checks
|
||||||
|
- trust client role information
|
||||||
|
- skip validation
|
||||||
|
|
||||||
|
Always:
|
||||||
|
|
||||||
|
- validate input
|
||||||
|
- check permissions
|
||||||
|
- preserve existing security patterns
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# API Change Workflow
|
||||||
|
|
||||||
|
Before modifying an endpoint:
|
||||||
|
|
||||||
|
1. Read the route.
|
||||||
|
2. Read related auth middleware.
|
||||||
|
3. Read database models involved.
|
||||||
|
4. Check existing tests.
|
||||||
|
|
||||||
|
After modifying:
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
npm run check
|
||||||
|
npm run test
|
||||||
|
|
||||||
|
Fix failures before completing.
|
||||||
@@ -0,0 +1,184 @@
|
|||||||
|
# Svelte 5 Patterns and Rules
|
||||||
|
|
||||||
|
This document contains Svelte-specific implementation rules.
|
||||||
|
|
||||||
|
Only read this file when modifying Svelte components, routes, or Svelte-specific logic.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Svelte Version
|
||||||
|
|
||||||
|
This project uses:
|
||||||
|
|
||||||
|
- SvelteKit 2
|
||||||
|
- Svelte 5
|
||||||
|
- runes mode
|
||||||
|
|
||||||
|
Always use:
|
||||||
|
|
||||||
|
- $state()
|
||||||
|
- $derived()
|
||||||
|
- $effect()
|
||||||
|
- $props()
|
||||||
|
|
||||||
|
Do not use legacy stores:
|
||||||
|
|
||||||
|
- writable()
|
||||||
|
- readable()
|
||||||
|
- derived()
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Component Props
|
||||||
|
|
||||||
|
Always destructure $props() with defaults.
|
||||||
|
|
||||||
|
Preferred pattern:
|
||||||
|
|
||||||
|
let {
|
||||||
|
record = {} as Record<string, unknown>,
|
||||||
|
fieldErrors = {} as Record<string, string>,
|
||||||
|
isSuper = false
|
||||||
|
}: {
|
||||||
|
record?: Record<string, unknown>;
|
||||||
|
fieldErrors?: Record<string, string>;
|
||||||
|
isSuper?: boolean;
|
||||||
|
} = $props();
|
||||||
|
|
||||||
|
Do not assume props exist.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# State Synchronization
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
|
||||||
|
Do not initialize $state from props when the prop can change.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
|
||||||
|
let name = $state(record.name);
|
||||||
|
|
||||||
|
This only captures the initial value.
|
||||||
|
|
||||||
|
Correct:
|
||||||
|
|
||||||
|
let name = $state('');
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
name = String(record.name ?? '');
|
||||||
|
});
|
||||||
|
|
||||||
|
Use $effect whenever component state needs to follow changing props.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Effect Ordering
|
||||||
|
|
||||||
|
$effect blocks execute in declaration order.
|
||||||
|
|
||||||
|
When multiple effects modify related state:
|
||||||
|
|
||||||
|
1. Reset state first.
|
||||||
|
2. Populate state second.
|
||||||
|
3. Perform dependent calculations afterward.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
let dirty = $state(false);
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
dirty = false;
|
||||||
|
});
|
||||||
|
|
||||||
|
$effect(() => {
|
||||||
|
name = String(record.name ?? '');
|
||||||
|
});
|
||||||
|
|
||||||
|
This ensures a new record starts clean.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Select Inputs
|
||||||
|
|
||||||
|
IMPORTANT:
|
||||||
|
|
||||||
|
Do not use bind:value on select elements whose values are synchronized through $effect.
|
||||||
|
|
||||||
|
Incorrect:
|
||||||
|
|
||||||
|
<select bind:value={frequency}>
|
||||||
|
<option value="1">One</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
This can fail to update correctly.
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
<select onchange={(e) => frequency = e.currentTarget.value}>
|
||||||
|
<option value="1" selected={frequency === '1'}>
|
||||||
|
One
|
||||||
|
</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
Use selected on options.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Input Elements
|
||||||
|
|
||||||
|
Text inputs are different.
|
||||||
|
|
||||||
|
This is acceptable:
|
||||||
|
|
||||||
|
<input bind:value={name} />
|
||||||
|
|
||||||
|
bind:value works correctly with normal input elements.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Forms
|
||||||
|
|
||||||
|
Admin forms use:
|
||||||
|
|
||||||
|
- $state for field values
|
||||||
|
- $effect for prop synchronization
|
||||||
|
- oninput for dirty tracking
|
||||||
|
- use:enhance for submission
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
<form oninput={markDirty}>
|
||||||
|
<button disabled={!!record?.id && !dirty}>
|
||||||
|
Save
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Avoid Unnecessary State
|
||||||
|
|
||||||
|
Prefer:
|
||||||
|
|
||||||
|
$derived()
|
||||||
|
|
||||||
|
for values computed from other state.
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
$state()
|
||||||
|
|
||||||
|
only for values that change independently.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Component Changes
|
||||||
|
|
||||||
|
Before modifying a component:
|
||||||
|
|
||||||
|
1. Read the entire component.
|
||||||
|
2. Find similar components.
|
||||||
|
3. Follow existing patterns.
|
||||||
|
4. Avoid introducing new abstractions unless necessary.
|
||||||
|
|
||||||
|
Small, consistent changes are preferred.
|
||||||
@@ -0,0 +1,241 @@
|
|||||||
|
# Testing Patterns
|
||||||
|
|
||||||
|
Only read this file when creating or modifying tests.
|
||||||
|
|
||||||
|
This project uses:
|
||||||
|
|
||||||
|
- Vitest
|
||||||
|
- Playwright browser testing
|
||||||
|
- Svelte Testing Library patterns through vitest/browser
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Test Philosophy
|
||||||
|
|
||||||
|
Tests should prove behavior.
|
||||||
|
|
||||||
|
Do not write tests that only confirm implementation details.
|
||||||
|
|
||||||
|
Every test must contain meaningful assertions.
|
||||||
|
|
||||||
|
A test without assertions fails because:
|
||||||
|
|
||||||
|
expect.requireAssertions is enabled.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Test Locations
|
||||||
|
|
||||||
|
Tests are colocated with source files.
|
||||||
|
|
||||||
|
Client tests:
|
||||||
|
|
||||||
|
*.svelte.test.ts
|
||||||
|
*.svelte.spec.ts
|
||||||
|
|
||||||
|
Server tests:
|
||||||
|
|
||||||
|
*.test.ts
|
||||||
|
*.spec.ts
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Vitest Projects
|
||||||
|
|
||||||
|
There are two test environments.
|
||||||
|
|
||||||
|
## Client
|
||||||
|
|
||||||
|
Used for Svelte components.
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
|
||||||
|
Playwright Chromium browser.
|
||||||
|
|
||||||
|
Used for:
|
||||||
|
|
||||||
|
- rendering
|
||||||
|
- user interaction
|
||||||
|
- form behavior
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Server
|
||||||
|
|
||||||
|
Used for:
|
||||||
|
|
||||||
|
- load functions
|
||||||
|
- actions
|
||||||
|
- server utilities
|
||||||
|
|
||||||
|
Environment:
|
||||||
|
|
||||||
|
Node.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Before Each Test
|
||||||
|
|
||||||
|
Always reset mocks:
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Server Tests
|
||||||
|
|
||||||
|
Server route tests should cover:
|
||||||
|
|
||||||
|
- authenticated behavior
|
||||||
|
- unauthenticated behavior
|
||||||
|
- authorization rules
|
||||||
|
- successful operations
|
||||||
|
- failed operations
|
||||||
|
|
||||||
|
Mock:
|
||||||
|
|
||||||
|
- $lib/db
|
||||||
|
- authentication helpers
|
||||||
|
- external services
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Prisma Mocking
|
||||||
|
|
||||||
|
Mock only the methods used.
|
||||||
|
|
||||||
|
Example:
|
||||||
|
|
||||||
|
vi.mock('$lib/db', () => ({
|
||||||
|
prisma: {
|
||||||
|
proclamation: {
|
||||||
|
findMany: vi.fn(),
|
||||||
|
findUnique: vi.fn(),
|
||||||
|
create: vi.fn(),
|
||||||
|
update: vi.fn(),
|
||||||
|
delete: vi.fn()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
Do not create massive unused mocks.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Authentication Testing
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
createLocals()
|
||||||
|
|
||||||
|
Examples:
|
||||||
|
|
||||||
|
Unauthenticated:
|
||||||
|
|
||||||
|
createLocals()
|
||||||
|
|
||||||
|
Authenticated:
|
||||||
|
|
||||||
|
createLocals(mockUser() as any)
|
||||||
|
|
||||||
|
Specific roles:
|
||||||
|
|
||||||
|
createLocals(
|
||||||
|
mockUser({ roleId: 4 }) as any
|
||||||
|
)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Client Test Locators
|
||||||
|
|
||||||
|
Available:
|
||||||
|
|
||||||
|
getByRole
|
||||||
|
getByText
|
||||||
|
getByPlaceholder
|
||||||
|
getByAltText
|
||||||
|
getByTestId
|
||||||
|
|
||||||
|
Unavailable:
|
||||||
|
|
||||||
|
getByLabel
|
||||||
|
getByDisplayValue
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Role Queries
|
||||||
|
|
||||||
|
Always use exact regex names.
|
||||||
|
|
||||||
|
Preferred:
|
||||||
|
|
||||||
|
page.getByRole(
|
||||||
|
'textbox',
|
||||||
|
{ name: /^Name$/ }
|
||||||
|
)
|
||||||
|
|
||||||
|
Avoid:
|
||||||
|
|
||||||
|
page.getByRole(
|
||||||
|
'textbox',
|
||||||
|
{ name: 'Name' }
|
||||||
|
)
|
||||||
|
|
||||||
|
because substring matching can create strict-mode failures.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Select Testing
|
||||||
|
|
||||||
|
Because selects use selected options instead of bind:value:
|
||||||
|
|
||||||
|
const element =
|
||||||
|
await page
|
||||||
|
.getByRole(
|
||||||
|
'combobox',
|
||||||
|
{ name: /^Frequency$/ }
|
||||||
|
)
|
||||||
|
.element();
|
||||||
|
|
||||||
|
expect(
|
||||||
|
(element as HTMLSelectElement).value
|
||||||
|
).toBe('2');
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Svelte Component Mocking
|
||||||
|
|
||||||
|
When mocking Svelte 5 components:
|
||||||
|
|
||||||
|
Use:
|
||||||
|
|
||||||
|
vi.mock('$lib/components/example.svelte', () => ({
|
||||||
|
default: ($anchor: Element) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
$anchor.replaceWith(div);
|
||||||
|
}
|
||||||
|
}));
|
||||||
|
|
||||||
|
Imports using mocked modules must come after vi.mock().
|
||||||
|
|
||||||
|
If variables are needed inside mocks:
|
||||||
|
|
||||||
|
Use vi.hoisted().
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
# Test Completion Rule
|
||||||
|
|
||||||
|
After writing tests:
|
||||||
|
|
||||||
|
Run:
|
||||||
|
|
||||||
|
npm run test
|
||||||
|
|
||||||
|
If failures occur:
|
||||||
|
|
||||||
|
Fix them.
|
||||||
|
|
||||||
|
Do not report "tests added" without verifying they pass.
|
||||||
Reference in New Issue
Block a user