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
7.4 KiB
7.4 KiB
description: 'Use when writing or editing tests for The Collective Hub. Covers multi-tenant test patterns, mocking site context, database mocking, auth testing, and coverage targets for multi-tenant code paths.' applyTo: 'src//*.test.ts', 'src//.spec.ts', 'src/**/.svelte.test.ts'
Testing: Multi-Tenant Patterns
Base testing rules are covered in
testing.instructions.md. This file covers patterns specific to testing multi-tenant code.
Overview
The Collective Hub's multi-tenant architecture introduces testing concerns that don't exist in single-tenant apps:
- Site scoping — every query must filter by
siteId - Auth state — routes behave differently based on user role
- Site context —
locals.site,locals.siteSettingsdrive rendering - Data isolation — one site's data must never leak to another
Mocking Site Context
In Server Tests
When testing server code (load functions, form actions, API routes), mock the locals object:
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockSite = {
id: 'test-site-uuid',
slug: 'test-site',
name: 'Test Site',
isActive: true,
};
const mockSettings = {
branding: { siteName: 'Test Site', tagline: 'Testing', logoCdnKey: null, backgroundCdnKey: null, faviconCdnKey: null },
theme: { preset: 'dark' as const, accentColor: '#e63946', backgroundColor: '#1a1a2e', textColor: '#eaeaea' },
homepage: { heroTitle: 'Welcome', heroSubtitle: '', aboutText: '', primaryButtonText: 'Join', primaryButtonLink: '', showNextEvent: true, showSchedule: true },
layout: { preset: 'standard' as const },
};
function createMockLocals(overrides: Partial<App.Locals> = {}): App.Locals {
return {
site: mockSite,
siteSlug: 'test-site',
siteSettings: mockSettings,
user: { id: 'user-uuid', discordId: '12345', discordUsername: 'testuser' },
membership: { id: 'membership-uuid', siteId: mockSite.id, userId: 'user-uuid', role: 'owner' },
...overrides,
} as App.Locals;
}
Testing with Different Auth States
describe('admin page load', () => {
it('redirects to login when unauthenticated', async () => {
const locals = createMockLocals({ user: null, membership: null });
// expect redirect(302, '/login')
});
it('allows access for authenticated owner', async () => {
const locals = createMockLocals({ user: mockUser, membership: { ...mockMembership, role: 'owner' } });
// expect data to load
});
it('allows access for admin', async () => {
const locals = createMockLocals({ user: mockUser, membership: { ...mockMembership, role: 'admin' } });
// expect data to load
});
it('redirects editor from settings page', async () => {
const locals = createMockLocals({ user: mockUser, membership: { ...mockMembership, role: 'editor' } });
// expect redirect(302, '/admin') or 403
});
});
Mocking the Database
Use vi.hoisted() with vi.mock() for Drizzle mocking:
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,
},
}));
Testing Site-Scoped Queries
Verify that queries filter by siteId:
it('queries events scoped to current site', async () => {
const mockWhere = vi.fn();
mockSelect.mockReturnValue({
from: vi.fn().mockReturnValue({
where: mockWhere.mockResolvedValue([]),
orderBy: vi.fn().mockResolvedValue([]),
}),
});
// Call the function being tested
await loadEvents({ siteId: mockSite.id });
// Verify the where clause includes siteId
expect(mockWhere).toHaveBeenCalled();
const whereArgs = mockWhere.mock.calls[0][0];
// whereArgs should contain eq(eventsTable.siteId, mockSite.id)
});
Testing Auth Guards
Server Load Functions
import { redirect } from '@sveltejs/kit';
it('throws redirect when user is not authenticated', async () => {
const locals = createMockLocals({ user: null });
try {
await load({ locals, params: {}, url: new URL('http://test.com') } as any);
expect.unreachable('Should have thrown redirect');
} catch (e) {
expect(e.status).toBe(302);
expect(e.location).toBe('/login');
}
});
Form Actions
it('returns 401 fail for unauthenticated form submission', async () => {
const locals = createMockLocals({ user: null });
const request = new Request('http://test.com/admin/settings', { method: 'POST' });
const result = await actions.default({ locals, request } as any);
expect(result.status).toBe(401);
});
Testing Data Isolation
Ensure one site's data doesn't leak into another:
it('only returns events for the current site', async () => {
const siteAEvents = [{ id: 'event-1', siteId: 'site-a' }];
const siteBEvents = [{ id: 'event-2', siteId: 'site-b' }];
// Mock to return siteA events when filtering by site-a
mockWhere.mockImplementation((...args) => {
// Inspect args to ensure siteId filtering
return siteAEvents;
});
const result = await loadEvents({ siteId: 'site-a' });
expect(result).toHaveLength(1);
expect(result[0].siteId).toBe('site-a');
// No site-b events leaked
expect(result.some((e: any) => e.siteId === 'site-b')).toBe(false);
});
Testing Admin Form Actions
it('saves site settings correctly', async () => {
const locals = createMockLocals();
const formData = new FormData();
formData.append('siteName', 'Updated Site Name');
mockUpdate.mockResolvedValue([{ id: mockSite.id }]);
const result = await actions.default({ locals, request: new Request('http://test.com/admin/settings', {
method: 'POST',
body: formData,
}) } as any);
expect(mockUpdate).toHaveBeenCalled();
expect(result).toEqual({ success: true, message: 'Settings saved' });
});
Testing API Routes
it('rejects unauthenticated upload', async () => {
const locals = createMockLocals({ user: null });
const request = new Request('http://test.com/api/assets', { method: 'POST' });
const response = await POST({ locals, request } as any);
expect(response.status).toBe(401);
});
it('validates file type', async () => {
const locals = createMockLocals();
const formData = new FormData();
formData.append('file', new File(['not-an-image'], 'test.txt', { type: 'text/plain' }));
const response = await POST({ locals, request: new Request('http://test.com/api/assets', {
method: 'POST',
body: formData,
}) } as any);
expect(response.status).toBe(400);
});
Coverage Targets
Focus test coverage on multi-tenant code paths:
| Component | Target Coverage | Key Scenarios |
|---|---|---|
| Site resolver | 90%+ | Found, not found, inactive site |
| Auth guards | 90%+ | Unauthenticated, wrong role, super admin bypass |
| Admin form actions | 80%+ | Save success, validation failure, auth failure |
| API routes | 80%+ | CRUD operations, auth, site scoping |
| DB queries | 90%+ | Correct siteId filtering, ordering, null handling |
| Public page render | 70%+ | All theme presets, missing settings defaults |