Skip to content
Merged
Show file tree
Hide file tree
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 May 8, 2026
5ac9c73
test(OUT-3708): clarify clientCompanyId rationale in seedQBCustomer
SandipBajracharya May 8, 2026
b18ee13
test(OUT-3708): add invoiceCreated webhook fixture
SandipBajracharya May 8, 2026
25773fc
test(OUT-3708): extend default mocks with invoice-flow methods
SandipBajracharya May 8, 2026
a59de76
test(OUT-3708): add setupInvoiceCreatedTest helper
SandipBajracharya May 8, 2026
1fb107e
test(OUT-3708): add invoice.created happy path integration test
SandipBajracharya May 8, 2026
3eafead
test(OUT-3708): prettier fixup on mocks.ts
SandipBajracharya May 8, 2026
f8f04fc
test(OUT-3708): pin draft status skip
SandipBajracharya May 8, 2026
f4c4f8d
test(OUT-3708): pin idempotency via existing claim row
SandipBajracharya May 8, 2026
d758c58
test(OUT-3708): pin short-circuit when invoice sync row exists
SandipBajracharya May 8, 2026
bc82051
test(OUT-3708): pin FAILED log path on QB createInvoice failure
SandipBajracharya May 8, 2026
6038e0c
test(OUT-3708): pin status=paid inline payment creation
SandipBajracharya May 8, 2026
734dd50
test(OUT-3708): pin one-off line item routing
SandipBajracharya May 8, 2026
88dd84d
test(OUT-3708): pin createNewProductFlag=false fallback to one-off
SandipBajracharya May 8, 2026
10a11e7
test(OUT-3708): pin useCompanyNameFlag company-only customer path
SandipBajracharya May 8, 2026
d0cb447
test(OUT-3708): unit-test handleInvoiceCreated orchestration branches
SandipBajracharya May 8, 2026
59dd1ce
test(OUT-3708): prettier fixup on statusPaidCreatesPayment
SandipBajracharya May 8, 2026
9804cac
test(OUT-3708): move @/utils/intuit mock to shared setup to fix flake
SandipBajracharya May 8, 2026
8318643
test(OUT-3708): fix happyPath ItemRef false positive + invoiceAlready…
SandipBajracharya May 8, 2026
969fede
test(OUT-3708): cover DocNumber collisions and clarify invoice.create…
SandipBajracharya May 19, 2026
17dc954
docs(OUT-3708): include high level test flow diagram doc
SandipBajracharya May 19, 2026
43c1460
chore(OUT-3708): lint fix
SandipBajracharya May 19, 2026
32d1007
chore(OUT-3708): isolate test/ from src TS program and forbid cross-i…
SandipBajracharya May 19, 2026
bc7fda7
test(OUT-3708): rebuild DocNumber-race mocks per beforeEach + trim co…
SandipBajracharya May 19, 2026
a296a04
test(OUT-3708): convert JSON webhook fixtures to TS for schema-typed …
SandipBajracharya May 19, 2026
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
17 changes: 17 additions & 0 deletions eslint.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,23 @@ const eslintConfig = [
],
},
}),
{
files: ['src/**/*.{ts,tsx}'],
rules: {
'no-restricted-imports': [
'error',
{
patterns: [
{
group: ['@test/*', '**/test/*', '**/test/**'],
message:
'Source code must not import from the test/ folder. Test helpers belong to test files only.',
},
],
},
],
},
},
{
ignores: [
'.next',
Expand Down
157 changes: 157 additions & 0 deletions test/diagrams/test-flow.md
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.
41 changes: 41 additions & 0 deletions test/fixtures/invoiceCreated.webhook.ts
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
17 changes: 0 additions & 17 deletions test/fixtures/priceCreated.webhook.json

This file was deleted.

25 changes: 25 additions & 0 deletions test/fixtures/priceCreated.webhook.ts
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
43 changes: 43 additions & 0 deletions test/helpers/invoiceCreatedTestSetup.ts
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()
})
Comment thread
SandipBajracharya marked this conversation as resolved.

return handle
}
51 changes: 51 additions & 0 deletions test/helpers/mocks.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,26 @@ export function createMockCopilotAPI(overrides: CopilotAPIOverrides = {}) {
createdAt: '2024-09-11T13:59:58.845233992Z',
updatedAt: '2024-09-11T13:59:58.845233992Z',
}),
// --- invoice.created defaults (OUT-3708) ---
getClient: vi.fn().mockResolvedValue({
id: '11111111-1111-1111-1111-111111111111',
givenName: 'Jane',
familyName: 'Doe',
email: 'jane@example.com',
companyId: '',
status: 'active',
}),
getCompany: vi.fn().mockResolvedValue(undefined),
getClients: vi.fn().mockResolvedValue({ data: [] }),
getPrice: vi.fn().mockResolvedValue({
id: 'C-wch-eSg',
productId: '2cf93cf0-45fa-485f-b584-03c2c38a3999',
amount: 60000,
currency: 'usd',
type: 'recurring',
}),
getPayments: vi.fn().mockResolvedValue({ data: [] }),
getInvoice: vi.fn().mockResolvedValue(undefined),
...overrides,
}
}
Expand Down Expand Up @@ -69,6 +89,37 @@ export function createMockIntuitAPI(overrides: IntuitAPIOverrides = {}) {
SyncToken: '0',
UnitPrice: 600,
}),
// --- invoice.created defaults (OUT-3708) ---
getACustomer: vi.fn().mockResolvedValue(undefined),
getCustomerByEmail: vi.fn().mockResolvedValue(undefined),
resolveUniqueCustomerName: vi
.fn()
.mockImplementation(async (n: string) => n),
createCustomer: vi.fn().mockResolvedValue({
Id: 'qb-cust-1',
SyncToken: '0',
DisplayName: 'Jane Doe',
PrimaryEmailAddr: { Address: 'jane@example.com' },
Active: true,
}),
customerSparseUpdate: vi.fn().mockResolvedValue({
Id: 'qb-cust-1',
SyncToken: '1',
DisplayName: 'Jane Doe',
PrimaryEmailAddr: { Address: 'jane@example.com' },
Active: true,
}),
createInvoice: vi.fn().mockResolvedValue({
Invoice: { Id: 'qb-inv-1', SyncToken: '0' },
}),
createPayment: vi.fn().mockResolvedValue({
Payment: { Id: 'qb-pay-1', SyncToken: '0' },
}),
// webhookInvoiceCreated pre-flights QBO for DocNumber collisions before
// every createInvoice call (OUT-3710). Default to "no collisions" so the
// base Assembly invoice number is used; override per-test to exercise the
// suffix-walk path.
findInvoicesByDocNumberPrefix: vi.fn().mockResolvedValue([]),
...overrides,
}
}
Expand Down
Loading
Loading