/** * pax8-sync-service.ts unit tests — first tests for this service. * * postgresClient.query and pax8-company-matcher's matchPax8Companies are both * mocked (following pax8-company-matcher.test.ts's mocking discipline); no * real Postgres or PAX8 API calls are made. A mock Pax8Client is injected via * the constructor's optional `client` parameter. */ import { describe, it, expect, vi, beforeEach } from 'vitest'; // Mock postgresClient BEFORE importing the module under test. const queryMock = vi.fn(); vi.mock('@/lib/services/postgres-client', () => ({ default: { query: (...args: unknown[]) => queryMock(...args), }, })); // Mock the Plan 03 matcher so syncCompanyMatches() delegation can be asserted // without exercising real pg_trgm/Postgres logic. const matchPax8CompaniesMock = vi.fn(); vi.mock('./pax8-company-matcher', () => ({ matchPax8Companies: (...args: unknown[]) => matchPax8CompaniesMock(...args), })); // Import AFTER the mocks are declared so vi.mock hoisting takes effect. import { Pax8SyncService } from './pax8-sync-service'; import type { Pax8Client } from './pax8-client'; interface MockCall { sql: string; params: unknown[]; } function calls(): MockCall[] { return queryMock.mock.calls.map(([sql, params]) => ({ sql: String(sql), params: (params as unknown[]) ?? [], })); } /** * Two invoice headers, each with one item. item-1 (type 'subscription') * exercises the default/CONFIRM cost mapping with real live-verified sample * values from 12-02-SUMMARY.md; item-2 (type 'one-time') exercises a second * observed type — both are CONFIRM per 12-02-SUMMARY.md, so both are expected * to use the same default mapping (no DIVERGENCE type was recorded). */ function makeMockClient(): Pax8Client { const invoiceOneItems = [ { id: 'item-1', type: 'subscription', companyId: 'company-1', subscriptionId: 'sub-1', quantity: 9, sku: 'MST-NCE-103-C100', description: 'Microsoft 365 Business Premium', startPeriod: '2026-06-03', endPeriod: '2026-07-02', price: 26.4, amountDue: 199.58, cost: 22.176, costTotal: 199.58, productId: 'prod-1', currencyCode: 'USD', }, ]; const invoiceTwoItems = [ { id: 'item-2', type: 'one-time', companyId: 'company-2', subscriptionId: null, quantity: 1, sku: 'ONE-TIME-SKU', description: 'Setup fee', startPeriod: null, endPeriod: null, price: 0.02, amountDue: 0.81, cost: 0.0182, costTotal: 0.81, productId: 'prod-2', currencyCode: 'USD', }, ]; const client = { listAllCompanies: vi.fn().mockResolvedValue([]), listAllSubscriptions: vi.fn().mockResolvedValue([]), listAllProducts: vi.fn().mockResolvedValue([]), listAllInvoices: vi.fn().mockResolvedValue([ { id: 'inv-1', invoiceDate: '2026-06-01', total: 237.6, status: 'PAID', currencyCode: 'USD' }, { id: 'inv-2', invoiceDate: '2026-07-01', total: 0.81, status: 'PAID', currencyCode: 'USD' }, ]), listAllInvoiceItems: vi.fn((invoiceId: string) => { if (invoiceId === 'inv-1') return Promise.resolve(invoiceOneItems); if (invoiceId === 'inv-2') return Promise.resolve(invoiceTwoItems); return Promise.resolve([]); }), }; return client as unknown as Pax8Client; } describe('Pax8SyncService — syncOrders + syncCompanyMatches', () => { beforeEach(() => { queryMock.mockReset(); queryMock.mockResolvedValue({ rows: [], rowCount: 0 }); matchPax8CompaniesMock.mockReset(); matchPax8CompaniesMock.mockResolvedValue({ scanned: 2, autoLinked: 1, flaggedAmbiguous: 1, flaggedNoCandidate: 0, durationMs: 1, }); }); it('upserts invoice headers into pax8_orders and items into pax8_order_items, binding companyId into pax8_company_id and amountDue into line_total', async () => { const service = new Pax8SyncService(makeMockClient()); await service.fullSync('test'); const orderInserts = calls().filter((c) => /INSERT INTO pax8_orders/.test(c.sql)); expect(orderInserts).toHaveLength(2); expect(orderInserts.map((c) => c.params[0])).toEqual(['inv-1', 'inv-2']); // Header pax8_company_id stays NULL (Pitfall 1) — bound as the literal // NULL in the VALUES clause, not a parameter, so no company id param leaks // into the header insert's params array. expect(orderInserts[0].sql).toMatch(/VALUES \(\$1, NULL/); const itemInserts = calls().filter((c) => /INSERT INTO pax8_order_items/.test(c.sql)); expect(itemInserts).toHaveLength(2); const item1Insert = itemInserts.find((c) => c.params[0] === 'item-1'); expect(item1Insert).toBeDefined(); expect(item1Insert!.params).toContain('company-1'); // pax8_company_id expect(item1Insert!.params).toContain(199.58); // line_total <- amountDue (default mapping) }); it('tombstones unseen order_items before orders via id <> ALL($1::uuid[])', async () => { const service = new Pax8SyncService(makeMockClient()); await service.fullSync('test'); const itemTombstone = calls().find( (c) => /UPDATE pax8_order_items/.test(c.sql) && /id\s*<>\s*ALL/.test(c.sql) ); expect(itemTombstone).toBeDefined(); expect(itemTombstone!.params[0]).toEqual(['item-1', 'item-2']); const orderTombstone = calls().find( (c) => /UPDATE pax8_orders/.test(c.sql) && /id\s*<>\s*ALL/.test(c.sql) ); expect(orderTombstone).toBeDefined(); expect(orderTombstone!.params[0]).toEqual(['inv-1', 'inv-2']); // Child-then-parent ordering (FK safety). const allCalls = calls(); const itemTombstoneIndex = allCalls.findIndex( (c) => /UPDATE pax8_order_items/.test(c.sql) && /id\s*<>\s*ALL/.test(c.sql) ); const orderTombstoneIndex = allCalls.findIndex( (c) => /UPDATE pax8_orders/.test(c.sql) && /id\s*<>\s*ALL/.test(c.sql) ); expect(itemTombstoneIndex).toBeLessThan(orderTombstoneIndex); }); it('resolveCostColumns applies the CONFIRM default mapping for both observed item types (subscription, one-time)', async () => { const service = new Pax8SyncService(makeMockClient()); await service.fullSync('test'); const itemInserts = calls().filter((c) => /INSERT INTO pax8_order_items/.test(c.sql)); const item1Insert = itemInserts.find((c) => c.params[0] === 'item-1'); // subscription: unit_price<-price(26.4), line_total<-amountDue(199.58), // partner_cost<-cost(22.176), partner_cost_total<-costTotal(199.58). expect(item1Insert!.params).toEqual(expect.arrayContaining([26.4, 199.58, 22.176])); const item2Insert = itemInserts.find((c) => c.params[0] === 'item-2'); // one-time: unit_price<-price(0.02), line_total<-amountDue(0.81), // partner_cost<-cost(0.0182), partner_cost_total<-costTotal(0.81) — both // CONFIRM per 12-02-SUMMARY.md, no divergence recorded for either type. expect(item2Insert!.params).toEqual(expect.arrayContaining([0.02, 0.81, 0.0182])); }); it('syncCompanyMatches delegates to matchPax8Companies and reports entity company_matches', async () => { const service = new Pax8SyncService(makeMockClient()); const result = await service.fullSync('test'); expect(matchPax8CompaniesMock).toHaveBeenCalledTimes(1); const matchEntity = result.entities.find((e) => e.entity === 'company_matches'); expect(matchEntity).toBeDefined(); expect(matchEntity!.success).toBe(true); expect(matchEntity!.upserted).toBe(1); // autoLinked // No soft-deletes are performed by matching (CR-02) — tombstoned must // always be 0 so sync_history.records_deleted never fabricates a // "deleted" count for review-flagged companies. expect(matchEntity!.tombstoned).toBe(0); expect(matchEntity!.flaggedForReview).toBe(1); // flaggedAmbiguous + flaggedNoCandidate }); it("fullSync's entities array includes an 'orders' result and a 'company_matches' result", async () => { const service = new Pax8SyncService(makeMockClient()); const result = await service.fullSync('test'); const entityNames = result.entities.map((e) => e.entity); expect(entityNames).toContain('orders'); expect(entityNames).toContain('company_matches'); const ordersEntity = result.entities.find((e) => e.entity === 'orders'); expect(ordersEntity!.success).toBe(true); expect(ordersEntity!.upserted).toBe(4); // 2 orders + 2 items }); });