mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-30 02:58:11 +00:00
180 lines
6.2 KiB
TypeScript
180 lines
6.2 KiB
TypeScript
/**
|
||
* 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 {};
|