feat: integration tests

This commit is contained in:
Nathnael
2026-08-03 10:51:43 +00:00
parent 6f137a11c3
commit f103b51de5
38 changed files with 7306 additions and 228 deletions

165
docker-compose.it.yaml Normal file
View 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
View 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
3060s of real API work per booking, so files share one schedule day.
- Files run sequentially (one DB); concurrency is exercised inside a test with
`Promise.all`.
- **A retired fixture keeps its shipment day at its peril.**
`rescueStrandedPaidForDay` sweeps every unlinked booking whose
`payment_status` is PAID and whose `scheduled_date` falls on the day being
filled, and re-places it on the fresh train. A previous run's paid bookings
therefore climb back aboard — 18 stowaway wagons on a 28-wagon day, until
`releaseUnpaidHolds` / `resetCorridorDay` started nulling `scheduled_date`.
- **Only a FULL train rests at DONE.** An under-filled day CONCLUDES and
REOPENS (`window_phase` back to OPEN, `booking_cycle_no` 2), so waiting for
DONE there waits forever — use `pollCycleConcluded`.
- **Wagon stock is finite and shared.** Paid bookings keep their wagons, so
`releaseUnpaidHolds()` also frees every earlier `CTR-IT-%` allocation —
without it the fifth or sixth file on a warm stack silently gets a short
consist.
- **One tenant per booking.** `seedTenantContracts` mints a company per booking
because a company may hold only one unpaid reservation at a time; staff book
and pay on their behalf, which is also the real Path B flow.
- **Group 1 rides a BUILT train, not a loco pair.** `maxWagonsPerTrain` is not a
cap: `syncScheduleMaxWagons` recomputes it from locomotive length (54 here)
every fill pass. A built train's coupled consist wins outright, so
`seed-g1-train.sql`'s 53 wagons ARE the capacity — the number every G1
scenario's arithmetic is written in. `createBuiltTrainSchedule` asserts it.
- **Customs bookings walk the phased chain** (`clearBookingPhasedCustoms`):
transit assignee → declaration draft → accept → declaration → duty →
transit permit → pre-clearance → delivery order. `clearance/finalize` refuses
them outright.
- Freight sends a dev-shortcut amount (1 minor unit, 10 for CAC) for every
non-`CBE_BILL` provider. The tests assert that as-is.

View 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
View 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
View 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`);
}

View 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
);

View 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'
);

View 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);
});
});

View 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 110-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 110 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);
});
});

View 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);
});

View 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);
});
});

View 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);
});
});

View 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);
}
});
});

View 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;

View 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);
});

View 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);
}
});
});

View 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);
}

View 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);
});
});

View 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");
}
});
});

View 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,
);
});
});

View 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
View 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(),
};

View 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

File diff suppressed because it is too large Load Diff

View 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")!);
});
});

View 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);
});
});

View 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);
});
});

View 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);
});
});

View 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");
});
});

View File

@@ -0,0 +1,420 @@
/**
* GROUP 1 · S6S8 — 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);
});
});

View 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" });
}

View 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);
});
});

View 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
View 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"]
}

View 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"],
},
});

View File

@@ -31,6 +31,10 @@
"e2e:freight:run": "node e2e/freight/scripts/e2e.mjs run",
"e2e:freight:ci": "node e2e/freight/scripts/e2e.mjs ci",
"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"
},
"devDependencies": {

426
pnpm-lock.yaml generated
View File

@@ -589,7 +589,7 @@ importers:
version: 5.101.0(react@19.2.6)
'@tria-plc/iamui':
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':
specifier: ^1.8.3
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
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:
dependencies:
'@edr/types':
@@ -12340,11 +12364,11 @@ snapshots:
'@babel/helpers': 7.29.7
'@babel/parser': 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
'@jridgewell/remapping': 2.3.5
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
json5: 2.2.3
semver: 6.3.1
@@ -12379,7 +12403,7 @@ snapshots:
'@babel/helper-optimise-call-expression': 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/traverse': 7.29.7
'@babel/traverse': 7.29.7(supports-color@5.5.0)
semver: 6.3.1
transitivePeerDependencies:
- supports-color
@@ -12388,14 +12412,7 @@ snapshots:
'@babel/helper-member-expression-to-functions@7.29.7':
dependencies:
'@babel/traverse': 7.29.7
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
'@babel/helper-module-imports@7.29.7':
dependencies:
'@babel/traverse': 7.29.7
'@babel/traverse': 7.29.7(supports-color@5.5.0)
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
@@ -12410,9 +12427,9 @@ snapshots:
'@babel/helper-module-transforms@7.29.7(@babel/core@7.29.7)':
dependencies:
'@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/traverse': 7.29.7
'@babel/traverse': 7.29.7(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -12427,13 +12444,13 @@ snapshots:
'@babel/core': 7.29.7
'@babel/helper-member-expression-to-functions': 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:
- supports-color
'@babel/helper-skip-transparent-expression-wrappers@7.29.7':
dependencies:
'@babel/traverse': 7.29.7
'@babel/traverse': 7.29.7(supports-color@5.5.0)
'@babel/types': 7.29.7
transitivePeerDependencies:
- supports-color
@@ -12586,18 +12603,6 @@ snapshots:
'@babel/parser': 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)':
dependencies:
'@babel/code-frame': 7.29.7
@@ -12821,7 +12826,7 @@ snapshots:
'@emotion/babel-plugin@11.13.5':
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
'@emotion/hash': 0.9.2
'@emotion/memoize': 0.9.0
@@ -12987,7 +12992,7 @@ snapshots:
'@eslint/eslintrc@2.1.4':
dependencies:
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
globals: 13.24.0
ignore: 5.3.2
@@ -13147,7 +13152,7 @@ snapshots:
'@humanwhocodes/config-array@0.13.0':
dependencies:
'@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
transitivePeerDependencies:
- supports-color
@@ -13177,6 +13182,14 @@ snapshots:
optionalDependencies:
'@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)':
dependencies:
'@inquirer/core': 11.2.1(@types/node@24.13.1)
@@ -13197,6 +13210,19 @@ snapshots:
optionalDependencies:
'@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)':
dependencies:
'@inquirer/ansi': 2.0.7
@@ -13319,6 +13345,11 @@ snapshots:
optionalDependencies:
'@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)':
optionalDependencies:
'@types/node': 24.13.1
@@ -14318,7 +14349,7 @@ snapshots:
'@puppeteer/browsers@2.13.2':
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
extract-zip: 2.0.1
progress: 2.0.3
proxy-agent: 6.5.0
@@ -16386,7 +16417,7 @@ snapshots:
'@tokenizer/inflate@0.4.1':
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
token-types: 6.1.2
transitivePeerDependencies:
- supports-color
@@ -16675,130 +16706,6 @@ snapshots:
- utf-8-validate
- 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':
dependencies:
fast-glob: 3.3.3
@@ -17162,7 +17069,7 @@ snapshots:
'@typescript-eslint/types': 8.60.1
'@typescript-eslint/typescript-estree': 8.60.1(typescript@5.9.3)
'@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
typescript: 5.9.3
transitivePeerDependencies:
@@ -17172,7 +17079,7 @@ snapshots:
dependencies:
'@typescript-eslint/tsconfig-utils': 8.60.1(typescript@5.9.3)
'@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
transitivePeerDependencies:
- supports-color
@@ -17191,7 +17098,7 @@ snapshots:
'@typescript-eslint/types': 8.60.1
'@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)
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
eslint: 8.57.1
ts-api-utils: 2.5.0(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/types': 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
semver: 7.8.2
tinyglobby: 0.2.17
@@ -17330,6 +17237,15 @@ snapshots:
chai: 5.3.3
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))':
dependencies:
'@vitest/spy': 2.1.9
@@ -17486,7 +17402,7 @@ snapshots:
agent-base@6.0.2:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -18010,16 +17926,6 @@ snapshots:
transitivePeerDependencies:
- 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:
dependencies:
babel-runtime: 6.26.0
@@ -18173,7 +18079,7 @@ snapshots:
dependencies:
bytes: 3.1.2
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
iconv-lite: 0.7.2
on-finished: 2.4.1
@@ -19174,7 +19080,7 @@ snapshots:
engine.io-client@6.6.5:
dependencies:
'@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
ws: 8.20.1
xmlhttprequest-ssl: 2.1.2
@@ -19194,7 +19100,7 @@ snapshots:
base64id: 2.0.0
cookie: 0.7.2
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
ws: 8.21.0
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):
dependencies:
'@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
get-tsconfig: 4.14.0
is-bun-module: 2.0.0
@@ -19555,7 +19461,7 @@ snapshots:
ajv: 6.15.0
chalk: 4.1.2
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
escape-string-regexp: 4.0.0
eslint-scope: 7.2.2
@@ -19785,7 +19691,7 @@ snapshots:
content-type: 1.0.5
cookie: 0.7.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
encodeurl: 2.0.0
escape-html: 1.0.3
@@ -19838,7 +19744,7 @@ snapshots:
extract-zip@2.0.1:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
get-stream: 5.2.0
yauzl: 2.10.0
optionalDependencies:
@@ -19991,7 +19897,7 @@ snapshots:
finalhandler@2.1.1:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
encodeurl: 2.0.0
escape-html: 1.0.3
on-finished: 2.4.1
@@ -20237,7 +20143,7 @@ snapshots:
dependencies:
basic-ftp: 5.3.1
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:
- supports-color
@@ -20518,7 +20424,7 @@ snapshots:
http-proxy-agent@7.0.2:
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -20531,14 +20437,14 @@ snapshots:
https-proxy-agent@5.0.1:
dependencies:
agent-base: 6.0.2
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
https-proxy-agent@7.0.6:
dependencies:
agent-base: 7.1.4
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
transitivePeerDependencies:
- supports-color
@@ -20963,7 +20869,7 @@ snapshots:
istanbul-lib-source-maps@4.0.1:
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
source-map: 0.6.1
transitivePeerDependencies:
@@ -21611,7 +21517,7 @@ snapshots:
dependencies:
chalk: 5.6.2
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
lilconfig: 3.1.3
listr2: 8.3.3
@@ -22012,6 +21918,32 @@ snapshots:
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):
dependencies:
'@inquirer/confirm': 6.1.1(@types/node@24.13.1)
@@ -22386,7 +22318,7 @@ snapshots:
dependencies:
'@tootallnate/quickjs-emscripten': 0.23.0
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
http-proxy-agent: 7.0.2
https-proxy-agent: 7.0.6
@@ -22726,7 +22658,7 @@ snapshots:
proxy-agent@6.5.0:
dependencies:
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
https-proxy-agent: 7.0.6
lru-cache: 7.18.3
@@ -22755,7 +22687,7 @@ snapshots:
dependencies:
'@puppeteer/browsers': 2.13.2
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
typed-query-selector: 2.12.2
webdriver-bidi-protocol: 0.4.1
@@ -23008,15 +22940,6 @@ snapshots:
- '@babel/core'
- 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):
dependencies:
date-fns: 3.6.0
@@ -23590,7 +23513,7 @@ snapshots:
router@2.2.0:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
depd: 2.0.0
is-promise: 4.0.0
parseurl: 1.3.3
@@ -23708,7 +23631,7 @@ snapshots:
send@1.2.1:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
encodeurl: 2.0.0
escape-html: 1.0.3
etag: 1.8.1
@@ -23926,7 +23849,7 @@ snapshots:
socket.io-adapter@2.5.8:
dependencies:
debug: 4.4.3(supports-color@8.1.1)
debug: 4.4.3(supports-color@5.5.0)
ws: 8.21.0
transitivePeerDependencies:
- bufferutil
@@ -23936,7 +23859,7 @@ snapshots:
socket.io-client@4.8.3:
dependencies:
'@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
socket.io-parser: 4.2.6
transitivePeerDependencies:
@@ -23947,7 +23870,7 @@ snapshots:
socket.io-parser@4.2.6:
dependencies:
'@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:
- supports-color
@@ -23956,7 +23879,7 @@ snapshots:
accepts: 1.3.8
base64id: 2.0.0
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
socket.io-adapter: 2.5.8
socket.io-parser: 4.2.6
@@ -23968,7 +23891,7 @@ snapshots:
socks-proxy-agent@8.0.5:
dependencies:
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
transitivePeerDependencies:
- supports-color
@@ -24248,24 +24171,6 @@ snapshots:
transitivePeerDependencies:
- '@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):
dependencies:
client-only: 0.0.1
@@ -24291,7 +24196,7 @@ snapshots:
dependencies:
component-emitter: 1.3.1
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
form-data: 4.0.5
formidable: 3.5.4
@@ -24798,7 +24703,7 @@ snapshots:
app-root-path: 3.1.0
buffer: 6.0.3
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)
dotenv: 16.6.1
glob: 10.5.0
@@ -24822,7 +24727,7 @@ snapshots:
app-root-path: 3.1.0
buffer: 6.0.3
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)
dotenv: 16.6.1
glob: 10.5.0
@@ -25122,10 +25027,28 @@ snapshots:
string_decoder: 1.3.0
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):
dependencies:
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
pathe: 1.1.2
vite: 5.4.21(@types/node@24.13.1)(lightningcss@1.32.0)(terser@5.48.0)
@@ -25140,6 +25063,17 @@ snapshots:
- supports-color
- 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):
dependencies:
esbuild: 0.21.5
@@ -25151,6 +25085,42 @@ snapshots:
lightningcss: 1.32.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):
dependencies:
'@vitest/expect': 2.1.9
@@ -25161,7 +25131,7 @@ snapshots:
'@vitest/spy': 2.1.9
'@vitest/utils': 2.1.9
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
magic-string: 0.30.21
pathe: 1.1.2

View File

@@ -5,6 +5,7 @@ packages:
- "packages/*"
- "packages/config/*"
- "e2e/*"
- "integration"
verifyDepsBeforeRun: warn
allowBuilds:
"@nestjs/core": true