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:
parent
691bb47a91
commit
ae44669b9e
1 changed files with 163 additions and 0 deletions
163
lib/services/pax8-company-matcher.test.ts
Normal file
163
lib/services/pax8-company-matcher.test.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue