Files
edr-platform/e2e/freight/cypress/e2e/flows/intercity_one_time.cy.ts
2026-07-24 13:26:19 +00:00

573 lines
23 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.

/**
* 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 {};