From 9a6b51d5815116774a53d83507ca57d92b874306 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 14 Jul 2026 15:51:37 +0545 Subject: [PATCH 1/5] test(OUT-3955): add product.updated fixture, xero/copilot mock defaults, and item code constant Co-Authored-By: Claude Opus 4.8 --- test/fixtures/productUpdated.webhook.ts | 16 ++++++++++++++ test/helpers/constants.ts | 4 +++- test/helpers/mocks.ts | 28 +++++++++++++++++++++++++ 3 files changed, 47 insertions(+), 1 deletion(-) create mode 100644 test/fixtures/productUpdated.webhook.ts diff --git a/test/fixtures/productUpdated.webhook.ts b/test/fixtures/productUpdated.webhook.ts new file mode 100644 index 0000000..2cc6d94 --- /dev/null +++ b/test/fixtures/productUpdated.webhook.ts @@ -0,0 +1,16 @@ +import { type ProductUpdatedWebhookSchema, ValidWebhookEvent } from '@invoice-sync/types' +import { TEST_PRODUCT } from '@test/helpers/constants' +import type { z } from 'zod' + +// description carries inline HTML so the happy path proves htmlToText runs +// before the payload reaches Xero. +const productUpdatedPayload: z.input = { + eventType: ValidWebhookEvent.ProductUpdated, + data: { + id: TEST_PRODUCT.id, + name: 'Updated Product', + description: 'Updated description here', + }, +} + +export default productUpdatedPayload diff --git a/test/helpers/constants.ts b/test/helpers/constants.ts index db6e41d..09a8604 100644 --- a/test/helpers/constants.ts +++ b/test/helpers/constants.ts @@ -17,11 +17,13 @@ export const TEST_TOKENS = { } // A Copilot product and the Xero item it maps to. `other` is a second item id -// for asserting a pre-existing mapping is left untouched. +// for asserting a pre-existing mapping is left untouched. `code` is the Xero +// item code product.updated resends on every update. export const TEST_PRODUCT = { id: '33333333-3333-4333-8333-333333333333' } export const TEST_XERO_ITEM = { id: '44444444-4444-4444-8444-444444444444', other: '99999999-9999-4999-8999-999999999999', + code: 'TEST-ITEM-CODE', } // The Copilot client an invoice is billed to (client-billed happy path) and its diff --git a/test/helpers/mocks.ts b/test/helpers/mocks.ts index 81545e0..6e91015 100644 --- a/test/helpers/mocks.ts +++ b/test/helpers/mocks.ts @@ -5,6 +5,7 @@ import { TEST_EXPENSE_ACCOUNT, TEST_INVOICE, TEST_PORTAL, + TEST_PRODUCT, TEST_SALES_ACCOUNT, TEST_XERO_BANK_TXN, TEST_XERO_CONTACT, @@ -45,6 +46,10 @@ export function createMockCopilotAPI(overrides: CopilotAPIOverrides = {}) { fallbackColor: null, createdAt: '2026-01-01T00:00:00.000Z', }), + // product.updated: Copilot product lookup for the sync-log productName. + getProductsMapById: vi.fn().mockResolvedValue({ + [TEST_PRODUCT.id]: { id: TEST_PRODUCT.id, name: 'Updated Product' }, + }), ...overrides, } } @@ -71,6 +76,29 @@ export function createMockXeroAPI(overrides: XeroAPIOverrides = {}) { ), // Item lookup for line-item mapping; empty so lines fall back to the copilot description. getItems: vi.fn().mockResolvedValue([]), + // product.updated: item lookup returns the mapped item with its code + name, + // used both for the updateItem code arg and the sync-log display name. + getItemsMap: vi.fn().mockResolvedValue({ + [TEST_XERO_ITEM.id]: { + itemID: TEST_XERO_ITEM.id, + code: TEST_XERO_ITEM.code, + name: 'Xero Item Name', + description: 'Old description', + }, + }), + // Echoes back the updated item so the service records it. + updateItem: vi.fn( + async ( + _tenantId: string, + itemID: string, + item: { code: string; name: string; description?: string }, + ) => ({ + itemID, + code: item.code, + name: item.name, + description: item.description, + }), + ), // No pre-existing accounts, so the sales account is created on the region-default path. getAccounts: vi.fn().mockResolvedValue([]), createSalesAccount: vi.fn( From dc04af7d5578606fe1d111a88f6cdd663ffa2e9e Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 14 Jul 2026 15:52:12 +0545 Subject: [PATCH 2/5] test(OUT-3955): cover product.updated happy path Co-Authored-By: Claude Opus 4.8 --- .../webhook/productUpdated/happyPath.test.ts | 62 +++++++++++++++++++ 1 file changed, 62 insertions(+) create mode 100644 test/integration/webhook/productUpdated/happyPath.test.ts diff --git a/test/integration/webhook/productUpdated/happyPath.test.ts b/test/integration/webhook/productUpdated/happyPath.test.ts new file mode 100644 index 0000000..59a90e9 --- /dev/null +++ b/test/integration/webhook/productUpdated/happyPath.test.ts @@ -0,0 +1,62 @@ +import productUpdatedPayload from '@test/fixtures/productUpdated.webhook' +import { TEST_PORTAL, TEST_PRODUCT, TEST_XERO_ITEM } from '@test/helpers/constants' +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' +import { failedSyncs } from '@/db/schema/failedSyncs.schema' +import { syncedItems } from '@/db/schema/syncedItems.schema' +import { SyncEntityType, SyncEventType, SyncStatus, syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — product.updated', () => { + const apis = setupWebhookTest() + + it('updates the mapped Xero item with sanitized fields and logs the sync as successful', async () => { + await seedConnectedPortal() + await seedSyncedItem() + + const res = await postWebhook(productUpdatedPayload) + expect(res.status).toBe(200) + + // Xero item updated once, with the code from the items map and the + // HTML-stripped description. + expect(apis.xero.updateItem).toHaveBeenCalledTimes(1) + const [tenantId, itemId, itemUpdate] = apis.xero.updateItem.mock.calls[0] + expect(tenantId).toBe(TEST_PORTAL.tenantId) + expect(itemId).toBe(TEST_XERO_ITEM.id) + expect(itemUpdate).toMatchObject({ + code: TEST_XERO_ITEM.code, + name: 'Updated Product', + description: 'Updated description here', + }) + + // Mapping row is left untouched — update never re-maps. + const items = await db.select().from(syncedItems) + expect(items).toHaveLength(1) + expect(items[0]).toMatchObject({ + portalId: TEST_PORTAL.id, + tenantId: TEST_PORTAL.tenantId, + productId: TEST_PRODUCT.id, + itemId: TEST_XERO_ITEM.id, + }) + + // Success sync log written for the update. + const logs = await db.select().from(syncLogs).where(eq(syncLogs.copilotId, TEST_PRODUCT.id)) + expect(logs).toHaveLength(1) + expect(logs[0]).toMatchObject({ + portalId: TEST_PORTAL.id, + tenantId: TEST_PORTAL.tenantId, + entityType: SyncEntityType.PRODUCT, + eventType: SyncEventType.UPDATED, + status: SyncStatus.SUCCESS, + xeroId: TEST_XERO_ITEM.id, + productName: 'Updated Product', + xeroItemName: 'Xero Item Name', + }) + + // No failure recorded on the happy path. + expect(await db.select().from(failedSyncs)).toHaveLength(0) + }) +}) From 6638ccc319117ba653d3085c7e7f0c21a08bfc8d Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 14 Jul 2026 15:52:14 +0545 Subject: [PATCH 3/5] test(OUT-3955): cover product.updated skip gates and unmapped no-op Co-Authored-By: Claude Opus 4.8 --- .../productUpdated/isSyncDisabled.test.ts | 34 ++++++++++++++++++ .../productUpdated/productNotMapped.test.ts | 27 ++++++++++++++ .../syncProductsAutomaticallyDisabled.test.ts | 35 +++++++++++++++++++ .../productUpdated/unsupportedRegion.test.ts | 25 +++++++++++++ 4 files changed, 121 insertions(+) create mode 100644 test/integration/webhook/productUpdated/isSyncDisabled.test.ts create mode 100644 test/integration/webhook/productUpdated/productNotMapped.test.ts create mode 100644 test/integration/webhook/productUpdated/syncProductsAutomaticallyDisabled.test.ts create mode 100644 test/integration/webhook/productUpdated/unsupportedRegion.test.ts diff --git a/test/integration/webhook/productUpdated/isSyncDisabled.test.ts b/test/integration/webhook/productUpdated/isSyncDisabled.test.ts new file mode 100644 index 0000000..653d241 --- /dev/null +++ b/test/integration/webhook/productUpdated/isSyncDisabled.test.ts @@ -0,0 +1,34 @@ +import productUpdatedPayload from '@test/fixtures/productUpdated.webhook' +import { TEST_XERO_ITEM } from '@test/helpers/constants' +import { seedConnectedPortal, seedSyncedItem } 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 { syncedItems } from '@/db/schema/syncedItems.schema' +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.updated (isSyncEnabled=false)', () => { + const apis = setupWebhookTest() + + it('returns 200 without updating the Xero item or writing any rows', async () => { + await seedConnectedPortal({ settings: { isSyncEnabled: false } }) + await seedSyncedItem() + + const res = await postWebhook(productUpdatedPayload) + expect(res.status).toBe(200) + + expect(apis.xero.updateItem).not.toHaveBeenCalled() + + // The seeded mapping is left untouched — the gate fires before any read. + const items = await db.select().from(syncedItems) + expect(items).toHaveLength(1) + expect(items[0].itemId).toBe(TEST_XERO_ITEM.id) + + expect(await db.select().from(syncLogs)).toHaveLength(0) + expect(await db.select().from(failedSyncs)).toHaveLength(0) + }) +}) diff --git a/test/integration/webhook/productUpdated/productNotMapped.test.ts b/test/integration/webhook/productUpdated/productNotMapped.test.ts new file mode 100644 index 0000000..6539b55 --- /dev/null +++ b/test/integration/webhook/productUpdated/productNotMapped.test.ts @@ -0,0 +1,27 @@ +import productUpdatedPayload from '@test/fixtures/productUpdated.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 { syncedItems } from '@/db/schema/syncedItems.schema' +import { syncLogs } from '@/db/schema/syncLogs.schema' + +// Sync is on but the product has no synced_items row, so the service short +// circuits with an empty result: no Xero call, no logs, no failure. +describe('POST /api/webhook — product.updated (product not mapped)', () => { + const apis = setupWebhookTest() + + it('returns 200 without calling Xero or writing any rows', async () => { + await seedConnectedPortal() + + const res = await postWebhook(productUpdatedPayload) + expect(res.status).toBe(200) + + expect(apis.xero.updateItem).not.toHaveBeenCalled() + expect(await db.select().from(syncLogs)).toHaveLength(0) + expect(await db.select().from(syncedItems)).toHaveLength(0) + expect(await db.select().from(failedSyncs)).toHaveLength(0) + }) +}) diff --git a/test/integration/webhook/productUpdated/syncProductsAutomaticallyDisabled.test.ts b/test/integration/webhook/productUpdated/syncProductsAutomaticallyDisabled.test.ts new file mode 100644 index 0000000..ce363f0 --- /dev/null +++ b/test/integration/webhook/productUpdated/syncProductsAutomaticallyDisabled.test.ts @@ -0,0 +1,35 @@ +import productUpdatedPayload from '@test/fixtures/productUpdated.webhook' +import { TEST_XERO_ITEM } from '@test/helpers/constants' +import { seedConnectedPortal, seedSyncedItem } 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 { syncedItems } from '@/db/schema/syncedItems.schema' +import { syncLogs } from '@/db/schema/syncLogs.schema' + +// Workspace sync is on, but automatic product sync is off, so the handler throws +// APIError(OK) which handleEvent swallows. The product stays mapped but is not +// pushed to Xero. +describe('POST /api/webhook — product.updated (syncProductsAutomatically=false)', () => { + const apis = setupWebhookTest() + + it('returns 200 without updating the Xero item or writing any rows', async () => { + await seedConnectedPortal({ settings: { syncProductsAutomatically: false } }) + await seedSyncedItem() + + const res = await postWebhook(productUpdatedPayload) + expect(res.status).toBe(200) + + expect(apis.xero.updateItem).not.toHaveBeenCalled() + + // The seeded mapping stays put — the gate skips before touching synced_items. + const items = await db.select().from(syncedItems) + expect(items).toHaveLength(1) + expect(items[0].itemId).toBe(TEST_XERO_ITEM.id) + + expect(await db.select().from(syncLogs)).toHaveLength(0) + expect(await db.select().from(failedSyncs)).toHaveLength(0) + }) +}) diff --git a/test/integration/webhook/productUpdated/unsupportedRegion.test.ts b/test/integration/webhook/productUpdated/unsupportedRegion.test.ts new file mode 100644 index 0000000..dcb621e --- /dev/null +++ b/test/integration/webhook/productUpdated/unsupportedRegion.test.ts @@ -0,0 +1,25 @@ +import productUpdatedPayload from '@test/fixtures/productUpdated.webhook' +import { seedConnectedPortal, seedSyncedItem } 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 { syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — product.updated unsupported region', () => { + const apis = setupWebhookTest() + + it('acks and skips when the Xero region is unsupported', async () => { + // GB is unsupported, so getRegionConfig returns null and handleEvent skips. + await seedConnectedPortal({ settings: { countryCode: 'GB' } }) + await seedSyncedItem() + + const res = await postWebhook(productUpdatedPayload) + expect(res.status).toBe(200) + + expect(apis.xero.updateItem).not.toHaveBeenCalled() + expect(await db.select().from(syncLogs)).toHaveLength(0) + expect(await db.select().from(failedSyncs)).toHaveLength(0) + }) +}) From d102d7664769a65e81c5c2f5cb7e7be3a287b31b Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 14 Jul 2026 15:52:16 +0545 Subject: [PATCH 4/5] test(OUT-3955): cover product.updated failure paths record failed_syncs Co-Authored-By: Claude Opus 4.8 --- .../productUpdated/missingXeroItem.test.ts | 60 +++++++++++++++++ .../xeroUpdateItemFails.test.ts | 64 +++++++++++++++++++ 2 files changed, 124 insertions(+) create mode 100644 test/integration/webhook/productUpdated/missingXeroItem.test.ts create mode 100644 test/integration/webhook/productUpdated/xeroUpdateItemFails.test.ts diff --git a/test/integration/webhook/productUpdated/missingXeroItem.test.ts b/test/integration/webhook/productUpdated/missingXeroItem.test.ts new file mode 100644 index 0000000..51de8d9 --- /dev/null +++ b/test/integration/webhook/productUpdated/missingXeroItem.test.ts @@ -0,0 +1,60 @@ +import productUpdatedPayload from '@test/fixtures/productUpdated.webhook' +import { TEST_PORTAL, TEST_PRODUCT, TEST_XERO_ITEM } from '@test/helpers/constants' +import { createMockXeroAPI } from '@test/helpers/mocks' +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, vi } from 'vitest' +import db from '@/db' +import { failedSyncs } from '@/db/schema/failedSyncs.schema' +import { syncedItems } from '@/db/schema/syncedItems.schema' +import { SyncEntityType, SyncEventType, SyncStatus, syncLogs } from '@/db/schema/syncLogs.schema' + +// The product is still mapped locally, but the Xero item is gone (deleted +// upstream), so getItemsMap has no entry for it. Reading its code throws before +// updateItem runs; the failure is recorded and rethrown (500). +describe('POST /api/webhook — product.updated (mapped Xero item missing)', () => { + const apis = setupWebhookTest(() => ({ + xero: createMockXeroAPI({ + getItemsMap: vi.fn().mockResolvedValue({}), + }), + })) + + it('records a FAILED sync log + failed_syncs and returns 500 without calling updateItem', async () => { + await seedConnectedPortal() + await seedSyncedItem() + + const res = await postWebhook(productUpdatedPayload) + expect(res.status).toBe(500) + + // The missing code is read before updateItem is invoked. + expect(apis.xero.updateItem).not.toHaveBeenCalled() + + const logs = await db.select().from(syncLogs).where(eq(syncLogs.copilotId, TEST_PRODUCT.id)) + expect(logs).toHaveLength(1) + expect(logs[0]).toMatchObject({ + portalId: TEST_PORTAL.id, + tenantId: TEST_PORTAL.tenantId, + entityType: SyncEntityType.PRODUCT, + eventType: SyncEventType.UPDATED, + status: SyncStatus.FAILED, + }) + expect(logs[0].errorMessage).toContain('Failed to update synced item') + + const failed = await db + .select() + .from(failedSyncs) + .where(eq(failedSyncs.resourceId, TEST_PRODUCT.id)) + expect(failed).toHaveLength(1) + expect(failed[0]).toMatchObject({ + type: 'product.updated', + resourceId: TEST_PRODUCT.id, + }) + + // Update never touches synced_items, so the mapping survives the failure. + const items = await db.select().from(syncedItems) + expect(items).toHaveLength(1) + expect(items[0].itemId).toBe(TEST_XERO_ITEM.id) + }) +}) diff --git a/test/integration/webhook/productUpdated/xeroUpdateItemFails.test.ts b/test/integration/webhook/productUpdated/xeroUpdateItemFails.test.ts new file mode 100644 index 0000000..acd1fe3 --- /dev/null +++ b/test/integration/webhook/productUpdated/xeroUpdateItemFails.test.ts @@ -0,0 +1,64 @@ +import productUpdatedPayload from '@test/fixtures/productUpdated.webhook' +import { TEST_PORTAL, TEST_PRODUCT, TEST_XERO_ITEM } from '@test/helpers/constants' +import { createMockXeroAPI } from '@test/helpers/mocks' +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, vi } from 'vitest' +import db from '@/db' +import { failedSyncs } from '@/db/schema/failedSyncs.schema' +import { syncedItems } from '@/db/schema/syncedItems.schema' +import { SyncEntityType, SyncEventType, SyncStatus, syncLogs } from '@/db/schema/syncLogs.schema' + +// updateItem throws → the service wraps it in an APIError with a FAILED sync-log +// payload, and handleEvent records both a FAILED sync log and a failed_syncs +// record before rethrowing (500). +describe('POST /api/webhook — product.updated (Xero updateItem fails)', () => { + const apis = setupWebhookTest(() => ({ + xero: createMockXeroAPI({ + updateItem: vi.fn().mockRejectedValue(new Error('Xero is on fire')), + }), + })) + + it('records a FAILED sync log + failed_syncs and returns 500', async () => { + await seedConnectedPortal() + await seedSyncedItem() + + const res = await postWebhook(productUpdatedPayload) + expect(res.status).toBe(500) + + // Got far enough to attempt the update. + expect(apis.xero.updateItem).toHaveBeenCalledTimes(1) + + // FAILED sync log written. + const logs = await db.select().from(syncLogs).where(eq(syncLogs.copilotId, TEST_PRODUCT.id)) + expect(logs).toHaveLength(1) + expect(logs[0]).toMatchObject({ + portalId: TEST_PORTAL.id, + tenantId: TEST_PORTAL.tenantId, + entityType: SyncEntityType.PRODUCT, + eventType: SyncEventType.UPDATED, + status: SyncStatus.FAILED, + }) + expect(logs[0].errorMessage).toContain('Failed to update synced item') + + // failed_syncs record queued for retry. + const failed = await db + .select() + .from(failedSyncs) + .where(eq(failedSyncs.resourceId, TEST_PRODUCT.id)) + expect(failed).toHaveLength(1) + expect(failed[0]).toMatchObject({ + portalId: TEST_PORTAL.id, + tenantId: TEST_PORTAL.tenantId, + type: 'product.updated', + resourceId: TEST_PRODUCT.id, + }) + + // Update never touches synced_items, so the mapping survives the failure. + const items = await db.select().from(syncedItems) + expect(items).toHaveLength(1) + expect(items[0].itemId).toBe(TEST_XERO_ITEM.id) + }) +}) From 12929d61a4236c35d1d3e11361122d95f7adcc46 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Tue, 14 Jul 2026 16:33:12 +0545 Subject: [PATCH 5/5] test(OUT-3955): assert no-mapping branch returns empty items result The product-not-mapped test previously asserted only 200 + no Xero call + no rows, which the swallowed syncProductsAutomatically gate also satisfies. Assert the handler's { items: [] } result in the response body so the test proves it reached the no-mapping path. Co-Authored-By: Claude Opus 4.8 --- .../webhook/productUpdated/productNotMapped.test.ts | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/test/integration/webhook/productUpdated/productNotMapped.test.ts b/test/integration/webhook/productUpdated/productNotMapped.test.ts index 6539b55..76f185b 100644 --- a/test/integration/webhook/productUpdated/productNotMapped.test.ts +++ b/test/integration/webhook/productUpdated/productNotMapped.test.ts @@ -19,6 +19,11 @@ describe('POST /api/webhook — product.updated (product not mapped)', () => { const res = await postWebhook(productUpdatedPayload) expect(res.status).toBe(200) + // Proves the no-mapping branch actually ran: the handler returns { items: [] }. + // The swallowed syncProductsAutomatically gate returns no data, so this would + // fail if that gate fired instead. + expect(await res.json()).toMatchObject({ data: { items: [] } }) + expect(apis.xero.updateItem).not.toHaveBeenCalled() expect(await db.select().from(syncLogs)).toHaveLength(0) expect(await db.select().from(syncedItems)).toHaveLength(0)