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