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
438 lines
11 KiB
Markdown
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*
|