diff --git a/lib/services/pax8-sync-service.test.ts b/lib/services/pax8-sync-service.test.ts new file mode 100644 index 0000000..1dcb2b1 --- /dev/null +++ b/lib/services/pax8-sync-service.test.ts @@ -0,0 +1,212 @@ +/** + * 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 + expect(matchEntity!.tombstoned).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 + }); +});