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
11 KiB
Testing Patterns
Analysis Date: 2026-05-03
Test Framework
Runner:
- Vitest 4.1.5
- Config:
vitest.config.tsat root - Node environment (not DOM)
Assertion Library:
- Vitest built-in
expect()— no separate library
Run Commands:
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.tsin same directory asservice-name.ts - Tests in
lib/**/*.test.tsonly (configured invitest.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:
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):
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):
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:
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:
beforeEach(() => {
mockedQuery.mockReset();
// or vi.restoreAllMocks() for all mocks
});
afterEach(() => {
vi.restoreAllMocks();
});
Mock implementation:
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):
mockedQuery.mockResolvedValueOnce({
rowCount: 1,
rows: [{ ticket_number: 'T20260428.0053', title: 'Issue', ... }],
});
Spy on function:
let findExistingSpy: ReturnType<typeof vi.spyOn>;
beforeEach(() => {
findExistingSpy = vi
.spyOn(persistence, 'findExistingAnalysisByContentHash')
.mockResolvedValue(null);
});
afterEach(() => {
findExistingSpy.mockRestore();
});
Stub globals:
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:
// 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, preprocessinglib/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.,
discoverExplicitLinksquerying mock DB)
E2E Tests:
- Not present in codebase
Common Patterns
Async Testing:
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:
it('throws on invalid object key', () => {
expect(() =>
presignDownload('../etc/eventlogs_1.json.gz', 600, config)
).toThrow(B2InvalidObjectKeyError);
});
Regex Testing:
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):
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:
// 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:
// 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 missedlib/services/llm/— pricing and call patterns tested, but provider-specific behavior not fully covered
Running Tests Locally
# 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