Files
edr-platform/integration/README.md
Nathnael 415ae52143 test(integration): boot freight API in-process across parallel shards
The suite drove a containerized freight API, so every code change needed an
image rebuild before a test could see it, and there was no way to attach a
debugger. Files also ran strictly in sequence against one shared database,
which is the root of the warm-stack gotchas the README documents: stowaway
paid bookings climbing back aboard, a short consist on the fifth file.

The freight app now boots inside each vitest worker from dist/, and each
worker owns a whole shard of the topology - its own database, payment API,
gateway mock and broker vhost - so nothing mutable is shared and files run
in parallel. Full suite drops from roughly 20 minutes to 196s at 4 shards.

- main.ts exports createFreightApp() so the harness applies the same prefix,
  pipes, filters and interceptors as production instead of replaying them by
  hand; self-start is guarded by require.main so the Dockerfile CMD still boots
- booking-window tick cadence is env-driven (BOOKING_WINDOW_TICK_CRON), */1 in
  the suite, */10 unchanged in production
- prepare-shards.mjs seeds a template database (boot seeders, then the SQL
  fixtures that depend on them) and clones it per shard; it.mjs re-clones on
  every run, so each run is hermetic
- gateway mock and payment API are generated per shard: the mock keeps modes
  and orders process-global and 20 of 25 specs reset it in beforeAll, and the
  inbound CBE bill query has to reach one specific shard's app
- poll() samples every 250ms instead of 2000ms, keeping the caller's deadline
- authz.it.ts seeds its own invoice; it previously read another spec's leftover
  and returned early, which silently passed on a pristine database

Known: an unlocked MAX(sequence_no)+1 in train-scheduling.service.ts races
under concurrent allocation and leaves a short consist, so 1-3 specs fail
intermittently. Pre-existing and reproduces at the production tick cadence.
2026-08-04 12:43:25 +00:00

258 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# Freight API integration suite
Headless, API-level tests for the freight API running against the **real**
`edr-payment-api`. Only the bank/wallet gateways are stubbed.
The freight API is **not containerized** here — it boots inside each vitest
worker from `apps/edr-freight-api/dist`, which `it:test` rebuilds every run. A
code change needs no image rebuild, and a breakpoint in the API is hit by the
test that provoked it.
```
pnpm it:up # build + start the stack (first run ~5 min)
pnpm it:test # vitest run (auto-ups the stack if needed)
pnpm it:test -- --reporter=basic src/payment-happy.it.ts
pnpm it:test --no-reset -- src/payment-happy.it.ts # skip the DB re-clone
pnpm it:logs payment-api-it-0
pnpm it:down # -v, wipes the throwaway DBs and the generated shard file
IT_SHARDS=1 pnpm it:up # 1 shard when RAM is tight or output must be readable
```
## Stack
`docker-compose.it.yaml` is an **overlay** on `docker-compose.e2e.yaml`, plus a
third **generated** file (`integration/.it-shards.yaml`, written by
`scripts/gen-shards.mjs`, gitignored) holding the per-shard services. It is a
separate compose project (`edr-freight-it`) on offset ports, so the Cypress e2e
stack can run alongside.
Each vitest worker is a whole shard of the topology. Nothing mutable is shared
between shards, so spec files run in parallel:
```
shard i (vitest worker i, VITEST_POOL_ID)
freight app in-process, host :3111+i
│ ▲
│ └──── HTTP ──── payment-api-it-{i} :3131+i inbound CBE bill query,
│ │ via host.docker.internal
└── HTTP ──────────────► │ ──HTTP──> gateway-mock-it-{i} :4600+i
▲ │
└── RabbitMQ vhost payment_s{i} ──┘ (outbox → payment.events → consumer)
database edr_it_s{i} ← CREATE DATABASE … TEMPLATE edr_freight_e2e
freight tables AND the payment API's edr_payment
schema live in it, so `db()` and `paymentDb()` are
one connection
```
Shared by every shard: Postgres (one container, one database each), RabbitMQ
(one container, one vhost each), MinIO, and the one-shot migration.
`IT_SHARDS` defaults to **4**. RAM is the ceiling, not cores — a shard is an
in-process Nest app plus two containers.
Never `docker compose -f docker-compose.it.yaml` on its own — it needs the base
file first, and the generated shard file last. Use `it.mjs`.
## Setup order
The sequence is load-bearing, which is why `scripts/prepare-shards.mjs` exists
rather than vitest's `globalSetup`:
1. `freight-migration-e2e` migrates the **template** database `edr_freight_e2e`.
2. `prepare-shards.mjs` boots the freight app once against the template and
calls `app.init()` — that fires `onApplicationBootstrap`, the always-on
seeders (org, units, positions, permissions).
3. …then applies the SQL fixtures, which declare exactly those as prerequisites
(`seed-users.sql`: *"created by the API's always-on boot seeders"*).
4. `CREATE DATABASE edr_it_s{i} TEMPLATE edr_freight_e2e` per shard — a file
copy on the tmpfs Postgres, not a re-run of migrations and five seeders.
5. The per-shard payment APIs start; each creates its own `edr_payment` schema
inside its shard database.
`it:test` re-runs step 4 on **every run** (stop payment APIs → drop → clone →
start), so each run is hermetic. `--no-reset` skips it for a quick rerun.
## Gateway mock
`gateway-mock/server.js` — one zero-dep `node:http` process serving every
provider under a path prefix, plus a control plane the tests drive:
| call | effect |
| --- | --- |
| `POST /__control/provider/:name` `{mode, times}` | `ok` / `fail` / `timeout` / `pending` / `paid` |
| `POST /__control/webhook` `{merchantOrderId, status, eventId, signature}` | fires a **correctly signed** callback at the payment API |
| `POST /__control/settle` `{merchantOrderId}` | pays at the bank with no callback (reconciliation path) |
| `GET /__control/calls` | every inbound provider call |
| `POST /__control/reset` | clear modes, orders and calls |
Signatures are real: the mock shares `CBE_SECRET_KEY` with the API, so
`verifyWebhookSignature` runs for real and `signature: "bad"` is a genuine
negative test. The suite drives **CBE Birr** end to end (plain HMAC, no key
material); other providers answer a generic stub until a scenario needs them.
Editing `server.js` needs a container restart (`docker compose … restart
gateway-mock-it-0`) — the code is a read-only mount, not baked into an image.
**One mock per shard.** `modes`, `calls` and `orders` are process-global in
`server.js`, and 20 of 25 specs call `POST /__control/reset` in `beforeAll`. On a
shared mock every starting spec would wipe every running spec's live orders —
and that fails as a wrong *assertion*, not an error: an unknown order still gets
a correctly-signed webhook, just carrying the mock's default amount. Per-shard
mocks are why the suite can run `payment-failure` (which forces providers into
`fail`/`timeout`) beside a spec that settles normally.
## Files
| file | covers |
| --- | --- |
| `src/payment-happy.it.ts` | initiate → webhook → outbox → broker → invoice PAID → booking advances |
| `src/payment-failure.it.ts` | provider down, decline, forged signature, reconciliation sweep, `unverifiable`, late capture |
| `src/concurrency.it.ts` | duplicate callbacks, two intents on one invoice, wagon budget, pay-window gate |
| `src/authz.it.ts` | cross-tenant isolation, login audience, service-token gates |
| `src/cbe-bill.it.ts` | inbound CBE Unified Bill: token → query (hops into freight) → payment |
| `src/expired-invoice-late-settle.it.ts` | pay-window drain tail; a settlement landing after the hold expired still pays the invoice and revives the booking |
| `src/bulk-import-full-train.it.ts` | six wheat bookings fill 54 wagons, then gate pass → T1 → dispatch → corridor → arrival → customs tail |
| `src/bulk-import-waiting-expiry.it.ts` | exact-fill trio selected, waiting three expire with the day |
| `src/bulk-import-split-promote.it.ts` | partial offer, split on settlement, expiry promotion, exact-remainder rebooking |
| `src/bulk-import-window-reopen.it.ts` | nobody pays → cycle 2 opens on the same train |
| `src/bulk-import-matrix.it.ts` | no-window day, sub-corridor, ride-along, whole-train giant |
| `src/bulk-export-full-train.it.ts` | FCFS accept = reservation, deadlines clamped to window close, export tail |
| `src/bulk-export-fcfs-space.it.ts` | reservations hold capacity, whole-or-nothing giant |
| `src/bulk-export-pay-or-lose.it.ts` | expiry frees space; window close expires the unpaid, no reopen |
| `src/bulk-export-matrix.it.ts` | mid-route boarding, leg occupancy, ride-along, sibling train windows |
| `src/bulk-b1-priority-expiry-refill.it.ts` | rule-engine priority band, expiry refill |
| `src/bulk-b2-per-item-floor.it.ts` | PER_ITEM wagon floor vs tonnage math |
| `src/bulk-b3-per-item-giant.it.ts` | PER_ITEM giant + line quantities (pins two defects) |
| `src/g1-s1-expiry-promotes-waitlist.it.ts` | 53-wagon built train: expiry frees exactly the waiting list's space |
| `src/g1-s2-exact-fill.it.ts` | exact 53/53 fill, every one of 68 containers mapped to a slot |
| `src/g1-s3-underfill-day-open.it.ts` | 28/53 is NOT FULL — the day still offers 25 wagons |
| `src/g1-s4-split-closes-gap.it.ts` | container split closes the last 3-wagon gap, 14-box remainder |
| `src/g1-s5-cascading-expiry.it.ts` | one settle promotes twice; expiries terminal, invoices closed |
| `src/g1-s6-s8-offers-government-tiers.it.ts` | ignored offer, government preemption, USD/customs/plain tiers |
| `src/g2-weight.it.ts` | weight before slots: base pull, overage tolerance, split sized on base only, light cargo |
| `src/flows.ts` | freight business steps, ported from `e2e/freight/cypress/e2e/flows/import-utils.ts` |
## Findings pinned by these tests
Where the platform's live behaviour differs from the scenario, the test asserts
what it actually does and says so in a comment, so a fix fails loudly:
- **Refill never supersedes an open partial offer** (`bulk-b1`, `g1-s5`). After
the giant expires and frees 28 wagons, the offered booking is re-selected but
keeps its stale 16-wagon offer; `applySplit` applies it, so the customer ships
16 of 20 with room to spare. `g1-s5` pins the container half: an expiry frees
8 more wagons and the 2-wagon offer beside them is never resized.
- **PER_ITEM bookings never get a partial offer** (`bulk-b3`). `sizeOffer` sizes
bulk offers by weight off `cargoTotalWeightVgm`, which for PER_ITEM holds the
ITEM COUNT — a 240-auto booking needing 60 wagons looks like 4, gets no offer,
allocates nothing, and expires with the day.
- **The contract booking path drops bulk `hazardousQuantity` / `reeferQuantity`**
(`bulk-b3`). Only `POST /api/bookings` maps and clamps them.
- **Paid intercity ride-alongs are unpinned back to the pool** (both matrices) —
staff must place them again. The Cypress twin never sees this because its
staff mark-paid shortcut leaves the reservation pinned.
- **Bulk priority is recomputed at doc-review** (`bulk-b1`), so writing
`priority_score` directly is a no-op; ranking has to come from a WAGON
priority config.
- **Freight sends a dev-shortcut amount** (1 minor unit, 10 for CAC) for every
non-`CBE_BILL` provider, with no short-payment guard.
- **A BUILT train's batch is blind to the pull limit** (`g2-weight`, last
describe). `remainingBudget` replaces the locomotive limits with
`{wagons: physicalWagons, weightTons: Infinity, lengthMeters: Infinity}` the
moment a schedule has a built train, so the batch reserves — and invoices —
a load the locomotives cannot pull. The only check left is at wagon
allocation, which then fails every tick with "Train set locomotives cannot
pull the gross weight … limit 3500T incl. tolerance". The customer is PAID
with zero wagons. Group 2 therefore runs its real weight scenarios on
locomotive PAIRS, where the limits survive.
- **The allocator weighs the whole consist, the batch weighs the booking.**
Allocation charges the tare of every wagon in the train set (53 × 22.4 T on
the G2 consist), while `needFor` charges only the tare of the wagons the
booking occupies — so the same 45-wagon load reads 3 528 T at reservation and
3 707.2 T at allocation. Two capacity models, one train.
- **Government preemption cannot reach a FULL train** (`g1-s6-s8`). `isFillable`
rejects a schedule whose `booking_window_status` is FULL before any budget or
victim is considered, and `refreshWindowStatus` re-derives that flag from live
capacity — so a genuinely full train is skipped and no commercial booking is
ever displaced. S7 therefore commits 52 of 53 slots.
- **The CUSTOMS priority band is dead in the shipped fixture.** It applies only
when the booking's SERVICE TYPE has `includes_customs`, and the corridor seed
ships one service type that does not — so the two CUSTOMS bands never score.
`seed-customs-service-type.sql` adds `RAIL_CUSTOMS` so the tier can be tested.
- **A customs SERVICE TYPE without a customs CONTRACT cannot finalize
clearance.** `finalizeClearance` looks up `clearance_output_<op>_<freight>`,
which the seeder deliberately leaves commented out, so `getByCode` 404s. Only
the phased path (`customs_clearing_enabled` on the contract) avoids it — which
is why S8's customs tenant is Path B.
- **A live intent makes a hold unexpirable here** (`expired-invoice-late-settle`).
Reconcile-before-expire live-queries every non-FAILED intent; the mock answers
`PROCESSING` for an unpaid order, which the payment API reads as "money in
flight" → `unverifiable` → never expire on unknown. So while a CBE Birr intent
is open, NOTHING retires the reservation — not the settle tick, not the staff
`bookings/:id/expire` override (it runs the same guard). Producing the
expired-invoice-with-a-payment case therefore needs the intent retired first
(`status = 'FAILED'`, which reconcile skips), after which a webhook still
late-captures it (`applyProviderResult`).
## Gotchas
- **One unpaid hold per company.** `assertNoUnpaidHold` blocks a company with a
`SELECTED_FOR_BATCH` booking from creating another. Every file starts with
`releaseUnpaidHolds()`, which also hard-deletes retired
`train_schedule_bookings` rows (`booking_id` is UNIQUE and the constraint
ignores `deleted_at`, so a soft-unlinked booking can never be re-batched).
- **Arrange is slow.** Contract → booking → clearance → ops accept → batch is
3060s of real API work per booking, so files share one schedule day.
- **Files run in parallel across shards, sequentially within one.** A shard owns
its whole topology, so two files never contend; two files on the *same* shard
still run one after the other, which is what the day-offset partitioning and
the per-file `releaseUnpaidHolds()` / `resetCorridorDay()` hygiene still
assume. Concurrency inside a single test is exercised with `Promise.all`.
- **Day offsets are the de-facto partition key** (`departureAt(n)` per file).
`payment-happy` and `expired-invoice-late-settle` both claim day 4 — harmless
now that a shard has its own database, but do not read the offsets as unique.
- **A retired fixture keeps its shipment day at its peril.**
`rescueStrandedPaidForDay` sweeps every unlinked booking whose
`payment_status` is PAID and whose `scheduled_date` falls on the day being
filled, and re-places it on the fresh train. Within a run this still bites
across files on one shard; *between* runs it no longer can — `it:test`
re-clones every shard database from the template first.
- **Only a FULL train rests at DONE.** An under-filled day CONCLUDES and
REOPENS (`window_phase` back to OPEN, `booking_cycle_no` 2), so waiting for
DONE there waits forever — use `pollCycleConcluded`.
- **Wagon stock is finite and shared** *within a shard*. Paid bookings keep
their wagons, so `releaseUnpaidHolds()` also frees every earlier `CTR-IT-%`
allocation — without it the fifth or sixth file on one shard silently gets a
short consist.
- **`poll()` samples every 250ms, but keeps the caller's deadline.** Callers pass
`attempts × intervalMs`; that product is the timeout, and only the sampling
rate changed. Override with `IT_POLL_INTERVAL_MS`.
- **The tick cadence is env-driven.** `BOOKING_WINDOW_TICK_CRON` is `*/1` here
and `*/10` in production. Every window phase, allocation and expiry the suite
waits on lands on that tick. A spec that proves sensitive to it should pin its
own value rather than the whole suite reverting.
- **One tenant per booking.** `seedTenantContracts` mints a company per booking
because a company may hold only one unpaid reservation at a time; staff book
and pay on their behalf, which is also the real Path B flow.
- **The weight axis is GROSS, but the column is not.**
`wagon_booking_allocations.allocated_weight_tons` holds CARGO only;
`allocatedGrossTons` adds each wagon type's tare, because the pull limit is
spent on both. Two 20ft at 28 T ride one wagon at 78.4 T gross — 35 of those
spend a 3 500 T locomotive pair, and reading the raw column would report
1 960 T and hide it.
- **Group 1 rides a BUILT train, not a loco pair.** `maxWagonsPerTrain` is not a
cap: `syncScheduleMaxWagons` recomputes it from locomotive length (54 here)
every fill pass. A built train's coupled consist wins outright, so
`seed-g1-train.sql`'s 53 wagons ARE the capacity — the number every G1
scenario's arithmetic is written in. `createBuiltTrainSchedule` asserts it.
- **Customs bookings walk the phased chain** (`clearBookingPhasedCustoms`):
transit assignee → declaration draft → accept → declaration → duty →
transit permit → pre-clearance → delivery order. `clearance/finalize` refuses
them outright.
- Freight sends a dev-shortcut amount (1 minor unit, 10 for CAC) for every
non-`CBE_BILL` provider. The tests assert that as-is.