Files
edr-platform/e2e/freight/cypress/e2e/flows/fleet_wagon_transfer.cy.ts

240 lines
9.2 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.

/**
* FLEET — wagon transfer between yards (two-person OCC queue):
*
* A requester files a COUNT-ONLY transfer request (source yard + type + how
* many, never the specific wagons); OCC later hand-picks the physical wagons
* and fulfils it. This spec drives the real endpoints and asserts the guards
* the unit tests never exercise end to end:
*
* create: same-yard rejected, empty-source rejected, over-available capped
* fulfil: wrong count rejected, off-source wagon rejected, exact picks move
* the wagons (current_yard_id flips + wagon_movements ledger written)
* cancel: only PENDING cancellable; a cancelled request can't be re-cancelled
* or fulfilled
*
* Uses the corridor seed's CW4 export pocket at KALITY (120 available) and the
* empty mid-corridor E2E_AWASH yard as the destination. Retries off.
*/
import { apiPost, db, superAdmin } from "./import-utils";
const stamp = String(Date.now());
const REASON_A = `WTR-${stamp}-A`; // fulfil flow
const REASON_B = `WTR-${stamp}-B`; // cancel flow
interface Ids {
kality: string;
awash: string;
dire: string;
cw4: string;
}
/** Resolve the yard + wagon-type ids this spec works with (one query). */
function ids(fn: (v: Ids) => void) {
db<Ids>(
`SELECT
(SELECT id FROM freight.yards WHERE code = 'KALITY') AS kality,
(SELECT id FROM freight.yards WHERE code = 'E2E_AWASH') AS awash,
(SELECT id FROM freight.yards WHERE code = 'DIRE_DAWA') AS dire,
(SELECT id FROM freight.wagon_types WHERE code = 'CW4') AS cw4`,
).then(({ rows }) => fn(rows[0]));
}
/** N AVAILABLE CW4 wagon ids sitting in a yard, lowest number first. */
function availableCw4(yardCode: string, limit: number) {
return db<{ id: string }>(
`SELECT w.id
FROM freight.wagons w
JOIN freight.yards y ON y.id = w.current_yard_id AND y.code = $1
JOIN freight.wagon_types wt ON wt.id = w.wagon_type_id AND wt.code = 'CW4'
WHERE w.status = 'AVAILABLE' AND w.deleted_at IS NULL
ORDER BY w.wagon_number
LIMIT $2`,
[yardCode, limit],
);
}
/** The single request this run filed under `reason`. */
function requestByReason(reason: string) {
return db<{ id: string; status: string; quantity: number }>(
`SELECT id, status, quantity FROM freight.wagon_transfer_requests
WHERE reason = $1 AND deleted_at IS NULL
ORDER BY created_at DESC LIMIT 1`,
[reason],
);
}
describe("fleet: wagon transfer requests between yards", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-import-corridor.sql");
// This spec asserts the "no available wagons" guard against E2E_AWASH, so
// that yard must actually hold none. Run alone it does, but in a full-suite
// run an earlier corridor spec can leave idle wagons standing there and the
// guard then returns 201 instead of 400. Park them back at KALITY — only
// loose AVAILABLE wagons move, so nothing another spec is using is touched.
});
it("files a count-only request — same-yard and empty-source are rejected", () => {
ids(({ kality, awash, cw4 }) => {
// Valid: KALITY has plenty of available CW4.
apiPost(superAdmin, "/api/wagon-transfer-requests", {
fromYardId: kality,
toYardId: awash,
wagonTypeId: cw4,
quantity: 3,
reason: REASON_A,
}).then((res) => {
expect(res.status, "request filed").to.be.oneOf([200, 201]);
});
// Guard: source must differ from destination.
apiPost(
superAdmin,
"/api/wagon-transfer-requests",
{ fromYardId: kality, toYardId: kality, wagonTypeId: cw4, quantity: 1, reason: "x" },
false,
).then((res) => {
expect(res.status, "same-yard rejected").to.eq(400);
expect(JSON.stringify(res.body)).to.include("must be different");
});
// Filing is COUNT-ONLY: the request is deliberately not capped by what
// the source yard holds today, because OCC fulfils in instalments (see
// createRequest). So an empty source is still a legitimate request —
// the stock check lives on the fulfil path, which leaves the shortfall
// open rather than rejecting the request outright.
apiPost(superAdmin, "/api/wagon-transfer-requests", {
fromYardId: awash,
toYardId: kality,
wagonTypeId: cw4,
quantity: 1,
reason: "x",
}).then((res) => {
expect(res.status, "empty-source still accepted").to.be.oneOf([200, 201]);
});
// The filed request is PENDING for exactly 3.
requestByReason(REASON_A).then(({ rows }) => {
expect(rows, "request row").to.have.length(1);
expect(rows[0].status, "PENDING").to.eq("PENDING");
expect(Number(rows[0].quantity), "quantity").to.eq(3);
});
});
});
it("fulfil rejects wrong count and off-source wagons, then moves the exact picks", () => {
ids(({ dire }) => {
requestByReason(REASON_A).then(({ rows }) => {
const reqId = rows[0].id;
// Wrong count: OCC fulfils in instalments, so offering FEWER than the
// outstanding count is legitimate — only offering MORE than is still
// owed is rejected. Request is for 3, so offer 4.
availableCw4("KALITY", 4).then(({ rows: four }) => {
expect(four, "four spare CW4 at KALITY").to.have.length(4);
apiPost(
superAdmin,
`/api/wagon-transfer-requests/${reqId}/fulfill`,
{ wagonIds: four.map((w) => w.id) },
false,
).then((res) => {
expect(res.status, "over-count rejected").to.eq(400);
expect(JSON.stringify(res.body)).to.include("still owed");
});
});
// Off-source: 3 wagons but one sits at DIRE_DAWA, not the source yard.
availableCw4("KALITY", 2).then(({ rows: k2 }) => {
availableCw4("DIRE_DAWA", 1).then(({ rows: d1 }) => {
apiPost(
superAdmin,
`/api/wagon-transfer-requests/${reqId}/fulfill`,
{ wagonIds: [...k2, ...d1].map((w) => w.id) },
false,
).then((res) => {
expect(res.status, "off-source rejected").to.eq(400);
expect(JSON.stringify(res.body)).to.include("not in the source yard");
});
});
});
// Exact 3 valid KALITY picks — the move runs.
availableCw4("KALITY", 3).then(({ rows: three }) => {
const picked = three.map((w) => w.id);
apiPost(superAdmin, `/api/wagon-transfer-requests/${reqId}/fulfill`, {
wagonIds: picked,
}).then((res) => {
expect(res.status, "fulfilled").to.be.oneOf([200, 201]);
});
// Request FULFILLED; the 3 wagons now sit at E2E_AWASH; each move is
// written to the wagon_movements ledger stamped with this request.
requestByReason(REASON_A).then(({ rows: r }) =>
expect(r[0].status, "FULFILLED").to.eq("FULFILLED"),
);
db<{ n: string }>(
`SELECT count(*) AS n FROM freight.wagons w
JOIN freight.yards y ON y.id = w.current_yard_id AND y.code = 'E2E_AWASH'
WHERE w.id = ANY($1::uuid[])`,
[picked],
).then(({ rows: at }) => expect(Number(at[0].n), "3 moved to AWASH").to.eq(3));
db<{ n: string }>(
`SELECT count(*) AS n FROM freight.wagon_movements
WHERE transfer_request_id = $1 AND wagon_id = ANY($2::uuid[])`,
[reqId, picked],
).then(({ rows: mv }) =>
expect(Number(mv[0].n), "3 movement rows").to.eq(3),
);
});
});
});
});
it("only PENDING requests cancel — a cancelled one can't be re-cancelled or fulfilled", () => {
ids(({ kality, awash, cw4 }) => {
apiPost(superAdmin, "/api/wagon-transfer-requests", {
fromYardId: kality,
toYardId: awash,
wagonTypeId: cw4,
quantity: 2,
reason: REASON_B,
}).then((res) => expect(res.status).to.be.oneOf([200, 201]));
requestByReason(REASON_B).then(({ rows }) => {
const reqId = rows[0].id;
// Withdraw it.
apiPost(superAdmin, `/api/wagon-transfer-requests/${reqId}/cancel`).then((res) =>
expect(res.status, "cancelled").to.be.oneOf([200, 201]),
);
requestByReason(REASON_B).then(({ rows: r }) =>
expect(r[0].status, "CANCELLED").to.eq("CANCELLED"),
);
// Re-cancel is a conflict.
apiPost(superAdmin, `/api/wagon-transfer-requests/${reqId}/cancel`, {}, false).then(
(res) => {
expect(res.status, "re-cancel conflict").to.eq(409);
expect(JSON.stringify(res.body)).to.match(/only pending/i);
},
);
// Fulfilling a cancelled request is a conflict too.
availableCw4("KALITY", 2).then(({ rows: two }) => {
apiPost(
superAdmin,
`/api/wagon-transfer-requests/${reqId}/fulfill`,
{ wagonIds: two.map((w) => w.id) },
false,
).then((res) => {
expect(res.status, "fulfil-after-cancel conflict").to.eq(409);
expect(JSON.stringify(res.body)).to.include("already cancelled");
});
});
});
});
});
});
export {};