docs: map existing codebase
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
289
.planning/codebase/TESTING.md
Normal file
289
.planning/codebase/TESTING.md
Normal file
@@ -0,0 +1,289 @@
|
||||
# Testing Patterns
|
||||
|
||||
**Analysis Date:** 2026-02-26
|
||||
|
||||
## Test Framework
|
||||
|
||||
**Runner:**
|
||||
- Vitest 4.0.18
|
||||
- Config: `vitest.config.ts`
|
||||
- Environment: `node` (no jsdom — tests are server-side only)
|
||||
- Globals: `true` — `describe`, `it`, `expect` available without imports (but explicit imports are used in practice)
|
||||
- `fileParallelism: false` — test files run sequentially
|
||||
- `pool: 'forks'` — each test file in isolated subprocess
|
||||
|
||||
**Assertion Library:**
|
||||
- Vitest built-in (`expect`)
|
||||
|
||||
**Path Aliases:**
|
||||
- `@/` resolves to `./src/` in test files (configured in `vitest.config.ts` via `resolve.alias`)
|
||||
|
||||
**Run Commands:**
|
||||
```bash
|
||||
npx vitest # Watch mode (all tests)
|
||||
npx vitest run # Run all tests once
|
||||
npx vitest run tests/unit/assignment-policy.test.ts # Single file
|
||||
npx vitest run -t 'test name' # Single test by name/pattern
|
||||
```
|
||||
|
||||
**Timeout:**
|
||||
- Default `testTimeout: 30000` (30 seconds) — allows for database operations
|
||||
|
||||
## Test File Organization
|
||||
|
||||
**Location:**
|
||||
- All tests live under `tests/` (not co-located with source files)
|
||||
- `tests/unit/` — pure-logic tests, no database
|
||||
- `tests/integration/` — database-backed tests using real Prisma client (currently `assignment-policy.test.ts` in both directories)
|
||||
- Setup: `tests/setup.ts`
|
||||
- Factories: `tests/helpers.ts`
|
||||
|
||||
**Naming:**
|
||||
- `{domain}.test.ts` — matches domain name: `assignment-policy.test.ts`, `round-engine.test.ts`
|
||||
- No `.spec.ts` files — exclusively `.test.ts`
|
||||
|
||||
**Structure:**
|
||||
```
|
||||
tests/
|
||||
├── setup.ts # Global test context, prisma client, createTestContext()
|
||||
├── helpers.ts # Test data factories (createTestUser, createTestRound, etc.)
|
||||
├── unit/
|
||||
│ └── assignment-policy.test.ts # Pure logic, no DB
|
||||
└── integration/
|
||||
└── assignment-policy.test.ts # DB-backed tests
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
**Suite Organization:**
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import type { CapMode } from '@prisma/client'
|
||||
import { resolveEffectiveCap } from '@/server/services/assignment-policy'
|
||||
|
||||
// ============================================================================
|
||||
// Section Title with box dividers
|
||||
// ============================================================================
|
||||
|
||||
describe('functionName', () => {
|
||||
it('returns expected value when condition', () => {
|
||||
const result = functionName(input)
|
||||
expect(result.value).toBe(expected)
|
||||
expect(result.source).toBe('system')
|
||||
})
|
||||
|
||||
describe('nested scenario group', () => {
|
||||
it('specific behavior', () => { ... })
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Helper/Stub Pattern:**
|
||||
```typescript
|
||||
// Builder functions at top of file construct minimal test objects
|
||||
function baseMemberContext(overrides: Partial<MemberContext> = {}): MemberContext {
|
||||
return {
|
||||
competition: {} as any,
|
||||
round: {} as any,
|
||||
member: { id: 'member-1', role: 'MEMBER', ... } as any,
|
||||
currentAssignmentCount: 0,
|
||||
...overrides,
|
||||
}
|
||||
}
|
||||
|
||||
function withJuryGroup(ctx: MemberContext, groupOverrides = {}): MemberContext {
|
||||
return { ...ctx, juryGroup: { id: 'jg-1', defaultMaxAssignments: 20, ...groupOverrides } as any }
|
||||
}
|
||||
```
|
||||
|
||||
**Patterns:**
|
||||
- Build minimal context objects inline — no heavy mocking frameworks
|
||||
- Use spread + override: `{ ...ctx, member: { ...ctx.member, maxAssignmentsOverride: 10 } }`
|
||||
- Assert on both value AND metadata: `expect(result.value).toBe(25)` + `expect(result.source).toBe('jury_group')`
|
||||
- Tests are descriptive: `'admin per-member override takes precedence over group default'`
|
||||
|
||||
## Mocking
|
||||
|
||||
**Framework:** None — unit tests avoid mocking entirely by testing pure functions.
|
||||
|
||||
**Approach:**
|
||||
- Unit tests pass plain JavaScript objects (`{} as any`) for unused dependencies
|
||||
- No `vi.mock()`, `vi.fn()`, or `vi.spyOn()` observed in current test files
|
||||
- Prisma is a real client connected to a test database (see integration tests)
|
||||
- tRPC context is constructed via `createTestContext(user)` — a plain object, not mocked
|
||||
|
||||
**What to Mock:**
|
||||
- External I/O (email, MinIO, OpenAI) — not currently tested; fire-and-forget pattern used
|
||||
- Anything not relevant to the assertion being made (`{} as any` for unused context fields)
|
||||
|
||||
**What NOT to Mock:**
|
||||
- Business logic functions under test
|
||||
- Prisma in integration tests — use real database with `DATABASE_URL_TEST`
|
||||
- The `createTestContext` / `createCaller` — these are lightweight stubs, not mocks
|
||||
|
||||
## Fixtures and Factories
|
||||
|
||||
**Test Data (from `tests/helpers.ts`):**
|
||||
```typescript
|
||||
// uid() creates unique prefixed IDs to avoid collisions
|
||||
export function uid(prefix = 'test'): string {
|
||||
return `${prefix}-${randomUUID().slice(0, 12)}`
|
||||
}
|
||||
|
||||
// Factories accept overrides for specific test scenarios
|
||||
export async function createTestUser(
|
||||
role: UserRole = 'JURY_MEMBER',
|
||||
overrides: Partial<{ email: string; name: string; ... }> = {}
|
||||
) {
|
||||
const id = uid('user')
|
||||
return prisma.user.create({
|
||||
data: {
|
||||
id,
|
||||
email: overrides.email ?? `${id}@test.local`,
|
||||
role,
|
||||
...
|
||||
}
|
||||
})
|
||||
}
|
||||
```
|
||||
|
||||
**Available Factories:**
|
||||
- `createTestUser(role, overrides)` — creates User in database
|
||||
- `createTestProgram(overrides)` — creates Program
|
||||
- `createTestCompetition(programId, overrides)` — creates Competition
|
||||
- `createTestRound(competitionId, overrides)` — creates Round (default: EVALUATION, ROUND_ACTIVE)
|
||||
- `createTestProject(programId, overrides)` — creates Project
|
||||
- `createTestProjectRoundState(projectId, roundId, overrides)` — creates ProjectRoundState
|
||||
- `createTestAssignment(userId, projectId, roundId, overrides)` — creates Assignment
|
||||
- `createTestEvaluation(assignmentId, formId, overrides)` — creates Evaluation
|
||||
- `createTestEvaluationForm(roundId, criteria)` — creates EvaluationForm
|
||||
- `createTestFilteringRule(roundId, overrides)` — creates FilteringRule
|
||||
- `createTestCOI(assignmentId, userId, projectId, hasConflict)` — creates ConflictOfInterest
|
||||
- `createTestCohort(roundId, overrides)` — creates Cohort
|
||||
- `createTestCohortProject(cohortId, projectId)` — creates CohortProject
|
||||
|
||||
**Location:**
|
||||
- Factories in `tests/helpers.ts`
|
||||
- Shared Prisma client in `tests/setup.ts`
|
||||
|
||||
## Coverage
|
||||
|
||||
**Requirements:** None enforced — no coverage thresholds configured.
|
||||
|
||||
**View Coverage:**
|
||||
```bash
|
||||
npx vitest run --coverage # Requires @vitest/coverage-v8 (not currently installed)
|
||||
```
|
||||
|
||||
## Test Types
|
||||
|
||||
**Unit Tests (`tests/unit/`):**
|
||||
- Scope: Pure business logic functions with no I/O
|
||||
- Approach: Construct in-memory objects, call function, assert return value
|
||||
- Examples: `assignment-policy.test.ts` tests `resolveEffectiveCap`, `evaluateAssignmentPolicy`
|
||||
- No database, no HTTP, no file system
|
||||
|
||||
**Integration Tests (`tests/integration/`):**
|
||||
- Scope: tRPC router procedures via `createCaller`
|
||||
- Approach: Create real database records → call procedure → assert DB state or return value → cleanup
|
||||
- Uses `DATABASE_URL_TEST` (or falls back to `DATABASE_URL`)
|
||||
- Sequential execution (`fileParallelism: false`) to avoid DB conflicts
|
||||
|
||||
**E2E Tests:**
|
||||
- Playwright configured (`@playwright/test` installed, `npm run test:e2e` script)
|
||||
- No test files found yet — framework is available but not implemented
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Integration Test Pattern (calling tRPC procedures):**
|
||||
```typescript
|
||||
import { describe, it, expect, afterAll } from 'vitest'
|
||||
import { prisma } from '../setup'
|
||||
import { createTestUser, createTestProgram, createTestCompetition, cleanupTestData, uid } from '../helpers'
|
||||
import { roundRouter } from '@/server/routers/round'
|
||||
|
||||
describe('round procedures', () => {
|
||||
let programId: string
|
||||
let adminUser: Awaited<ReturnType<typeof createTestUser>>
|
||||
|
||||
beforeAll(async () => {
|
||||
adminUser = await createTestUser('SUPER_ADMIN')
|
||||
const program = await createTestProgram()
|
||||
programId = program.id
|
||||
})
|
||||
|
||||
it('activates a round', async () => {
|
||||
const competition = await createTestCompetition(programId)
|
||||
const caller = createCaller(roundRouter, adminUser)
|
||||
const result = await caller.activate({ roundId: round.id })
|
||||
expect(result.status).toBe('ROUND_ACTIVE')
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await cleanupTestData(programId, [adminUser.id])
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Unit Test Pattern (pure logic):**
|
||||
```typescript
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { resolveEffectiveCap } from '@/server/services/assignment-policy'
|
||||
|
||||
describe('resolveEffectiveCap', () => {
|
||||
it('returns system default when no jury group', () => {
|
||||
const ctx = baseMemberContext() // local builder function
|
||||
const result = resolveEffectiveCap(ctx)
|
||||
expect(result.value).toBe(SYSTEM_DEFAULT_CAP)
|
||||
expect(result.source).toBe('system')
|
||||
})
|
||||
})
|
||||
```
|
||||
|
||||
**Async Testing:**
|
||||
```typescript
|
||||
it('creates evaluation', async () => {
|
||||
const result = await caller.evaluation.start({ assignmentId: assignment.id })
|
||||
expect(result.status).toBe('DRAFT')
|
||||
})
|
||||
```
|
||||
|
||||
**Error Testing:**
|
||||
```typescript
|
||||
it('throws FORBIDDEN when accessing others evaluation', async () => {
|
||||
const otherUser = await createTestUser('JURY_MEMBER')
|
||||
const caller = createCaller(evaluationRouter, otherUser)
|
||||
await expect(
|
||||
caller.get({ assignmentId: assignment.id })
|
||||
).rejects.toThrow('FORBIDDEN')
|
||||
})
|
||||
```
|
||||
|
||||
**Cleanup (afterAll):**
|
||||
```typescript
|
||||
afterAll(async () => {
|
||||
// Pass programId to cascade-delete competition data, plus explicit user IDs
|
||||
await cleanupTestData(programId, [adminUser.id, jurorUser.id])
|
||||
})
|
||||
```
|
||||
|
||||
## Test Infrastructure Details
|
||||
|
||||
**`createTestContext(user)`** in `tests/setup.ts`:
|
||||
- Builds a fake tRPC context matching `{ session: { user, expires }, prisma, ip, userAgent }`
|
||||
- `prisma` is the shared test client
|
||||
- Used internally by `createCaller`
|
||||
|
||||
**`createCaller(routerModule, user)`** in `tests/setup.ts`:
|
||||
- Shorthand: `const caller = createCaller(evaluationRouter, adminUser)`
|
||||
- Returns type-safe caller — procedures called as `await caller.procedureName(input)`
|
||||
- Import the router module directly, not `appRouter`
|
||||
|
||||
**Database Isolation:**
|
||||
- Tests share one database — isolation is by unique IDs (via `uid()`)
|
||||
- `cleanupTestData(programId)` does ordered deletion respecting FK constraints
|
||||
- Always call `cleanupTestData` in `afterAll`, never skip
|
||||
|
||||
---
|
||||
|
||||
*Testing analysis: 2026-02-26*
|
||||
Reference in New Issue
Block a user