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

270 lines
9.6 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 two-trains-one-day — 20 GENERAL bookings against TWO 54-wagon
* trains sharing one route-day (D+24): ONE window timeline, ONE batch
* release, overflow cascading earliest-departure-first, and a full DUAL
* lifecycle (both trains dispatch, run the corridor and arrive the same day).
*
* both schedules share the group window (identical clocks); one
* doc-review-complete releases the WHOLE day
* 20 × 6w = 120w demand vs 108: train 1 takes 9 bookings, the overflow
* lands on train 2 (9 more), 2 wait
* all 18 pay → 54/54 on EACH train; the 2 waiters expire only after BOTH
* trains conclude (the sweep defers while a sibling is open)
* both trains finalize, gate-pass, dispatch, checkpoint and arrive —
* every booking ARRIVED under its own train, movements ledger per train
*
* Sequential steps of one journey — retries off.
*/
import {
apiPost,
bookContainers,
clearGeneralBooking,
completeDocReview,
createImportSchedule,
db,
departureAt,
eatDayStr,
ensureCorridorRoute,
forceReservationExpiry,
markPaid,
opsStaff,
pollAllocations,
pollBookingStatus,
pollDb,
resetCorridorDay,
seedImportContract,
setPriority,
withBooking,
type ScheduleRow,
} from "./import-utils";
const DEPARTURE_1 = departureAt(24);
const DEPARTURE_2 = new Date(DEPARTURE_1.getTime() + 2 * 3_600_000); // same EAT day
const BOOKING_DAY = eatDayStr(DEPARTURE_1);
const stamp = String(Date.now());
const stampedRef = (suffix: string) => `CTR-IMP-${stamp}-${suffix}`;
const USERS = Array.from({ length: 20 }, (_, i) =>
`GT${String(i + 1).padStart(2, "0")}`,
);
const RIDERS = USERS.slice(0, 18); // 9 per train
const WAITERS = USERS.slice(18); // 2 expire after both trains conclude
interface DayScheduleRow {
id: string;
window_phase: string;
booking_window_status: string;
status: string;
scheduled_departure_date: string;
}
/** Both schedules of the day, earliest departure first. */
function dayShedules() {
return db<DayScheduleRow>(
`SELECT ts.id, ts.window_phase, ts.booking_window_status, ts.status,
ts.scheduled_departure_date
FROM freight.train_schedules ts
JOIN freight.yards o ON o.id = ts.origin_station_id AND o.code = 'DJIB_PORT'
JOIN freight.yards d ON d.id = ts.destination_station_id AND d.code = 'KALITY'
WHERE ts.deleted_at IS NULL
AND abs(extract(epoch FROM (ts.scheduled_departure_date - $1::timestamptz))) < 14400
ORDER BY ts.scheduled_departure_date ASC`,
[DEPARTURE_1.toISOString()],
);
}
function withBothSchedules(fn: (first: DayScheduleRow, second: DayScheduleRow) => void) {
dayShedules().then(({ rows }) => {
expect(rows, "two schedules on the day").to.have.length(2);
fn(rows[0], rows[1]);
});
}
describe("GENERAL two trains, one day: shared window, overflow, dual lifecycle", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
USERS.forEach((suffix) =>
seedImportContract({ suffix, reference: stampedRef(suffix), kind: "GENERAL" }),
);
});
it("operations schedules TWO 54-wagon trains on one day — one shared window, forced open", () => {
ensureCorridorRoute();
resetCorridorDay(DEPARTURE_1);
createImportSchedule({ departure: DEPARTURE_1, locoPair: ["LOCO-IMP-13", "LOCO-IMP-14"] });
createImportSchedule({ departure: DEPARTURE_2, locoPair: ["LOCO-IMP-15", "LOCO-IMP-16"] });
// One clock for the whole day: force IDENTICAL window timestamps on both
// siblings (the group rule's shared timeline, arranged deterministically).
withBothSchedules((first, second) => {
db(
`UPDATE freight.train_schedules
SET window_opens_at = now() - interval '1 minute',
window_closes_at = now() + interval '60 minutes'
WHERE id = ANY($1::uuid[])`,
[[first.id, second.id]],
);
[first.id, second.id].forEach((id) =>
pollDb<ScheduleRow>(
`schedule ${id} OPEN`,
`SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`,
[id],
(row) => row?.window_phase === "OPEN" && row?.booking_window_status === "OPEN",
),
);
});
});
it("20 users book into the day (never a specific train); every booking clears per booking", () => {
USERS.forEach((suffix, i) => {
bookContainers({
suffix,
runStamp: stamp,
isoSeed: 17_000 + i * 15,
twenty: 12,
scheduledDate: BOOKING_DAY,
});
clearGeneralBooking(suffix, BOOKING_DAY);
});
USERS.forEach((suffix, i) => setPriority(suffix, i + 1));
});
it("ONE doc-review-complete releases the whole day — 18 reserved across both trains, 2 wait", () => {
withBothSchedules((first, second) => {
db(
`UPDATE freight.train_schedules
SET window_closes_at = now() - interval '1 second'
WHERE id = ANY($1::uuid[]) AND window_phase = 'OPEN'`,
[[first.id, second.id]],
);
[first.id, second.id].forEach((id) =>
pollDb<ScheduleRow>(
`schedule ${id} DOC_REVIEW`,
`SELECT window_phase FROM freight.train_schedules WHERE id = $1`,
[id],
(row) => row?.window_phase === "DOC_REVIEW",
),
);
// Staff complete doc review on ONE train — the group stamp releases both.
completeDocReview(first.id);
pollDb<ScheduleRow>(
"sibling released by the same action",
`SELECT window_phase FROM freight.train_schedules WHERE id = $1`,
[second.id],
(row) => row?.window_phase === "PAYMENT" || row?.window_phase === "DONE",
);
});
RIDERS.forEach((suffix) =>
pollBookingStatus(suffix, ["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"]),
);
WAITERS.forEach((suffix) =>
withBooking(suffix, (b) => {
expect(b.status, `${suffix} waiting`).to.eq("FULLY_EXECUTED");
}),
);
});
it("all 18 pay — 54/54 on EACH train, overflow filled earliest-departure-first", () => {
RIDERS.forEach((suffix) => {
markPaid(suffix);
pollAllocations(suffix, 6);
});
withBothSchedules((first, second) => {
[first.id, second.id].forEach((id) => {
pollDb<{ n: string }>(
`train ${id} carries 9 bookings`,
`SELECT count(*) AS n FROM freight.train_schedule_bookings
WHERE train_schedule_id = $1 AND deleted_at IS NULL`,
[id],
(row) => Number(row?.n) === 9,
15,
);
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`,
[id],
).then(({ rows }) => expect(Number(rows[0].n), "54 wagons").to.eq(54));
});
// The top-priority bookings ride the EARLIEST departure.
withBooking("GT01", (b) =>
expect(b.train_schedule_id, "GT01 on the first train").to.eq(first.id),
);
});
});
it("both trains conclude FULL — only then do the 2 waiters expire (sweep defers for siblings)", () => {
withBothSchedules((first, second) => {
db(
`UPDATE freight.train_schedules
SET payment_phase_ends_at = now() - interval '1 second'
WHERE id = ANY($1::uuid[]) AND window_phase = 'PAYMENT'`,
[[first.id, second.id]],
);
[first.id, second.id].forEach((id) =>
pollDb<ScheduleRow>(
`schedule ${id} FULL + DONE`,
`SELECT window_phase, booking_window_status FROM freight.train_schedules WHERE id = $1`,
[id],
(row) => row?.booking_window_status === "FULL" && row?.window_phase === "DONE",
),
);
});
WAITERS.forEach((suffix) => pollBookingStatus(suffix, "EXPIRED"));
});
it("dual lifecycle: both trains gate-pass, dispatch, run the corridor and arrive", () => {
withBothSchedules((first, second) => {
[first.id, second.id].forEach((id) => {
pollDb<ScheduleRow>(
`schedule ${id} finalized`,
`SELECT status FROM freight.train_schedules WHERE id = $1`,
[id],
(row) => row?.status === "SCHEDULED",
10,
);
apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/import-djibouti/gatepass-granted`)
.its("status")
.should("be.oneOf", [200, 201]);
apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/dispatch`)
.its("status")
.should("be.oneOf", [200, 201]);
[1, 2, 3, 4].forEach((seq) => {
apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/checkpoints`, {
sequenceNo: seq,
kind: "PASSED",
})
.its("status")
.should("be.oneOf", [200, 201]);
});
apiPost(opsStaff, `/api/train-scheduling/schedules/${id}/checkpoints`, {
sequenceNo: 5,
kind: "ARRIVED",
})
.its("status")
.should("be.oneOf", [200, 201]);
pollDb<ScheduleRow>(
`schedule ${id} ARRIVED`,
`SELECT status FROM freight.train_schedules WHERE id = $1`,
[id],
(row) => row?.status === "ARRIVED",
20,
);
db<{ n: string }>(
`SELECT count(*) AS n FROM freight.wagon_movements WHERE train_schedule_id = $1`,
[id],
).then(({ rows }) =>
expect(Number(rows[0].n), `train ${id} movements`).to.be.at.least(54),
);
});
});
RIDERS.forEach((suffix) => pollBookingStatus(suffix, "ARRIVED", 20));
});
});
export {};