From b1c41e94789deb7d7851d7283d9267c4dee01ae5 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 2 Jul 2026 19:13:20 +0545 Subject: [PATCH 01/10] test(OUT-3950): add invoice.created happy-path integration tests for US and AU Co-Authored-By: Claude Opus 4.8 --- test/fixtures/invoiceCreated.webhook.ts | 37 ++++++++ test/helpers/constants.ts | 18 ++++ test/helpers/mocks.ts | 71 +++++++++++++- test/helpers/seed.ts | 55 ++++++++++- test/helpers/webhookTestSetup.ts | 26 ++++++ .../webhook/invoiceCreated/happyPath.test.ts | 92 +++++++++++++++++++ 6 files changed, 296 insertions(+), 3 deletions(-) create mode 100644 test/fixtures/invoiceCreated.webhook.ts create mode 100644 test/helpers/webhookTestSetup.ts create mode 100644 test/integration/webhook/invoiceCreated/happyPath.test.ts diff --git a/test/fixtures/invoiceCreated.webhook.ts b/test/fixtures/invoiceCreated.webhook.ts new file mode 100644 index 0000000..6add24a --- /dev/null +++ b/test/fixtures/invoiceCreated.webhook.ts @@ -0,0 +1,37 @@ +import { type InvoiceCreatedWebhookSchema, ValidWebhookEvent } from '@invoice-sync/types' +import { TEST_CLIENT, TEST_COMPANY, TEST_INVOICE } from '@test/helpers/constants' +import type { z } from 'zod' + +type InvoiceWebhookInput = z.input +type InvoiceData = InvoiceWebhookInput['data'] + +// Builds an invoice.created webhook payload for the client-billed happy path. +// Pass `dataOverrides` to vary a single case (status, collectionMethod, +// lineItems, taxAmount, etc.) without repeating the whole object. +export function buildInvoiceCreatedWebhook( + dataOverrides: Partial = {}, +): InvoiceWebhookInput { + return { + eventType: ValidWebhookEvent.InvoiceCreated, + data: { + clientId: TEST_CLIENT.id, + companyId: TEST_COMPANY.id, + collectionMethod: 'sendInvoice', + createdAt: '2026-07-02T00:00:00.000Z', + currency: 'USD', + dueDate: '2026-07-16T00:00:00.000Z', + fileUrl: 'https://example.test/invoice.pdf', + id: TEST_INVOICE.id, + lineItems: [{ amount: 10000, description: 'Consulting', quantity: 1 }], + memo: 'Thanks for your business', + number: TEST_INVOICE.number, + sentDate: '2026-07-02T00:00:00.000Z', + status: 'open', + taxAmount: 825, + taxPercentage: 8.25, + total: 10825, + updatedAt: '2026-07-02T00:00:00.000Z', + ...dataOverrides, + }, + } +} diff --git a/test/helpers/constants.ts b/test/helpers/constants.ts index 6a90005..5eba9da 100644 --- a/test/helpers/constants.ts +++ b/test/helpers/constants.ts @@ -23,3 +23,21 @@ export const TEST_XERO_ITEM = { id: '44444444-4444-4444-8444-444444444444', other: '99999999-9999-4999-8999-999999999999', } + +// The Copilot client an invoice is billed to (client-billed happy path) and its +// company. companyId must be a valid v4 uuid (InvoiceCreatedEventSchema.companyId). +export const TEST_CLIENT = { + id: '55555555-5555-4555-8555-555555555555', + email: 'client@example.test', + givenName: 'Test', + familyName: 'Client', +} +export const TEST_COMPANY = { id: '66666666-6666-4666-8666-666666666666' } + +// The Copilot invoice and the Xero entities it maps to. Xero ids are v4 uuids +// because synced_invoices.xeroInvoiceId / salesAccountId and synced_contacts.contactId +// are uuid columns. +export const TEST_INVOICE = { id: 'test-invoice-00000001', number: 'INV-0001' } +export const TEST_XERO_CONTACT = { id: '77777777-7777-4777-8777-777777777777' } +export const TEST_XERO_INVOICE = { id: '88888888-8888-4888-8888-888888888888' } +export const TEST_SALES_ACCOUNT = { id: 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa' } diff --git a/test/helpers/mocks.ts b/test/helpers/mocks.ts index 5604605..597b48b 100644 --- a/test/helpers/mocks.ts +++ b/test/helpers/mocks.ts @@ -1,4 +1,12 @@ -import { TEST_PORTAL, TEST_XERO_ITEM } from '@test/helpers/constants' +import { + TEST_CLIENT, + TEST_COMPANY, + TEST_PORTAL, + TEST_SALES_ACCOUNT, + TEST_XERO_CONTACT, + TEST_XERO_INVOICE, + TEST_XERO_ITEM, +} from '@test/helpers/constants' import { type Mock, vi } from 'vitest' import { CopilotAPI } from '@/lib/copilot/CopilotAPI' import XeroAPI from '@/lib/xero/XeroAPI' @@ -21,11 +29,22 @@ export function createMockCopilotAPI(overrides: CopilotAPIOverrides = {}) { workspaceId: TEST_PORTAL.id, internalUserId: TEST_PORTAL.internalUserId, }), + getClient: vi.fn().mockResolvedValue({ + id: TEST_CLIENT.id, + givenName: TEST_CLIENT.givenName, + familyName: TEST_CLIENT.familyName, + email: TEST_CLIENT.email, + companyIds: [TEST_COMPANY.id], + status: 'active', + avatarImageUrl: null, + fallbackColor: null, + createdAt: '2026-01-01T00:00:00.000Z', + }), ...overrides, } } -// Mocked XeroAPI. Defaults cover the product.created happy path: +// Mocked XeroAPI. Defaults cover the product.created and invoice.created happy paths: // - setTokenSet: no-op (called in the service constructor) // - getOrganisationCountryCode: a supported region, so no live call // - createItems: echoes the code and gives each item a unique uuid, like real @@ -45,6 +64,54 @@ export function createMockXeroAPI(overrides: XeroAPIOverrides = {}) { description: item.description, })), ), + // Item lookup for line-item mapping; empty so lines fall back to the copilot description. + getItems: vi.fn().mockResolvedValue([]), + // No pre-existing accounts, so the sales account is created on the region-default path. + getAccounts: vi.fn().mockResolvedValue([]), + createSalesAccount: vi.fn( + async (_tenantId: string, account: { code: string; name: string }) => ({ + accountID: TEST_SALES_ACCOUNT.id, + code: account.code, + name: account.name, + type: 'REVENUE', + status: 'ACTIVE', + enablePaymentsToAccount: true, + }), + ), + enablePaymentsForAccount: vi.fn().mockResolvedValue(undefined), + // No matching tax rate, so a region-specific rate is created. + getTaxRates: vi.fn().mockResolvedValue([]), + createTaxRate: vi.fn( + async ( + _tenantId: string, + taxRate: { name: string; reportTaxType?: string; taxComponents?: { rate: number }[] }, + ) => ({ + name: taxRate.name, + reportTaxType: taxRate.reportTaxType, + taxType: 'ASSEMBLYTAX', + effectiveRate: taxRate.taxComponents?.[0]?.rate ?? 0, + status: 'ACTIVE', + }), + ), + // New-contact path: no synced contact seeded, so createContact is what runs. + getContact: vi.fn().mockResolvedValue(undefined), + createContact: vi.fn( + async ( + _tenantId: string, + contact: { name: string; emailAddress?: string; firstName?: string; lastName?: string }, + ) => ({ + contactID: TEST_XERO_CONTACT.id, + name: contact.name, + emailAddress: contact.emailAddress, + firstName: contact.firstName, + lastName: contact.lastName, + }), + ), + createInvoice: vi.fn(async (_tenantId: string, invoice: { invoiceNumber?: string }) => ({ + invoiceID: TEST_XERO_INVOICE.id, + invoiceNumber: invoice.invoiceNumber, + status: 'AUTHORISED', + })), ...overrides, } } diff --git a/test/helpers/seed.ts b/test/helpers/seed.ts index b60b163..4009b32 100644 --- a/test/helpers/seed.ts +++ b/test/helpers/seed.ts @@ -1,8 +1,20 @@ -import { TEST_PORTAL, TEST_PRODUCT, TEST_TOKENS, TEST_XERO_ITEM } from '@test/helpers/constants' +import { + TEST_CLIENT, + TEST_INVOICE, + TEST_PORTAL, + TEST_PRODUCT, + TEST_SALES_ACCOUNT, + TEST_TOKENS, + TEST_XERO_CONTACT, + TEST_XERO_INVOICE, + TEST_XERO_ITEM, +} from '@test/helpers/constants' import type { InferInsertModel } from 'drizzle-orm' import type { TokenSet } from 'xero-node' import db from '@/db' import { settings } from '@/db/schema/settings.schema' +import { SyncedContactUserType, syncedContacts } from '@/db/schema/syncedContacts.schema' +import { syncedInvoices } from '@/db/schema/syncedInvoices.schema' import { syncedItems } from '@/db/schema/syncedItems.schema' import { xeroConnections } from '@/db/schema/xeroConnections.schema' @@ -81,3 +93,44 @@ export async function seedSyncedItem(overrides: SyncedItemOverrides = {}) { .returning() return row } + +type SyncedInvoiceOverrides = Partial> + +const baseSyncedInvoice: InferInsertModel = { + portalId: TEST_PORTAL.id, + tenantId: TEST_PORTAL.tenantId, + copilotInvoiceId: TEST_INVOICE.id, + xeroInvoiceId: TEST_XERO_INVOICE.id, + salesAccountId: TEST_SALES_ACCOUNT.id, + status: 'success', +} + +// Seeds a synced_invoices row. Defaults to a fully-synced ('success') invoice +// for idempotency tests; override `status`/ids as needed. +export async function seedSyncedInvoice(overrides: SyncedInvoiceOverrides = {}) { + const [row] = await db + .insert(syncedInvoices) + .values({ ...baseSyncedInvoice, ...overrides }) + .returning() + return row +} + +type SyncedContactOverrides = Partial> + +const baseSyncedContact: InferInsertModel = { + portalId: TEST_PORTAL.id, + tenantId: TEST_PORTAL.tenantId, + clientOrCompanyId: TEST_CLIENT.id, + userType: SyncedContactUserType.CLIENT, + contactId: TEST_XERO_CONTACT.id, +} + +// Seeds a synced_contacts row (client-billed by default). Available for the +// contact-reuse path; not used by the baseline new-contact tests. +export async function seedSyncedContact(overrides: SyncedContactOverrides = {}) { + const [row] = await db + .insert(syncedContacts) + .values({ ...baseSyncedContact, ...overrides }) + .returning() + return row +} diff --git a/test/helpers/webhookTestSetup.ts b/test/helpers/webhookTestSetup.ts new file mode 100644 index 0000000..8ef7912 --- /dev/null +++ b/test/helpers/webhookTestSetup.ts @@ -0,0 +1,26 @@ +import { installMockApis, type MockCopilotAPI, type MockXeroAPI } from '@test/helpers/mocks' +import { truncateAllTestTables } from '@test/helpers/testDb' +import { beforeEach } from 'vitest' + +type InstallOpts = Parameters[0] + +export interface WebhookTestHandle { + copilot: MockCopilotAPI + xero: MockXeroAPI +} + +// beforeEach for webhook integration tests: truncates the DB and installs fresh +// mocks. Returns a handle with the current test's copilot/xero mocks. +// `optsFactory` runs per test so overrides get fresh vi.fn()s. +export function setupWebhookTest(optsFactory?: () => InstallOpts): WebhookTestHandle { + const handle = {} as WebhookTestHandle + + beforeEach(async () => { + await truncateAllTestTables() + const { copilot, xero } = installMockApis(optsFactory?.()) + handle.copilot = copilot + handle.xero = xero + }) + + return handle +} diff --git a/test/integration/webhook/invoiceCreated/happyPath.test.ts b/test/integration/webhook/invoiceCreated/happyPath.test.ts new file mode 100644 index 0000000..6499c10 --- /dev/null +++ b/test/integration/webhook/invoiceCreated/happyPath.test.ts @@ -0,0 +1,92 @@ +import { buildInvoiceCreatedWebhook } from '@test/fixtures/invoiceCreated.webhook' +import { + TEST_CLIENT, + TEST_INVOICE, + TEST_PORTAL, + TEST_SALES_ACCOUNT, + TEST_XERO_CONTACT, + TEST_XERO_INVOICE, +} from '@test/helpers/constants' +import { seedConnectedPortal } from '@test/helpers/seed' +import { postWebhook } from '@test/helpers/webhook' +import { setupWebhookTest } from '@test/helpers/webhookTestSetup' +import { eq } from 'drizzle-orm' +import { describe, expect, it } from 'vitest' +import db from '@/db' +import { failedSyncs } from '@/db/schema/failedSyncs.schema' +import { syncedInvoices } from '@/db/schema/syncedInvoices.schema' +import { SyncEntityType, SyncEventType, SyncStatus, syncLogs } from '@/db/schema/syncLogs.schema' +import { ReportTaxType } from '@/lib/xero/region' + +// Region-specific expectations. Sales-account code and tax reportTaxType are the +// two observable US/AU differences in the invoice.created flow. +const REGIONS = [ + { countryCode: 'US', salesCode: '4000', reportTaxType: undefined }, + { countryCode: 'AU', salesCode: '9000', reportTaxType: ReportTaxType.OUTPUT }, +] as const + +describe.each(REGIONS)('POST /api/webhook — invoice.created [$countryCode]', (region) => { + const apis = setupWebhookTest() + + it('creates a Xero contact + invoice, maps synced_invoices, and logs success', async () => { + await seedConnectedPortal({ settings: { countryCode: region.countryCode } }) + + const res = await postWebhook(buildInvoiceCreatedWebhook()) + expect(res.status).toBe(200) + + // New contact created for the client (no synced contact seeded). + expect(apis.xero.createContact).toHaveBeenCalledTimes(1) + expect(apis.xero.getContact).not.toHaveBeenCalled() + + // Sales account created with the region's code. + expect(apis.xero.createSalesAccount).toHaveBeenCalledTimes(1) + expect(apis.xero.createSalesAccount.mock.calls[0][1]).toMatchObject({ code: region.salesCode }) + + // Tax rate created with the region's reportTaxType. + expect(apis.xero.createTaxRate).toHaveBeenCalledTimes(1) + expect(apis.xero.createTaxRate.mock.calls[0][1].reportTaxType).toBe(region.reportTaxType) + + // Invoice created once for the tenant, lines posted to the region's sales code. + expect(apis.xero.createInvoice).toHaveBeenCalledTimes(1) + const [tenantId, invoice] = apis.xero.createInvoice.mock.calls[0] + expect(tenantId).toBe(TEST_PORTAL.tenantId) + expect(invoice).toMatchObject({ + type: 'ACCREC', + status: 'AUTHORISED', + invoiceNumber: TEST_INVOICE.number, + contact: { contactID: TEST_XERO_CONTACT.id }, + }) + expect(invoice.lineItems).toHaveLength(1) + expect(invoice.lineItems[0].accountCode).toBe(region.salesCode) + + // synced_invoices row marked success and mapped to the Xero invoice. + const invoices = await db.select().from(syncedInvoices) + expect(invoices).toHaveLength(1) + expect(invoices[0]).toMatchObject({ + portalId: TEST_PORTAL.id, + tenantId: TEST_PORTAL.tenantId, + copilotInvoiceId: TEST_INVOICE.id, + xeroInvoiceId: TEST_XERO_INVOICE.id, + salesAccountId: TEST_SALES_ACCOUNT.id, + status: 'success', + }) + + // Success sync log written for the invoice. + const logs = await db.select().from(syncLogs).where(eq(syncLogs.copilotId, TEST_INVOICE.id)) + expect(logs).toHaveLength(1) + expect(logs[0]).toMatchObject({ + portalId: TEST_PORTAL.id, + tenantId: TEST_PORTAL.tenantId, + entityType: SyncEntityType.INVOICE, + eventType: SyncEventType.CREATED, + status: SyncStatus.SUCCESS, + xeroId: TEST_XERO_INVOICE.id, + invoiceNumber: TEST_INVOICE.number, + customerEmail: TEST_CLIENT.email, + customerName: `${TEST_CLIENT.givenName} ${TEST_CLIENT.familyName}`, + }) + + // No failure recorded on the happy path. + expect(await db.select().from(failedSyncs)).toHaveLength(0) + }) +}) From d8d4352927ef1b562605f27b8d591e87443d25e3 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 2 Jul 2026 19:13:22 +0545 Subject: [PATCH 02/10] test(OUT-3950): assert invoice.created skips tax rate when taxAmount is zero Co-Authored-By: Claude Opus 4.8 --- .../webhook/invoiceCreated/noTax.test.ts | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 test/integration/webhook/invoiceCreated/noTax.test.ts diff --git a/test/integration/webhook/invoiceCreated/noTax.test.ts b/test/integration/webhook/invoiceCreated/noTax.test.ts new file mode 100644 index 0000000..dc428c1 --- /dev/null +++ b/test/integration/webhook/invoiceCreated/noTax.test.ts @@ -0,0 +1,37 @@ +import { buildInvoiceCreatedWebhook } from '@test/fixtures/invoiceCreated.webhook' +import { TEST_INVOICE } from '@test/helpers/constants' +import { seedConnectedPortal } from '@test/helpers/seed' +import { postWebhook } from '@test/helpers/webhook' +import { setupWebhookTest } from '@test/helpers/webhookTestSetup' +import { eq } from 'drizzle-orm' +import { describe, expect, it } from 'vitest' +import db from '@/db' +import { syncedInvoices } from '@/db/schema/syncedInvoices.schema' +import { SyncStatus, syncLogs } from '@/db/schema/syncLogs.schema' + +const REGIONS = [{ countryCode: 'US' }, { countryCode: 'AU' }] as const + +describe.each(REGIONS)('POST /api/webhook — invoice.created no tax [$countryCode]', (region) => { + const apis = setupWebhookTest() + + it('creates the invoice without looking up or creating a tax rate', async () => { + await seedConnectedPortal({ settings: { countryCode: region.countryCode } }) + + const res = await postWebhook(buildInvoiceCreatedWebhook({ taxAmount: 0, taxPercentage: 0 })) + expect(res.status).toBe(200) + + // taxAmount falsy → tax rate path skipped entirely. + expect(apis.xero.getTaxRates).not.toHaveBeenCalled() + expect(apis.xero.createTaxRate).not.toHaveBeenCalled() + + // Invoice still created and marked success. + expect(apis.xero.createInvoice).toHaveBeenCalledTimes(1) + const invoices = await db.select().from(syncedInvoices) + expect(invoices).toHaveLength(1) + expect(invoices[0].status).toBe('success') + + const logs = await db.select().from(syncLogs).where(eq(syncLogs.copilotId, TEST_INVOICE.id)) + expect(logs).toHaveLength(1) + expect(logs[0].status).toBe(SyncStatus.SUCCESS) + }) +}) From 27bdc12cdd7335159575edf06c048331862c3a2b Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 2 Jul 2026 19:13:24 +0545 Subject: [PATCH 03/10] test(OUT-3950): assert invoice.created replay skips xero and adds no log Co-Authored-By: Claude Opus 4.8 --- .../invoiceCreated/idempotency.test.ts | 31 +++++++++++++++++++ 1 file changed, 31 insertions(+) create mode 100644 test/integration/webhook/invoiceCreated/idempotency.test.ts diff --git a/test/integration/webhook/invoiceCreated/idempotency.test.ts b/test/integration/webhook/invoiceCreated/idempotency.test.ts new file mode 100644 index 0000000..8803a47 --- /dev/null +++ b/test/integration/webhook/invoiceCreated/idempotency.test.ts @@ -0,0 +1,31 @@ +import { buildInvoiceCreatedWebhook } from '@test/fixtures/invoiceCreated.webhook' +import { TEST_INVOICE } from '@test/helpers/constants' +import { seedConnectedPortal, seedSyncedInvoice } from '@test/helpers/seed' +import { postWebhook } from '@test/helpers/webhook' +import { setupWebhookTest } from '@test/helpers/webhookTestSetup' +import { eq } from 'drizzle-orm' +import { describe, expect, it } from 'vitest' +import db from '@/db' +import { syncedInvoices } from '@/db/schema/syncedInvoices.schema' +import { syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — invoice.created idempotency', () => { + const apis = setupWebhookTest() + + it('skips Xero and writes no new log when the invoice is already synced', async () => { + await seedConnectedPortal() + await seedSyncedInvoice({ status: 'success' }) + + const res = await postWebhook(buildInvoiceCreatedWebhook()) + expect(res.status).toBe(200) + + // Already-synced record short-circuits before any Xero call. + expect(apis.xero.createInvoice).not.toHaveBeenCalled() + expect(apis.xero.createContact).not.toHaveBeenCalled() + + // Still exactly one invoice row, and no invoice.created sync log was added. + expect(await db.select().from(syncedInvoices)).toHaveLength(1) + const logs = await db.select().from(syncLogs).where(eq(syncLogs.copilotId, TEST_INVOICE.id)) + expect(logs).toHaveLength(0) + }) +}) From a05e5a9dd74d264addfb12e7ce06370e5175efa7 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 2 Jul 2026 19:13:26 +0545 Subject: [PATCH 04/10] test(OUT-3950): assert invoice.created skips draft and chargeAutomatically Co-Authored-By: Claude Opus 4.8 --- .../chargeAutomatically.test.ts | 25 +++++++++++++++++++ .../invoiceCreated/draftInvoice.test.ts | 24 ++++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 test/integration/webhook/invoiceCreated/chargeAutomatically.test.ts create mode 100644 test/integration/webhook/invoiceCreated/draftInvoice.test.ts diff --git a/test/integration/webhook/invoiceCreated/chargeAutomatically.test.ts b/test/integration/webhook/invoiceCreated/chargeAutomatically.test.ts new file mode 100644 index 0000000..f66ec6a --- /dev/null +++ b/test/integration/webhook/invoiceCreated/chargeAutomatically.test.ts @@ -0,0 +1,25 @@ +import { buildInvoiceCreatedWebhook } from '@test/fixtures/invoiceCreated.webhook' +import { seedConnectedPortal } from '@test/helpers/seed' +import { postWebhook } from '@test/helpers/webhook' +import { setupWebhookTest } from '@test/helpers/webhookTestSetup' +import { describe, expect, it } from 'vitest' +import db from '@/db' +import { failedSyncs } from '@/db/schema/failedSyncs.schema' +import { syncedInvoices } from '@/db/schema/syncedInvoices.schema' + +describe('POST /api/webhook — invoice.created chargeAutomatically', () => { + const apis = setupWebhookTest() + + it('skips a chargeAutomatically invoice without syncing', async () => { + await seedConnectedPortal() + + const res = await postWebhook( + buildInvoiceCreatedWebhook({ collectionMethod: 'chargeAutomatically' }), + ) + expect(res.status).toBe(200) + + expect(apis.xero.createInvoice).not.toHaveBeenCalled() + expect(await db.select().from(syncedInvoices)).toHaveLength(0) + expect(await db.select().from(failedSyncs)).toHaveLength(0) + }) +}) diff --git a/test/integration/webhook/invoiceCreated/draftInvoice.test.ts b/test/integration/webhook/invoiceCreated/draftInvoice.test.ts new file mode 100644 index 0000000..43b5e46 --- /dev/null +++ b/test/integration/webhook/invoiceCreated/draftInvoice.test.ts @@ -0,0 +1,24 @@ +import { buildInvoiceCreatedWebhook } from '@test/fixtures/invoiceCreated.webhook' +import { seedConnectedPortal } from '@test/helpers/seed' +import { postWebhook } from '@test/helpers/webhook' +import { setupWebhookTest } from '@test/helpers/webhookTestSetup' +import { describe, expect, it } from 'vitest' +import db from '@/db' +import { failedSyncs } from '@/db/schema/failedSyncs.schema' +import { syncedInvoices } from '@/db/schema/syncedInvoices.schema' + +describe('POST /api/webhook — invoice.created draft', () => { + const apis = setupWebhookTest() + + it('acks a draft invoice without syncing or recording a failure', async () => { + await seedConnectedPortal() + + const res = await postWebhook(buildInvoiceCreatedWebhook({ status: 'draft' })) + expect(res.status).toBe(200) + + expect(apis.xero.createInvoice).not.toHaveBeenCalled() + expect(await db.select().from(syncedInvoices)).toHaveLength(0) + // Draft is ignored via APIError(OK) — not a failure. + expect(await db.select().from(failedSyncs)).toHaveLength(0) + }) +}) From 83093c3c65d618b6905edcc1ba42665e57adf590 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 2 Jul 2026 19:13:28 +0545 Subject: [PATCH 05/10] test(OUT-3950): assert invoice.created with no line items stays pending Co-Authored-By: Claude Opus 4.8 --- .../invoiceCreated/noLineItems.test.ts | 33 +++++++++++++++++++ 1 file changed, 33 insertions(+) create mode 100644 test/integration/webhook/invoiceCreated/noLineItems.test.ts diff --git a/test/integration/webhook/invoiceCreated/noLineItems.test.ts b/test/integration/webhook/invoiceCreated/noLineItems.test.ts new file mode 100644 index 0000000..e102bd5 --- /dev/null +++ b/test/integration/webhook/invoiceCreated/noLineItems.test.ts @@ -0,0 +1,33 @@ +import { buildInvoiceCreatedWebhook } from '@test/fixtures/invoiceCreated.webhook' +import { TEST_INVOICE } from '@test/helpers/constants' +import { seedConnectedPortal } from '@test/helpers/seed' +import { postWebhook } from '@test/helpers/webhook' +import { setupWebhookTest } from '@test/helpers/webhookTestSetup' +import { eq } from 'drizzle-orm' +import { describe, expect, it } from 'vitest' +import db from '@/db' +import { failedSyncs } from '@/db/schema/failedSyncs.schema' +import { syncedInvoices } from '@/db/schema/syncedInvoices.schema' +import { syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — invoice.created no line items', () => { + const apis = setupWebhookTest() + + it('leaves the invoice pending without creating it in Xero', async () => { + await seedConnectedPortal() + + const res = await postWebhook(buildInvoiceCreatedWebhook({ lineItems: [] })) + expect(res.status).toBe(200) + + expect(apis.xero.createInvoice).not.toHaveBeenCalled() + + // A pending record is created; no sync log; not a failure. + const invoices = await db.select().from(syncedInvoices) + expect(invoices).toHaveLength(1) + expect(invoices[0]).toMatchObject({ copilotInvoiceId: TEST_INVOICE.id, status: 'pending' }) + + const logs = await db.select().from(syncLogs).where(eq(syncLogs.copilotId, TEST_INVOICE.id)) + expect(logs).toHaveLength(0) + expect(await db.select().from(failedSyncs)).toHaveLength(0) + }) +}) From a46aca733d930f78ef73d86ed34be78bc425ba9c Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 2 Jul 2026 19:13:30 +0545 Subject: [PATCH 06/10] test(OUT-3950): assert invoice.created acks unsupported region and disabled sync Co-Authored-By: Claude Opus 4.8 --- .../invoiceCreated/syncDisabled.test.ts | 23 +++++++++++++++++++ .../invoiceCreated/unsupportedRegion.test.ts | 22 ++++++++++++++++++ 2 files changed, 45 insertions(+) create mode 100644 test/integration/webhook/invoiceCreated/syncDisabled.test.ts create mode 100644 test/integration/webhook/invoiceCreated/unsupportedRegion.test.ts diff --git a/test/integration/webhook/invoiceCreated/syncDisabled.test.ts b/test/integration/webhook/invoiceCreated/syncDisabled.test.ts new file mode 100644 index 0000000..3847f3d --- /dev/null +++ b/test/integration/webhook/invoiceCreated/syncDisabled.test.ts @@ -0,0 +1,23 @@ +import { buildInvoiceCreatedWebhook } from '@test/fixtures/invoiceCreated.webhook' +import { seedConnectedPortal } from '@test/helpers/seed' +import { postWebhook } from '@test/helpers/webhook' +import { setupWebhookTest } from '@test/helpers/webhookTestSetup' +import { describe, expect, it } from 'vitest' +import db from '@/db' +import { syncedInvoices } from '@/db/schema/syncedInvoices.schema' + +describe('POST /api/webhook — invoice.created sync disabled', () => { + const apis = setupWebhookTest() + + it('short-circuits at the controller when sync is disabled', async () => { + await seedConnectedPortal({ settings: { isSyncEnabled: false } }) + + const res = await postWebhook(buildInvoiceCreatedWebhook()) + expect(res.status).toBe(200) + expect(await res.json()).toMatchObject({ message: 'Sync is disabled for this workspace' }) + + // Handler never runs. + expect(apis.xero.createInvoice).not.toHaveBeenCalled() + expect(await db.select().from(syncedInvoices)).toHaveLength(0) + }) +}) diff --git a/test/integration/webhook/invoiceCreated/unsupportedRegion.test.ts b/test/integration/webhook/invoiceCreated/unsupportedRegion.test.ts new file mode 100644 index 0000000..8f44e8d --- /dev/null +++ b/test/integration/webhook/invoiceCreated/unsupportedRegion.test.ts @@ -0,0 +1,22 @@ +import { buildInvoiceCreatedWebhook } from '@test/fixtures/invoiceCreated.webhook' +import { seedConnectedPortal } from '@test/helpers/seed' +import { postWebhook } from '@test/helpers/webhook' +import { setupWebhookTest } from '@test/helpers/webhookTestSetup' +import { describe, expect, it } from 'vitest' +import db from '@/db' +import { syncedInvoices } from '@/db/schema/syncedInvoices.schema' + +describe('POST /api/webhook — invoice.created unsupported region', () => { + const apis = setupWebhookTest() + + it('acks and skips syncing when the Xero region is unsupported', async () => { + // GB is not in SUPPORTED_COUNTRIES → getRegionConfig() returns null. + await seedConnectedPortal({ settings: { countryCode: 'GB' } }) + + const res = await postWebhook(buildInvoiceCreatedWebhook()) + expect(res.status).toBe(200) + + expect(apis.xero.createInvoice).not.toHaveBeenCalled() + expect(await db.select().from(syncedInvoices)).toHaveLength(0) + }) +}) From 3694bff646e3a976b363b46ab661a22e66910acb Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 2 Jul 2026 19:13:32 +0545 Subject: [PATCH 07/10] test(OUT-3950): assert invoice.created createInvoice failure records failed_syncs Co-Authored-By: Claude Opus 4.8 --- .../xeroCreateInvoiceFails.test.ts | 50 +++++++++++++++++++ 1 file changed, 50 insertions(+) create mode 100644 test/integration/webhook/invoiceCreated/xeroCreateInvoiceFails.test.ts diff --git a/test/integration/webhook/invoiceCreated/xeroCreateInvoiceFails.test.ts b/test/integration/webhook/invoiceCreated/xeroCreateInvoiceFails.test.ts new file mode 100644 index 0000000..a77daae --- /dev/null +++ b/test/integration/webhook/invoiceCreated/xeroCreateInvoiceFails.test.ts @@ -0,0 +1,50 @@ +import { ValidWebhookEvent } from '@invoice-sync/types' +import { buildInvoiceCreatedWebhook } from '@test/fixtures/invoiceCreated.webhook' +import { TEST_INVOICE } from '@test/helpers/constants' +import { createMockXeroAPI } from '@test/helpers/mocks' +import { seedConnectedPortal } from '@test/helpers/seed' +import { postWebhook } from '@test/helpers/webhook' +import { setupWebhookTest } from '@test/helpers/webhookTestSetup' +import { eq } from 'drizzle-orm' +import { describe, expect, it, vi } from 'vitest' +import db from '@/db' +import { failedSyncs } from '@/db/schema/failedSyncs.schema' +import { syncedInvoices } from '@/db/schema/syncedInvoices.schema' +import { SyncStatus, syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — invoice.created xero failure', () => { + // Override createInvoice to throw; a fresh factory per test keeps the mock isolated. + const apis = setupWebhookTest(() => ({ + xero: createMockXeroAPI({ + createInvoice: vi.fn().mockRejectedValue(new Error('Xero 500: invoice rejected')), + }), + })) + + it('records failure in synced_invoices, sync_logs and failed_syncs, and returns 500', async () => { + await seedConnectedPortal() + + const res = await postWebhook(buildInvoiceCreatedWebhook()) + expect(res.status).toBe(500) + + expect(apis.xero.createInvoice).toHaveBeenCalledTimes(1) + + // Invoice record marked failed, not mapped to a Xero id. + const invoices = await db.select().from(syncedInvoices) + expect(invoices).toHaveLength(1) + expect(invoices[0]).toMatchObject({ copilotInvoiceId: TEST_INVOICE.id, status: 'failed' }) + expect(invoices[0].xeroInvoiceId).toBeNull() + + // A failed sync log is written for the invoice. + const logs = await db.select().from(syncLogs).where(eq(syncLogs.copilotId, TEST_INVOICE.id)) + expect(logs).toHaveLength(1) + expect(logs[0].status).toBe(SyncStatus.FAILED) + + // A failed_syncs row is recorded for retry. + const failed = await db.select().from(failedSyncs) + expect(failed).toHaveLength(1) + expect(failed[0]).toMatchObject({ + type: ValidWebhookEvent.InvoiceCreated, + resourceId: TEST_INVOICE.id, + }) + }) +}) From 2ed042f4fa4ef137efc276d6d86262076a6892be Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 2 Jul 2026 19:16:15 +0545 Subject: [PATCH 08/10] ci(OUT-3950): run integration tests on PRs, gated behind lint and type-check MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Integration tests run only on pull_request (not arbitrary pushes) and via needs:[run-linter, type-check], so a lint/type error fails fast without spinning up Docker + Testcontainers. Workflow triggers on PRs to main and main pushes to avoid duplicate runs. No secrets needed — .env.test is committed and globalSetup provisions the DB. Co-Authored-By: Claude Opus 4.8 --- .github/workflows/code-quality.yml | 44 +++++++++++++++++++++++++++++- 1 file changed, 43 insertions(+), 1 deletion(-) diff --git a/.github/workflows/code-quality.yml b/.github/workflows/code-quality.yml index 3753486..47e6b17 100644 --- a/.github/workflows/code-quality.yml +++ b/.github/workflows/code-quality.yml @@ -1,6 +1,12 @@ name: Code Quality Checks -on: push +# Run on PRs to main and on pushes to main (post-merge sanity). Feature-branch +# pushes without a PR don't trigger CI, so nothing double-fires. +on: + pull_request: + branches: [main] + push: + branches: [main] permissions: contents: read @@ -78,3 +84,39 @@ jobs: - name: Type check run: pnpm typecheck + + integration-tests: + name: Integration tests + # Only on PRs, and only after the cheap static checks pass — so a lint/type + # error fails fast without spinning up Docker + Testcontainers. + if: github.event_name == 'pull_request' + needs: [run-linter, type-check] + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Check out repository + uses: actions/checkout@v4 + + - name: Set up pnpm + uses: pnpm/action-setup@v4 + with: + version: 10.15 + run_install: false + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: '22' + cache: pnpm + cache-dependency-path: | + pnpm-lock.yaml + **/pnpm-lock.yaml + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + # Testcontainers starts an ephemeral Postgres via Docker, preinstalled on + # ubuntu-latest. globalSetup loads .env.test (committed, non-secret stubs) + # and sets DATABASE_URL from the container, so no secrets are needed here. + - name: Run integration tests + run: pnpm test From 00759654ffe2c6d5fa8265625c4008df34cd2cdf Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 2 Jul 2026 20:21:22 +0545 Subject: [PATCH 09/10] test(OUT-3950): make idempotency short-circuit assertions exhaustive Also assert createSalesAccount, getTaxRates and createTaxRate are not called, so a regression that reached the sync fan-out before the success short-circuit would be caught, not just createInvoice/createContact. Co-Authored-By: Claude Opus 4.8 --- test/integration/webhook/invoiceCreated/idempotency.test.ts | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/test/integration/webhook/invoiceCreated/idempotency.test.ts b/test/integration/webhook/invoiceCreated/idempotency.test.ts index 8803a47..f5a1f3f 100644 --- a/test/integration/webhook/invoiceCreated/idempotency.test.ts +++ b/test/integration/webhook/invoiceCreated/idempotency.test.ts @@ -19,9 +19,13 @@ describe('POST /api/webhook — invoice.created idempotency', () => { const res = await postWebhook(buildInvoiceCreatedWebhook()) expect(res.status).toBe(200) - // Already-synced record short-circuits before any Xero call. + // Already-synced record short-circuits before the whole sync fan-out, so no + // Xero call — contact, tax, account, or invoice — is reached. expect(apis.xero.createInvoice).not.toHaveBeenCalled() expect(apis.xero.createContact).not.toHaveBeenCalled() + expect(apis.xero.createSalesAccount).not.toHaveBeenCalled() + expect(apis.xero.getTaxRates).not.toHaveBeenCalled() + expect(apis.xero.createTaxRate).not.toHaveBeenCalled() // Still exactly one invoice row, and no invoice.created sync log was added. expect(await db.select().from(syncedInvoices)).toHaveLength(1) From cb58ea8fbdefc2e8176e41046ec47979e51fdac1 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Thu, 2 Jul 2026 20:29:55 +0545 Subject: [PATCH 10/10] refactor(OUT-3950): migrate product.created tests to shared setupWebhookTest setupWebhookTest and setupProductCreatedTest were character-for-character identical. Point the product.created tests at the canonical helper and delete the duplicate, so the shared beforeEach pattern lives in one place. Co-Authored-By: Claude Opus 4.8 --- test/helpers/productCreatedTestSetup.ts | 26 ------------------- .../webhook/productCreated/happyPath.test.ts | 4 +-- .../productCreated/idempotency.test.ts | 4 +-- .../productCreated/isSyncDisabled.test.ts | 4 +-- .../syncProductsAutomaticallyDisabled.test.ts | 4 +-- .../xeroCreateItemFails.test.ts | 4 +-- 6 files changed, 10 insertions(+), 36 deletions(-) delete mode 100644 test/helpers/productCreatedTestSetup.ts diff --git a/test/helpers/productCreatedTestSetup.ts b/test/helpers/productCreatedTestSetup.ts deleted file mode 100644 index c3cb156..0000000 --- a/test/helpers/productCreatedTestSetup.ts +++ /dev/null @@ -1,26 +0,0 @@ -import { installMockApis, type MockCopilotAPI, type MockXeroAPI } from '@test/helpers/mocks' -import { truncateAllTestTables } from '@test/helpers/testDb' -import { beforeEach } from 'vitest' - -type InstallOpts = Parameters[0] - -export interface ProductCreatedTestHandle { - copilot: MockCopilotAPI - xero: MockXeroAPI -} - -// beforeEach for product.created tests: truncates the DB and installs fresh -// mocks. Returns a handle with the current test's copilot/xero mocks. -// `optsFactory` runs per test so overrides get fresh vi.fn()s. -export function setupProductCreatedTest(optsFactory?: () => InstallOpts): ProductCreatedTestHandle { - const handle = {} as ProductCreatedTestHandle - - beforeEach(async () => { - await truncateAllTestTables() - const { copilot, xero } = installMockApis(optsFactory?.()) - handle.copilot = copilot - handle.xero = xero - }) - - return handle -} diff --git a/test/integration/webhook/productCreated/happyPath.test.ts b/test/integration/webhook/productCreated/happyPath.test.ts index 98b43c2..8abb4b4 100644 --- a/test/integration/webhook/productCreated/happyPath.test.ts +++ b/test/integration/webhook/productCreated/happyPath.test.ts @@ -1,8 +1,8 @@ import productCreatedPayload from '@test/fixtures/productCreated.webhook' import { TEST_PORTAL, TEST_PRODUCT, TEST_XERO_ITEM } from '@test/helpers/constants' -import { setupProductCreatedTest } from '@test/helpers/productCreatedTestSetup' import { seedConnectedPortal } from '@test/helpers/seed' import { postWebhook } from '@test/helpers/webhook' +import { setupWebhookTest } from '@test/helpers/webhookTestSetup' import { eq } from 'drizzle-orm' import { describe, expect, it } from 'vitest' import db from '@/db' @@ -11,7 +11,7 @@ import { syncedItems } from '@/db/schema/syncedItems.schema' import { SyncEntityType, SyncEventType, SyncStatus, syncLogs } from '@/db/schema/syncLogs.schema' describe('POST /api/webhook — product.created', () => { - const apis = setupProductCreatedTest() + const apis = setupWebhookTest() it('creates a Xero item, maps it in synced_items, and logs the sync as successful', async () => { await seedConnectedPortal() diff --git a/test/integration/webhook/productCreated/idempotency.test.ts b/test/integration/webhook/productCreated/idempotency.test.ts index 5607845..7c815a6 100644 --- a/test/integration/webhook/productCreated/idempotency.test.ts +++ b/test/integration/webhook/productCreated/idempotency.test.ts @@ -1,8 +1,8 @@ import productCreatedPayload from '@test/fixtures/productCreated.webhook' import { TEST_PRODUCT, TEST_XERO_ITEM } from '@test/helpers/constants' -import { setupProductCreatedTest } from '@test/helpers/productCreatedTestSetup' import { seedConnectedPortal, seedSyncedItem } from '@test/helpers/seed' import { postWebhook } from '@test/helpers/webhook' +import { setupWebhookTest } from '@test/helpers/webhookTestSetup' import { eq } from 'drizzle-orm' import { describe, expect, it } from 'vitest' import db from '@/db' @@ -14,7 +14,7 @@ import { syncLogs } from '@/db/schema/syncLogs.schema' // hits the pre-check that skips mapped products. The insert-time conflict branch // only fires on a real race, so it's left uncovered on purpose. describe('POST /api/webhook — product.created (already mapped)', () => { - const apis = setupProductCreatedTest() + const apis = setupWebhookTest() it('skips the Xero call and writes no new rows when the product is already mapped', async () => { await seedConnectedPortal() diff --git a/test/integration/webhook/productCreated/isSyncDisabled.test.ts b/test/integration/webhook/productCreated/isSyncDisabled.test.ts index 8c9c94a..29a118f 100644 --- a/test/integration/webhook/productCreated/isSyncDisabled.test.ts +++ b/test/integration/webhook/productCreated/isSyncDisabled.test.ts @@ -1,7 +1,7 @@ import productCreatedPayload from '@test/fixtures/productCreated.webhook' -import { setupProductCreatedTest } from '@test/helpers/productCreatedTestSetup' import { seedConnectedPortal } from '@test/helpers/seed' import { postWebhook } from '@test/helpers/webhook' +import { setupWebhookTest } from '@test/helpers/webhookTestSetup' import { describe, expect, it } from 'vitest' import db from '@/db' import { failedSyncs } from '@/db/schema/failedSyncs.schema' @@ -11,7 +11,7 @@ import { syncLogs } from '@/db/schema/syncLogs.schema' // Workspace sync is off, so the controller stops before dispatching the event. // This is a different gate from syncProductsAutomatically. describe('POST /api/webhook — product.created (isSyncEnabled=false)', () => { - const apis = setupProductCreatedTest() + const apis = setupWebhookTest() it('returns 200 without creating a Xero item or writing any rows', async () => { await seedConnectedPortal({ settings: { isSyncEnabled: false } }) diff --git a/test/integration/webhook/productCreated/syncProductsAutomaticallyDisabled.test.ts b/test/integration/webhook/productCreated/syncProductsAutomaticallyDisabled.test.ts index abc86fb..5a6b0b4 100644 --- a/test/integration/webhook/productCreated/syncProductsAutomaticallyDisabled.test.ts +++ b/test/integration/webhook/productCreated/syncProductsAutomaticallyDisabled.test.ts @@ -1,7 +1,7 @@ import productCreatedPayload from '@test/fixtures/productCreated.webhook' -import { setupProductCreatedTest } from '@test/helpers/productCreatedTestSetup' import { seedConnectedPortal } from '@test/helpers/seed' import { postWebhook } from '@test/helpers/webhook' +import { setupWebhookTest } from '@test/helpers/webhookTestSetup' import { describe, expect, it } from 'vitest' import db from '@/db' import { failedSyncs } from '@/db/schema/failedSyncs.schema' @@ -11,7 +11,7 @@ import { syncLogs } from '@/db/schema/syncLogs.schema' // Workspace sync is on, but automatic product sync is off, so the service skips // the product. See isSyncDisabled.test.ts for the workspace-level gate. describe('POST /api/webhook — product.created (syncProductsAutomatically=false)', () => { - const apis = setupProductCreatedTest() + const apis = setupWebhookTest() it('returns 200 without creating a Xero item or writing any rows', async () => { await seedConnectedPortal({ settings: { syncProductsAutomatically: false } }) diff --git a/test/integration/webhook/productCreated/xeroCreateItemFails.test.ts b/test/integration/webhook/productCreated/xeroCreateItemFails.test.ts index 5c4c7c2..3475341 100644 --- a/test/integration/webhook/productCreated/xeroCreateItemFails.test.ts +++ b/test/integration/webhook/productCreated/xeroCreateItemFails.test.ts @@ -1,9 +1,9 @@ import productCreatedPayload from '@test/fixtures/productCreated.webhook' import { TEST_PORTAL, TEST_PRODUCT } from '@test/helpers/constants' import { createMockXeroAPI } from '@test/helpers/mocks' -import { setupProductCreatedTest } from '@test/helpers/productCreatedTestSetup' import { seedConnectedPortal } from '@test/helpers/seed' import { postWebhook } from '@test/helpers/webhook' +import { setupWebhookTest } from '@test/helpers/webhookTestSetup' import { eq } from 'drizzle-orm' import { describe, expect, it, vi } from 'vitest' import db from '@/db' @@ -14,7 +14,7 @@ import { SyncEntityType, SyncEventType, SyncStatus, syncLogs } from '@/db/schema // createItems throws → no mapping row, but a FAILED sync log and a failed_syncs // record are written before the error is rethrown (500). describe('POST /api/webhook — product.created (Xero createItems fails)', () => { - const apis = setupProductCreatedTest(() => ({ + const apis = setupWebhookTest(() => ({ xero: createMockXeroAPI({ createItems: vi.fn().mockRejectedValue(new Error('Xero is on fire')), }),