-
Notifications
You must be signed in to change notification settings - Fork 0
test(OUT-3708): unit + integration tests for invoice.created webhook #244
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
25 commits
Select commit
Hold shift + click to select a range
b94abf4
test(OUT-3708): add seedQBCustomer + seedQBInvoiceSync helpers
SandipBajracharya 5ac9c73
test(OUT-3708): clarify clientCompanyId rationale in seedQBCustomer
SandipBajracharya b18ee13
test(OUT-3708): add invoiceCreated webhook fixture
SandipBajracharya 25773fc
test(OUT-3708): extend default mocks with invoice-flow methods
SandipBajracharya a59de76
test(OUT-3708): add setupInvoiceCreatedTest helper
SandipBajracharya 1fb107e
test(OUT-3708): add invoice.created happy path integration test
SandipBajracharya 3eafead
test(OUT-3708): prettier fixup on mocks.ts
SandipBajracharya f8f04fc
test(OUT-3708): pin draft status skip
SandipBajracharya f4c4f8d
test(OUT-3708): pin idempotency via existing claim row
SandipBajracharya d758c58
test(OUT-3708): pin short-circuit when invoice sync row exists
SandipBajracharya bc82051
test(OUT-3708): pin FAILED log path on QB createInvoice failure
SandipBajracharya 6038e0c
test(OUT-3708): pin status=paid inline payment creation
SandipBajracharya 734dd50
test(OUT-3708): pin one-off line item routing
SandipBajracharya 88dd84d
test(OUT-3708): pin createNewProductFlag=false fallback to one-off
SandipBajracharya 10a11e7
test(OUT-3708): pin useCompanyNameFlag company-only customer path
SandipBajracharya d0cb447
test(OUT-3708): unit-test handleInvoiceCreated orchestration branches
SandipBajracharya 59dd1ce
test(OUT-3708): prettier fixup on statusPaidCreatesPayment
SandipBajracharya 9804cac
test(OUT-3708): move @/utils/intuit mock to shared setup to fix flake
SandipBajracharya 8318643
test(OUT-3708): fix happyPath ItemRef false positive + invoiceAlready…
SandipBajracharya 969fede
test(OUT-3708): cover DocNumber collisions and clarify invoice.create…
SandipBajracharya 17dc954
docs(OUT-3708): include high level test flow diagram doc
SandipBajracharya 43c1460
chore(OUT-3708): lint fix
SandipBajracharya 32d1007
chore(OUT-3708): isolate test/ from src TS program and forbid cross-i…
SandipBajracharya bc7fda7
test(OUT-3708): rebuild DocNumber-race mocks per beforeEach + trim co…
SandipBajracharya a296a04
test(OUT-3708): convert JSON webhook fixtures to TS for schema-typed …
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
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,157 @@ | ||
| # Test execution flow | ||
|
|
||
| High-level diagrams of how unit and integration tests run in this repo. Aimed at a new engineer onboarding to the test suite. | ||
|
|
||
| Source of truth for the wiring shown here: | ||
| - `vitest.config.ts` (two projects: `unit`, `integration`) | ||
| - `test/integration/globalSetup.ts` (Testcontainers + migrations) | ||
| - `test/integration/setup.ts` (shared mocks for outbound APIs) | ||
| - `test/helpers/*` (`seed.ts`, `testDb.ts`, `webhook.ts`, `invoiceCreatedTestSetup.ts`, `mocks.ts`) | ||
|
|
||
| Legend used in every diagram: | ||
| - **Blue** = real infrastructure (Postgres in a container). | ||
| - **Orange** = mocked at the module boundary; no real network call leaves the process. | ||
|
|
||
| --- | ||
|
|
||
| ## Unit tests | ||
|
|
||
| No container, no migrations, no HTTP layer. Each test file owns its mocks at the top of the file and instantiates the unit under test directly. | ||
|
|
||
| ```mermaid | ||
| flowchart TD | ||
| A["yarn test<br/>(Vitest)"] --> B["Unit project<br/>environment: node<br/>NO globalSetup, NO setupFiles"] | ||
|
|
||
| B --> C["Test file<br/>e.g. test/unit/api/quickbooks/webhook/handleInvoiceCreated.test.ts"] | ||
| C --> D["Per-file vi.mock(...) at top of file<br/>(every external boundary stubbed)"] | ||
|
|
||
| D --> D1["vi.mock @/db<br/>(no real Postgres at all)"] | ||
| D --> D2["vi.mock @/utils/copilotAPI"] | ||
| D --> D3["vi.mock @/utils/intuitAPI"] | ||
| D --> D4["vi.mock @sentry/nextjs"] | ||
| D --> D5["vi.mock @/utils/logger / sleep / auth"] | ||
|
|
||
| D1 --> E["beforeEach<br/>reset spies, wire return values"] | ||
| D2 --> E | ||
| D3 --> E | ||
| D4 --> E | ||
| D5 --> E | ||
|
|
||
| E --> F["Instantiate the unit under test directly<br/>e.g. new WebhookService(mockUser)"] | ||
| F --> G["Call the method<br/>service.handleWebhookEvent(payload)"] | ||
|
|
||
| G --> H1["Mocked DB calls → return canned rows"] | ||
| G --> H2["Mocked Copilot/Intuit clients → return canned data or throw"] | ||
|
|
||
| H1 --> I["Assertions"] | ||
| H2 --> I | ||
| I --> I1["expect(returnValue)"] | ||
| I --> I2["expect(mockFn).toHaveBeenCalledWith(...)"] | ||
|
|
||
| style D fill:#ffd6a5,stroke:#bc6c25 | ||
| style D1 fill:#ffd6a5,stroke:#bc6c25 | ||
| style D2 fill:#ffd6a5,stroke:#bc6c25 | ||
| style D3 fill:#ffd6a5,stroke:#bc6c25 | ||
| style D4 fill:#ffd6a5,stroke:#bc6c25 | ||
| style D5 fill:#ffd6a5,stroke:#bc6c25 | ||
| ``` | ||
|
|
||
| Reading guide: | ||
| - Unit tests never reach `route.ts` or Drizzle. | ||
| - Each unit test file owns its mock declarations (this is the opposite of integration, where mocks are centralized in `setup.ts`). | ||
| - The unit-under-test is constructed directly with a fake `User` and called as a plain function; assertions are on its return value and on the recorded calls to the mocked dependencies. | ||
|
|
||
| --- | ||
|
|
||
| ## Integration tests | ||
|
|
||
| Two full vertical flows shown side by side. **Left column** runs once per `yarn test`; **right column** runs for each `it`. Read each column top-to-bottom independently. No edge connects the two — Part 1 simply leaves the worker in a state Part 2 can use. | ||
|
|
||
| ```mermaid | ||
| %%{init: {"flowchart": {"defaultRenderer": "elk"}}}%% | ||
| flowchart LR | ||
| subgraph Bootstrap["Part 1 — Bootstrap (runs once)"] | ||
| direction TB | ||
| A["yarn test<br/>(Vitest)"] | ||
| B["Integration project<br/>pool: forks · fileParallelism: false · isolate: false"] | ||
| C["globalSetup.ts"] | ||
| C1["Testcontainers<br/>PostgreSqlContainer('postgres:16-alpine')"] | ||
| C2["drizzle-orm/migrator<br/>applies src/db/migrations/*"] | ||
| C3["process.env.DATABASE_URL =<br/>container.getConnectionUri()"] | ||
| C4["dotenv loads .env.test<br/>(override: true)"] | ||
| D["Single forked worker boots<br/>(inherits env vars)"] | ||
| E["setupFiles → test/integration/setup.ts"] | ||
| E1["vi.mock @/utils/copilotAPI"] | ||
| E2["vi.mock @/utils/intuitAPI"] | ||
| E3["vi.mock @/utils/intuit<br/>(pinned on globalThis)"] | ||
| E4["vi.mock @sentry/nextjs"] | ||
| R["Ready for test files"] | ||
|
|
||
| A --> B --> C --> C1 --> C2 --> C3 --> C4 --> D --> E | ||
| E --> E1 | ||
| E --> E2 | ||
| E --> E3 | ||
| E --> E4 | ||
| E1 --> R | ||
| E2 --> R | ||
| E3 --> R | ||
| E4 --> R | ||
| end | ||
|
|
||
| subgraph PerTest["Part 2 — Per-test request flow (runs each it)"] | ||
| direction TB | ||
| F["Test file<br/>e.g. invoiceCreated/happyPath.test.ts"] | ||
| G["setupInvoiceCreatedTest()"] | ||
| G1["beforeEach:<br/>truncateAllTestTables()<br/>+ installMockApis()"] | ||
| G2["afterEach:<br/>vi.clearAllMocks()"] | ||
| H["Test body"] | ||
| H1["seedHealthyPortal /<br/>seedProductSync<br/>(writes via @/db singleton)"] | ||
| H2["postWebhook(payload)"] | ||
| I["next-test-api-route-handler<br/>testApiHandler(...)"] | ||
| J["Next.js route<br/>src/app/api/quickbooks/webhook/route.ts"] | ||
| K["Controller → WebhookService<br/>→ Invoice/Product/Payment services"] | ||
| L1[("Postgres in Testcontainer<br/>via @/db Drizzle singleton")] | ||
| L2["MOCKED CopilotAPI"] | ||
| L3["MOCKED IntuitAPI"] | ||
| M["Response"] | ||
| N["expect(status / DB rows / mock.calls)"] | ||
|
|
||
| F --> G | ||
| G --> G1 | ||
| G --> G2 | ||
| G1 --> H | ||
| H --> H1 | ||
| H --> H2 | ||
| H2 --> I --> J --> K | ||
| K --> L1 | ||
| K --> L2 | ||
| K --> L3 | ||
| L1 --> M | ||
| L2 --> M | ||
| L3 --> M | ||
| M --> N | ||
| end | ||
|
|
||
| style C1 fill:#cfe8ff,stroke:#1e6091 | ||
| style L1 fill:#cfe8ff,stroke:#1e6091 | ||
| style E fill:#ffd6a5,stroke:#bc6c25 | ||
| style E1 fill:#ffd6a5,stroke:#bc6c25 | ||
| style E2 fill:#ffd6a5,stroke:#bc6c25 | ||
| style E3 fill:#ffd6a5,stroke:#bc6c25 | ||
| style E4 fill:#ffd6a5,stroke:#bc6c25 | ||
| style L2 fill:#ffd6a5,stroke:#bc6c25 | ||
| style L3 fill:#ffd6a5,stroke:#bc6c25 | ||
| style R fill:#e8e8e8,stroke:#555,stroke-dasharray: 4 3 | ||
| ``` | ||
|
|
||
| Takeaways: | ||
| - **One container, one worker, one module registry** for the whole run. Outbound APIs (Copilot, Intuit, Sentry) are mocked once in Part 1 and every test file inherits those mocks. | ||
| - `pool: forks + fileParallelism: false + isolate: false` is what makes the shared module registry safe — see `docs/vitest-gotchas.md` for the traps that motivated those settings. | ||
| - The test hits the **real** Next.js route handler — middleware, Zod parsing, and `withErrorHandler` all execute. Only **outbound network** is faked; the database is real Postgres (blue). | ||
| - `truncateAllTestTables()` is what keeps a shared container safe across tests. If you add a new table to `src/db/schema/*`, add it to `test/helpers/testDb.ts` too. | ||
|
|
||
| --- | ||
|
|
||
| ## One-line summary | ||
|
|
||
| > Unit tests stub everything outside the function under test; integration tests stub only the **outbound** network (Copilot + Intuit + Sentry) and run the real route handler against a real Postgres started by Testcontainers. |
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,41 @@ | ||
| import type { z } from 'zod' | ||
|
|
||
| import { InvoiceStatus } from '@/app/api/core/types/invoice' | ||
| import { InvoiceCreatedResponseSchema } from '@/type/dto/webhook.dto' | ||
|
|
||
| type Envelope = { | ||
| eventType: 'invoice.created' | ||
| object: 'invoice' | ||
| } | ||
|
|
||
| type InvoiceCreatedFixture = Envelope & | ||
| z.input<typeof InvoiceCreatedResponseSchema> | ||
|
|
||
| const invoiceCreatedPayload: InvoiceCreatedFixture = { | ||
| eventType: 'invoice.created', | ||
| object: 'invoice', | ||
| data: { | ||
| id: 'inv-cop-0001', | ||
| number: 'INV-0001', | ||
| status: InvoiceStatus.OPEN, | ||
| total: 60000, | ||
| clientId: '11111111-1111-1111-1111-111111111111', | ||
| companyId: '', | ||
| lineItems: [ | ||
| { | ||
| productId: '2cf93cf0-45fa-485f-b584-03c2c38a3999', | ||
| priceId: 'C-wch-eSg', | ||
| amount: 60000, | ||
| quantity: 1, | ||
| description: 'Test product line', | ||
| }, | ||
| ], | ||
| paymentMethodPreferences: [], | ||
| taxAmount: 0, | ||
| taxPercentage: null, | ||
| sentDate: '2026-05-08T00:00:00.000Z', | ||
| dueDate: '2026-05-15T00:00:00.000Z', | ||
| }, | ||
| } | ||
|
|
||
| export default invoiceCreatedPayload |
This file was deleted.
Oops, something went wrong.
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 type { z } from 'zod' | ||
|
|
||
| import { PriceCreatedResponseSchema } from '@/type/dto/webhook.dto' | ||
|
|
||
| type Envelope = { | ||
| eventType: 'price.created' | ||
| object: 'price' | ||
| created: string | ||
| } | ||
|
|
||
| type PriceCreatedFixture = Envelope & z.input<typeof PriceCreatedResponseSchema> | ||
|
|
||
| const priceCreatedPayload: PriceCreatedFixture = { | ||
| eventType: 'price.created', | ||
| object: 'price', | ||
| created: '2024-09-11T13:59:58.845233992Z', | ||
| data: { | ||
| id: 'C-wch-eSg', | ||
| productId: '2cf93cf0-45fa-485f-b584-03c2c38a3999', | ||
| amount: 60000, | ||
| type: 'recurring', | ||
| }, | ||
| } | ||
|
|
||
| export default priceCreatedPayload |
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,43 @@ | ||
| 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 InvoiceCreatedTestHandle { | ||
| copilot: MockCopilotAPI | ||
| intuit: MockIntuitAPI | ||
| } | ||
|
|
||
| /** | ||
| * Registers the standard `beforeEach` (truncate + installMockApis) and | ||
| * `afterEach` (clearAllMocks) hooks used by every invoice.created integration | ||
| * test. Returns a live handle whose `copilot` / `intuit` properties are | ||
| * replaced with fresh mock instances before each test. | ||
| * | ||
| * Mirrors `setupPriceCreatedTest`. The `optsFactory` is invoked once per test | ||
| * so callers can supply overrides whose underlying `vi.fn()`s are freshly | ||
| * instantiated. | ||
| */ | ||
| export function setupInvoiceCreatedTest( | ||
| optsFactory?: () => InstallOpts, | ||
| ): InvoiceCreatedTestHandle { | ||
| const handle = {} as InvoiceCreatedTestHandle | ||
|
|
||
| beforeEach(async () => { | ||
| await truncateAllTestTables() | ||
| const { copilot, intuit } = installMockApis(optsFactory?.()) | ||
| handle.copilot = copilot | ||
| handle.intuit = intuit | ||
| }) | ||
|
|
||
| afterEach(() => { | ||
| 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
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.