test(12-03): add pax8-company-matcher unit tests

- Mocks postgresClient.query (default export) per pax8-client.test.ts
  discipline, adapted from fetch mocking
- Covers all five decision branches: auto-link, review (below
  threshold), review (near-tie, D-02), empty candidates (D-03), and
  the idempotency guard (D-05/SC#4)
- Covers dryRun: asserts zero UPDATE/INSERT/DELETE calls issued
- npx vitest run lib/services/pax8-company-matcher.test.ts: 6/6 passed
This commit is contained in:
lorentz 2026-07-10 22:47:43 -04:00
parent 691bb47a91
commit ae44669b9e

View file

@ -0,0 +1,163 @@
/**
* pax8-company-matcher.ts unit tests.
*
* postgresClient.query is mocked similarity() runs in real Postgres and is
* never exercised here; these tests only assert on the SQL string + bound
* params issued for each decision branch (auto-link / review / empty
* candidates / idempotency guard / dryRun), matching the mocking discipline
* of lib/services/pax8-client.test.ts adapted from fetch to postgresClient.query.
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
// Mock postgresClient BEFORE importing the module under test — the matcher
// imports the default export.
const queryMock = vi.fn();
vi.mock('@/lib/services/postgres-client', () => ({
default: {
query: (...args: unknown[]) => queryMock(...args),
},
}));
// Import AFTER the mock is declared so vi.mock hoisting takes effect.
import { matchPax8Companies } from './pax8-company-matcher';
interface MockCall {
sql: string;
params: unknown[];
}
function calls(): MockCall[] {
return queryMock.mock.calls.map(([sql, params]) => ({
sql: String(sql),
params: (params as unknown[]) ?? [],
}));
}
/** Eligibility SELECT response — a single pax8_companies row under test. */
function eligibleRow(id: string, name: string) {
return { rows: [{ id, name }], rowCount: 1 };
}
/** findCandidates() response — score rows shaped like the real query output. */
function candidateRows(rows: Array<{ id: string; score: number }>) {
return {
rows: rows.map((r) => ({ id: r.id, score: r.score.toFixed(3) })),
rowCount: rows.length,
};
}
describe('matchPax8Companies', () => {
beforeEach(() => {
queryMock.mockReset();
});
it('auto-link: single candidate scoring 0.95 issues an UPDATE against pax8_companies and no review INSERT', async () => {
queryMock
.mockResolvedValueOnce(eligibleRow('p1', 'Acme Inc'))
.mockResolvedValueOnce(candidateRows([{ id: '10', score: 0.95 }]));
const result = await matchPax8Companies({ limit: 10 });
expect(result.autoLinked).toBe(1);
expect(result.flaggedAmbiguous).toBe(0);
expect(result.flaggedNoCandidate).toBe(0);
const applyLinkCall = calls().find((c) => /UPDATE pax8_companies/.test(c.sql) && /autotask_company_id\s*=\s*\$2/.test(c.sql));
expect(applyLinkCall).toBeDefined();
expect(applyLinkCall!.params).toEqual(['p1', 10, '0.950']);
const reviewInsert = calls().find((c) => /INSERT INTO pax8_company_match_review/.test(c.sql));
expect(reviewInsert).toBeUndefined();
});
it('review (below threshold): best candidate 0.80 issues an INSERT/UPSERT into pax8_company_match_review and no auto-link UPDATE writing autotask_company_id', async () => {
queryMock
.mockResolvedValueOnce(eligibleRow('p2', 'Widgets LLC'))
.mockResolvedValueOnce(candidateRows([{ id: '20', score: 0.8 }]));
const result = await matchPax8Companies({ limit: 10 });
expect(result.flaggedAmbiguous).toBe(1);
expect(result.autoLinked).toBe(0);
const reviewInsert = calls().find((c) => /INSERT INTO pax8_company_match_review/.test(c.sql));
expect(reviewInsert).toBeDefined();
expect(reviewInsert!.params[0]).toBe('p2');
expect(reviewInsert!.params[1]).toEqual([20]);
const autoLinkUpdate = calls().find(
(c) => /UPDATE pax8_companies/.test(c.sql) && /autotask_company_id\s*=\s*\$2/.test(c.sql)
);
expect(autoLinkUpdate).toBeUndefined();
});
it('review (near-tie): candidates 0.95 and 0.92 force review even though the top score clears 0.90 (D-02)', async () => {
queryMock
.mockResolvedValueOnce(eligibleRow('p3', 'Contoso Corp'))
.mockResolvedValueOnce(
candidateRows([
{ id: '30', score: 0.95 },
{ id: '31', score: 0.92 },
])
);
const result = await matchPax8Companies({ limit: 10 });
expect(result.flaggedAmbiguous).toBe(1);
expect(result.autoLinked).toBe(0);
const reviewInsert = calls().find((c) => /INSERT INTO pax8_company_match_review/.test(c.sql));
expect(reviewInsert).toBeDefined();
expect(reviewInsert!.params[1]).toEqual([30, 31]);
const autoLinkUpdate = calls().find(
(c) => /UPDATE pax8_companies/.test(c.sql) && /autotask_company_id\s*=\s*\$2/.test(c.sql)
);
expect(autoLinkUpdate).toBeUndefined();
});
it('empty candidates: zero rows above the floor produce a review row with an empty candidate_company_ids array (D-03)', async () => {
queryMock
.mockResolvedValueOnce(eligibleRow('p4', 'No Match Co'))
.mockResolvedValueOnce(candidateRows([]));
const result = await matchPax8Companies({ limit: 10 });
expect(result.flaggedNoCandidate).toBe(1);
const reviewInsert = calls().find((c) => /INSERT INTO pax8_company_match_review/.test(c.sql));
expect(reviewInsert).toBeDefined();
expect(reviewInsert!.params[1]).toEqual([]);
expect(reviewInsert!.params[2]).toEqual([]);
});
it('idempotent (D-05/SC#4): the applyLink UPDATE includes the resolved_at guard and the manual-method guard; the eligibility SELECT excludes manually-resolved rows', async () => {
queryMock
.mockResolvedValueOnce(eligibleRow('p5', 'Idempotent Co'))
.mockResolvedValueOnce(candidateRows([{ id: '50', score: 0.99 }]));
await matchPax8Companies({ limit: 10 });
const eligibilitySelect = calls().find((c) => /FROM pax8_companies/.test(c.sql) && /SELECT/.test(c.sql));
expect(eligibilitySelect).toBeDefined();
expect(eligibilitySelect!.sql).toContain("match_method IS DISTINCT FROM 'manual'");
expect(eligibilitySelect!.sql).toContain('resolved_at IS NOT NULL');
const applyLinkCall = calls().find((c) => /UPDATE pax8_companies/.test(c.sql) && /autotask_company_id\s*=\s*\$2/.test(c.sql));
expect(applyLinkCall).toBeDefined();
expect(applyLinkCall!.sql).toContain('resolved_at IS NOT NULL');
expect(applyLinkCall!.sql).toContain("IS DISTINCT FROM 'manual'");
});
it('dryRun: no write queries (UPDATE/INSERT/DELETE) are issued when dryRun is true', async () => {
queryMock
.mockResolvedValueOnce(eligibleRow('p6', 'DryRun Co'))
.mockResolvedValueOnce(candidateRows([{ id: '60', score: 0.99 }]));
await matchPax8Companies({ limit: 10, dryRun: true });
const writeCalls = calls().filter((c) => /^\s*(UPDATE|INSERT|DELETE)/i.test(c.sql));
expect(writeCalls).toHaveLength(0);
});
});