-
Notifications
You must be signed in to change notification settings - Fork 0
OUT-3773: integration tests for payment.succeeded webhook event #256
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
8 commits
Select commit
Hold shift + click to select a range
ddd4921
test(OUT-3773): mock sleep and afterIfAvailable globally for integrat…
SandipBajracharya 45eae49
test(OUT-3773): extend test helpers and fixture for payment.succeeded
SandipBajracharya 6a2faf3
test(OUT-3773): integration tests for payment.succeeded webhook event
SandipBajracharya 948c2fe
test(OUT-3773): annotate getAnAccount positional args and fix prettie…
SandipBajracharya f00b91b
test(OUT-3773): document Name/AccountType as stubs in getAnAccount mock
SandipBajracharya 5be6a22
test(OUT-3773): use TEST_COPILOT_INVOICE_ID in getInvoice mock default
SandipBajracharya 5ec0d1b
test(OUT-3773): export paymentSucceededPayload as named, restore spie…
SandipBajracharya c707b4a
chore(OUT-3773): lint fix
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,30 @@ | ||
| import type { z } from 'zod' | ||
|
|
||
| import { PaymentStatus } from '@/app/api/core/types/invoice' | ||
| import { PaymentSucceededResponseSchema } from '@/type/dto/webhook.dto' | ||
| import { | ||
| TEST_COPILOT_INVOICE_ID, | ||
| TEST_COPILOT_PAYMENT_ID, | ||
| } from '@test/helpers/seed' | ||
|
|
||
| type Envelope = { | ||
| eventType: 'payment.succeeded' | ||
| object: 'payment' | ||
| } | ||
|
|
||
| type PaymentSucceededFixture = Envelope & | ||
| z.input<typeof PaymentSucceededResponseSchema> | ||
|
|
||
| export const paymentSucceededPayload: PaymentSucceededFixture = { | ||
| eventType: 'payment.succeeded', | ||
| object: 'payment', | ||
| data: { | ||
| id: TEST_COPILOT_PAYMENT_ID, | ||
| invoiceId: TEST_COPILOT_INVOICE_ID, | ||
| status: PaymentStatus.SUCCEEDED, | ||
| paymentMethod: 'creditCard', | ||
| brand: 'visa', | ||
| feeAmount: { paidByPlatform: 2500, paidByClient: 0 }, | ||
| createdAt: '2024-02-21T15:31:16.789Z', | ||
| }, | ||
| } |
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,44 @@ | ||
| import { beforeEach, afterEach, vi } from 'vitest' | ||
| import { truncateAllTestTables } from '@test/helpers/testDb' | ||
| import { | ||
| installMockApis, | ||
| type MockCopilotAPI, | ||
| type MockIntuitAPI, | ||
| } from '@test/helpers/mocks' | ||
|
|
||
| type InstallOpts = Parameters<typeof installMockApis>[0] | ||
|
|
||
| export interface PaymentSucceededTestHandle { | ||
| copilot: MockCopilotAPI | ||
| intuit: MockIntuitAPI | ||
| } | ||
|
|
||
| /** | ||
| * Registers the standard `beforeEach` (truncate + installMockApis) and | ||
| * `afterEach` (clearAllMocks) hooks used by every payment.succeeded | ||
| * integration test. Mirrors `setupInvoiceCreatedTest` / | ||
| * `setupPriceCreatedTest`. The `optsFactory` is invoked once per test so | ||
| * callers can supply overrides whose underlying `vi.fn()`s are freshly | ||
| * instantiated. | ||
| */ | ||
| export function setupPaymentSucceededTest( | ||
| optsFactory?: () => InstallOpts, | ||
| ): PaymentSucceededTestHandle { | ||
| const handle = {} as PaymentSucceededTestHandle | ||
|
|
||
| beforeEach(async () => { | ||
| await truncateAllTestTables() | ||
| const { copilot, intuit } = installMockApis(optsFactory?.()) | ||
| handle.copilot = copilot | ||
| handle.intuit = intuit | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| // clearAllMocks (not restoreAllMocks) — the module-level mock factories in | ||
| // test/integration/setup.ts must stay installed across tests; we only want | ||
| // to reset call counts and implementations set in beforeEach. | ||
| vi.clearAllMocks() | ||
| }) | ||
|
|
||
| return handle | ||
| } |
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
26 changes: 26 additions & 0 deletions
26
test/integration/quickbooks/paymentSucceeded/absorbedFeeFlagOff.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,26 @@ | ||
| import { describe, it, expect } from 'vitest' | ||
|
|
||
| import { db } from '@/db' | ||
| import { QBSyncLog } from '@/db/schema/qbSyncLogs' | ||
|
|
||
| import { paymentSucceededPayload } from '@test/fixtures/paymentSucceeded.webhook' | ||
| import { seedHealthyPortal, seedQBInvoiceSync } from '@test/helpers/seed' | ||
| import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' | ||
| import { postWebhook } from '@test/helpers/webhook' | ||
|
|
||
| describe('POST /api/quickbooks/webhook — payment.succeeded (absorbed-fee flag off)', () => { | ||
| const apis = setupPaymentSucceededTest() | ||
|
|
||
| it('skips processing when the portal opts out of recording absorbed fees', async () => { | ||
| await seedHealthyPortal({ setting: { absorbedFeeFlag: false } }) | ||
| await seedQBInvoiceSync() | ||
|
|
||
| const res = await postWebhook(paymentSucceededPayload) | ||
| expect(res.status).toBe(200) | ||
|
|
||
| expect(await db.select().from(QBSyncLog)).toHaveLength(0) | ||
| expect(apis.copilot.getInvoice).not.toHaveBeenCalled() | ||
| expect(apis.intuit.getAnAccount).not.toHaveBeenCalled() | ||
| expect(apis.intuit.createPurchase).not.toHaveBeenCalled() | ||
| }) | ||
| }) |
51 changes: 51 additions & 0 deletions
51
test/integration/quickbooks/paymentSucceeded/copilotInvoiceNotFound.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,51 @@ | ||
| import { describe, it, expect, vi } from 'vitest' | ||
| import { eq } from 'drizzle-orm' | ||
|
|
||
| import { db } from '@/db' | ||
| import { QBSyncLog } from '@/db/schema/qbSyncLogs' | ||
| import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log' | ||
|
|
||
| import { paymentSucceededPayload } from '@test/fixtures/paymentSucceeded.webhook' | ||
| import { | ||
| seedHealthyPortal, | ||
| seedQBInvoiceSync, | ||
| TEST_COPILOT_PAYMENT_ID, | ||
| } from '@test/helpers/seed' | ||
| import { createMockCopilotAPI } from '@test/helpers/mocks' | ||
| import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' | ||
| import { postWebhook } from '@test/helpers/webhook' | ||
|
|
||
| describe('POST /api/quickbooks/webhook — payment.succeeded (Copilot returns no invoice)', () => { | ||
| const apis = setupPaymentSucceededTest(() => ({ | ||
| copilot: createMockCopilotAPI({ | ||
| getInvoice: vi.fn().mockResolvedValue(undefined), | ||
| }), | ||
| })) | ||
|
|
||
| it('returns a 404 and does not call QuickBooks when Copilot cannot find the invoice', async () => { | ||
| await seedHealthyPortal({ setting: { absorbedFeeFlag: true } }) | ||
| await seedQBInvoiceSync() | ||
|
|
||
| const res = await postWebhook(paymentSucceededPayload) | ||
| // The not-found throw escapes the inner try/catch (which only wraps the QB | ||
| // calls) and propagates to withErrorHandler, which surfaces it as 404. | ||
| expect(res.status).toBe(404) | ||
|
|
||
| // No FAILED log is written — the throw happens before the outer catch block | ||
| // that writes sync logs for QB-layer errors. The claimed PENDING row is the | ||
| // only row in the table. | ||
| const logs = await db | ||
| .select() | ||
| .from(QBSyncLog) | ||
| .where(eq(QBSyncLog.copilotId, TEST_COPILOT_PAYMENT_ID)) | ||
| expect(logs).toHaveLength(1) | ||
| expect(logs[0]).toMatchObject({ | ||
| entityType: EntityType.PAYMENT, | ||
| eventType: EventType.SUCCEEDED, | ||
| status: LogStatus.PENDING, | ||
| }) | ||
|
|
||
| expect(apis.intuit.createPurchase).not.toHaveBeenCalled() | ||
| expect(apis.intuit.deletePurchase).not.toHaveBeenCalled() | ||
| }) | ||
| }) |
84 changes: 84 additions & 0 deletions
84
test/integration/quickbooks/paymentSucceeded/happyPath.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,84 @@ | ||
| import { describe, it, expect } from 'vitest' | ||
| import { eq } from 'drizzle-orm' | ||
|
|
||
| import { db } from '@/db' | ||
| import { QBSyncLog } from '@/db/schema/qbSyncLogs' | ||
| import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log' | ||
|
|
||
| import { paymentSucceededPayload } from '@test/fixtures/paymentSucceeded.webhook' | ||
| import { | ||
| seedHealthyPortal, | ||
| seedQBInvoiceSync, | ||
| TEST_PORTAL_ID, | ||
| TEST_INVOICE_NUMBER, | ||
| TEST_COPILOT_INVOICE_ID, | ||
| TEST_COPILOT_PAYMENT_ID, | ||
| TEST_QB_PURCHASE_ID, | ||
| TEST_ASSET_ACCOUNT_REF, | ||
| TEST_EXPENSE_ACCOUNT_REF, | ||
| } from '@test/helpers/seed' | ||
| import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' | ||
| import { postWebhook } from '@test/helpers/webhook' | ||
|
|
||
| describe('POST /api/quickbooks/webhook — payment.succeeded (absorbed-fee expense recorded)', () => { | ||
| const apis = setupPaymentSucceededTest() | ||
|
|
||
| it('records the absorbed fee as a QuickBooks expense and logs the sync as successful', async () => { | ||
| await seedHealthyPortal({ setting: { absorbedFeeFlag: true } }) | ||
| await seedQBInvoiceSync() | ||
|
|
||
| const res = await postWebhook(paymentSucceededPayload) | ||
| expect(res.status).toBe(200) | ||
|
|
||
| // For (payment, succeeded) the polymorphic `quickbooks_id` column holds the | ||
| // QBO Purchase id, not a payment id. See memory/project_qb_sync_logs_semantics.md. | ||
| const logs = await db | ||
| .select() | ||
| .from(QBSyncLog) | ||
| .where(eq(QBSyncLog.copilotId, TEST_COPILOT_PAYMENT_ID)) | ||
| expect(logs).toHaveLength(1) | ||
| expect(logs[0]).toMatchObject({ | ||
| portalId: TEST_PORTAL_ID, | ||
| entityType: EntityType.PAYMENT, | ||
| eventType: EventType.SUCCEEDED, | ||
| status: LogStatus.SUCCESS, | ||
| copilotId: TEST_COPILOT_PAYMENT_ID, | ||
| quickbooksId: TEST_QB_PURCHASE_ID, | ||
| feeAmount: '2500.00', | ||
| qbItemName: 'Assembly Fees', | ||
| remark: 'Absorbed fees', | ||
| }) | ||
|
|
||
| expect(apis.copilot.getInvoice).toHaveBeenCalledWith( | ||
| TEST_COPILOT_INVOICE_ID, | ||
| ) | ||
| expect(apis.intuit.getAnAccount).toHaveBeenCalledTimes(2) // asset + expense | ||
| expect(apis.intuit.getAnAccount).toHaveBeenCalledWith( | ||
| undefined, // account name | ||
| TEST_ASSET_ACCOUNT_REF, // account id | ||
| true, // includeInactive | ||
| ) | ||
| expect(apis.intuit.getAnAccount).toHaveBeenCalledWith( | ||
| undefined, | ||
| TEST_EXPENSE_ACCOUNT_REF, | ||
| true, | ||
| ) | ||
| expect(apis.intuit.createPurchase).toHaveBeenCalledTimes(1) | ||
| expect(apis.intuit.deletePurchase).not.toHaveBeenCalled() | ||
|
|
||
| const [purchasePayload] = apis.intuit.createPurchase.mock.calls[0] | ||
| expect(purchasePayload).toMatchObject({ | ||
| PaymentType: 'Cash', | ||
| AccountRef: { value: TEST_ASSET_ACCOUNT_REF }, | ||
| DocNumber: TEST_INVOICE_NUMBER, | ||
| TxnDate: '2024-02-21', | ||
| }) | ||
| expect(purchasePayload.Line[0]).toMatchObject({ | ||
| DetailType: 'AccountBasedExpenseLineDetail', | ||
| Amount: 25, | ||
| AccountBasedExpenseLineDetail: { | ||
| AccountRef: { value: TEST_EXPENSE_ACCOUNT_REF }, | ||
| }, | ||
| }) | ||
| }) | ||
| }) |
54 changes: 54 additions & 0 deletions
54
test/integration/quickbooks/paymentSucceeded/idempotency.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,54 @@ | ||
| import { describe, it, expect } from 'vitest' | ||
|
|
||
| import { db } from '@/db' | ||
| import { QBSyncLog } from '@/db/schema/qbSyncLogs' | ||
| import { EntityType, EventType, LogStatus } from '@/app/api/core/types/log' | ||
|
|
||
| import { paymentSucceededPayload } from '@test/fixtures/paymentSucceeded.webhook' | ||
| import { | ||
| seedHealthyPortal, | ||
| seedQBInvoiceSync, | ||
| TEST_PORTAL_ID, | ||
| TEST_INVOICE_NUMBER, | ||
| TEST_COPILOT_PAYMENT_ID, | ||
| TEST_QB_PURCHASE_ID, | ||
| } from '@test/helpers/seed' | ||
| import { setupPaymentSucceededTest } from '@test/helpers/paymentSucceededTestSetup' | ||
| import { postWebhook } from '@test/helpers/webhook' | ||
|
|
||
| describe('POST /api/quickbooks/webhook — payment.succeeded (same webhook delivered twice)', () => { | ||
| const apis = setupPaymentSucceededTest() | ||
|
|
||
| it('processes the payment only once when a sync log for it already exists', async () => { | ||
| await seedHealthyPortal({ setting: { absorbedFeeFlag: true } }) | ||
| await seedQBInvoiceSync() | ||
|
|
||
| // Simulate a prior successful delivery that already claimed and processed | ||
| // this payment. Inline insert mirrors the pattern in invoiceCreated/idempotency.test.ts. | ||
| await db.insert(QBSyncLog).values({ | ||
| portalId: TEST_PORTAL_ID, | ||
| entityType: EntityType.PAYMENT, | ||
| eventType: EventType.SUCCEEDED, | ||
| status: LogStatus.SUCCESS, | ||
| copilotId: TEST_COPILOT_PAYMENT_ID, | ||
| invoiceNumber: TEST_INVOICE_NUMBER, | ||
| quickbooksId: TEST_QB_PURCHASE_ID, | ||
| feeAmount: '2500.00', | ||
| qbItemName: 'Assembly Fees', | ||
| remark: 'Absorbed fees', | ||
| }) | ||
|
|
||
| const res = await postWebhook(paymentSucceededPayload) | ||
| expect(res.status).toBe(200) | ||
|
|
||
| // Seeded log row stays exactly as it was; the second delivery did not | ||
| // overwrite it or insert a new row. | ||
| const logs = await db.select().from(QBSyncLog) | ||
| expect(logs).toHaveLength(1) | ||
| expect(logs[0].status).toBe(LogStatus.SUCCESS) | ||
|
|
||
| expect(apis.copilot.getInvoice).not.toHaveBeenCalled() | ||
| expect(apis.intuit.createPurchase).not.toHaveBeenCalled() | ||
| expect(apis.intuit.deletePurchase).not.toHaveBeenCalled() | ||
| }) | ||
| }) |
Oops, something went wrong.
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.