Files
edr-platform/e2e/freight/cypress/e2e/flows/general_rush_hour.cy.ts
2026-07-23 11:06:10 +00:00

180 lines
6.2 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.

/**
* GENERAL rush hour — 20 "users" (20 GENERAL contracts) book one 54-wagon
* import train in the SAME first window (D+23). Every GENERAL booking clears
* PER BOOKING (upload → GL approve → finalize → proceed → ops accept) before
* it may enter the pool. Then:
*
* a 21st booking whose operation request is never accepted is EXPIRED by
* the batch at doc-review end (it can no longer make the train)
* the batch reserves the top 9 (9 × 6w = 54/54); 11 wait
* the payment race: 5 pay, 4 miss their deadline → the freed 24 wagons
* promote the next 4 waiters live (payment phase extends); they pay →
* the train still departs FULL
* the 7 left-over waiters expire in the day-end sweep; every contract
* stays CONTRACT_ACTIVE (GENERAL survives its bookings)
*
* Sequential steps of one journey — retries off.
*/
import {
bookContainers,
clearGeneralBooking,
closeBookingWindow,
completeDocReview,
createImportSchedule,
db,
departureAt,
eatDayStr,
endPaymentPhase,
ensureCorridorRoute,
expectWagonType,
forceReservationExpiry,
forceWindowOpen,
markPaid,
pollAllocations,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
setPriority,
withBooking,
withSchedule,
type ScheduleRow,
} from "./import-utils";
const DEPARTURE = departureAt(23);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
/** 20 virtual users — one GENERAL contract each, 12×20ft = 6 wagons per booking. */
const USERS = Array.from({ length: 20 }, (_, i) =>
`GR${String(i + 1).padStart(2, "0")}`,
);
const SELECTED = USERS.slice(0, 9); // 9 × 6w = 54
const PAYERS = SELECTED.slice(0, 5);
const DEFAULTERS = SELECTED.slice(5, 9);
const PROMOTED = USERS.slice(9, 13); // take the defaulters' 24 wagons
const LEFTOVER = USERS.slice(13); // 7 expire with the day
describe("GENERAL rush hour: 20 users, one train, one window", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
[...USERS, "GRNA"].forEach((suffix) =>
seedImportContract({ suffix, reference: stampedRef(suffix), kind: "GENERAL" }),
);
});
it("operations prepares the corridor train with an open first window", () => {
ensureCorridorRoute();
resetCorridorDay(DEPARTURE);
createImportSchedule({ departure: DEPARTURE, locoPair: ["LOCO-IMP-11", "LOCO-IMP-12"] });
withSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60));
});
it("20 users book at once — each GENERAL booking clears PER BOOKING into the pool", () => {
USERS.forEach((suffix, i) => {
bookContainers({
suffix,
runStamp: stamp,
isoSeed: 16_000 + i * 15,
twenty: 12,
scheduledDate: BOOKING_DAY,
});
clearGeneralBooking(suffix, BOOKING_DAY);
});
USERS.forEach((suffix, i) => setPriority(suffix, i + 1));
});
it("a 21st booking never accepted by operations is expired when doc review ends", () => {
bookContainers({
suffix: "GRNA",
runStamp: stamp,
isoSeed: 16_500,
twenty: 12,
scheduledDate: BOOKING_DAY,
});
// Clearance done, operation requested — but ops never accept it.
withBooking("GRNA", (b) => {
cy.log(`GRNA ${b.reference} stays OPERATION_REQUEST_PENDING`);
});
db(
`UPDATE freight.bookings b SET status = 'OPERATION_REQUEST_PENDING'
FROM freight.contracts ct
WHERE ct.id = b.contract_id AND ct.reference LIKE 'CTR-IMP-%-GRNA'
AND b.status = 'AWAITING_DOCUMENTS'`,
[],
);
withSchedule(DEPARTURE, (s) => {
closeBookingWindow(s.id);
completeDocReview(s.id);
});
// Never-accepted bookings are swept BEFORE the batch runs.
pollBookingStatus("GRNA", "EXPIRED");
});
it("the batch reserves the top 9 (54/54); 11 wait with no pay window", () => {
SELECTED.forEach((suffix) =>
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
[...PROMOTED, ...LEFTOVER].forEach((suffix) =>
withBooking(suffix, (b) => {
expect(b.status, `${suffix} waiting`).to.eq("FULLY_EXECUTED");
expect(b.payment_deadline, `${suffix} no pay window yet`).to.be.null;
}),
);
});
it("payment race: 5 pay, 4 default — the freed 24 wagons promote the next 4 waiters live", () => {
PAYERS.forEach((suffix) => {
markPaid(suffix);
pollAllocations(suffix, 6);
});
DEFAULTERS.forEach((suffix) => forceReservationExpiry(suffix));
PROMOTED.forEach((suffix) =>
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
});
it("the promoted 4 pay — the train departs FULL at 54/54, typed", () => {
PROMOTED.forEach((suffix) => {
markPaid(suffix);
pollAllocations(suffix, 6);
expectWagonType(suffix, "NW5", 6);
});
withSchedule(DEPARTURE, (s) => {
endPaymentPhase(s.id);
pollDb<ScheduleRow>(
"window FULL + DONE",
`SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`,
[s.id],
(row) => row?.booking_window_status === "FULL" && row?.window_phase === "DONE",
);
db<{ n: string }>(
`SELECT count(DISTINCT wba.train_set_wagon_id) AS n
FROM freight.wagon_booking_allocations wba
JOIN freight.train_schedule_bookings tsb
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
WHERE wba.deleted_at IS NULL AND tsb.deleted_at IS NULL`,
[s.id],
).then(({ rows }) => expect(Number(rows[0].n), "54 wagons allocated").to.eq(54));
});
});
it("the 7 leftover waiters expire in ONE day-end sweep; every contract stays ACTIVE", () => {
LEFTOVER.forEach((suffix) => pollBookingStatus(suffix, "EXPIRED"));
db<{ n: string }>(
`SELECT count(*) AS n FROM freight.contracts
WHERE reference LIKE 'CTR-IMP-%-GR%' AND status = 'CONTRACT_ACTIVE'
AND deleted_at IS NULL AND created_at > now() - interval '1 hour'`,
[],
).then(({ rows }) =>
expect(Number(rows[0].n), "GENERAL contracts survive their bookings").to.be.at.least(21),
);
});
});
export {};