From 368b3b7afac7538a6ecc1da48d4c6b6d8d3dfaa9 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 10 Jul 2026 17:28:46 +0545 Subject: [PATCH 1/8] test(OUT-3953): add invoice.deleted happy-path integration test Co-Authored-By: Claude Opus 4.8 --- test/fixtures/deletedInvoice.webhook.ts | 20 +++++++ test/helpers/deleteSyncFlag.ts | 14 +++++ test/helpers/mocks.ts | 5 ++ .../webhook/invoiceDeleted/happyPath.test.ts | 59 +++++++++++++++++++ 4 files changed, 98 insertions(+) create mode 100644 test/fixtures/deletedInvoice.webhook.ts create mode 100644 test/helpers/deleteSyncFlag.ts create mode 100644 test/integration/webhook/invoiceDeleted/happyPath.test.ts diff --git a/test/fixtures/deletedInvoice.webhook.ts b/test/fixtures/deletedInvoice.webhook.ts new file mode 100644 index 0000000..f124e34 --- /dev/null +++ b/test/fixtures/deletedInvoice.webhook.ts @@ -0,0 +1,20 @@ +import { type InvoiceDeletedWebhookSchema, ValidWebhookEvent } from '@invoice-sync/types' +import { TEST_INVOICE } from '@test/helpers/constants' +import type { z } from 'zod' + +type DeletedInvoiceWebhookInput = z.input +type DeletedInvoiceData = DeletedInvoiceWebhookInput['data'] + +// Builds an invoice.deleted webhook payload. The payload is just { id }; pass +// `dataOverrides` to vary the invoice id. +export function buildDeletedInvoiceWebhook( + dataOverrides: Partial = {}, +): DeletedInvoiceWebhookInput { + return { + eventType: ValidWebhookEvent.InvoiceDeleted, + data: { + id: TEST_INVOICE.id, + ...dataOverrides, + }, + } +} diff --git a/test/helpers/deleteSyncFlag.ts b/test/helpers/deleteSyncFlag.ts new file mode 100644 index 0000000..56d80d8 --- /dev/null +++ b/test/helpers/deleteSyncFlag.ts @@ -0,0 +1,14 @@ +import { afterEach, beforeEach } from 'vitest' +import env from '@/config/server.env' + +// FLAG_ENABLE_DELETE_SYNC defaults to false and env is parsed once at import. +// Flip the shared singleton on for delete-sync tests and reset it after so the +// value never leaks into other files (the suite runs isolate:false). +export function enableDeleteSyncForTest() { + beforeEach(() => { + env.FLAG_ENABLE_DELETE_SYNC = true + }) + afterEach(() => { + env.FLAG_ENABLE_DELETE_SYNC = false + }) +} diff --git a/test/helpers/mocks.ts b/test/helpers/mocks.ts index 17c0ced..a5b44d4 100644 --- a/test/helpers/mocks.ts +++ b/test/helpers/mocks.ts @@ -127,6 +127,11 @@ export function createMockXeroAPI(overrides: XeroAPIOverrides = {}) { invoiceID: TEST_XERO_INVOICE.id, status: 'VOIDED', }), + // invoice.deleted: delete the (voided) Xero invoice. + deleteInvoice: vi.fn().mockResolvedValue({ + invoiceID: TEST_XERO_INVOICE.id, + status: 'DELETED', + }), ...overrides, } } diff --git a/test/integration/webhook/invoiceDeleted/happyPath.test.ts b/test/integration/webhook/invoiceDeleted/happyPath.test.ts new file mode 100644 index 0000000..38029da --- /dev/null +++ b/test/integration/webhook/invoiceDeleted/happyPath.test.ts @@ -0,0 +1,59 @@ +import { buildDeletedInvoiceWebhook } from '@test/fixtures/deletedInvoice.webhook' +import { TEST_INVOICE, TEST_PORTAL, TEST_XERO_INVOICE } from '@test/helpers/constants' +import { enableDeleteSyncForTest } from '@test/helpers/deleteSyncFlag' +import { seedConnectedPortal, seedSyncedInvoice, seedSyncLog } 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 { SyncEventType, SyncStatus, syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — invoice.deleted', () => { + const apis = setupWebhookTest() + enableDeleteSyncForTest() + + it('voids the invoice then deletes it in Xero, logging both events', async () => { + await seedConnectedPortal() + await seedSyncedInvoice({ status: 'success' }) + await seedSyncLog() + + // The default getInvoiceById returns a non-VOIDED invoice, so this drives + // the "void first, then delete" branch. + const res = await postWebhook(buildDeletedInvoiceWebhook()) + expect(res.status).toBe(200) + + // Not-yet-voided invoice is voided first, then deleted. + expect(apis.xero.voidInvoice).toHaveBeenCalledTimes(1) + expect(apis.xero.deleteInvoice).toHaveBeenCalledTimes(1) + const [tenantId, xeroInvoiceId] = apis.xero.deleteInvoice.mock.calls[0] + expect(tenantId).toBe(TEST_PORTAL.tenantId) + expect(xeroInvoiceId).toBe(TEST_XERO_INVOICE.id) + + // Invoice row is untouched by the delete. + const invoices = await db.select().from(syncedInvoices) + expect(invoices).toHaveLength(1) + expect(invoices[0]).toMatchObject({ copilotInvoiceId: TEST_INVOICE.id, status: 'success' }) + + // Both a voided (from the internal void) and a deleted success log are written. + const voidedLogs = await db + .select() + .from(syncLogs) + .where(eq(syncLogs.eventType, SyncEventType.VOIDED)) + expect(voidedLogs).toHaveLength(1) + const deletedLogs = await db + .select() + .from(syncLogs) + .where(eq(syncLogs.eventType, SyncEventType.DELETED)) + expect(deletedLogs).toHaveLength(1) + expect(deletedLogs[0]).toMatchObject({ + status: SyncStatus.SUCCESS, + invoiceNumber: TEST_INVOICE.number, + }) + + // No failure recorded on the happy path. + expect(await db.select().from(failedSyncs)).toHaveLength(0) + }) +}) From c756e9a30e3c4ab1e538739965586ee4230fac74 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 10 Jul 2026 17:28:49 +0545 Subject: [PATCH 2/8] test(OUT-3953): assert invoice.deleted skips voiding when already voided Co-Authored-By: Claude Opus 4.8 --- .../invoiceDeleted/alreadyVoided.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 test/integration/webhook/invoiceDeleted/alreadyVoided.test.ts diff --git a/test/integration/webhook/invoiceDeleted/alreadyVoided.test.ts b/test/integration/webhook/invoiceDeleted/alreadyVoided.test.ts new file mode 100644 index 0000000..d9ff446 --- /dev/null +++ b/test/integration/webhook/invoiceDeleted/alreadyVoided.test.ts @@ -0,0 +1,49 @@ +import { buildDeletedInvoiceWebhook } from '@test/fixtures/deletedInvoice.webhook' +import { TEST_XERO_INVOICE } from '@test/helpers/constants' +import { enableDeleteSyncForTest } from '@test/helpers/deleteSyncFlag' +import { createMockXeroAPI } from '@test/helpers/mocks' +import { seedConnectedPortal, seedSyncedInvoice, seedSyncLog } 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 { SyncEventType, SyncStatus, syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — invoice.deleted already voided', () => { + const apis = setupWebhookTest(() => ({ + xero: createMockXeroAPI({ + getInvoiceById: vi.fn().mockResolvedValue({ + invoiceID: TEST_XERO_INVOICE.id, + status: 'VOIDED', + }), + }), + })) + enableDeleteSyncForTest() + + it('skips voiding and deletes directly', async () => { + await seedConnectedPortal() + await seedSyncedInvoice({ status: 'success' }) + await seedSyncLog() + + const res = await postWebhook(buildDeletedInvoiceWebhook()) + expect(res.status).toBe(200) + + // Already voided → skip the internal void, delete directly. + expect(apis.xero.voidInvoice).not.toHaveBeenCalled() + expect(apis.xero.deleteInvoice).toHaveBeenCalledTimes(1) + + // Only a deleted log; no voided log. + const voidedLogs = await db + .select() + .from(syncLogs) + .where(eq(syncLogs.eventType, SyncEventType.VOIDED)) + expect(voidedLogs).toHaveLength(0) + const deletedLogs = await db + .select() + .from(syncLogs) + .where(eq(syncLogs.eventType, SyncEventType.DELETED)) + expect(deletedLogs).toHaveLength(1) + expect(deletedLogs[0]).toMatchObject({ status: SyncStatus.SUCCESS }) + }) +}) From 746eda8893bc4fb1757cd968eda1641682b1f17b Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 10 Jul 2026 17:28:51 +0545 Subject: [PATCH 3/8] test(OUT-3953): assert invoice.deleted voids but skips delete when flag disabled Co-Authored-By: Claude Opus 4.8 --- .../invoiceDeleted/deleteSyncDisabled.test.ts | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 test/integration/webhook/invoiceDeleted/deleteSyncDisabled.test.ts diff --git a/test/integration/webhook/invoiceDeleted/deleteSyncDisabled.test.ts b/test/integration/webhook/invoiceDeleted/deleteSyncDisabled.test.ts new file mode 100644 index 0000000..d1e7b98 --- /dev/null +++ b/test/integration/webhook/invoiceDeleted/deleteSyncDisabled.test.ts @@ -0,0 +1,35 @@ +import { buildDeletedInvoiceWebhook } from '@test/fixtures/deletedInvoice.webhook' +import { seedConnectedPortal, seedSyncedInvoice, seedSyncLog } 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 { SyncEventType, syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — invoice.deleted delete sync disabled', () => { + const apis = setupWebhookTest() + // FLAG_ENABLE_DELETE_SYNC defaults to false — no enableDeleteSyncForTest(). + + it('voids the invoice but skips the delete when the flag is off', async () => { + await seedConnectedPortal() + await seedSyncedInvoice({ status: 'success' }) + await seedSyncLog() + + const res = await postWebhook(buildDeletedInvoiceWebhook()) + expect(res.status).toBe(200) + + // Voided, but the flag gates the actual delete. + expect(apis.xero.voidInvoice).toHaveBeenCalledTimes(1) + expect(apis.xero.deleteInvoice).not.toHaveBeenCalled() + + // No deleted log written. + const deletedLogs = await db + .select() + .from(syncLogs) + .where(eq(syncLogs.eventType, SyncEventType.DELETED)) + expect(deletedLogs).toHaveLength(0) + expect(await db.select().from(failedSyncs)).toHaveLength(0) + }) +}) From 841a1e8374b1c491aa578ae5ac46c58a54b97d72 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 10 Jul 2026 17:28:53 +0545 Subject: [PATCH 4/8] test(OUT-3953): assert invoice.deleted short-circuits when sync disabled Co-Authored-By: Claude Opus 4.8 --- .../invoiceDeleted/syncDisabled.test.ts | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) create mode 100644 test/integration/webhook/invoiceDeleted/syncDisabled.test.ts diff --git a/test/integration/webhook/invoiceDeleted/syncDisabled.test.ts b/test/integration/webhook/invoiceDeleted/syncDisabled.test.ts new file mode 100644 index 0000000..c381824 --- /dev/null +++ b/test/integration/webhook/invoiceDeleted/syncDisabled.test.ts @@ -0,0 +1,22 @@ +import { buildDeletedInvoiceWebhook } from '@test/fixtures/deletedInvoice.webhook' +import { seedConnectedPortal, seedSyncedInvoice } from '@test/helpers/seed' +import { postWebhook } from '@test/helpers/webhook' +import { setupWebhookTest } from '@test/helpers/webhookTestSetup' +import { describe, expect, it } from 'vitest' + +describe('POST /api/webhook — invoice.deleted sync disabled', () => { + const apis = setupWebhookTest() + + it('short-circuits at the controller when sync is disabled', async () => { + await seedConnectedPortal({ settings: { isSyncEnabled: false } }) + await seedSyncedInvoice({ status: 'success' }) + + const res = await postWebhook(buildDeletedInvoiceWebhook()) + expect(res.status).toBe(200) + expect(await res.json()).toMatchObject({ message: 'Sync is disabled for this workspace' }) + + // Handler never runs. + expect(apis.xero.voidInvoice).not.toHaveBeenCalled() + expect(apis.xero.deleteInvoice).not.toHaveBeenCalled() + }) +}) From 7d8a34131a7091c1719821e3ed4275c5df62294f Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 10 Jul 2026 17:28:56 +0545 Subject: [PATCH 5/8] test(OUT-3953): assert invoice.deleted returns 500 when the xero invoice is missing Co-Authored-By: Claude Opus 4.8 --- .../invoiceDeleted/invoiceNotFound.test.ts | 49 +++++++++++++++++++ 1 file changed, 49 insertions(+) create mode 100644 test/integration/webhook/invoiceDeleted/invoiceNotFound.test.ts diff --git a/test/integration/webhook/invoiceDeleted/invoiceNotFound.test.ts b/test/integration/webhook/invoiceDeleted/invoiceNotFound.test.ts new file mode 100644 index 0000000..c86323d --- /dev/null +++ b/test/integration/webhook/invoiceDeleted/invoiceNotFound.test.ts @@ -0,0 +1,49 @@ +import { ValidWebhookEvent } from '@invoice-sync/types' +import { buildDeletedInvoiceWebhook } from '@test/fixtures/deletedInvoice.webhook' +import { TEST_INVOICE } from '@test/helpers/constants' +import { createMockXeroAPI } from '@test/helpers/mocks' +import { seedConnectedPortal, seedSyncedInvoice, seedSyncLog } 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 { SyncEventType, SyncStatus, syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — invoice.deleted Xero invoice not found', () => { + const apis = setupWebhookTest(() => ({ + xero: createMockXeroAPI({ + getInvoiceById: vi.fn().mockResolvedValue(undefined), + }), + })) + + it('records failed_syncs and returns 500 when the Xero invoice is missing', async () => { + await seedConnectedPortal() + await seedSyncedInvoice({ status: 'success' }) + await seedSyncLog() + + const res = await postWebhook(buildDeletedInvoiceWebhook()) + // Validation sits inside the try, so not-found is re-wrapped as 500 (not 404). + expect(res.status).toBe(500) + + expect(apis.xero.voidInvoice).not.toHaveBeenCalled() + expect(apis.xero.deleteInvoice).not.toHaveBeenCalled() + + // A failed deleted log is written (prevSyncLog supplies entityType)... + const deletedLogs = await db + .select() + .from(syncLogs) + .where(eq(syncLogs.eventType, SyncEventType.DELETED)) + expect(deletedLogs).toHaveLength(1) + expect(deletedLogs[0]).toMatchObject({ status: SyncStatus.FAILED }) + + // ...and a failed_syncs row recorded for retry. + const failed = await db.select().from(failedSyncs) + expect(failed).toHaveLength(1) + expect(failed[0]).toMatchObject({ + type: ValidWebhookEvent.InvoiceDeleted, + resourceId: TEST_INVOICE.id, + }) + }) +}) From e6dfd78a6b532b4809c409afeda882c2255074c3 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 10 Jul 2026 17:28:58 +0545 Subject: [PATCH 6/8] test(OUT-3953): assert invoice.deleted records failed_syncs on deleteInvoice failure Co-Authored-By: Claude Opus 4.8 --- .../invoiceDeleted/xeroDeleteFails.test.ts | 55 +++++++++++++++++++ 1 file changed, 55 insertions(+) create mode 100644 test/integration/webhook/invoiceDeleted/xeroDeleteFails.test.ts diff --git a/test/integration/webhook/invoiceDeleted/xeroDeleteFails.test.ts b/test/integration/webhook/invoiceDeleted/xeroDeleteFails.test.ts new file mode 100644 index 0000000..9f4924d --- /dev/null +++ b/test/integration/webhook/invoiceDeleted/xeroDeleteFails.test.ts @@ -0,0 +1,55 @@ +import { ValidWebhookEvent } from '@invoice-sync/types' +import { buildDeletedInvoiceWebhook } from '@test/fixtures/deletedInvoice.webhook' +import { TEST_CLIENT, TEST_INVOICE } from '@test/helpers/constants' +import { enableDeleteSyncForTest } from '@test/helpers/deleteSyncFlag' +import { createMockXeroAPI } from '@test/helpers/mocks' +import { seedConnectedPortal, seedSyncedInvoice, seedSyncLog } 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 { SyncEventType, SyncStatus, syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — invoice.deleted xero failure', () => { + const apis = setupWebhookTest(() => ({ + xero: createMockXeroAPI({ + deleteInvoice: vi.fn().mockRejectedValue(new Error('Xero 500: delete rejected')), + }), + })) + enableDeleteSyncForTest() + + it('records failure in sync_logs and failed_syncs, and returns 500', async () => { + await seedConnectedPortal() + await seedSyncedInvoice({ status: 'success' }) + await seedSyncLog() + + const res = await postWebhook(buildDeletedInvoiceWebhook()) + expect(res.status).toBe(500) + + // Voided first, then the delete throws. + expect(apis.xero.voidInvoice).toHaveBeenCalledTimes(1) + expect(apis.xero.deleteInvoice).toHaveBeenCalledTimes(1) + + // A failed deleted log carrying the created metadata... + const deletedLogs = await db + .select() + .from(syncLogs) + .where(eq(syncLogs.eventType, SyncEventType.DELETED)) + expect(deletedLogs).toHaveLength(1) + expect(deletedLogs[0]).toMatchObject({ + status: SyncStatus.FAILED, + invoiceNumber: TEST_INVOICE.number, + customerName: `${TEST_CLIENT.givenName} ${TEST_CLIENT.familyName}`, + }) + + // ...and a failed_syncs row for retry. + const failed = await db.select().from(failedSyncs) + expect(failed).toHaveLength(1) + expect(failed[0]).toMatchObject({ + type: ValidWebhookEvent.InvoiceDeleted, + resourceId: TEST_INVOICE.id, + }) + }) +}) From f127073fd7910535a84e6d7f4719a147f222c5c8 Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 10 Jul 2026 17:30:01 +0545 Subject: [PATCH 7/8] fix(OUT-3953): default sync-log entityType when deleting without a prior created log deleteInvoice built its sync-log payloads by spreading prevSyncLog without setting the NOT-NULL entityType. When no invoice.created log exists the log INSERT crashes, so a delete whose Xero calls succeeded still returns 500 and (on the failure path) records no failed_syncs row. Hardcode entityType like voidInvoice and syncPaidInvoiceToXero already do, and cover it with a regression test. Co-Authored-By: Claude Opus 4.8 --- .../lib/SyncedInvoices.service.ts | 2 + .../invoiceDeleted/noPriorCreatedLog.test.ts | 50 +++++++++++++++++++ 2 files changed, 52 insertions(+) create mode 100644 test/integration/webhook/invoiceDeleted/noPriorCreatedLog.test.ts diff --git a/src/features/invoice-sync/lib/SyncedInvoices.service.ts b/src/features/invoice-sync/lib/SyncedInvoices.service.ts index 6ae635d..30536e1 100644 --- a/src/features/invoice-sync/lib/SyncedInvoices.service.ts +++ b/src/features/invoice-sync/lib/SyncedInvoices.service.ts @@ -407,6 +407,7 @@ class SyncedInvoicesService extends AuthenticatedXeroService { // Add to sync log await syncLogsService.createSyncLog({ ...prevSyncLog, + entityType: SyncEntityType.INVOICE, eventType: SyncEventType.DELETED, status: SyncStatus.SUCCESS, syncDate: new Date(), @@ -418,6 +419,7 @@ class SyncedInvoicesService extends AuthenticatedXeroService { error, failedSyncLogPayload: { ...prevSyncLog, + entityType: SyncEntityType.INVOICE, eventType: SyncEventType.DELETED, }, }) diff --git a/test/integration/webhook/invoiceDeleted/noPriorCreatedLog.test.ts b/test/integration/webhook/invoiceDeleted/noPriorCreatedLog.test.ts new file mode 100644 index 0000000..6fa7cbb --- /dev/null +++ b/test/integration/webhook/invoiceDeleted/noPriorCreatedLog.test.ts @@ -0,0 +1,50 @@ +import { buildDeletedInvoiceWebhook } from '@test/fixtures/deletedInvoice.webhook' +import { TEST_XERO_INVOICE } from '@test/helpers/constants' +import { enableDeleteSyncForTest } from '@test/helpers/deleteSyncFlag' +import { createMockXeroAPI } from '@test/helpers/mocks' +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, vi } from 'vitest' +import db from '@/db' +import { failedSyncs } from '@/db/schema/failedSyncs.schema' +import { SyncEntityType, SyncEventType, SyncStatus, syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — invoice.deleted without a prior created log', () => { + const apis = setupWebhookTest(() => ({ + xero: createMockXeroAPI({ + getInvoiceById: vi.fn().mockResolvedValue({ + invoiceID: TEST_XERO_INVOICE.id, + status: 'VOIDED', + }), + }), + })) + enableDeleteSyncForTest() + + it('deletes successfully even when no invoice.created log exists', async () => { + await seedConnectedPortal() + await seedSyncedInvoice({ status: 'success' }) + // No seedSyncLog(): the deleted log must default its NOT-NULL entityType + // rather than inherit it from a prior invoice.created log. + + const res = await postWebhook(buildDeletedInvoiceWebhook()) + expect(res.status).toBe(200) + + expect(apis.xero.voidInvoice).not.toHaveBeenCalled() + expect(apis.xero.deleteInvoice).toHaveBeenCalledTimes(1) + + // The deleted success log still writes, defaulting entityType to invoice. + const deletedLogs = await db + .select() + .from(syncLogs) + .where(eq(syncLogs.eventType, SyncEventType.DELETED)) + expect(deletedLogs).toHaveLength(1) + expect(deletedLogs[0]).toMatchObject({ + status: SyncStatus.SUCCESS, + entityType: SyncEntityType.INVOICE, + }) + + expect(await db.select().from(failedSyncs)).toHaveLength(0) + }) +}) From 061d1048a13865695e195ad55a2f4b2192b44cce Mon Sep 17 00:00:00 2001 From: SandipBajracharya Date: Fri, 10 Jul 2026 17:30:03 +0545 Subject: [PATCH 8/8] test(OUT-3953): assert invoice.deleted creates the missing xero invoice then deletes Co-Authored-By: Claude Opus 4.8 --- .../invoiceDeleted/missingXeroInvoice.test.ts | 56 +++++++++++++++++++ 1 file changed, 56 insertions(+) create mode 100644 test/integration/webhook/invoiceDeleted/missingXeroInvoice.test.ts diff --git a/test/integration/webhook/invoiceDeleted/missingXeroInvoice.test.ts b/test/integration/webhook/invoiceDeleted/missingXeroInvoice.test.ts new file mode 100644 index 0000000..db6a268 --- /dev/null +++ b/test/integration/webhook/invoiceDeleted/missingXeroInvoice.test.ts @@ -0,0 +1,56 @@ +import { buildDeletedInvoiceWebhook } from '@test/fixtures/deletedInvoice.webhook' +import { buildInvoiceCreatedWebhook } from '@test/fixtures/invoiceCreated.webhook' +import { TEST_INVOICE, TEST_XERO_INVOICE } from '@test/helpers/constants' +import { enableDeleteSyncForTest } from '@test/helpers/deleteSyncFlag' +import { createMockCopilotAPI } from '@test/helpers/mocks' +import { seedConnectedPortal, seedSyncedInvoice } from '@test/helpers/seed' +import { postWebhook } from '@test/helpers/webhook' +import { setupWebhookTest } from '@test/helpers/webhookTestSetup' +import { and, eq } from 'drizzle-orm' +import { describe, expect, it, vi } from 'vitest' +import db from '@/db' +import { syncedInvoices } from '@/db/schema/syncedInvoices.schema' +import { SyncEventType, SyncStatus, syncLogs } from '@/db/schema/syncLogs.schema' + +describe('POST /api/webhook — invoice.deleted missing Xero invoice', () => { + // No xeroInvoiceId, so the service re-fetches the Copilot invoice and creates + // it in Xero before voiding and deleting. + const apis = setupWebhookTest(() => ({ + copilot: createMockCopilotAPI({ + getInvoice: vi.fn().mockResolvedValue(buildInvoiceCreatedWebhook().data), + }), + })) + enableDeleteSyncForTest() + + it('creates the missing Xero invoice, voids it, then deletes it', async () => { + await seedConnectedPortal() + await seedSyncedInvoice({ status: 'pending', xeroInvoiceId: null }) + + const res = await postWebhook(buildDeletedInvoiceWebhook()) + expect(res.status).toBe(200) + + // Missing invoice is created, then voided, then deleted. + expect(apis.copilot.getInvoice).toHaveBeenCalledTimes(1) + expect(apis.xero.createInvoice).toHaveBeenCalledTimes(1) + expect(apis.xero.voidInvoice).toHaveBeenCalledTimes(1) + expect(apis.xero.deleteInvoice).toHaveBeenCalledTimes(1) + + // Invoice row now mapped to Xero and marked success. + const invoices = await db.select().from(syncedInvoices) + expect(invoices).toHaveLength(1) + expect(invoices[0]).toMatchObject({ + copilotInvoiceId: TEST_INVOICE.id, + xeroInvoiceId: TEST_XERO_INVOICE.id, + status: 'success', + }) + + // A deleted success log written. + const deletedLogs = await db + .select() + .from(syncLogs) + .where( + and(eq(syncLogs.eventType, SyncEventType.DELETED), eq(syncLogs.status, SyncStatus.SUCCESS)), + ) + expect(deletedLogs).toHaveLength(1) + }) +})