diff --git a/lib/services/pax8-company-matcher.test.ts b/lib/services/pax8-company-matcher.test.ts new file mode 100644 index 0000000..9ad3152 --- /dev/null +++ b/lib/services/pax8-company-matcher.test.ts @@ -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); + }); +});