test(12-04): add pax8-sync-service.test.ts for syncOrders + syncCompanyMatches

- First unit tests for Pax8SyncService, mocking postgresClient.query and
  pax8-company-matcher's matchPax8Companies (following
  pax8-company-matcher.test.ts's mocking discipline)
- Asserts INSERT INTO pax8_orders / pax8_order_items with companyId bound
  into pax8_company_id and amountDue into line_total
- Asserts resolveCostColumns' default (CONFIRM) mapping for both observed
  item types (subscription, one-time) per 12-02-SUMMARY.md
- Asserts child-then-parent tombstone ordering (pax8_order_items before
  pax8_orders)
- Asserts fullSync's entities include 'orders' and 'company_matches'

No TDD gate — plan is autonomous without a plan-level `type: tdd`
frontmatter; the implementation already existed from Tasks 1-2, so this
follows the same single-commit test-addition pattern established by
12-02-SUMMARY.md's Task 2.
This commit is contained in:
lorentz 2026-07-10 22:54:18 -04:00
parent 5f960601e3
commit 59294cee5e

View file

@ -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
});
});