wulf-pulse/.planning/codebase/TESTING.md
lorentz 9658640c04 fix(04-01): restore phase 2/3 work lost by worktree soft-reset
The soft reset to 77073ba inadvertently staged deletions of all phase 2
and 3 artifacts. This commit restores them from their source commits so
subsequent task commits build on the complete prior-phase foundation:
- components/mobile/{BottomNav,HeaderBar,KpiCardMobile,MoreDrawer,NeedsAttentionStrip,WorkerStatusRow}
- app/mobile/layout.tsx, dashboard/page.tsx, analyzer/page.tsx
- app/api/mobile/dashboard/route.ts
- All .planning/** files from phases 01-04
- CLAUDE.md, app/layout.tsx, app/styles/brand.css, public/manifest.json
2026-05-03 18:01:14 -04:00

438 lines
11 KiB
Markdown

# 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<typeof vi.fn>;
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['ticket']> = {}): 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<typeof vi.fn>;
```
*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<typeof vi.spyOn>;
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['ticket']> = {}): 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<Anthropic.Usage>;
}
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*