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:
@@ -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()`
|
||||
@@ -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>
|
||||
```
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
Reference in New Issue
Block a user