mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
Add detailed documentation for Claude Code in .claude/ directory: - code-style.md: formatting, naming, linting rules - database.md: Drizzle ORM patterns and schema conventions - testing.md: Jest/Vitest patterns with mock factories - nestjs-backend.md: controller, service, DTO patterns - error-handling.md: Go-style Result types and error codes - sveltekit-web.md: Svelte 5 runes and store patterns - expo-mobile.md: React Native with NativeWind - authentication.md: Mana Core Auth integration Update root CLAUDE.md to reference new guidelines
15 KiB
15 KiB
Testing Guidelines
Overview
| App Type | Framework | Config | File Pattern |
|---|---|---|---|
| NestJS Backend | Jest + ts-jest | jest.config.js |
*.spec.ts |
| Expo Mobile | Jest + jest-expo | jest.config.js |
*.test.tsx |
| SvelteKit Web | Vitest | vitest.config.ts |
*.test.ts |
| E2E | Playwright | playwright.config.ts |
e2e/*.spec.ts |
Coverage Requirements
// Target: 80% for all new code
coverageThresholds: {
global: {
branches: 80,
functions: 80,
lines: 80,
statements: 80,
},
}
Test File Organization
src/
├── __tests__/
│ ├── utils/
│ │ ├── mock-factories.ts # Centralized factories
│ │ └── test-helpers.ts # Shared utilities
│ └── fixtures/ # Test data files
├── feature/
│ ├── feature.service.ts
│ └── feature.service.spec.ts # Colocated test
└── ...
Mock Factories Pattern
Create reusable factories for test data:
// src/__tests__/utils/mock-factories.ts
import { nanoid } from 'nanoid';
export const mockUserFactory = {
create: (overrides: Partial<User> = {}): User => ({
id: nanoid(),
email: `test-${nanoid(6)}@example.com`,
name: 'Test User',
role: 'user',
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
}),
createMany: (count: number, overrides: Partial<User> = {}): User[] => {
return Array.from({ length: count }, () => mockUserFactory.create(overrides));
},
};
export const mockFileFactory = {
create: (overrides: Partial<File> = {}): File => ({
id: nanoid(),
userId: nanoid(),
name: `file-${nanoid(6)}.txt`,
mimeType: 'text/plain',
size: 1024,
storagePath: `/files/${nanoid()}`,
storageKey: nanoid(),
isDeleted: false,
isFavorite: false,
createdAt: new Date(),
updatedAt: new Date(),
...overrides,
}),
createMany: (count: number, overrides: Partial<File> = {}): File[] => {
return Array.from({ length: count }, () => mockFileFactory.create(overrides));
},
};
// Usage in tests:
const user = mockUserFactory.create({ role: 'admin' });
const files = mockFileFactory.createMany(5, { userId: user.id });
Test Helpers
// src/__tests__/utils/test-helpers.ts
import { ConfigService } from '@nestjs/config';
// Mock config service
export function createMockConfigService(overrides: Record<string, any> = {}) {
const config: Record<string, any> = {
DATABASE_URL: 'postgresql://test:test@localhost:5432/test',
MANA_CORE_AUTH_URL: 'http://localhost:3001',
...overrides,
};
return {
get: jest.fn((key: string) => config[key]),
getOrThrow: jest.fn((key: string) => {
if (!(key in config)) throw new Error(`Missing config: ${key}`);
return config[key];
}),
} as unknown as ConfigService;
}
// Mock database with chainable methods
export function createMockDb() {
const results: any[] = [];
let resultIndex = 0;
const mockDb = {
select: jest.fn().mockReturnThis(),
from: jest.fn().mockReturnThis(),
where: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
offset: jest.fn().mockReturnThis(),
orderBy: jest.fn().mockReturnThis(),
insert: jest.fn().mockReturnThis(),
values: jest.fn().mockReturnThis(),
update: jest.fn().mockReturnThis(),
set: jest.fn().mockReturnThis(),
delete: jest.fn().mockReturnThis(),
returning: jest.fn().mockReturnThis(),
leftJoin: jest.fn().mockReturnThis(),
transaction: jest.fn(),
// Thenable for await
then: jest.fn((resolve) => resolve(results[resultIndex++] || [])),
// Helper to set results
mockResults: (...newResults: any[]) => {
results.length = 0;
results.push(...newResults);
resultIndex = 0;
},
// Reset all mocks
reset: () => {
jest.clearAllMocks();
results.length = 0;
resultIndex = 0;
},
};
return mockDb;
}
// Assertion helpers
export const assertHelpers = {
assertIsUuid: (value: string) => {
expect(value).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i
);
},
assertIsRecent: (date: Date, toleranceMs = 5000) => {
const now = Date.now();
expect(date.getTime()).toBeGreaterThan(now - toleranceMs);
expect(date.getTime()).toBeLessThanOrEqual(now);
},
assertResultOk: <T>(result: Result<T>): T => {
expect(result.ok).toBe(true);
if (!result.ok) throw new Error('Expected ok result');
return result.data;
},
assertResultErr: (result: Result<any>, expectedCode?: ErrorCode) => {
expect(result.ok).toBe(false);
if (result.ok) throw new Error('Expected error result');
if (expectedCode) {
expect(result.error.code).toBe(expectedCode);
}
return result.error;
},
};
NestJS Unit Tests
Service Tests
// src/files/file.service.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { FileService } from './file.service';
import { DATABASE_CONNECTION } from '../db/database.module';
import { mockFileFactory, createMockDb, assertHelpers } from '../__tests__/utils';
import { ErrorCode } from '@manacore/shared-errors';
describe('FileService', () => {
let service: FileService;
let mockDb: ReturnType<typeof createMockDb>;
beforeEach(async () => {
mockDb = createMockDb();
const module: TestingModule = await Test.createTestingModule({
providers: [
FileService,
{ provide: DATABASE_CONNECTION, useValue: mockDb },
],
}).compile();
service = module.get<FileService>(FileService);
});
afterEach(() => {
mockDb.reset();
});
describe('findById', () => {
it('should return file when found', async () => {
const mockFile = mockFileFactory.create();
mockDb.mockResults([mockFile]);
const result = await service.findById(mockFile.id, mockFile.userId);
const file = assertHelpers.assertResultOk(result);
expect(file.id).toBe(mockFile.id);
expect(mockDb.select).toHaveBeenCalled();
});
it('should return NOT_FOUND error when file does not exist', async () => {
mockDb.mockResults([]);
const result = await service.findById('non-existent', 'user-123');
const error = assertHelpers.assertResultErr(result, ErrorCode.FILE_NOT_FOUND);
expect(error.message).toContain('not found');
});
it('should not return files belonging to other users', async () => {
mockDb.mockResults([]); // Query returns empty due to userId filter
const result = await service.findById('file-123', 'different-user');
assertHelpers.assertResultErr(result, ErrorCode.FILE_NOT_FOUND);
});
});
describe('create', () => {
it('should create and return new file', async () => {
const userId = 'user-123';
const dto = {
name: 'test.txt',
mimeType: 'text/plain',
size: 1024,
storagePath: '/files/test.txt',
storageKey: 'key-123',
};
const createdFile = mockFileFactory.create({ ...dto, userId });
mockDb.mockResults([createdFile]);
const result = await service.create(userId, dto);
const file = assertHelpers.assertResultOk(result);
expect(file.name).toBe(dto.name);
expect(mockDb.insert).toHaveBeenCalled();
});
it('should return validation error for empty name', async () => {
const result = await service.create('user-123', {
name: '',
mimeType: 'text/plain',
size: 100,
storagePath: '/test',
storageKey: 'key',
});
assertHelpers.assertResultErr(result, ErrorCode.MISSING_REQUIRED_FIELD);
});
});
});
Controller Tests
// src/files/file.controller.spec.ts
import { Test, TestingModule } from '@nestjs/testing';
import { FileController } from './file.controller';
import { FileService } from './file.service';
import { JwtAuthGuard } from '@manacore/shared-nestjs-auth';
import { mockFileFactory } from '../__tests__/utils';
import { ok, err, ErrorCode, AppException } from '@manacore/shared-errors';
describe('FileController', () => {
let controller: FileController;
let fileService: jest.Mocked<FileService>;
const mockUser = { userId: 'user-123', email: 'test@example.com', role: 'user' };
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
controllers: [FileController],
providers: [
{
provide: FileService,
useValue: {
findById: jest.fn(),
create: jest.fn(),
delete: jest.fn(),
},
},
],
})
.overrideGuard(JwtAuthGuard)
.useValue({ canActivate: () => true })
.compile();
controller = module.get<FileController>(FileController);
fileService = module.get(FileService) as jest.Mocked<FileService>;
});
describe('GET /files/:id', () => {
it('should return file when found', async () => {
const mockFile = mockFileFactory.create();
fileService.findById.mockResolvedValue(ok(mockFile));
const result = await controller.getFile(mockFile.id, mockUser);
expect(result.file).toEqual(mockFile);
expect(fileService.findById).toHaveBeenCalledWith(mockFile.id, mockUser.userId);
});
it('should throw AppException when file not found', async () => {
fileService.findById.mockResolvedValue(
err(ErrorCode.FILE_NOT_FOUND, 'File not found')
);
await expect(controller.getFile('non-existent', mockUser))
.rejects
.toThrow(AppException);
});
});
describe('Guards', () => {
it('should have JwtAuthGuard applied', () => {
const guards = Reflect.getMetadata('__guards__', FileController);
expect(guards).toContain(JwtAuthGuard);
});
});
});
Vitest (SvelteKit) Tests
Store Tests
// src/lib/stores/files.test.ts
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { fileStore } from './files.svelte';
import { api } from '$lib/api/client';
vi.mock('$lib/api/client', () => ({
api: {
files: {
list: vi.fn(),
delete: vi.fn(),
},
},
}));
describe('fileStore', () => {
beforeEach(() => {
vi.clearAllMocks();
fileStore.reset();
});
it('should load files successfully', async () => {
const mockFiles = [
{ id: '1', name: 'file1.txt' },
{ id: '2', name: 'file2.txt' },
];
vi.mocked(api.files.list).mockResolvedValue({ ok: true, data: mockFiles });
await fileStore.loadFiles();
expect(fileStore.files).toEqual(mockFiles);
expect(fileStore.loading).toBe(false);
expect(fileStore.error).toBeNull();
});
it('should handle load error', async () => {
vi.mocked(api.files.list).mockResolvedValue({
ok: false,
error: { code: 'ERR_7001', message: 'Database error' },
});
await fileStore.loadFiles();
expect(fileStore.files).toEqual([]);
expect(fileStore.error).toBe('Database error');
});
it('should remove file from list after delete', async () => {
fileStore.files = [
{ id: '1', name: 'file1.txt' },
{ id: '2', name: 'file2.txt' },
];
vi.mocked(api.files.delete).mockResolvedValue({ ok: true, data: undefined });
await fileStore.deleteFile('1');
expect(fileStore.files).toHaveLength(1);
expect(fileStore.files[0].id).toBe('2');
});
});
Component Tests
// src/lib/components/FileItem.test.ts
import { describe, it, expect, vi } from 'vitest';
import { render, screen, fireEvent } from '@testing-library/svelte';
import FileItem from './FileItem.svelte';
describe('FileItem', () => {
const mockFile = {
id: '1',
name: 'document.pdf',
size: 1024,
mimeType: 'application/pdf',
createdAt: new Date('2024-01-01'),
};
it('should render file name', () => {
render(FileItem, { props: { file: mockFile } });
expect(screen.getByText('document.pdf')).toBeInTheDocument();
});
it('should format file size', () => {
render(FileItem, { props: { file: mockFile } });
expect(screen.getByText('1 KB')).toBeInTheDocument();
});
it('should call onDelete when delete button clicked', async () => {
const onDelete = vi.fn();
render(FileItem, { props: { file: mockFile, onDelete } });
const deleteButton = screen.getByRole('button', { name: /delete/i });
await fireEvent.click(deleteButton);
expect(onDelete).toHaveBeenCalledWith(mockFile.id);
});
});
E2E Tests (Playwright)
Configuration
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
testDir: './e2e',
fullyParallel: true,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,
workers: process.env.CI ? 1 : undefined,
use: {
baseURL: 'http://localhost:5173',
trace: 'on-first-retry',
screenshot: 'only-on-failure',
},
projects: [
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
{ name: 'Mobile Safari', use: { ...devices['iPhone 12'] } },
],
webServer: {
command: 'pnpm run build && pnpm run preview',
port: 5173,
reuseExistingServer: !process.env.CI,
},
});
E2E Test Example
// e2e/file-upload.spec.ts
import { test, expect } from '@playwright/test';
test.describe('File Upload', () => {
test.beforeEach(async ({ page }) => {
// Login before each test
await page.goto('/login');
await page.fill('[name="email"]', 'test@example.com');
await page.fill('[name="password"]', 'password123');
await page.click('button[type="submit"]');
await expect(page).toHaveURL('/files');
});
test('should upload a file successfully', async ({ page }) => {
// Open upload dialog
await page.click('button:has-text("Upload")');
// Select file
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles('./e2e/fixtures/test-file.txt');
// Wait for upload
await expect(page.getByText('test-file.txt')).toBeVisible();
await page.click('button:has-text("Upload")');
// Verify file appears in list
await expect(page.getByRole('listitem', { name: 'test-file.txt' })).toBeVisible();
});
test('should show error for oversized file', async ({ page }) => {
await page.click('button:has-text("Upload")');
const fileInput = page.locator('input[type="file"]');
await fileInput.setInputFiles('./e2e/fixtures/large-file.zip');
await expect(page.getByText(/file too large/i)).toBeVisible();
});
});
Running Tests
# Run all tests
pnpm test
# Run with coverage
pnpm test:cov
# Run specific project
pnpm --filter @storage/backend test
# Run in watch mode
pnpm test:watch
# Run E2E tests
pnpm test:e2e
# Run E2E in headed mode
pnpm test:e2e --headed
Best Practices
Do's
- Use factories for consistent test data
- Test behavior, not implementation
- One assertion per test when possible
- Clean up after each test (reset mocks, state)
- Use descriptive test names that explain expected behavior
Don'ts
- Don't test framework code - trust NestJS, Svelte, etc.
- Don't mock everything - integration tests are valuable
- Don't test private methods - test through public API
- Don't share state between tests - each test should be independent
- Don't write flaky tests - fix or remove them