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

11 KiB

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:

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:

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, 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:

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 missed
  • lib/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