Files
the-collective-hub/.github/prompts/generate-api-test.prompt.md
KungRaseri b192cd53ba 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
2026-06-05 23:46:15 -07:00

8.6 KiB

description, agent
description agent
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

Generate Vitest server tests for a SvelteKit API route (+server.ts) in The Collective Hub.

Follow all testing conventions in:

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

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:

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:

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:

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

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