mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 12:41:04 +00:00
573 lines
23 KiB
TypeScript
573 lines
23 KiB
TypeScript
/**
|
||
* Intercity ONE_TIME journey — the full life of a domestic ride-along shipment:
|
||
*
|
||
* 1. portal — customer creates an INTERCITY / One-Time / Container contract
|
||
* (Mojo Dry Port → Dire Dawa Yard) and submits it
|
||
* 2. backoffice — marketer REJECTS it with a reason
|
||
* 3. portal — customer sees the rejection banner + reason, then submits a
|
||
* fresh contract
|
||
* 4. backoffice — marketer accepts + approves LINE_STAFF, director approves
|
||
* → CONTRACT_READY
|
||
* 5. portal — customer OTP-signs → SIGNED_CUSTOMER
|
||
* 6. backoffice — marketer counter-signs → AWAITING_CLEARANCE_DOCUMENTS
|
||
* (ONE_TIME intercity always routes through the
|
||
* intercity-documents step; the fixture seeds one REQUIRED
|
||
* document so the step is real)
|
||
* 6b. portal — customer uploads the required intercity document
|
||
* → CLEARANCE_UNDER_REVIEW
|
||
* 7. backoffice — operations approves the document, then finalizes document
|
||
* approval → FULLY_EXECUTED
|
||
* 8. portal — customer books 2 × 20ft under the contract (intercity has
|
||
* no shipment date) → booking OPERATION_REQUEST_PENDING
|
||
* 9. backoffice — operations accepts the operation request → booking
|
||
* FULLY_EXECUTED (intercity waiting pool)
|
||
* 10. backoffice — operations creates the EXPORT route
|
||
* Mojo → Dire Dawa → Djibouti Port (distances seeded)
|
||
* 11. backoffice — operations schedules the export train (built Train-Builder
|
||
* train seeded by seed-intercity.sql)
|
||
* 12. backoffice — operations accepts the intercity booking onto the train
|
||
* (Workspace → Intercity ride-along) → SELECTED_FOR_BATCH with
|
||
* a pay deadline that never outlives the export window close
|
||
* 13. staff mark-paid (API — the batch panel has no mounted UI button)
|
||
* → PAID + SCHEDULED + linked to the schedule
|
||
*
|
||
* Sequential steps of one journey — retries off (steps are not idempotent).
|
||
*/
|
||
|
||
const customer = "user@gmail.com";
|
||
const companyTin = "0102030405"; // seed-company.sql
|
||
const opsStaff = "operation@edr.local";
|
||
|
||
const ORIGIN_YARD = "Mojo Dry Port";
|
||
const DEST_YARD = "Dire Dawa Yard";
|
||
const PORT_YARD = "Djibouti Port Terminal";
|
||
const TRAIN_CODE = "TRN-E2E-1";
|
||
|
||
const apiUrl = () => Cypress.env("apiUrl") as string;
|
||
|
||
/** Latest contract of the seeded company — the journey's contract. */
|
||
function dbContract() {
|
||
return cy.task<{ rows: Array<{ id: string; reference: string; status: string }> }>(
|
||
"db:query",
|
||
{
|
||
sql: `SELECT ct.id, ct.reference, ct.status
|
||
FROM freight.contracts ct
|
||
JOIN freight.companies c ON c.id = ct.company_id
|
||
WHERE c.tin = $1 AND ct.trade_direction = 'DOMESTIC'
|
||
ORDER BY ct.created_at DESC LIMIT 1`,
|
||
params: [companyTin],
|
||
},
|
||
);
|
||
}
|
||
|
||
function withContract(fn: (c: { id: string; reference: string; status: string }) => void) {
|
||
dbContract().then(({ rows }) => {
|
||
expect(rows, "latest contract for the seeded company").to.have.length(1);
|
||
fn(rows[0]);
|
||
});
|
||
}
|
||
|
||
function expectContractStatus(expected: string) {
|
||
dbContract().then(({ rows }) => {
|
||
expect(rows[0]?.status, "contract status").to.eq(expected);
|
||
});
|
||
}
|
||
|
||
/** Latest DOMESTIC booking of the seeded company — the journey's booking. */
|
||
function dbBooking() {
|
||
return cy.task<{
|
||
rows: Array<{
|
||
id: string;
|
||
reference: string;
|
||
status: string;
|
||
scheduling_status: string;
|
||
train_schedule_id: string | null;
|
||
payment_deadline: string | null;
|
||
scheduled_date: string | null;
|
||
}>;
|
||
}>("db:query", {
|
||
sql: `SELECT b.id, b.reference, b.status, b.scheduling_status,
|
||
b.train_schedule_id, b.payment_deadline, b.scheduled_date
|
||
FROM freight.bookings b
|
||
JOIN freight.companies c ON c.id = b.company_id
|
||
WHERE c.tin = $1 AND b.trade_direction = 'DOMESTIC'
|
||
ORDER BY b.created_at DESC LIMIT 1`,
|
||
params: [companyTin],
|
||
});
|
||
}
|
||
|
||
function withBooking(
|
||
fn: (b: {
|
||
id: string;
|
||
reference: string;
|
||
status: string;
|
||
scheduling_status: string;
|
||
train_schedule_id: string | null;
|
||
payment_deadline: string | null;
|
||
scheduled_date: string | null;
|
||
}) => void,
|
||
) {
|
||
dbBooking().then(({ rows }) => {
|
||
expect(rows, "intercity booking for the seeded company").to.have.length(1);
|
||
fn(rows[0]);
|
||
});
|
||
}
|
||
|
||
/** Latest export schedule created by this journey. */
|
||
function dbSchedule() {
|
||
return cy.task<{
|
||
rows: Array<{ id: string; status: string; direction: string; window_closes_at: string }>;
|
||
}>("db:query", {
|
||
sql: `SELECT ts.id, ts.status, ts.direction, ts.window_closes_at
|
||
FROM freight.train_schedules ts
|
||
ORDER BY ts.created_at DESC LIMIT 1`,
|
||
});
|
||
}
|
||
|
||
/** Fill a labelled Mantine input (label[for] → input id). */
|
||
function fill(label: string | RegExp, value: string) {
|
||
cy.contains("label", label)
|
||
.invoke("attr", "for")
|
||
.then((id) => {
|
||
cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true });
|
||
});
|
||
}
|
||
|
||
/**
|
||
* Run the portal wizard for an INTERCITY / One-Time / Container contract and
|
||
* submit it. Reused for the initial (to-be-rejected) and the second contract.
|
||
*/
|
||
function createIntercityContract() {
|
||
cy.loginPortal(customer);
|
||
cy.visitPortal("/contracts/new");
|
||
|
||
// Step 0 — Setup. Intercity forces ETB and hides the customs section.
|
||
cy.mantineSelect(/^Operation Type/, /^Intercity$/);
|
||
cy.mantineSelect(/^Contract Kind/, "One-Time Contract");
|
||
cy.mantineSelect(/^New or Renewal/, "New Contract");
|
||
cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click();
|
||
cy.mantineSelect(/^Payment Currency/, /^ETB/);
|
||
cy.contains("button", "Continue").click({ force: true });
|
||
|
||
// Step 1 — Cargo & Route (Ethiopian yards only for intercity). Container
|
||
// contracts auto-cover both 20ft & 40ft (no size picker) and the cargo
|
||
// description moved to booking time — the scope select is enough here.
|
||
cy.mantineSelect(/^Cargo Scope/, /Containerized/);
|
||
cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD);
|
||
cy.mantineSelect(/^Destination Yard/, DEST_YARD);
|
||
cy.contains("button", "Continue").click({ force: true });
|
||
|
||
// Step 2 — Review & Submit → quotation modal.
|
||
cy.contains("button", "Submit").click({ force: true });
|
||
cy.contains("Approve your quotation", { timeout: 30000 }).should("be.visible");
|
||
cy.contains("button", "Approve & submit").click();
|
||
|
||
cy.location("pathname", { timeout: 20000 }).should("eq", "/contracts");
|
||
|
||
dbContract().then(({ rows }) => {
|
||
expect(rows, "contract row").to.have.length(1);
|
||
expect(rows[0].status).to.eq("SUBMITTED");
|
||
expect(rows[0].reference).to.match(/^CTR-/);
|
||
});
|
||
}
|
||
|
||
describe("intercity one-time journey: contract → booking → export train", { retries: 0 }, () => {
|
||
before(() => {
|
||
// Container types, locomotives, built train, yard distances — the
|
||
// infrastructure the UI journey cannot create in-flow.
|
||
cy.task("db:seedFile", "seed-intercity.sql");
|
||
});
|
||
|
||
// ── Contract: submit → reject → resubmit → approve → sign ────────────────
|
||
|
||
it("customer submits an intercity one-time contract", () => {
|
||
createIntercityContract();
|
||
});
|
||
|
||
it("marketer rejects the submission with a reason", () => {
|
||
cy.loginBackoffice("marketer@edr.local");
|
||
withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
|
||
|
||
cy.contains("button", "Reject contract", { timeout: 20000 }).click();
|
||
cy.get(".mantine-Modal-content")
|
||
.contains("label", "Reason for rejection")
|
||
.invoke("attr", "for")
|
||
.then((id) => {
|
||
cy.get(`[id="${id}"]`).type("E2E rejection — cargo details incomplete");
|
||
});
|
||
cy.get(".mantine-Modal-content").contains("button", /^Reject$/).click();
|
||
|
||
// Modal closes on success; the status pill can sit inside clipped layout,
|
||
// so the authoritative check is the DB row.
|
||
cy.get(".mantine-Modal-content", { timeout: 20000 }).should("not.exist");
|
||
expectContractStatus("REJECTED");
|
||
});
|
||
|
||
it("customer sees the rejection reason on the contracts list", () => {
|
||
cy.loginPortal(customer);
|
||
cy.visitPortal("/contracts");
|
||
|
||
// The list is a collapsed table — expand the rejected contract's row to
|
||
// reveal its step banner with the staff reason.
|
||
withContract((c) => {
|
||
cy.contains("tr", c.reference, { timeout: 20000 })
|
||
.find("button")
|
||
.first()
|
||
.click();
|
||
});
|
||
cy.contains("This contract was rejected.", { timeout: 20000 }).should("be.visible");
|
||
cy.contains("Reason: E2E rejection — cargo details incomplete").should("be.visible");
|
||
});
|
||
|
||
it("customer submits a fresh intercity contract", () => {
|
||
createIntercityContract();
|
||
});
|
||
|
||
it("marketer accepts the submission and approves the LINE_STAFF step", () => {
|
||
cy.loginBackoffice("marketer@edr.local");
|
||
withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
|
||
|
||
cy.contains("button", "Accept for approval", { timeout: 20000 }).click();
|
||
cy.contains("button", "Accept & start approval", { timeout: 20000 })
|
||
.should("not.be.disabled")
|
||
.click();
|
||
|
||
cy.contains("Approval chain", { timeout: 20000 }).should("be.visible");
|
||
cy.contains("button", "Approve", { timeout: 20000 }).click();
|
||
cy.contains("button", "Confirm approval").click();
|
||
cy.contains("1/2", { timeout: 20000 }).should("be.visible");
|
||
|
||
expectContractStatus("PENDING_APPROVAL");
|
||
});
|
||
|
||
it("director approves the final step — contract PDF becomes ready", () => {
|
||
cy.loginBackoffice("director@edr.local");
|
||
withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
|
||
|
||
cy.contains("button", "Approve", { timeout: 20000 }).click();
|
||
cy.contains("button", "Confirm approval").click();
|
||
|
||
cy.contains("button", "View & sign", { timeout: 30000 }).should("exist");
|
||
expectContractStatus("CONTRACT_READY");
|
||
});
|
||
|
||
it("customer signs the contract with OTP", () => {
|
||
cy.loginPortal(customer);
|
||
withContract((c) => cy.visitPortal(`/contracts/${c.id}/view`));
|
||
|
||
// Scroll the contract iframe to the bottom so the consent bar unlocks.
|
||
const unlockConsent = (attempt: number) => {
|
||
cy.get('iframe[title="Contract document"]', { timeout: 30000 }).then(($f) => {
|
||
const win = ($f[0] as HTMLIFrameElement).contentWindow;
|
||
const el = win?.document?.scrollingElement ?? win?.document?.documentElement;
|
||
if (win && el) {
|
||
el.scrollTop = el.scrollHeight;
|
||
win.dispatchEvent(new Event("scroll"));
|
||
}
|
||
});
|
||
cy.wait(500).then(() => {
|
||
cy.get("body").then(($b) => {
|
||
if ($b.text().includes("I have read the entire contract")) return;
|
||
expect(attempt, "consent bar unlocked").to.be.lessThan(20);
|
||
unlockConsent(attempt + 1);
|
||
});
|
||
});
|
||
};
|
||
unlockConsent(0);
|
||
|
||
cy.contains("I have read the entire contract", { timeout: 15000 }).click();
|
||
cy.contains("button", /^Sign contract$|^Approve & sign$/).click();
|
||
|
||
cy.contains("label", "Full name")
|
||
.invoke("attr", "for")
|
||
.then((id) => {
|
||
cy.get(`[id="${id}"]`).clear().type("Demo User");
|
||
});
|
||
cy.drawSignature();
|
||
cy.contains("button", "Continue to verification").click();
|
||
|
||
cy.contains("Verify it's you", { timeout: 20000 }).should("be.visible");
|
||
cy.getOtp(customer).then((otp) => cy.typeOtp(otp));
|
||
cy.contains("button", "Verify & sign").click();
|
||
|
||
cy.contains("Your signature has been recorded", { timeout: 30000 }).should("be.visible");
|
||
expectContractStatus("SIGNED_CUSTOMER");
|
||
});
|
||
|
||
it("marketer counter-signs — intercity one-time enters the documents step", () => {
|
||
cy.loginBackoffice("marketer@edr.local");
|
||
withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}/view`));
|
||
|
||
cy.contains("button", /^Sign as staff$|^Approve & sign$/, { timeout: 30000 }).click();
|
||
cy.contains("label", "Full name")
|
||
.invoke("attr", "for")
|
||
.then((id) => {
|
||
cy.get(`[id="${id}"]`).clear().type("EDR Marketer");
|
||
});
|
||
cy.drawSignature();
|
||
cy.get(".mantine-Modal-content")
|
||
.contains("button", /^Confirm signature$|^Approve & sign$/)
|
||
.click();
|
||
|
||
cy.contains("counter-signed", { timeout: 30000 }).should("be.visible");
|
||
|
||
// DOMESTIC one-time always routes through the intercity-documents step —
|
||
// unlike GENERAL, it does NOT go straight to CONTRACT_ACTIVE.
|
||
expectContractStatus("AWAITING_CLEARANCE_DOCUMENTS");
|
||
});
|
||
|
||
it("customer uploads the required intercity document", () => {
|
||
cy.loginPortal(customer);
|
||
// Deep link auto-opens the clearance documents modal.
|
||
withContract((c) => cy.visitPortal(`/contracts/${c.id}?action=clearance`));
|
||
|
||
cy.contains("Cargo Manifest", { timeout: 30000 }).should("exist");
|
||
cy.get('.mantine-Modal-content input[type="file"]')
|
||
.first()
|
||
.selectFile("cypress/fixtures/docs/license.pdf", { force: true });
|
||
cy.contains("button", "Submit documents", { timeout: 15000 })
|
||
.should("not.be.disabled")
|
||
.click();
|
||
|
||
// Upload hands the contract to Operations review — the card flips to
|
||
// "Under review" (the host modal may linger while queries refetch).
|
||
cy.contains("Under review", { timeout: 30000 }).should("exist");
|
||
expectContractStatus("CLEARANCE_UNDER_REVIEW");
|
||
});
|
||
|
||
it("operations approves the document and finalizes — contract fully executed", () => {
|
||
cy.loginBackoffice(opsStaff);
|
||
withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
|
||
|
||
// The clearance review section lives behind its own tab on the detail page.
|
||
cy.contains('[role="tab"]', "Clearance Review", { timeout: 30000 }).click();
|
||
|
||
// Approve the uploaded Cargo Manifest, then finalize.
|
||
cy.contains("button", /Approve all/, { timeout: 30000 }).click();
|
||
cy.contains("1/1 approved", { timeout: 30000 }).should("exist");
|
||
|
||
cy.contains("button", "Finalize document approval", { timeout: 30000 })
|
||
.should("not.be.disabled")
|
||
.click();
|
||
|
||
cy.contains("finalized", { timeout: 30000 }).should("be.visible");
|
||
expectContractStatus("FULLY_EXECUTED");
|
||
});
|
||
|
||
// ── Booking under the contract ────────────────────────────────────────────
|
||
|
||
it("customer books 2 × 20ft under the contract (no shipment date for intercity)", () => {
|
||
cy.loginPortal(customer);
|
||
withContract((c) => cy.visitPortal(`/contracts/${c.id}/bookings/new`));
|
||
|
||
cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible");
|
||
|
||
// 20ft quantities must be even (pairs share a wagon).
|
||
fill(/^Quantity/, "2");
|
||
|
||
// One ISO container number per unit.
|
||
cy.get('input[placeholder*="MSCU"]', { timeout: 15000 }).should("have.length", 2);
|
||
cy.get('input[placeholder*="MSCU"]').eq(0).type("MSCU1234567");
|
||
cy.get('input[placeholder*="MSCU"]').eq(1).type("TCLU7654321");
|
||
|
||
// VGM per unit — column inputs carry a placeholder, not a linked label.
|
||
cy.get('input[placeholder*="24.5"]').each(($input) => {
|
||
cy.wrap($input).clear({ force: true }).type("10", { force: true });
|
||
});
|
||
|
||
// Intercity: no "Shipment day" picker — the ride-along note renders instead.
|
||
cy.contains("Shipment day").should("not.exist");
|
||
|
||
cy.contains("button", "Review price & book").should("not.be.disabled").click();
|
||
cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible");
|
||
cy.contains("button", "Confirm & book").click();
|
||
|
||
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
|
||
|
||
withBooking((b) => {
|
||
expect(b.status).to.eq("OPERATION_REQUEST_PENDING");
|
||
expect(b.scheduled_date, "intercity bookings carry no scheduled date").to.eq(null);
|
||
});
|
||
});
|
||
|
||
it("operations accepts the operation request — booking joins the intercity pool", () => {
|
||
cy.loginBackoffice(opsStaff);
|
||
withBooking((b) => cy.visit(`/dashboard/booking-requests/${b.id}`));
|
||
|
||
cy.contains("button", /Accept operation|^Accept$/, { timeout: 20000 }).click();
|
||
cy.contains("Accept operation request?", { timeout: 15000 }).should("be.visible");
|
||
cy.get(".mantine-Modal-content").contains("button", /^Accept$/).click();
|
||
|
||
withBooking((b) => {
|
||
expect(b.status, "accepted intercity booking waits in the pool").to.eq("FULLY_EXECUTED");
|
||
expect(b.train_schedule_id).to.eq(null);
|
||
});
|
||
});
|
||
|
||
// ── Route + export schedule ───────────────────────────────────────────────
|
||
|
||
it("operations creates the export route Mojo → Dire Dawa → Djibouti Port", () => {
|
||
cy.loginBackoffice(opsStaff);
|
||
|
||
// Skip creation when a previous run already added the route (unique yards
|
||
// pair) — the journey stays re-runnable against a warm DB.
|
||
cy.task<{ rows: Array<{ n: string }> }>("db:query", {
|
||
sql: `SELECT count(*) AS n
|
||
FROM freight.routes r
|
||
JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO'
|
||
JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = 'DJIB_PORT'
|
||
WHERE r.deleted_at IS NULL`,
|
||
}).then(({ rows }) => {
|
||
if (Number(rows[0].n) > 0) return;
|
||
|
||
cy.visit("/dashboard/routes");
|
||
cy.contains("button", "Add route", { timeout: 20000 }).click();
|
||
cy.contains("Add Route", { timeout: 15000 }).should("be.visible");
|
||
|
||
// Third stop row, then fill Origin / Milestone / Destination in order.
|
||
cy.get(".mantine-Modal-content").contains("button", "Add milestone").click();
|
||
const pickYard = (index: number, yard: string) => {
|
||
cy.get('.mantine-Modal-content input[placeholder="Select yard"]')
|
||
.eq(index)
|
||
.click({ force: true });
|
||
// Three yard selects share option texts — only the open dropdown counts.
|
||
cy.get('[role="option"]:visible').contains(yard).click();
|
||
};
|
||
pickYard(0, ORIGIN_YARD);
|
||
pickYard(1, DEST_YARD);
|
||
pickYard(2, PORT_YARD);
|
||
|
||
// Distances (when the build has them) resolve from the seeded rows.
|
||
cy.get(".mantine-Modal-content").contains("button", "Save").click();
|
||
});
|
||
|
||
cy.task<{ rows: Array<{ direction: string }> }>("db:query", {
|
||
sql: `SELECT r.direction
|
||
FROM freight.routes r
|
||
JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO'
|
||
JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = 'DJIB_PORT'
|
||
WHERE r.deleted_at IS NULL`,
|
||
}).then(({ rows }) => {
|
||
expect(rows, "export route").to.have.length.at.least(1);
|
||
expect(rows[0].direction).to.eq("EXPORT");
|
||
});
|
||
});
|
||
|
||
it("operations schedules the export train from the built consist", () => {
|
||
cy.loginBackoffice(opsStaff);
|
||
|
||
// One departure per route per day — a warm DB from a previous run already
|
||
// has this train scheduled, so only create when none is live.
|
||
cy.task<{ rows: Array<{ n: string }> }>("db:query", {
|
||
sql: `SELECT count(*) AS n
|
||
FROM freight.train_schedules ts
|
||
JOIN freight.routes r ON r.id = ts.route_id
|
||
JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO'
|
||
WHERE ts.status IN ('DRAFT', 'SCHEDULED') AND ts.deleted_at IS NULL`,
|
||
}).then(({ rows }) => {
|
||
if (Number(rows[0].n) > 0) return;
|
||
|
||
cy.visit("/dashboard/operations/train-scheduling-v2");
|
||
cy.contains("button", "New schedule", { timeout: 20000 }).click();
|
||
cy.contains("Create train schedule", { timeout: 15000 }).should("be.visible");
|
||
|
||
cy.mantineSelect(/^Route$/, new RegExp(ORIGIN_YARD));
|
||
|
||
// Two days out, local datetime-local format.
|
||
const departure = new Date(Date.now() + 2 * 86400000);
|
||
const local = new Date(departure.getTime() - departure.getTimezoneOffset() * 60000)
|
||
.toISOString()
|
||
.slice(0, 16);
|
||
cy.get('.mantine-Modal-content input[type="datetime-local"]')
|
||
.clear({ force: true })
|
||
.type(local, { force: true });
|
||
|
||
cy.mantineSelect(/^Train$/, new RegExp(TRAIN_CODE));
|
||
cy.get(".mantine-Modal-content").contains("button", "Create").click();
|
||
|
||
// Create navigates straight to the new schedule's detail page.
|
||
cy.location("pathname", { timeout: 30000 }).should(
|
||
"match",
|
||
/\/dashboard\/operations\/train-scheduling-v2\/.+/,
|
||
);
|
||
});
|
||
|
||
dbSchedule().then(({ rows }) => {
|
||
expect(rows, "created schedule").to.have.length(1);
|
||
expect(rows[0].direction).to.eq("EXPORT");
|
||
});
|
||
});
|
||
|
||
// ── Intercity ride-along: accept → pay → allocated ────────────────────────
|
||
|
||
it("operations accepts the intercity booking onto the export train", () => {
|
||
cy.loginBackoffice(opsStaff);
|
||
dbSchedule().then(({ rows: schedules }) => {
|
||
cy.visit(`/dashboard/operations/train-scheduling-v2/${schedules[0].id}`);
|
||
});
|
||
|
||
cy.contains('[role="tab"]', "Workspace", { timeout: 30000 }).click();
|
||
// Presence, not viewport visibility — the panel can sit below the fold /
|
||
// inside clipped layout once earlier runs' rows stack up.
|
||
cy.contains("Intercity ride-along", { timeout: 30000 }).should("exist");
|
||
|
||
withBooking((b) => {
|
||
cy.contains("tr", b.reference, { timeout: 30000 })
|
||
.find('input[type="checkbox"]')
|
||
.check({ force: true });
|
||
cy.contains("button", /Accept .*onto this train/).click();
|
||
|
||
// Accepted table shows the pay-window state (inside a horizontal
|
||
// Table.ScrollContainer — assert presence, not viewport visibility).
|
||
cy.contains("Awaiting payment", { timeout: 30000 }).should("exist");
|
||
});
|
||
|
||
// Export parity: the ride-along's pay deadline never outlives the export
|
||
// booking window (reserve() clamps it to window_closes_at).
|
||
dbSchedule().then(({ rows: schedules }) => {
|
||
withBooking((b) => {
|
||
expect(b.status).to.eq("SELECTED_FOR_BATCH");
|
||
expect(b.train_schedule_id).to.eq(schedules[0].id);
|
||
expect(b.payment_deadline, "pay deadline set").to.be.a("string");
|
||
expect(new Date(b.payment_deadline!).getTime()).to.be.at.most(
|
||
new Date(schedules[0].window_closes_at).getTime(),
|
||
);
|
||
});
|
||
});
|
||
});
|
||
|
||
it("staff mark the ride-along paid — booking allocates onto the train", () => {
|
||
// ScheduleBatchPanel (the only "Mark paid" button) is not mounted in the
|
||
// current UI, so drive the staff override endpoint directly.
|
||
withBooking((b) => {
|
||
cy.apiLogin(opsStaff).then(({ token }) => {
|
||
cy.request({
|
||
method: "POST",
|
||
url: `${apiUrl()}/api/train-scheduling/bookings/${b.id}/mark-paid`,
|
||
headers: { Authorization: `Bearer ${token}` },
|
||
})
|
||
.its("status")
|
||
.should("be.oneOf", [200, 201]);
|
||
});
|
||
});
|
||
|
||
withBooking((b) => {
|
||
expect(b.status).to.eq("PAID");
|
||
expect(b.scheduling_status).to.eq("SCHEDULED");
|
||
expect(b.train_schedule_id, "still pinned to the export train").to.be.a("string");
|
||
|
||
// The schedule↔booking link row is what makes the booking visible on the
|
||
// train board, in yard work, and to the wagon planner.
|
||
cy.task<{ rows: Array<{ n: string }> }>("db:query", {
|
||
sql: `SELECT count(*) AS n FROM freight.train_schedule_bookings
|
||
WHERE booking_id = $1 AND deleted_at IS NULL`,
|
||
params: [b.id],
|
||
}).then(({ rows }) => {
|
||
expect(Number(rows[0].n), "train_schedule_bookings link").to.eq(1);
|
||
});
|
||
});
|
||
});
|
||
});
|
||
|
||
export {};
|