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,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
|
||||
Reference in New Issue
Block a user