Files
edr-platform/integration/src/bulk-export-fcfs-space.it.ts
2026-08-03 10:51:43 +00:00

183 lines
5.7 KiB
TypeScript

/**
* 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);
});
});