- 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
163 lines
6.2 KiB
TypeScript
163 lines
6.2 KiB
TypeScript
/**
|
|
* 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);
|
|
});
|
|
});
|