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

191 lines
7.3 KiB
TypeScript

/**
* GROUP 1 · S5 — one expiry cascades into a second promotion.
*
* In the batch: A 10w + B 20w + C 23w = 53/53
* Waiting: D 8w, E 5w (priority order D before E)
*
* A never pays and EXPIRES → 10 wagons freed. ONE settle then serves BOTH
* waiting bookings in the same pass: D is reserved whole (8w) and E, which no
* longer fits in the 2 wagons left, is OFFERED a partial of exactly those 2.
* That is the assertion the scenario exists for — fillFromWaitingList loops
* until a pass places nothing, so a single-pass top-up would leave E untouched
* until the next window cycle.
*
* D then expires too, freeing 8 more wagons — and E's offer is NOT resized.
* FINDING (the same one bulk-b1 pins): a refill never supersedes an open
* partial offer, so E pays its stale 2-wagon offer and ships 2 of its 5 wagons
* while 8 sit idle. The train settles at B 20 + C 23 + E 2 = 45/53.
*
* Every expiry must also leave an audit trail — a terminal EXPIRED booking with
* its invoice closed out, never a silent disappearance. An open invoice on an
* expired seat is money the customer could still pay for a train they are no
* longer on.
*/
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,
extendPayWindow,
forceReservationExpiry,
forceWindowOpen,
livePayableInvoices,
payViaGateway,
pollAllocations,
pollBookingStatus,
pollCycleConcluded,
pollPartialOffer,
releaseUnpaidHolds,
resetCorridorDay,
scheduleRow,
seedTenantContracts,
setPriority,
} from "./flows";
const DEPARTURE = departureAt(54);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const STAMP = String(Date.now());
const SHAPES = {
A: { twenty: 20, forty: 0, wagons: 10 },
B: { twenty: 40, forty: 0, wagons: 20 },
C: { twenty: 0, forty: 23, wagons: 23 },
D: { twenty: 16, forty: 0, wagons: 8 },
E: { twenty: 10, forty: 0, wagons: 5 },
} as const;
const ORDER = ["A", "B", "C", "D", "E"] as const;
const IN_BATCH = ["A", "B", "C"] as const;
const WAITING = ["D", "E"] as const;
/** What A's expiry leaves loose once D takes its 8 — E's offer is sized to it. */
const E_OFFER = SHAPES.A.wagons - SHAPES.D.wagons; // 2
const RIDING = SHAPES.B.wagons + SHAPES.C.wagons + E_OFFER; // 45
describe("g1 s5: expiry cascades into a second promotion", () => {
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 = 24_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("A+B+C fill the train; D and E queue behind them, each fitting the hole above", () => {
for (const suffix of ORDER) {
expect(containerWagons(SHAPES[suffix].twenty, SHAPES[suffix].forty), `${suffix} wagons`).toBe(
SHAPES[suffix].wagons,
);
}
expect(
IN_BATCH.reduce((sum, s) => sum + SHAPES[s].wagons, 0),
"A+B+C fill the train exactly",
).toBe(G1_WAGONS);
expect(SHAPES.D.wagons, "D fits inside A's 10").toBeLessThan(SHAPES.A.wagons);
expect(SHAPES.E.wagons, "E fits inside D's 8").toBeLessThan(SHAPES.D.wagons);
});
it("the batch reserves A, B and C; D and E wait in priority order", async () => {
await closeBookingWindow(scheduleId);
await completeDocReview(scheduleId);
for (const suffix of IN_BATCH) {
await pollBookingStatus(booking.get(suffix)!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]);
}
for (const suffix of WAITING) {
const row = await bookingRow(booking.get(suffix)!);
expect(row.status, `${suffix} waitlisted`).toBe("FULLY_EXECUTED");
expect(row.train_schedule_id, `${suffix} holds no seat`).toBeNull();
}
});
it("B and C pay; A expires and ONE pass serves both waiting bookings", async () => {
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")!);
// D fits A's hole whole…
await pollBookingStatus(booking.get("D")!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], 40);
// …and the same pass keeps going: E no longer fits in the 2 wagons left, so
// it is offered exactly those rather than being left for the next cycle.
const offer = await pollPartialOffer(booking.get("E")!);
expect(Number(offer.offered_wagons), "E offered the 2 loose wagons").toBe(E_OFFER);
});
it("D expires too — but E's stale 2-wagon offer is never resized", async () => {
await forceReservationExpiry(booking.get("D")!);
// FINDING (bulk-b1 pins the same gap): the refill re-selects a booking that
// already holds an OFFER without re-sizing it, so the 8 wagons D just freed
// stay unsold and E ships 2 of the 5 it asked for. Assert what the engine
// really does, so a fix to the offer path fails loudly here.
await pollBookingStatus(booking.get("E")!, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"], 40);
expect((await bookingRow(booking.get("E")!)).payment_deadline, "E got a pay window").toBeTruthy();
await extendPayWindow(scheduleId, [booking.get("E")!]);
await payViaGateway(booking.get("E")!);
await pollAllocations(booking.get("E")!, E_OFFER);
expect((await bookingRow(booking.get("E")!)).is_split, "E rode split, 2 of 5").toBe(true);
});
it("both expiries are terminal and auditable — nothing vanished silently", async () => {
for (const suffix of ["A", "D"] as const) {
const row = await bookingRow(booking.get(suffix)!);
expect(row.status, `${suffix} terminal EXPIRED`).toBe("EXPIRED");
expect(row.train_schedule_id, `${suffix} holds no seat`).toBeNull();
expect(
await livePayableInvoices(booking.get(suffix)!),
`${suffix} has no live payable invoice`,
).toBe(0);
}
});
it("the train settles at 45/53 — eight wagons unsold, short of FULL", async () => {
await endPaymentPhase(scheduleId);
await pollCycleConcluded(scheduleId);
expect(await allocatedWagons(scheduleId), "45 wagons allocated").toBe(RIDING);
expect(
(await scheduleRow(scheduleId)).booking_window_status,
"window not FULL at 45/53",
).not.toBe("FULL");
});
});