Files
edr-platform/e2e/freight/cypress/e2e/flows/booking_cancel.cy.ts
2026-07-31 10:33:27 +00:00

138 lines
4.7 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.

/**
* BOOKING — cancel lifecycle + the commit gate:
*
* `POST /bookings/:id/cancel` is only legal for a booking that has not yet
* been committed to a train (DRAFT … OPERATION_REQUEST_PENDING). This spec
* proves both sides end to end, which the unit tests never do:
*
* an un-accepted booking (OPERATION_REQUEST_PENDING) cancels → CANCELLED,
* and its open payable invoice is expired (no dangling payable)
* cancelling it again is rejected (status CANCELLED not allowed)
* a PAID + allocated booking CANNOT be cancelled — the guard blocks it.
* (There is deliberately no cancel-after-allocation / refund path; a
* committed booking leaves only by EXPIRE, never customer cancel.)
*
* EXPORT bulk corridor (FCFS: accept reserves immediately, mark-paid allocates).
* Retries off — sequential steps of one journey.
*/
import {
acceptExport,
apiPost,
bookBulk,
clearToOperationRequestPending,
createImportSchedule,
db,
departureAt,
eatDayStr,
ensureExportRoute,
EXP_DEST,
EXP_ORIGIN,
forceWindowOpen,
markPaid,
pollAllocations,
resetCorridorDay,
seedImportContract,
superAdmin,
withBooking,
withExportSchedule,
} from "./import-utils";
const DEPARTURE = departureAt(6);
const BOOKING_DAY = eatDayStr(DEPARTURE);
const stamp = String(Date.now());
function seedBulkExport(suffix: string) {
seedImportContract({
suffix,
reference: `CTR-IMP-${stamp}-${suffix}`,
freight: "BULK",
direction: "EXPORT",
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
}
/** Fire cancel with a reason; caller decides whether to fail on non-2xx. */
function cancel(bookingId: string, failOnStatusCode = true) {
return apiPost(
superAdmin,
`/api/bookings/${bookingId}/cancel`,
{ reason: `E2E cancel ${stamp}` },
failOnStatusCode,
);
}
describe("booking cancel: pre-commit only, blocked once allocated", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
["CXA", "CXB"].forEach(seedBulkExport);
});
it("operations opens the export corridor + a D+6 CW4 train", () => {
ensureExportRoute();
resetCorridorDay(DEPARTURE, EXP_DEST, EXP_ORIGIN);
createImportSchedule({
departure: DEPARTURE,
locoPair: ["LOCO-EXP-1", "LOCO-EXP-2"],
kind: "bulk",
originCode: EXP_ORIGIN,
destCode: EXP_DEST,
});
withExportSchedule(DEPARTURE, (s) => forceWindowOpen(s.id, 60));
});
it("an un-accepted booking cancels, its invoice is expired, and re-cancel is rejected", () => {
bookBulk({ suffix: "CXA", tons: 700, scheduledDate: BOOKING_DAY });
clearToOperationRequestPending("CXA", BOOKING_DAY);
// Cleared non-customs export booking sits at OPERATION_REQUEST_PENDING.
withBooking("CXA", (b) => {
expect(b.status, "pre-accept status").to.eq("OPERATION_REQUEST_PENDING");
cancel(b.id).then((res) => expect(res.status, "cancelled").to.be.oneOf([200, 201]));
});
withBooking("CXA", (b) => {
expect(b.status, "now CANCELLED").to.eq("CANCELLED");
// No open payable invoice may survive a cancel.
db<{ n: string }>(
`SELECT count(*) AS n FROM freight.invoices
WHERE source = 'booking' AND source_id = $1
AND status NOT IN ('EXPIRED','CANCELLED','PAID')
AND deleted_at IS NULL`,
[b.id],
).then(({ rows }) => expect(Number(rows[0].n), "no open invoice left").to.eq(0));
// Cancelling a CANCELLED booking is rejected by the status guard.
cancel(b.id, false).then((res) => {
expect(res.status, "re-cancel rejected").to.be.within(400, 422);
expect(JSON.stringify(res.body)).to.include("Cannot perform this action");
expect(JSON.stringify(res.body)).to.include("CANCELLED");
});
});
});
it("a PAID + allocated booking cannot be cancelled — the commit gate blocks it", () => {
bookBulk({ suffix: "CXB", tons: 700, scheduledDate: BOOKING_DAY });
clearToOperationRequestPending("CXB", BOOKING_DAY);
acceptExport("CXB");
markPaid("CXB");
pollAllocations("CXB", 10); // 700T / 70T = 10 CW4 wagons
withBooking("CXB", (b) => {
expect(b.status, "committed + paid").to.eq("PAID");
cancel(b.id, false).then((res) => {
expect(res.status, "cancel blocked after allocation").to.be.within(400, 422);
expect(JSON.stringify(res.body)).to.include("Cannot perform this action");
expect(JSON.stringify(res.body)).to.include("PAID");
});
});
// The block is real: the booking still rides, wagons still allocated.
withBooking("CXB", (b) => expect(b.status, "still PAID").to.eq("PAID"));
pollAllocations("CXB", 10);
});
});
export {};