mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-09-07 18:55:42 +00:00
270 lines
9.6 KiB
TypeScript
270 lines
9.6 KiB
TypeScript
/**
|
||
* 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 {};
|