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

184 lines
6.8 KiB
TypeScript

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