mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 15:18:11 +00:00
138 lines
4.7 KiB
TypeScript
138 lines
4.7 KiB
TypeScript
/**
|
||
* 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 {};
|