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

197 lines
6.1 KiB
TypeScript

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