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