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.
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:
freight-migration-e2emigrates the template databaseedr_freight_e2e.prepare-shards.mjsboots the freight app once against the template and callsapp.init()— that firesonApplicationBootstrap, the always-on seeders (org, units, positions, permissions).- …then applies the SQL fixtures, which declare exactly those as prerequisites
(
seed-users.sql: "created by the API's always-on boot seeders"). CREATE DATABASE edr_it_s{i} TEMPLATE edr_freight_e2eper shard — a file copy on the tmpfs Postgres, not a re-run of migrations and five seeders.- The per-shard payment APIs start; each creates its own
edr_paymentschema 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;applySplitapplies it, so the customer ships 16 of 20 with room to spare.g1-s5pins 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).sizeOffersizes bulk offers by weight offcargoTotalWeightVgm, 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). OnlyPOST /api/bookingsmaps 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 writingpriority_scoredirectly 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_BILLprovider, with no short-payment guard. -
A BUILT train's batch is blind to the pull limit (
g2-weight, last describe).remainingBudgetreplaces 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
needForcharges 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).isFillablerejects a schedule whosebooking_window_statusis FULL before any budget or victim is considered, andrefreshWindowStatusre-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.sqladdsRAIL_CUSTOMSso the tier can be tested. -
A customs SERVICE TYPE without a customs CONTRACT cannot finalize clearance.
finalizeClearancelooks upclearance_output_<op>_<freight>, which the seeder deliberately leaves commented out, sogetByCode404s. Only the phased path (customs_clearing_enabledon 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 answersPROCESSINGfor 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 staffbookings/:id/expireoverride (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.
assertNoUnpaidHoldblocks a company with aSELECTED_FOR_BATCHbooking from creating another. Every file starts withreleaseUnpaidHolds(), which also hard-deletes retiredtrain_schedule_bookingsrows (booking_idis UNIQUE and the constraint ignoresdeleted_at, so a soft-unlinked booking can never be re-batched). - Arrange is slow. Contract → booking → clearance → ops accept → batch is 30–60s 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 withPromise.all. - Day offsets are the de-facto partition key (
departureAt(n)per file).payment-happyandexpired-invoice-late-settleboth 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.
rescueStrandedPaidForDaysweeps every unlinked booking whosepayment_statusis PAID and whosescheduled_datefalls 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:testre-clones every shard database from the template first. - Only a FULL train rests at DONE. An under-filled day CONCLUDES and
REOPENS (
window_phaseback to OPEN,booking_cycle_no2), so waiting for DONE there waits forever — usepollCycleConcluded. - Wagon stock is finite and shared within a shard. Paid bookings keep
their wagons, so
releaseUnpaidHolds()also frees every earlierCTR-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 passattempts × intervalMs; that product is the timeout, and only the sampling rate changed. Override withIT_POLL_INTERVAL_MS.- The tick cadence is env-driven.
BOOKING_WINDOW_TICK_CRONis*/1here and*/10in 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.
seedTenantContractsmints 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_tonsholds CARGO only;allocatedGrossTonsadds 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.
maxWagonsPerTrainis not a cap:syncScheduleMaxWagonsrecomputes it from locomotive length (54 here) every fill pass. A built train's coupled consist wins outright, soseed-g1-train.sql's 53 wagons ARE the capacity — the number every G1 scenario's arithmetic is written in.createBuiltTrainScheduleasserts it. - Customs bookings walk the phased chain (
clearBookingPhasedCustoms): transit assignee → declaration draft → accept → declaration → duty → transit permit → pre-clearance → delivery order.clearance/finalizerefuses them outright. - Freight sends a dev-shortcut amount (1 minor unit, 10 for CAC) for every
non-
CBE_BILLprovider. The tests assert that as-is.