Files
edr-platform/integration/README.md
2026-08-03 10:51:43 +00:00

157 lines
9.7 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.
```
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_<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.
- **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.