# 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*
