Files
edr-platform/integration/src/g1-s1-expiry-promotes-waitlist.it.ts
2026-08-03 10:51:43 +00:00

182 lines
6.5 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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