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
2 changes: 2 additions & 0 deletions src/features/invoice-sync/lib/SyncedInvoices.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -418,6 +419,7 @@ class SyncedInvoicesService extends AuthenticatedXeroService {
error,
failedSyncLogPayload: {
...prevSyncLog,
entityType: SyncEntityType.INVOICE,
eventType: SyncEventType.DELETED,
},
})
Expand Down
20 changes: 20 additions & 0 deletions test/fixtures/deletedInvoice.webhook.ts
Original file line number Diff line number Diff line change
@@ -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<typeof InvoiceDeletedWebhookSchema>
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<DeletedInvoiceData> = {},
): DeletedInvoiceWebhookInput {
return {
eventType: ValidWebhookEvent.InvoiceDeleted,
data: {
id: TEST_INVOICE.id,
...dataOverrides,
},
}
}
14 changes: 14 additions & 0 deletions test/helpers/deleteSyncFlag.ts
Original file line number Diff line number Diff line change
@@ -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
})
}
5 changes: 5 additions & 0 deletions test/helpers/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}
}
Expand Down
49 changes: 49 additions & 0 deletions test/integration/webhook/invoiceDeleted/alreadyVoided.test.ts
Original file line number Diff line number Diff line change
@@ -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 })
})
})
35 changes: 35 additions & 0 deletions test/integration/webhook/invoiceDeleted/deleteSyncDisabled.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
59 changes: 59 additions & 0 deletions test/integration/webhook/invoiceDeleted/happyPath.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
49 changes: 49 additions & 0 deletions test/integration/webhook/invoiceDeleted/invoiceNotFound.test.ts
Original file line number Diff line number Diff line change
@@ -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,
})
})
})
56 changes: 56 additions & 0 deletions test/integration/webhook/invoiceDeleted/missingXeroInvoice.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
50 changes: 50 additions & 0 deletions test/integration/webhook/invoiceDeleted/noPriorCreatedLog.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})
})
22 changes: 22 additions & 0 deletions test/integration/webhook/invoiceDeleted/syncDisabled.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
Loading
Loading