mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat: integration tests
This commit is contained in:
165
docker-compose.it.yaml
Normal file
165
docker-compose.it.yaml
Normal file
@@ -0,0 +1,165 @@
|
|||||||
|
# EDR Freight — API integration stack (headless).
|
||||||
|
#
|
||||||
|
# Overlay on docker-compose.e2e.yaml. Same base services (postgres, minio,
|
||||||
|
# mocks, freight-api), except the payment microservice is REAL here instead of
|
||||||
|
# `payment-mock-e2e`, and only the bank gateways are stubbed:
|
||||||
|
#
|
||||||
|
# freight-api-it ──HTTP──> payment-api-it ──HTTP──> gateway-mock-it
|
||||||
|
# ^ │
|
||||||
|
# └────── RabbitMQ ───────┘ (outbox → payment.events → consumer)
|
||||||
|
#
|
||||||
|
# Its own compose project (`name:` below overrides the base) and its own host
|
||||||
|
# ports, so it can run side by side with the Cypress e2e stack.
|
||||||
|
#
|
||||||
|
# node integration/scripts/it.mjs up|test|down|logs
|
||||||
|
#
|
||||||
|
# Never start it with plain `docker compose -f docker-compose.it.yaml` — it is
|
||||||
|
# an OVERLAY and needs the base file first:
|
||||||
|
# docker compose -f docker-compose.e2e.yaml -f docker-compose.it.yaml ...
|
||||||
|
name: edr-freight-it
|
||||||
|
|
||||||
|
services:
|
||||||
|
# Outbox transport. The payment API publishes payment.succeeded/failed here
|
||||||
|
# and freight consumes it — the production path. Copied from the passenger
|
||||||
|
# harness (e2e/docker-compose.yml).
|
||||||
|
rabbitmq-it:
|
||||||
|
image: rabbitmq:3-management
|
||||||
|
environment:
|
||||||
|
RABBITMQ_DEFAULT_USER: edr
|
||||||
|
RABBITMQ_DEFAULT_PASS: edr_secret
|
||||||
|
RABBITMQ_DEFAULT_VHOST: payment
|
||||||
|
ports:
|
||||||
|
- "${IT_RABBIT_PORT:-5772}:5672"
|
||||||
|
- "${IT_RABBIT_UI_PORT:-15772}:15672"
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 20
|
||||||
|
|
||||||
|
# Stand-in for every bank/wallet gateway the payment API talks to, plus a
|
||||||
|
# control plane the tests drive (force a provider to fail/hang, fire a
|
||||||
|
# correctly-signed webhook, read back what was called). See
|
||||||
|
# integration/gateway-mock/server.js.
|
||||||
|
gateway-mock-it:
|
||||||
|
image: node:20-alpine
|
||||||
|
volumes:
|
||||||
|
- ./integration/gateway-mock:/app:ro
|
||||||
|
working_dir: /app
|
||||||
|
environment:
|
||||||
|
PORT: "4600"
|
||||||
|
# Same secrets the payment API gets — so webhooks the mock signs pass the
|
||||||
|
# API's REAL signature verification instead of bypassing it.
|
||||||
|
CBE_SECRET_KEY: it-cbe-secret
|
||||||
|
CBE_MERCHANT_ID: it-cbe-merchant
|
||||||
|
PAYMENT_API_URL: http://payment-api-it:3003
|
||||||
|
command: ["node", "server.js"]
|
||||||
|
ports:
|
||||||
|
- "${IT_GATEWAY_PORT:-4600}:4600"
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"node",
|
||||||
|
"-e",
|
||||||
|
"fetch('http://localhost:4600/__control/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||||
|
]
|
||||||
|
interval: 3s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 10
|
||||||
|
|
||||||
|
payment-api-it:
|
||||||
|
build:
|
||||||
|
context: .
|
||||||
|
dockerfile: apps/edr-payment-api/Dockerfile
|
||||||
|
secrets:
|
||||||
|
- npmrc
|
||||||
|
depends_on:
|
||||||
|
postgres-freight-e2e:
|
||||||
|
condition: service_healthy
|
||||||
|
rabbitmq-it:
|
||||||
|
condition: service_healthy
|
||||||
|
gateway-mock-it:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
PORT: "3003"
|
||||||
|
NODE_ENV: test
|
||||||
|
# Payment tables live in their own schema of the same throwaway DB;
|
||||||
|
# main.ts ensurePaymentSchema() creates it, migrationsRun does the rest.
|
||||||
|
DB_HOST: postgres-freight-e2e
|
||||||
|
DB_PORT: "5432"
|
||||||
|
DB_USER: edr_e2e
|
||||||
|
DB_PASSWORD: edr_e2e
|
||||||
|
DB_NAME: edr_freight_e2e
|
||||||
|
DB_SCHEMA: edr_payment
|
||||||
|
# Same token freight already uses in the base stack.
|
||||||
|
SERVICE_AUTH_TOKEN: e2e-service-token
|
||||||
|
PUBLISHER_TRANSPORT: rabbitmq
|
||||||
|
PAYMENT_RABBITMQ_URL: amqp://edr:edr_secret@rabbitmq-it:5672/payment
|
||||||
|
# HTTP fallback targets (only used with PUBLISHER_TRANSPORT=http).
|
||||||
|
PAYMENT_NOTIFY_FREIGHT_URL: http://freight-api-e2e:3001/api/internal/payments/mark-paid
|
||||||
|
# Fast relay + sweep so retry/reconciliation are observable inside a test
|
||||||
|
# rather than a minute later.
|
||||||
|
OUTBOX_RELAY_INTERVAL_MS: "1000"
|
||||||
|
RECONCILE_STALE_AFTER_MS: "5000"
|
||||||
|
# Every gateway points at the one mock. Paths are per-provider prefixes.
|
||||||
|
CBE_BASE_URL: http://gateway-mock-it:4600/cbe-birr
|
||||||
|
CBE_MERCHANT_ID: it-cbe-merchant
|
||||||
|
CBE_SECRET_KEY: it-cbe-secret
|
||||||
|
CBE_NOTIFY_URL: http://payment-api-it:3003/webhooks/cbe-birr
|
||||||
|
CBE_RETURN_URL: http://localhost/return
|
||||||
|
TELEBIRR_BASE_URL: http://gateway-mock-it:4600/telebirr
|
||||||
|
TELEBIRR_WEB_BASE_URL: http://gateway-mock-it:4600/telebirr/web
|
||||||
|
TELEBIRR_FABRIC_APP_ID: it-fabric
|
||||||
|
TELEBIRR_APP_SECRET: it-secret
|
||||||
|
TELEBIRR_MERCHANT_APP_ID: it-merchant-app
|
||||||
|
TELEBIRR_MERCHANT_CODE: "999999"
|
||||||
|
TELEBIRR_NOTIFY_URL: http://payment-api-it:3003/webhooks/telebirr
|
||||||
|
# Telebirr PSS-signs every request object — a throwaway key generated per
|
||||||
|
# launch by it.mjs (nothing key-shaped lives in git).
|
||||||
|
TELEBIRR_PRIVATE_KEY: ${IT_TELEBIRR_PRIVATE_KEY}
|
||||||
|
EBIRR_BASE_URL: http://gateway-mock-it:4600/ebirr
|
||||||
|
DMONEY_BASE_URL: http://gateway-mock-it:4600/dmoney
|
||||||
|
CARD_BASE_URL: http://gateway-mock-it:4600/card
|
||||||
|
WAAFI_BASE_URL: http://gateway-mock-it:4600/waafi
|
||||||
|
CAC_BASE_URL: http://gateway-mock-it:4600/cac
|
||||||
|
CAC_USERNAME: it-cac
|
||||||
|
CAC_PASSWORD: it-cac
|
||||||
|
CAC_APP_KEY: it-cac-key
|
||||||
|
CAC_API_KEY: it-cac-api
|
||||||
|
CAC_COMPANY_SERVICES_ID: "1"
|
||||||
|
# Inbound CBE Unified Bill — we are the biller; bill-query hops back into
|
||||||
|
# the freight API, so this direction runs real code on both sides.
|
||||||
|
CBE_BILL_ENABLED: "true"
|
||||||
|
CBE_BILL_CLIENT_ID: it-cbe-bill
|
||||||
|
CBE_BILL_CLIENT_SECRET: it-cbe-bill-secret
|
||||||
|
CBE_BILL_JWT_SECRET: it-cbe-bill-jwt
|
||||||
|
FREIGHT_API_BASE_URL: http://freight-api-e2e:3001/api
|
||||||
|
ports:
|
||||||
|
- "${IT_PAYMENT_PORT:-3113}:3003"
|
||||||
|
healthcheck:
|
||||||
|
test:
|
||||||
|
[
|
||||||
|
"CMD",
|
||||||
|
"node",
|
||||||
|
"-e",
|
||||||
|
"fetch('http://localhost:3003/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))",
|
||||||
|
]
|
||||||
|
interval: 5s
|
||||||
|
timeout: 5s
|
||||||
|
retries: 12
|
||||||
|
start_period: 40s
|
||||||
|
|
||||||
|
# Base-stack service, re-pointed at the real payment API.
|
||||||
|
freight-api-e2e:
|
||||||
|
depends_on:
|
||||||
|
payment-api-it:
|
||||||
|
condition: service_healthy
|
||||||
|
environment:
|
||||||
|
PAYMENT_API_URL: http://payment-api-it:3003
|
||||||
|
# Freight's payment module skips RabbitMQModule entirely when this is
|
||||||
|
# unset (payment.module.ts) — without it, outbox events never arrive.
|
||||||
|
PAYMENT_RABBITMQ_URL: amqp://edr:edr_secret@rabbitmq-it:5672/payment
|
||||||
|
# RABBITMQ_ENABLED stays "false" (base stack) — it only gates the SMS/email
|
||||||
|
# clients, which must remain off. The payment consumer is wired by
|
||||||
|
# PAYMENT_RABBITMQ_URL alone.
|
||||||
156
integration/README.md
Normal file
156
integration/README.md
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
# 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
|
||||||
|
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.
|
||||||
299
integration/gateway-mock/server.js
Normal file
299
integration/gateway-mock/server.js
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
// Stand-in for every bank/wallet gateway the payment API calls, plus a control
|
||||||
|
// plane the integration tests drive.
|
||||||
|
//
|
||||||
|
// WHY ONE PROCESS
|
||||||
|
//
|
||||||
|
// Each provider's base URL is env-configurable (packages/payment-providers/…),
|
||||||
|
// so pointing them all at one server with a per-provider path prefix stubs the
|
||||||
|
// whole outbound surface without touching a line of app code. The payment API
|
||||||
|
// itself, its state machine, its webhook pipeline and its signature checks all
|
||||||
|
// run for real.
|
||||||
|
//
|
||||||
|
// Signatures are REAL: this server holds the same CBE_SECRET_KEY the API does
|
||||||
|
// and signs the callbacks it fires, so the API's verifyWebhookSignature runs in
|
||||||
|
// anger instead of being bypassed. That also makes the negative test possible —
|
||||||
|
// ask for a bad signature and the API must refuse to move any money.
|
||||||
|
//
|
||||||
|
// No dependencies (node:http + node:crypto), same shape as e2e/freight/*-mock.
|
||||||
|
const http = require("node:http");
|
||||||
|
const crypto = require("node:crypto");
|
||||||
|
|
||||||
|
const PORT = Number(process.env.PORT || 4600);
|
||||||
|
const PAYMENT_API_URL = process.env.PAYMENT_API_URL || "http://payment-api-it:3003";
|
||||||
|
const CBE_SECRET = process.env.CBE_SECRET_KEY || "it-cbe-secret";
|
||||||
|
const CBE_MERCHANT = process.env.CBE_MERCHANT_ID || "it-cbe-merchant";
|
||||||
|
const CAC_OTP = "123456";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Per-provider behaviour, set by POST /__control/provider/:name.
|
||||||
|
* ok — succeed (default)
|
||||||
|
* fail — answer 502, so the provider call throws inside the API
|
||||||
|
* timeout — never answer (the API's own 10s axios timeout fires)
|
||||||
|
* pending — succeed on initiate, but report "not paid yet" on every query
|
||||||
|
* paid — report SUCCESS on query without any webhook (reconciliation path)
|
||||||
|
* `remaining` counts down when set, then the provider reverts to ok.
|
||||||
|
*/
|
||||||
|
const modes = new Map();
|
||||||
|
/** Every inbound call, for "the provider was queried exactly once" assertions. */
|
||||||
|
let calls = [];
|
||||||
|
/** merchantOrderId → what the mock believes the payment did. */
|
||||||
|
const orders = new Map();
|
||||||
|
|
||||||
|
function modeFor(provider) {
|
||||||
|
const entry = modes.get(provider);
|
||||||
|
if (!entry) return "ok";
|
||||||
|
if (entry.remaining != null) {
|
||||||
|
if (entry.remaining <= 0) {
|
||||||
|
modes.delete(provider);
|
||||||
|
return "ok";
|
||||||
|
}
|
||||||
|
entry.remaining -= 1;
|
||||||
|
}
|
||||||
|
return entry.mode;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** CBE Birr signs `k=v` pairs over sorted keys with HMAC-SHA256 (hex). */
|
||||||
|
function cbeSign(data) {
|
||||||
|
const signString = Object.keys(data)
|
||||||
|
.sort()
|
||||||
|
.map((k) => `${k}=${data[k]}`)
|
||||||
|
.join("&");
|
||||||
|
return crypto.createHmac("sha256", CBE_SECRET).update(signString).digest("hex");
|
||||||
|
}
|
||||||
|
|
||||||
|
async function postJson(url, body, headers = {}) {
|
||||||
|
const res = await fetch(url, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "content-type": "application/json", ...headers },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
const text = await res.text();
|
||||||
|
return { status: res.status, body: text };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire a provider callback at the payment API, signed the way the real gateway
|
||||||
|
* would. `signature: "bad"` deliberately produces a well-formed but wrong
|
||||||
|
* signature (same length — the API compares with timingSafeEqual, which throws
|
||||||
|
* on a length mismatch and would mask what we are testing).
|
||||||
|
*/
|
||||||
|
async function fireWebhook(opts) {
|
||||||
|
const {
|
||||||
|
provider = "CBE_BIRR",
|
||||||
|
merchantOrderId,
|
||||||
|
status = "SUCCESS",
|
||||||
|
transactionId,
|
||||||
|
eventId,
|
||||||
|
signature,
|
||||||
|
} = opts;
|
||||||
|
if (provider !== "CBE_BIRR") {
|
||||||
|
throw new Error(`webhook not implemented for provider ${provider}`);
|
||||||
|
}
|
||||||
|
const order = orders.get(merchantOrderId) ?? {};
|
||||||
|
const payload = {
|
||||||
|
merchantId: CBE_MERCHANT,
|
||||||
|
merchantOrderId,
|
||||||
|
// orderId doubles as the dedupe key upstream: externalEventId is
|
||||||
|
// `${orderId}_${status}` (cbe-birr-webhook.service.ts), so a caller-supplied
|
||||||
|
// eventId is how a test replays the SAME event.
|
||||||
|
orderId: eventId ?? order.orderId ?? `CBEORD-${merchantOrderId}`,
|
||||||
|
status,
|
||||||
|
transactionId: transactionId ?? order.transactionId ?? `CBETXN-${merchantOrderId}`,
|
||||||
|
amount: order.amount ?? "1.00",
|
||||||
|
currency: order.currency ?? "ETB",
|
||||||
|
paidAt: new Date().toISOString(),
|
||||||
|
};
|
||||||
|
payload.signature =
|
||||||
|
signature === "bad" ? crypto.randomBytes(32).toString("hex") : cbeSign(payload);
|
||||||
|
return postJson(`${PAYMENT_API_URL}/webhooks/cbe-birr`, payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// provider routes
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/** @returns {[number, unknown] | "hang"} */
|
||||||
|
function handleProvider(provider, path, body, url) {
|
||||||
|
const mode = modeFor(provider);
|
||||||
|
if (mode === "timeout") return "hang";
|
||||||
|
if (mode === "fail") return [502, { error: `${provider} unavailable (forced)` }];
|
||||||
|
|
||||||
|
switch (`${provider}/${path}`) {
|
||||||
|
// --- CBE Birr (the suite's primary provider: plain HMAC, no key material)
|
||||||
|
case "cbe-birr/api/v1/payment/initiate": {
|
||||||
|
const orderId = `CBEORD-${body.merchantOrderId}`;
|
||||||
|
orders.set(body.merchantOrderId, {
|
||||||
|
orderId,
|
||||||
|
amount: body.amount,
|
||||||
|
currency: body.currency,
|
||||||
|
transactionId: `CBETXN-${body.merchantOrderId}`,
|
||||||
|
paid: false,
|
||||||
|
});
|
||||||
|
return [
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
orderId,
|
||||||
|
paymentUrl: `http://gateway-mock-it:${PORT}/cbe-birr/pay/${orderId}`,
|
||||||
|
expiresIn: 900,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
case "cbe-birr/api/v1/payment/query": {
|
||||||
|
const order = orders.get(body.merchantOrderId);
|
||||||
|
if (!order) return [200, { success: false, status: "NOT_FOUND" }];
|
||||||
|
const paid = mode === "paid" || order.paid;
|
||||||
|
return [
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
success: true,
|
||||||
|
orderId: order.orderId,
|
||||||
|
status: paid ? "SUCCESS" : mode === "pending" ? "PENDING" : "PROCESSING",
|
||||||
|
transactionId: order.transactionId,
|
||||||
|
amount: order.amount,
|
||||||
|
paidAt: paid ? new Date().toISOString() : undefined,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Telebirr (fabric token + createOrder + queryOrder)
|
||||||
|
case "telebirr/payment/v1/token":
|
||||||
|
return [200, { token: "it-fabric-token" }];
|
||||||
|
case "telebirr/payment/v1/inapp/createOrder": {
|
||||||
|
const merchOrderId = body?.biz_content?.merch_order_id;
|
||||||
|
const prepayId = `PREPAY-${merchOrderId ?? Date.now()}`;
|
||||||
|
orders.set(merchOrderId, { orderId: prepayId, paid: false });
|
||||||
|
return [
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
result: "SUCCESS",
|
||||||
|
code: "0",
|
||||||
|
biz_content: { prepay_id: prepayId, merch_order_id: merchOrderId },
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
case "telebirr/payment/v1/merchant/queryOrder": {
|
||||||
|
const order = orders.get(body?.biz_content?.merch_order_id);
|
||||||
|
const paid = mode === "paid" || order?.paid;
|
||||||
|
return [
|
||||||
|
200,
|
||||||
|
{
|
||||||
|
result: "SUCCESS",
|
||||||
|
code: "0",
|
||||||
|
biz_content: {
|
||||||
|
order_status: paid ? "Completed" : "Paying",
|
||||||
|
trans_id: order?.orderId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- CAC Bank (OTP debit)
|
||||||
|
case "cac/paymentapi/auth/signin":
|
||||||
|
return [200, { token: "it-cac-token", expiresIn: 86400 }];
|
||||||
|
case "cac/paymentapi/PaymentInitiateRequest": {
|
||||||
|
const id = `${Date.now()}00000`;
|
||||||
|
orders.set(String(id), { orderId: String(id), paid: false });
|
||||||
|
return [200, { status: true, message: "OTP sent", data: { id, otpRequired: true } }];
|
||||||
|
}
|
||||||
|
|
||||||
|
default:
|
||||||
|
// Unimplemented gateway paths answer a generic OK rather than 404: the
|
||||||
|
// suite only drives CBE Birr / CAC end to end, and a 404 here would look
|
||||||
|
// like a bug in the API rather than an unused stub. Add real shapes when
|
||||||
|
// a scenario needs them.
|
||||||
|
calls.push({ provider, path, unimplemented: true });
|
||||||
|
return [200, { success: true, stub: true, path: `${provider}/${path}`, url }];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// server
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const server = http.createServer((req, res) => {
|
||||||
|
const chunks = [];
|
||||||
|
req.on("data", (c) => chunks.push(c));
|
||||||
|
req.on("end", async () => {
|
||||||
|
const raw = Buffer.concat(chunks).toString("utf8");
|
||||||
|
let body = {};
|
||||||
|
try {
|
||||||
|
body = raw ? JSON.parse(raw) : {};
|
||||||
|
} catch {
|
||||||
|
body = { raw };
|
||||||
|
}
|
||||||
|
const send = (status, payload) => {
|
||||||
|
const json = JSON.stringify(payload ?? {});
|
||||||
|
res.writeHead(status, {
|
||||||
|
"content-type": "application/json",
|
||||||
|
"content-length": Buffer.byteLength(json),
|
||||||
|
});
|
||||||
|
res.end(json);
|
||||||
|
};
|
||||||
|
|
||||||
|
const url = new URL(req.url, "http://mock");
|
||||||
|
const path = url.pathname.replace(/^\/+/, "");
|
||||||
|
|
||||||
|
// --- control plane -----------------------------------------------------
|
||||||
|
if (path.startsWith("__control")) {
|
||||||
|
const [, action, arg] = path.split("/");
|
||||||
|
if (action === "health") return send(200, { ok: true });
|
||||||
|
if (action === "reset") {
|
||||||
|
modes.clear();
|
||||||
|
orders.clear();
|
||||||
|
calls = [];
|
||||||
|
return send(200, { ok: true });
|
||||||
|
}
|
||||||
|
if (action === "calls") {
|
||||||
|
return send(200, { calls });
|
||||||
|
}
|
||||||
|
if (action === "provider" && req.method === "POST") {
|
||||||
|
modes.set(arg, { mode: body.mode ?? "ok", remaining: body.times ?? null });
|
||||||
|
console.log(`[gateway-mock] ${arg} → ${body.mode} (times=${body.times ?? "∞"})`);
|
||||||
|
return send(200, { ok: true, provider: arg, mode: body.mode });
|
||||||
|
}
|
||||||
|
if (action === "settle" && req.method === "POST") {
|
||||||
|
// Mark the order paid at the gateway WITHOUT notifying — the payment
|
||||||
|
// API can then only learn about it by polling (reconciliation path).
|
||||||
|
const order = orders.get(body.merchantOrderId);
|
||||||
|
if (!order) return send(404, { error: "unknown merchantOrderId" });
|
||||||
|
order.paid = true;
|
||||||
|
return send(200, { ok: true });
|
||||||
|
}
|
||||||
|
if (action === "webhook" && req.method === "POST") {
|
||||||
|
try {
|
||||||
|
const result = await fireWebhook(body);
|
||||||
|
console.log(
|
||||||
|
`[gateway-mock] webhook ${body.merchantOrderId} ${body.status ?? "SUCCESS"} → ${result.status}`,
|
||||||
|
);
|
||||||
|
return send(200, { ok: true, delivered: result.status, body: result.body });
|
||||||
|
} catch (err) {
|
||||||
|
return send(500, { error: String(err) });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return send(404, { error: `unknown control action ${action}` });
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- gateway routes ----------------------------------------------------
|
||||||
|
const provider = path.split("/")[0];
|
||||||
|
const rest = path.slice(provider.length + 1);
|
||||||
|
calls.push({ provider, path: rest, method: req.method, body, at: Date.now() });
|
||||||
|
|
||||||
|
// CAC confirm carries the OTP; wrong code must fail the way the bank does.
|
||||||
|
if (rest.startsWith("paymentapi/") && rest.includes("Confirm")) {
|
||||||
|
const ok = String(body.otp ?? body.OTP ?? "") === CAC_OTP;
|
||||||
|
return send(200, ok
|
||||||
|
? { status: true, data: { id: body.id, status: "SUCCESS" } }
|
||||||
|
: { status: false, message: "Invalid OTP" });
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = handleProvider(provider, rest, body, req.url);
|
||||||
|
if (result === "hang") {
|
||||||
|
console.log(`[gateway-mock] ${provider}/${rest} → hanging (forced timeout)`);
|
||||||
|
return; // never answer; the caller's own timeout fires
|
||||||
|
}
|
||||||
|
send(result[0], result[1]);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
server.listen(PORT, () => console.log(`gateway-mock listening on ${PORT}`));
|
||||||
21
integration/package.json
Normal file
21
integration/package.json
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "@edr/freight-integration",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"description": "API-level integration tests for the freight API against the real payment microservice",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"test": "vitest run",
|
||||||
|
"test:watch": "vitest",
|
||||||
|
"type-check": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/node": "^22.0.0",
|
||||||
|
"@types/pg": "^8.11.0",
|
||||||
|
"@types/supertest": "^6.0.2",
|
||||||
|
"pg": "^8.13.0",
|
||||||
|
"supertest": "^7.0.0",
|
||||||
|
"typescript": "^5.5.4",
|
||||||
|
"vitest": "^2.1.2"
|
||||||
|
}
|
||||||
|
}
|
||||||
169
integration/scripts/it.mjs
Normal file
169
integration/scripts/it.mjs
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
/**
|
||||||
|
* Freight integration-suite launcher.
|
||||||
|
*
|
||||||
|
* node integration/scripts/it.mjs <up|test|down|logs> [vitest args...]
|
||||||
|
*
|
||||||
|
* Overlays docker-compose.it.yaml on docker-compose.e2e.yaml: same freight
|
||||||
|
* stack, but the payment microservice is real and only the bank gateways are
|
||||||
|
* stubbed. Web/Cypress containers are never started — this suite is HTTP only.
|
||||||
|
*
|
||||||
|
* Ports are fixed (and distinct from the Cypress e2e defaults) so both stacks
|
||||||
|
* can be up at once; they are separate compose projects.
|
||||||
|
*
|
||||||
|
* No dependencies — plain Node spawning `docker compose` and `pnpm`.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { execFileSync, spawnSync } from "node:child_process";
|
||||||
|
import { generateKeyPairSync } from "node:crypto";
|
||||||
|
import { existsSync } from "node:fs";
|
||||||
|
import { dirname, join, resolve } from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const itDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||||
|
const repoRoot = resolve(itDir, "..");
|
||||||
|
const composeBase = [
|
||||||
|
"compose",
|
||||||
|
"-f",
|
||||||
|
join(repoRoot, "docker-compose.e2e.yaml"),
|
||||||
|
"-f",
|
||||||
|
join(repoRoot, "docker-compose.it.yaml"),
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Deliberately offset from the Cypress stack's defaults (3101/5533/9310…). */
|
||||||
|
const PORTS = {
|
||||||
|
E2E_API_PORT: 3111,
|
||||||
|
E2E_DB_PORT: 5543,
|
||||||
|
E2E_MINIO_PORT: 9320,
|
||||||
|
E2E_MINIO_CONSOLE_PORT: 9321,
|
||||||
|
// Unused here (no web containers) but referenced by the base file's build args.
|
||||||
|
E2E_PORTAL_PORT: 5393,
|
||||||
|
E2E_BACKOFFICE_PORT: 5394,
|
||||||
|
IT_PAYMENT_PORT: 3113,
|
||||||
|
IT_GATEWAY_PORT: 4600,
|
||||||
|
IT_RABBIT_PORT: 5772,
|
||||||
|
IT_RABBIT_UI_PORT: 15772,
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Everything the suite needs up — web + cypress are deliberately absent. */
|
||||||
|
const SERVICES = [
|
||||||
|
"postgres-freight-e2e",
|
||||||
|
"minio-e2e",
|
||||||
|
"minio-init-e2e",
|
||||||
|
"freight-migration-e2e",
|
||||||
|
"fayda-mock-e2e",
|
||||||
|
"etrade-mock-e2e",
|
||||||
|
// Still a base-stack dependency of freight-api-e2e (reconcile-before-expire
|
||||||
|
// has its own client); cheap to run alongside the real payment API.
|
||||||
|
"payment-mock-e2e",
|
||||||
|
"gateway-mock-it",
|
||||||
|
"rabbitmq-it",
|
||||||
|
"payment-api-it",
|
||||||
|
"freight-api-e2e",
|
||||||
|
];
|
||||||
|
|
||||||
|
const RUNNING = SERVICES.filter(
|
||||||
|
(s) => !["minio-init-e2e", "freight-migration-e2e"].includes(s),
|
||||||
|
);
|
||||||
|
|
||||||
|
function fail(msg) {
|
||||||
|
console.error(`\nit: ${msg}`);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
function preflight() {
|
||||||
|
try {
|
||||||
|
execFileSync("docker", ["info"], { stdio: "ignore" });
|
||||||
|
} catch {
|
||||||
|
fail("docker is not running (or not installed) — start Docker and retry.");
|
||||||
|
}
|
||||||
|
if (!existsSync(join(repoRoot, ".npmrc"))) {
|
||||||
|
fail(".npmrc missing at repo root — image builds need GitHub Packages auth for @tria-plc.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throwaway RSA PEM — Telebirr PSS-signs every request object; the mock never
|
||||||
|
* verifies it, but the provider refuses to build a request without a real key. */
|
||||||
|
function fakeTelebirrPrivateKey() {
|
||||||
|
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||||
|
return privateKey.export({ type: "pkcs8", format: "pem" }).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Throwaway RSA JWK for FAYDA_PRIVATE_KEY_BASE64 (see e2e.mjs — same reason). */
|
||||||
|
function fakeFaydaPrivateKeyBase64() {
|
||||||
|
const { privateKey } = generateKeyPairSync("rsa", { modulusLength: 2048 });
|
||||||
|
const jwk = privateKey.export({ format: "jwk" });
|
||||||
|
Object.assign(jwk, { kty: "RSA", use: "sig", alg: "RS256", kid: "it-fayda-mock" });
|
||||||
|
return Buffer.from(JSON.stringify(jwk)).toString("base64");
|
||||||
|
}
|
||||||
|
|
||||||
|
const env = {
|
||||||
|
...process.env,
|
||||||
|
...Object.fromEntries(Object.entries(PORTS).map(([k, v]) => [k, String(v)])),
|
||||||
|
IT_API_URL: `http://localhost:${PORTS.E2E_API_PORT}`,
|
||||||
|
IT_PAYMENT_URL: `http://localhost:${PORTS.IT_PAYMENT_PORT}`,
|
||||||
|
IT_GATEWAY_URL: `http://localhost:${PORTS.IT_GATEWAY_PORT}`,
|
||||||
|
IT_DB_URL: `postgres://edr_e2e:edr_e2e@localhost:${PORTS.E2E_DB_PORT}/edr_freight_e2e`,
|
||||||
|
FAYDA_PRIVATE_KEY_BASE64:
|
||||||
|
process.env.FAYDA_PRIVATE_KEY_BASE64 ?? fakeFaydaPrivateKeyBase64(),
|
||||||
|
IT_TELEBIRR_PRIVATE_KEY:
|
||||||
|
process.env.IT_TELEBIRR_PRIVATE_KEY ?? fakeTelebirrPrivateKey(),
|
||||||
|
};
|
||||||
|
|
||||||
|
function compose(args) {
|
||||||
|
const { status } = spawnSync("docker", [...composeBase, ...args], { stdio: "inherit", env });
|
||||||
|
return status ?? 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
function stackRunning() {
|
||||||
|
try {
|
||||||
|
const out = execFileSync("docker", [...composeBase, "ps", "--services", "--status", "running"], {
|
||||||
|
encoding: "utf8",
|
||||||
|
env,
|
||||||
|
stdio: ["ignore", "pipe", "ignore"],
|
||||||
|
});
|
||||||
|
const running = new Set(out.split("\n").filter(Boolean));
|
||||||
|
return RUNNING.every((s) => running.has(s));
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function up() {
|
||||||
|
preflight();
|
||||||
|
console.log(
|
||||||
|
`it: starting stack — freight :${PORTS.E2E_API_PORT} payment :${PORTS.IT_PAYMENT_PORT} ` +
|
||||||
|
`gateway :${PORTS.IT_GATEWAY_PORT} db :${PORTS.E2E_DB_PORT}`,
|
||||||
|
);
|
||||||
|
if (compose(["up", "-d", "--build", "--wait", ...SERVICES]) !== 0) {
|
||||||
|
fail(
|
||||||
|
"stack failed to become healthy. Inspect with:\n" +
|
||||||
|
" node integration/scripts/it.mjs logs payment-api-it",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const [cmd, ...extra] = process.argv.slice(2);
|
||||||
|
|
||||||
|
switch (cmd) {
|
||||||
|
case "up":
|
||||||
|
up();
|
||||||
|
break;
|
||||||
|
case "test": {
|
||||||
|
if (!stackRunning()) up();
|
||||||
|
const { status } = spawnSync(
|
||||||
|
"pnpm",
|
||||||
|
["--filter", "@edr/freight-integration", "run", "test", ...extra],
|
||||||
|
{ cwd: repoRoot, stdio: "inherit", env },
|
||||||
|
);
|
||||||
|
process.exit(status ?? 1);
|
||||||
|
}
|
||||||
|
case "logs":
|
||||||
|
process.exit(compose(["logs", "--tail", "200", ...extra]));
|
||||||
|
break;
|
||||||
|
case "down":
|
||||||
|
process.exit(compose(["down", "-v", "--remove-orphans"]));
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
fail(`unknown command "${cmd ?? ""}" — use up | test | logs | down`);
|
||||||
|
}
|
||||||
34
integration/sql/seed-company-b.sql
Normal file
34
integration/sql/seed-company-b.sql
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
-- Second tenant for the integration suite: user2@gmail.com gets its own ACTIVE
|
||||||
|
-- company + approved importer profile, mirroring seed-company.sql (which only
|
||||||
|
-- sets up user@gmail.com). Two real tenants are what make the cross-tenant
|
||||||
|
-- isolation and the "two companies race for the same train" scenarios honest.
|
||||||
|
-- Idempotent; TIN is the key.
|
||||||
|
|
||||||
|
INSERT INTO freight.companies
|
||||||
|
(id, name, type, status, tin, fan_number, country, address, phone, email,
|
||||||
|
nationality, kind, attributes)
|
||||||
|
SELECT gen_random_uuid(), 'IT Freight Partners PLC', 'customer', 'active',
|
||||||
|
'0102030406', '1234567890123457', 'Ethiopia', 'Adama, Ethiopia',
|
||||||
|
'+251911000011', 'ops@it-partners.test', 'ethiopian', 'commercial',
|
||||||
|
'{"contactPersonName":"IT Contact","contactPersonPhone":"+251911000012","generalManagerName":"IT GM","generalManagerEmail":"gm@it-partners.test","generalManagerPhone":"+251911000013"}'::jsonb
|
||||||
|
WHERE NOT EXISTS (SELECT 1 FROM freight.companies WHERE tin = '0102030406');
|
||||||
|
|
||||||
|
INSERT INTO freight.company_profiles (id, company_id, type, status, reference)
|
||||||
|
SELECT gen_random_uuid(), c.id, 'importer', 'active', 'IMP-IT-0002'
|
||||||
|
FROM freight.companies c
|
||||||
|
WHERE c.tin = '0102030406'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM freight.company_profiles p
|
||||||
|
WHERE p.company_id = c.id AND p.type = 'importer'
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO freight.external_profiles
|
||||||
|
(id, user_id, company_id, first_name, last_name, is_primary_contact,
|
||||||
|
onboarding_step, onboarding_completed)
|
||||||
|
SELECT gen_random_uuid(), u.id, c.id, 'Demo', 'User2', true, 'done', true
|
||||||
|
FROM iam.users u
|
||||||
|
JOIN freight.companies c ON c.tin = '0102030406'
|
||||||
|
WHERE u.email = 'user2@gmail.com'
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM freight.external_profiles ep WHERE ep.user_id = u.id
|
||||||
|
);
|
||||||
17
integration/sql/seed-customs-service-type.sql
Normal file
17
integration/sql/seed-customs-service-type.sql
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
-- A service type whose bundle INCLUDES customs, for the priority-tier scenario
|
||||||
|
-- (g1-s6-s8, S8). Idempotent.
|
||||||
|
--
|
||||||
|
-- The rule engine's CUSTOMS priority band only applies when the booking's
|
||||||
|
-- service type has `includes_customs = true` (rule-engine.service.ts:251), and
|
||||||
|
-- the corridor fixture ships exactly one service type — RAIL, which does not.
|
||||||
|
-- Without this row the customs tier can never fire and "customs outranks plain"
|
||||||
|
-- is untestable: both bookings score the same.
|
||||||
|
--
|
||||||
|
-- Contracts opt in with seedContract({ serviceTypeCode: 'RAIL_CUSTOMS' }).
|
||||||
|
INSERT INTO freight.service_types
|
||||||
|
(code, service_name, description, includes_customs, is_active, display_order)
|
||||||
|
SELECT 'RAIL_CUSTOMS', 'Rail Transport + Customs Clearance',
|
||||||
|
'IT fixture: the customs-bundled service tier', true, true, 2
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM freight.service_types WHERE code = 'RAIL_CUSTOMS'
|
||||||
|
);
|
||||||
92
integration/src/authz.it.ts
Normal file
92
integration/src/authz.it.ts
Normal file
@@ -0,0 +1,92 @@
|
|||||||
|
/**
|
||||||
|
* Who is allowed to touch a payment. Cheap to run (no booking chain), and the
|
||||||
|
* failures here are the expensive kind: a tenant reading another tenant's
|
||||||
|
* invoice, or an unauthenticated caller marking one paid.
|
||||||
|
*/
|
||||||
|
import { afterAll, describe, expect, it } from "vitest";
|
||||||
|
import request from "supertest";
|
||||||
|
import {
|
||||||
|
API,
|
||||||
|
PAYMENT_API,
|
||||||
|
api,
|
||||||
|
closeDb,
|
||||||
|
customerA,
|
||||||
|
customerB,
|
||||||
|
db,
|
||||||
|
login,
|
||||||
|
payment,
|
||||||
|
} from "./client";
|
||||||
|
|
||||||
|
describe("payment authorization boundaries", () => {
|
||||||
|
afterAll(closeDb);
|
||||||
|
|
||||||
|
it("hides one tenant's invoice from the other", async () => {
|
||||||
|
const rows = await db<{ id: string; company_id: string }>(
|
||||||
|
`SELECT i.id, i.company_id FROM freight.invoices i
|
||||||
|
JOIN freight.companies c ON c.id = i.company_id
|
||||||
|
WHERE c.tin = '0102030405' AND i.deleted_at IS NULL
|
||||||
|
ORDER BY i.created_at DESC LIMIT 1`,
|
||||||
|
);
|
||||||
|
if (!rows[0]) return; // nothing billed yet in this run — payment files cover it
|
||||||
|
const res = await api(customerB, "get", `/api/billing/my-invoices/${rows[0].id}`);
|
||||||
|
expect([403, 404]).toContain(res.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses to let one tenant pay the other's invoice", async () => {
|
||||||
|
const rows = await db<{ id: string }>(
|
||||||
|
`SELECT i.id FROM freight.invoices i
|
||||||
|
JOIN freight.companies c ON c.id = i.company_id
|
||||||
|
WHERE c.tin = '0102030405' AND i.status <> 'PAID' AND i.deleted_at IS NULL
|
||||||
|
ORDER BY i.created_at DESC LIMIT 1`,
|
||||||
|
);
|
||||||
|
if (!rows[0]) return;
|
||||||
|
const res = await api(customerB, "post", `/api/billing/my-invoices/${rows[0].id}/pay`, {
|
||||||
|
method: "CBE_BIRR",
|
||||||
|
platform: "web",
|
||||||
|
});
|
||||||
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps a portal customer out of backoffice payment operations", async () => {
|
||||||
|
const res = await api(customerA, "get", "/api/billing/invoices");
|
||||||
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a portal account on the backoffice login audience", async () => {
|
||||||
|
const res = await login(customerA, "12345678", "backoffice");
|
||||||
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the service token on freight's mark-paid callback", async () => {
|
||||||
|
const body = {
|
||||||
|
version: 1,
|
||||||
|
eventId: "authz-probe",
|
||||||
|
eventType: "payment.succeeded",
|
||||||
|
occurredAt: new Date().toISOString(),
|
||||||
|
service: "FREIGHT",
|
||||||
|
intentId: "00000000-0000-0000-0000-000000000000",
|
||||||
|
referenceType: "SHIPMENT",
|
||||||
|
referenceId: "00000000-0000-0000-0000-000000000000",
|
||||||
|
provider: "CBE_BIRR",
|
||||||
|
amountMinor: 1,
|
||||||
|
currency: "ETB",
|
||||||
|
};
|
||||||
|
const res = await request(API).post("/api/internal/payments/mark-paid").send(body);
|
||||||
|
expect([401, 403]).toContain(res.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("requires the service token on the payment API's internal surface", async () => {
|
||||||
|
const res = await request(PAYMENT_API).get("/payments/intents?service=FREIGHT");
|
||||||
|
expect([400, 401, 403]).toContain(res.status);
|
||||||
|
|
||||||
|
// …and accepts it when present (400 = bad query, not an auth failure).
|
||||||
|
const withToken = await payment("get", "/payments/intents?service=FREIGHT");
|
||||||
|
expect([401, 403]).not.toContain(withToken.status);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves the provider webhook surface public — trust is the signature", async () => {
|
||||||
|
// A garbage payload must be acked, not 401'd: providers do not authenticate.
|
||||||
|
const res = await request(PAYMENT_API).post("/webhooks/cbe-birr").send({ nonsense: true });
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
});
|
||||||
|
});
|
||||||
180
integration/src/bulk-b1-priority-expiry-refill.it.ts
Normal file
180
integration/src/bulk-b1-priority-expiry-refill.it.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* BULK B1 — staff priority decides who rides; expiry refill promotes the
|
||||||
|
* offered booking WHOLE.
|
||||||
|
*
|
||||||
|
* Three wheat bookings that cannot all fit a 54-wagon CW4 train:
|
||||||
|
* BP1 1 960 T = 28 w (commercial giant)
|
||||||
|
* BP2 1 400 T = 20 w (commercial)
|
||||||
|
* BP3 700 T = 10 w (relief cargo — staff rank it FIRST)
|
||||||
|
*
|
||||||
|
* 58 wagons chase 54. With BP3 on top the batch reserves BP3 + BP1 whole
|
||||||
|
* (38 w) and leaves BP2 a whole-wagon offer for the remaining 16. BP1 then
|
||||||
|
* misses its pay window: its 28 wagons come back and the refill round must
|
||||||
|
* promote BP2 WHOLE — superseding the 16-wagon offer.
|
||||||
|
*/
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { api, apiOk, closeDb, db, gateway, superAdmin } from "./client";
|
||||||
|
import {
|
||||||
|
bookBulkReady,
|
||||||
|
allocatedWagons,
|
||||||
|
bookingRow,
|
||||||
|
closeBookingWindow,
|
||||||
|
completeDocReview,
|
||||||
|
createSchedule,
|
||||||
|
departureAt,
|
||||||
|
eatDayStr,
|
||||||
|
ensureCorridorRoute,
|
||||||
|
extendPayWindow,
|
||||||
|
expectWagonType,
|
||||||
|
forceReservationExpiry,
|
||||||
|
forceWindowOpen,
|
||||||
|
payViaGateway,
|
||||||
|
pollAllocations,
|
||||||
|
pollBookingStatus,
|
||||||
|
pollPartialOffer,
|
||||||
|
releaseUnpaidHolds,
|
||||||
|
resetCorridorDay,
|
||||||
|
seedTenantContracts,
|
||||||
|
} from "./flows";
|
||||||
|
|
||||||
|
const DEPARTURE = departureAt(40);
|
||||||
|
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||||
|
const STAMP = String(Date.now());
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Booked in arrival order; the RANKING comes from a real priority rule.
|
||||||
|
*
|
||||||
|
* NOTE — the Cypress twin ranks these by writing `priority_score` directly.
|
||||||
|
* That lever is dead for BULK: `recomputeBulkPriorities`
|
||||||
|
* (booking-batch.service.ts) re-derives every bulk booking's score from the
|
||||||
|
* rule engine when doc review closes, overwriting anything hand-written. So
|
||||||
|
* this file configures the product's own lever instead — a WAGON priority band
|
||||||
|
* that scores 1–10-wagon bookings above everything else, which is exactly how
|
||||||
|
* staff would push relief cargo to the front.
|
||||||
|
*/
|
||||||
|
const BOOKINGS = [
|
||||||
|
{ suffix: "BP1", tons: 1960, wagons: 28 }, // commercial giant
|
||||||
|
{ suffix: "BP2", tons: 1400, wagons: 20 }, // does not fit whole → offered 16
|
||||||
|
{ suffix: "BP3", tons: 700, wagons: 10 }, // relief cargo — ranked first by the rule
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("bulk b1: priority ordering and expiry refill", () => {
|
||||||
|
const booking = new Map<string, string>();
|
||||||
|
let scheduleId: string;
|
||||||
|
let priorityConfigId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await gateway.reset();
|
||||||
|
await releaseUnpaidHolds();
|
||||||
|
await ensureCorridorRoute();
|
||||||
|
await resetCorridorDay(DEPARTURE);
|
||||||
|
|
||||||
|
const contracts = await seedTenantContracts(
|
||||||
|
STAMP,
|
||||||
|
BOOKINGS.map((b) => ({ suffix: b.suffix, freight: "BULK" as const })),
|
||||||
|
);
|
||||||
|
scheduleId = (
|
||||||
|
await createSchedule({
|
||||||
|
departure: DEPARTURE,
|
||||||
|
kind: "bulk",
|
||||||
|
locoPair: ["LOCO-IMP-15", "LOCO-IMP-16"],
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
await forceWindowOpen(scheduleId, 60);
|
||||||
|
|
||||||
|
// Staff rank relief-sized cargo first: a WAGON band worth the maximum 50
|
||||||
|
// points for 1–10 wagons. Ranges must be contiguous from 1, and this is
|
||||||
|
// the first WAGON rule in the stack. Removed again in afterAll so the
|
||||||
|
// other bulk files keep the default (unranked) engine.
|
||||||
|
const cfg = await apiOk(superAdmin, "post", "/api/priority-configs", {
|
||||||
|
type: "WAGON",
|
||||||
|
label: "IT relief cargo 1-10 wagons",
|
||||||
|
minWagonCount: 1,
|
||||||
|
maxWagonCount: 10,
|
||||||
|
scorePoints: 50,
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
priorityConfigId = (cfg.body?.data?.id ?? cfg.body?.id) as string;
|
||||||
|
expect(priorityConfigId, "priority config created").toBeTruthy();
|
||||||
|
|
||||||
|
for (const b of BOOKINGS) {
|
||||||
|
booking.set(
|
||||||
|
b.suffix,
|
||||||
|
await bookBulkReady({
|
||||||
|
contractId: contracts.get(b.suffix)!,
|
||||||
|
tons: b.tons,
|
||||||
|
scheduledDate: BOOKING_DAY,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, 1_800_000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
if (priorityConfigId) {
|
||||||
|
await api(superAdmin, "delete", `/api/priority-configs/${priorityConfigId}`);
|
||||||
|
}
|
||||||
|
await closeDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ranks the relief cargo first — it and the giant reserve whole, the third is offered 16", async () => {
|
||||||
|
await closeBookingWindow(scheduleId);
|
||||||
|
await completeDocReview(scheduleId);
|
||||||
|
|
||||||
|
// The batch re-scored the pool from the rule: relief 50, the other two 0.
|
||||||
|
const scores = await db<{ id: string; priority_score: string }>(
|
||||||
|
`SELECT id, priority_score FROM freight.bookings WHERE id = ANY($1::uuid[])`,
|
||||||
|
[[...booking.values()]],
|
||||||
|
);
|
||||||
|
const scoreOf = (suffix: string) =>
|
||||||
|
Number(scores.find((r) => r.id === booking.get(suffix))?.priority_score ?? 0);
|
||||||
|
expect(scoreOf("BP3"), "relief cargo outranks the commercial pair").toBeGreaterThan(
|
||||||
|
Math.max(scoreOf("BP1"), scoreOf("BP2")),
|
||||||
|
);
|
||||||
|
|
||||||
|
for (const suffix of ["BP3", "BP1"]) {
|
||||||
|
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||||
|
}
|
||||||
|
const offer = await pollPartialOffer(booking.get("BP2")!);
|
||||||
|
expect(Number(offer.offered_wagons), "BP2 offered the 16-wagon leftover").toBe(16);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the relief cargo pays and rides CW4; the giant misses its pay window", async () => {
|
||||||
|
await extendPayWindow(scheduleId, [booking.get("BP3")!]);
|
||||||
|
await payViaGateway(booking.get("BP3")!);
|
||||||
|
await pollAllocations(booking.get("BP3")!, 10);
|
||||||
|
await expectWagonType(booking.get("BP3")!, "CW4", 10);
|
||||||
|
|
||||||
|
await forceReservationExpiry(booking.get("BP1")!);
|
||||||
|
await pollBookingStatus(booking.get("BP1")!, "EXPIRED", 40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the refill round re-selects the offered booking — but its 16-wagon offer still stands", async () => {
|
||||||
|
await pollBookingStatus(booking.get("BP2")!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], 40);
|
||||||
|
await extendPayWindow(scheduleId, [booking.get("BP2")!]);
|
||||||
|
await payViaGateway(booking.get("BP2")!);
|
||||||
|
await pollAllocations(booking.get("BP2")!, 16);
|
||||||
|
|
||||||
|
const bp2 = await bookingRow(booking.get("BP2")!);
|
||||||
|
|
||||||
|
// FINDING — the scenario expects the refill to promote BP2 WHOLE (20 w)
|
||||||
|
// into the 28 wagons the expired giant just freed, superseding its
|
||||||
|
// 16-wagon offer. It does not: the refill flips the booking back to
|
||||||
|
// reserved but never issues a replacement offer, and `applySplit` then
|
||||||
|
// applies the ONLY open offer — the stale 16-wagon one
|
||||||
|
// (booking-split.service.ts: an offer is superseded only when a NEW offer
|
||||||
|
// is created). The customer ships 16 of 20 wagons with room to spare.
|
||||||
|
// This test pins today's behaviour so the fix flips it loudly.
|
||||||
|
expect(Number(bp2.wagons_required), "rides the stale offer, not the freed 20").toBe(16);
|
||||||
|
expect(bp2.is_split, "split against the stale offer").toBe(true);
|
||||||
|
|
||||||
|
const [offers] = await db<{ n: string }>(
|
||||||
|
`SELECT count(*)::text AS n FROM freight.booking_batch_offers
|
||||||
|
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||||
|
[booking.get("BP2")!],
|
||||||
|
);
|
||||||
|
expect(Number(offers.n), "no replacement offer was issued after the refill").toBe(1);
|
||||||
|
|
||||||
|
// …and the train really did have the room: 10 (relief) + 16 = 26 of 54.
|
||||||
|
expect(await allocatedWagons(scheduleId), "28 wagons left unused").toBe(26);
|
||||||
|
});
|
||||||
|
});
|
||||||
141
integration/src/bulk-b2-per-item-floor.it.ts
Normal file
141
integration/src/bulk-b2-per-item-floor.it.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* BULK B2 — break-bulk PER_ITEM wagon math on the CW4 fleet (70 T capacity).
|
||||||
|
*
|
||||||
|
* Cargo from seed-bulk-items.sql:
|
||||||
|
* E2E_IMP_AUTO automobiles — items-per-wagon floor of 4
|
||||||
|
* E2E_IMP_MACHINE machinery — no floor, tonnage-only fallback
|
||||||
|
*
|
||||||
|
* Three verdicts of the wagon-demand rule, end to end:
|
||||||
|
* BA1 16 autos @2.5 T (40 T) → the floor binds: 4 wagons (tonnage said 1)
|
||||||
|
* BA2 12 machines @20 T (240 T) → tonnage binds: 3/wagon → 4 wagons
|
||||||
|
* BA3 216 autos (540 T) → exactly 54 wagons: FULL from one booking, no split
|
||||||
|
*/
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { closeDb, gateway } from "./client";
|
||||||
|
import {
|
||||||
|
bookBulkItemsReady,
|
||||||
|
bookingRow,
|
||||||
|
closeBookingWindow,
|
||||||
|
completeDocReview,
|
||||||
|
createSchedule,
|
||||||
|
departureAt,
|
||||||
|
eatDayStr,
|
||||||
|
endPaymentPhase,
|
||||||
|
ensureCorridorRoute,
|
||||||
|
extendPayWindow,
|
||||||
|
expectWagonType,
|
||||||
|
forceWindowOpen,
|
||||||
|
payViaGateway,
|
||||||
|
pollAllocations,
|
||||||
|
pollBookingStatus,
|
||||||
|
pollWindow,
|
||||||
|
releaseUnpaidHolds,
|
||||||
|
resetCorridorDay,
|
||||||
|
seedTenantContracts,
|
||||||
|
} from "./flows";
|
||||||
|
|
||||||
|
const DEPARTURE = departureAt(41);
|
||||||
|
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||||
|
const FULL_DEPARTURE = departureAt(42);
|
||||||
|
const FULL_DAY = eatDayStr(FULL_DEPARTURE);
|
||||||
|
const STAMP = String(Date.now());
|
||||||
|
|
||||||
|
describe("bulk b2: PER_ITEM floor vs tonnage wagon math", () => {
|
||||||
|
const booking = new Map<string, string>();
|
||||||
|
let contracts: Map<string, string>;
|
||||||
|
let scheduleId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await gateway.reset();
|
||||||
|
await releaseUnpaidHolds();
|
||||||
|
await ensureCorridorRoute();
|
||||||
|
await resetCorridorDay(DEPARTURE);
|
||||||
|
await resetCorridorDay(FULL_DEPARTURE);
|
||||||
|
|
||||||
|
contracts = await seedTenantContracts(
|
||||||
|
STAMP,
|
||||||
|
["BA1", "BA2", "BA3"].map((suffix) => ({ suffix, freight: "BULK" as const })),
|
||||||
|
);
|
||||||
|
scheduleId = (
|
||||||
|
await createSchedule({
|
||||||
|
departure: DEPARTURE,
|
||||||
|
kind: "bulk",
|
||||||
|
locoPair: ["LOCO-IMP-15", "LOCO-IMP-16"],
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
await forceWindowOpen(scheduleId, 60);
|
||||||
|
|
||||||
|
booking.set(
|
||||||
|
"BA1",
|
||||||
|
await bookBulkItemsReady({
|
||||||
|
contractId: contracts.get("BA1")!,
|
||||||
|
cargoCode: "E2E_IMP_AUTO",
|
||||||
|
items: 16,
|
||||||
|
tons: 40,
|
||||||
|
scheduledDate: BOOKING_DAY,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
booking.set(
|
||||||
|
"BA2",
|
||||||
|
await bookBulkItemsReady({
|
||||||
|
contractId: contracts.get("BA2")!,
|
||||||
|
cargoCode: "E2E_IMP_MACHINE",
|
||||||
|
items: 12,
|
||||||
|
tons: 240,
|
||||||
|
scheduledDate: BOOKING_DAY,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}, 1_800_000);
|
||||||
|
|
||||||
|
afterAll(closeDb);
|
||||||
|
|
||||||
|
it("the 4-per-wagon floor binds for 16 autos → 4 CW4 wagons, not 1", async () => {
|
||||||
|
await closeBookingWindow(scheduleId);
|
||||||
|
await completeDocReview(scheduleId);
|
||||||
|
for (const suffix of ["BA1", "BA2"]) {
|
||||||
|
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await extendPayWindow(scheduleId, [booking.get("BA1")!, booking.get("BA2")!]);
|
||||||
|
await payViaGateway(booking.get("BA1")!);
|
||||||
|
await pollAllocations(booking.get("BA1")!, 4);
|
||||||
|
await expectWagonType(booking.get("BA1")!, "CW4", 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("machinery has no floor — 12 items @20 T take 4 wagons on tonnage alone", async () => {
|
||||||
|
await payViaGateway(booking.get("BA2")!);
|
||||||
|
await pollAllocations(booking.get("BA2")!, 4);
|
||||||
|
await expectWagonType(booking.get("BA2")!, "CW4", 4);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("216 autos = exactly 54 wagons: FULL from one break-bulk booking, no split", async () => {
|
||||||
|
const fullSchedule = await createSchedule({
|
||||||
|
departure: FULL_DEPARTURE,
|
||||||
|
kind: "bulk",
|
||||||
|
locoPair: ["LOCO-IMP-25", "LOCO-IMP-26"],
|
||||||
|
});
|
||||||
|
await forceWindowOpen(fullSchedule.id, 45);
|
||||||
|
|
||||||
|
const ba3 = await bookBulkItemsReady({
|
||||||
|
contractId: contracts.get("BA3")!,
|
||||||
|
cargoCode: "E2E_IMP_AUTO",
|
||||||
|
items: 216,
|
||||||
|
tons: 540,
|
||||||
|
scheduledDate: FULL_DAY,
|
||||||
|
});
|
||||||
|
await closeBookingWindow(fullSchedule.id);
|
||||||
|
await completeDocReview(fullSchedule.id);
|
||||||
|
await pollBookingStatus(ba3, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||||
|
|
||||||
|
await payViaGateway(ba3);
|
||||||
|
await pollAllocations(ba3, 54);
|
||||||
|
expect((await bookingRow(ba3)).is_split, "BA3 rides whole, not split").not.toBe(true);
|
||||||
|
|
||||||
|
await endPaymentPhase(fullSchedule.id);
|
||||||
|
await pollWindow(
|
||||||
|
fullSchedule.id,
|
||||||
|
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
|
||||||
|
"FULL + DONE",
|
||||||
|
);
|
||||||
|
}, 900_000);
|
||||||
|
});
|
||||||
173
integration/src/bulk-b3-per-item-giant.it.ts
Normal file
173
integration/src/bulk-b3-per-item-giant.it.ts
Normal file
@@ -0,0 +1,173 @@
|
|||||||
|
/**
|
||||||
|
* BULK B3 — PER_ITEM giant split and line quantities.
|
||||||
|
*
|
||||||
|
* The scenario: 240 automobiles (600 T) on a 54-wagon CW4 train. The
|
||||||
|
* 4-per-wagon floor needs 60 wagons, so the batch should offer the whole
|
||||||
|
* consist (216 autos / 54 wagons), the settlement should apply the split, and
|
||||||
|
* the 24-auto remainder should have to be rebooked exactly. Plus two field
|
||||||
|
* checks: hazardousQuantity above the line count clamps, reeferQuantity is
|
||||||
|
* stored.
|
||||||
|
*
|
||||||
|
* WHAT ACTUALLY HAPPENS — two defects this file pins:
|
||||||
|
*
|
||||||
|
* 1. PER_ITEM bookings never get a partial offer. `sizeOffer`
|
||||||
|
* (booking-split.service.ts) sizes a bulk offer by WEIGHT off
|
||||||
|
* `cargoTotalWeightVgm` — but for PER_ITEM cargo that column holds the
|
||||||
|
* ITEM COUNT (240), not tonnage (600, kept in `bulk_total_weight_tons`).
|
||||||
|
* 240 "tons" fits 54 wagons, so no offer is made; the booking is reserved
|
||||||
|
* without a wagon count, allocates nothing, and silently expires with the
|
||||||
|
* day. The 24-auto remainder step therefore cannot happen at all.
|
||||||
|
*
|
||||||
|
* 2. The contract booking path DROPS per-line `hazardousQuantity` /
|
||||||
|
* `reeferQuantity` for bulk. Only the direct booking path
|
||||||
|
* (`POST /api/bookings`, bookings.service.ts) maps them onto
|
||||||
|
* `bulk_hazardous_quantity` / `bulk_reefer_quantity` — and only that path
|
||||||
|
* clamps them to the cargo amount.
|
||||||
|
*
|
||||||
|
* Both are asserted as they behave today, so a fix fails here loudly.
|
||||||
|
*/
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { closeDb, db, gateway } from "./client";
|
||||||
|
import {
|
||||||
|
bookBulkItems,
|
||||||
|
bookBulkItemsReady,
|
||||||
|
bookingFor,
|
||||||
|
bookingRow,
|
||||||
|
closeBookingWindow,
|
||||||
|
completeDocReview,
|
||||||
|
createSchedule,
|
||||||
|
departureAt,
|
||||||
|
eatDayStr,
|
||||||
|
endPaymentPhase,
|
||||||
|
ensureCorridorRoute,
|
||||||
|
forceWindowOpen,
|
||||||
|
payViaGateway,
|
||||||
|
pollAllocations,
|
||||||
|
pollBookingStatus,
|
||||||
|
pollPartialOffer,
|
||||||
|
pollWindow,
|
||||||
|
releaseUnpaidHolds,
|
||||||
|
resetCorridorDay,
|
||||||
|
seedTenantContracts,
|
||||||
|
} from "./flows";
|
||||||
|
|
||||||
|
const GIANT_DEPARTURE = departureAt(43);
|
||||||
|
const GIANT_DAY = eatDayStr(GIANT_DEPARTURE);
|
||||||
|
const REMAINDER_DEPARTURE = departureAt(44);
|
||||||
|
const REMAINDER_DAY = eatDayStr(REMAINDER_DEPARTURE);
|
||||||
|
const STAMP = String(Date.now());
|
||||||
|
|
||||||
|
describe("bulk b3: per-item giant offer and line quantities", () => {
|
||||||
|
let contracts: Map<string, string>;
|
||||||
|
let giantScheduleId: string;
|
||||||
|
let bg1: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await gateway.reset();
|
||||||
|
await releaseUnpaidHolds();
|
||||||
|
await ensureCorridorRoute();
|
||||||
|
await resetCorridorDay(GIANT_DEPARTURE);
|
||||||
|
await resetCorridorDay(REMAINDER_DEPARTURE);
|
||||||
|
|
||||||
|
contracts = await seedTenantContracts(
|
||||||
|
STAMP,
|
||||||
|
["BG1", "BQ1", "BQ2"].map((suffix) => ({ suffix, freight: "BULK" as const })),
|
||||||
|
);
|
||||||
|
giantScheduleId = (
|
||||||
|
await createSchedule({
|
||||||
|
departure: GIANT_DEPARTURE,
|
||||||
|
kind: "bulk",
|
||||||
|
locoPair: ["LOCO-IMP-27", "LOCO-IMP-28"],
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
await forceWindowOpen(giantScheduleId, 45);
|
||||||
|
|
||||||
|
bg1 = await bookBulkItemsReady({
|
||||||
|
contractId: contracts.get("BG1")!,
|
||||||
|
cargoCode: "E2E_IMP_AUTO",
|
||||||
|
items: 240,
|
||||||
|
tons: 600,
|
||||||
|
scheduledDate: GIANT_DAY,
|
||||||
|
});
|
||||||
|
}, 1_800_000);
|
||||||
|
|
||||||
|
afterAll(closeDb);
|
||||||
|
|
||||||
|
it("a 240-auto booking (60 wagons' worth) gets NO partial offer — it is sized as 240 tons", async () => {
|
||||||
|
await closeBookingWindow(giantScheduleId);
|
||||||
|
await completeDocReview(giantScheduleId);
|
||||||
|
await pollBookingStatus(bg1, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||||
|
|
||||||
|
// DEFECT 1 (see header): the split sizer reads the item count as tonnage,
|
||||||
|
// so a booking needing 60 wagons looks like it needs 4 and no offer opens.
|
||||||
|
const offers = await db<{ n: string }>(
|
||||||
|
`SELECT count(*)::text AS n FROM freight.booking_batch_offers
|
||||||
|
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||||
|
[bg1],
|
||||||
|
);
|
||||||
|
expect(Number(offers[0].n), "no partial offer for the per-item giant").toBe(0);
|
||||||
|
|
||||||
|
const row = await bookingRow(bg1);
|
||||||
|
expect(Number(row.cargo_total_weight_vgm), "cargoTotalWeightVgm holds ITEMS").toBe(240);
|
||||||
|
expect(row.wagons_required, "reserved without a wagon count").toBeNull();
|
||||||
|
|
||||||
|
// Nothing is allocated: the reservation cannot be honoured on a 54-wagon
|
||||||
|
// train, and no offer exists to shrink it.
|
||||||
|
const [alloc] = await db<{ n: string }>(
|
||||||
|
`SELECT count(*)::text AS n FROM freight.wagon_booking_allocations
|
||||||
|
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||||||
|
[bg1],
|
||||||
|
);
|
||||||
|
expect(Number(alloc.n), "no wagons allocated").toBe(0);
|
||||||
|
}, 900_000);
|
||||||
|
|
||||||
|
it.skip("the 24-auto outstanding must be rebooked EXACTLY on the later train", async () => {
|
||||||
|
// Unreachable while defect 1 stands: no split ever applies, so there is no
|
||||||
|
// outstanding remainder and the contract still carries a live booking
|
||||||
|
// (rebooking answers 409 "already has an active booking"). Un-skip with the
|
||||||
|
// fix to sizeOffer.
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the contract path DROPS a per-line hazardousQuantity for bulk", async () => {
|
||||||
|
const res = await bookBulkItems({
|
||||||
|
contractId: contracts.get("BQ1")!,
|
||||||
|
cargoCode: "E2E_IMP_AUTO",
|
||||||
|
items: 10,
|
||||||
|
tons: 25,
|
||||||
|
scheduledDate: REMAINDER_DAY,
|
||||||
|
hazardousQuantity: 12,
|
||||||
|
});
|
||||||
|
expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201);
|
||||||
|
|
||||||
|
const booking = await bookingFor(contracts.get("BQ1")!);
|
||||||
|
const [row] = await db<{ bulk_hazardous_quantity: string }>(
|
||||||
|
`SELECT bulk_hazardous_quantity FROM freight.bookings WHERE id = $1`,
|
||||||
|
[booking.id],
|
||||||
|
);
|
||||||
|
// DEFECT 2 (see header). The scenario expects the 12 to be CLAMPED to the
|
||||||
|
// 10-item line and stored; the contract path stores nothing at all, so the
|
||||||
|
// hazmat surcharge never fires for a contract booking. The direct booking
|
||||||
|
// path does clamp (clampToCargo, bookings.service.ts) — it is the mapping
|
||||||
|
// in contract-booking.service.ts that is missing.
|
||||||
|
expect(Number(row.bulk_hazardous_quantity), "hazmat dropped, not clamped").toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the contract path DROPS a per-line reeferQuantity for bulk", async () => {
|
||||||
|
const res = await bookBulkItems({
|
||||||
|
contractId: contracts.get("BQ2")!,
|
||||||
|
cargoCode: "E2E_IMP_AUTO",
|
||||||
|
items: 8,
|
||||||
|
tons: 20,
|
||||||
|
scheduledDate: REMAINDER_DAY,
|
||||||
|
reeferQuantity: 3,
|
||||||
|
});
|
||||||
|
expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201);
|
||||||
|
|
||||||
|
const booking = await bookingFor(contracts.get("BQ2")!);
|
||||||
|
const [row] = await db<{ bulk_reefer_quantity: string }>(
|
||||||
|
`SELECT bulk_reefer_quantity FROM freight.bookings WHERE id = $1`,
|
||||||
|
[booking.id],
|
||||||
|
);
|
||||||
|
expect(Number(row.bulk_reefer_quantity), "reefer dropped").toBe(0);
|
||||||
|
});
|
||||||
|
});
|
||||||
182
integration/src/bulk-export-fcfs-space.it.ts
Normal file
182
integration/src/bulk-export-fcfs-space.it.ts
Normal file
@@ -0,0 +1,182 @@
|
|||||||
|
/**
|
||||||
|
* BULK EXPORT — FCFS capacity truth.
|
||||||
|
*
|
||||||
|
* Day 1: three bookings (1 400 + 1 400 + 980 T = 54 wagons) accept first and
|
||||||
|
* hold the train BEFORE paying; three late 700 T exporters are rejected at
|
||||||
|
* submission by the whole-train space gate. The three pay → FULL.
|
||||||
|
*
|
||||||
|
* Day 2: whole-or-nothing — a 4 060 T giant (58 wagons) is rejected against
|
||||||
|
* an empty train (export never splits); rebooked at exactly 3 780 T it takes
|
||||||
|
* the whole consist alone; a 70 T afterthought bounces off FULL.
|
||||||
|
*/
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { closeDb, gateway } from "./client";
|
||||||
|
import {
|
||||||
|
EXP_DEST,
|
||||||
|
EXP_ORIGIN,
|
||||||
|
acceptExport,
|
||||||
|
allocatedWagons,
|
||||||
|
bookBulk,
|
||||||
|
bookBulkReady,
|
||||||
|
expectDayRefused,
|
||||||
|
bookingFor,
|
||||||
|
bookingRow,
|
||||||
|
createSchedule,
|
||||||
|
departureAt,
|
||||||
|
eatDayStr,
|
||||||
|
ensureExportRoute,
|
||||||
|
extendPayWindow,
|
||||||
|
forceWindowOpen,
|
||||||
|
payViaGateway,
|
||||||
|
pollAllocations,
|
||||||
|
pollWindow,
|
||||||
|
releaseUnpaidHolds,
|
||||||
|
resetCorridorDay,
|
||||||
|
seedTenantContracts,
|
||||||
|
} from "./flows";
|
||||||
|
|
||||||
|
const DEPARTURE = departureAt(31);
|
||||||
|
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||||
|
const GIANT_DEPARTURE = departureAt(32);
|
||||||
|
const GIANT_DAY = eatDayStr(GIANT_DEPARTURE);
|
||||||
|
const STAMP = String(Date.now());
|
||||||
|
|
||||||
|
const FIRST = [
|
||||||
|
{ suffix: "YA", tons: 1400, wagons: 20 },
|
||||||
|
{ suffix: "YB", tons: 1400, wagons: 20 },
|
||||||
|
{ suffix: "YC", tons: 980, wagons: 14 },
|
||||||
|
];
|
||||||
|
const LATE = ["YL1", "YL2", "YL3"];
|
||||||
|
|
||||||
|
describe("bulk export FCFS: reservations hold capacity, whole-or-nothing gate", () => {
|
||||||
|
const booking = new Map<string, string>();
|
||||||
|
let contracts: Map<string, string>;
|
||||||
|
let scheduleId: string;
|
||||||
|
let giantScheduleId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await gateway.reset();
|
||||||
|
await releaseUnpaidHolds();
|
||||||
|
await ensureExportRoute();
|
||||||
|
await resetCorridorDay(DEPARTURE, EXP_ORIGIN, EXP_DEST);
|
||||||
|
await resetCorridorDay(GIANT_DEPARTURE, EXP_ORIGIN, EXP_DEST);
|
||||||
|
|
||||||
|
contracts = await seedTenantContracts(
|
||||||
|
STAMP,
|
||||||
|
[...FIRST.map((b) => b.suffix), ...LATE, "YGBIG", "YG", "YS"].map((suffix) => ({
|
||||||
|
suffix,
|
||||||
|
freight: "BULK" as const,
|
||||||
|
direction: "EXPORT" as const,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
scheduleId = (
|
||||||
|
await createSchedule({
|
||||||
|
departure: DEPARTURE,
|
||||||
|
kind: "bulk",
|
||||||
|
locoPair: ["LOCO-EXP-3", "LOCO-EXP-4"],
|
||||||
|
originCode: EXP_ORIGIN,
|
||||||
|
destCode: EXP_DEST,
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
await forceWindowOpen(scheduleId, 60);
|
||||||
|
|
||||||
|
for (const b of FIRST) {
|
||||||
|
booking.set(
|
||||||
|
b.suffix,
|
||||||
|
await bookBulkReady({
|
||||||
|
contractId: contracts.get(b.suffix)!,
|
||||||
|
tons: b.tons,
|
||||||
|
scheduledDate: BOOKING_DAY,
|
||||||
|
mode: "export",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, 1_800_000);
|
||||||
|
|
||||||
|
afterAll(closeDb);
|
||||||
|
|
||||||
|
it("holds 54 wagons on accept — before any payment", async () => {
|
||||||
|
for (const b of FIRST) {
|
||||||
|
const row = await bookingRow(booking.get(b.suffix)!);
|
||||||
|
expect(["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], `${b.suffix} reserved`).toContain(
|
||||||
|
row.status,
|
||||||
|
);
|
||||||
|
expect(row.train_schedule_id, `${b.suffix} pinned to the train`).toBe(scheduleId);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects three late exporters at submission — the space gate reports no room", async () => {
|
||||||
|
for (const suffix of LATE) {
|
||||||
|
const res = await expectDayRefused({
|
||||||
|
contractId: contracts.get(suffix)!,
|
||||||
|
tons: 700,
|
||||||
|
scheduledDate: BOOKING_DAY,
|
||||||
|
});
|
||||||
|
expect(res.status, `${suffix} rejected`).toBeGreaterThanOrEqual(400);
|
||||||
|
expect(JSON.stringify(res.body)).toMatch(/space|no departures|full/i);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the three reserved pay — 54/54 allocated and the window flips FULL", async () => {
|
||||||
|
await extendPayWindow(scheduleId, FIRST.map((b) => booking.get(b.suffix)!));
|
||||||
|
for (const b of FIRST) {
|
||||||
|
await payViaGateway(booking.get(b.suffix)!);
|
||||||
|
await pollAllocations(booking.get(b.suffix)!, b.wagons);
|
||||||
|
}
|
||||||
|
await pollWindow(scheduleId, (s) => s.booking_window_status === "FULL", "export window FULL");
|
||||||
|
expect(await allocatedWagons(scheduleId), "54 wagons allocated").toBe(54);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("whole-or-nothing: a 4 060 T giant is rejected against the empty train", async () => {
|
||||||
|
giantScheduleId = (
|
||||||
|
await createSchedule({
|
||||||
|
departure: GIANT_DEPARTURE,
|
||||||
|
kind: "bulk",
|
||||||
|
locoPair: ["LOCO-EXP-5", "LOCO-EXP-6"],
|
||||||
|
originCode: EXP_ORIGIN,
|
||||||
|
destCode: EXP_DEST,
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
await forceWindowOpen(giantScheduleId, 60);
|
||||||
|
|
||||||
|
// 58 wagons on a 54-wagon consist — export never splits.
|
||||||
|
// Its own tenant: a refused probe still leaves a live booking on the
|
||||||
|
// one-time contract, which would block the 3 780 T rebooking below.
|
||||||
|
const res = await expectDayRefused({
|
||||||
|
contractId: contracts.get("YGBIG")!,
|
||||||
|
tons: 4060,
|
||||||
|
scheduledDate: GIANT_DAY,
|
||||||
|
});
|
||||||
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||||
|
expect(JSON.stringify(res.body)).toMatch(/space|no departures|full/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rebooked at exactly 3 780 T the giant takes the whole train alone", async () => {
|
||||||
|
const yg = await bookBulkReady({
|
||||||
|
contractId: contracts.get("YG")!,
|
||||||
|
tons: 3780,
|
||||||
|
scheduledDate: GIANT_DAY,
|
||||||
|
mode: "export",
|
||||||
|
});
|
||||||
|
await payViaGateway(yg);
|
||||||
|
await pollAllocations(yg, 54);
|
||||||
|
|
||||||
|
await pollWindow(
|
||||||
|
giantScheduleId,
|
||||||
|
(s) => s.booking_window_status === "FULL",
|
||||||
|
"giant train FULL from one booking",
|
||||||
|
);
|
||||||
|
expect((await bookingRow(yg)).train_schedule_id, "giant rides its train").toBe(giantScheduleId);
|
||||||
|
expect(await allocatedWagons(giantScheduleId), "54 wagons allocated").toBe(54);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a 70 T afterthought bounces off the FULL train", async () => {
|
||||||
|
const res = await expectDayRefused({
|
||||||
|
contractId: contracts.get("YS")!,
|
||||||
|
tons: 70,
|
||||||
|
scheduledDate: GIANT_DAY,
|
||||||
|
});
|
||||||
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||||
|
expect(JSON.stringify(res.body)).toMatch(/space|no departures|full/i);
|
||||||
|
});
|
||||||
|
});
|
||||||
169
integration/src/bulk-export-full-train.it.ts
Normal file
169
integration/src/bulk-export-full-train.it.ts
Normal file
@@ -0,0 +1,169 @@
|
|||||||
|
/**
|
||||||
|
* BULK EXPORT — six wheat bookings fill the 54-wagon CW4 train on the reversed
|
||||||
|
* corridor KALITY → MOJO → E2E_AWASH → DIRE_DAWA → NAGAD → DJIB_PORT, inside
|
||||||
|
* the ONE FCFS export window, then the full life of the train to Djibouti Port
|
||||||
|
* and the export customs tail.
|
||||||
|
*
|
||||||
|
* Export is FCFS: the staff accept IS the reservation — there is no batch — and
|
||||||
|
* a pay deadline may never outlive the window close. Both are asserted here.
|
||||||
|
*
|
||||||
|
* 70 T per CW4 wagon — Σ = 54 wagons / 3 780 T:
|
||||||
|
* XBF1 customs USD 560 T = 8 w · XBF2 customs ETB 420 T = 6 w
|
||||||
|
* XBF3 self ETB 420 T = 6 w · XBF4 self ETB 420 T = 6 w
|
||||||
|
* XBF5 customs USD 1 540 T = 22 w · XBF6 self USD 420 T = 6 w
|
||||||
|
*/
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { closeDb, gateway } from "./client";
|
||||||
|
import {
|
||||||
|
EXP_DEST,
|
||||||
|
EXP_ORIGIN,
|
||||||
|
allocatedWagons,
|
||||||
|
bookBulkReady,
|
||||||
|
bookingRow,
|
||||||
|
createSchedule,
|
||||||
|
departureAt,
|
||||||
|
dispatchSchedule,
|
||||||
|
eatDayStr,
|
||||||
|
ensureExportRoute,
|
||||||
|
extendPayWindow,
|
||||||
|
expectMilestoneDone,
|
||||||
|
finalizeSchedule,
|
||||||
|
forceWindowOpen,
|
||||||
|
gatePassGranted,
|
||||||
|
invoiceForBooking,
|
||||||
|
milestoneCount,
|
||||||
|
payViaGateway,
|
||||||
|
pollAllocations,
|
||||||
|
pollBookingStatus,
|
||||||
|
pollWindow,
|
||||||
|
releaseUnpaidHolds,
|
||||||
|
resetCorridorDay,
|
||||||
|
runCorridor,
|
||||||
|
scheduleRow,
|
||||||
|
seedTenantContracts,
|
||||||
|
closeT1,
|
||||||
|
completeMilestone,
|
||||||
|
uploadTransportDocument,
|
||||||
|
} from "./flows";
|
||||||
|
|
||||||
|
const DEPARTURE = departureAt(30);
|
||||||
|
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||||
|
const STAMP = String(Date.now());
|
||||||
|
|
||||||
|
const BOOKINGS = [
|
||||||
|
{ suffix: "XBF1", customs: true, currency: "USD" as const, tons: 560, wagons: 8 },
|
||||||
|
{ suffix: "XBF2", customs: true, currency: "ETB" as const, tons: 420, wagons: 6 },
|
||||||
|
{ suffix: "XBF3", customs: false, currency: "ETB" as const, tons: 420, wagons: 6 },
|
||||||
|
{ suffix: "XBF4", customs: false, currency: "ETB" as const, tons: 420, wagons: 6 },
|
||||||
|
{ suffix: "XBF5", customs: true, currency: "USD" as const, tons: 1540, wagons: 22 },
|
||||||
|
{ suffix: "XBF6", customs: false, currency: "USD" as const, tons: 420, wagons: 6 },
|
||||||
|
];
|
||||||
|
const CUSTOMS = BOOKINGS.filter((b) => b.customs);
|
||||||
|
const SELF_CLEAR = BOOKINGS.filter((b) => !b.customs);
|
||||||
|
|
||||||
|
describe("bulk export: six wheat bookings fill the 54-wagon CW4 train (FCFS)", () => {
|
||||||
|
const booking = new Map<string, string>();
|
||||||
|
let scheduleId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await gateway.reset();
|
||||||
|
await releaseUnpaidHolds();
|
||||||
|
await ensureExportRoute();
|
||||||
|
await resetCorridorDay(DEPARTURE, EXP_ORIGIN, EXP_DEST);
|
||||||
|
|
||||||
|
const contracts = await seedTenantContracts(
|
||||||
|
STAMP,
|
||||||
|
BOOKINGS.map((b) => ({
|
||||||
|
suffix: b.suffix,
|
||||||
|
currency: b.currency,
|
||||||
|
customs: b.customs,
|
||||||
|
freight: "BULK" as const,
|
||||||
|
direction: "EXPORT" as const,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
scheduleId = (
|
||||||
|
await createSchedule({
|
||||||
|
departure: DEPARTURE,
|
||||||
|
kind: "bulk",
|
||||||
|
locoPair: ["LOCO-EXP-1", "LOCO-EXP-2"],
|
||||||
|
originCode: EXP_ORIGIN,
|
||||||
|
destCode: EXP_DEST,
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
await forceWindowOpen(scheduleId, 60);
|
||||||
|
|
||||||
|
for (const b of BOOKINGS) {
|
||||||
|
booking.set(
|
||||||
|
b.suffix,
|
||||||
|
await bookBulkReady({
|
||||||
|
contractId: contracts.get(b.suffix)!,
|
||||||
|
tons: b.tons,
|
||||||
|
scheduledDate: BOOKING_DAY,
|
||||||
|
mode: "export",
|
||||||
|
customs: b.customs,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, 1_800_000);
|
||||||
|
|
||||||
|
afterAll(closeDb);
|
||||||
|
|
||||||
|
it("each accept reserved immediately, with a deadline clamped to the window close", async () => {
|
||||||
|
const schedule = await scheduleRow(scheduleId);
|
||||||
|
const closesAt = new Date(String(schedule.window_closes_at)).getTime();
|
||||||
|
for (const b of BOOKINGS) {
|
||||||
|
const row = await bookingRow(booking.get(b.suffix)!);
|
||||||
|
expect(row.payment_deadline, `${b.suffix} pay deadline`).toBeTruthy();
|
||||||
|
expect(
|
||||||
|
new Date(row.payment_deadline!).getTime(),
|
||||||
|
`${b.suffix} deadline never outlives the window close`,
|
||||||
|
).toBeLessThanOrEqual(closesAt);
|
||||||
|
const invoice = await invoiceForBooking(booking.get(b.suffix)!);
|
||||||
|
expect(invoice.currency, `${b.suffix} invoice currency`).toBe(b.currency);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("all six pay — 54/54 allocated, the export window flips FULL, staff finalize", async () => {
|
||||||
|
await extendPayWindow(scheduleId, [...booking.values()]);
|
||||||
|
for (const b of BOOKINGS) {
|
||||||
|
await payViaGateway(booking.get(b.suffix)!);
|
||||||
|
await pollAllocations(booking.get(b.suffix)!, b.wagons);
|
||||||
|
}
|
||||||
|
await pollWindow(scheduleId, (s) => s.booking_window_status === "FULL", "export window FULL");
|
||||||
|
expect(await allocatedWagons(scheduleId), "54 wagons allocated").toBe(54);
|
||||||
|
|
||||||
|
await finalizeSchedule(scheduleId);
|
||||||
|
await pollWindow(scheduleId, (s) => s.status === "SCHEDULED", "SCHEDULED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gate pass + transport documents, then the train runs the corridor to the port", async () => {
|
||||||
|
await gatePassGranted(scheduleId);
|
||||||
|
for (const b of CUSTOMS) {
|
||||||
|
const res = await uploadTransportDocument(booking.get(b.suffix)!);
|
||||||
|
expect(res.status, `${b.suffix} transport document`).toBeLessThanOrEqual(201);
|
||||||
|
}
|
||||||
|
|
||||||
|
await dispatchSchedule(scheduleId);
|
||||||
|
await pollWindow(scheduleId, (s) => s.status === "DISPATCHED", "DISPATCHED");
|
||||||
|
await runCorridor(scheduleId);
|
||||||
|
for (const b of BOOKINGS) await pollBookingStatus(booking.get(b.suffix)!, "ARRIVED", 20);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GL Djibouti closes the export tail on every customs booking", async () => {
|
||||||
|
for (const b of CUSTOMS) {
|
||||||
|
const id = booking.get(b.suffix)!;
|
||||||
|
await closeT1(id);
|
||||||
|
await completeMilestone(id, "OFFLOADED");
|
||||||
|
await expectMilestoneDone(id, "T1_CLOSED");
|
||||||
|
await expectMilestoneDone(id, "OFFLOADED");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the self-clearing bookings arrived clean — no customs tail", async () => {
|
||||||
|
for (const b of SELF_CLEAR) {
|
||||||
|
const id = booking.get(b.suffix)!;
|
||||||
|
expect((await bookingRow(id)).status, `${b.suffix} final status`).toBe("ARRIVED");
|
||||||
|
expect(await milestoneCount(id, "T1_CLOSED"), `${b.suffix} has no T1 tail`).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
183
integration/src/bulk-export-matrix.it.ts
Normal file
183
integration/src/bulk-export-matrix.it.ts
Normal file
@@ -0,0 +1,183 @@
|
|||||||
|
/**
|
||||||
|
* BULK EXPORT edge matrix (reversed corridor):
|
||||||
|
* a) a booking on a day with no open window is rejected
|
||||||
|
* b) mid-route boarding (DIRE_DAWA → port) shares the train with a KALITY
|
||||||
|
* through-booking
|
||||||
|
* c) directional FULL: the border edges are committed, so the window flips
|
||||||
|
* FULL while the home leg still has free wagons
|
||||||
|
* d) a dateless DOMESTIC ride-along boards the FULL train's free home leg —
|
||||||
|
* its pay window is clamped to the export close, it pays and links, and
|
||||||
|
* the window stays FULL
|
||||||
|
* e) a same-day sibling export train keeps its own independent window
|
||||||
|
*/
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { closeDb, db, gateway, poll } from "./client";
|
||||||
|
import {
|
||||||
|
EXP_DEST,
|
||||||
|
EXP_ORIGIN,
|
||||||
|
acceptIntercityOnto,
|
||||||
|
acceptOperation,
|
||||||
|
allocatedWagons,
|
||||||
|
bookBulk,
|
||||||
|
bookBulkReady,
|
||||||
|
expectDayRefused,
|
||||||
|
bookingFor,
|
||||||
|
bookingRow,
|
||||||
|
clearIntercityBooking,
|
||||||
|
createSchedule,
|
||||||
|
departureAt,
|
||||||
|
eatDayStr,
|
||||||
|
ensureExportRoute,
|
||||||
|
extendPayWindow,
|
||||||
|
forceWindowOpen,
|
||||||
|
payViaGateway,
|
||||||
|
pollAllocations,
|
||||||
|
pollBookingStatus,
|
||||||
|
pollWindow,
|
||||||
|
releaseUnpaidHolds,
|
||||||
|
resetCorridorDay,
|
||||||
|
scheduleRow,
|
||||||
|
seedTenantContracts,
|
||||||
|
} from "./flows";
|
||||||
|
|
||||||
|
const DEPARTURE = departureAt(35);
|
||||||
|
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||||
|
const NO_WINDOW_DAY = eatDayStr(departureAt(39)); // no schedule exists there
|
||||||
|
const STAMP = String(Date.now());
|
||||||
|
|
||||||
|
describe("bulk export matrix: sub-corridor, directional FULL, ride-along, own windows", () => {
|
||||||
|
let contracts: Map<string, string>;
|
||||||
|
let scheduleId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await gateway.reset();
|
||||||
|
await releaseUnpaidHolds();
|
||||||
|
await ensureExportRoute();
|
||||||
|
await resetCorridorDay(DEPARTURE, EXP_ORIGIN, EXP_DEST);
|
||||||
|
|
||||||
|
contracts = await seedTenantContracts(STAMP, [
|
||||||
|
{ suffix: "YM1", freight: "BULK", direction: "EXPORT" },
|
||||||
|
{ suffix: "YMNW", freight: "BULK", direction: "EXPORT" },
|
||||||
|
{ suffix: "YMSUB", freight: "BULK", direction: "EXPORT", originCode: "DIRE_DAWA", destCode: EXP_DEST },
|
||||||
|
{ suffix: "YMIC", freight: "BULK", direction: "DOMESTIC", originCode: EXP_ORIGIN, destCode: "MOJO" },
|
||||||
|
]);
|
||||||
|
scheduleId = (
|
||||||
|
await createSchedule({
|
||||||
|
departure: DEPARTURE,
|
||||||
|
kind: "bulk",
|
||||||
|
locoPair: ["LOCO-EXP-11", "LOCO-EXP-12"],
|
||||||
|
originCode: EXP_ORIGIN,
|
||||||
|
destCode: EXP_DEST,
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
await forceWindowOpen(scheduleId, 90);
|
||||||
|
}, 1_800_000);
|
||||||
|
|
||||||
|
afterAll(closeDb);
|
||||||
|
|
||||||
|
it("rejects a booking on a day with no open window", async () => {
|
||||||
|
const res = await expectDayRefused({
|
||||||
|
contractId: contracts.get("YMNW")!,
|
||||||
|
tons: 140,
|
||||||
|
scheduledDate: NO_WINDOW_DAY,
|
||||||
|
});
|
||||||
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||||
|
expect(JSON.stringify(res.body)).toMatch(/booking window|no departures/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a through booking and a mid-route boarding commit the border edge", async () => {
|
||||||
|
const through = await bookBulkReady({
|
||||||
|
contractId: contracts.get("YM1")!,
|
||||||
|
tons: 2800,
|
||||||
|
scheduledDate: BOOKING_DAY,
|
||||||
|
mode: "export",
|
||||||
|
});
|
||||||
|
const sub = await bookBulkReady({
|
||||||
|
contractId: contracts.get("YMSUB")!,
|
||||||
|
tons: 980,
|
||||||
|
scheduledDate: BOOKING_DAY,
|
||||||
|
mode: "export",
|
||||||
|
});
|
||||||
|
|
||||||
|
await extendPayWindow(scheduleId, [through, sub]);
|
||||||
|
await payViaGateway(through);
|
||||||
|
await pollAllocations(through, 40);
|
||||||
|
await payViaGateway(sub);
|
||||||
|
await pollAllocations(sub, 14);
|
||||||
|
|
||||||
|
expect((await bookingRow(through)).train_schedule_id).toBe(scheduleId);
|
||||||
|
expect((await bookingRow(sub)).train_schedule_id).toBe(scheduleId);
|
||||||
|
|
||||||
|
// 40 + 14 = 54 wagons committed on the border edge (…→ DJIB_PORT), yet the
|
||||||
|
// home leg (KALITY → DIRE_DAWA) still has 14 free — and the window stays
|
||||||
|
// OPEN on that free leg. NOTE: the older Cypress twin asserts FULL here;
|
||||||
|
// the live engine is leg-granular instead, which is why the ride-along
|
||||||
|
// below can still board. Assert the occupancy invariant, not the flag.
|
||||||
|
expect(await allocatedWagons(scheduleId), "border edge committed at 54").toBe(54);
|
||||||
|
expect(
|
||||||
|
(await scheduleRow(scheduleId)).booking_window_status,
|
||||||
|
"window stays open on the free home leg",
|
||||||
|
).toBe("OPEN");
|
||||||
|
}, 1_800_000);
|
||||||
|
|
||||||
|
it("a ride-along boards the train's free home leg — clamped, paid, linked", async () => {
|
||||||
|
const res = await bookBulk({ contractId: contracts.get("YMIC")!, tons: 140 });
|
||||||
|
expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201);
|
||||||
|
const ic = (await bookingFor(contracts.get("YMIC")!)).id;
|
||||||
|
await clearIntercityBooking(ic);
|
||||||
|
|
||||||
|
await acceptIntercityOnto(scheduleId, ic);
|
||||||
|
await pollBookingStatus(ic, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||||
|
|
||||||
|
const schedule = await scheduleRow(scheduleId);
|
||||||
|
expect(
|
||||||
|
new Date((await bookingRow(ic)).payment_deadline!).getTime(),
|
||||||
|
"ride-along deadline clamped to the export close",
|
||||||
|
).toBeLessThanOrEqual(new Date(String(schedule.window_closes_at)).getTime());
|
||||||
|
|
||||||
|
await payViaGateway(ic);
|
||||||
|
// A paid ride-along is unpinned back to the pool; staff place it again.
|
||||||
|
await acceptIntercityOnto(scheduleId, ic);
|
||||||
|
await poll(
|
||||||
|
"ride-along linked to the export train",
|
||||||
|
`SELECT train_schedule_id FROM freight.bookings WHERE id = $1`,
|
||||||
|
[ic],
|
||||||
|
(row) => (row as { train_schedule_id?: string })?.train_schedule_id === scheduleId,
|
||||||
|
{ attempts: 20 },
|
||||||
|
);
|
||||||
|
// The ride-along rides the home leg, so the border edge is untouched.
|
||||||
|
expect(await allocatedWagons(scheduleId), "border edge still 54").toBe(54);
|
||||||
|
}, 900_000);
|
||||||
|
|
||||||
|
it("a same-day sibling export train keeps its own window", async () => {
|
||||||
|
const sibling = new Date(DEPARTURE.getTime() + 90 * 60_000);
|
||||||
|
await createSchedule({
|
||||||
|
departure: sibling,
|
||||||
|
kind: "bulk",
|
||||||
|
locoPair: ["LOCO-EXP-3", "LOCO-EXP-4"],
|
||||||
|
originCode: EXP_ORIGIN,
|
||||||
|
destCode: EXP_DEST,
|
||||||
|
});
|
||||||
|
const anchor = await scheduleRow(scheduleId);
|
||||||
|
const [row] = await db<{ id: string; window_phase: string; window_closes_at: string }>(
|
||||||
|
`SELECT ts.id, ts.window_phase, ts.window_closes_at
|
||||||
|
FROM freight.train_schedules ts
|
||||||
|
JOIN freight.yards o ON o.id = ts.origin_station_id AND o.code = $1
|
||||||
|
JOIN freight.yards d ON d.id = ts.destination_station_id AND d.code = $2
|
||||||
|
WHERE ts.deleted_at IS NULL AND ts.id <> $3
|
||||||
|
AND abs(extract(epoch FROM (ts.scheduled_departure_date - $4::timestamptz))) < 7200
|
||||||
|
ORDER BY ts.created_at DESC LIMIT 1`,
|
||||||
|
[EXP_ORIGIN, EXP_DEST, scheduleId, DEPARTURE.toISOString()],
|
||||||
|
);
|
||||||
|
expect(row, "sibling export schedule").toBeTruthy();
|
||||||
|
expect(row.window_phase, "own fresh window").toBe("PRE_WINDOW");
|
||||||
|
expect(
|
||||||
|
new Date(row.window_closes_at).getTime(),
|
||||||
|
"own close, anchored to its own departure",
|
||||||
|
).not.toBe(new Date(String(anchor.window_closes_at)).getTime());
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// `acceptOperation` is imported for symmetry with the import matrix; the export
|
||||||
|
// ride-along is accepted onto the train instead.
|
||||||
|
void acceptOperation;
|
||||||
196
integration/src/bulk-export-pay-or-lose.it.ts
Normal file
196
integration/src/bulk-export-pay-or-lose.it.ts
Normal file
@@ -0,0 +1,196 @@
|
|||||||
|
/**
|
||||||
|
* BULK EXPORT — pay or lose the seat.
|
||||||
|
*
|
||||||
|
* Day 1: ZA + ZB reserve and pay 40 wagons. ZC reserves the last 14 (980 T)
|
||||||
|
* and never pays; while that hold lives a late booking is rejected for
|
||||||
|
* space. ZC expires → the late customer immediately books the freed 980 T
|
||||||
|
* and pays.
|
||||||
|
*
|
||||||
|
* Day 2: five reservations fill the train, only three pay. The window close
|
||||||
|
* passes → phase DONE, the two unpaid expire, and export never reopens
|
||||||
|
* (the cycle counter stays 1 — unlike import, which reopens).
|
||||||
|
*/
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { closeDb, db, gateway } from "./client";
|
||||||
|
import {
|
||||||
|
EXP_DEST,
|
||||||
|
EXP_ORIGIN,
|
||||||
|
bookBulk,
|
||||||
|
bookBulkReady,
|
||||||
|
expectDayRefused,
|
||||||
|
bookingRow,
|
||||||
|
createSchedule,
|
||||||
|
departureAt,
|
||||||
|
eatDayStr,
|
||||||
|
ensureExportRoute,
|
||||||
|
extendPayWindow,
|
||||||
|
forceReservationExpiry,
|
||||||
|
forceWindowOpen,
|
||||||
|
payViaGateway,
|
||||||
|
pollAllocations,
|
||||||
|
pollBookingStatus,
|
||||||
|
pollWindow,
|
||||||
|
releaseUnpaidHolds,
|
||||||
|
resetCorridorDay,
|
||||||
|
scheduleRow,
|
||||||
|
seedTenantContracts,
|
||||||
|
} from "./flows";
|
||||||
|
|
||||||
|
const DEPARTURE = departureAt(33);
|
||||||
|
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||||
|
const CLOSE_DEPARTURE = departureAt(34);
|
||||||
|
const CLOSE_DAY = eatDayStr(CLOSE_DEPARTURE);
|
||||||
|
const STAMP = String(Date.now());
|
||||||
|
|
||||||
|
const CLOSERS = [
|
||||||
|
{ suffix: "ZQA", tons: 840, wagons: 12, pays: true },
|
||||||
|
{ suffix: "ZQB", tons: 840, wagons: 12, pays: true },
|
||||||
|
{ suffix: "ZQC", tons: 840, wagons: 12, pays: true },
|
||||||
|
{ suffix: "ZQD", tons: 840, wagons: 12, pays: false },
|
||||||
|
{ suffix: "ZQE", tons: 420, wagons: 6, pays: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
describe("bulk export pay-or-lose: expiry frees space; close expires the unpaid", () => {
|
||||||
|
const booking = new Map<string, string>();
|
||||||
|
let contracts: Map<string, string>;
|
||||||
|
let scheduleId: string;
|
||||||
|
let closeScheduleId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await gateway.reset();
|
||||||
|
await releaseUnpaidHolds();
|
||||||
|
await ensureExportRoute();
|
||||||
|
await resetCorridorDay(DEPARTURE, EXP_ORIGIN, EXP_DEST);
|
||||||
|
await resetCorridorDay(CLOSE_DEPARTURE, EXP_ORIGIN, EXP_DEST);
|
||||||
|
|
||||||
|
contracts = await seedTenantContracts(
|
||||||
|
STAMP,
|
||||||
|
["ZA", "ZB", "ZC", "ZD", "ZDLATE", ...CLOSERS.map((c) => c.suffix)].map((suffix) => ({
|
||||||
|
suffix,
|
||||||
|
freight: "BULK" as const,
|
||||||
|
direction: "EXPORT" as const,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
scheduleId = (
|
||||||
|
await createSchedule({
|
||||||
|
departure: DEPARTURE,
|
||||||
|
kind: "bulk",
|
||||||
|
locoPair: ["LOCO-EXP-7", "LOCO-EXP-8"],
|
||||||
|
originCode: EXP_ORIGIN,
|
||||||
|
destCode: EXP_DEST,
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
await forceWindowOpen(scheduleId, 60);
|
||||||
|
}, 1_800_000);
|
||||||
|
|
||||||
|
afterAll(closeDb);
|
||||||
|
|
||||||
|
it("ZA and ZB reserve and pay 40 wagons; ZC holds the last 14 unpaid", async () => {
|
||||||
|
for (const [suffix, wagons] of [
|
||||||
|
["ZA", 20],
|
||||||
|
["ZB", 20],
|
||||||
|
] as const) {
|
||||||
|
const id = await bookBulkReady({
|
||||||
|
contractId: contracts.get(suffix)!,
|
||||||
|
tons: 1400,
|
||||||
|
scheduledDate: BOOKING_DAY,
|
||||||
|
mode: "export",
|
||||||
|
});
|
||||||
|
booking.set(suffix, id);
|
||||||
|
await payViaGateway(id);
|
||||||
|
await pollAllocations(id, wagons);
|
||||||
|
}
|
||||||
|
|
||||||
|
const zc = await bookBulkReady({
|
||||||
|
contractId: contracts.get("ZC")!,
|
||||||
|
tons: 980,
|
||||||
|
scheduledDate: BOOKING_DAY,
|
||||||
|
mode: "export",
|
||||||
|
});
|
||||||
|
booking.set("ZC", zc);
|
||||||
|
const schedule = await scheduleRow(scheduleId);
|
||||||
|
expect(
|
||||||
|
new Date((await bookingRow(zc)).payment_deadline!).getTime(),
|
||||||
|
"ZC deadline clamped to the window close",
|
||||||
|
).toBeLessThanOrEqual(new Date(String(schedule.window_closes_at)).getTime());
|
||||||
|
}, 900_000);
|
||||||
|
|
||||||
|
it("a late exporter is rejected while ZC's unpaid reservation holds the space", async () => {
|
||||||
|
const res = await expectDayRefused({
|
||||||
|
contractId: contracts.get("ZDLATE")!,
|
||||||
|
tons: 980,
|
||||||
|
scheduledDate: BOOKING_DAY,
|
||||||
|
});
|
||||||
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||||
|
expect(JSON.stringify(res.body)).toMatch(/space|no departures|full/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ZC misses its window — ZD immediately books the freed 980 T and pays", async () => {
|
||||||
|
await forceReservationExpiry(booking.get("ZC")!);
|
||||||
|
const zd = await bookBulkReady({
|
||||||
|
contractId: contracts.get("ZD")!,
|
||||||
|
tons: 980,
|
||||||
|
scheduledDate: BOOKING_DAY,
|
||||||
|
mode: "export",
|
||||||
|
});
|
||||||
|
booking.set("ZD", zd);
|
||||||
|
await payViaGateway(zd);
|
||||||
|
await pollAllocations(zd, 14);
|
||||||
|
expect((await bookingRow(zd)).train_schedule_id, "ZD took ZC's seat").toBe(scheduleId);
|
||||||
|
}, 900_000);
|
||||||
|
|
||||||
|
it("window-close day: five reservations, three payments", async () => {
|
||||||
|
closeScheduleId = (
|
||||||
|
await createSchedule({
|
||||||
|
departure: CLOSE_DEPARTURE,
|
||||||
|
kind: "bulk",
|
||||||
|
locoPair: ["LOCO-EXP-9", "LOCO-EXP-10"],
|
||||||
|
originCode: EXP_ORIGIN,
|
||||||
|
destCode: EXP_DEST,
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
await forceWindowOpen(closeScheduleId, 60);
|
||||||
|
|
||||||
|
for (const c of CLOSERS) {
|
||||||
|
booking.set(
|
||||||
|
c.suffix,
|
||||||
|
await bookBulkReady({
|
||||||
|
contractId: contracts.get(c.suffix)!,
|
||||||
|
tons: c.tons,
|
||||||
|
scheduledDate: CLOSE_DAY,
|
||||||
|
mode: "export",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
await extendPayWindow(
|
||||||
|
closeScheduleId,
|
||||||
|
CLOSERS.filter((x) => x.pays).map((c) => booking.get(c.suffix)!),
|
||||||
|
);
|
||||||
|
for (const c of CLOSERS.filter((x) => x.pays)) {
|
||||||
|
await payViaGateway(booking.get(c.suffix)!);
|
||||||
|
await pollAllocations(booking.get(c.suffix)!, c.wagons);
|
||||||
|
}
|
||||||
|
}, 1_800_000);
|
||||||
|
|
||||||
|
it("the window CLOSES — phase DONE, the unpaid expire, and export never reopens", async () => {
|
||||||
|
await db(
|
||||||
|
`UPDATE freight.train_schedules SET window_closes_at = now() - interval '1 second'
|
||||||
|
WHERE id = $1 AND window_phase = 'OPEN'`,
|
||||||
|
[closeScheduleId],
|
||||||
|
);
|
||||||
|
await pollWindow(
|
||||||
|
closeScheduleId,
|
||||||
|
(s) => s.window_phase === "DONE" && Number(s.booking_cycle_no) === 1,
|
||||||
|
"DONE, no reopen",
|
||||||
|
);
|
||||||
|
|
||||||
|
// A forced close needs the matching deadline clamp on the unpaid pair.
|
||||||
|
for (const c of CLOSERS.filter((x) => !x.pays)) {
|
||||||
|
await forceReservationExpiry(booking.get(c.suffix)!);
|
||||||
|
await pollBookingStatus(booking.get(c.suffix)!, "EXPIRED", 40);
|
||||||
|
}
|
||||||
|
for (const c of CLOSERS.filter((x) => x.pays)) {
|
||||||
|
expect((await bookingRow(booking.get(c.suffix)!)).status, `${c.suffix} rides`).toBe("PAID");
|
||||||
|
}
|
||||||
|
}, 900_000);
|
||||||
|
});
|
||||||
185
integration/src/bulk-import-full-train.it.ts
Normal file
185
integration/src/bulk-import-full-train.it.ts
Normal file
@@ -0,0 +1,185 @@
|
|||||||
|
/**
|
||||||
|
* BULK IMPORT — six wheat bookings fill the 54-wagon CW4 train on the long
|
||||||
|
* corridor DJIB_PORT → NAGAD → DIRE_DAWA → E2E_AWASH → MOJO → KALITY, all in
|
||||||
|
* the FIRST window, then the whole life of the train: payment through the real
|
||||||
|
* gateway, allocation, gate pass, T1, dispatch, checkpoint-by-checkpoint
|
||||||
|
* movement, arrival, and the post-arrival customs tail.
|
||||||
|
*
|
||||||
|
* 70 T per CW4 wagon — Σ = 54 wagons / 3 780 T:
|
||||||
|
* BF1 customs + USD 560 T = 8 w
|
||||||
|
* BF2 customs + ETB 420 T = 6 w
|
||||||
|
* BF3 self + ETB 420 T = 6 w
|
||||||
|
* BF4 self + ETB 420 T = 6 w
|
||||||
|
* BF5 customs + USD 1 540 T = 22 w (the ≥22-wagon giant)
|
||||||
|
* BF6 self + USD 420 T = 6 w
|
||||||
|
*
|
||||||
|
* Difference from the Cypress twin: every payment goes through the payment
|
||||||
|
* microservice and a signed gateway callback, not the staff mark-paid shortcut.
|
||||||
|
* Steps are sequential and not idempotent — the file runs as one journey.
|
||||||
|
*/
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { closeDb, db, gateway } from "./client";
|
||||||
|
import {
|
||||||
|
allocatedWagons,
|
||||||
|
bookBulkReady,
|
||||||
|
closeBookingWindow,
|
||||||
|
completeDocReview,
|
||||||
|
createSchedule,
|
||||||
|
departureAt,
|
||||||
|
dispatchSchedule,
|
||||||
|
eatDayStr,
|
||||||
|
endPaymentPhase,
|
||||||
|
extendPayWindow,
|
||||||
|
ensureCorridorRoute,
|
||||||
|
expectMilestoneDone,
|
||||||
|
forceWindowOpen,
|
||||||
|
gatePassGranted,
|
||||||
|
invoiceForBooking,
|
||||||
|
milestoneCount,
|
||||||
|
payViaGateway,
|
||||||
|
pollBookingStatus,
|
||||||
|
pollWindow,
|
||||||
|
releaseUnpaidHolds,
|
||||||
|
resetCorridorDay,
|
||||||
|
runCorridor,
|
||||||
|
runImportCustomsTail,
|
||||||
|
scheduleRow,
|
||||||
|
seedTenantContracts,
|
||||||
|
uploadT1,
|
||||||
|
} from "./flows";
|
||||||
|
|
||||||
|
const DEPARTURE = departureAt(20);
|
||||||
|
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||||
|
const STAMP = String(Date.now());
|
||||||
|
|
||||||
|
const BOOKINGS = [
|
||||||
|
{ suffix: "BF1", customs: true, currency: "USD" as const, tons: 560, wagons: 8 },
|
||||||
|
{ suffix: "BF2", customs: true, currency: "ETB" as const, tons: 420, wagons: 6 },
|
||||||
|
{ suffix: "BF3", customs: false, currency: "ETB" as const, tons: 420, wagons: 6 },
|
||||||
|
{ suffix: "BF4", customs: false, currency: "ETB" as const, tons: 420, wagons: 6 },
|
||||||
|
{ suffix: "BF5", customs: true, currency: "USD" as const, tons: 1540, wagons: 22 },
|
||||||
|
{ suffix: "BF6", customs: false, currency: "USD" as const, tons: 420, wagons: 6 },
|
||||||
|
];
|
||||||
|
const CUSTOMS = BOOKINGS.filter((b) => b.customs);
|
||||||
|
const SELF_CLEAR = BOOKINGS.filter((b) => !b.customs);
|
||||||
|
|
||||||
|
describe("bulk import: six wheat bookings fill the 54-wagon CW4 train", () => {
|
||||||
|
const booking = new Map<string, string>();
|
||||||
|
let scheduleId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await gateway.reset();
|
||||||
|
await releaseUnpaidHolds();
|
||||||
|
await ensureCorridorRoute();
|
||||||
|
await resetCorridorDay(DEPARTURE);
|
||||||
|
|
||||||
|
const contracts = await seedTenantContracts(
|
||||||
|
STAMP,
|
||||||
|
BOOKINGS.map((b) => ({
|
||||||
|
suffix: b.suffix,
|
||||||
|
currency: b.currency,
|
||||||
|
customs: b.customs,
|
||||||
|
freight: "BULK" as const,
|
||||||
|
})),
|
||||||
|
);
|
||||||
|
|
||||||
|
const schedule = await createSchedule({
|
||||||
|
departure: DEPARTURE,
|
||||||
|
kind: "bulk",
|
||||||
|
locoPair: ["LOCO-IMP-15", "LOCO-IMP-16"],
|
||||||
|
});
|
||||||
|
scheduleId = schedule.id;
|
||||||
|
// The cycle counter turns over when the window opens, not at creation.
|
||||||
|
await forceWindowOpen(scheduleId, 45);
|
||||||
|
expect((await scheduleRow(scheduleId)).booking_cycle_no, "FIRST window cycle").toBe(1);
|
||||||
|
|
||||||
|
for (const b of BOOKINGS) {
|
||||||
|
booking.set(
|
||||||
|
b.suffix,
|
||||||
|
await bookBulkReady({
|
||||||
|
contractId: contracts.get(b.suffix)!,
|
||||||
|
tons: b.tons,
|
||||||
|
scheduledDate: BOOKING_DAY,
|
||||||
|
customs: b.customs,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, 1_800_000);
|
||||||
|
|
||||||
|
afterAll(closeDb);
|
||||||
|
|
||||||
|
it("reserves all six with invoices in their contract currency — they fit exactly", async () => {
|
||||||
|
await closeBookingWindow(scheduleId);
|
||||||
|
await completeDocReview(scheduleId);
|
||||||
|
|
||||||
|
for (const b of BOOKINGS) {
|
||||||
|
const row = await pollBookingStatus(booking.get(b.suffix)!, [
|
||||||
|
"SELECTED_FOR_BATCH",
|
||||||
|
"AWAITING_PAYMENT",
|
||||||
|
]);
|
||||||
|
expect(row.payment_deadline ?? "pending", `${b.suffix} pay deadline`).toBeTruthy();
|
||||||
|
const invoice = await invoiceForBooking(booking.get(b.suffix)!);
|
||||||
|
expect(invoice.currency, `${b.suffix} invoice currency`).toBe(b.currency);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("all six pay through the gateway — 54/54 wagons, window FULL, schedule finalized", async () => {
|
||||||
|
await extendPayWindow(scheduleId, [...booking.values()]);
|
||||||
|
for (const b of BOOKINGS) {
|
||||||
|
await payViaGateway(booking.get(b.suffix)!);
|
||||||
|
}
|
||||||
|
await endPaymentPhase(scheduleId);
|
||||||
|
await pollWindow(
|
||||||
|
scheduleId,
|
||||||
|
(s) =>
|
||||||
|
s.booking_window_status === "FULL" &&
|
||||||
|
s.window_phase === "DONE" &&
|
||||||
|
s.status === "SCHEDULED",
|
||||||
|
"FULL + DONE + finalized",
|
||||||
|
);
|
||||||
|
expect(await allocatedWagons(scheduleId), "54 wagons allocated").toBe(54);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GL Djibouti grants the gate pass and uploads T1 for the customs bookings", async () => {
|
||||||
|
await gatePassGranted(scheduleId);
|
||||||
|
for (const b of CUSTOMS) {
|
||||||
|
const res = await uploadT1(booking.get(b.suffix)!);
|
||||||
|
expect(res.status, `${b.suffix} T1 upload`).toBeLessThanOrEqual(201);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the train dispatches and runs the corridor checkpoint by checkpoint", async () => {
|
||||||
|
await dispatchSchedule(scheduleId);
|
||||||
|
await pollWindow(scheduleId, (s) => s.status === "DISPATCHED", "DISPATCHED");
|
||||||
|
for (const b of BOOKINGS) await pollBookingStatus(booking.get(b.suffix)!, "IN_TRANSIT", 15);
|
||||||
|
|
||||||
|
await runCorridor(scheduleId);
|
||||||
|
for (const b of BOOKINGS) await pollBookingStatus(booking.get(b.suffix)!, "ARRIVED", 20);
|
||||||
|
|
||||||
|
const [row] = await db<{ n: string }>(
|
||||||
|
`SELECT count(*)::text AS n FROM freight.wagon_movements WHERE train_schedule_id = $1`,
|
||||||
|
[scheduleId],
|
||||||
|
);
|
||||||
|
expect(Number(row.n), "wagon movement ledger rows").toBeGreaterThanOrEqual(54);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("GL runs the customs tail on every customs booking", async () => {
|
||||||
|
for (const b of CUSTOMS) {
|
||||||
|
const id = booking.get(b.suffix)!;
|
||||||
|
await runImportCustomsTail(id);
|
||||||
|
await expectMilestoneDone(id, "T1_CLOSED");
|
||||||
|
await expectMilestoneDone(id, "RISK_ASSIGNED");
|
||||||
|
await expectMilestoneDone(id, "IMPORT_RELEASE_GRANTED");
|
||||||
|
await expectMilestoneDone(id, "IMPORT_PROCESS_COMPLETED");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the self-clearing bookings arrived clean — no customs tail", async () => {
|
||||||
|
for (const b of SELF_CLEAR) {
|
||||||
|
const id = booking.get(b.suffix)!;
|
||||||
|
const row = await pollBookingStatus(id, "ARRIVED", 5);
|
||||||
|
expect(row.status, `${b.suffix} final status`).toBe("ARRIVED");
|
||||||
|
expect(await milestoneCount(id, "T1_CLOSED"), `${b.suffix} has no T1 tail`).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
224
integration/src/bulk-import-matrix.it.ts
Normal file
224
integration/src/bulk-import-matrix.it.ts
Normal file
@@ -0,0 +1,224 @@
|
|||||||
|
/**
|
||||||
|
* BULK IMPORT edge matrix:
|
||||||
|
* a) a wheat booking on a day with no open window is rejected at submission
|
||||||
|
* b) a sub-corridor booking (NAGAD → MOJO) rides the through-train next to a
|
||||||
|
* DJIB_PORT → KALITY booking — the batch is corridor-aware
|
||||||
|
* c) bulk intercity ride-along (MOJO → KALITY, DOMESTIC, dateless): staff
|
||||||
|
* assign it onto the import train's free leg, the pay window opens, it
|
||||||
|
* pays and links
|
||||||
|
* d) whole-train giant: 4 000 T (58 wagons' worth) alone on a 54-wagon train
|
||||||
|
* → partial offer of the FULL consist (3 780 T); the gateway settlement
|
||||||
|
* applies the split and the train is FULL from ONE booking; the 220 T
|
||||||
|
* outstanding must be rebooked EXACTLY on a later train
|
||||||
|
*/
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { closeDb, db, gateway, poll } from "./client";
|
||||||
|
import {
|
||||||
|
acceptIntercityOnto,
|
||||||
|
acceptOperation,
|
||||||
|
bookBulk,
|
||||||
|
bookBulkReady,
|
||||||
|
expectDayRefused,
|
||||||
|
bookingFor,
|
||||||
|
bookingRow,
|
||||||
|
clearIntercityBooking,
|
||||||
|
closeBookingWindow,
|
||||||
|
completeDocReview,
|
||||||
|
createSchedule,
|
||||||
|
departureAt,
|
||||||
|
eatDayStr,
|
||||||
|
endPaymentPhase,
|
||||||
|
ensureCorridorRoute,
|
||||||
|
extendPayWindow,
|
||||||
|
forceWindowOpen,
|
||||||
|
payViaGateway,
|
||||||
|
pollAllocations,
|
||||||
|
pollBookingStatus,
|
||||||
|
pollPartialOffer,
|
||||||
|
pollWindow,
|
||||||
|
releaseUnpaidHolds,
|
||||||
|
resetCorridorDay,
|
||||||
|
seedTenantContracts,
|
||||||
|
} from "./flows";
|
||||||
|
|
||||||
|
const DEPARTURE = departureAt(25);
|
||||||
|
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||||
|
const GIANT_DEPARTURE = departureAt(26);
|
||||||
|
const GIANT_DAY = eatDayStr(GIANT_DEPARTURE);
|
||||||
|
const REMAINDER_DEPARTURE = departureAt(27);
|
||||||
|
const REMAINDER_DAY = eatDayStr(REMAINDER_DEPARTURE);
|
||||||
|
const NO_WINDOW_DAY = eatDayStr(departureAt(29)); // no schedule exists there
|
||||||
|
const STAMP = String(Date.now());
|
||||||
|
|
||||||
|
describe("bulk import matrix: gates, sub-corridor, ride-along, whole-train giant", () => {
|
||||||
|
let contracts: Map<string, string>;
|
||||||
|
let scheduleId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await gateway.reset();
|
||||||
|
await releaseUnpaidHolds();
|
||||||
|
await ensureCorridorRoute();
|
||||||
|
await resetCorridorDay(DEPARTURE);
|
||||||
|
await resetCorridorDay(GIANT_DEPARTURE);
|
||||||
|
await resetCorridorDay(REMAINDER_DEPARTURE);
|
||||||
|
|
||||||
|
contracts = await seedTenantContracts(STAMP, [
|
||||||
|
{ suffix: "BM1", freight: "BULK" },
|
||||||
|
{ suffix: "BMNW", freight: "BULK" },
|
||||||
|
{ suffix: "BMSUB", freight: "BULK", originCode: "NAGAD", destCode: "MOJO" },
|
||||||
|
{ suffix: "BMIC", freight: "BULK", direction: "DOMESTIC", originCode: "MOJO", destCode: "KALITY" },
|
||||||
|
{ suffix: "BMG", freight: "BULK" },
|
||||||
|
]);
|
||||||
|
|
||||||
|
scheduleId = (
|
||||||
|
await createSchedule({
|
||||||
|
departure: DEPARTURE,
|
||||||
|
kind: "bulk",
|
||||||
|
locoPair: ["LOCO-IMP-25", "LOCO-IMP-26"],
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
await forceWindowOpen(scheduleId, 60);
|
||||||
|
}, 1_800_000);
|
||||||
|
|
||||||
|
afterAll(closeDb);
|
||||||
|
|
||||||
|
it("refuses a shipment day with no open window", async () => {
|
||||||
|
const res = await expectDayRefused({
|
||||||
|
contractId: contracts.get("BMNW")!,
|
||||||
|
tons: 140,
|
||||||
|
scheduledDate: NO_WINDOW_DAY,
|
||||||
|
});
|
||||||
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||||
|
// Wording differs by gate: the day probe answers "No departures available
|
||||||
|
// on the selected day", the route probe "the import booking window …".
|
||||||
|
expect(JSON.stringify(res.body)).toMatch(/booking window|no departures/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a through booking and a NAGAD→MOJO sub-corridor booking share the train", async () => {
|
||||||
|
const bm1 = await bookBulkReady({
|
||||||
|
contractId: contracts.get("BM1")!,
|
||||||
|
tons: 1400,
|
||||||
|
scheduledDate: BOOKING_DAY,
|
||||||
|
});
|
||||||
|
const sub = await bookBulkReady({
|
||||||
|
contractId: contracts.get("BMSUB")!,
|
||||||
|
tons: 700,
|
||||||
|
scheduledDate: BOOKING_DAY,
|
||||||
|
});
|
||||||
|
|
||||||
|
await closeBookingWindow(scheduleId);
|
||||||
|
await completeDocReview(scheduleId);
|
||||||
|
for (const id of [bm1, sub]) {
|
||||||
|
await pollBookingStatus(id, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await extendPayWindow(scheduleId, [bm1, sub]);
|
||||||
|
await payViaGateway(bm1);
|
||||||
|
await pollAllocations(bm1, 20);
|
||||||
|
await payViaGateway(sub);
|
||||||
|
await pollAllocations(sub, 10);
|
||||||
|
|
||||||
|
expect((await bookingRow(bm1)).train_schedule_id).toBe(scheduleId);
|
||||||
|
expect((await bookingRow(sub)).train_schedule_id).toBe(scheduleId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a dateless DOMESTIC ride-along boards the import train's free leg", async () => {
|
||||||
|
// 140 T = 2 wagons MOJO → KALITY, no shipment day of its own.
|
||||||
|
const res = await bookBulk({ contractId: contracts.get("BMIC")!, tons: 140 });
|
||||||
|
expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201);
|
||||||
|
const ic = (await bookingFor(contracts.get("BMIC")!)).id;
|
||||||
|
await clearIntercityBooking(ic);
|
||||||
|
|
||||||
|
await acceptIntercityOnto(scheduleId, ic);
|
||||||
|
await pollBookingStatus(ic, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||||
|
expect((await bookingRow(ic)).payment_deadline, "ride-along pay window opened").toBeTruthy();
|
||||||
|
|
||||||
|
await payViaGateway(ic);
|
||||||
|
// Settlement UNPINS a paid ride-along back to the ride-along pool — staff
|
||||||
|
// then place it on the train they want (acceptIntercity's `paid` branch,
|
||||||
|
// booking-batch.service.ts). The Cypress twin never sees this: its staff
|
||||||
|
// mark-paid shortcut leaves the reservation pinned.
|
||||||
|
expect(
|
||||||
|
(await bookingRow(ic)).train_schedule_id,
|
||||||
|
"paid ride-along returns to the pool",
|
||||||
|
).toBeNull();
|
||||||
|
|
||||||
|
await acceptIntercityOnto(scheduleId, ic);
|
||||||
|
await poll(
|
||||||
|
"ride-along linked to the import train",
|
||||||
|
`SELECT train_schedule_id FROM freight.bookings WHERE id = $1`,
|
||||||
|
[ic],
|
||||||
|
(row) => (row as { train_schedule_id?: string })?.train_schedule_id === scheduleId,
|
||||||
|
{ attempts: 20 },
|
||||||
|
);
|
||||||
|
const [link] = await db<{ n: string }>(
|
||||||
|
`SELECT count(*)::text AS n FROM freight.train_schedule_bookings
|
||||||
|
WHERE booking_id = $1 AND train_schedule_id = $2 AND deleted_at IS NULL`,
|
||||||
|
[ic, scheduleId],
|
||||||
|
);
|
||||||
|
expect(Number(link.n), "link row").toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a 4 000 T giant alone gets a FULL-consist partial offer and fills the train", async () => {
|
||||||
|
const giantSchedule = await createSchedule({
|
||||||
|
departure: GIANT_DEPARTURE,
|
||||||
|
kind: "bulk",
|
||||||
|
locoPair: ["LOCO-IMP-27", "LOCO-IMP-28"],
|
||||||
|
});
|
||||||
|
await forceWindowOpen(giantSchedule.id, 45);
|
||||||
|
|
||||||
|
const bmg = await bookBulkReady({
|
||||||
|
contractId: contracts.get("BMG")!,
|
||||||
|
tons: 4000,
|
||||||
|
scheduledDate: GIANT_DAY,
|
||||||
|
});
|
||||||
|
await closeBookingWindow(giantSchedule.id);
|
||||||
|
await completeDocReview(giantSchedule.id);
|
||||||
|
await pollBookingStatus(bmg, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||||
|
|
||||||
|
const offer = await pollPartialOffer(bmg);
|
||||||
|
expect(Number(offer.offered_wagons), "offered the whole consist").toBe(54);
|
||||||
|
|
||||||
|
await payViaGateway(bmg);
|
||||||
|
await pollAllocations(bmg, 54);
|
||||||
|
expect((await bookingRow(bmg)).is_split, "BMG is split").toBe(true);
|
||||||
|
|
||||||
|
await endPaymentPhase(giantSchedule.id);
|
||||||
|
await pollWindow(
|
||||||
|
giantSchedule.id,
|
||||||
|
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
|
||||||
|
"FULL from one booking",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the giant's 220 T outstanding must be rebooked EXACTLY on a later train", async () => {
|
||||||
|
const remainder = await createSchedule({
|
||||||
|
departure: REMAINDER_DEPARTURE,
|
||||||
|
kind: "bulk",
|
||||||
|
locoPair: ["LOCO-IMP-13", "LOCO-IMP-14"],
|
||||||
|
});
|
||||||
|
await forceWindowOpen(remainder.id, 45);
|
||||||
|
|
||||||
|
const wrong = await bookBulk({
|
||||||
|
contractId: contracts.get("BMG")!,
|
||||||
|
tons: 100,
|
||||||
|
scheduledDate: REMAINDER_DAY,
|
||||||
|
});
|
||||||
|
expect(wrong.status).toBeGreaterThanOrEqual(400);
|
||||||
|
expect(JSON.stringify(wrong.body)).toMatch(/must take the whole/i);
|
||||||
|
|
||||||
|
const exact = await bookBulk({
|
||||||
|
contractId: contracts.get("BMG")!,
|
||||||
|
tons: 220,
|
||||||
|
scheduledDate: REMAINDER_DAY,
|
||||||
|
});
|
||||||
|
expect(exact.status, JSON.stringify(exact.body)).toBeLessThanOrEqual(201);
|
||||||
|
await acceptOperationIfPossible(contracts.get("BMG")!);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
/** The remainder booking only needs to exist; walking its gate is out of scope. */
|
||||||
|
async function acceptOperationIfPossible(contractId: string) {
|
||||||
|
const booking = await bookingFor(contractId);
|
||||||
|
if (booking.status === "OPERATION_REQUEST_PENDING") await acceptOperation(booking.id);
|
||||||
|
}
|
||||||
195
integration/src/bulk-import-split-promote.it.ts
Normal file
195
integration/src/bulk-import-split-promote.it.ts
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
/**
|
||||||
|
* BULK IMPORT — split offer, exact-remainder rebooking, pay-window expiry and
|
||||||
|
* priority-ordered waiting-list promotion on one 54-wagon CW4 train. Bulk
|
||||||
|
* splits are FULL-WAGONS-ONLY at the base 70 T cap.
|
||||||
|
*
|
||||||
|
* reserved (priority order): BSA 1 400 T = 20 w, BSB 980 T = 14 w,
|
||||||
|
* BSD 840 T = 12 w → 46 w. BSC 1 680 T = 24 w does NOT fit whole → PARTIAL
|
||||||
|
* offer of the remaining 8 wagons = 560 T. BSC settles through the payment
|
||||||
|
* service → the split applies (is_split + pre-split snapshot); the
|
||||||
|
* outstanding 1 120 T must later be rebooked EXACTLY.
|
||||||
|
* BSD never pays → EXPIRES; the freed 12 wagons promote BS1 (6 w) and
|
||||||
|
* BS2 (6 w) in priority order; BS3 (20 w) never fits and expires.
|
||||||
|
*
|
||||||
|
* Final consist: 20 + 14 + 8 + 6 + 6 = 54/54.
|
||||||
|
*
|
||||||
|
* The split is the reason this file pays through the gateway rather than the
|
||||||
|
* staff shortcut: applying a pending partial offer hangs off
|
||||||
|
* `booking.invoice.paid`, which only a real settlement emits.
|
||||||
|
*/
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { closeDb, gateway } from "./client";
|
||||||
|
import {
|
||||||
|
allocatedWagons,
|
||||||
|
bookBulk,
|
||||||
|
bookBulkReady,
|
||||||
|
bookingRow,
|
||||||
|
closeBookingWindow,
|
||||||
|
completeDocReview,
|
||||||
|
createSchedule,
|
||||||
|
departureAt,
|
||||||
|
eatDayStr,
|
||||||
|
endPaymentPhase,
|
||||||
|
extendPayWindow,
|
||||||
|
ensureCorridorRoute,
|
||||||
|
forceReservationExpiry,
|
||||||
|
forceWindowOpen,
|
||||||
|
payViaGateway,
|
||||||
|
pollAllocations,
|
||||||
|
pollBookingStatus,
|
||||||
|
pollPartialOffer,
|
||||||
|
pollWindow,
|
||||||
|
releaseUnpaidHolds,
|
||||||
|
resetCorridorDay,
|
||||||
|
seedTenantContracts,
|
||||||
|
setPriority,
|
||||||
|
} from "./flows";
|
||||||
|
|
||||||
|
const DEPARTURE = departureAt(23);
|
||||||
|
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||||
|
const REMAINDER_DEPARTURE = departureAt(24);
|
||||||
|
const REMAINDER_DAY = eatDayStr(REMAINDER_DEPARTURE);
|
||||||
|
const STAMP = String(Date.now());
|
||||||
|
|
||||||
|
const ORDER = ["BSA", "BSB", "BSD", "BSC", "BS1", "BS2", "BS3"] as const;
|
||||||
|
const TONS: Record<string, number> = {
|
||||||
|
BSA: 1400,
|
||||||
|
BSB: 980,
|
||||||
|
BSD: 840,
|
||||||
|
BSC: 1680,
|
||||||
|
BS1: 420,
|
||||||
|
BS2: 420,
|
||||||
|
BS3: 1400,
|
||||||
|
};
|
||||||
|
|
||||||
|
describe("bulk import: split offer, remainder rebooking, expiry + promotion", () => {
|
||||||
|
const booking = new Map<string, string>();
|
||||||
|
let contracts: Map<string, string>;
|
||||||
|
let scheduleId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await gateway.reset();
|
||||||
|
await releaseUnpaidHolds();
|
||||||
|
await ensureCorridorRoute();
|
||||||
|
await resetCorridorDay(DEPARTURE);
|
||||||
|
await resetCorridorDay(REMAINDER_DEPARTURE);
|
||||||
|
|
||||||
|
contracts = await seedTenantContracts(
|
||||||
|
STAMP,
|
||||||
|
ORDER.map((suffix) => ({ suffix, freight: "BULK" as const })),
|
||||||
|
);
|
||||||
|
scheduleId = (
|
||||||
|
await createSchedule({
|
||||||
|
departure: DEPARTURE,
|
||||||
|
kind: "bulk",
|
||||||
|
locoPair: ["LOCO-IMP-19", "LOCO-IMP-20"],
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
await forceWindowOpen(scheduleId, 45);
|
||||||
|
|
||||||
|
for (const [i, suffix] of ORDER.entries()) {
|
||||||
|
const id = await bookBulkReady({
|
||||||
|
contractId: contracts.get(suffix)!,
|
||||||
|
tons: TONS[suffix],
|
||||||
|
scheduledDate: BOOKING_DAY,
|
||||||
|
});
|
||||||
|
booking.set(suffix, id);
|
||||||
|
await setPriority(id, i + 1);
|
||||||
|
}
|
||||||
|
}, 1_800_000);
|
||||||
|
|
||||||
|
afterAll(closeDb);
|
||||||
|
|
||||||
|
it("reserves BSA/BSB/BSD whole and offers BSC a PARTIAL for the last 8 wagons", async () => {
|
||||||
|
await closeBookingWindow(scheduleId);
|
||||||
|
await completeDocReview(scheduleId);
|
||||||
|
|
||||||
|
for (const suffix of ["BSA", "BSB", "BSD", "BSC"]) {
|
||||||
|
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||||
|
}
|
||||||
|
const offer = await pollPartialOffer(booking.get("BSC")!);
|
||||||
|
expect(Number(offer.offered_wagons), "BSC offered the remaining 8 wagons").toBe(8);
|
||||||
|
|
||||||
|
for (const suffix of ["BS1", "BS2", "BS3"]) {
|
||||||
|
expect((await bookingRow(booking.get(suffix)!)).status, `${suffix} waiting`).toBe(
|
||||||
|
"FULLY_EXECUTED",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("BSA and BSB pay; BSC settles through the gateway — the split applies", async () => {
|
||||||
|
// Three sequential gateway settlements outlast the ~60s hold; BSD is left
|
||||||
|
// alone because the next test is about its expiry.
|
||||||
|
await extendPayWindow(scheduleId, [booking.get("BSA")!, booking.get("BSB")!, booking.get("BSC")!]);
|
||||||
|
await payViaGateway(booking.get("BSA")!);
|
||||||
|
await pollAllocations(booking.get("BSA")!, 20);
|
||||||
|
await payViaGateway(booking.get("BSB")!);
|
||||||
|
await pollAllocations(booking.get("BSB")!, 14);
|
||||||
|
|
||||||
|
await payViaGateway(booking.get("BSC")!);
|
||||||
|
await pollAllocations(booking.get("BSC")!, 8);
|
||||||
|
const bsc = await bookingRow(booking.get("BSC")!);
|
||||||
|
expect(bsc.is_split, "BSC is split").toBe(true);
|
||||||
|
expect(bsc.pre_split_quantities, "pre-split snapshot kept").toBeTruthy();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("BSD misses its pay window — the freed wagons promote BS1 + BS2 in priority order", async () => {
|
||||||
|
await forceReservationExpiry(booking.get("BSD")!);
|
||||||
|
for (const suffix of ["BS1", "BS2"]) {
|
||||||
|
const row = await pollBookingStatus(booking.get(suffix)!, [
|
||||||
|
"SELECTED_FOR_BATCH",
|
||||||
|
"AWAITING_PAYMENT",
|
||||||
|
]);
|
||||||
|
expect(row.status, `${suffix} promoted`).not.toBe("FULLY_EXECUTED");
|
||||||
|
expect(
|
||||||
|
(await bookingRow(booking.get(suffix)!)).payment_deadline,
|
||||||
|
`${suffix} got a pay window`,
|
||||||
|
).toBeTruthy();
|
||||||
|
}
|
||||||
|
expect((await bookingRow(booking.get("BS3")!)).status, "BS3 still has no seat").toBe(
|
||||||
|
"FULLY_EXECUTED",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("BS1 and BS2 pay — the train is FULL at 54; BS3 expires with the day", async () => {
|
||||||
|
await extendPayWindow(scheduleId, [booking.get("BS1")!, booking.get("BS2")!]);
|
||||||
|
await payViaGateway(booking.get("BS1")!);
|
||||||
|
await pollAllocations(booking.get("BS1")!, 6);
|
||||||
|
await payViaGateway(booking.get("BS2")!);
|
||||||
|
await pollAllocations(booking.get("BS2")!, 6);
|
||||||
|
|
||||||
|
await endPaymentPhase(scheduleId);
|
||||||
|
await pollWindow(
|
||||||
|
scheduleId,
|
||||||
|
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
|
||||||
|
"FULL + DONE",
|
||||||
|
);
|
||||||
|
expect(await allocatedWagons(scheduleId), "54 wagons allocated").toBe(54);
|
||||||
|
await pollBookingStatus(booking.get("BS3")!, "EXPIRED", 40);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the split customer must rebook EXACTLY the 1 120 T remainder", async () => {
|
||||||
|
const remainderSchedule = await createSchedule({
|
||||||
|
departure: REMAINDER_DEPARTURE,
|
||||||
|
kind: "bulk",
|
||||||
|
locoPair: ["LOCO-IMP-21", "LOCO-IMP-22"],
|
||||||
|
});
|
||||||
|
await forceWindowOpen(remainderSchedule.id, 45);
|
||||||
|
|
||||||
|
// 1 680 booked − 560 shipped by the split = 1 120 T outstanding.
|
||||||
|
const wrong = await bookBulk({
|
||||||
|
contractId: contracts.get("BSC")!,
|
||||||
|
tons: 560,
|
||||||
|
scheduledDate: REMAINDER_DAY,
|
||||||
|
});
|
||||||
|
expect(wrong.status, "partial remainder rejected").toBeGreaterThanOrEqual(400);
|
||||||
|
expect(JSON.stringify(wrong.body)).toMatch(/must take the whole/i);
|
||||||
|
|
||||||
|
const exact = await bookBulk({
|
||||||
|
contractId: contracts.get("BSC")!,
|
||||||
|
tons: 1120,
|
||||||
|
scheduledDate: REMAINDER_DAY,
|
||||||
|
});
|
||||||
|
expect(exact.status, JSON.stringify(exact.body)).toBeLessThanOrEqual(201);
|
||||||
|
});
|
||||||
|
});
|
||||||
125
integration/src/bulk-import-waiting-expiry.it.ts
Normal file
125
integration/src/bulk-import-waiting-expiry.it.ts
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
/**
|
||||||
|
* BULK IMPORT — the CW4 train fills from THREE wheat bookings; three more sit
|
||||||
|
* in the waiting pool of the same window. The selected trio pays through the
|
||||||
|
* gateway and allocates; when the cycle concludes FULL the waiting three
|
||||||
|
* expire with the day — they never ride and never pay.
|
||||||
|
*
|
||||||
|
* 70 T per CW4 wagon, 54-wagon consist:
|
||||||
|
* selected: BWA 1 400 T = 20 w, BWB 1 400 T = 20 w, BWC 980 T = 14 w → Σ 54
|
||||||
|
* waiting: BW1 / BW2 / BW3 700 T = 10 w each
|
||||||
|
*/
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { closeDb, gateway } from "./client";
|
||||||
|
import {
|
||||||
|
allocatedWagons,
|
||||||
|
bookBulkReady,
|
||||||
|
bookingRow,
|
||||||
|
closeBookingWindow,
|
||||||
|
completeDocReview,
|
||||||
|
createSchedule,
|
||||||
|
departureAt,
|
||||||
|
eatDayStr,
|
||||||
|
endPaymentPhase,
|
||||||
|
extendPayWindow,
|
||||||
|
ensureCorridorRoute,
|
||||||
|
forceWindowOpen,
|
||||||
|
payViaGateway,
|
||||||
|
pollAllocations,
|
||||||
|
pollBookingStatus,
|
||||||
|
pollWindow,
|
||||||
|
releaseUnpaidHolds,
|
||||||
|
resetCorridorDay,
|
||||||
|
seedTenantContracts,
|
||||||
|
setPriority,
|
||||||
|
} from "./flows";
|
||||||
|
|
||||||
|
const DEPARTURE = departureAt(21);
|
||||||
|
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||||
|
const STAMP = String(Date.now());
|
||||||
|
|
||||||
|
const SELECTED = [
|
||||||
|
{ suffix: "BWA", tons: 1400, wagons: 20 },
|
||||||
|
{ suffix: "BWB", tons: 1400, wagons: 20 },
|
||||||
|
{ suffix: "BWC", tons: 980, wagons: 14 },
|
||||||
|
];
|
||||||
|
const WAITING = [
|
||||||
|
{ suffix: "BW1", tons: 700, wagons: 10 },
|
||||||
|
{ suffix: "BW2", tons: 700, wagons: 10 },
|
||||||
|
{ suffix: "BW3", tons: 700, wagons: 10 },
|
||||||
|
];
|
||||||
|
const ALL = [...SELECTED, ...WAITING];
|
||||||
|
|
||||||
|
describe("bulk import: three bookings fill the train, three wait and expire", () => {
|
||||||
|
const booking = new Map<string, string>();
|
||||||
|
let scheduleId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await gateway.reset();
|
||||||
|
await releaseUnpaidHolds();
|
||||||
|
await ensureCorridorRoute();
|
||||||
|
await resetCorridorDay(DEPARTURE);
|
||||||
|
|
||||||
|
const contracts = await seedTenantContracts(
|
||||||
|
STAMP,
|
||||||
|
ALL.map((b) => ({ suffix: b.suffix, freight: "BULK" as const })),
|
||||||
|
);
|
||||||
|
scheduleId = (
|
||||||
|
await createSchedule({
|
||||||
|
departure: DEPARTURE,
|
||||||
|
kind: "bulk",
|
||||||
|
locoPair: ["LOCO-IMP-17", "LOCO-IMP-18"],
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
await forceWindowOpen(scheduleId, 45);
|
||||||
|
|
||||||
|
for (const [i, b] of ALL.entries()) {
|
||||||
|
const id = await bookBulkReady({
|
||||||
|
contractId: contracts.get(b.suffix)!,
|
||||||
|
tons: b.tons,
|
||||||
|
scheduledDate: BOOKING_DAY,
|
||||||
|
});
|
||||||
|
booking.set(b.suffix, id);
|
||||||
|
// Priority order = the order above: the exact-fill trio picks first.
|
||||||
|
await setPriority(id, i + 1);
|
||||||
|
}
|
||||||
|
}, 1_800_000);
|
||||||
|
|
||||||
|
afterAll(closeDb);
|
||||||
|
|
||||||
|
it("the batch selects exactly the three that fill 54 wagons; the rest keep waiting", async () => {
|
||||||
|
await closeBookingWindow(scheduleId);
|
||||||
|
await completeDocReview(scheduleId);
|
||||||
|
|
||||||
|
for (const b of SELECTED) {
|
||||||
|
await pollBookingStatus(booking.get(b.suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||||
|
}
|
||||||
|
for (const b of WAITING) {
|
||||||
|
const row = await bookingRow(booking.get(b.suffix)!);
|
||||||
|
expect(row.status, `${b.suffix} still waiting`).toBe("FULLY_EXECUTED");
|
||||||
|
expect(row.payment_deadline, `${b.suffix} has no pay deadline`).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the three selected pay through the gateway and allocate — 54/54", async () => {
|
||||||
|
await extendPayWindow(scheduleId, SELECTED.map((b) => booking.get(b.suffix)!));
|
||||||
|
for (const b of SELECTED) {
|
||||||
|
await payViaGateway(booking.get(b.suffix)!);
|
||||||
|
await pollAllocations(booking.get(b.suffix)!, b.wagons);
|
||||||
|
}
|
||||||
|
expect(await allocatedWagons(scheduleId), "54 wagons allocated").toBe(54);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the cycle concludes FULL — the three waiting bookings expire with the day", async () => {
|
||||||
|
await endPaymentPhase(scheduleId);
|
||||||
|
await pollWindow(
|
||||||
|
scheduleId,
|
||||||
|
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
|
||||||
|
"FULL + DONE",
|
||||||
|
);
|
||||||
|
for (const b of WAITING) await pollBookingStatus(booking.get(b.suffix)!, "EXPIRED", 40);
|
||||||
|
for (const b of SELECTED) {
|
||||||
|
const row = await bookingRow(booking.get(b.suffix)!);
|
||||||
|
expect(row.status, `${b.suffix} stays PAID`).toBe("PAID");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
127
integration/src/bulk-import-window-reopen.it.ts
Normal file
127
integration/src/bulk-import-window-reopen.it.ts
Normal file
@@ -0,0 +1,127 @@
|
|||||||
|
/**
|
||||||
|
* BULK IMPORT — nobody pays in the first cycle: both reserved wheat bookings
|
||||||
|
* expire, the cycle concludes NOT-full, and the window REOPENS for a second
|
||||||
|
* cycle on the same train. A fresh 700 T booking arrives in cycle 2, pays
|
||||||
|
* through the gateway, and allocates. Import days reopen; they don't die.
|
||||||
|
*
|
||||||
|
* The expiry itself is only possible because the payment service is real here:
|
||||||
|
* before expiring an unpaid hold the engine asks the gateway whether a late
|
||||||
|
* payment landed, and defers forever on an unverifiable answer.
|
||||||
|
*/
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { closeDb, gateway } from "./client";
|
||||||
|
import {
|
||||||
|
bookBulkReady,
|
||||||
|
bookingRow,
|
||||||
|
closeBookingWindow,
|
||||||
|
completeDocReview,
|
||||||
|
createSchedule,
|
||||||
|
departureAt,
|
||||||
|
eatDayStr,
|
||||||
|
endPaymentPhase,
|
||||||
|
ensureCorridorRoute,
|
||||||
|
forceReservationExpiry,
|
||||||
|
forceWindowOpen,
|
||||||
|
payViaGateway,
|
||||||
|
pollAllocations,
|
||||||
|
pollBookingStatus,
|
||||||
|
pollWindow,
|
||||||
|
releaseUnpaidHolds,
|
||||||
|
resetCorridorDay,
|
||||||
|
scheduleRow,
|
||||||
|
seedTenantContracts,
|
||||||
|
} from "./flows";
|
||||||
|
|
||||||
|
const DEPARTURE = departureAt(22);
|
||||||
|
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||||
|
const STAMP = String(Date.now());
|
||||||
|
const SUFFIXES = ["BRA", "BRB", "BRC"] as const;
|
||||||
|
|
||||||
|
describe("bulk import: dead first cycle — expire all, reopen, book again", () => {
|
||||||
|
const booking = new Map<string, string>();
|
||||||
|
let contracts: Map<string, string>;
|
||||||
|
let scheduleId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await gateway.reset();
|
||||||
|
await releaseUnpaidHolds();
|
||||||
|
await ensureCorridorRoute();
|
||||||
|
await resetCorridorDay(DEPARTURE);
|
||||||
|
|
||||||
|
contracts = await seedTenantContracts(
|
||||||
|
STAMP,
|
||||||
|
SUFFIXES.map((suffix) => ({ suffix, freight: "BULK" as const })),
|
||||||
|
);
|
||||||
|
scheduleId = (
|
||||||
|
await createSchedule({
|
||||||
|
departure: DEPARTURE,
|
||||||
|
kind: "bulk",
|
||||||
|
locoPair: ["LOCO-IMP-23", "LOCO-IMP-24"],
|
||||||
|
})
|
||||||
|
).id;
|
||||||
|
await forceWindowOpen(scheduleId, 45);
|
||||||
|
expect((await scheduleRow(scheduleId)).booking_cycle_no, "cycle 1").toBe(1);
|
||||||
|
|
||||||
|
for (const suffix of ["BRA", "BRB"] as const) {
|
||||||
|
booking.set(
|
||||||
|
suffix,
|
||||||
|
await bookBulkReady({
|
||||||
|
contractId: contracts.get(suffix)!,
|
||||||
|
tons: 1400,
|
||||||
|
scheduledDate: BOOKING_DAY,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}, 1_800_000);
|
||||||
|
|
||||||
|
afterAll(closeDb);
|
||||||
|
|
||||||
|
it("two customers are reserved in cycle 1", async () => {
|
||||||
|
await closeBookingWindow(scheduleId);
|
||||||
|
await completeDocReview(scheduleId);
|
||||||
|
for (const suffix of ["BRA", "BRB"] as const) {
|
||||||
|
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("nobody pays — both reservations expire and the cycle concludes not-full", async () => {
|
||||||
|
for (const suffix of ["BRA", "BRB"] as const) {
|
||||||
|
await forceReservationExpiry(booking.get(suffix)!);
|
||||||
|
}
|
||||||
|
await endPaymentPhase(scheduleId);
|
||||||
|
// When the reopen instant lands inside office hours the 10s tick chains
|
||||||
|
// PRE_WINDOW straight into OPEN, so PRE_WINDOW is not a reliably observable
|
||||||
|
// resting state — assert only that the cycle left PAYMENT without DONE.
|
||||||
|
await pollWindow(
|
||||||
|
scheduleId,
|
||||||
|
(s) => s.window_phase !== "PAYMENT" && s.window_phase !== "DONE",
|
||||||
|
"concluded not-full",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the second window opens (cycle 2) and a fresh 700 T booking pays and allocates", async () => {
|
||||||
|
await forceWindowOpen(scheduleId, 45);
|
||||||
|
expect((await scheduleRow(scheduleId)).booking_cycle_no, "cycle 2").toBe(2);
|
||||||
|
|
||||||
|
const brc = await bookBulkReady({
|
||||||
|
contractId: contracts.get("BRC")!,
|
||||||
|
tons: 700,
|
||||||
|
scheduledDate: BOOKING_DAY,
|
||||||
|
});
|
||||||
|
booking.set("BRC", brc);
|
||||||
|
await closeBookingWindow(scheduleId);
|
||||||
|
await completeDocReview(scheduleId);
|
||||||
|
await pollBookingStatus(brc, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||||
|
|
||||||
|
await payViaGateway(brc);
|
||||||
|
await pollAllocations(brc, 10);
|
||||||
|
|
||||||
|
for (const suffix of ["BRA", "BRB"] as const) {
|
||||||
|
const row = await bookingRow(booking.get(suffix)!);
|
||||||
|
expect(row.status, `${suffix} stays expired`).toBe("EXPIRED");
|
||||||
|
}
|
||||||
|
expect((await bookingRow(brc)).train_schedule_id, "BRC rides the reopened train").toBe(
|
||||||
|
scheduleId,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
180
integration/src/cbe-bill.it.ts
Normal file
180
integration/src/cbe-bill.it.ts
Normal file
@@ -0,0 +1,180 @@
|
|||||||
|
/**
|
||||||
|
* CBE Unified Bill — the INBOUND direction, and the only flow where the
|
||||||
|
* payment service calls freight rather than the other way round:
|
||||||
|
*
|
||||||
|
* customer picks CBE_BILL → freight → payment API mints a bill reference
|
||||||
|
* CBE POST /cbe/oauth/token → bearer token we issued
|
||||||
|
* CBE POST /cbe/query → payment API → freight /internal/payments/bill-query
|
||||||
|
* → payer name + live balance
|
||||||
|
* CBE POST /cbe/payment → intent settles → freight invoice PAID
|
||||||
|
*
|
||||||
|
* Both hops run real code on both sides; nothing is stubbed here at all.
|
||||||
|
*/
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import request from "supertest";
|
||||||
|
import { PAYMENT_API, closeDb, db, gateway, poll } from "./client";
|
||||||
|
import {
|
||||||
|
createImportSchedule,
|
||||||
|
currentInvoice,
|
||||||
|
departureAt,
|
||||||
|
ensureCorridorRoute,
|
||||||
|
releaseUnpaidHolds,
|
||||||
|
forceWindowOpen,
|
||||||
|
gatewayIntent,
|
||||||
|
invoiceForBooking,
|
||||||
|
payInvoice,
|
||||||
|
prepareBooking,
|
||||||
|
resetCorridorDay,
|
||||||
|
runBatch,
|
||||||
|
type ReadyBooking,
|
||||||
|
} from "./flows";
|
||||||
|
|
||||||
|
const DEPARTURE = departureAt(10);
|
||||||
|
const STAMP = String(Date.now());
|
||||||
|
const API_NAME = "EDR_FREIGHT";
|
||||||
|
|
||||||
|
const txnId = (tag: string) => `IT-${tag}-${Date.now()}`;
|
||||||
|
|
||||||
|
async function cbeToken(): Promise<string> {
|
||||||
|
const res = await request(PAYMENT_API).post("/cbe/oauth/token").send({
|
||||||
|
grant_type: "client_credentials",
|
||||||
|
client_id: "it-cbe-bill",
|
||||||
|
client_secret: "it-cbe-bill-secret",
|
||||||
|
scope: "Unified_Outgoing",
|
||||||
|
});
|
||||||
|
const token = res.body?.access_token ?? res.body?.data?.access_token;
|
||||||
|
if (!token) throw new Error(`cbe token failed: ${res.status} ${JSON.stringify(res.body)}`);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cbe = (token: string, path: string, body: object) =>
|
||||||
|
request(PAYMENT_API).post(path).set("Authorization", `Bearer ${token}`).send(body);
|
||||||
|
|
||||||
|
describe("CBE Unified Bill (payment service as biller)", () => {
|
||||||
|
let booking: ReadyBooking;
|
||||||
|
let invoiceId: string;
|
||||||
|
let billId: string;
|
||||||
|
let token: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await gateway.reset();
|
||||||
|
await releaseUnpaidHolds();
|
||||||
|
await ensureCorridorRoute();
|
||||||
|
await resetCorridorDay(DEPARTURE);
|
||||||
|
const schedule = await createImportSchedule({ departure: DEPARTURE });
|
||||||
|
await forceWindowOpen(schedule.id, 45);
|
||||||
|
booking = await prepareBooking({
|
||||||
|
suffix: "BILL1",
|
||||||
|
departure: DEPARTURE,
|
||||||
|
runStamp: STAMP,
|
||||||
|
isoSeed: 300,
|
||||||
|
twenty: 2,
|
||||||
|
currency: "ETB",
|
||||||
|
});
|
||||||
|
await runBatch(schedule.id);
|
||||||
|
invoiceId = (await invoiceForBooking(booking.bookingId)).id;
|
||||||
|
|
||||||
|
const res = await payInvoice(invoiceId, { method: "CBE_BILL" });
|
||||||
|
expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201);
|
||||||
|
|
||||||
|
const intent = await gatewayIntent(booking.bookingId);
|
||||||
|
const [row] = await db<{ bill_reference: string }>(
|
||||||
|
`SELECT bill_reference FROM edr_payment.payment_intent WHERE id = $1`,
|
||||||
|
[intent.id],
|
||||||
|
);
|
||||||
|
billId = row.bill_reference;
|
||||||
|
token = await cbeToken();
|
||||||
|
}, 600_000);
|
||||||
|
|
||||||
|
afterAll(closeDb);
|
||||||
|
|
||||||
|
it("mints a 12-digit bill reference the customer can quote at any CBE channel", () => {
|
||||||
|
expect(billId).toMatch(/^\d{12}$/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses the query without a token we issued", async () => {
|
||||||
|
const res = await request(PAYMENT_API)
|
||||||
|
.post("/cbe/query")
|
||||||
|
.send({ Destination_Api_Name: API_NAME, End_To_End_Txn_Id: txnId("noauth"), Bill_Id: billId });
|
||||||
|
expect(res.status).toBe(401);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("answers the bill lookup from live freight data", async () => {
|
||||||
|
const invoice = await currentInvoice(invoiceId);
|
||||||
|
const res = await cbe(token, "/cbe/query", {
|
||||||
|
Destination_Api_Name: API_NAME,
|
||||||
|
End_To_End_Txn_Id: txnId("q1"),
|
||||||
|
Bill_Id: billId,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.Response_Code).toBe("0");
|
||||||
|
expect(res.body.Bill_Id).toBe(billId);
|
||||||
|
// The amount and payer come from freight's billQuery, not from a cached
|
||||||
|
// copy in the payment service.
|
||||||
|
expect(Number(res.body.Total_Amount)).toBeCloseTo(Number(invoice.balance_amount), 2);
|
||||||
|
expect(res.body.Full_Name).toBe("E2E Logistics PLC");
|
||||||
|
expect(res.body.Payment_Reason).toMatch(/invoice/i);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an unknown bill as a business failure, not an error", async () => {
|
||||||
|
const res = await cbe(token, "/cbe/query", {
|
||||||
|
Destination_Api_Name: API_NAME,
|
||||||
|
End_To_End_Txn_Id: txnId("q404"),
|
||||||
|
Bill_Id: "000000000000",
|
||||||
|
});
|
||||||
|
// Business failures are HTTP 200 + Response_Code "3" — CBE treats a non-200
|
||||||
|
// as a channel fault and retries.
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.Response_Code).toBe("3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("settles the freight invoice when CBE reports the debit", async () => {
|
||||||
|
const invoice = await currentInvoice(invoiceId);
|
||||||
|
const res = await cbe(token, "/cbe/payment", {
|
||||||
|
Destination_Api_Name: API_NAME,
|
||||||
|
End_To_End_Txn_Id: txnId("p1"),
|
||||||
|
Cbe_Txn_Ref: `CBE${Date.now()}`,
|
||||||
|
Timestamp: new Date().toISOString(),
|
||||||
|
Bill_Id: billId,
|
||||||
|
Amount: String(invoice.balance_amount),
|
||||||
|
Currency: "ETB",
|
||||||
|
Full_Name: "IT Payer",
|
||||||
|
Phone_No: "+251911000001",
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.Response_Code).toBe("0");
|
||||||
|
|
||||||
|
const paid = await poll<{ status: string }>(
|
||||||
|
"invoice PAID via CBE bill",
|
||||||
|
`SELECT status FROM freight.invoices WHERE id = $1`,
|
||||||
|
[invoiceId],
|
||||||
|
(row) => row?.status === "PAID",
|
||||||
|
{ attempts: 30, intervalMs: 2000 },
|
||||||
|
);
|
||||||
|
expect(paid.status).toBe("PAID");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a second debit on the same bill", async () => {
|
||||||
|
const res = await cbe(token, "/cbe/payment", {
|
||||||
|
Destination_Api_Name: API_NAME,
|
||||||
|
End_To_End_Txn_Id: txnId("p2"),
|
||||||
|
Cbe_Txn_Ref: `CBE${Date.now()}`,
|
||||||
|
Timestamp: new Date().toISOString(),
|
||||||
|
Bill_Id: billId,
|
||||||
|
Amount: "1.00",
|
||||||
|
Currency: "ETB",
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.Response_Code).toBe("3");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports an already-paid bill on a later query", async () => {
|
||||||
|
const res = await cbe(token, "/cbe/query", {
|
||||||
|
Destination_Api_Name: API_NAME,
|
||||||
|
End_To_End_Txn_Id: txnId("q2"),
|
||||||
|
Bill_Id: billId,
|
||||||
|
});
|
||||||
|
expect(res.status).toBe(200);
|
||||||
|
expect(res.body.Response_Code).toBe("3");
|
||||||
|
});
|
||||||
|
});
|
||||||
218
integration/src/client.ts
Normal file
218
integration/src/client.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
/**
|
||||||
|
* Plumbing for the integration suite: HTTP against the containerized freight
|
||||||
|
* and payment APIs, SQL against their shared throwaway Postgres, and the
|
||||||
|
* gateway mock's control plane.
|
||||||
|
*
|
||||||
|
* This is the Cypress-free port of e2e/freight/cypress/e2e/flows/import-utils.ts —
|
||||||
|
* same request sequences, same SQL, `pg.Pool` instead of `cy.task`.
|
||||||
|
*/
|
||||||
|
import request from "supertest";
|
||||||
|
import { Pool, type QueryResultRow } from "pg";
|
||||||
|
|
||||||
|
export const API = process.env.IT_API_URL ?? "http://localhost:3111";
|
||||||
|
export const PAYMENT_API = process.env.IT_PAYMENT_URL ?? "http://localhost:3113";
|
||||||
|
export const GATEWAY = process.env.IT_GATEWAY_URL ?? "http://localhost:4600";
|
||||||
|
export const SERVICE_TOKEN = process.env.IT_SERVICE_TOKEN ?? "e2e-service-token";
|
||||||
|
|
||||||
|
const DB_URL =
|
||||||
|
process.env.IT_DB_URL ?? "postgres://edr_e2e:edr_e2e@localhost:5543/edr_freight_e2e";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// users (e2e/freight/cypress/fixtures/seed-users.sql + users.json)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const customerA = "user@gmail.com";
|
||||||
|
export const customerB = "user2@gmail.com";
|
||||||
|
export const opsStaff = "operation@edr.local";
|
||||||
|
export const chief = "chief@edr.local";
|
||||||
|
/** isSuperAdmin bypasses assertFreightPermission — used for the GL/clearance steps. */
|
||||||
|
export const superAdmin = "superadmin@tria.com";
|
||||||
|
|
||||||
|
const STAFF_PASSWORD = "password@tria";
|
||||||
|
const CUSTOMER_PASSWORD = "12345678";
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// database
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const pool = new Pool({ connectionString: DB_URL, max: 12 });
|
||||||
|
|
||||||
|
export async function db<T extends QueryResultRow = Record<string, unknown>>(
|
||||||
|
sql: string,
|
||||||
|
params: unknown[] = [],
|
||||||
|
): Promise<T[]> {
|
||||||
|
const res = await pool.query<T>(sql, params);
|
||||||
|
return res.rows;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function closeDb(): Promise<void> {
|
||||||
|
await pool.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Poll a query until `check` passes. Async settlement here is broker-driven. */
|
||||||
|
export async function poll<T extends QueryResultRow = Record<string, unknown>>(
|
||||||
|
label: string,
|
||||||
|
sql: string,
|
||||||
|
params: unknown[],
|
||||||
|
check: (row: T | undefined) => boolean,
|
||||||
|
{ attempts = 40, intervalMs = 2000 } = {},
|
||||||
|
): Promise<T> {
|
||||||
|
let last: T | undefined;
|
||||||
|
for (let i = 0; i < attempts; i++) {
|
||||||
|
last = (await db<T>(sql, params))[0];
|
||||||
|
if (check(last)) return last as T;
|
||||||
|
await sleep(intervalMs);
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`timed out waiting for ${label} after ${attempts} attempts — last row: ${JSON.stringify(last)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// auth
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
const tokens = new Map<string, string>();
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bearer token for a seeded account. The login audience is not cosmetic:
|
||||||
|
* `user_type='individual'` accounts are rejected on the backoffice audience and
|
||||||
|
* vice versa (EDRFREIGHT-415), so it is derived from the address.
|
||||||
|
*/
|
||||||
|
export async function tokenFor(email: string): Promise<string> {
|
||||||
|
const cached = tokens.get(email);
|
||||||
|
if (cached) return cached;
|
||||||
|
|
||||||
|
const portal = email.endsWith("@gmail.com");
|
||||||
|
const res = await request(API)
|
||||||
|
.post("/api/auth/login")
|
||||||
|
.set("x-client-app", portal ? "portal" : "backoffice")
|
||||||
|
.send({ email, password: portal ? CUSTOMER_PASSWORD : STAFF_PASSWORD });
|
||||||
|
|
||||||
|
// The login response is flattened by the app (no `.data` envelope) and 201s.
|
||||||
|
const token = res.body?.token ?? res.body?.data?.token;
|
||||||
|
if (!token) {
|
||||||
|
throw new Error(`login failed for ${email}: ${res.status} ${JSON.stringify(res.body)}`);
|
||||||
|
}
|
||||||
|
tokens.set(email, token);
|
||||||
|
return token;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Login without caching, so audience-rejection can be asserted. */
|
||||||
|
export function login(email: string, password: string, app: "portal" | "backoffice") {
|
||||||
|
return request(API).post("/api/auth/login").set("x-client-app", app).send({ email, password });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// freight API
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export type Method = "get" | "post" | "patch" | "delete";
|
||||||
|
|
||||||
|
/** Authenticated call to the freight API as `email`. Never throws on 4xx/5xx. */
|
||||||
|
export async function api(
|
||||||
|
email: string,
|
||||||
|
method: Method,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<request.Response> {
|
||||||
|
const token = await tokenFor(email);
|
||||||
|
const req = request(API)[method](path).set("Authorization", `Bearer ${token}`);
|
||||||
|
return method === "get" || method === "delete" ? req.send() : req.send(body ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Same, but fails loudly on a non-2xx — for arrange steps that must succeed. */
|
||||||
|
export async function apiOk(
|
||||||
|
email: string,
|
||||||
|
method: Method,
|
||||||
|
path: string,
|
||||||
|
body?: unknown,
|
||||||
|
): Promise<request.Response> {
|
||||||
|
const res = await api(email, method, path, body);
|
||||||
|
if (res.status < 200 || res.status > 201) {
|
||||||
|
throw new Error(
|
||||||
|
`${method.toUpperCase()} ${path} as ${email} → ${res.status}: ${JSON.stringify(res.body)}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return res;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Multipart upload (clearance documents). supertest handles the encoding. */
|
||||||
|
export async function upload(
|
||||||
|
email: string,
|
||||||
|
path: string,
|
||||||
|
filePath: string,
|
||||||
|
field = "files",
|
||||||
|
fields: Record<string, string> = {},
|
||||||
|
): Promise<request.Response> {
|
||||||
|
const token = await tokenFor(email);
|
||||||
|
const req = request(API).post(path).set("Authorization", `Bearer ${token}`);
|
||||||
|
for (const [k, v] of Object.entries(fields)) req.field(k, v);
|
||||||
|
return req.attach(field, filePath);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// payment API (service-to-service surface)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export function payment(method: Method, path: string, body?: unknown) {
|
||||||
|
const req = request(PAYMENT_API)[method](path).set("x-service-token", SERVICE_TOKEN);
|
||||||
|
return method === "get" || method === "delete" ? req.send() : req.send(body ?? {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Payment-side rows. The payment service owns its own schema in the same DB. */
|
||||||
|
export function paymentDb<T extends QueryResultRow = Record<string, unknown>>(
|
||||||
|
sql: string,
|
||||||
|
params: unknown[] = [],
|
||||||
|
) {
|
||||||
|
return db<T>(sql, params);
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface IntentRow extends QueryResultRow {
|
||||||
|
id: string;
|
||||||
|
status: string;
|
||||||
|
provider: string;
|
||||||
|
merchant_order_id: string;
|
||||||
|
amount_minor: string;
|
||||||
|
currency: string;
|
||||||
|
reference_id: string;
|
||||||
|
provider_txn_id: string | null;
|
||||||
|
expires_at: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function intentByMerchantOrderId(merchantOrderId: string) {
|
||||||
|
return db<IntentRow>(
|
||||||
|
`SELECT * FROM edr_payment.payment_intent WHERE merchant_order_id = $1`,
|
||||||
|
[merchantOrderId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// gateway mock control plane
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
export const gateway = {
|
||||||
|
reset: () => request(GATEWAY).post("/__control/reset").send({}),
|
||||||
|
|
||||||
|
/** Force a provider's next `times` calls (or all of them) into a mode. */
|
||||||
|
mode: (provider: string, mode: "ok" | "fail" | "timeout" | "pending" | "paid", times?: number) =>
|
||||||
|
request(GATEWAY).post(`/__control/provider/${provider}`).send({ mode, times }),
|
||||||
|
|
||||||
|
/** Mark the order settled at the gateway WITHOUT a callback (polling path). */
|
||||||
|
settle: (merchantOrderId: string) =>
|
||||||
|
request(GATEWAY).post("/__control/settle").send({ merchantOrderId }),
|
||||||
|
|
||||||
|
/** Fire a signed provider callback at the payment API. */
|
||||||
|
webhook: (opts: {
|
||||||
|
merchantOrderId: string;
|
||||||
|
provider?: string;
|
||||||
|
status?: string;
|
||||||
|
eventId?: string;
|
||||||
|
transactionId?: string;
|
||||||
|
signature?: "bad";
|
||||||
|
}) => request(GATEWAY).post("/__control/webhook").send(opts),
|
||||||
|
|
||||||
|
calls: () => request(GATEWAY).get("/__control/calls").send(),
|
||||||
|
};
|
||||||
215
integration/src/concurrency.it.ts
Normal file
215
integration/src/concurrency.it.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
/**
|
||||||
|
* Many users, at once. Each test drives one production race through the real
|
||||||
|
* HTTP surface and asserts the guard that is supposed to hold:
|
||||||
|
*
|
||||||
|
* - two settlements of one invoice → billing.markInvoiceAsPaid pessimistic lock
|
||||||
|
* - a replayed callback storm → webhook dedupe on externalEventId
|
||||||
|
* - two tenants, one wagon budget → reserveOnExport re-verify under lock (H8)
|
||||||
|
* - an invoice-number burst → pg_advisory_xact_lock in invoice-numbering
|
||||||
|
* - pay after the window closed → payInvoice dueAt gate
|
||||||
|
*
|
||||||
|
* These are the tests expected to find things. When one fails, read it as a
|
||||||
|
* finding, not as a flaky assertion.
|
||||||
|
*/
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
closeDb,
|
||||||
|
customerA,
|
||||||
|
customerB,
|
||||||
|
db,
|
||||||
|
gateway,
|
||||||
|
poll,
|
||||||
|
sleep,
|
||||||
|
} from "./client";
|
||||||
|
import {
|
||||||
|
TIN_B,
|
||||||
|
createImportSchedule,
|
||||||
|
currentInvoice,
|
||||||
|
departureAt,
|
||||||
|
ensureCorridorRoute,
|
||||||
|
releaseUnpaidHolds,
|
||||||
|
forceWindowOpen,
|
||||||
|
gatewayIntent,
|
||||||
|
invoiceForBooking,
|
||||||
|
payInvoice,
|
||||||
|
prepareBooking,
|
||||||
|
resetCorridorDay,
|
||||||
|
runBatch,
|
||||||
|
type ReadyBooking,
|
||||||
|
} from "./flows";
|
||||||
|
|
||||||
|
const DEPARTURE = departureAt(8);
|
||||||
|
const STAMP = String(Date.now());
|
||||||
|
|
||||||
|
describe("concurrency and multi-tenant races", () => {
|
||||||
|
let scheduleId: string;
|
||||||
|
let a: ReadyBooking;
|
||||||
|
let b: ReadyBooking;
|
||||||
|
let invoiceA: string;
|
||||||
|
let invoiceB: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await gateway.reset();
|
||||||
|
await releaseUnpaidHolds();
|
||||||
|
await ensureCorridorRoute();
|
||||||
|
await resetCorridorDay(DEPARTURE);
|
||||||
|
const schedule = await createImportSchedule({ departure: DEPARTURE });
|
||||||
|
scheduleId = schedule.id;
|
||||||
|
await forceWindowOpen(scheduleId, 45);
|
||||||
|
|
||||||
|
// Two DIFFERENT tenants on the same train-day.
|
||||||
|
a = await prepareBooking({
|
||||||
|
suffix: "CON-A",
|
||||||
|
departure: DEPARTURE,
|
||||||
|
runStamp: STAMP,
|
||||||
|
isoSeed: 200,
|
||||||
|
twenty: 2,
|
||||||
|
});
|
||||||
|
b = await prepareBooking({
|
||||||
|
suffix: "CON-B",
|
||||||
|
departure: DEPARTURE,
|
||||||
|
runStamp: STAMP,
|
||||||
|
isoSeed: 210,
|
||||||
|
twenty: 2,
|
||||||
|
tin: TIN_B,
|
||||||
|
as: customerB,
|
||||||
|
});
|
||||||
|
await runBatch(scheduleId);
|
||||||
|
invoiceA = (await invoiceForBooking(a.bookingId)).id;
|
||||||
|
invoiceB = (await invoiceForBooking(b.bookingId)).id;
|
||||||
|
}, 900_000);
|
||||||
|
|
||||||
|
afterAll(closeDb);
|
||||||
|
|
||||||
|
it("settles once when two callbacks land simultaneously", async () => {
|
||||||
|
expect((await payInvoice(invoiceA, { method: "CBE_BIRR" })).status).toBeLessThanOrEqual(201);
|
||||||
|
const intent = await gatewayIntent(a.bookingId);
|
||||||
|
|
||||||
|
// Five concurrent deliveries of the SAME provider event.
|
||||||
|
const results = await Promise.all(
|
||||||
|
Array.from({ length: 5 }, () =>
|
||||||
|
gateway.webhook({
|
||||||
|
merchantOrderId: intent.merchant_order_id,
|
||||||
|
eventId: `RACE-${intent.merchant_order_id}`,
|
||||||
|
}),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
expect(results.every((r) => r.body.delivered === 200)).toBe(true);
|
||||||
|
|
||||||
|
await poll<{ status: string }>(
|
||||||
|
"invoice PAID under duplicate delivery",
|
||||||
|
`SELECT status FROM freight.invoices WHERE id = $1`,
|
||||||
|
[invoiceA],
|
||||||
|
(row) => row?.status === "PAID",
|
||||||
|
{ attempts: 30, intervalMs: 2000 },
|
||||||
|
);
|
||||||
|
await sleep(3000);
|
||||||
|
|
||||||
|
// Dedupe is at the webhook table: one row, one outbox event, one ledger entry.
|
||||||
|
const [{ n: events }] = await db<{ n: string }>(
|
||||||
|
`SELECT count(*)::text AS n FROM edr_payment.payment_webhook_event
|
||||||
|
WHERE merchant_order_id = $1`,
|
||||||
|
[intent.merchant_order_id],
|
||||||
|
);
|
||||||
|
expect(Number(events)).toBe(1);
|
||||||
|
|
||||||
|
const [{ n: outbox }] = await db<{ n: string }>(
|
||||||
|
`SELECT count(*)::text AS n FROM edr_payment.notification_outbox
|
||||||
|
WHERE intent_id = $1 AND event_type = 'payment.succeeded'`,
|
||||||
|
[intent.id],
|
||||||
|
);
|
||||||
|
expect(Number(outbox)).toBe(1);
|
||||||
|
|
||||||
|
const invoice = await currentInvoice(invoiceA);
|
||||||
|
expect((invoice.payments as unknown[]).length).toBe(1);
|
||||||
|
expect(Number(invoice.balance_amount)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("credits an invoice once even when two intents settle for it", async () => {
|
||||||
|
// The per-reference unique index was dropped (migration 1782200000000), so
|
||||||
|
// two intents on one booking are legal now. Both settling must still not
|
||||||
|
// double-credit the invoice.
|
||||||
|
const paid = await payInvoice(invoiceB, { method: "CBE_BIRR", as: customerB });
|
||||||
|
expect(paid.status, JSON.stringify(paid.body)).toBeLessThanOrEqual(201);
|
||||||
|
const first = await gatewayIntent(b.bookingId);
|
||||||
|
|
||||||
|
// A second initiate for the same reference, different provider.
|
||||||
|
const second = await payInvoice(invoiceB, { method: "TELEBIRR", as: customerB });
|
||||||
|
expect(second.status).toBeLessThanOrEqual(201);
|
||||||
|
|
||||||
|
await Promise.all([
|
||||||
|
gateway.webhook({ merchantOrderId: first.merchant_order_id }),
|
||||||
|
gateway.webhook({
|
||||||
|
merchantOrderId: first.merchant_order_id,
|
||||||
|
eventId: `SECOND-${first.merchant_order_id}`,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
|
||||||
|
await poll<{ status: string }>(
|
||||||
|
"invoice PAID once",
|
||||||
|
`SELECT status FROM freight.invoices WHERE id = $1`,
|
||||||
|
[invoiceB],
|
||||||
|
(row) => row?.status === "PAID",
|
||||||
|
{ attempts: 30, intervalMs: 2000 },
|
||||||
|
);
|
||||||
|
await sleep(4000);
|
||||||
|
|
||||||
|
const invoice = await currentInvoice(invoiceB);
|
||||||
|
expect(Number(invoice.paid_amount)).toBeLessThanOrEqual(Number(invoice.total_amount));
|
||||||
|
expect(Number(invoice.balance_amount)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("gives two tenants distinct, gapless invoice numbers under a burst", async () => {
|
||||||
|
const numbers = await db<{ invoice_number: string }>(
|
||||||
|
`SELECT invoice_number FROM freight.invoices
|
||||||
|
WHERE created_at > now() - interval '30 minutes' AND deleted_at IS NULL`,
|
||||||
|
);
|
||||||
|
const seen = numbers.map((r) => r.invoice_number);
|
||||||
|
expect(new Set(seen).size).toBe(seen.length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never over-reserves the train when both tenants push at once", async () => {
|
||||||
|
// The batch already ran for this day. Assert the invariant it must keep:
|
||||||
|
// reserved wagons never exceed the consist.
|
||||||
|
const [row] = await db<{ max_wagons: number; reserved: string }>(
|
||||||
|
`SELECT ts.max_wagons,
|
||||||
|
COALESCE(SUM(b.wagons_required), 0)::text AS reserved
|
||||||
|
FROM freight.train_schedules ts
|
||||||
|
LEFT JOIN freight.bookings b
|
||||||
|
ON b.train_schedule_id = ts.id AND b.deleted_at IS NULL
|
||||||
|
AND b.status NOT IN ('EXPIRED','CANCELLED','REJECTED')
|
||||||
|
WHERE ts.id = $1
|
||||||
|
GROUP BY ts.max_wagons`,
|
||||||
|
[scheduleId],
|
||||||
|
);
|
||||||
|
expect(Number(row.reserved)).toBeLessThanOrEqual(Number(row.max_wagons));
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a fresh payment once the pay window has closed", async () => {
|
||||||
|
// A booking whose deadline has passed must not be able to START a payment
|
||||||
|
// (billing.payInvoice dueAt gate) — a payment begun BEFORE the deadline is
|
||||||
|
// still honoured later by the expire-time gateway reconcile, which is why
|
||||||
|
// the gate lives on initiation and not on settlement.
|
||||||
|
const departure = departureAt(9);
|
||||||
|
await releaseUnpaidHolds();
|
||||||
|
await resetCorridorDay(departure);
|
||||||
|
const schedule = await createImportSchedule({ departure });
|
||||||
|
await forceWindowOpen(schedule.id, 45);
|
||||||
|
const third = await prepareBooking({
|
||||||
|
suffix: "CON-C",
|
||||||
|
departure,
|
||||||
|
runStamp: STAMP,
|
||||||
|
isoSeed: 220,
|
||||||
|
twenty: 2,
|
||||||
|
});
|
||||||
|
await runBatch(schedule.id);
|
||||||
|
const invoice = await invoiceForBooking(third.bookingId);
|
||||||
|
|
||||||
|
await db(`UPDATE freight.invoices SET due_at = now() - interval '1 minute' WHERE id = $1`, [
|
||||||
|
invoice.id,
|
||||||
|
]);
|
||||||
|
const res = await payInvoice(invoice.id, { method: "CBE_BIRR", as: customerA });
|
||||||
|
expect(res.status).toBe(400);
|
||||||
|
expect(JSON.stringify(res.body)).toMatch(/payment window/i);
|
||||||
|
}, 600_000);
|
||||||
|
});
|
||||||
1680
integration/src/flows.ts
Normal file
1680
integration/src/flows.ts
Normal file
File diff suppressed because it is too large
Load Diff
181
integration/src/g1-s1-expiry-promotes-waitlist.it.ts
Normal file
181
integration/src/g1-s1-expiry-promotes-waitlist.it.ts
Normal file
@@ -0,0 +1,181 @@
|
|||||||
|
/**
|
||||||
|
* GROUP 1 · S1 — a no-pay expiry frees exactly the space the waiting list needs.
|
||||||
|
*
|
||||||
|
* A 3×40ft = 3 wagons
|
||||||
|
* B 20×20ft + 10×40ft = 20 wagons
|
||||||
|
* C 30×40ft = 30 wagons
|
||||||
|
* ─────────
|
||||||
|
* 53 = the whole BUILT train → all three reserved
|
||||||
|
* D 6×20ft = 3 wagons → no room → WAITING LIST
|
||||||
|
*
|
||||||
|
* B and C pay through the real gateway. A never does: its deadline passes, A
|
||||||
|
* EXPIRES, and its 3 wagons return to the day's pool. The top-up pass then
|
||||||
|
* promotes D — an exact fit — and D pays.
|
||||||
|
*
|
||||||
|
* Final consist: B 20 + C 30 + D 3 = 53/53, FULL.
|
||||||
|
* A is recoverable: its contract is untouched, so it can rebook a later day
|
||||||
|
* with no re-approval.
|
||||||
|
*
|
||||||
|
* The 53 slots are the BUILT train's coupled consist (seed-g1-train.sql), not a
|
||||||
|
* locomotive-length figure — see {@link createBuiltTrainSchedule}.
|
||||||
|
*/
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { closeDb, gateway } from "./client";
|
||||||
|
import {
|
||||||
|
G1_WAGONS,
|
||||||
|
allocatedWagons,
|
||||||
|
bookContainersReady,
|
||||||
|
bookingRow,
|
||||||
|
closeBookingWindow,
|
||||||
|
completeDocReview,
|
||||||
|
containerWagons,
|
||||||
|
createBuiltTrainSchedule,
|
||||||
|
departureAt,
|
||||||
|
eatDayStr,
|
||||||
|
endPaymentPhase,
|
||||||
|
ensureCorridorRoute,
|
||||||
|
expectContractStillBookable,
|
||||||
|
extendPayWindow,
|
||||||
|
forceReservationExpiry,
|
||||||
|
forceWindowOpen,
|
||||||
|
linkedBookings,
|
||||||
|
payViaGateway,
|
||||||
|
pollAllocations,
|
||||||
|
pollBookingStatus,
|
||||||
|
pollWindow,
|
||||||
|
releaseUnpaidHolds,
|
||||||
|
resetCorridorDay,
|
||||||
|
seedTenantContracts,
|
||||||
|
setPriority,
|
||||||
|
} from "./flows";
|
||||||
|
|
||||||
|
const DEPARTURE = departureAt(50);
|
||||||
|
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||||
|
const STAMP = String(Date.now());
|
||||||
|
|
||||||
|
/** Booking order is also PRIORITY order: A must be INSIDE the batch, because
|
||||||
|
* its expiry is what frees the space D needs. */
|
||||||
|
const SHAPES = {
|
||||||
|
A: { twenty: 0, forty: 3, wagons: 3 },
|
||||||
|
B: { twenty: 20, forty: 10, wagons: 20 },
|
||||||
|
C: { twenty: 0, forty: 30, wagons: 30 },
|
||||||
|
D: { twenty: 6, forty: 0, wagons: 3 },
|
||||||
|
} as const;
|
||||||
|
const ORDER = ["A", "B", "C", "D"] as const;
|
||||||
|
const IN_BATCH = ["A", "B", "C"] as const;
|
||||||
|
|
||||||
|
describe("g1 s1: expiry frees exactly the waiting list's space", () => {
|
||||||
|
const booking = new Map<string, string>();
|
||||||
|
let scheduleId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await gateway.reset();
|
||||||
|
await releaseUnpaidHolds();
|
||||||
|
await ensureCorridorRoute();
|
||||||
|
await resetCorridorDay(DEPARTURE);
|
||||||
|
|
||||||
|
const contracts = await seedTenantContracts(
|
||||||
|
STAMP,
|
||||||
|
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
|
||||||
|
);
|
||||||
|
scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id;
|
||||||
|
await forceWindowOpen(scheduleId, 60);
|
||||||
|
|
||||||
|
let isoSeed = 20_000;
|
||||||
|
for (const [i, suffix] of ORDER.entries()) {
|
||||||
|
const shape = SHAPES[suffix];
|
||||||
|
booking.set(
|
||||||
|
suffix,
|
||||||
|
await bookContainersReady({
|
||||||
|
contractId: contracts.get(suffix)!,
|
||||||
|
runStamp: STAMP,
|
||||||
|
isoSeed,
|
||||||
|
twenty: shape.twenty,
|
||||||
|
forty: shape.forty,
|
||||||
|
scheduledDate: BOOKING_DAY,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
isoSeed += shape.twenty + shape.forty;
|
||||||
|
await setPriority(booking.get(suffix)!, i + 1);
|
||||||
|
}
|
||||||
|
}, 1_800_000);
|
||||||
|
|
||||||
|
afterAll(closeDb);
|
||||||
|
|
||||||
|
it("the wagon math is exactly one trainload, and D fits exactly A's share", () => {
|
||||||
|
for (const suffix of ORDER) {
|
||||||
|
expect(containerWagons(SHAPES[suffix].twenty, SHAPES[suffix].forty), `${suffix} wagons`).toBe(
|
||||||
|
SHAPES[suffix].wagons,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
const booked = IN_BATCH.reduce((sum, s) => sum + SHAPES[s].wagons, 0);
|
||||||
|
expect(booked, "A+B+C fill the train exactly").toBe(G1_WAGONS);
|
||||||
|
expect(SHAPES.D.wagons, "D fits exactly the space A frees").toBe(SHAPES.A.wagons);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the batch reserves A, B and C; D holds a place in line rather than being rejected", async () => {
|
||||||
|
await closeBookingWindow(scheduleId);
|
||||||
|
await completeDocReview(scheduleId);
|
||||||
|
|
||||||
|
for (const suffix of IN_BATCH) {
|
||||||
|
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||||
|
expect(
|
||||||
|
(await bookingRow(booking.get(suffix)!)).payment_deadline,
|
||||||
|
`${suffix} got a pay window`,
|
||||||
|
).toBeTruthy();
|
||||||
|
}
|
||||||
|
const d = await bookingRow(booking.get("D")!);
|
||||||
|
expect(d.status, "D waitlisted").toBe("FULLY_EXECUTED");
|
||||||
|
expect(d.train_schedule_id, "D holds no seat").toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("B and C pay inside the window; A never does and EXPIRES, freeing 3 wagons", async () => {
|
||||||
|
// A is deliberately left out: the next assertion is about its expiry.
|
||||||
|
await extendPayWindow(scheduleId, [booking.get("B")!, booking.get("C")!]);
|
||||||
|
await payViaGateway(booking.get("B")!);
|
||||||
|
await pollAllocations(booking.get("B")!, SHAPES.B.wagons);
|
||||||
|
await payViaGateway(booking.get("C")!);
|
||||||
|
await pollAllocations(booking.get("C")!, SHAPES.C.wagons);
|
||||||
|
|
||||||
|
await forceReservationExpiry(booking.get("A")!);
|
||||||
|
expect((await bookingRow(booking.get("A")!)).status, "A expired unpaid").toBe("EXPIRED");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the freed 3 wagons promote D — an exact fit — and D pays", async () => {
|
||||||
|
// fillFromWaitingList runs on the tick that follows the expiry; no second
|
||||||
|
// staff action is needed.
|
||||||
|
const promoted = await pollBookingStatus(
|
||||||
|
booking.get("D")!,
|
||||||
|
["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"],
|
||||||
|
40,
|
||||||
|
);
|
||||||
|
expect(promoted.status, "D promoted off the waiting list").not.toBe("FULLY_EXECUTED");
|
||||||
|
expect((await bookingRow(booking.get("D")!)).payment_deadline, "D got a pay window").toBeTruthy();
|
||||||
|
|
||||||
|
await extendPayWindow(scheduleId, [booking.get("D")!]);
|
||||||
|
await payViaGateway(booking.get("D")!);
|
||||||
|
await pollAllocations(booking.get("D")!, SHAPES.D.wagons);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the train departs FULL at 53/53 — B 20 + C 30 + D 3, and A holds no seat", async () => {
|
||||||
|
await endPaymentPhase(scheduleId);
|
||||||
|
await pollWindow(
|
||||||
|
scheduleId,
|
||||||
|
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
|
||||||
|
"FULL + DONE",
|
||||||
|
);
|
||||||
|
expect(await allocatedWagons(scheduleId), "53 wagons allocated").toBe(G1_WAGONS);
|
||||||
|
expect(await linkedBookings(scheduleId), "3 bookings linked").toBe(3);
|
||||||
|
|
||||||
|
for (const suffix of ["B", "C", "D"] as const) {
|
||||||
|
expect((await bookingRow(booking.get(suffix)!)).status, `${suffix} rides`).toBe("PAID");
|
||||||
|
}
|
||||||
|
const a = await bookingRow(booking.get("A")!);
|
||||||
|
expect(a.status, "A does not ride").toBe("EXPIRED");
|
||||||
|
expect(a.train_schedule_id, "A holds no seat").toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("A is recoverable — its contract needs no re-approval to rebook a later day", async () => {
|
||||||
|
await expectContractStillBookable(booking.get("A")!);
|
||||||
|
});
|
||||||
|
});
|
||||||
142
integration/src/g1-s2-exact-fill.it.ts
Normal file
142
integration/src/g1-s2-exact-fill.it.ts
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
/**
|
||||||
|
* GROUP 1 · S2 — four bookings pay and fill the train to the slot.
|
||||||
|
*
|
||||||
|
* A 3×40ft = 3 wagons · 3 containers
|
||||||
|
* B 20×20ft + 10×40ft = 20 wagons · 30 containers
|
||||||
|
* C 25×40ft = 25 wagons · 25 containers
|
||||||
|
* D 10×20ft = 5 wagons · 10 containers
|
||||||
|
* ─────────
|
||||||
|
* 53/53 → FULL
|
||||||
|
*
|
||||||
|
* Everyone is selected, everyone pays, nothing splits and nobody waits. What
|
||||||
|
* this really guards is the ALLOCATION rather than the arithmetic: the 53
|
||||||
|
* wagons carry 68 containers and every one must land on exactly one slot with
|
||||||
|
* its number on it. A booking that took wagons but never mapped its units would
|
||||||
|
* still read 53/53 on the board — hence the per-container assertion at the end.
|
||||||
|
*/
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { closeDb, gateway } from "./client";
|
||||||
|
import {
|
||||||
|
G1_WAGONS,
|
||||||
|
allocatedWagons,
|
||||||
|
bookContainersReady,
|
||||||
|
bookingRow,
|
||||||
|
closeBookingWindow,
|
||||||
|
completeDocReview,
|
||||||
|
containerWagons,
|
||||||
|
createBuiltTrainSchedule,
|
||||||
|
departureAt,
|
||||||
|
eatDayStr,
|
||||||
|
endPaymentPhase,
|
||||||
|
ensureCorridorRoute,
|
||||||
|
expectContainersPlaced,
|
||||||
|
expectNoPartialOffer,
|
||||||
|
extendPayWindow,
|
||||||
|
forceWindowOpen,
|
||||||
|
linkedBookings,
|
||||||
|
payViaGateway,
|
||||||
|
pollAllocations,
|
||||||
|
pollBookingStatus,
|
||||||
|
pollWindow,
|
||||||
|
releaseUnpaidHolds,
|
||||||
|
resetCorridorDay,
|
||||||
|
seedTenantContracts,
|
||||||
|
} from "./flows";
|
||||||
|
|
||||||
|
const DEPARTURE = departureAt(51);
|
||||||
|
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||||
|
const STAMP = String(Date.now());
|
||||||
|
|
||||||
|
const SHAPES = {
|
||||||
|
A: { twenty: 0, forty: 3, wagons: 3, containers: 3 },
|
||||||
|
B: { twenty: 20, forty: 10, wagons: 20, containers: 30 },
|
||||||
|
C: { twenty: 0, forty: 25, wagons: 25, containers: 25 },
|
||||||
|
D: { twenty: 10, forty: 0, wagons: 5, containers: 10 },
|
||||||
|
} as const;
|
||||||
|
const ORDER = ["A", "B", "C", "D"] as const;
|
||||||
|
const TOTAL_CONTAINERS = ORDER.reduce((sum, s) => sum + SHAPES[s].containers, 0); // 68
|
||||||
|
|
||||||
|
describe("g1 s2: four bookings pay and fill the train exactly", () => {
|
||||||
|
const booking = new Map<string, string>();
|
||||||
|
let scheduleId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await gateway.reset();
|
||||||
|
await releaseUnpaidHolds();
|
||||||
|
await ensureCorridorRoute();
|
||||||
|
await resetCorridorDay(DEPARTURE);
|
||||||
|
|
||||||
|
const contracts = await seedTenantContracts(
|
||||||
|
STAMP,
|
||||||
|
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
|
||||||
|
);
|
||||||
|
scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id;
|
||||||
|
await forceWindowOpen(scheduleId, 60);
|
||||||
|
|
||||||
|
let isoSeed = 21_000;
|
||||||
|
for (const suffix of ORDER) {
|
||||||
|
const shape = SHAPES[suffix];
|
||||||
|
booking.set(
|
||||||
|
suffix,
|
||||||
|
await bookContainersReady({
|
||||||
|
contractId: contracts.get(suffix)!,
|
||||||
|
runStamp: STAMP,
|
||||||
|
isoSeed,
|
||||||
|
twenty: shape.twenty,
|
||||||
|
forty: shape.forty,
|
||||||
|
scheduledDate: BOOKING_DAY,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
isoSeed += shape.containers;
|
||||||
|
}
|
||||||
|
}, 1_800_000);
|
||||||
|
|
||||||
|
afterAll(closeDb);
|
||||||
|
|
||||||
|
it("the four bookings add up to exactly one trainload", () => {
|
||||||
|
for (const suffix of ORDER) {
|
||||||
|
expect(containerWagons(SHAPES[suffix].twenty, SHAPES[suffix].forty), `${suffix} wagons`).toBe(
|
||||||
|
SHAPES[suffix].wagons,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
ORDER.reduce((sum, s) => sum + SHAPES[s].wagons, 0),
|
||||||
|
"A+B+C+D fill the train exactly",
|
||||||
|
).toBe(G1_WAGONS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the batch reserves all four whole — an exact fit offers nobody a split", async () => {
|
||||||
|
await closeBookingWindow(scheduleId);
|
||||||
|
await completeDocReview(scheduleId);
|
||||||
|
for (const suffix of ORDER) {
|
||||||
|
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||||
|
await expectNoPartialOffer(booking.get(suffix)!, suffix);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("all four pay through the gateway and are allocated onto the train", async () => {
|
||||||
|
await extendPayWindow(scheduleId, [...booking.values()]);
|
||||||
|
for (const suffix of ORDER) {
|
||||||
|
await payViaGateway(booking.get(suffix)!);
|
||||||
|
await pollAllocations(booking.get(suffix)!, SHAPES[suffix].wagons);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the train is FULL at 53/53 and every one of the 68 containers has a slot", async () => {
|
||||||
|
await endPaymentPhase(scheduleId);
|
||||||
|
await pollWindow(
|
||||||
|
scheduleId,
|
||||||
|
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
|
||||||
|
"FULL + DONE",
|
||||||
|
);
|
||||||
|
expect(await allocatedWagons(scheduleId), "53 wagons allocated").toBe(G1_WAGONS);
|
||||||
|
expect(await linkedBookings(scheduleId), "4 bookings linked").toBe(4);
|
||||||
|
for (const suffix of ORDER) {
|
||||||
|
expect((await bookingRow(booking.get(suffix)!)).status, `${suffix} rides`).toBe("PAID");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Allocation is per CONTAINER, not just per wagon: 53 filled slots with
|
||||||
|
// only some units mapped would still read as a full train.
|
||||||
|
await expectContainersPlaced(scheduleId, TOTAL_CONTAINERS);
|
||||||
|
});
|
||||||
|
});
|
||||||
141
integration/src/g1-s3-underfill-day-open.it.ts
Normal file
141
integration/src/g1-s3-underfill-day-open.it.ts
Normal file
@@ -0,0 +1,141 @@
|
|||||||
|
/**
|
||||||
|
* GROUP 1 · S3 — an under-filled train keeps its day open.
|
||||||
|
*
|
||||||
|
* A 12×20ft = 6 wagons
|
||||||
|
* B 10×40ft = 10 wagons
|
||||||
|
* C 24×20ft = 12 wagons
|
||||||
|
* ─────────
|
||||||
|
* 28/53 → 25 slots still free
|
||||||
|
*
|
||||||
|
* Everyone pays, nobody splits, nobody waits. The assertion is the NEGATIVE
|
||||||
|
* one: the window must NOT be marked FULL, because the day has to stay on offer
|
||||||
|
* to customers who have not booked yet. A train that closed its day at 28/53
|
||||||
|
* would silently refuse 25 wagons of business — so the claim is checked the way
|
||||||
|
* a customer experiences it, through the portal's own day-availability query.
|
||||||
|
*/
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { closeDb, gateway } from "./client";
|
||||||
|
import {
|
||||||
|
G1_WAGONS,
|
||||||
|
allocatedWagons,
|
||||||
|
bookContainersReady,
|
||||||
|
bookingRow,
|
||||||
|
closeBookingWindow,
|
||||||
|
completeDocReview,
|
||||||
|
containerWagons,
|
||||||
|
createBuiltTrainSchedule,
|
||||||
|
dayAvailability,
|
||||||
|
departureAt,
|
||||||
|
eatDayStr,
|
||||||
|
endPaymentPhase,
|
||||||
|
ensureCorridorRoute,
|
||||||
|
expectNoPartialOffer,
|
||||||
|
extendPayWindow,
|
||||||
|
forceWindowOpen,
|
||||||
|
payViaGateway,
|
||||||
|
pollAllocations,
|
||||||
|
pollBookingStatus,
|
||||||
|
pollCycleConcluded,
|
||||||
|
releaseUnpaidHolds,
|
||||||
|
resetCorridorDay,
|
||||||
|
scheduleRow,
|
||||||
|
seedTenantContracts,
|
||||||
|
} from "./flows";
|
||||||
|
|
||||||
|
const DEPARTURE = departureAt(52);
|
||||||
|
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||||
|
const STAMP = String(Date.now());
|
||||||
|
|
||||||
|
const SHAPES = {
|
||||||
|
A: { twenty: 12, forty: 0, wagons: 6 },
|
||||||
|
B: { twenty: 0, forty: 10, wagons: 10 },
|
||||||
|
C: { twenty: 24, forty: 0, wagons: 12 },
|
||||||
|
} as const;
|
||||||
|
const ORDER = ["A", "B", "C"] as const;
|
||||||
|
const BOOKED_WAGONS = ORDER.reduce((sum, s) => sum + SHAPES[s].wagons, 0); // 28
|
||||||
|
const FREE_WAGONS = G1_WAGONS - BOOKED_WAGONS; // 25
|
||||||
|
|
||||||
|
describe("g1 s3: an under-filled train keeps its day open", () => {
|
||||||
|
const booking = new Map<string, string>();
|
||||||
|
let scheduleId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await gateway.reset();
|
||||||
|
await releaseUnpaidHolds();
|
||||||
|
await ensureCorridorRoute();
|
||||||
|
await resetCorridorDay(DEPARTURE);
|
||||||
|
|
||||||
|
const contracts = await seedTenantContracts(
|
||||||
|
STAMP,
|
||||||
|
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
|
||||||
|
);
|
||||||
|
scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id;
|
||||||
|
await forceWindowOpen(scheduleId, 60);
|
||||||
|
|
||||||
|
let isoSeed = 22_000;
|
||||||
|
for (const suffix of ORDER) {
|
||||||
|
const shape = SHAPES[suffix];
|
||||||
|
booking.set(
|
||||||
|
suffix,
|
||||||
|
await bookContainersReady({
|
||||||
|
contractId: contracts.get(suffix)!,
|
||||||
|
runStamp: STAMP,
|
||||||
|
isoSeed,
|
||||||
|
twenty: shape.twenty,
|
||||||
|
forty: shape.forty,
|
||||||
|
scheduledDate: BOOKING_DAY,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
isoSeed += shape.twenty + shape.forty;
|
||||||
|
}
|
||||||
|
}, 1_800_000);
|
||||||
|
|
||||||
|
afterAll(closeDb);
|
||||||
|
|
||||||
|
it("the three bookings leave 25 of the 53 slots free", () => {
|
||||||
|
for (const suffix of ORDER) {
|
||||||
|
expect(containerWagons(SHAPES[suffix].twenty, SHAPES[suffix].forty), `${suffix} wagons`).toBe(
|
||||||
|
SHAPES[suffix].wagons,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(BOOKED_WAGONS, "A+B+C = 28 wagons").toBe(28);
|
||||||
|
expect(FREE_WAGONS, "25 slots unused").toBe(25);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the batch reserves all three whole — with room to spare nobody is offered a split", async () => {
|
||||||
|
await closeBookingWindow(scheduleId);
|
||||||
|
await completeDocReview(scheduleId);
|
||||||
|
for (const suffix of ORDER) {
|
||||||
|
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||||
|
await expectNoPartialOffer(booking.get(suffix)!, suffix);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("all three pay — 28 of 53 wagons used and the window is NOT marked FULL", async () => {
|
||||||
|
await extendPayWindow(scheduleId, [...booking.values()]);
|
||||||
|
for (const suffix of ORDER) {
|
||||||
|
await payViaGateway(booking.get(suffix)!);
|
||||||
|
await pollAllocations(booking.get(suffix)!, SHAPES[suffix].wagons);
|
||||||
|
}
|
||||||
|
await endPaymentPhase(scheduleId);
|
||||||
|
await pollCycleConcluded(scheduleId);
|
||||||
|
|
||||||
|
expect(await allocatedWagons(scheduleId), "28 wagons allocated").toBe(BOOKED_WAGONS);
|
||||||
|
expect(
|
||||||
|
(await scheduleRow(scheduleId)).booking_window_status,
|
||||||
|
"window not FULL at 28/53",
|
||||||
|
).not.toBe("FULL");
|
||||||
|
for (const suffix of ORDER) {
|
||||||
|
expect((await bookingRow(booking.get(suffix)!)).status, `${suffix} rides`).toBe("PAID");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the day is still on offer to customers, with 25 free wagons", async () => {
|
||||||
|
// The customer-facing consequence, asked the way the portal asks it. A
|
||||||
|
// train that under-filled but stopped offering its day is the actual bug
|
||||||
|
// this scenario guards, and `freeWagons` is where it would show.
|
||||||
|
const day = await dayAvailability(booking.get("A")!, BOOKING_DAY);
|
||||||
|
expect(day.trainsForDay, "the day still runs a train").toBe(true);
|
||||||
|
expect(Number(day.freeWagons), "25 wagons still on offer").toBe(FREE_WAGONS);
|
||||||
|
});
|
||||||
|
});
|
||||||
172
integration/src/g1-s4-split-closes-gap.it.ts
Normal file
172
integration/src/g1-s4-split-closes-gap.it.ts
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
/**
|
||||||
|
* GROUP 1 · S4 — an over-subscribed day closes its last gap with a split.
|
||||||
|
*
|
||||||
|
* A 30×40ft = 30 wagons
|
||||||
|
* B 40×20ft = 20 wagons
|
||||||
|
* C 20×20ft = 10 wagons
|
||||||
|
* ─────────
|
||||||
|
* 60 wagons of demand for 53 slots
|
||||||
|
*
|
||||||
|
* The batch takes A and B whole — 50 used, 3 left. C needs 10 and cannot fit,
|
||||||
|
* so rather than being skipped it is OFFERED the 3 remaining wagons (6×20ft).
|
||||||
|
* C pays the offer THROUGH THE REAL PAYMENT PATH and the split applies: only a
|
||||||
|
* settled `booking.invoice.paid` applies a pending offer, so the staff
|
||||||
|
* mark-paid shortcut would allocate C whole and quietly defeat the scenario.
|
||||||
|
*
|
||||||
|
* What the split leaves behind is the other half of the case:
|
||||||
|
* - `is_split` set and `pre_split_quantities` snapshotting the ORIGINAL 20;
|
||||||
|
* - the booking itself reduced to the offered 6 containers;
|
||||||
|
* - a 14×20ft remainder the customer rolls to a later window.
|
||||||
|
*/
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { closeDb, gateway } from "./client";
|
||||||
|
import {
|
||||||
|
G1_WAGONS,
|
||||||
|
allocatedWagons,
|
||||||
|
bookContainersReady,
|
||||||
|
bookingRow,
|
||||||
|
closeBookingWindow,
|
||||||
|
completeDocReview,
|
||||||
|
containerCount,
|
||||||
|
containerWagons,
|
||||||
|
createBuiltTrainSchedule,
|
||||||
|
departureAt,
|
||||||
|
eatDayStr,
|
||||||
|
endPaymentPhase,
|
||||||
|
ensureCorridorRoute,
|
||||||
|
expectNoPartialOffer,
|
||||||
|
extendPayWindow,
|
||||||
|
forceWindowOpen,
|
||||||
|
payViaGateway,
|
||||||
|
pollAllocations,
|
||||||
|
pollBookingStatus,
|
||||||
|
pollPartialOffer,
|
||||||
|
pollWindow,
|
||||||
|
releaseUnpaidHolds,
|
||||||
|
resetCorridorDay,
|
||||||
|
seedTenantContracts,
|
||||||
|
setPriority,
|
||||||
|
} from "./flows";
|
||||||
|
|
||||||
|
const DEPARTURE = departureAt(53);
|
||||||
|
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||||
|
const STAMP = String(Date.now());
|
||||||
|
|
||||||
|
const SHAPES = {
|
||||||
|
A: { twenty: 0, forty: 30, wagons: 30 },
|
||||||
|
B: { twenty: 40, forty: 0, wagons: 20 },
|
||||||
|
C: { twenty: 20, forty: 0, wagons: 10 },
|
||||||
|
} as const;
|
||||||
|
const ORDER = ["A", "B", "C"] as const;
|
||||||
|
/** A + B take 50 of 53; the gap is what C is offered. */
|
||||||
|
const GAP_WAGONS = G1_WAGONS - SHAPES.A.wagons - SHAPES.B.wagons; // 3
|
||||||
|
const OFFERED_CONTAINERS = GAP_WAGONS * 2; // 6 × 20ft
|
||||||
|
const REMAINDER_CONTAINERS = SHAPES.C.twenty - OFFERED_CONTAINERS; // 14
|
||||||
|
|
||||||
|
describe("g1 s4: a split closes the last 3-wagon gap", () => {
|
||||||
|
const booking = new Map<string, string>();
|
||||||
|
let scheduleId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await gateway.reset();
|
||||||
|
await releaseUnpaidHolds();
|
||||||
|
await ensureCorridorRoute();
|
||||||
|
await resetCorridorDay(DEPARTURE);
|
||||||
|
|
||||||
|
const contracts = await seedTenantContracts(
|
||||||
|
STAMP,
|
||||||
|
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
|
||||||
|
);
|
||||||
|
scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id;
|
||||||
|
await forceWindowOpen(scheduleId, 60);
|
||||||
|
|
||||||
|
let isoSeed = 23_000;
|
||||||
|
for (const [i, suffix] of ORDER.entries()) {
|
||||||
|
const shape = SHAPES[suffix];
|
||||||
|
booking.set(
|
||||||
|
suffix,
|
||||||
|
await bookContainersReady({
|
||||||
|
contractId: contracts.get(suffix)!,
|
||||||
|
runStamp: STAMP,
|
||||||
|
isoSeed,
|
||||||
|
twenty: shape.twenty,
|
||||||
|
forty: shape.forty,
|
||||||
|
scheduledDate: BOOKING_DAY,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
isoSeed += shape.twenty + shape.forty;
|
||||||
|
// Priority decides who gets a whole seat and who gets the offer.
|
||||||
|
await setPriority(booking.get(suffix)!, i + 1);
|
||||||
|
}
|
||||||
|
}, 1_800_000);
|
||||||
|
|
||||||
|
afterAll(closeDb);
|
||||||
|
|
||||||
|
it("demand exceeds the train by 7 wagons, leaving a 3-wagon gap after A and B", () => {
|
||||||
|
for (const suffix of ORDER) {
|
||||||
|
expect(containerWagons(SHAPES[suffix].twenty, SHAPES[suffix].forty), `${suffix} wagons`).toBe(
|
||||||
|
SHAPES[suffix].wagons,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
ORDER.reduce((sum, s) => sum + SHAPES[s].wagons, 0),
|
||||||
|
"60 wagons of demand",
|
||||||
|
).toBe(60);
|
||||||
|
expect(GAP_WAGONS, "3-wagon gap after A+B").toBe(3);
|
||||||
|
expect(SHAPES.C.wagons, "C cannot fit whole").toBeGreaterThan(GAP_WAGONS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the batch takes A and B whole and offers C exactly the 3 remaining wagons", async () => {
|
||||||
|
await closeBookingWindow(scheduleId);
|
||||||
|
await completeDocReview(scheduleId);
|
||||||
|
|
||||||
|
for (const suffix of ["A", "B"] as const) {
|
||||||
|
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||||
|
await expectNoPartialOffer(booking.get(suffix)!, suffix);
|
||||||
|
}
|
||||||
|
const offer = await pollPartialOffer(booking.get("C")!);
|
||||||
|
expect(Number(offer.offered_wagons), "offer sized to the gap").toBe(GAP_WAGONS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("A and B pay whole; C settles its partial and the split applies", async () => {
|
||||||
|
await extendPayWindow(scheduleId, [...booking.values()]);
|
||||||
|
await payViaGateway(booking.get("A")!);
|
||||||
|
await pollAllocations(booking.get("A")!, SHAPES.A.wagons);
|
||||||
|
await payViaGateway(booking.get("B")!);
|
||||||
|
await pollAllocations(booking.get("B")!, SHAPES.B.wagons);
|
||||||
|
|
||||||
|
await payViaGateway(booking.get("C")!);
|
||||||
|
await pollAllocations(booking.get("C")!, GAP_WAGONS);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("C is flagged split, snapshotted at 20×20ft, and reduced to the offered 6", async () => {
|
||||||
|
const c = await bookingRow(booking.get("C")!);
|
||||||
|
expect(c.is_split, "C is split").toBe(true);
|
||||||
|
|
||||||
|
const snapshot = c.pre_split_quantities as { bySize?: Record<string, number> } | null;
|
||||||
|
expect(snapshot, "pre-split snapshot kept").toBeTruthy();
|
||||||
|
// The remainder is later measured against this snapshot, so the ORIGINAL
|
||||||
|
// quantity has to survive in it — not the reduced one.
|
||||||
|
expect(
|
||||||
|
Number(snapshot?.bySize?.["20FT"] ?? snapshot?.bySize?.["20ft"]),
|
||||||
|
"snapshot holds the original 20 × 20ft",
|
||||||
|
).toBe(SHAPES.C.twenty);
|
||||||
|
|
||||||
|
expect(await containerCount(booking.get("C")!), `C shrank to ${OFFERED_CONTAINERS} boxes`).toBe(
|
||||||
|
OFFERED_CONTAINERS,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the train is FULL at 53/53 and C's 14-container remainder is outstanding", async () => {
|
||||||
|
await endPaymentPhase(scheduleId);
|
||||||
|
await pollWindow(
|
||||||
|
scheduleId,
|
||||||
|
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
|
||||||
|
"FULL + DONE",
|
||||||
|
);
|
||||||
|
expect(await allocatedWagons(scheduleId), "53 wagons allocated").toBe(G1_WAGONS);
|
||||||
|
// 20 booked − 6 shipped = 14 still owed; the engine holds the customer to
|
||||||
|
// rebooking exactly that (asserted for bulk in bulk-import-split-promote).
|
||||||
|
expect(REMAINDER_CONTAINERS, "14 × 20ft outstanding").toBe(14);
|
||||||
|
});
|
||||||
|
});
|
||||||
190
integration/src/g1-s5-cascading-expiry.it.ts
Normal file
190
integration/src/g1-s5-cascading-expiry.it.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
/**
|
||||||
|
* GROUP 1 · S5 — one expiry cascades into a second promotion.
|
||||||
|
*
|
||||||
|
* In the batch: A 10w + B 20w + C 23w = 53/53
|
||||||
|
* Waiting: D 8w, E 5w (priority order D before E)
|
||||||
|
*
|
||||||
|
* A never pays and EXPIRES → 10 wagons freed. ONE settle then serves BOTH
|
||||||
|
* waiting bookings in the same pass: D is reserved whole (8w) and E, which no
|
||||||
|
* longer fits in the 2 wagons left, is OFFERED a partial of exactly those 2.
|
||||||
|
* That is the assertion the scenario exists for — fillFromWaitingList loops
|
||||||
|
* until a pass places nothing, so a single-pass top-up would leave E untouched
|
||||||
|
* until the next window cycle.
|
||||||
|
*
|
||||||
|
* D then expires too, freeing 8 more wagons — and E's offer is NOT resized.
|
||||||
|
* FINDING (the same one bulk-b1 pins): a refill never supersedes an open
|
||||||
|
* partial offer, so E pays its stale 2-wagon offer and ships 2 of its 5 wagons
|
||||||
|
* while 8 sit idle. The train settles at B 20 + C 23 + E 2 = 45/53.
|
||||||
|
*
|
||||||
|
* Every expiry must also leave an audit trail — a terminal EXPIRED booking with
|
||||||
|
* its invoice closed out, never a silent disappearance. An open invoice on an
|
||||||
|
* expired seat is money the customer could still pay for a train they are no
|
||||||
|
* longer on.
|
||||||
|
*/
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { closeDb, gateway } from "./client";
|
||||||
|
import {
|
||||||
|
G1_WAGONS,
|
||||||
|
allocatedWagons,
|
||||||
|
bookContainersReady,
|
||||||
|
bookingRow,
|
||||||
|
closeBookingWindow,
|
||||||
|
completeDocReview,
|
||||||
|
containerWagons,
|
||||||
|
createBuiltTrainSchedule,
|
||||||
|
departureAt,
|
||||||
|
eatDayStr,
|
||||||
|
endPaymentPhase,
|
||||||
|
ensureCorridorRoute,
|
||||||
|
extendPayWindow,
|
||||||
|
forceReservationExpiry,
|
||||||
|
forceWindowOpen,
|
||||||
|
livePayableInvoices,
|
||||||
|
payViaGateway,
|
||||||
|
pollAllocations,
|
||||||
|
pollBookingStatus,
|
||||||
|
pollCycleConcluded,
|
||||||
|
pollPartialOffer,
|
||||||
|
releaseUnpaidHolds,
|
||||||
|
resetCorridorDay,
|
||||||
|
scheduleRow,
|
||||||
|
seedTenantContracts,
|
||||||
|
setPriority,
|
||||||
|
} from "./flows";
|
||||||
|
|
||||||
|
const DEPARTURE = departureAt(54);
|
||||||
|
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||||
|
const STAMP = String(Date.now());
|
||||||
|
|
||||||
|
const SHAPES = {
|
||||||
|
A: { twenty: 20, forty: 0, wagons: 10 },
|
||||||
|
B: { twenty: 40, forty: 0, wagons: 20 },
|
||||||
|
C: { twenty: 0, forty: 23, wagons: 23 },
|
||||||
|
D: { twenty: 16, forty: 0, wagons: 8 },
|
||||||
|
E: { twenty: 10, forty: 0, wagons: 5 },
|
||||||
|
} as const;
|
||||||
|
const ORDER = ["A", "B", "C", "D", "E"] as const;
|
||||||
|
const IN_BATCH = ["A", "B", "C"] as const;
|
||||||
|
const WAITING = ["D", "E"] as const;
|
||||||
|
/** What A's expiry leaves loose once D takes its 8 — E's offer is sized to it. */
|
||||||
|
const E_OFFER = SHAPES.A.wagons - SHAPES.D.wagons; // 2
|
||||||
|
const RIDING = SHAPES.B.wagons + SHAPES.C.wagons + E_OFFER; // 45
|
||||||
|
|
||||||
|
describe("g1 s5: expiry cascades into a second promotion", () => {
|
||||||
|
const booking = new Map<string, string>();
|
||||||
|
let scheduleId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await gateway.reset();
|
||||||
|
await releaseUnpaidHolds();
|
||||||
|
await ensureCorridorRoute();
|
||||||
|
await resetCorridorDay(DEPARTURE);
|
||||||
|
|
||||||
|
const contracts = await seedTenantContracts(
|
||||||
|
STAMP,
|
||||||
|
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
|
||||||
|
);
|
||||||
|
scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id;
|
||||||
|
await forceWindowOpen(scheduleId, 60);
|
||||||
|
|
||||||
|
let isoSeed = 24_000;
|
||||||
|
for (const [i, suffix] of ORDER.entries()) {
|
||||||
|
const shape = SHAPES[suffix];
|
||||||
|
booking.set(
|
||||||
|
suffix,
|
||||||
|
await bookContainersReady({
|
||||||
|
contractId: contracts.get(suffix)!,
|
||||||
|
runStamp: STAMP,
|
||||||
|
isoSeed,
|
||||||
|
twenty: shape.twenty,
|
||||||
|
forty: shape.forty,
|
||||||
|
scheduledDate: BOOKING_DAY,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
isoSeed += shape.twenty + shape.forty;
|
||||||
|
await setPriority(booking.get(suffix)!, i + 1);
|
||||||
|
}
|
||||||
|
}, 1_800_000);
|
||||||
|
|
||||||
|
afterAll(closeDb);
|
||||||
|
|
||||||
|
it("A+B+C fill the train; D and E queue behind them, each fitting the hole above", () => {
|
||||||
|
for (const suffix of ORDER) {
|
||||||
|
expect(containerWagons(SHAPES[suffix].twenty, SHAPES[suffix].forty), `${suffix} wagons`).toBe(
|
||||||
|
SHAPES[suffix].wagons,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
expect(
|
||||||
|
IN_BATCH.reduce((sum, s) => sum + SHAPES[s].wagons, 0),
|
||||||
|
"A+B+C fill the train exactly",
|
||||||
|
).toBe(G1_WAGONS);
|
||||||
|
expect(SHAPES.D.wagons, "D fits inside A's 10").toBeLessThan(SHAPES.A.wagons);
|
||||||
|
expect(SHAPES.E.wagons, "E fits inside D's 8").toBeLessThan(SHAPES.D.wagons);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the batch reserves A, B and C; D and E wait in priority order", async () => {
|
||||||
|
await closeBookingWindow(scheduleId);
|
||||||
|
await completeDocReview(scheduleId);
|
||||||
|
for (const suffix of IN_BATCH) {
|
||||||
|
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||||
|
}
|
||||||
|
for (const suffix of WAITING) {
|
||||||
|
const row = await bookingRow(booking.get(suffix)!);
|
||||||
|
expect(row.status, `${suffix} waitlisted`).toBe("FULLY_EXECUTED");
|
||||||
|
expect(row.train_schedule_id, `${suffix} holds no seat`).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("B and C pay; A expires and ONE pass serves both waiting bookings", async () => {
|
||||||
|
await extendPayWindow(scheduleId, [booking.get("B")!, booking.get("C")!]);
|
||||||
|
await payViaGateway(booking.get("B")!);
|
||||||
|
await pollAllocations(booking.get("B")!, SHAPES.B.wagons);
|
||||||
|
await payViaGateway(booking.get("C")!);
|
||||||
|
await pollAllocations(booking.get("C")!, SHAPES.C.wagons);
|
||||||
|
|
||||||
|
await forceReservationExpiry(booking.get("A")!);
|
||||||
|
// D fits A's hole whole…
|
||||||
|
await pollBookingStatus(booking.get("D")!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], 40);
|
||||||
|
// …and the same pass keeps going: E no longer fits in the 2 wagons left, so
|
||||||
|
// it is offered exactly those rather than being left for the next cycle.
|
||||||
|
const offer = await pollPartialOffer(booking.get("E")!);
|
||||||
|
expect(Number(offer.offered_wagons), "E offered the 2 loose wagons").toBe(E_OFFER);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("D expires too — but E's stale 2-wagon offer is never resized", async () => {
|
||||||
|
await forceReservationExpiry(booking.get("D")!);
|
||||||
|
// FINDING (bulk-b1 pins the same gap): the refill re-selects a booking that
|
||||||
|
// already holds an OFFER without re-sizing it, so the 8 wagons D just freed
|
||||||
|
// stay unsold and E ships 2 of the 5 it asked for. Assert what the engine
|
||||||
|
// really does, so a fix to the offer path fails loudly here.
|
||||||
|
await pollBookingStatus(booking.get("E")!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], 40);
|
||||||
|
expect((await bookingRow(booking.get("E")!)).payment_deadline, "E got a pay window").toBeTruthy();
|
||||||
|
|
||||||
|
await extendPayWindow(scheduleId, [booking.get("E")!]);
|
||||||
|
await payViaGateway(booking.get("E")!);
|
||||||
|
await pollAllocations(booking.get("E")!, E_OFFER);
|
||||||
|
expect((await bookingRow(booking.get("E")!)).is_split, "E rode split, 2 of 5").toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("both expiries are terminal and auditable — nothing vanished silently", async () => {
|
||||||
|
for (const suffix of ["A", "D"] as const) {
|
||||||
|
const row = await bookingRow(booking.get(suffix)!);
|
||||||
|
expect(row.status, `${suffix} terminal EXPIRED`).toBe("EXPIRED");
|
||||||
|
expect(row.train_schedule_id, `${suffix} holds no seat`).toBeNull();
|
||||||
|
expect(
|
||||||
|
await livePayableInvoices(booking.get(suffix)!),
|
||||||
|
`${suffix} has no live payable invoice`,
|
||||||
|
).toBe(0);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the train settles at 45/53 — eight wagons unsold, short of FULL", async () => {
|
||||||
|
await endPaymentPhase(scheduleId);
|
||||||
|
await pollCycleConcluded(scheduleId);
|
||||||
|
expect(await allocatedWagons(scheduleId), "45 wagons allocated").toBe(RIDING);
|
||||||
|
expect(
|
||||||
|
(await scheduleRow(scheduleId)).booking_window_status,
|
||||||
|
"window not FULL at 45/53",
|
||||||
|
).not.toBe("FULL");
|
||||||
|
});
|
||||||
|
});
|
||||||
420
integration/src/g1-s6-s8-offers-government-tiers.it.ts
Normal file
420
integration/src/g1-s6-s8-offers-government-tiers.it.ts
Normal file
@@ -0,0 +1,420 @@
|
|||||||
|
/**
|
||||||
|
* GROUP 1 · S6–S8 — who gets the last wagons on the 53-wagon built train.
|
||||||
|
*
|
||||||
|
* S6 a split offer nobody takes: the offer lapses, the booking expires
|
||||||
|
* WHOLE, and the wagons it was offered go unsold.
|
||||||
|
* S7 a government booking jumps the queue — by PREEMPTION, not by ranking:
|
||||||
|
* it displaces the lowest-priority commercial reservation and rides
|
||||||
|
* unpaid, carrying the +50 000 bonus.
|
||||||
|
* S8 commercial priority tiers decide who is offered the remainder:
|
||||||
|
* USD payer > customs service > plain, with no per-booking priority set.
|
||||||
|
*
|
||||||
|
* TWO NOTES ON HOW THE PRODUCT REALLY WORKS
|
||||||
|
*
|
||||||
|
* S7 — the +50 000 bonus (GOVERNMENT_PRIORITY_BONUS) keys off
|
||||||
|
* `bookings.is_government`, not a government-institution lookup, and
|
||||||
|
* government does not merely outrank: `preemptForGovernment` EXPIRES the
|
||||||
|
* lowest-priority commercial booking whose leg overlaps and allocates in its
|
||||||
|
* place. Government bookings are created with POST /api/bookings against a
|
||||||
|
* kind='government' company and promoted with /government-expedite — never
|
||||||
|
* through the contract wizard.
|
||||||
|
*
|
||||||
|
* FINDING — preemption cannot reach a train whose window already reads FULL:
|
||||||
|
* `isFillable` rejects FULL outright, before any budget or victim is
|
||||||
|
* considered, and `refreshWindowStatus` re-derives FULL from live capacity, so
|
||||||
|
* a genuinely full train stays skipped. The scenario therefore books 52 of 53
|
||||||
|
* slots: committed, one slot short, which is the closest reachable state to
|
||||||
|
* "a full train" and still exercises the displacement.
|
||||||
|
*
|
||||||
|
* S8 — the retired USD_PAYER / RAIL_AND_FORWARDING priority RULES are gone
|
||||||
|
* (ReplacePriorityRulesWithPriorityConfigs). The live model is
|
||||||
|
* `priority_configs`, typed WAGON | CURRENCY | CUSTOMS and scored by
|
||||||
|
* wagon-count band. The scenario's intent — tiered ordering, lowest tier gets
|
||||||
|
* the split — is preserved against that mechanism. The CUSTOMS band only
|
||||||
|
* applies when the booking's SERVICE TYPE bundles customs, which is why S8's
|
||||||
|
* customs tenant is sold RAIL_CUSTOMS (seed-customs-service-type.sql).
|
||||||
|
*/
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { closeDb, gateway } from "./client";
|
||||||
|
import {
|
||||||
|
G1_WAGONS,
|
||||||
|
allocatedWagons,
|
||||||
|
bookContainersReady,
|
||||||
|
bookingRow,
|
||||||
|
closeBookingWindow,
|
||||||
|
completeDocReview,
|
||||||
|
containerCount,
|
||||||
|
containerWagons,
|
||||||
|
createBuiltTrainSchedule,
|
||||||
|
createGovernmentBooking,
|
||||||
|
createPriorityConfig,
|
||||||
|
departureAt,
|
||||||
|
dropPriorityConfig,
|
||||||
|
eatDayStr,
|
||||||
|
endPaymentPhase,
|
||||||
|
ensureCorridorRoute,
|
||||||
|
expectNoPartialOffer,
|
||||||
|
extendPayWindow,
|
||||||
|
forceOfferLapse,
|
||||||
|
forceWindowOpen,
|
||||||
|
governmentExpedite,
|
||||||
|
linkedBookings,
|
||||||
|
livePayableInvoices,
|
||||||
|
payViaGateway,
|
||||||
|
pinToSchedule,
|
||||||
|
pollAllocations,
|
||||||
|
pollBookingStatus,
|
||||||
|
pollCycleConcluded,
|
||||||
|
pollPartialOffer,
|
||||||
|
pollWindow,
|
||||||
|
releaseGovernmentBookings,
|
||||||
|
releaseUnpaidHolds,
|
||||||
|
resetCorridorDay,
|
||||||
|
scheduleRow,
|
||||||
|
seedTenantContracts,
|
||||||
|
setPriority,
|
||||||
|
triggerBatchRun,
|
||||||
|
} from "./flows";
|
||||||
|
|
||||||
|
const STAMP = String(Date.now());
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
// S6 — a split offer nobody takes
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("g1 s6: an ignored split offer expires the booking whole", () => {
|
||||||
|
const DEPARTURE = departureAt(55);
|
||||||
|
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||||
|
const SHAPES = {
|
||||||
|
SA: { twenty: 0, forty: 30, wagons: 30 },
|
||||||
|
SB: { twenty: 40, forty: 0, wagons: 20 },
|
||||||
|
SC: { twenty: 20, forty: 0, wagons: 10 },
|
||||||
|
} as const;
|
||||||
|
const ORDER = ["SA", "SB", "SC"] as const;
|
||||||
|
const GAP = G1_WAGONS - SHAPES.SA.wagons - SHAPES.SB.wagons; // 3
|
||||||
|
const RIDING = SHAPES.SA.wagons + SHAPES.SB.wagons; // 50
|
||||||
|
|
||||||
|
const booking = new Map<string, string>();
|
||||||
|
let scheduleId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await gateway.reset();
|
||||||
|
await releaseUnpaidHolds();
|
||||||
|
await ensureCorridorRoute();
|
||||||
|
await resetCorridorDay(DEPARTURE);
|
||||||
|
|
||||||
|
const contracts = await seedTenantContracts(
|
||||||
|
STAMP,
|
||||||
|
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
|
||||||
|
);
|
||||||
|
scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id;
|
||||||
|
await forceWindowOpen(scheduleId, 60);
|
||||||
|
|
||||||
|
let isoSeed = 25_000;
|
||||||
|
for (const [i, suffix] of ORDER.entries()) {
|
||||||
|
const shape = SHAPES[suffix];
|
||||||
|
booking.set(
|
||||||
|
suffix,
|
||||||
|
await bookContainersReady({
|
||||||
|
contractId: contracts.get(suffix)!,
|
||||||
|
runStamp: STAMP,
|
||||||
|
isoSeed,
|
||||||
|
twenty: shape.twenty,
|
||||||
|
forty: shape.forty,
|
||||||
|
scheduledDate: BOOKING_DAY,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
isoSeed += shape.twenty + shape.forty;
|
||||||
|
await setPriority(booking.get(suffix)!, i + 1);
|
||||||
|
}
|
||||||
|
}, 1_800_000);
|
||||||
|
|
||||||
|
it("SA and SB take 50 wagons; SC is offered the last 3", async () => {
|
||||||
|
expect(containerWagons(SHAPES.SC.twenty, 0), "SC needs 10 wagons").toBe(SHAPES.SC.wagons);
|
||||||
|
await closeBookingWindow(scheduleId);
|
||||||
|
await completeDocReview(scheduleId);
|
||||||
|
|
||||||
|
for (const suffix of ["SA", "SB"] as const) {
|
||||||
|
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||||
|
await expectNoPartialOffer(booking.get(suffix)!, suffix);
|
||||||
|
}
|
||||||
|
const offer = await pollPartialOffer(booking.get("SC")!);
|
||||||
|
expect(Number(offer.offered_wagons), "SC offered the 3-wagon gap").toBe(GAP);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("SC ignores the offer for the whole window — it EXPIRES whole", async () => {
|
||||||
|
await extendPayWindow(scheduleId, [booking.get("SA")!, booking.get("SB")!]);
|
||||||
|
await payViaGateway(booking.get("SA")!);
|
||||||
|
await pollAllocations(booking.get("SA")!, SHAPES.SA.wagons);
|
||||||
|
await payViaGateway(booking.get("SB")!);
|
||||||
|
await pollAllocations(booking.get("SB")!, SHAPES.SB.wagons);
|
||||||
|
|
||||||
|
// The offer dies with the booking's pay deadline; the tick settles it.
|
||||||
|
await forceOfferLapse(booking.get("SC")!);
|
||||||
|
|
||||||
|
// "Whole" is the load-bearing word: an ignored PARTIAL must not leave the
|
||||||
|
// booking silently reduced to the 3 wagons it was offered — the customer
|
||||||
|
// still owns all 20 containers and can rebook them intact.
|
||||||
|
const sc = await bookingRow(booking.get("SC")!);
|
||||||
|
expect(sc.is_split, "SC was never split").not.toBe(true);
|
||||||
|
expect(await containerCount(booking.get("SC")!), "SC's 20 containers intact").toBe(
|
||||||
|
SHAPES.SC.twenty,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the train departs NOT FULL at 50/53 — the 3 offered wagons went unsold", async () => {
|
||||||
|
await endPaymentPhase(scheduleId);
|
||||||
|
await pollCycleConcluded(scheduleId);
|
||||||
|
expect(await allocatedWagons(scheduleId), "50 wagons allocated").toBe(RIDING);
|
||||||
|
expect(
|
||||||
|
(await scheduleRow(scheduleId)).booking_window_status,
|
||||||
|
"window not FULL at 50/53",
|
||||||
|
).not.toBe("FULL");
|
||||||
|
expect(GAP, "3 wagons wasted").toBe(3);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
// S7 — government preempts the lowest-priority commercial reservation
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("g1 s7: a government booking preempts commercial", () => {
|
||||||
|
const DEPARTURE = departureAt(56);
|
||||||
|
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||||
|
/** GA outranks GB, so GB is the one preemption must take. */
|
||||||
|
const SHAPES = {
|
||||||
|
GA: { forty: 25, wagons: 25 },
|
||||||
|
GB: { forty: 27, wagons: 27 },
|
||||||
|
} as const;
|
||||||
|
const ORDER = ["GA", "GB"] as const;
|
||||||
|
/** More than the single free slot: the government booking cannot fit as-is. */
|
||||||
|
const GOV_WAGONS = 15;
|
||||||
|
|
||||||
|
const booking = new Map<string, string>();
|
||||||
|
let scheduleId: string;
|
||||||
|
let govBookingId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await gateway.reset();
|
||||||
|
await releaseUnpaidHolds();
|
||||||
|
await releaseGovernmentBookings();
|
||||||
|
await ensureCorridorRoute();
|
||||||
|
await resetCorridorDay(DEPARTURE);
|
||||||
|
|
||||||
|
const contracts = await seedTenantContracts(
|
||||||
|
STAMP,
|
||||||
|
ORDER.map((suffix) => ({ suffix, freight: "CONTAINER" as const })),
|
||||||
|
);
|
||||||
|
scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id;
|
||||||
|
await forceWindowOpen(scheduleId, 60);
|
||||||
|
|
||||||
|
let isoSeed = 26_000;
|
||||||
|
for (const [i, suffix] of ORDER.entries()) {
|
||||||
|
booking.set(
|
||||||
|
suffix,
|
||||||
|
await bookContainersReady({
|
||||||
|
contractId: contracts.get(suffix)!,
|
||||||
|
runStamp: STAMP,
|
||||||
|
isoSeed,
|
||||||
|
forty: SHAPES[suffix].forty,
|
||||||
|
scheduledDate: BOOKING_DAY,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
isoSeed += SHAPES[suffix].forty;
|
||||||
|
await setPriority(booking.get(suffix)!, i + 1);
|
||||||
|
}
|
||||||
|
}, 1_800_000);
|
||||||
|
|
||||||
|
it("GA pays and GB holds a reservation — 52 of 53 slots committed", async () => {
|
||||||
|
await closeBookingWindow(scheduleId);
|
||||||
|
await completeDocReview(scheduleId);
|
||||||
|
for (const suffix of ORDER) {
|
||||||
|
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
await extendPayWindow(scheduleId, [booking.get("GA")!]);
|
||||||
|
await payViaGateway(booking.get("GA")!);
|
||||||
|
await pollAllocations(booking.get("GA")!, SHAPES.GA.wagons);
|
||||||
|
|
||||||
|
// GB stays UNPAID on purpose — it is the reservation the government
|
||||||
|
// booking must displace — but its hold is widened so it does not lapse on
|
||||||
|
// its own while the government booking is being created.
|
||||||
|
await extendPayWindow(scheduleId, [booking.get("GB")!]);
|
||||||
|
expect((await bookingRow(booking.get("GB")!)).status, "GB still reserved").toMatch(
|
||||||
|
/SELECTED_FOR_BATCH|AWAITING_PAYMENT/,
|
||||||
|
);
|
||||||
|
expect(SHAPES.GA.wagons + SHAPES.GB.wagons, "52 of 53 committed").toBe(G1_WAGONS - 1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("a government booking is created and expedited — PAID without paying", async () => {
|
||||||
|
govBookingId = await createGovernmentBooking({ forty: GOV_WAGONS });
|
||||||
|
// Idempotent: create() already expedites, the endpoint is the retry path.
|
||||||
|
await governmentExpedite(govBookingId);
|
||||||
|
|
||||||
|
const gov = await bookingRow(govBookingId);
|
||||||
|
expect(gov.status, "PAID after expedite").toBe("PAID");
|
||||||
|
expect(gov.is_government, "flagged government").toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("it carries the +50 000 bonus, far above any commercial score", async () => {
|
||||||
|
const govScore = Number((await bookingRow(govBookingId)).priority_score);
|
||||||
|
expect(govScore, "government bonus applied").toBeGreaterThanOrEqual(50_000);
|
||||||
|
expect(
|
||||||
|
Number((await bookingRow(booking.get("GA")!)).priority_score),
|
||||||
|
"top commercial still far below government",
|
||||||
|
).toBeLessThan(govScore);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the fill displaces GB — the lower-priority reservation — and GA is untouched", async () => {
|
||||||
|
// Staff pin, then run the batch: fillSchedule's pool is keyed on
|
||||||
|
// `booking.train_schedule_id`, and the customer-facing pin is unavailable
|
||||||
|
// because the window closed at doc review.
|
||||||
|
await pinToSchedule(govBookingId, scheduleId);
|
||||||
|
await triggerBatchRun(scheduleId);
|
||||||
|
|
||||||
|
await pollBookingStatus(booking.get("GB")!, "EXPIRED", 30);
|
||||||
|
const gb = await bookingRow(booking.get("GB")!);
|
||||||
|
expect(gb.scheduling_status, "GB back to ELIGIBLE").toBe("ELIGIBLE");
|
||||||
|
expect(gb.payment_deadline, "GB pay window cleared").toBeNull();
|
||||||
|
expect(await livePayableInvoices(booking.get("GB")!), "GB invoice closed out").toBe(0);
|
||||||
|
|
||||||
|
const ga = await bookingRow(booking.get("GA")!);
|
||||||
|
expect(ga.status, "GA survives untouched").toBe("PAID");
|
||||||
|
expect(ga.train_schedule_id, "GA still on this train").toBe(scheduleId);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the government booking rides on the freed wagons — allocated, never invoiced", async () => {
|
||||||
|
await pollAllocations(govBookingId, GOV_WAGONS);
|
||||||
|
const gov = await bookingRow(govBookingId);
|
||||||
|
expect(gov.scheduling_status, "government SCHEDULED").toBe("SCHEDULED");
|
||||||
|
expect(await livePayableInvoices(govBookingId), "government rides unpaid").toBe(0);
|
||||||
|
expect(await linkedBookings(scheduleId), "GA + government hold the seats").toBe(2);
|
||||||
|
expect(await allocatedWagons(scheduleId), "25 commercial + 15 government").toBe(
|
||||||
|
SHAPES.GA.wagons + GOV_WAGONS,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
// S8 — commercial priority tiers order the batch
|
||||||
|
// ───────────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
describe("g1 s8: priority tiers order the batch", () => {
|
||||||
|
const DEPARTURE = departureAt(57);
|
||||||
|
const BOOKING_DAY = eatDayStr(DEPARTURE);
|
||||||
|
/** Three equal bookings — only the TIER differs, so ordering is the only
|
||||||
|
* thing that can decide who is left with the remainder. */
|
||||||
|
const SHAPES = {
|
||||||
|
PA: { forty: 20, wagons: 20, tier: "USD payer" },
|
||||||
|
PB: { forty: 20, wagons: 20, tier: "customs service" },
|
||||||
|
PC: { forty: 20, wagons: 20, tier: "plain" },
|
||||||
|
} as const;
|
||||||
|
const ORDER = ["PA", "PB", "PC"] as const;
|
||||||
|
/** 60 wagons of demand for 53 slots → the third gets a 13-wagon offer. */
|
||||||
|
const GAP = G1_WAGONS - SHAPES.PA.wagons - SHAPES.PB.wagons; // 13
|
||||||
|
|
||||||
|
const booking = new Map<string, string>();
|
||||||
|
let scheduleId: string;
|
||||||
|
let usdConfigId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await gateway.reset();
|
||||||
|
await releaseUnpaidHolds();
|
||||||
|
await ensureCorridorRoute();
|
||||||
|
await resetCorridorDay(DEPARTURE);
|
||||||
|
|
||||||
|
// The USD tier. The CUSTOMS bands (7 / 15 points) ship with the corridor
|
||||||
|
// fixture, so only the currency band has to be added — and it is dropped in
|
||||||
|
// afterAll, because an active band re-ranks every later file's pool.
|
||||||
|
usdConfigId = await createPriorityConfig({
|
||||||
|
type: "CURRENCY",
|
||||||
|
label: `IT USD payer ${STAMP.slice(-5)}`,
|
||||||
|
currency: "USD",
|
||||||
|
minWagonCount: 1,
|
||||||
|
maxWagonCount: 53,
|
||||||
|
scorePoints: 35,
|
||||||
|
});
|
||||||
|
|
||||||
|
const contracts = await seedTenantContracts(STAMP, [
|
||||||
|
{ suffix: "PA", freight: "CONTAINER" as const, currency: "USD" as const },
|
||||||
|
{
|
||||||
|
suffix: "PB",
|
||||||
|
freight: "CONTAINER" as const,
|
||||||
|
customs: true,
|
||||||
|
serviceTypeCode: "RAIL_CUSTOMS",
|
||||||
|
},
|
||||||
|
{ suffix: "PC", freight: "CONTAINER" as const },
|
||||||
|
]);
|
||||||
|
scheduleId = (await createBuiltTrainSchedule({ departure: DEPARTURE })).id;
|
||||||
|
await forceWindowOpen(scheduleId, 60);
|
||||||
|
|
||||||
|
let isoSeed = 27_000;
|
||||||
|
for (const suffix of ORDER) {
|
||||||
|
booking.set(
|
||||||
|
suffix,
|
||||||
|
await bookContainersReady({
|
||||||
|
contractId: contracts.get(suffix)!,
|
||||||
|
runStamp: STAMP,
|
||||||
|
isoSeed,
|
||||||
|
forty: SHAPES[suffix].forty,
|
||||||
|
scheduledDate: BOOKING_DAY,
|
||||||
|
customs: suffix === "PB",
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
isoSeed += SHAPES[suffix].forty;
|
||||||
|
}
|
||||||
|
}, 1_800_000);
|
||||||
|
|
||||||
|
afterAll(async () => {
|
||||||
|
await dropPriorityConfig(usdConfigId);
|
||||||
|
await closeDb();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the engine scores USD above customs above plain — no manual priority set", async () => {
|
||||||
|
// Deliberately no setPriority anywhere in this file: the point is that the
|
||||||
|
// rule engine's own bands produce the order. Pinning the scores by hand
|
||||||
|
// would test setPriority, not the tiers.
|
||||||
|
const scores = new Map<string, number>();
|
||||||
|
for (const suffix of ORDER) {
|
||||||
|
scores.set(suffix, Number((await bookingRow(booking.get(suffix)!)).priority_score));
|
||||||
|
}
|
||||||
|
expect(scores.get("PA")!, "USD tier outranks the customs tier").toBeGreaterThan(
|
||||||
|
scores.get("PB")!,
|
||||||
|
);
|
||||||
|
expect(scores.get("PB")!, "customs tier outranks plain").toBeGreaterThan(scores.get("PC")!);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("the two top tiers board whole; the lowest is offered the 13-wagon remainder", async () => {
|
||||||
|
expect(GAP, "13-wagon remainder after the top two").toBe(13);
|
||||||
|
await closeBookingWindow(scheduleId);
|
||||||
|
await completeDocReview(scheduleId);
|
||||||
|
|
||||||
|
for (const suffix of ["PA", "PB"] as const) {
|
||||||
|
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
|
||||||
|
await expectNoPartialOffer(booking.get(suffix)!, suffix);
|
||||||
|
}
|
||||||
|
const offer = await pollPartialOffer(booking.get("PC")!);
|
||||||
|
expect(Number(offer.offered_wagons), "PC offered the exact remainder").toBe(GAP);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("all three settle — PC ships 13 of its 20 wagons and the train is FULL", async () => {
|
||||||
|
await extendPayWindow(scheduleId, [...booking.values()]);
|
||||||
|
await payViaGateway(booking.get("PA")!);
|
||||||
|
await pollAllocations(booking.get("PA")!, SHAPES.PA.wagons);
|
||||||
|
await payViaGateway(booking.get("PB")!);
|
||||||
|
await pollAllocations(booking.get("PB")!, SHAPES.PB.wagons);
|
||||||
|
await payViaGateway(booking.get("PC")!);
|
||||||
|
await pollAllocations(booking.get("PC")!, GAP);
|
||||||
|
|
||||||
|
expect((await bookingRow(booking.get("PC")!)).is_split, "PC is the split one").toBe(true);
|
||||||
|
expect(await containerCount(booking.get("PC")!), "PC shrank to 13 containers").toBe(GAP);
|
||||||
|
|
||||||
|
await endPaymentPhase(scheduleId);
|
||||||
|
await pollWindow(
|
||||||
|
scheduleId,
|
||||||
|
(s) => s.booking_window_status === "FULL" && s.window_phase === "DONE",
|
||||||
|
"FULL + DONE",
|
||||||
|
);
|
||||||
|
expect(await allocatedWagons(scheduleId), "53 wagons allocated").toBe(G1_WAGONS);
|
||||||
|
});
|
||||||
|
});
|
||||||
70
integration/src/global-setup.ts
Normal file
70
integration/src/global-setup.ts
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
/**
|
||||||
|
* Runs once before any spec: wait for both APIs, then seed.
|
||||||
|
*
|
||||||
|
* Seeds are the Cypress suite's fixtures, reused verbatim (they are idempotent
|
||||||
|
* `insert … where not exists`), plus one of our own for the second tenant:
|
||||||
|
* seed-users.sql → seed-company.sql (order matters; company needs the users)
|
||||||
|
* seed-import-corridor.sql (yards, locos, wagons, rates, distances)
|
||||||
|
* seed-bulk-items.sql (PER_ITEM break-bulk cargo types)
|
||||||
|
* seed-g1-train.sql (the 53-wagon BUILT container train)
|
||||||
|
* seed-government.sql (the kind='government' company)
|
||||||
|
* seed-company-b.sql (user2@gmail.com's company — this suite)
|
||||||
|
* seed-customs-service-type.sql (a service type that bundles customs)
|
||||||
|
*/
|
||||||
|
import { readFileSync } from "node:fs";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { Client } from "pg";
|
||||||
|
|
||||||
|
const API = process.env.IT_API_URL ?? "http://localhost:3111";
|
||||||
|
const PAYMENT_API = process.env.IT_PAYMENT_URL ?? "http://localhost:3113";
|
||||||
|
const GATEWAY = process.env.IT_GATEWAY_URL ?? "http://localhost:4600";
|
||||||
|
const DB_URL =
|
||||||
|
process.env.IT_DB_URL ?? "postgres://edr_e2e:edr_e2e@localhost:5543/edr_freight_e2e";
|
||||||
|
|
||||||
|
const CYPRESS_FIXTURES = join(process.cwd(), "..", "e2e", "freight", "cypress", "fixtures");
|
||||||
|
const OWN_FIXTURES = join(process.cwd(), "sql");
|
||||||
|
|
||||||
|
const SEEDS: Array<[dir: string, file: string]> = [
|
||||||
|
[CYPRESS_FIXTURES, "seed-users.sql"],
|
||||||
|
[CYPRESS_FIXTURES, "seed-company.sql"],
|
||||||
|
[CYPRESS_FIXTURES, "seed-import-corridor.sql"],
|
||||||
|
[CYPRESS_FIXTURES, "seed-bulk-items.sql"],
|
||||||
|
[CYPRESS_FIXTURES, "seed-g1-train.sql"],
|
||||||
|
[CYPRESS_FIXTURES, "seed-government.sql"],
|
||||||
|
[OWN_FIXTURES, "seed-company-b.sql"],
|
||||||
|
[OWN_FIXTURES, "seed-customs-service-type.sql"],
|
||||||
|
];
|
||||||
|
|
||||||
|
async function waitFor(label: string, url: string, attempts = 60): Promise<void> {
|
||||||
|
for (let i = 0; i < attempts; i++) {
|
||||||
|
try {
|
||||||
|
const res = await fetch(url);
|
||||||
|
if (res.ok) return;
|
||||||
|
} catch {
|
||||||
|
/* not up yet */
|
||||||
|
}
|
||||||
|
await new Promise((r) => setTimeout(r, 2000));
|
||||||
|
}
|
||||||
|
throw new Error(`${label} never became healthy at ${url}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function setup(): Promise<void> {
|
||||||
|
await Promise.all([
|
||||||
|
waitFor("freight-api", `${API}/api/health`),
|
||||||
|
waitFor("payment-api", `${PAYMENT_API}/health`),
|
||||||
|
waitFor("gateway-mock", `${GATEWAY}/__control/health`),
|
||||||
|
]);
|
||||||
|
|
||||||
|
const client = new Client({ connectionString: DB_URL });
|
||||||
|
await client.connect();
|
||||||
|
try {
|
||||||
|
for (const [dir, file] of SEEDS) {
|
||||||
|
await client.query(readFileSync(join(dir, file), "utf8"));
|
||||||
|
console.log(`it: seeded ${file}`);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
await client.end();
|
||||||
|
}
|
||||||
|
|
||||||
|
await fetch(`${GATEWAY}/__control/reset`, { method: "POST" });
|
||||||
|
}
|
||||||
249
integration/src/payment-failure.it.ts
Normal file
249
integration/src/payment-failure.it.ts
Normal file
@@ -0,0 +1,249 @@
|
|||||||
|
/**
|
||||||
|
* What happens when the bank misbehaves. Each test forces the gateway mock
|
||||||
|
* into a failure mode and asserts the platform's answer — the point being that
|
||||||
|
* NO failure may ever settle an invoice that was not paid, and no failure may
|
||||||
|
* lose a payment that was.
|
||||||
|
*
|
||||||
|
* Covered: provider down at initiate, hard decline, forged signature, replayed
|
||||||
|
* callback, silent settlement found only by the reconciliation sweep, and the
|
||||||
|
* unverifiable answer that must stop freight from expiring a paying customer.
|
||||||
|
*/
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import { closeDb, db, gateway, payment, poll, sleep } from "./client";
|
||||||
|
import {
|
||||||
|
createImportSchedule,
|
||||||
|
currentInvoice,
|
||||||
|
departureAt,
|
||||||
|
ensureCorridorRoute,
|
||||||
|
releaseUnpaidHolds,
|
||||||
|
forceWindowOpen,
|
||||||
|
gatewayIntent,
|
||||||
|
invoiceForBooking,
|
||||||
|
payInvoice,
|
||||||
|
prepareBooking,
|
||||||
|
resetCorridorDay,
|
||||||
|
runBatch,
|
||||||
|
type ReadyBooking,
|
||||||
|
} from "./flows";
|
||||||
|
|
||||||
|
const DEPARTURE = departureAt(6);
|
||||||
|
const STAMP = String(Date.now());
|
||||||
|
|
||||||
|
/** Four independent bookings so one test's terminal state can't poison another. */
|
||||||
|
const CASES = ["FAIL1", "FAIL2", "FAIL3", "FAIL4"] as const;
|
||||||
|
type CaseName = (typeof CASES)[number];
|
||||||
|
|
||||||
|
describe("payment failure and recovery", () => {
|
||||||
|
const bookings = new Map<CaseName, ReadyBooking>();
|
||||||
|
const invoices = new Map<CaseName, string>();
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await gateway.reset();
|
||||||
|
await releaseUnpaidHolds();
|
||||||
|
await ensureCorridorRoute();
|
||||||
|
await resetCorridorDay(DEPARTURE);
|
||||||
|
const schedule = await createImportSchedule({ departure: DEPARTURE });
|
||||||
|
await forceWindowOpen(schedule.id, 45);
|
||||||
|
|
||||||
|
let isoSeed = 100;
|
||||||
|
for (const suffix of CASES) {
|
||||||
|
bookings.set(
|
||||||
|
suffix,
|
||||||
|
await prepareBooking({
|
||||||
|
suffix,
|
||||||
|
departure: DEPARTURE,
|
||||||
|
runStamp: STAMP,
|
||||||
|
isoSeed,
|
||||||
|
twenty: 2,
|
||||||
|
}),
|
||||||
|
);
|
||||||
|
isoSeed += 2;
|
||||||
|
}
|
||||||
|
await runBatch(schedule.id);
|
||||||
|
for (const suffix of CASES) {
|
||||||
|
invoices.set(suffix, (await invoiceForBooking(bookings.get(suffix)!.bookingId)).id);
|
||||||
|
}
|
||||||
|
}, 600_000);
|
||||||
|
|
||||||
|
afterAll(closeDb);
|
||||||
|
|
||||||
|
it("leaves the invoice payable when the provider is unreachable", async () => {
|
||||||
|
const invoiceId = invoices.get("FAIL1")!;
|
||||||
|
await gateway.mode("cbe-birr", "fail");
|
||||||
|
|
||||||
|
const res = await payInvoice(invoiceId, { method: "CBE_BIRR" });
|
||||||
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||||
|
|
||||||
|
// No phantom settlement, and the customer can retry.
|
||||||
|
const invoice = await currentInvoice(invoiceId);
|
||||||
|
expect(invoice.status).not.toBe("PAID");
|
||||||
|
expect(invoice.paid_at).toBeNull();
|
||||||
|
|
||||||
|
await gateway.mode("cbe-birr", "ok");
|
||||||
|
const retry = await payInvoice(invoiceId, { method: "CBE_BIRR" });
|
||||||
|
expect(retry.status, JSON.stringify(retry.body)).toBeLessThanOrEqual(201);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps the invoice open on a declined payment", async () => {
|
||||||
|
const { bookingId } = bookings.get("FAIL2")!;
|
||||||
|
const invoiceId = invoices.get("FAIL2")!;
|
||||||
|
await gateway.mode("cbe-birr", "ok");
|
||||||
|
expect((await payInvoice(invoiceId, { method: "CBE_BIRR" })).status).toBeLessThanOrEqual(201);
|
||||||
|
|
||||||
|
const intent = await gatewayIntent(bookingId);
|
||||||
|
await gateway.webhook({ merchantOrderId: intent.merchant_order_id, status: "FAILED" });
|
||||||
|
|
||||||
|
const failed = await poll<{ status: string }>(
|
||||||
|
"intent FAILED",
|
||||||
|
`SELECT status FROM edr_payment.payment_intent WHERE id = $1`,
|
||||||
|
[intent.id],
|
||||||
|
(row) => row?.status === "FAILED",
|
||||||
|
{ attempts: 20, intervalMs: 1000 },
|
||||||
|
);
|
||||||
|
expect(failed.status).toBe("FAILED");
|
||||||
|
|
||||||
|
const invoice = await currentInvoice(invoiceId);
|
||||||
|
expect(invoice.status).not.toBe("PAID");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores a forged signature — event recorded, money untouched", async () => {
|
||||||
|
const { bookingId } = bookings.get("FAIL3")!;
|
||||||
|
const invoiceId = invoices.get("FAIL3")!;
|
||||||
|
expect((await payInvoice(invoiceId, { method: "CBE_BIRR" })).status).toBeLessThanOrEqual(201);
|
||||||
|
|
||||||
|
const intent = await gatewayIntent(bookingId);
|
||||||
|
const res = await gateway.webhook({
|
||||||
|
merchantOrderId: intent.merchant_order_id,
|
||||||
|
signature: "bad",
|
||||||
|
});
|
||||||
|
// Providers must still get a 2xx (Waafi times out at 5s and never retries).
|
||||||
|
expect(res.body.delivered).toBe(200);
|
||||||
|
|
||||||
|
const event = await poll<{ signature_valid: boolean; processing_error: string | null }>(
|
||||||
|
"forged webhook recorded",
|
||||||
|
`SELECT signature_valid, processing_error FROM edr_payment.payment_webhook_event
|
||||||
|
WHERE merchant_order_id = $1 ORDER BY received_at DESC LIMIT 1`,
|
||||||
|
[intent.merchant_order_id],
|
||||||
|
(row) => !!row,
|
||||||
|
{ attempts: 15, intervalMs: 1000 },
|
||||||
|
);
|
||||||
|
expect(event.signature_valid).toBe(false);
|
||||||
|
expect(event.processing_error).toBe("signature-invalid");
|
||||||
|
|
||||||
|
await sleep(3000);
|
||||||
|
const after = await db<{ status: string }>(
|
||||||
|
`SELECT status FROM edr_payment.payment_intent WHERE id = $1`,
|
||||||
|
[intent.id],
|
||||||
|
);
|
||||||
|
expect(after[0].status).not.toBe("SUCCEEDED");
|
||||||
|
expect((await currentInvoice(invoiceId)).status).not.toBe("PAID");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("settles from the reconciliation sweep alone, with no callback at all", async () => {
|
||||||
|
const { bookingId } = bookings.get("FAIL4")!;
|
||||||
|
const invoiceId = invoices.get("FAIL4")!;
|
||||||
|
expect((await payInvoice(invoiceId, { method: "CBE_BIRR" })).status).toBeLessThanOrEqual(201);
|
||||||
|
|
||||||
|
const intent = await gatewayIntent(bookingId);
|
||||||
|
// The customer pays at the bank, but the callback is lost in the network.
|
||||||
|
await gateway.settle(intent.merchant_order_id);
|
||||||
|
|
||||||
|
// RECONCILE_STALE_AFTER_MS=5s, sweep every 30s — one sweep is enough.
|
||||||
|
const settled = await poll<{ status: string }>(
|
||||||
|
"intent settled by sweep",
|
||||||
|
`SELECT status FROM edr_payment.payment_intent WHERE id = $1`,
|
||||||
|
[intent.id],
|
||||||
|
(row) => row?.status === "SUCCEEDED",
|
||||||
|
{ attempts: 30, intervalMs: 3000 },
|
||||||
|
);
|
||||||
|
expect(settled.status).toBe("SUCCEEDED");
|
||||||
|
|
||||||
|
const invoice = await poll<{ status: string }>(
|
||||||
|
"invoice PAID via sweep",
|
||||||
|
`SELECT status FROM freight.invoices WHERE id = $1`,
|
||||||
|
[invoiceId],
|
||||||
|
(row) => row?.status === "PAID",
|
||||||
|
{ attempts: 30, intervalMs: 2000 },
|
||||||
|
);
|
||||||
|
expect(invoice.status).toBe("PAID");
|
||||||
|
|
||||||
|
// No webhook was ever delivered for this one.
|
||||||
|
const [{ n }] = await db<{ n: string }>(
|
||||||
|
`SELECT count(*)::text AS n FROM edr_payment.payment_webhook_event
|
||||||
|
WHERE merchant_order_id = $1`,
|
||||||
|
[intent.merchant_order_id],
|
||||||
|
);
|
||||||
|
expect(Number(n)).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports `unverifiable` when the gateway cannot answer, so freight must not expire the hold", async () => {
|
||||||
|
// FAIL1 still has a live (unsettled) intent — FAIL2's is terminal, and a
|
||||||
|
// reference with nothing to verify legitimately answers "not paid".
|
||||||
|
const { bookingId } = bookings.get("FAIL1")!;
|
||||||
|
await gateway.mode("cbe-birr", "timeout");
|
||||||
|
|
||||||
|
const res = await payment("post", "/payments/reconcile", {
|
||||||
|
service: "FREIGHT",
|
||||||
|
referenceType: "SHIPMENT",
|
||||||
|
referenceId: bookingId,
|
||||||
|
});
|
||||||
|
await gateway.mode("cbe-birr", "ok");
|
||||||
|
|
||||||
|
expect([200, 201]).toContain(res.status);
|
||||||
|
const body = res.body?.data ?? res.body;
|
||||||
|
expect(body.paid).toBe(false);
|
||||||
|
// An unknown answer must never read as "definitely unpaid" — that is what
|
||||||
|
// stops the batch engine from expiring a customer who actually paid.
|
||||||
|
expect(body.unverifiable).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("captures late: a settlement after the intent expired still pays the invoice", async () => {
|
||||||
|
const { bookingId } = bookings.get("FAIL3")!;
|
||||||
|
const invoiceId = invoices.get("FAIL3")!;
|
||||||
|
const intent = await gatewayIntent(bookingId);
|
||||||
|
|
||||||
|
// Retire the intent the way an expiry sweep would, then let the money land.
|
||||||
|
await db(
|
||||||
|
`UPDATE edr_payment.payment_intent
|
||||||
|
SET status = 'EXPIRED', expires_at = now() - interval '1 minute'
|
||||||
|
WHERE id = $1`,
|
||||||
|
[intent.id],
|
||||||
|
);
|
||||||
|
await gateway.webhook({
|
||||||
|
merchantOrderId: intent.merchant_order_id,
|
||||||
|
eventId: `LATE-${intent.merchant_order_id}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
const captured = await poll<{ status: string }>(
|
||||||
|
"late capture flips the intent",
|
||||||
|
`SELECT status FROM edr_payment.payment_intent WHERE id = $1`,
|
||||||
|
[intent.id],
|
||||||
|
(row) => row?.status === "SUCCEEDED",
|
||||||
|
{ attempts: 20, intervalMs: 1000 },
|
||||||
|
);
|
||||||
|
expect(captured.status).toBe("SUCCEEDED");
|
||||||
|
|
||||||
|
const invoice = await poll<{ status: string }>(
|
||||||
|
"invoice settled by late capture",
|
||||||
|
`SELECT status FROM freight.invoices WHERE id = $1`,
|
||||||
|
[invoiceId],
|
||||||
|
(row) => row?.status === "PAID",
|
||||||
|
{ attempts: 30, intervalMs: 2000 },
|
||||||
|
);
|
||||||
|
expect(invoice.status).toBe("PAID");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("retries delivery until the consumer is back", async () => {
|
||||||
|
// Deliberately not covered here: it needs the freight container stopped
|
||||||
|
// mid-test, which would break every other file sharing this stack.
|
||||||
|
// `node integration/scripts/it.mjs logs` + a manual `docker compose stop
|
||||||
|
// freight-api-e2e` reproduces it; the relay's backoff is unit-testable.
|
||||||
|
// ponytail: outbox retry asserted only via attempts>0 below; add a
|
||||||
|
// dedicated single-file stack if this ever regresses.
|
||||||
|
const rows = await db<{ status: string; attempts: number }>(
|
||||||
|
`SELECT status, attempts FROM edr_payment.notification_outbox ORDER BY created_at DESC LIMIT 20`,
|
||||||
|
);
|
||||||
|
expect(rows.length).toBeGreaterThan(0);
|
||||||
|
expect(rows.every((r) => r.status !== "FAILED")).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
178
integration/src/payment-happy.it.ts
Normal file
178
integration/src/payment-happy.it.ts
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
/**
|
||||||
|
* The freight ⇄ payment happy path, end to end through both services.
|
||||||
|
*
|
||||||
|
* portal pays invoice
|
||||||
|
* → freight billing.payInvoice → payment API /payments/initiate
|
||||||
|
* → CBE Birr provider → gateway mock (intent opened, invoice.payment_id set)
|
||||||
|
* gateway calls back (correctly signed)
|
||||||
|
* → payment API webhook pipeline → intent SUCCEEDED → outbox row
|
||||||
|
* → RabbitMQ → freight consumer → settleByPaymentId
|
||||||
|
* → invoice PAID → booking.invoice.paid → booking advances
|
||||||
|
*
|
||||||
|
* Nothing here is stubbed except the bank itself.
|
||||||
|
*/
|
||||||
|
import { afterAll, beforeAll, describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
closeDb,
|
||||||
|
customerA,
|
||||||
|
db,
|
||||||
|
gateway,
|
||||||
|
intentByMerchantOrderId,
|
||||||
|
poll,
|
||||||
|
} from "./client";
|
||||||
|
import {
|
||||||
|
createImportSchedule,
|
||||||
|
currentInvoice,
|
||||||
|
departureAt,
|
||||||
|
ensureCorridorRoute,
|
||||||
|
releaseUnpaidHolds,
|
||||||
|
forceWindowOpen,
|
||||||
|
freightPayment,
|
||||||
|
gatewayIntent,
|
||||||
|
invoiceForBooking,
|
||||||
|
payInvoice,
|
||||||
|
prepareBooking,
|
||||||
|
resetCorridorDay,
|
||||||
|
runBatch,
|
||||||
|
type ReadyBooking,
|
||||||
|
} from "./flows";
|
||||||
|
|
||||||
|
const DEPARTURE = departureAt(4);
|
||||||
|
const STAMP = String(Date.now());
|
||||||
|
|
||||||
|
describe("freight invoice settles through the real payment service", () => {
|
||||||
|
let booking: ReadyBooking;
|
||||||
|
let invoiceId: string;
|
||||||
|
|
||||||
|
beforeAll(async () => {
|
||||||
|
await gateway.reset();
|
||||||
|
await releaseUnpaidHolds();
|
||||||
|
await ensureCorridorRoute();
|
||||||
|
await resetCorridorDay(DEPARTURE);
|
||||||
|
const schedule = await createImportSchedule({ departure: DEPARTURE });
|
||||||
|
await forceWindowOpen(schedule.id, 45);
|
||||||
|
|
||||||
|
booking = await prepareBooking({
|
||||||
|
suffix: "PAY1",
|
||||||
|
departure: DEPARTURE,
|
||||||
|
runStamp: STAMP,
|
||||||
|
isoSeed: 0,
|
||||||
|
twenty: 2,
|
||||||
|
});
|
||||||
|
await runBatch(schedule.id);
|
||||||
|
invoiceId = (await invoiceForBooking(booking.bookingId)).id;
|
||||||
|
});
|
||||||
|
|
||||||
|
afterAll(closeDb);
|
||||||
|
|
||||||
|
it("opens a gateway intent and links it to the invoice", async () => {
|
||||||
|
const res = await payInvoice(invoiceId, { method: "CBE_BIRR" });
|
||||||
|
expect(res.status, JSON.stringify(res.body)).toBeLessThanOrEqual(201);
|
||||||
|
|
||||||
|
const intent = await gatewayIntent(booking.bookingId);
|
||||||
|
expect(intent.provider).toBe("CBE_BIRR");
|
||||||
|
expect(intent.status).toBe("REQUIRES_ACTION");
|
||||||
|
expect(intent.merchant_order_id).toBeTruthy();
|
||||||
|
|
||||||
|
// The invoice must carry the intent id BEFORE any callback can arrive —
|
||||||
|
// settlement correlates on it (billing.service.ts payInvoice).
|
||||||
|
const invoice = await currentInvoice(invoiceId);
|
||||||
|
expect(invoice.payment_id).toBeTruthy();
|
||||||
|
|
||||||
|
// Freight's local projection of the same intent.
|
||||||
|
const projection = await freightPayment(invoice.payment_id!);
|
||||||
|
expect(projection.status).toBe("action-required");
|
||||||
|
expect(projection.merchant_order_id).toBe(intent.merchant_order_id);
|
||||||
|
|
||||||
|
// Freight deliberately sends a dev-shortcut amount for non-CBE_BILL
|
||||||
|
// providers (payment.service.ts:238-247) — 1 minor unit, not the invoice
|
||||||
|
// total. Asserted, not "fixed": changing it is a product decision.
|
||||||
|
expect(Number(intent.amount_minor)).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("settles the invoice and advances the booking when the gateway calls back", async () => {
|
||||||
|
const intent = await gatewayIntent(booking.bookingId);
|
||||||
|
const res = await gateway.webhook({ merchantOrderId: intent.merchant_order_id });
|
||||||
|
expect(res.body.delivered).toBe(200);
|
||||||
|
|
||||||
|
const settled = await poll<{ status: string }>(
|
||||||
|
"payment intent SUCCEEDED",
|
||||||
|
`SELECT status FROM edr_payment.payment_intent WHERE id = $1`,
|
||||||
|
[intent.id],
|
||||||
|
(row) => row?.status === "SUCCEEDED",
|
||||||
|
{ attempts: 20, intervalMs: 1000 },
|
||||||
|
);
|
||||||
|
expect(settled.status).toBe("SUCCEEDED");
|
||||||
|
|
||||||
|
// The outbox row is written in the SAME transaction as the intent update
|
||||||
|
// and relayed to RabbitMQ by the relay loop.
|
||||||
|
const outbox = await poll<{ status: string; attempts: number }>(
|
||||||
|
"outbox row relayed",
|
||||||
|
`SELECT status, attempts FROM edr_payment.notification_outbox
|
||||||
|
WHERE intent_id = $1 AND event_type = 'payment.succeeded'`,
|
||||||
|
[intent.id],
|
||||||
|
(row) => row?.status === "SENT",
|
||||||
|
{ attempts: 20, intervalMs: 1000 },
|
||||||
|
);
|
||||||
|
expect(outbox.status).toBe("SENT");
|
||||||
|
|
||||||
|
// …and freight, on the other end of the broker, settles the invoice.
|
||||||
|
const invoice = await poll<{ status: string; paid_amount: string; balance_amount: string }>(
|
||||||
|
"invoice PAID",
|
||||||
|
`SELECT status, paid_amount, balance_amount FROM freight.invoices WHERE id = $1`,
|
||||||
|
[invoiceId],
|
||||||
|
(row) => row?.status === "PAID",
|
||||||
|
{ attempts: 30, intervalMs: 2000 },
|
||||||
|
);
|
||||||
|
expect(Number(invoice.balance_amount)).toBe(0);
|
||||||
|
expect(Number(invoice.paid_amount)).toBeGreaterThan(0);
|
||||||
|
|
||||||
|
const booked = await poll<{ status: string }>(
|
||||||
|
"booking advanced on payment",
|
||||||
|
`SELECT status FROM freight.bookings WHERE id = $1`,
|
||||||
|
[booking.bookingId],
|
||||||
|
(row) => row?.status === "PAID",
|
||||||
|
{ attempts: 20, intervalMs: 2000 },
|
||||||
|
);
|
||||||
|
expect(booked.status).toBe("PAID");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("records exactly one intent, one webhook event and one ledger entry", async () => {
|
||||||
|
const intent = await gatewayIntent(booking.bookingId);
|
||||||
|
const intents = await intentByMerchantOrderId(intent.merchant_order_id);
|
||||||
|
expect(intents.length).toBe(1);
|
||||||
|
|
||||||
|
const [{ n }] = await db<{ n: string }>(
|
||||||
|
`SELECT count(*)::text AS n FROM edr_payment.payment_webhook_event
|
||||||
|
WHERE merchant_order_id = $1`,
|
||||||
|
[intent.merchant_order_id],
|
||||||
|
);
|
||||||
|
expect(Number(n)).toBe(1);
|
||||||
|
|
||||||
|
const invoice = await currentInvoice(invoiceId);
|
||||||
|
const ledger = (invoice.payments ?? []) as unknown[];
|
||||||
|
expect(Array.isArray(ledger) ? ledger.length : 0).toBe(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is idempotent — a replayed callback changes nothing", async () => {
|
||||||
|
const intent = await gatewayIntent(booking.bookingId);
|
||||||
|
const before = await currentInvoice(invoiceId);
|
||||||
|
|
||||||
|
// Same orderId ⇒ same externalEventId ⇒ deduped at the webhook table.
|
||||||
|
await gateway.webhook({
|
||||||
|
merchantOrderId: intent.merchant_order_id,
|
||||||
|
eventId: `CBEORD-${intent.merchant_order_id}`,
|
||||||
|
});
|
||||||
|
await new Promise((r) => setTimeout(r, 3000));
|
||||||
|
|
||||||
|
const after = await currentInvoice(invoiceId);
|
||||||
|
expect(after.status).toBe("PAID");
|
||||||
|
expect(after.paid_amount).toBe(before.paid_amount);
|
||||||
|
expect((after.payments as unknown[]).length).toBe((before.payments as unknown[]).length);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("refuses a second payment on an already-paid invoice", async () => {
|
||||||
|
const res = await payInvoice(invoiceId, { method: "CBE_BIRR", as: customerA });
|
||||||
|
expect(res.status).toBeGreaterThanOrEqual(400);
|
||||||
|
});
|
||||||
|
});
|
||||||
16
integration/tsconfig.json
Normal file
16
integration/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"target": "ES2022",
|
||||||
|
"lib": ["ES2022"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"types": ["node"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "vitest.config.ts"]
|
||||||
|
}
|
||||||
18
integration/vitest.config.ts
Normal file
18
integration/vitest.config.ts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
import { defineConfig } from "vitest/config";
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
test: {
|
||||||
|
include: ["src/**/*.it.ts"],
|
||||||
|
globalSetup: ["src/global-setup.ts"],
|
||||||
|
// One shared containerized stack and one shared database: files run in
|
||||||
|
// sequence. Concurrency is exercised INSIDE tests (Promise.all), which is
|
||||||
|
// what the guards under test actually see in production.
|
||||||
|
fileParallelism: false,
|
||||||
|
testTimeout: 180_000,
|
||||||
|
hookTimeout: 180_000,
|
||||||
|
// Booking/scheduling steps are not idempotent — a retry would assert
|
||||||
|
// against a half-advanced booking.
|
||||||
|
retry: 0,
|
||||||
|
reporters: ["verbose"],
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -31,6 +31,10 @@
|
|||||||
"e2e:freight:run": "node e2e/freight/scripts/e2e.mjs run",
|
"e2e:freight:run": "node e2e/freight/scripts/e2e.mjs run",
|
||||||
"e2e:freight:ci": "node e2e/freight/scripts/e2e.mjs ci",
|
"e2e:freight:ci": "node e2e/freight/scripts/e2e.mjs ci",
|
||||||
"e2e:freight:down": "node e2e/freight/scripts/e2e.mjs down",
|
"e2e:freight:down": "node e2e/freight/scripts/e2e.mjs down",
|
||||||
|
"it:up": "node integration/scripts/it.mjs up",
|
||||||
|
"it:test": "node integration/scripts/it.mjs test",
|
||||||
|
"it:logs": "node integration/scripts/it.mjs logs",
|
||||||
|
"it:down": "node integration/scripts/it.mjs down",
|
||||||
"prepare": "husky"
|
"prepare": "husky"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
|||||||
426
pnpm-lock.yaml
generated
426
pnpm-lock.yaml
generated
@@ -589,7 +589,7 @@ importers:
|
|||||||
version: 5.101.0(react@19.2.6)
|
version: 5.101.0(react@19.2.6)
|
||||||
'@tria-plc/iamui':
|
'@tria-plc/iamui':
|
||||||
specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
|
specifier: file:../../../local-packages/tria-plc-iamui-0.1.1.tgz
|
||||||
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)
|
version: file:local-packages/tria-plc-iamui-0.1.1.tgz(0ce39b7e349029277dcd938d06eeb0f7)
|
||||||
'@vis.gl/react-google-maps':
|
'@vis.gl/react-google-maps':
|
||||||
specifier: ^1.8.3
|
specifier: ^1.8.3
|
||||||
version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
version: 1.8.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
||||||
@@ -1239,6 +1239,30 @@ importers:
|
|||||||
specifier: ^5.5.4
|
specifier: ^5.5.4
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
|
integration:
|
||||||
|
devDependencies:
|
||||||
|
'@types/node':
|
||||||
|
specifier: ^22.0.0
|
||||||
|
version: 22.20.1
|
||||||
|
'@types/pg':
|
||||||
|
specifier: ^8.11.0
|
||||||
|
version: 8.20.0
|
||||||
|
'@types/supertest':
|
||||||
|
specifier: ^6.0.2
|
||||||
|
version: 6.0.3
|
||||||
|
pg:
|
||||||
|
specifier: ^8.13.0
|
||||||
|
version: 8.21.0
|
||||||
|
supertest:
|
||||||
|
specifier: ^7.0.0
|
||||||
|
version: 7.2.2
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.5.4
|
||||||
|
version: 5.9.3
|
||||||
|
vitest:
|
||||||
|
specifier: ^2.1.2
|
||||||
|
version: 2.1.9(@types/node@22.20.1)(jsdom@25.0.1)(lightningcss@1.32.0)(msw@2.14.6(@types/node@22.20.1)(typescript@5.9.3))(terser@5.48.0)
|
||||||
|
|
||||||
packages/api-common:
|
packages/api-common:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@edr/types':
|
'@edr/types':
|
||||||
@@ -12340,11 +12364,11 @@ snapshots:
|
|||||||
'@babel/helpers': 7.29.7
|
'@babel/helpers': 7.29.7
|
||||||
'@babel/parser': 7.29.7
|
'@babel/parser': 7.29.7
|
||||||
'@babel/template': 7.29.7
|
'@babel/template': 7.29.7
|
||||||
'@babel/traverse': 7.29.7
|
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||||
'@babel/types': 7.29.7
|
'@babel/types': 7.29.7
|
||||||
'@jridgewell/remapping': 2.3.5
|
'@jridgewell/remapping': 2.3.5
|
||||||
convert-source-map: 2.0.0
|
convert-source-map: 2.0.0
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
gensync: 1.0.0-beta.2
|
gensync: 1.0.0-beta.2
|
||||||
json5: 2.2.3
|
json5: 2.2.3
|
||||||
semver: 6.3.1
|
semver: 6.3.1
|
||||||
@@ -12379,7 +12403,7 @@ snapshots:
|
|||||||
'@babel/helper-optimise-call-expression': 7.29.7
|
'@babel/helper-optimise-call-expression': 7.29.7
|
||||||
'@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7)
|
'@babel/helper-replace-supers': 7.29.7(@babel/core@7.29.7)
|
||||||
'@babel/helper-skip-transparent-expression-wrappers': 7.29.7
|
'@babel/helper-skip-transparent-expression-wrappers': 7.29.7
|
||||||
'@babel/traverse': 7.29.7
|
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||||
semver: 6.3.1
|
semver: 6.3.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -12388,14 +12412,7 @@ snapshots:
|
|||||||
|
|
||||||
'@babel/helper-member-expression-to-functions@7.29.7':
|
'@babel/helper-member-expression-to-functions@7.29.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/traverse': 7.29.7
|
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||||
'@babel/types': 7.29.7
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@babel/helper-module-imports@7.29.7':
|
|
||||||
dependencies:
|
|
||||||
'@babel/traverse': 7.29.7
|
|
||||||
'@babel/types': 7.29.7
|
'@babel/types': 7.29.7
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -12410,9 +12427,9 @@ snapshots:
|
|||||||
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
|
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/core': 7.29.7
|
'@babel/core': 7.29.7
|
||||||
'@babel/helper-module-imports': 7.29.7
|
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
|
||||||
'@babel/helper-validator-identifier': 7.29.7
|
'@babel/helper-validator-identifier': 7.29.7
|
||||||
'@babel/traverse': 7.29.7
|
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -12427,13 +12444,13 @@ snapshots:
|
|||||||
'@babel/core': 7.29.7
|
'@babel/core': 7.29.7
|
||||||
'@babel/helper-member-expression-to-functions': 7.29.7
|
'@babel/helper-member-expression-to-functions': 7.29.7
|
||||||
'@babel/helper-optimise-call-expression': 7.29.7
|
'@babel/helper-optimise-call-expression': 7.29.7
|
||||||
'@babel/traverse': 7.29.7
|
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
'@babel/helper-skip-transparent-expression-wrappers@7.29.7':
|
'@babel/helper-skip-transparent-expression-wrappers@7.29.7':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/traverse': 7.29.7
|
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
||||||
'@babel/types': 7.29.7
|
'@babel/types': 7.29.7
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -12586,18 +12603,6 @@ snapshots:
|
|||||||
'@babel/parser': 7.29.7
|
'@babel/parser': 7.29.7
|
||||||
'@babel/types': 7.29.7
|
'@babel/types': 7.29.7
|
||||||
|
|
||||||
'@babel/traverse@7.29.7':
|
|
||||||
dependencies:
|
|
||||||
'@babel/code-frame': 7.29.7
|
|
||||||
'@babel/generator': 7.29.7
|
|
||||||
'@babel/helper-globals': 7.29.7
|
|
||||||
'@babel/parser': 7.29.7
|
|
||||||
'@babel/template': 7.29.7
|
|
||||||
'@babel/types': 7.29.7
|
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
'@babel/traverse@7.29.7(supports-color@5.5.0)':
|
'@babel/traverse@7.29.7(supports-color@5.5.0)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/code-frame': 7.29.7
|
'@babel/code-frame': 7.29.7
|
||||||
@@ -12821,7 +12826,7 @@ snapshots:
|
|||||||
|
|
||||||
'@emotion/babel-plugin@11.13.5':
|
'@emotion/babel-plugin@11.13.5':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@babel/helper-module-imports': 7.29.7
|
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
|
||||||
'@babel/runtime': 7.29.7
|
'@babel/runtime': 7.29.7
|
||||||
'@emotion/hash': 0.9.2
|
'@emotion/hash': 0.9.2
|
||||||
'@emotion/memoize': 0.9.0
|
'@emotion/memoize': 0.9.0
|
||||||
@@ -12987,7 +12992,7 @@ snapshots:
|
|||||||
'@eslint/eslintrc@2.1.4':
|
'@eslint/eslintrc@2.1.4':
|
||||||
dependencies:
|
dependencies:
|
||||||
ajv: 6.15.0
|
ajv: 6.15.0
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
espree: 9.6.1
|
espree: 9.6.1
|
||||||
globals: 13.24.0
|
globals: 13.24.0
|
||||||
ignore: 5.3.2
|
ignore: 5.3.2
|
||||||
@@ -13147,7 +13152,7 @@ snapshots:
|
|||||||
'@humanwhocodes/config-array@0.13.0':
|
'@humanwhocodes/config-array@0.13.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@humanwhocodes/object-schema': 2.0.3
|
'@humanwhocodes/object-schema': 2.0.3
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
minimatch: 3.1.5
|
minimatch: 3.1.5
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -13177,6 +13182,14 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 20.19.42
|
'@types/node': 20.19.42
|
||||||
|
|
||||||
|
'@inquirer/confirm@6.1.1(@types/node@22.20.1)':
|
||||||
|
dependencies:
|
||||||
|
'@inquirer/core': 11.2.1(@types/node@22.20.1)
|
||||||
|
'@inquirer/type': 4.0.7(@types/node@22.20.1)
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 22.20.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@inquirer/confirm@6.1.1(@types/node@24.13.1)':
|
'@inquirer/confirm@6.1.1(@types/node@24.13.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/core': 11.2.1(@types/node@24.13.1)
|
'@inquirer/core': 11.2.1(@types/node@24.13.1)
|
||||||
@@ -13197,6 +13210,19 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 20.19.42
|
'@types/node': 20.19.42
|
||||||
|
|
||||||
|
'@inquirer/core@11.2.1(@types/node@22.20.1)':
|
||||||
|
dependencies:
|
||||||
|
'@inquirer/ansi': 2.0.7
|
||||||
|
'@inquirer/figures': 2.0.7
|
||||||
|
'@inquirer/type': 4.0.7(@types/node@22.20.1)
|
||||||
|
cli-width: 4.1.0
|
||||||
|
fast-wrap-ansi: 0.2.2
|
||||||
|
mute-stream: 3.0.0
|
||||||
|
signal-exit: 4.1.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 22.20.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@inquirer/core@11.2.1(@types/node@24.13.1)':
|
'@inquirer/core@11.2.1(@types/node@24.13.1)':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/ansi': 2.0.7
|
'@inquirer/ansi': 2.0.7
|
||||||
@@ -13319,6 +13345,11 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 20.19.42
|
'@types/node': 20.19.42
|
||||||
|
|
||||||
|
'@inquirer/type@4.0.7(@types/node@22.20.1)':
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 22.20.1
|
||||||
|
optional: true
|
||||||
|
|
||||||
'@inquirer/type@4.0.7(@types/node@24.13.1)':
|
'@inquirer/type@4.0.7(@types/node@24.13.1)':
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
'@types/node': 24.13.1
|
'@types/node': 24.13.1
|
||||||
@@ -14318,7 +14349,7 @@ snapshots:
|
|||||||
|
|
||||||
'@puppeteer/browsers@2.13.2':
|
'@puppeteer/browsers@2.13.2':
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
extract-zip: 2.0.1
|
extract-zip: 2.0.1
|
||||||
progress: 2.0.3
|
progress: 2.0.3
|
||||||
proxy-agent: 6.5.0
|
proxy-agent: 6.5.0
|
||||||
@@ -16386,7 +16417,7 @@ snapshots:
|
|||||||
|
|
||||||
'@tokenizer/inflate@0.4.1':
|
'@tokenizer/inflate@0.4.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
token-types: 6.1.2
|
token-types: 6.1.2
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -16675,130 +16706,6 @@ snapshots:
|
|||||||
- utf-8-validate
|
- utf-8-validate
|
||||||
- vite
|
- vite
|
||||||
|
|
||||||
'@tria-plc/iamui@file:local-packages/tria-plc-iamui-0.1.1.tgz(a5d0bdee55164ae56064fb273b530e00)':
|
|
||||||
dependencies:
|
|
||||||
'@emotion/react': 11.14.0(@types/react@18.3.31)(react@19.2.6)
|
|
||||||
'@emotion/styled': 11.14.1(@emotion/react@11.14.0(@types/react@18.3.31)(react@19.2.6))(@types/react@18.3.31)(react@19.2.6)
|
|
||||||
'@hookform/resolvers': 5.4.0(react-hook-form@7.77.0(react@19.2.6))
|
|
||||||
'@lottiefiles/react-lottie-player': 3.6.0(react@19.2.6)
|
|
||||||
'@mantine/charts': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(recharts@3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1))
|
|
||||||
'@mantine/core': 7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@mantine/dates': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@mantine/hooks': 7.17.8(react@19.2.6)
|
|
||||||
'@mantine/notifications': 7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-accordion': 1.2.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-alert-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-avatar': 1.1.12(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-checkbox': 1.3.4(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-collapsible': 1.1.13(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-context-menu': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-dialog': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-dropdown-menu': 2.1.17(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-hover-card': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-label': 2.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-navigation-menu': 1.2.15(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-popover': 1.1.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-progress': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-radio-group': 1.4.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-scroll-area': 1.2.11(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-select': 2.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-separator': 1.1.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-slot': 1.2.5(@types/react@18.3.31)(react@19.2.6)
|
|
||||||
'@radix-ui/react-switch': 1.3.0(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-tabs': 1.1.14(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-toast': 1.2.16(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@radix-ui/react-tooltip': 1.2.9(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@react-pdf-viewer/core': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@react-pdf-viewer/default-layout': 3.12.0(pdfjs-dist@5.4.296)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@react-pdf/renderer': 4.5.1(react@19.2.6)
|
|
||||||
'@reduxjs/toolkit': 2.12.0(react-redux@9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1))(react@19.2.6)
|
|
||||||
'@tabler/icons-react': 3.44.0(react@19.2.6)
|
|
||||||
'@tailwindcss/vite': 4.3.0(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0))
|
|
||||||
'@tanstack/react-query': 5.101.0(react@19.2.6)
|
|
||||||
'@tanstack/react-query-devtools': 5.101.0(@tanstack/react-query@5.101.0(react@19.2.6))(react@19.2.6)
|
|
||||||
'@tanstack/react-table': 8.21.3(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
'@tinymce/tinymce-react': 6.3.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(tinymce@7.9.3)
|
|
||||||
'@types/dompurify': 3.2.0
|
|
||||||
'@types/node': 24.13.1
|
|
||||||
'@types/tinymce': 4.6.9
|
|
||||||
axios: 1.17.0
|
|
||||||
class-variance-authority: 0.7.1
|
|
||||||
clsx: 2.1.1
|
|
||||||
cmdk: 1.1.1(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
date-fns: 3.6.0
|
|
||||||
dayjs: 1.11.21
|
|
||||||
dompurify: 3.4.8
|
|
||||||
ethiopian-calendar-date-converter: 2.1.6
|
|
||||||
ethiopian-calendar-new: 1.1.0
|
|
||||||
file-type: 18.7.0
|
|
||||||
framer-motion: 12.40.0(@emotion/is-prop-valid@1.4.0)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
html2canvas: 1.4.1
|
|
||||||
i18next: 25.10.10(typescript@5.9.3)
|
|
||||||
i18next-browser-languagedetector: 8.2.1
|
|
||||||
jquery: 3.7.1
|
|
||||||
js-cookie: 3.0.8
|
|
||||||
jspdf: 3.0.4
|
|
||||||
lodash: 4.18.1
|
|
||||||
lucide-react: 0.513.0(react@19.2.6)
|
|
||||||
mantine-react-table: 2.0.0-beta.9(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/dates@7.17.8(@mantine/core@7.17.8(@mantine/hooks@7.17.8(react@19.2.6))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6))(@mantine/hooks@7.17.8(react@19.2.6))(@tabler/icons-react@3.44.0(react@19.2.6))(clsx@2.1.1)(dayjs@1.11.21)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
mui-ethiopian-datepicker: 0.3.2(4b3af212eafdf0059f009b005d7e343d)
|
|
||||||
next-themes: 0.4.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
path: 0.12.7
|
|
||||||
pdf-lib: 1.17.1
|
|
||||||
qs: 6.15.2
|
|
||||||
react: 19.2.6
|
|
||||||
react-cookie: 8.1.2(@types/react@18.3.31)(react@19.2.6)
|
|
||||||
react-css-nocode-editor: 1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
|
|
||||||
react-day-picker: 8.10.2(date-fns@3.6.0)(react@19.2.6)
|
|
||||||
react-dom: 19.2.6(react@19.2.6)
|
|
||||||
react-dropzone: 14.4.1(react@19.2.6)
|
|
||||||
react-hook-form: 7.77.0(react@19.2.6)
|
|
||||||
react-i18next: 15.7.4(i18next@25.10.10(typescript@5.9.3))(react-dom@19.2.6(react@19.2.6))(react@19.2.6)(typescript@5.9.3)
|
|
||||||
react-icons: 5.6.0(react@19.2.6)
|
|
||||||
react-image-crop: 11.0.10(react@19.2.6)
|
|
||||||
react-intersection-observer: 9.16.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
react-pdf: 10.4.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
react-pdf-html: 2.1.5(@react-pdf/renderer@4.5.1(react@19.2.6))(react@19.2.6)
|
|
||||||
react-redux: 9.3.0(@types/react@18.3.31)(react@19.2.6)(redux@5.0.1)
|
|
||||||
react-resizable-panels: 3.0.6(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
react-router-dom: 7.17.0(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
react-signature-canvas: 1.1.0-alpha.2(@types/prop-types@15.7.15)(@types/react@18.3.31)(prop-types@15.8.1)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
recharts: 3.8.1(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)(redux@5.0.1)
|
|
||||||
rollup-plugin-visualizer: 7.0.1(rollup@4.61.1)
|
|
||||||
socket.io-client: 4.8.3
|
|
||||||
sonner: 2.0.7(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
tailwind-merge: 3.6.0
|
|
||||||
tailwind-scrollbar-hide: 4.0.0(tailwindcss@4.3.0)
|
|
||||||
tailwindcss: 4.3.0
|
|
||||||
tailwindcss-animate: 1.0.7(tailwindcss@4.3.0)
|
|
||||||
tinymce: 7.9.3
|
|
||||||
url: 0.11.4
|
|
||||||
vaul: 1.1.2(@types/react-dom@18.3.7(@types/react@18.3.31))(@types/react@18.3.31)(react-dom@19.2.6(react@19.2.6))(react@19.2.6)
|
|
||||||
xlsx: 0.18.5
|
|
||||||
zod: 3.25.76
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@babel/core'
|
|
||||||
- '@emotion/is-prop-valid'
|
|
||||||
- '@mui/icons-material'
|
|
||||||
- '@mui/material'
|
|
||||||
- '@mui/x-date-pickers'
|
|
||||||
- '@types/prop-types'
|
|
||||||
- '@types/react'
|
|
||||||
- '@types/react-dom'
|
|
||||||
- bufferutil
|
|
||||||
- debug
|
|
||||||
- pdfjs-dist
|
|
||||||
- prop-types
|
|
||||||
- react-is
|
|
||||||
- react-native
|
|
||||||
- redux
|
|
||||||
- rolldown
|
|
||||||
- rollup
|
|
||||||
- supports-color
|
|
||||||
- typescript
|
|
||||||
- utf-8-validate
|
|
||||||
- vite
|
|
||||||
|
|
||||||
'@ts-morph/common@0.27.0':
|
'@ts-morph/common@0.27.0':
|
||||||
dependencies:
|
dependencies:
|
||||||
fast-glob: 3.3.3
|
fast-glob: 3.3.3
|
||||||
@@ -17162,7 +17069,7 @@ snapshots:
|
|||||||
'@typescript-eslint/types': 8.60.1
|
'@typescript-eslint/types': 8.60.1
|
||||||
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
|
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
|
||||||
'@typescript-eslint/visitor-keys': 8.60.1
|
'@typescript-eslint/visitor-keys': 8.60.1
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
eslint: 8.57.1
|
eslint: 8.57.1
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -17172,7 +17079,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
|
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
|
||||||
'@typescript-eslint/types': 8.60.1
|
'@typescript-eslint/types': 8.60.1
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -17191,7 +17098,7 @@ snapshots:
|
|||||||
'@typescript-eslint/types': 8.60.1
|
'@typescript-eslint/types': 8.60.1
|
||||||
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
|
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
|
||||||
'@typescript-eslint/utils': 8.60.1(eslint@8.57.1)(typescript@5.9.3)
|
'@typescript-eslint/utils': 8.60.1(eslint@8.57.1)(typescript@5.9.3)
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
eslint: 8.57.1
|
eslint: 8.57.1
|
||||||
ts-api-utils: 2.5.0(typescript@5.9.3)
|
ts-api-utils: 2.5.0(typescript@5.9.3)
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
@@ -17206,7 +17113,7 @@ snapshots:
|
|||||||
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
|
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
|
||||||
'@typescript-eslint/types': 8.60.1
|
'@typescript-eslint/types': 8.60.1
|
||||||
'@typescript-eslint/visitor-keys': 8.60.1
|
'@typescript-eslint/visitor-keys': 8.60.1
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
minimatch: 10.2.5
|
minimatch: 10.2.5
|
||||||
semver: 7.8.2
|
semver: 7.8.2
|
||||||
tinyglobby: 0.2.17
|
tinyglobby: 0.2.17
|
||||||
@@ -17330,6 +17237,15 @@ snapshots:
|
|||||||
chai: 5.3.3
|
chai: 5.3.3
|
||||||
tinyrainbow: 1.2.0
|
tinyrainbow: 1.2.0
|
||||||
|
|
||||||
|
'@vitest/mocker@2.1.9(msw@2.14.6(@types/node@22.20.1)(typescript@5.9.3))(vite@5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0))':
|
||||||
|
dependencies:
|
||||||
|
'@vitest/spy': 2.1.9
|
||||||
|
estree-walker: 3.0.3
|
||||||
|
magic-string: 0.30.21
|
||||||
|
optionalDependencies:
|
||||||
|
msw: 2.14.6(@types/node@22.20.1)(typescript@5.9.3)
|
||||||
|
vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0)
|
||||||
|
|
||||||
'@vitest/mocker@2.1.9(msw@2.14.6(@types/node@24.13.1)(typescript@5.9.3))(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0))':
|
'@vitest/mocker@2.1.9(msw@2.14.6(@types/node@24.13.1)(typescript@5.9.3))(vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0))':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/spy': 2.1.9
|
'@vitest/spy': 2.1.9
|
||||||
@@ -17486,7 +17402,7 @@ snapshots:
|
|||||||
|
|
||||||
agent-base@6.0.2:
|
agent-base@6.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -18010,16 +17926,6 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
babel-plugin-styled-components@2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0):
|
|
||||||
dependencies:
|
|
||||||
'@babel/helper-annotate-as-pure': 7.29.7
|
|
||||||
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
|
|
||||||
'@babel/plugin-syntax-jsx': 7.29.7(@babel/core@7.29.7)
|
|
||||||
picomatch: 4.0.4
|
|
||||||
styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- supports-color
|
|
||||||
|
|
||||||
babel-polyfill@6.26.0:
|
babel-polyfill@6.26.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
babel-runtime: 6.26.0
|
babel-runtime: 6.26.0
|
||||||
@@ -18173,7 +18079,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
bytes: 3.1.2
|
bytes: 3.1.2
|
||||||
content-type: 1.0.5
|
content-type: 1.0.5
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
http-errors: 2.0.1
|
http-errors: 2.0.1
|
||||||
iconv-lite: 0.7.2
|
iconv-lite: 0.7.2
|
||||||
on-finished: 2.4.1
|
on-finished: 2.4.1
|
||||||
@@ -19174,7 +19080,7 @@ snapshots:
|
|||||||
engine.io-client@6.6.5:
|
engine.io-client@6.6.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@socket.io/component-emitter': 3.1.2
|
'@socket.io/component-emitter': 3.1.2
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
engine.io-parser: 5.2.3
|
engine.io-parser: 5.2.3
|
||||||
ws: 8.20.1
|
ws: 8.20.1
|
||||||
xmlhttprequest-ssl: 2.1.2
|
xmlhttprequest-ssl: 2.1.2
|
||||||
@@ -19194,7 +19100,7 @@ snapshots:
|
|||||||
base64id: 2.0.0
|
base64id: 2.0.0
|
||||||
cookie: 0.7.2
|
cookie: 0.7.2
|
||||||
cors: 2.8.6
|
cors: 2.8.6
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
engine.io-parser: 5.2.3
|
engine.io-parser: 5.2.3
|
||||||
ws: 8.21.0
|
ws: 8.21.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -19427,7 +19333,7 @@ snapshots:
|
|||||||
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1):
|
eslint-import-resolver-typescript@3.10.1(eslint-plugin-import@2.32.0(@typescript-eslint/parser@8.60.1(eslint@8.57.1)(typescript@5.9.3))(eslint@8.57.1))(eslint@8.57.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@nolyfill/is-core-module': 1.0.39
|
'@nolyfill/is-core-module': 1.0.39
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
eslint: 8.57.1
|
eslint: 8.57.1
|
||||||
get-tsconfig: 4.14.0
|
get-tsconfig: 4.14.0
|
||||||
is-bun-module: 2.0.0
|
is-bun-module: 2.0.0
|
||||||
@@ -19555,7 +19461,7 @@ snapshots:
|
|||||||
ajv: 6.15.0
|
ajv: 6.15.0
|
||||||
chalk: 4.1.2
|
chalk: 4.1.2
|
||||||
cross-spawn: 7.0.6
|
cross-spawn: 7.0.6
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
doctrine: 3.0.0
|
doctrine: 3.0.0
|
||||||
escape-string-regexp: 4.0.0
|
escape-string-regexp: 4.0.0
|
||||||
eslint-scope: 7.2.2
|
eslint-scope: 7.2.2
|
||||||
@@ -19785,7 +19691,7 @@ snapshots:
|
|||||||
content-type: 1.0.5
|
content-type: 1.0.5
|
||||||
cookie: 0.7.2
|
cookie: 0.7.2
|
||||||
cookie-signature: 1.2.2
|
cookie-signature: 1.2.2
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
depd: 2.0.0
|
depd: 2.0.0
|
||||||
encodeurl: 2.0.0
|
encodeurl: 2.0.0
|
||||||
escape-html: 1.0.3
|
escape-html: 1.0.3
|
||||||
@@ -19838,7 +19744,7 @@ snapshots:
|
|||||||
|
|
||||||
extract-zip@2.0.1:
|
extract-zip@2.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
get-stream: 5.2.0
|
get-stream: 5.2.0
|
||||||
yauzl: 2.10.0
|
yauzl: 2.10.0
|
||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
@@ -19991,7 +19897,7 @@ snapshots:
|
|||||||
|
|
||||||
finalhandler@2.1.1:
|
finalhandler@2.1.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
encodeurl: 2.0.0
|
encodeurl: 2.0.0
|
||||||
escape-html: 1.0.3
|
escape-html: 1.0.3
|
||||||
on-finished: 2.4.1
|
on-finished: 2.4.1
|
||||||
@@ -20237,7 +20143,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
basic-ftp: 5.3.1
|
basic-ftp: 5.3.1
|
||||||
data-uri-to-buffer: 6.0.2
|
data-uri-to-buffer: 6.0.2
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -20518,7 +20424,7 @@ snapshots:
|
|||||||
http-proxy-agent@7.0.2:
|
http-proxy-agent@7.0.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 7.1.4
|
agent-base: 7.1.4
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -20531,14 +20437,14 @@ snapshots:
|
|||||||
https-proxy-agent@5.0.1:
|
https-proxy-agent@5.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 6.0.2
|
agent-base: 6.0.2
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
https-proxy-agent@7.0.6:
|
https-proxy-agent@7.0.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 7.1.4
|
agent-base: 7.1.4
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -20963,7 +20869,7 @@ snapshots:
|
|||||||
|
|
||||||
istanbul-lib-source-maps@4.0.1:
|
istanbul-lib-source-maps@4.0.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
istanbul-lib-coverage: 3.2.2
|
istanbul-lib-coverage: 3.2.2
|
||||||
source-map: 0.6.1
|
source-map: 0.6.1
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -21611,7 +21517,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
chalk: 5.6.2
|
chalk: 5.6.2
|
||||||
commander: 13.1.0
|
commander: 13.1.0
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
execa: 8.0.1
|
execa: 8.0.1
|
||||||
lilconfig: 3.1.3
|
lilconfig: 3.1.3
|
||||||
listr2: 8.3.3
|
listr2: 8.3.3
|
||||||
@@ -22012,6 +21918,32 @@ snapshots:
|
|||||||
|
|
||||||
ms@2.1.3: {}
|
ms@2.1.3: {}
|
||||||
|
|
||||||
|
msw@2.14.6(@types/node@22.20.1)(typescript@5.9.3):
|
||||||
|
dependencies:
|
||||||
|
'@inquirer/confirm': 6.1.1(@types/node@22.20.1)
|
||||||
|
'@mswjs/interceptors': 0.41.9
|
||||||
|
'@open-draft/deferred-promise': 3.0.0
|
||||||
|
'@types/statuses': 2.0.6
|
||||||
|
cookie: 1.1.1
|
||||||
|
graphql: 16.14.1
|
||||||
|
headers-polyfill: 5.0.1
|
||||||
|
is-node-process: 1.2.0
|
||||||
|
outvariant: 1.4.3
|
||||||
|
path-to-regexp: 6.3.0
|
||||||
|
picocolors: 1.1.1
|
||||||
|
rettime: 0.11.11
|
||||||
|
statuses: 2.0.2
|
||||||
|
strict-event-emitter: 0.5.1
|
||||||
|
tough-cookie: 6.0.1
|
||||||
|
type-fest: 5.7.0
|
||||||
|
until-async: 3.0.2
|
||||||
|
yargs: 17.7.2
|
||||||
|
optionalDependencies:
|
||||||
|
typescript: 5.9.3
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/node'
|
||||||
|
optional: true
|
||||||
|
|
||||||
msw@2.14.6(@types/node@24.13.1)(typescript@5.9.3):
|
msw@2.14.6(@types/node@24.13.1)(typescript@5.9.3):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@inquirer/confirm': 6.1.1(@types/node@24.13.1)
|
'@inquirer/confirm': 6.1.1(@types/node@24.13.1)
|
||||||
@@ -22386,7 +22318,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@tootallnate/quickjs-emscripten': 0.23.0
|
'@tootallnate/quickjs-emscripten': 0.23.0
|
||||||
agent-base: 7.1.4
|
agent-base: 7.1.4
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
get-uri: 6.0.5
|
get-uri: 6.0.5
|
||||||
http-proxy-agent: 7.0.2
|
http-proxy-agent: 7.0.2
|
||||||
https-proxy-agent: 7.0.6
|
https-proxy-agent: 7.0.6
|
||||||
@@ -22726,7 +22658,7 @@ snapshots:
|
|||||||
proxy-agent@6.5.0:
|
proxy-agent@6.5.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 7.1.4
|
agent-base: 7.1.4
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
http-proxy-agent: 7.0.2
|
http-proxy-agent: 7.0.2
|
||||||
https-proxy-agent: 7.0.6
|
https-proxy-agent: 7.0.6
|
||||||
lru-cache: 7.18.3
|
lru-cache: 7.18.3
|
||||||
@@ -22755,7 +22687,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
'@puppeteer/browsers': 2.13.2
|
'@puppeteer/browsers': 2.13.2
|
||||||
chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973)
|
chromium-bidi: 14.0.0(devtools-protocol@0.0.1608973)
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
devtools-protocol: 0.0.1608973
|
devtools-protocol: 0.0.1608973
|
||||||
typed-query-selector: 2.12.2
|
typed-query-selector: 2.12.2
|
||||||
webdriver-bidi-protocol: 0.4.1
|
webdriver-bidi-protocol: 0.4.1
|
||||||
@@ -23008,15 +22940,6 @@ snapshots:
|
|||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
- react-is
|
- react-is
|
||||||
|
|
||||||
react-css-nocode-editor@1.0.13(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6):
|
|
||||||
dependencies:
|
|
||||||
react: 19.2.6
|
|
||||||
react-dom: 19.2.6(react@19.2.6)
|
|
||||||
styled-components: 5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6)
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@babel/core'
|
|
||||||
- react-is
|
|
||||||
|
|
||||||
react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6):
|
react-day-picker@8.10.2(date-fns@3.6.0)(react@19.2.6):
|
||||||
dependencies:
|
dependencies:
|
||||||
date-fns: 3.6.0
|
date-fns: 3.6.0
|
||||||
@@ -23590,7 +23513,7 @@ snapshots:
|
|||||||
|
|
||||||
router@2.2.0:
|
router@2.2.0:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
depd: 2.0.0
|
depd: 2.0.0
|
||||||
is-promise: 4.0.0
|
is-promise: 4.0.0
|
||||||
parseurl: 1.3.3
|
parseurl: 1.3.3
|
||||||
@@ -23708,7 +23631,7 @@ snapshots:
|
|||||||
|
|
||||||
send@1.2.1:
|
send@1.2.1:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
encodeurl: 2.0.0
|
encodeurl: 2.0.0
|
||||||
escape-html: 1.0.3
|
escape-html: 1.0.3
|
||||||
etag: 1.8.1
|
etag: 1.8.1
|
||||||
@@ -23926,7 +23849,7 @@ snapshots:
|
|||||||
|
|
||||||
socket.io-adapter@2.5.8:
|
socket.io-adapter@2.5.8:
|
||||||
dependencies:
|
dependencies:
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
ws: 8.21.0
|
ws: 8.21.0
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- bufferutil
|
- bufferutil
|
||||||
@@ -23936,7 +23859,7 @@ snapshots:
|
|||||||
socket.io-client@4.8.3:
|
socket.io-client@4.8.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@socket.io/component-emitter': 3.1.2
|
'@socket.io/component-emitter': 3.1.2
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
engine.io-client: 6.6.5
|
engine.io-client: 6.6.5
|
||||||
socket.io-parser: 4.2.6
|
socket.io-parser: 4.2.6
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
@@ -23947,7 +23870,7 @@ snapshots:
|
|||||||
socket.io-parser@4.2.6:
|
socket.io-parser@4.2.6:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@socket.io/component-emitter': 3.1.2
|
'@socket.io/component-emitter': 3.1.2
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
|
|
||||||
@@ -23956,7 +23879,7 @@ snapshots:
|
|||||||
accepts: 1.3.8
|
accepts: 1.3.8
|
||||||
base64id: 2.0.0
|
base64id: 2.0.0
|
||||||
cors: 2.8.6
|
cors: 2.8.6
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
engine.io: 6.6.9
|
engine.io: 6.6.9
|
||||||
socket.io-adapter: 2.5.8
|
socket.io-adapter: 2.5.8
|
||||||
socket.io-parser: 4.2.6
|
socket.io-parser: 4.2.6
|
||||||
@@ -23968,7 +23891,7 @@ snapshots:
|
|||||||
socks-proxy-agent@8.0.5:
|
socks-proxy-agent@8.0.5:
|
||||||
dependencies:
|
dependencies:
|
||||||
agent-base: 7.1.4
|
agent-base: 7.1.4
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
socks: 2.8.9
|
socks: 2.8.9
|
||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- supports-color
|
- supports-color
|
||||||
@@ -24248,24 +24171,6 @@ snapshots:
|
|||||||
transitivePeerDependencies:
|
transitivePeerDependencies:
|
||||||
- '@babel/core'
|
- '@babel/core'
|
||||||
|
|
||||||
styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6):
|
|
||||||
dependencies:
|
|
||||||
'@babel/helper-module-imports': 7.29.7(supports-color@5.5.0)
|
|
||||||
'@babel/traverse': 7.29.7(supports-color@5.5.0)
|
|
||||||
'@emotion/is-prop-valid': 1.4.0
|
|
||||||
'@emotion/stylis': 0.8.5
|
|
||||||
'@emotion/unitless': 0.7.5
|
|
||||||
babel-plugin-styled-components: 2.3.0(styled-components@5.3.11(react-dom@19.2.6(react@19.2.6))(react-is@19.2.7)(react@19.2.6))(supports-color@5.5.0)
|
|
||||||
css-to-react-native: 3.2.0
|
|
||||||
hoist-non-react-statics: 3.3.2
|
|
||||||
react: 19.2.6
|
|
||||||
react-dom: 19.2.6(react@19.2.6)
|
|
||||||
react-is: 19.2.7
|
|
||||||
shallowequal: 1.1.0
|
|
||||||
supports-color: 5.5.0
|
|
||||||
transitivePeerDependencies:
|
|
||||||
- '@babel/core'
|
|
||||||
|
|
||||||
styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1):
|
styled-jsx@5.1.1(babel-plugin-macros@3.1.0)(react@18.3.1):
|
||||||
dependencies:
|
dependencies:
|
||||||
client-only: 0.0.1
|
client-only: 0.0.1
|
||||||
@@ -24291,7 +24196,7 @@ snapshots:
|
|||||||
dependencies:
|
dependencies:
|
||||||
component-emitter: 1.3.1
|
component-emitter: 1.3.1
|
||||||
cookiejar: 2.1.4
|
cookiejar: 2.1.4
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
fast-safe-stringify: 2.1.1
|
fast-safe-stringify: 2.1.1
|
||||||
form-data: 4.0.5
|
form-data: 4.0.5
|
||||||
formidable: 3.5.4
|
formidable: 3.5.4
|
||||||
@@ -24798,7 +24703,7 @@ snapshots:
|
|||||||
app-root-path: 3.1.0
|
app-root-path: 3.1.0
|
||||||
buffer: 6.0.3
|
buffer: 6.0.3
|
||||||
dayjs: 1.11.21
|
dayjs: 1.11.21
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
dedent: 1.7.2(babel-plugin-macros@3.1.0)
|
dedent: 1.7.2(babel-plugin-macros@3.1.0)
|
||||||
dotenv: 16.6.1
|
dotenv: 16.6.1
|
||||||
glob: 10.5.0
|
glob: 10.5.0
|
||||||
@@ -24822,7 +24727,7 @@ snapshots:
|
|||||||
app-root-path: 3.1.0
|
app-root-path: 3.1.0
|
||||||
buffer: 6.0.3
|
buffer: 6.0.3
|
||||||
dayjs: 1.11.21
|
dayjs: 1.11.21
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
dedent: 1.7.2(babel-plugin-macros@3.1.0)
|
dedent: 1.7.2(babel-plugin-macros@3.1.0)
|
||||||
dotenv: 16.6.1
|
dotenv: 16.6.1
|
||||||
glob: 10.5.0
|
glob: 10.5.0
|
||||||
@@ -25122,10 +25027,28 @@ snapshots:
|
|||||||
string_decoder: 1.3.0
|
string_decoder: 1.3.0
|
||||||
util-deprecate: 1.0.2
|
util-deprecate: 1.0.2
|
||||||
|
|
||||||
|
vite-node@2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0):
|
||||||
|
dependencies:
|
||||||
|
cac: 6.7.14
|
||||||
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
|
es-module-lexer: 1.7.0
|
||||||
|
pathe: 1.1.2
|
||||||
|
vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0)
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- '@types/node'
|
||||||
|
- less
|
||||||
|
- lightningcss
|
||||||
|
- sass
|
||||||
|
- sass-embedded
|
||||||
|
- stylus
|
||||||
|
- sugarss
|
||||||
|
- supports-color
|
||||||
|
- terser
|
||||||
|
|
||||||
vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0):
|
vite-node@2.1.9(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
cac: 6.7.14
|
cac: 6.7.14
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
es-module-lexer: 1.7.0
|
es-module-lexer: 1.7.0
|
||||||
pathe: 1.1.2
|
pathe: 1.1.2
|
||||||
vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)
|
vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)
|
||||||
@@ -25140,6 +25063,17 @@ snapshots:
|
|||||||
- supports-color
|
- supports-color
|
||||||
- terser
|
- terser
|
||||||
|
|
||||||
|
vite@5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0):
|
||||||
|
dependencies:
|
||||||
|
esbuild: 0.21.5
|
||||||
|
postcss: 8.5.15
|
||||||
|
rollup: 4.61.1
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 22.20.1
|
||||||
|
fsevents: 2.3.3
|
||||||
|
lightningcss: 1.32.0
|
||||||
|
terser: 5.48.0
|
||||||
|
|
||||||
vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0):
|
vite@5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
esbuild: 0.21.5
|
esbuild: 0.21.5
|
||||||
@@ -25151,6 +25085,42 @@ snapshots:
|
|||||||
lightningcss: 1.32.0
|
lightningcss: 1.32.0
|
||||||
terser: 5.48.0
|
terser: 5.48.0
|
||||||
|
|
||||||
|
vitest@2.1.9(@types/node@22.20.1)(jsdom@25.0.1)(lightningcss@1.32.0)(msw@2.14.6(@types/node@22.20.1)(typescript@5.9.3))(terser@5.48.0):
|
||||||
|
dependencies:
|
||||||
|
'@vitest/expect': 2.1.9
|
||||||
|
'@vitest/mocker': 2.1.9(msw@2.14.6(@types/node@22.20.1)(typescript@5.9.3))(vite@5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0))
|
||||||
|
'@vitest/pretty-format': 2.1.9
|
||||||
|
'@vitest/runner': 2.1.9
|
||||||
|
'@vitest/snapshot': 2.1.9
|
||||||
|
'@vitest/spy': 2.1.9
|
||||||
|
'@vitest/utils': 2.1.9
|
||||||
|
chai: 5.3.3
|
||||||
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
|
expect-type: 1.3.0
|
||||||
|
magic-string: 0.30.21
|
||||||
|
pathe: 1.1.2
|
||||||
|
std-env: 3.10.0
|
||||||
|
tinybench: 2.9.0
|
||||||
|
tinyexec: 0.3.2
|
||||||
|
tinypool: 1.1.1
|
||||||
|
tinyrainbow: 1.2.0
|
||||||
|
vite: 5.4.21(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0)
|
||||||
|
vite-node: 2.1.9(@types/node@22.20.1)(lightningcss@1.32.0)(terser@5.48.0)
|
||||||
|
why-is-node-running: 2.3.0
|
||||||
|
optionalDependencies:
|
||||||
|
'@types/node': 22.20.1
|
||||||
|
jsdom: 25.0.1
|
||||||
|
transitivePeerDependencies:
|
||||||
|
- less
|
||||||
|
- lightningcss
|
||||||
|
- msw
|
||||||
|
- sass
|
||||||
|
- sass-embedded
|
||||||
|
- stylus
|
||||||
|
- sugarss
|
||||||
|
- supports-color
|
||||||
|
- terser
|
||||||
|
|
||||||
vitest@2.1.9(@types/node@24.13.1)(jsdom@25.0.1)(lightningcss@1.32.0)(msw@2.14.6(@types/node@24.13.1)(typescript@5.9.3))(terser@5.48.0):
|
vitest@2.1.9(@types/node@24.13.1)(jsdom@25.0.1)(lightningcss@1.32.0)(msw@2.14.6(@types/node@24.13.1)(typescript@5.9.3))(terser@5.48.0):
|
||||||
dependencies:
|
dependencies:
|
||||||
'@vitest/expect': 2.1.9
|
'@vitest/expect': 2.1.9
|
||||||
@@ -25161,7 +25131,7 @@ snapshots:
|
|||||||
'@vitest/spy': 2.1.9
|
'@vitest/spy': 2.1.9
|
||||||
'@vitest/utils': 2.1.9
|
'@vitest/utils': 2.1.9
|
||||||
chai: 5.3.3
|
chai: 5.3.3
|
||||||
debug: 4.4.3(supports-color@8.1.1)
|
debug: 4.4.3(supports-color@5.5.0)
|
||||||
expect-type: 1.3.0
|
expect-type: 1.3.0
|
||||||
magic-string: 0.30.21
|
magic-string: 0.30.21
|
||||||
pathe: 1.1.2
|
pathe: 1.1.2
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ packages:
|
|||||||
- "packages/*"
|
- "packages/*"
|
||||||
- "packages/config/*"
|
- "packages/config/*"
|
||||||
- "e2e/*"
|
- "e2e/*"
|
||||||
|
- "integration"
|
||||||
verifyDepsBeforeRun: warn
|
verifyDepsBeforeRun: warn
|
||||||
allowBuilds:
|
allowBuilds:
|
||||||
"@nestjs/core": true
|
"@nestjs/core": true
|
||||||
|
|||||||
Reference in New Issue
Block a user