# 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; `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. - **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__`, 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. ## 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 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.** `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. A previous run's paid bookings therefore climb back aboard — 18 stowaway wagons on a 28-wagon day, until `releaseUnpaidHolds` / `resetCorridorDay` started nulling `scheduled_date`. - **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.** Paid bookings keep their wagons, so `releaseUnpaidHolds()` also frees every earlier `CTR-IT-%` allocation — without it the fifth or sixth file on a warm stack silently gets a short consist. - **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. - **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.