docs(copilot): add Copilot instructions for The Collective Hub

- Add comprehensive project overview and core philosophy
- Document file structure reference for the codebase
- Create key files reference table for task-specific guidance
- Include multi-tenant guidelines and site resolution flow
This commit is contained in:
KungRaseri
2026-06-05 23:46:15 -07:00
parent f4245a996a
commit b192cd53ba
33 changed files with 5710 additions and 120 deletions
@@ -0,0 +1,79 @@
---
description: 'Scaffold a new admin page for The Collective Hub. Generates +page.server.ts (auth guard, load data, form actions) and +page.svelte (Svelte 5, form, Tailwind).'
agent: 'agent'
---
Scaffold a new admin page at `admin/{section}/` for The Collective Hub.
Follow all conventions in:
- [admin-panel.instructions.md](.github/instructions/admin-panel.instructions.md)
- [auth-and-roles.instructions.md](.github/instructions/auth-and-roles.instructions.md)
- [svelte5.instructions.md](.github/instructions/svelte5.instructions.md)
- [server-ts.instructions.md](.github/instructions/server-ts.instructions.md)
## What to Create
### 1. `+page.server.ts`
```ts
import { fail, redirect } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { siteSettings } from '$lib/server/db/schema';
import { eq } from 'drizzle-orm';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals }) => {
if (!locals.user) redirect(302, '/login');
// Load current data
return {
settings: locals.siteSettings,
// Add more data as needed
};
};
export const actions: Actions = {
default: async ({ locals, request }) => {
if (!locals.user) return fail(401, { error: 'Unauthorized' });
const form = await request.formData();
// const value = form.get('fieldName') as string;
// Validate
// if (!value) return fail(400, { error: 'Field is required' });
// Save to DB (scoped by locals.site.id)
// await db.update(siteSettings)...
return { success: true, message: 'Saved' };
},
};
```
### 2. `+page.svelte`
```svelte
<script lang="ts">
import type { PageData } from './$types';
let { data, form }: { data: PageData; form: import('./$types').ActionData | null } = $props();
</script>
<div class="max-w-2xl mx-auto p-6">
<h1 class="text-2xl font-bold mb-6">Page Title</h1>
<form method="POST" class="space-y-4">
<!-- Form fields -->
{#if form?.error}
<p class="text-red-500 text-sm">{form.error}</p>
{/if}
{#if form?.success}
<p class="text-green-500 text-sm">{form.message}</p>
{/if}
<button type="submit" class="rounded-md bg-blue-600 px-4 py-2 text-white hover:bg-blue-700">
Save Changes
</button>
</form>
</div>
```
@@ -0,0 +1,67 @@
---
description: 'Scaffold a new SvelteKit API endpoint (+server.ts) for The Collective Hub. Generates route handler with auth, site scoping, and validation patterns.'
agent: 'agent'
---
Scaffold a new API endpoint at `api/{resource}/` for The Collective Hub.
Follow all conventions in:
- [api-route-patterns.instructions.md](.github/instructions/api-route-patterns.instructions.md)
- [auth-and-roles.instructions.md](.github/instructions/auth-and-roles.instructions.md)
- [server-ts.instructions.md](.github/instructions/server-ts.instructions.md)
## What to Create
### `+server.ts`
Determine the HTTP methods needed (GET, POST, PUT, PATCH, DELETE) based on the resource.
```ts
import { json } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { eq, and } from 'drizzle-orm';
import { /* yourTable */ } from '$lib/server/db/schema';
import type { RequestHandler } from './$types';
export const GET: RequestHandler = async ({ locals, url }) => {
if (!locals.user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const siteId = locals.site.id;
const items = await db
.select()
.from(/* yourTable */)
.where(eq(/* yourTable */.siteId, siteId))
.orderBy(/* yourTable */.createdAt);
return json({ data: items });
};
export const POST: RequestHandler = async ({ locals, request }) => {
if (!locals.user) {
return json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
// Validate body...
const [created] = await db
.insert(/* yourTable */)
.values({
siteId: locals.site.id,
// ... validated fields
})
.returning();
return json({ data: created }, { status: 201 });
};
```
## Notes
- Always check `locals.user` before accessing `locals.site`
- Always filter queries by `locals.site.id`
- Return appropriate HTTP status codes (200, 201, 400, 401, 403, 404, 500)
- For file uploads, use multipart form data via `request.formData()` instead of `request.json()`
+274
View File
@@ -0,0 +1,274 @@
---
description: 'Generate Vitest server tests for SvelteKit +server.ts API routes in The Collective Hub. Covers multi-tenant mocking, Drizzle database mocking, Better Auth / Discord OAuth mocking, form action testing, and API route test patterns.'
agent: 'agent'
---
Generate Vitest server tests for a SvelteKit API route (`+server.ts`) in The Collective Hub.
Follow all testing conventions in:
- [testing-multi-tenant.instructions.md](.github/instructions/testing-multi-tenant.instructions.md)
- [testing.instructions.md](.github/instructions/testing.instructions.md)
- [api-route-patterns.instructions.md](.github/instructions/api-route-patterns.instructions.md)
- [server-ts.instructions.md](.github/instructions/server-ts.instructions.md)
## What to Create
### Test file placement
Place the test file next to the source, dropping the `+` prefix:
| Source file | Test file |
| ----------- | --------- |
| `src/routes/api/contact/+server.ts` | `src/routes/api/contact/server.test.ts` |
| `src/routes/api/events/+server.ts` | `src/routes/api/events/server.test.ts` |
| `src/routes/admin/settings/+page.server.ts` | `src/routes/admin/settings/page.server.test.ts` |
> SvelteKit errors on `+server.test.ts` at build time, so drop the `+` prefix for route test files.
### Basic test template
```ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { POST } from './+server';
import type { RequestEvent } from '@sveltejs/kit';
// --- MOCKS ---
const { mockSelect, mockInsert, mockUpdate, mockDelete } = vi.hoisted(() => ({
mockSelect: vi.fn(),
mockInsert: vi.fn(),
mockUpdate: vi.fn(),
mockDelete: vi.fn(),
}));
vi.mock('$lib/server/db', () => ({
db: {
select: mockSelect,
insert: mockInsert,
update: mockUpdate,
delete: mockDelete,
},
}));
// --- HELPERS ---
const mockSite = {
id: 'test-site-uuid',
slug: 'test-site',
name: 'Test Site',
isActive: true,
};
function createMockLocals(overrides: Partial<App.Locals> = {}): App.Locals {
return {
site: mockSite,
siteSlug: 'test-site',
siteSettings: null as any,
user: { id: 'user-uuid', discordId: '12345', discordUsername: 'testuser' },
membership: { id: 'membership-uuid', siteId: mockSite.id, userId: 'user-uuid', role: 'owner' },
...overrides,
} as App.Locals;
}
function createRequestEvent(method: string, overrides: Partial<RequestEvent> = {}): RequestEvent {
return {
request: new Request(`http://localhost/api/resource`, { method }),
locals: createMockLocals(),
params: {},
url: new URL(`http://localhost/api/resource`),
...overrides,
} as unknown as RequestEvent;
}
// --- TESTS ---
describe('POST /api/resource', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns 401 when unauthenticated', async () => {
const event = createRequestEvent('POST', {
locals: createMockLocals({ user: null, membership: null }),
});
const response = await POST(event);
expect(response.status).toBe(401);
const body = await response.json();
expect(body).toHaveProperty('error');
});
it('returns 400 for invalid input', async () => {
const formData = new FormData();
// Missing required field
formData.append('name', '');
const event = createRequestEvent('POST', {
request: new Request('http://localhost/api/resource', {
method: 'POST',
body: formData,
}),
});
const response = await POST(event);
expect(response.status).toBe(400);
});
it('creates a resource successfully', async () => {
const createdResource = { id: 'new-uuid', siteId: mockSite.id, name: 'Test Resource' };
mockInsert.mockReturnValue({ values: vi.fn().mockReturnValue({ returning: vi.fn().mockResolvedValue([createdResource]) }) });
const formData = new FormData();
formData.append('name', 'Test Resource');
const event = createRequestEvent('POST', {
request: new Request('http://localhost/api/resource', {
method: 'POST',
body: formData,
}),
});
const response = await POST(event);
expect(response.status).toBe(201);
const body = await response.json();
expect(body.data).toEqual(createdResource);
});
});
```
### Testing different auth roles
When testing role-based access, use the `membership.role` field:
```ts
it('allows owner to create resource', async () => {
const event = createRequestEvent('POST', {
locals: createMockLocals({
membership: { id: 'm-uuid', siteId: mockSite.id, userId: 'user-uuid', role: 'owner' },
}),
});
// expect 201
});
it('allows admin to create resource', async () => {
const event = createRequestEvent('POST', {
locals: createMockLocals({
membership: { id: 'm-uuid', siteId: mockSite.id, userId: 'user-uuid', role: 'admin' },
}),
});
// expect 201
});
it('rejects editor from creating resource', async () => {
const event = createRequestEvent('POST', {
locals: createMockLocals({
membership: { id: 'm-uuid', siteId: mockSite.id, userId: 'user-uuid', role: 'editor' },
}),
});
// expect 403
});
it('allows super admin from any site', async () => {
// Super admins are identified by SUPER_ADMIN_DISCORD_IDS env var
// Mock the env check or auth module to return super admin status
const event = createRequestEvent('POST', {
locals: createMockLocals({
user: { id: 'super-uuid', discordId: '999999999999999999', discordUsername: 'superadmin' },
membership: null, // super admins may not have a membership
}),
});
// expect 201 if super admin bypass is implemented
});
```
### Testing site-scoped queries
Verify that API routes filter by `locals.site.id`:
```ts
it('queries scoped to current site', async () => {
const mockWhere = vi.fn();
mockSelect.mockReturnValue({
from: vi.fn().mockReturnValue({
where: mockWhere.mockResolvedValue([]),
orderBy: vi.fn().mockResolvedValue([]),
}),
});
const response = await GET(createRequestEvent('GET'));
expect(mockWhere).toHaveBeenCalled();
// The where clause should include eq(table.siteId, mockSite.id)
});
```
### Testing JSON API endpoints
For endpoints accepting JSON body instead of FormData:
```ts
it('accepts JSON body', async () => {
const payload = { name: 'Test', description: 'Description' };
const event = createRequestEvent('POST', {
request: new Request('http://localhost/api/resource', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
}),
});
const response = await POST(event);
expect(response.status).toBe(201);
});
```
### Testing error responses
```ts
it('returns 404 when resource not found', async () => {
mockSelect.mockReturnValue({
from: vi.fn().mockReturnValue({
where: vi.fn().mockResolvedValue([]),
}),
});
const response = await GET(createRequestEvent('GET'));
expect(response.status).toBe(404);
});
it('returns 500 on database error', async () => {
mockSelect.mockImplementation(() => {
throw new Error('Database connection failed');
});
const response = await GET(createRequestEvent('GET'));
expect(response.status).toBe(500);
const body = await response.json();
expect(body.error).toBe('Internal server error');
});
```
## File placement rules
| Source file | Test file |
| ----------- | --------- |
| `src/routes/api/{resource}/+server.ts` | `src/routes/api/{resource}/server.test.ts` |
| `src/routes/admin/{section}/+page.server.ts` | `src/routes/admin/{section}/page.server.test.ts` |
> Always drop the `+` prefix from route filenames when creating test files.
## Key patterns to follow
1. **Mock the database** with `vi.hoisted()` and `vi.mock('$lib/server/db')` — never connect to a real database
2. **Mock site context** via `locals.site`, `locals.siteSlug`, `locals.siteSettings`
3. **Test auth states** — unauthenticated, owner, admin, editor, super admin
4. **Test site scoping** — verify queries filter by `locals.site.id`
5. **Test request validation** — missing fields, invalid types, empty bodies
6. **Test response formats** — success JSON, error JSON, redirects
7. **Use `createMockLocals()`** helper for consistent locals mocking
8. **Use `createRequestEvent()`** helper for consistent RequestEvent construction
9. **Reset mocks** in `beforeEach` to prevent test pollution
@@ -0,0 +1,95 @@
---
agent: 'agent'
description: Generate a Vitest browser component test for a Svelte 5 component
---
You are generating a Vitest browser component test for a Svelte 5 component in The Collective Hub project.
## Prerequisites
Before generating tests, confirm that `@testing-library/svelte` and `@vitest/browser` are installed. If not, tell the user to run:
```sh
npm install -D @testing-library/svelte @vitest/browser playwright
npx playwright install chromium
```
And that the vitest config needs a `client` project block added (describe what to add).
## Your task
Read the target `.svelte` component (provided by the user or open in the editor), then generate a co-located `*.svelte.test.ts` file.
## Rules
- File goes next to the source: `src/lib/components/ui/StatCard.svelte``src/lib/components/ui/StatCard.svelte.test.ts`
- Use `.svelte.test.ts` extension — this runs under Vitest's browser/Playwright environment
- Import `render`, `screen`, `fireEvent` (or `userEvent`) from `'@testing-library/svelte'`
- Import `describe`, `it`, `expect`, `vi` from `'vitest'`
- `requireAssertions` is enabled globally in [`vitest.config.ts`](vitest.config.ts) via `expect: { requireAssertions: true }`**every `it` block must have at least one `expect()`**
- Never import from `$lib/server/**` — those modules are excluded from the browser environment
- Use Testing Library queries (`getByText`, `getByRole`, `getByLabelText`, etc.) — not `document.querySelector`
## Svelte 5 rendering
Svelte 5 components use runes. Props are passed as the `props` option:
```ts
import { render, screen } from '@testing-library/svelte';
import MyComponent from './MyComponent.svelte';
render(MyComponent, { props: { title: 'Hello', value: 42 } });
```
For components with snippet props, you may need to render wrapper markup — note this in the test as a limitation.
## Test structure
For each component, test:
1. **Renders with default/required props** — check the key text/elements appear
2. **Conditional rendering** — if the component has `{#if}` blocks, test both branches
3. **Prop variations** — test meaningful prop combinations (not exhaustive)
4. **User interaction** — if the component has buttons/inputs, test click/input events
5. **Accessibility** — check that interactive elements have appropriate roles/labels
```ts
import { render, screen, fireEvent } from '@testing-library/svelte';
import { describe, it, expect, vi } from 'vitest';
import StatCard from './StatCard.svelte';
describe('StatCard', () => {
it('renders the title and value', () => {
render(StatCard, { props: { title: 'Movies', value: 42 } });
expect(screen.getByText('Movies')).toBeInTheDocument();
expect(screen.getByText('42')).toBeInTheDocument();
});
it('renders a loading state when value is undefined', () => {
render(StatCard, { props: { title: 'Movies', value: undefined } });
expect(screen.getByRole('status')).toBeInTheDocument();
});
});
```
## What to analyze in the component
Read the `.svelte` file carefully:
- What props (`$props()`) does it accept? What are their types and defaults?
- What does the component render? What are the key visible elements?
- Are there conditional blocks (`{#if}`, `{#each}`) to test both branches of?
- Does it emit events or call callbacks passed as props?
- Does it use `$bindable()` for two-way binding?
- Does it fetch data or call server functions? If so, mock those.
- What ARIA roles or labels are present?
Generate a complete, focused test file. Keep tests small and single-purpose. Prefer `getByRole` over `getByText` for interactive elements.
## Project context
- Components live under `src/lib/components/` (ui/, layout/, auth/) or `src/routes/**/*.svelte`
- Icons use `@lucide/svelte` components
- Tailwind classes are not assertion targets — test behavior and text content
@@ -0,0 +1,63 @@
---
description: 'Scaffold a new Drizzle database migration for The Collective Hub. Generates schema additions in schema.ts and runs drizzle-kit generate to produce the SQL migration.'
agent: 'agent'
---
Generate a Drizzle database migration for: ${input}
Follow all conventions in:
- [database-schema.instructions.md](.github/instructions/database-schema.instructions.md)
- [multi-tenant-architecture.instructions.md](.github/instructions/multi-tenant-architecture.instructions.md)
## Rules
1. **Additive changes only** — never add destructive operations (ALTER DROP, RENAME) in a production migration
2. **Every new table must have a `siteId` column** — references `sites.id` with `onDelete: 'cascade'`
3. **Every new table must have:**
- `id: uuid('id').defaultRandom().primaryKey()`
- `createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow()`
- `updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow().$onUpdate(() => new Date())`
4. **Use snake_case for column names** in the database
5. **Add indexes** on `siteId` and any commonly filtered columns
6. **Use appropriate types:** `text`, `boolean`, `integer`, `jsonb`, `timestamp`
## Steps
1. Add the new table/column definition to [`src/lib/server/db/schema.ts`](src/lib/server/db/schema.ts)
2. Run `npx drizzle-kit generate` to create the SQL migration file
3. Review the generated SQL in `drizzle/` to ensure it matches expectations
## Example: New Table
```ts
// In schema.ts
export const newTable = pgTable(
'new_table',
{
id: uuid('id').defaultRandom().primaryKey(),
siteId: uuid('site_id')
.notNull()
.references(() => sites.id, { onDelete: 'cascade' }),
name: text('name').notNull(),
isActive: boolean('is_active').notNull().default(true),
createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(),
updatedAt: timestamp('updated_at', { withTimezone: true })
.notNull()
.defaultNow()
.$onUpdate(() => new Date()),
},
(table) => [
index('new_table_site_id_idx').on(table.siteId),
]
);
```
## Example: New Column
```ts
// Add to an existing table definition
export const events = pgTable('events', {
// ... existing columns
newColumn: text('new_column'), // Add new optional column
});
```
@@ -0,0 +1,90 @@
---
description: 'Scaffold seed data script for The Collective Hub. Generates a seed.mjs file that creates a local dev site, default settings, and optional sample data.'
agent: 'agent'
---
Generate or update a seed script for The Collective Hub.
Follow the conventions in:
- [database-schema.instructions.md](.github/instructions/database-schema.instructions.md)
- [multi-tenant-architecture.instructions.md](.github/instructions/multi-tenant-architecture.instructions.md)
## Purpose
The seed script creates a local development site so that the app runs without requiring an existing site in the database.
## Script Structure ([`scripts/seed.mjs`](scripts/seed.mjs))
```js
// @ts-check
import { drizzle } from 'drizzle-orm/postgres-js';
import postgres from 'postgres';
import * as schema from '../src/lib/server/db/schema.js';
const connectionString = process.env.DATABASE_URL || 'postgresql://localhost:5432/collective_hub';
const client = postgres(connectionString);
const db = drizzle(client, { schema });
async function seed() {
console.log('🌱 Seeding database...');
// Upsert site
const [site] = await db
.insert(schema.sites)
.values({
slug: 'local-dev',
name: 'Local Dev Site',
isActive: true,
})
.onConflictDoUpdate({ target: schema.sites.slug, set: { name: 'Local Dev Site' } })
.returning();
// Upsert site settings
const defaultSettings = {
branding: {
siteName: 'Local Dev Site',
tagline: 'A community for watching bad movies',
logoCdnKey: null,
backgroundCdnKey: null,
faviconCdnKey: null,
},
theme: {
preset: 'dark',
accentColor: '#e63946',
backgroundColor: '#1a1a2e',
textColor: '#eaeaea',
},
homepage: {
heroTitle: 'Welcome to Local Dev Site',
heroSubtitle: 'We watch bad movies so you don\'t have to',
aboutText: 'A community of bad movie enthusiasts who gather weekly to watch, laugh, and critique the worst cinema has to offer.',
primaryButtonText: 'Join us on Discord',
primaryButtonLink: 'https://discord.gg/example',
showNextEvent: true,
showSchedule: true,
},
layout: {
preset: 'standard',
},
};
await db
.insert(schema.siteSettings)
.values({ siteId: site.id, settings: defaultSettings })
.onConflictDoUpdate({ target: schema.siteSettings.siteId, set: { settings: defaultSettings } });
console.log(`✅ Seeded site: ${site.slug} (${site.id})`);
await client.end();
}
seed().catch((err) => {
console.error('❌ Seed failed:', err);
process.exit(1);
});
```
## Environment
- Set `DATABASE_URL` env var for the target database
- Defaults to `postgresql://localhost:5432/collective_hub` for local dev
- Run with: `node scripts/seed.mjs`
@@ -0,0 +1,71 @@
---
description: 'Scaffold a new Svelte 5 component for The Collective Hub. Follows runes convention, snippets over slots, Tailwind CSS, and Lucide icons.'
agent: 'agent'
---
Scaffold a Svelte 5 component for The Collective Hub: ${input}
Follow all conventions in:
- [components.instructions.md](.github/instructions/components.instructions.md)
- [svelte5.instructions.md](.github/instructions/svelte5.instructions.md)
- [icons.instructions.md](.github/instructions/icons.instructions.md)
- [tailwindcss.instructions.md](.github/instructions/tailwindcss.instructions.md)
## Conventions
1. **PascalCase filename**`TheComponent.svelte`
2. **Place in appropriate folder:**
- `src/lib/components/ui/` — generic reusable UI primitives
- `src/lib/components/layout/` — structural shell components
- Route directory — page-specific components
3. **Use Svelte 5 runes**:
- `$props()` for component props with inline type
- `$state()` for local state
- `$derived()` for computed values
- `$effect()` for side effects (sparingly)
- `$bindable()` for two-way bindings
4. **Snippets over slots** for composition patterns
5. **Tailwind CSS** for styling
6. **Lucide icons** from `@lucide/svelte` — import individual icons by name
7. **No `svelte-ignore` comments** — fix accessibility and type issues properly
## Example Component
```svelte
<script lang="ts">
import { Heart } from '@lucide/svelte';
let {
title,
description = '',
liked = $bindable(false),
}: {
title: string;
description?: string;
liked?: boolean;
} = $props();
let count = $state(0);
let doubled = $derived(count * 2);
</script>
<div class="rounded-lg border p-4">
<div class="flex items-center gap-2">
<h2 class="text-lg font-semibold">{title}</h2>
<button
onclick={() => (liked = !liked)}
class="ml-auto"
aria-label={liked ? 'Unlike' : 'Like'}
>
<Heart class={liked ? 'fill-red-500 text-red-500' : ''} />
</button>
</div>
{#if description}
<p class="mt-2 text-gray-600">{description}</p>
{/if}
<p class="mt-2">Count: {count} (doubled: {doubled})</p>
<button onclick={() => count++} class="mt-2 rounded bg-blue-600 px-3 py-1 text-white">
Increment
</button>
</div>
```
+80
View File
@@ -0,0 +1,80 @@
---
description: 'Scaffold a new SvelteKit route with a server load function and Svelte 5 page component for The Collective Hub. Generates +page.server.ts (auth guard, site-scoped Drizzle query, typed return) and +page.svelte (runes, Tailwind layout).'
name: 'New Route'
argument-hint: "Route path and purpose (e.g. 'admin/events detail page' or 'public schedule page')"
agent: 'agent'
---
Scaffold a new SvelteKit route for: ${input}
Follow all conventions in:
- [multi-tenant-architecture.instructions.md](.github/instructions/multi-tenant-architecture.instructions.md)
- [auth-and-roles.instructions.md](.github/instructions/auth-and-roles.instructions.md)
- [svelte5.instructions.md](.github/instructions/svelte5.instructions.md)
- [server-ts.instructions.md](.github/instructions/server-ts.instructions.md)
## What to create
Determine the route path from the input (e.g. `admin/events``src/routes/admin/events/`).
### 1. `+page.server.ts`
```ts
import { redirect } from '@sveltejs/kit';
import { db } from '$lib/server/db';
import { eq, and } from 'drizzle-orm';
import { /* yourTable */ } from '$lib/server/db/schema';
import type { PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ locals, params }) => {
if (!locals.user) redirect(302, '/login');
const siteId = locals.site.id;
// Site-scoped query — always filter by siteId
const items = await db
.select()
.from(/* yourTable */)
.where(
and(
eq(/* yourTable */.siteId, siteId),
// Add more filters as needed
)
)
.orderBy(/* yourTable */.createdAt);
return {
items,
settings: locals.siteSettings,
};
};
```
- For **admin routes**, add a role check after the auth guard: `if (locals.membership?.role !== 'owner' && locals.membership?.role !== 'admin') redirect(302, '/admin');`
- For **public routes**, skip the auth guard entirely — just load data scoped by `siteId`
- Add `actions: Actions` only if the route has a form — skip it for read-only pages
- Use `fail` / `redirect` from `@sveltejs/kit` in actions per [server-ts.instructions.md](.github/instructions/server-ts.instructions.md)
### 2. `+page.svelte`
```svelte
<script lang="ts">
import type { PageData } from './$types';
let { data }: { data: PageData } = $props();
let { items, settings } = data;
</script>
<div class="p-6">
<h1 class="text-2xl font-bold"><!-- Page title --></h1>
<!-- Render {items} here -->
</div>
```
- Use Svelte 5 runes throughout (`$props`, `$state`, `$derived`)
- Tailwind utility classes for layout (`p-6`, `flex`, `gap-4`, etc.)
- No `export let`, no `on:` directives, no `<slot>`
## After generating
List the files created and their paths. Ask if a form action should be added.
+190
View File
@@ -0,0 +1,190 @@
---
agent: 'agent'
description: Analyse test coverage gaps for a file or directory and generate missing tests
---
You are performing test coverage analysis and generating missing tests for The Collective Hub project.
## Your task
The user has provided one or more source files or a directory to analyse. For each file:
1. Read the source file
2. Identify all exported functions, components, handlers, and branches that lack test coverage
3. Check whether a co-located test file already exists
4. Generate or extend the test file to cover the gaps
If the user has not specified a file, ask them to provide one before proceeding.
---
## Coverage analysis checklist
For each source file, enumerate all of the following that need tests:
### Server files (`*.server.ts`, `+server.ts`, `+page.server.ts`)
- [ ] Every exported `load` function — success path, unauthenticated redirect, DB error
- [ ] Every form `action` — valid input, `fail()` path, redirect path
- [ ] Every HTTP handler (`GET`, `POST`, `PATCH`, `DELETE`) — 2xx and 4xx/5xx paths
- [ ] Auth guard branches — unauthenticated (`locals.user` is null), insufficient role
- [ ] Query results — empty array, single result, multiple results
### Svelte components (`*.svelte`)
- [ ] Default render with required props
- [ ] Every `{#if}` branch (both true and false)
- [ ] Every `{#each}` branch — empty array, non-empty array
- [ ] User interactions — click, input, form submit
- [ ] Conditional class/style variations driven by props
- [ ] Slot/snippet presence and absence (note as limitation if not testable)
### Utility / library files (`*.ts`)
- [ ] Every exported function — happy path
- [ ] Edge cases: empty input, null/undefined, boundary values
- [ ] Error paths: thrown errors, returned error objects
---
## File placement rules
| Source file | Test file |
| ------------------------------------------- | -------------------------------------------------- |
| `src/lib/utils/format.ts` | `src/lib/utils/format.test.ts` |
| `src/lib/components/ui/ServiceCard.svelte` | `src/lib/components/ui/ServiceCard.svelte.test.ts` |
| `src/routes/api/contact/+server.ts` | `src/routes/api/contact/server.test.ts` |
| `src/routes/(app)/services/+page.server.ts` | `src/routes/(app)/services/page.server.test.ts` |
> **Drop the `+` prefix** for route test files — SvelteKit errors on `+server.test.ts` at build time.
> Server tests use `.test.ts`; component tests use `.svelte.test.ts`.
---
## Mocking rules
### Always mock the database
`vi.mock` is hoisted before `const` declarations. **Always use `vi.hoisted()`** for mock functions referenced inside `vi.mock` factories:
```ts
const { mockFindMany, mockInsert } = vi.hoisted(() => ({
mockFindMany: vi.fn(),
mockInsert: vi.fn()
}));
vi.mock('$lib/server/db', () => ({
db: {
query: {
services: { findMany: mockFindMany, findFirst: vi.fn() }
},
insert: mockInsert
}
}));
```
Reset mocks in `beforeEach`:
```ts
beforeEach(() => {
vi.clearAllMocks();
});
```
### Create a mock RequestEvent for +server.ts handlers
```ts
function mockEvent(overrides: Partial<RequestEvent> = {}): RequestEvent {
return {
request: new Request('http://localhost/api/resource', { method: 'GET' }),
locals: { user: { id: 'user-1', role: 'user' } },
params: {},
url: new URL('http://localhost/api/resource'),
...overrides
} as unknown as RequestEvent;
}
```
### Create a mock PageServerLoadEvent for load functions
```ts
function mockLoadEvent(overrides = {}) {
return {
locals: { user: { id: 'user-1', role: 'user' } },
params: {},
url: new URL('http://localhost/page'),
...overrides
} as any;
}
```
### Stub fetch for external API calls
```ts
vi.stubGlobal(
'fetch',
vi.fn().mockResolvedValue(new Response(JSON.stringify({ data: [] }), { status: 200 }))
);
```
---
## Test structure
```ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
// mocks before imports that use them
const { mockFn } = vi.hoisted(() => ({ mockFn: vi.fn() }));
vi.mock('$lib/server/db', () => ({ db: { query: { table: { findMany: mockFn } } } }));
import { myFunction } from './myModule';
describe('myFunction', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('returns expected result for valid input', async () => {
mockFn.mockResolvedValue([{ id: 1 }]);
const result = await myFunction('valid');
expect(result).toEqual({ id: 1 });
});
it('returns null when not found', async () => {
mockFn.mockResolvedValue([]);
const result = await myFunction('missing');
expect(result).toBeNull();
});
});
```
---
## requireAssertions rule
`expect.requireAssertions: true` is set globally. **Every `it` block must contain at least one `expect()`** or the test will fail even if no error is thrown.
---
## Coverage run command
After generating tests, remind the user they can check coverage with:
```sh
npm run test:run -- --coverage
```
The HTML report is written to `coverage/index.html`.
---
## Output format
For each source file analysed:
1. **Coverage gaps** — bullet list of untested functions/branches
2. **Test file path** — where the new/updated test file will go
3. **Generated test file** — full content, ready to save
If a test file already exists, show only the **new `it` blocks** to add, not the full file.