# Testing Patterns **Analysis Date:** 2026-05-03 ## Test Framework **Runner:** - Vitest 4.1.5 - Config: `vitest.config.ts` at root - Node environment (not DOM) **Assertion Library:** - Vitest built-in `expect()` — no separate library **Run Commands:** ```bash npm test # Run all tests once (vitest run) npm run test:watch # Watch mode (vitest) npx tsc --noEmit --pretty # Type check (required, only safety net for most code) npm run build # Build check (turbopack) ``` ## Test File Organization **Location:** - Co-located with source files in `lib/services/` - Pattern: `service-name.test.ts` in same directory as `service-name.ts` - Tests in `lib/**/*.test.ts` only (configured in `vitest.config.ts`) **Coverage:** - **Fully tested:** `lib/services/analyzer/**/*.test.ts`, `lib/services/rmm/**/*.test.ts`, `lib/services/b2/**/*.test.ts` - **Partially tested:** `lib/services/analyzer/link-discovery.test.ts` (link discovery logic) - **Not tested:** Most of `app/api/`, all pages, forms, UI components, sync services, entity sync, webhooks **Important:** Most of the codebase has no tests — type-check is the only safety net. ## Test Structure **Suite Organization:** ```typescript import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; describe('FEATURE_NAME', () => { beforeEach(() => { // Setup per test }); afterEach(() => { // Cleanup per test }); it('should do something', () => { expect(result).toBe(expected); }); it('should handle edge case', async () => { const r = await someAsyncFunction(); expect(r.done).toBe(true); }); }); ``` **Patterns from actual tests:** *Test with mock setup* (from `lib/services/analyzer/link-discovery.test.ts`): ```typescript import { describe, it, expect, vi, beforeEach } from 'vitest'; import { discoverExplicitLinks } from './link-discovery'; vi.mock('@/lib/services/postgres-client', () => ({ default: { query: vi.fn(), }, })); import postgresClient from '@/lib/services/postgres-client'; const mockedQuery = postgresClient.query as unknown as ReturnType; describe('discoverExplicitLinks', () => { beforeEach(() => { mockedQuery.mockReset(); }); it('skips self-references', async () => { mockedQuery.mockResolvedValueOnce({ rowCount: 1, rows: [{ ticket_number: 'T20260428.0053', ... }], }); const r = await discoverExplicitLinks(bundle); expect(r.explicit).toHaveLength(1); }); }); ``` *Test with utility fixture helper* (from `lib/services/analyzer/link-discovery.test.ts`): ```typescript function bundle(partial: Partial = {}): RawTicketBundle { return { ticket: { id: 1, ticket_number: 'T20260430.0084', title: 'Master problem ticket — Hynes', // ... default fields ...partial, }, notes: [], time_entries: [], }; } it('parses refs from description', async () => { const b = bundle({ description: 'See T20260427.0142' }); // ... test logic }); ``` ## Mocking **Framework:** Vitest's `vi` object **Patterns:** *Mock entire module:* ```typescript vi.mock('@/lib/services/postgres-client', () => ({ default: { query: vi.fn(), }, })); import postgresClient from '@/lib/services/postgres-client'; const mockedQuery = postgresClient.query as unknown as ReturnType; ``` *Reset mocks between tests:* ```typescript beforeEach(() => { mockedQuery.mockReset(); // or vi.restoreAllMocks() for all mocks }); afterEach(() => { vi.restoreAllMocks(); }); ``` *Mock implementation:* ```typescript mockedQuery.mockImplementationOnce(async (_sql: string, params: unknown[]) => { const numbers = params[0] as string[]; return { rowCount: numbers.length, rows: numbers.map((n) => ({ ticket_number: n, title: 't', status_label: 'Open', })), }; }); ``` *Mock resolved value (for async):* ```typescript mockedQuery.mockResolvedValueOnce({ rowCount: 1, rows: [{ ticket_number: 'T20260428.0053', title: 'Issue', ... }], }); ``` *Spy on function:* ```typescript let findExistingSpy: ReturnType; beforeEach(() => { findExistingSpy = vi .spyOn(persistence, 'findExistingAnalysisByContentHash') .mockResolvedValue(null); }); afterEach(() => { findExistingSpy.mockRestore(); }); ``` *Stub globals:* ```typescript const realDate = Date; beforeEach(() => { const fixed = new Date('2026-05-02T20:00:00.000Z'); vi.stubGlobal( 'Date', class extends realDate { constructor(...args: unknown[]) { if (args.length === 0) { super(fixed.getTime()); } else { super(...(args as [any])); } } static now() { return fixed.getTime(); } } as unknown as DateConstructor ); }); afterEach(() => { vi.unstubAllGlobals(); }); ``` ## What to Mock **DO mock:** - Database queries (postgres-client) - External API clients (Autotask, IT Glue, etc.) - File I/O - Time-dependent operations (Date) - Long-running operations **DO NOT mock:** - Regular functions being tested - Utility functions (regex helpers, string transformers) - Type definitions ## Fixtures and Factories **Test Data Creation:** Use helper functions to build test fixtures: ```typescript // From link-discovery.test.ts function bundle(partial: Partial = {}): RawTicketBundle { return { ticket: { id: 1, ticket_number: 'T20260430.0084', title: 'Master problem ticket — Hynes', description: null, status: 1, status_label: 'New', // ... 30+ default fields ...partial, // Override with test-specific values }, notes: [], time_entries: [], }; } // Usage in test it('flags master-problem-ticket title', () => { const r = detectProblemTicket( bundle({ title: 'Master problem ticket — recurring degradation' }), false ); expect(r.isProblemTicket).toBe(true); }); ``` **JSON Fixtures:** - Load from files for large datasets: `readFileSync(resolve(__dirname, 'fixtures', 'T20260424.0045.input.json'), 'utf8')` - Example: `/opt/stacks/pulse/lib/services/analyzer/fixtures/` **Location:** Test fixtures live alongside test files in same directory ## Coverage **Requirements:** None enforced (no CI, local-only testing) **View Coverage:** Not configured **Note:** Tests exist for: - `lib/services/analyzer/` — 9 test files covering pipeline stages, link discovery, redaction, preprocessing - `lib/services/rmm/` — 3 test files (worker, target-resolver, registry scripts) - `lib/services/b2/` — 1 test file (presign URLs, crypto) - `lib/services/llm/` — 2 test files (LLM calls, pricing) Untested areas: All API routes, all pages, forms, UI components, sync services, webhooks ## Test Types **Unit Tests:** - Test individual functions in isolation - Mock external dependencies - Examples: `extractExplicitFromText()`, `OBJECT_KEY_REGEX`, `presignDownload()` **Integration Tests:** - Not separated from unit tests - Some tests validate full workflow (e.g., `discoverExplicitLinks` querying mock DB) **E2E Tests:** - Not present in codebase ## Common Patterns **Async Testing:** ```typescript it('resolves problem_ticket_id', async () => { mockedQuery.mockResolvedValueOnce({ rowCount: 1, rows: [{ ticket_number: 'T20260427.0142' }], }); const r = await discoverExplicitLinks(bundle); expect(r.explicit[0].ticket_number).toBe('T20260427.0142'); }); ``` **Error Testing:** ```typescript it('throws on invalid object key', () => { expect(() => presignDownload('../etc/eventlogs_1.json.gz', 600, config) ).toThrow(B2InvalidObjectKeyError); }); ``` **Regex Testing:** ```typescript describe('TICKET_NUMBER_REGEX', () => { it('matches canonical format', () => { const m = 'see T20260430.0084 and T20260427.0142'.match(TICKET_NUMBER_REGEX); expect(m).toEqual(['T20260430.0084', 'T20260427.0142']); }); it('does not match invalid lengths', () => { expect('T2026.0084'.match(TICKET_NUMBER_REGEX)).toBeNull(); }); }); ``` **Sequential Mock Queuing** (for LLM stages): ```typescript interface Reply { text: string; usage?: Partial; } function makeFakeAnthropic(queue: Reply[]): { fake: Anthropic; bodies: any[] } { const bodies: any[] = []; let i = 0; const create = vi.fn(async (body: any) => { bodies.push(body); const next = queue[i++]; if (!next) throw new Error('No more queued LLM replies'); return { id: `msg_${i}`, content: [{ type: 'text', text: next.text }], usage: { input_tokens: 5000, output_tokens: 500, ... }, } as Anthropic.Message; }); return { fake: { messages: { create } } as unknown as Anthropic, bodies }; } // Usage const { fake: anthropic, bodies } = makeFakeAnthropic([ { text: validTriage() }, { text: validSonnet() }, { text: validOpus(), usage: { ... } }, ]); ``` ## Accessing Internals for Testing **Pattern:** Modules export `_INTERNALS` object with functions/constants not otherwise exported: ```typescript // In source: lib/services/b2/client.ts export const _B2_INTERNALS = { deriveSigningKey, }; // In test: lib/services/b2/client.test.ts import { _B2_INTERNALS } from './client'; describe('deriveSigningKey', () => { it('produces a 32-byte HMAC-SHA256 chain', () => { const k = _B2_INTERNALS.deriveSigningKey('sec-fixture', '20260502', 'us-west-002', 's3'); expect(k.length).toBe(32); }); }); ``` Also: ```typescript // lib/services/analyzer/worker.ts export const _RMM_WORKER_INTERNALS = { extractResult, }; // lib/services/analyzer/worker.test.ts import { _RMM_WORKER_INTERNALS } from './worker'; describe('extractResult', () => { const { extractResult } = _RMM_WORKER_INTERNALS; it('returns done=false while jobStatus is running', () => { const r = extractResult({ jobStatus: 'running', stdOut: null }, 'dev-1'); expect(r.done).toBe(false); }); }); ``` ## Test Coverage Gaps **Untested areas (HIGH RISK):** | Component | Reason | Impact | |-----------|--------|--------| | `app/api/` all routes | No tests configured | New bugs undetected until runtime | | `app/` all pages | No tests | UI regressions undetected | | `components/` all | No tests | UI logic errors undetected | | `lib/services/entity-sync.ts` | No tests | Sync failures undetected; blocks on type-check | | `lib/services/sync-scheduler.ts` | No tests | Schedule logic errors undetected | | `lib/services/webhook-service.ts` | No tests | HMAC verification, webhook processing untested | | `lib/auth.ts`, `lib/auth-utils.ts` | No tests | Auth failures undetected until login attempt | | `lib/permissions.ts` | No tests | Permission checks untested | **Partially tested areas:** - `lib/services/analyzer/` — pipeline stages tested, worker tested, but integration edge cases may be missed - `lib/services/llm/` — pricing and call patterns tested, but provider-specific behavior not fully covered ## Running Tests Locally ```bash # All tests once npm test # Watch mode (rerun on file change) npm run test:watch # Type check (required before commit) npx tsc --noEmit --pretty # Build (catches more errors) npm run build ``` --- *Testing analysis: 2026-05-03*