Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 16 additions & 0 deletions test/fixtures/productUpdated.webhook.ts
Original file line number Diff line number Diff line change
@@ -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<typeof ProductUpdatedWebhookSchema> = {
eventType: ValidWebhookEvent.ProductUpdated,
data: {
id: TEST_PRODUCT.id,
name: 'Updated Product',
description: 'Updated <b>description</b> here',
},
}

export default productUpdatedPayload
4 changes: 3 additions & 1 deletion test/helpers/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 28 additions & 0 deletions test/helpers/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
TEST_EXPENSE_ACCOUNT,
TEST_INVOICE,
TEST_PORTAL,
TEST_PRODUCT,
TEST_SALES_ACCOUNT,
TEST_XERO_BANK_TXN,
TEST_XERO_CONTACT,
Expand Down Expand Up @@ -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,
}
}
Expand All @@ -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(
Expand Down
62 changes: 62 additions & 0 deletions test/integration/webhook/productUpdated/happyPath.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
34 changes: 34 additions & 0 deletions test/integration/webhook/productUpdated/isSyncDisabled.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
60 changes: 60 additions & 0 deletions test/integration/webhook/productUpdated/missingXeroItem.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
32 changes: 32 additions & 0 deletions test/integration/webhook/productUpdated/productNotMapped.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
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)

// 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)
Comment thread
SandipBajracharya marked this conversation as resolved.
expect(await db.select().from(syncedItems)).toHaveLength(0)
expect(await db.select().from(failedSyncs)).toHaveLength(0)
})
})
Original file line number Diff line number Diff line change
@@ -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)
})
})
25 changes: 25 additions & 0 deletions test/integration/webhook/productUpdated/unsupportedRegion.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
Original file line number Diff line number Diff line change
@@ -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)
})
})
Loading