/** * 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(); let contracts: Map; 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); }); });