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:
parent
5f960601e3
commit
59294cee5e
1 changed files with 212 additions and 0 deletions
212
lib/services/pax8-sync-service.test.ts
Normal file
212
lib/services/pax8-sync-service.test.ts
Normal 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
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue