9.7 KiB
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.
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:logs payment-api-it
pnpm it:down # -v, wipes the throwaway DB
Stack
docker-compose.it.yaml is an overlay on docker-compose.e2e.yaml — same
freight API, Postgres (tmpfs), MinIO, Fayda/eTrade mocks; plus RabbitMQ, the
real payment API, and one gateway mock. It is a separate compose project
(edr-freight-it) on offset ports, so the Cypress e2e stack can run alongside.
freight-api-e2e :3111 ──HTTP──> payment-api-it :3113 ──HTTP──> gateway-mock-it :4600
^ │
└────────── RabbitMQ :5772 ─────┘ (outbox → payment.events → consumer)
Never docker compose -f docker-compose.it.yaml on its own — it needs the base
file first. Use it.mjs.
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) — the code is a read-only mount, not baked into an image.
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/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/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. - 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.
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 sequentially (one DB); concurrency is exercised inside a test with
Promise.all. - 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. A previous run's paid bookings therefore climb back aboard — 18 stowaway wagons on a 28-wagon day, untilreleaseUnpaidHolds/resetCorridorDaystarted nullingscheduled_date. - 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. Paid bookings keep their wagons, so
releaseUnpaidHolds()also frees every earlierCTR-IT-%allocation — without it the fifth or sixth file on a warm stack silently gets a short consist. - 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. - 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.