b192cd53ba
- 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
275 lines
8.6 KiB
Markdown
275 lines
8.6 KiB
Markdown
---
|
|
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
|