Files
edr-platform/e2e/freight/cypress/e2e/flows/g1-utils.ts
2026-08-02 10:26:36 +00:00

695 lines
27 KiB
TypeScript

/**
* Shared helpers for the GROUP 1 scenario specs (g1_s1 … g1_s8).
*
* These specs are the "visual" variant of the corridor suite: the fleet
* configuration phase (wagons → locomotives → train consist → schedule) and
* every capacity verdict are driven and asserted through the BACKOFFICE UI,
* while the bulk of the cargo (50+ wagons ≈ 100+ ISO container inputs per
* scenario) is still created through the API. See `bookOneVisually` below for
* where the line is drawn and why.
*
* Everything here builds on ./import-utils — the corridor route, contract
* seeding, window choreography and polling are unchanged. This module adds
* only what Group 1 needs on top:
*
* - a 53-WAGON BUILT TRAIN (seed-g1-train.sql). Group 1's arithmetic is
* written for 53 slots; a loco-pair schedule cannot hold that number
* because syncScheduleMaxWagons recomputes max_wagons from locomotive
* length (floor(760 / 13.966) = 54 on this corridor). A built train's
* physical consist wins outright — booking-batch.service.ts:4152:
* const maxWagons = physicalWagons ?? capacityLimits(loco).base.wagons
* so the consist staff marshal IS the capacity, and it survives the tick.
*
* - the FULL / NOT FULL verdict helpers, which every scenario ends on.
*
* No module-level mutable state: Cypress re-evaluates the spec bundle on every
* cross-origin visit, so helpers resolve rows by stamped-reference suffix and
* newest-row, never by a captured id. (Same rule as import-utils.)
*/
import {
acceptExport,
acceptOperation,
apiPost,
bookContainers,
clearToOperationRequestPending,
closeBookingWindow,
db,
dbSchedule,
forceWindowOpen,
opsStaff,
ORIGIN,
pollDb,
withBooking,
withSchedule,
type ScheduleRow,
} from "./import-utils";
/** The Group 1 built train's consist size — see seed-g1-train.sql. */
export const G1_WAGONS = 53;
export const G1_TRAIN = "TRN-G1-1";
/** Second identical train, for the multi-schedule scenarios. */
export const G1_TRAIN_2 = "TRN-G1-2";
// ---------------------------------------------------------------------------
// wagon arithmetic — the number every scenario is written in
// ---------------------------------------------------------------------------
/**
* Escape a DB-sourced string for use inside a RegExp. Yard and train labels go
* straight into `cy.contains(new RegExp(...))` selectors, and a label carrying
* a metacharacter (a "." or "(" in a yard name) would otherwise silently match
* the wrong option — or nothing at all.
*/
export function escapeRegExp(value: string): string {
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
}
/**
* A Date as the unzoned "YYYY-MM-DDTHH:mm" wall-clock string that an
* `<input type="datetime-local">` accepts (the create-schedule form's Departure
* date field).
*
* The value MUST be in the BROWSER's local zone, not EAT. The input carries no
* offset, so whatever is typed is read as local time and converted on submit —
* pre-shifting to EAT on a UTC browser files the departure three hours late,
* which put it outside dbSchedule's ±1h lookup window and made a successfully
* created schedule look like it had never been created at all.
*
* Built from the local getters rather than toISOString for exactly that reason.
*/
export function localDateTime(d: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return (
`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
`T${pad(d.getHours())}:${pad(d.getMinutes())}`
);
}
/**
* Wagons a container booking needs: 20ft containers pair up two-per-wagon,
* 40ft take a whole wagon each. An ODD 20ft count still costs a whole wagon
* (and the portal form blocks submitting one — `hasOdd20ft`), so callers
* should keep 20ft quantities even.
*/
export function wagonsFor(twenty: number, forty: number): number {
return Math.ceil(twenty / 2) + forty;
}
// ---------------------------------------------------------------------------
// the built-train schedule
// ---------------------------------------------------------------------------
/**
* Create the Group 1 schedule on the corridor from the BUILT 53-wagon train.
*
* Deliberately NOT `createImportSchedule({ locoPair })`: that path derives
* capacity from locomotive length and would give 54 slots. Passing the train
* makes the coupled consist the cap (see module header).
*/
export function createG1Schedule(opts: {
departure: Date;
trainCode?: string;
routeId: string;
}) {
const trainCode = opts.trainCode ?? G1_TRAIN;
dbSchedule(opts.departure).then(({ rows }) => {
if (rows.length > 0) return;
db<{ id: string }>(`SELECT id FROM freight.trains WHERE code = $1`, [trainCode]).then(
({ rows: trains }) => {
expect(trains, `built train ${trainCode}`).to.have.length(1);
apiPost(opsStaff, "/api/train-scheduling/container/schedules", {
routeId: opts.routeId,
scheduleDate: opts.departure.toISOString(),
trainId: trains[0].id,
})
.its("status")
.should("be.oneOf", [200, 201]);
},
);
});
}
/**
* The visual configuration phase, shared by every Group 1 scenario: operations
* schedules the built train on the corridor through the REAL create form
* (Route → Departure date → Train), then the window is opened.
*
* The form is built-train only — there is no locomotive-pair option and no
* max-wagons field in it (the pair path lives in AllocateBookingWizard), which
* is exactly what this suite wants: the consist chosen here IS the capacity.
*
* Leaves the schedule OPEN with `closesInMinutes` of window left.
*/
export function configureAndOpenSchedule(opts: {
departure: Date;
trainCode?: string;
originCode?: string;
closesInMinutes?: number;
wagons?: number;
}) {
const trainCode = opts.trainCode ?? G1_TRAIN;
const originCode = opts.originCode ?? ORIGIN;
cy.loginBackoffice(opsStaff);
cy.visit("/dashboard/operations/train-scheduling-v2");
cy.contains("button", "New schedule", { timeout: 120000 }).click();
cy.contains("Create train schedule", { timeout: 120000 }).should("be.visible");
// Route options are composed by formatRouteLabel, which renders yard LABELS
// ("Djibouti Port"), never codes — so resolve the label for this corridor.
db<{ label: string }>(`SELECT label FROM freight.yards WHERE code = $1`, [
originCode,
]).then(({ rows }) => {
expect(rows, `origin yard ${originCode}`).to.have.length(1);
cy.mantineSelect("Route", new RegExp(escapeRegExp(rows[0].label)));
});
// datetime-local takes an unzoned "YYYY-MM-DDTHH:mm" wall-clock string.
cy.get('input[type="datetime-local"]').type(localDateTime(opts.departure), {
force: true,
});
// Option text is composed: "TRN-G1-1 — E2E Group-1 … · 53 wagons".
cy.mantineSelect("Train", new RegExp(escapeRegExp(trainCode)));
cy.get(".mantine-Modal-content").contains("button", "Create").click();
cy.get(".mantine-Modal-content", { timeout: 120000 }).should("not.exist");
expectCapacity(opts.departure, opts.wagons ?? G1_WAGONS);
withSchedule(opts.departure, (s) => forceWindowOpen(s.id, opts.closesInMinutes ?? 45));
}
/**
* Close the window and run the batch from the board's own button — the one
* manual phase action the app exposes (closing itself is time-driven; there is
* no "close window" control anywhere in the UI).
*
* Asserts the phase actually advanced: a button that silently no-ops would
* otherwise leave every downstream assertion to time out far from the cause.
*/
export function closeWindowAndRunBatch(departure: Date) {
withSchedule(departure, (s) => closeBookingWindow(s.id));
cy.loginBackoffice(opsStaff);
withSchedule(departure, (s) => cy.visit(`/dashboard/operations/batch-board/${s.id}`));
cy.contains("Doc review", { timeout: 120000 }).should("exist");
cy.contains("button", "Doc review complete — run batch", { timeout: 120000 }).click();
withSchedule(departure, (s) =>
pollDb<ScheduleRow>(
"batch ran — window phase advanced",
`SELECT window_phase FROM freight.train_schedules WHERE id = $1`,
[s.id],
// DONE when the batch reserved nobody — itself a scenario outcome.
(row) => !!row && ["PAYMENT", "DONE"].includes(row.window_phase as string),
20,
),
);
}
/**
* Read the Priority Tracking board: the lane counts and the capacity divider.
* Pass only what the scenario cares about.
*
* The divider is ONE text node — `Capacity line · 53/53 wagons · FULL` — so
* the FULL suffix cannot be asserted separately from the ratio.
*/
export function expectBoard(
departure: Date,
opts: {
inBatch?: number;
waiting?: number;
expired?: number;
capacity?: { used: number; max?: number; full?: boolean };
},
) {
cy.loginBackoffice(opsStaff);
withSchedule(departure, (s) => cy.visit(`/dashboard/operations/batch-board/${s.id}`));
cy.contains(/Priority Tracking/, { timeout: 120000 }).click();
cy.contains("Priority ranking", { timeout: 120000 }).should("be.visible");
if (opts.inBatch !== undefined) {
cy.contains(new RegExp(`In the batch\\s*\\(${opts.inBatch}\\)`), {
timeout: 120000,
}).should("exist");
}
if (opts.waiting !== undefined) {
if (opts.waiting === 0) cy.contains(/Waiting list/).should("not.exist");
else cy.contains(new RegExp(`Waiting list\\s*\\(${opts.waiting}\\)`)).should("exist");
}
if (opts.expired !== undefined) {
if (opts.expired === 0) cy.contains(/Expired\s*\(/).should("not.exist");
else cy.contains(new RegExp(`Expired\\s*\\(${opts.expired}\\)`)).should("exist");
}
if (opts.capacity) {
const max = opts.capacity.max ?? G1_WAGONS;
const suffix = opts.capacity.full ? " · FULL" : "";
cy.contains(`Capacity line · ${opts.capacity.used}/${max} wagons${suffix}`).should(
"exist",
);
}
}
/**
* Assert the schedule's capacity is the built consist, not the loco-derived
* 54. Worth asserting explicitly in every scenario's config phase: if a future
* change lets the length recompute win again, EVERY Group 1 expectation shifts
* by one slot and the exact-fit cases (S1, S2) would fail somewhere far from
* the cause.
*/
export function expectCapacity(departure: Date, wagons = G1_WAGONS) {
dbSchedule(departure).then(({ rows }) => {
expect(rows, "G1 schedule").to.have.length(1);
expect(rows[0].max_wagons, `consist capacity = ${wagons}`).to.eq(wagons);
});
}
// ---------------------------------------------------------------------------
// the verdict — every scenario ends naming its binding axis
// ---------------------------------------------------------------------------
/** Distinct wagon slots actually allocated to bookings on a schedule. */
export function allocatedWagons(scheduleId: string) {
return 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`,
[scheduleId],
).then(({ rows }) => Number(rows[0].n));
}
/**
* The scenario's closing verdict: how many of the train's slots ended up
* filled, and whether the engine agrees it is FULL.
*
* `booking_window_status` is the engine's own word (FULL / OPEN / CLOSED) —
* asserting the slot count alone would pass on a train that is physically full
* but which the window state machine never marked, which is exactly the bug
* class these scenarios exist to catch.
*/
export function expectVerdict(
departure: Date,
expected: { wagons: number; full: boolean; capacity?: number },
) {
const capacity = expected.capacity ?? G1_WAGONS;
dbSchedule(departure).then(({ rows }) => {
expect(rows, "G1 schedule").to.have.length(1);
const schedule = rows[0];
allocatedWagons(schedule.id).then((n) => {
expect(n, `${expected.wagons}/${capacity} wagons allocated`).to.eq(expected.wagons);
});
if (expected.full) {
pollDb<ScheduleRow>(
`window FULL (${expected.wagons}/${capacity})`,
`SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
[schedule.id],
(row) => row?.booking_window_status === "FULL",
20,
);
} else {
db<ScheduleRow>(
`SELECT booking_window_status FROM freight.train_schedules WHERE id = $1`,
[schedule.id],
).then(({ rows: after }) => {
expect(
after[0].booking_window_status,
`not FULL (${expected.wagons}/${capacity})`,
).to.not.eq("FULL");
});
}
});
}
// ---------------------------------------------------------------------------
// booking → clearance gate → operations queue
// ---------------------------------------------------------------------------
/**
* Book containers and walk the booking all the way to the operations pool.
*
* EVERY contract booking is now born in the clearance gate — see
* contract-booking.service.ts:211, "EVERY contract booking clears per booking
* now — both contract kinds, both paths, intercity included". A booking is
* created in AWAITING_DOCUMENTS regardless of whether customs clearance is
* enabled, so calling `acceptOperation` straight after `bookContainers` always
* 409s with:
*
* Cannot perform this action on status "AWAITING_DOCUMENTS".
* Allowed: OPERATION_REQUEST_PENDING
*
* The gate is: upload a document → GL approves it → finalize → the customer
* proceeds with the shipment day. `clearToOperationRequestPending` runs that
* whole chain (the e2e seed configures no required documents, so one ad-hoc
* doc satisfies the 100%-approved rule).
*
* Use this instead of bookContainers + acceptOperation anywhere a booking has
* to reach the day pool.
*/
export function bookAndClear(opts: {
suffix: string;
runStamp: string;
isoSeed: number;
twenty?: number;
forty?: number;
scheduledDate: string;
vgmTons?: number;
/** EXPORT reserves on accept (FCFS) rather than entering the batch pool. */
mode?: "import" | "export";
}) {
bookContainers({
suffix: opts.suffix,
runStamp: opts.runStamp,
isoSeed: opts.isoSeed,
twenty: opts.twenty,
forty: opts.forty,
scheduledDate: opts.scheduledDate,
vgmTons: opts.vgmTons,
});
clearToOperationRequestPending(opts.suffix, opts.scheduledDate);
if (opts.mode === "export") acceptExport(opts.suffix);
else acceptOperation(opts.suffix);
}
/**
* The clearance half alone, for a booking created some other way — e.g. the
* portal form (`bookContainersVisually`), which leaves the booking sitting in
* the same AWAITING_DOCUMENTS gate.
*/
export function clearAndAccept(opts: {
suffix: string;
scheduledDate: string;
mode?: "import" | "export";
}) {
clearToOperationRequestPending(opts.suffix, opts.scheduledDate);
if (opts.mode === "export") acceptExport(opts.suffix);
else acceptOperation(opts.suffix);
}
// ---------------------------------------------------------------------------
// booking through the real portal form
// ---------------------------------------------------------------------------
/**
* Book containers the way a customer actually does: the portal's New Shipment
* form, end to end. Reserved for the SMALL booking in each scenario — one
* container is one ISO input, so a 30-wagon booking would mean 30-60 of them.
*
* The three traps this navigates (all learned from export_one_time.cy.ts and
* the form source):
* 1. size editors render in CONTRACT-SCOPE order, not 20ft-then-40ft, so
* each is addressed by its "20ft containers" heading;
* 2. the shipment-day calendar does not render until the cargo quantities
* are valid — "available days depend on the wagons your cargo needs";
* 3. a blank cargo description aborts the submit SILENTLY (no modal, no
* toast, no request) — cy.fillCargoDescription covers it.
*
* Leaves the browser on /bookings/:id, the page the form redirects to.
*/
export function bookContainersVisually(opts: {
contractId: string;
twenty?: number;
forty?: number;
/** Day to pick in the inline calendar — must be a bookable (enabled) day. */
shipmentDay: Date;
/** Distinct ISO prefixes keep container numbers unique across scenarios. */
isoPrefix?: string;
/**
* Per-run stamp, same one the spec passes to the API path. Without it every
* run typed the identical SEXU1000000… block and the second run against a
* warm DB was rejected — the container number is already booked.
*/
runStamp?: string;
vgmTons?: number;
}) {
const twenty = opts.twenty ?? 0;
const forty = opts.forty ?? 0;
const total = twenty + forty;
expect(total, "at least one container").to.be.greaterThan(0);
// The form blocks an odd 20ft count (a lone 20ft cannot be paired onto a
// wagon) — "Review price & book" would stay disabled and the spec would
// fail on a timeout rather than on this, the real reason.
expect(twenty % 2, "20ft quantity must be even").to.eq(0);
cy.visitPortal(`/contracts/${opts.contractId}/bookings/new`);
cy.contains("New Shipment Booking", { timeout: 120000 }).should("be.visible");
// BOTH size cards must be given a quantity, including the unused one.
//
// The form renders a ContainerLineEditor per size in the contract's cargo
// scope, and an untouched editor keeps one blank unit row. The zod schema
// requires a valid ISO number AND a VGM on EVERY unit row
// (new-shipment-form/schema.ts:39-50), so that blank row fails validation and
// handleSubmit aborts SILENTLY — no modal, no toast, no request. Typing 0
// truncates the card's units to none (syncUnits: `next.length = max(0, qty)`)
// and takes it out of validation.
fillSizeQuantity("20ft", String(twenty));
fillSizeQuantity("40ft", String(forty));
// One ISO row per container, then the VGM on each.
//
// Scoped PER SIZE CARD, not globally: the form renders a ContainerLineEditor
// for every size in the contract's cargo scope, and an editor left at
// quantity 0 still renders one blank unit row. A global
// `input[placeholder*="MSCU"]` therefore counts the other card's row too —
// "Found 7, expected 6" — and the numbers land in the wrong card.
const prefix = opts.isoPrefix ?? "MSCU";
// 7 digits: 5 of run stamp + 2 of unit index. Keeps every run's block
// distinct while staying inside the ISO field width (max 99 units/booking).
const runBlock = Number((opts.runStamp ?? String(Date.now())).slice(-5));
let unit = 0;
const fillUnits = (size: "20ft" | "40ft", count: number) => {
if (!count) return;
cy.contains(`${size} containers`, { timeout: 120000 })
.closest("div.rounded-xl")
.within(() => {
cy.get('input[placeholder*="MSCU"]', { timeout: 120000 }).should(
"have.length",
count,
);
for (let i = 0; i < count; i += 1) {
const iso = `${prefix}${String(runBlock).padStart(5, "0")}${String(unit + i).padStart(2, "0")}`;
cy.get('input[placeholder*="MSCU"]')
.eq(i)
.clear({ force: true })
.type(iso, { force: true });
}
cy.get('input[placeholder*="24.5"]').each(($input) => {
cy.wrap($input)
.clear({ force: true })
.type(String(opts.vgmTons ?? 10), { force: true });
});
})
.then(() => {
unit += count;
});
};
fillUnits("20ft", twenty);
fillUnits("40ft", forty);
pickShipmentDay(opts.shipmentDay);
cy.fillCargoDescription();
cy.contains("button", "Review price & book").should("not.be.disabled").click();
cy.contains("Confirm shipment price", { timeout: 120000 }).should("be.visible");
cy.contains("button", "Confirm & book").click();
cy.location("pathname", { timeout: 120000 }).should("match", /^\/bookings\/.+/);
}
/**
* One container-size line's quantity, addressed by its heading rather than by
* position — the cards render in CONTRACT-SCOPE order, not 20ft-then-40ft.
*
* A no-op when the contract does not scope this size, so callers can always
* set both (see the note about blank unit rows in bookContainersVisually).
*/
function fillSizeQuantity(size: "20ft" | "40ft", value: string) {
cy.get("body").then(($body) => {
if (!$body.text().includes(`${size} containers`)) return;
cy.contains(`${size} containers`, { timeout: 120000 })
.closest("div.rounded-xl")
.find('input[type="number"]')
.first()
.clear({ force: true })
.type(value, { force: true });
});
}
/** Pick a day on the Schedule card's inline, cargo-aware calendar. */
function pickShipmentDay(day: Date) {
cy.contains(/available day/, { timeout: 120000 }).should("exist");
// Day cells are plain buttons in a grid; only bookable days are enabled
// (out-of-month duplicates and unscheduled days stay disabled).
const eatDay = new Date(day.getTime() + 3 * 3_600_000).getUTCDate();
cy.get("button:not(:disabled)", { timeout: 120000 })
.contains(new RegExp(`^${eatDay}$`))
.click({ force: true });
}
// ---------------------------------------------------------------------------
// visual assertions on the backoffice schedule board
// ---------------------------------------------------------------------------
/**
* Open the schedule's detail page as operations staff. Every scenario does
* this at least twice — once after configuring the train (to SEE the empty
* 53-slot consist) and once at the end (to SEE the verdict).
*/
export function visitSchedule(departure: Date) {
cy.loginBackoffice(opsStaff);
dbSchedule(departure).then(({ rows }) => {
expect(rows, "G1 schedule").to.have.length(1);
cy.visit(`/dashboard/operations/train-scheduling-v2/${rows[0].id}`);
});
}
/**
* Every container on the train mapped to a wagon slot, with a real container
* number on it.
*
* Wagon-slot counts alone cannot catch a half-done allocation: a booking whose
* wagons were reserved but whose units were never placed still reads as a full
* train on the board. The units live in `wagon_allocation_container_items`
* (one row per container, `position_on_wagon` + `container_number`), hanging
* off `wagon_booking_allocations`.
*/
export function expectContainersPlaced(scheduleId: string, containers: number) {
pollDb<{ n: string }>(
`${containers} containers mapped to wagon slots`,
`SELECT count(*) AS n
FROM freight.wagon_allocation_container_items ci
JOIN freight.wagon_booking_allocations wba
ON wba.id = ci.wagon_booking_allocation_id
JOIN freight.train_schedule_bookings tsb
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
WHERE ci.deleted_at IS NULL AND wba.deleted_at IS NULL
AND tsb.deleted_at IS NULL`,
[scheduleId],
(row) => Number(row?.n ?? 0) === containers,
25,
);
// A placed unit with no number would be an empty slot wearing a container's
// name — the marshalling sheet is generated from exactly this column.
db<{ n: string }>(
`SELECT count(*) AS n
FROM freight.wagon_allocation_container_items ci
JOIN freight.wagon_booking_allocations wba
ON wba.id = ci.wagon_booking_allocation_id
JOIN freight.train_schedule_bookings tsb
ON tsb.booking_id = wba.booking_id AND tsb.train_schedule_id = $1
WHERE ci.deleted_at IS NULL AND wba.deleted_at IS NULL
AND tsb.deleted_at IS NULL
AND (ci.container_number IS NULL OR ci.container_number = '')`,
[scheduleId],
).then(({ rows }) =>
expect(Number(rows[0].n), "no slot left without a container number").to.eq(0),
);
}
// ---------------------------------------------------------------------------
// split offers — S4, S6, S7, S8
// ---------------------------------------------------------------------------
/**
* Poll until the batch has raised a partial (split) offer on a booking.
* The engine offers rather than reserves when a booking cannot fit whole but
* some room remains — sizePartialOfferWagons budgets that room against the
* BASE caps only, never the locomotive's overage tolerance (see S11).
*/
export function expectSplitOffer(suffix: string) {
withBooking(suffix, (b) => {
pollDb<{ status: string }>(
`${suffix} open partial offer`,
`SELECT status FROM freight.booking_batch_offers
WHERE booking_id = $1 AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1`,
[b.id],
(row) => row?.status === "OFFERED",
15,
);
});
}
/** Assert NO split offer was raised — the whole-or-nothing cases. */
export function expectNoSplitOffer(suffix: string) {
withBooking(suffix, (b) => {
db<{ n: string }>(
`SELECT count(*) AS n FROM freight.booking_batch_offers
WHERE booking_id = $1 AND deleted_at IS NULL`,
[b.id],
).then(({ rows }) => expect(Number(rows[0].n), `${suffix} has no split offer`).to.eq(0));
});
}
/**
* Let a split offer lapse rather than paying it (S6). The offer expires with
* the booking's pay deadline, so pushing the deadline into the past and
* letting the 10s tick run is the same thing the wall clock would do.
*/
export function forceOfferLapse(suffix: string) {
withBooking(suffix, (b) =>
db(
`UPDATE freight.bookings SET payment_deadline = now() - interval '1 second'
WHERE id = $1`,
[b.id],
),
);
}
// ---------------------------------------------------------------------------
// waiting list — S1, S5, S24
// ---------------------------------------------------------------------------
/**
* A booking that lost the batch sits at FULLY_EXECUTED with no schedule — it
* is on the day's waiting list, not rejected. Promotion happens when capacity
* frees up (fillFromWaitingList loops up to 10 rounds, so one expiry can
* cascade into several promotions — see S5).
*/
export function expectWaitlisted(suffix: string) {
withBooking(suffix, (b) => {
expect(b.status, `${suffix} waitlisted`).to.eq("FULLY_EXECUTED");
expect(b.train_schedule_id, `${suffix} holds no seat`).to.be.null;
});
}
/** Poll until a waitlisted booking has been promoted into a pay window. */
export function expectPromoted(suffix: string) {
pollDb<{ status: string; payment_deadline: string | null }>(
`${suffix} promoted from the waiting list`,
`SELECT b.status, b.payment_deadline FROM freight.bookings b
JOIN freight.contracts ct ON ct.id = b.contract_id
WHERE ct.reference LIKE 'CTR-IMP-%-' || $1 AND b.deleted_at IS NULL
ORDER BY b.created_at DESC LIMIT 1`,
[suffix],
(row) =>
!!row &&
["SELECTED_FOR_BATCH", "AWAITING_PAYMENT"].includes(row.status) &&
row.payment_deadline !== null,
30,
);
}
// ---------------------------------------------------------------------------
// recoverability — S1's tail
// ---------------------------------------------------------------------------
/**
* An EXPIRED booking is recoverable without re-approval: its contract is still
* FULLY_EXECUTED, so the customer can book again onto a later day. Asserting
* the CONTRACT state (not just the booking's) is the point — a bug that also
* retired the contract would strand the customer.
*/
export function expectRecoverable(suffix: string) {
withBooking(suffix, (b) => {
expect(b.status, `${suffix} expired`).to.eq("EXPIRED");
db<{ status: string }>(`SELECT status FROM freight.contracts WHERE id = $1`, [
b.contract_id,
]).then(({ rows }) =>
expect(rows[0].status, `${suffix} contract still bookable`).to.be.oneOf([
"FULLY_EXECUTED",
"CONTRACT_ACTIVE",
]),
);
});
}