# 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*
