-
Notifications
You must be signed in to change notification settings - Fork 0
test(OUT-3955): integration tests for product.updated webhook #72
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
5 commits
Select commit
Hold shift + click to select a range
9a6b51d
test(OUT-3955): add product.updated fixture, xero/copilot mock defaul…
SandipBajracharya dc04af7
test(OUT-3955): cover product.updated happy path
SandipBajracharya 6638ccc
test(OUT-3955): cover product.updated skip gates and unmapped no-op
SandipBajracharya d102d76
test(OUT-3955): cover product.updated failure paths record failed_syncs
SandipBajracharya 12929d6
test(OUT-3955): assert no-mapping branch returns empty items result
SandipBajracharya File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
34
test/integration/webhook/productUpdated/isSyncDisabled.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
60
test/integration/webhook/productUpdated/missingXeroItem.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
32
test/integration/webhook/productUpdated/productNotMapped.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| expect(await db.select().from(syncedItems)).toHaveLength(0) | ||
| expect(await db.select().from(failedSyncs)).toHaveLength(0) | ||
| }) | ||
| }) | ||
35 changes: 35 additions & 0 deletions
35
test/integration/webhook/productUpdated/syncProductsAutomaticallyDisabled.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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
25
test/integration/webhook/productUpdated/unsupportedRegion.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| }) | ||
| }) |
64 changes: 64 additions & 0 deletions
64
test/integration/webhook/productUpdated/xeroUpdateItemFails.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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) | ||
| }) | ||
| }) |
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.