Files
edr-platform/integration/README.md
2026-08-03 12:57:32 +00:00

11 KiB
Raw Blame History

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/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.

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 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.
  • 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.