Files
edr-platform/e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts
2026-07-31 10:33:27 +00:00

246 lines
9.9 KiB
TypeScript

/**
* Contract creation → finalization, spanning portal + backoffice:
*
* 1. portal (user@gmail.com, company seeded active by seed-company.sql):
* wizard → GENERAL / Import / Container (both sizes) → submit + approve quote
* 2. backoffice marketer: "Accept for approval" (validity) + approve the
* LINE_STAFF step
* 3. backoffice director: approve the DIRECTOR step → PDF → CONTRACT_READY
* 4. portal customer: scroll contract, agree, draw signature, OTP-sign
* → SIGNED_CUSTOMER
* 5. backoffice marketer: counter-sign as staff → GENERAL contract goes
* CONTRACT_ACTIVE (per-booking clearance, no contract-level gate)
*
* Sequential steps of one journey — retries off (steps are not idempotent).
*/
const customer = "user@gmail.com";
const companyTin = "0102030405"; // seed-company.sql
/**
* The journey's contract = the seeded company's latest contract. Each test
* resolves it from the DB instead of sharing module state — tests stay
* independently runnable against the current DB state.
*/
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
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 expectStatus(expected: string) {
dbContract().then(({ rows }) => {
expect(rows[0]?.status, `contract status`).to.eq(expected);
});
}
describe("contract lifecycle: creation to finalization", { retries: 0 }, () => {
before(() => {
// Re-runnable against a warm DB. The API allows one active contract per
// customer + service type + route, so this spec's own contract from an
// earlier run 409s the new one at creation — and since staff accept it
// with a year's validity, it would keep doing so for a year. Retire just
// the shape this spec creates (its own wizard-made GENERAL imports),
// leaving the seeded corridor/segment fixtures alone.
cy.retireStaleContracts({
tin: companyTin,
kind: "GENERAL",
direction: "IMPORT",
});
});
it("customer creates and submits a GENERAL import container contract", () => {
cy.loginPortal(customer);
cy.visitPortal("/contracts/new");
// Step 0 — Setup.
cy.mantineSelect(/^Operation Type/, /^Import$/);
cy.mantineSelect(/^Contract Kind/, "General Contract");
cy.mantineSelect(/^New or Renewal/, "New Contract");
cy.contains("Rail Transport Only", { timeout: 15000 }).click();
// Contracts are always quoted in USD now — the billing currency is picked
// per shipment at booking time, not here.
cy.contains("button", "Continue").click({ force: true });
// Step 1 — Cargo & Route. Container contracts now auto-cover BOTH 20ft &
// 40ft (no size picker — just an info card) and the cargo description moved
// to booking time, so the scope select plus the route is all this step needs.
cy.mantineSelect(/^Cargo Scope/, /Containerized/);
cy.mantineSelect(/^Origin Yard/, "Djibouti Port Terminal");
cy.mantineSelect(/^Destination Yard/, "Mojo Dry Port");
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");
// `exist`, not `be.visible`: the status badge sits inside the list's
// horizontally-scrolling container, so Cypress reports it as clipped by an
// overflow parent. The DB assertion below is the authoritative check.
cy.contains("Submitted", { timeout: 15000 }).should("exist");
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-/);
});
});
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();
// The modal takes an explicit validity window and pre-fills neither date.
cy.acceptValidityWindow();
cy.contains("button", "Accept & start approval", { timeout: 20000 })
.should("not.be.disabled")
.click();
// Approval chain instantiated: LINE_STAFF → DIRECTOR. Approve step 1.
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");
expectStatus("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();
// Final approval renders the contract PDF synchronously → CONTRACT_READY.
// The approval-chain card unmounts once the contract leaves approval, so
// assert on the signing CTA that replaces it.
cy.contains("button", "View & sign", { timeout: 30000 }).should("exist");
expectStatus("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.
// Retried because the iframe can re-render (query refetch) after a scroll.
const unlockConsent = (attempt: number) => {
cy.get('iframe[title="Contract document"]', { timeout: 30000 }).then(
($f) => {
const win = ($f[0] as HTMLIFrameElement).contentWindow;
// documentElement can be null while the srcDoc is (re)parsing —
// skip this round and let the retry pick it up.
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) => {
// Probe the description that only renders once hasScrolledToBottom
// flips — the consent LABEL is always in the DOM (just its checkbox
// is disabled), so matching on it raced ahead of the scroll handler
// and left the Sign button disabled.
if ($b.text().includes("You may now sign the contract")) return;
expect(attempt, "consent bar unlocked").to.be.lessThan(20);
unlockConsent(attempt + 1);
});
});
};
unlockConsent(0);
// Check the input itself — clicking the label text lands on the Checkbox's
// label span, which does not toggle it, leaving Sign disabled.
cy.contains("I have read the entire contract", { timeout: 15000 })
.closest(".mantine-Checkbox-root")
.find('input[type="checkbox"]')
.check({ force: true });
cy.contains("button", /^Sign contract$|^Approve & sign$/).click();
// Signature modal: name + drawn signature.
cy.contains("label", "Full name")
.invoke("attr", "for")
.then((id) => {
cy.get(`[id="${id}"]`).clear().type("Demo User");
});
cy.drawSignature();
cy.uploadCompanyStamp();
cy.contains("button", "Continue to verification").click();
// OTP modal — code goes to the signer's registered contacts (email only
// for the seeded demo user); read it from the DB.
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",
);
expectStatus("SIGNED_CUSTOMER");
});
it("staff counter-signs — GENERAL contract becomes CONTRACT_ACTIVE", () => {
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();
// Staff counter-sign gates on a stamp too, same as the customer's modal.
cy.uploadCompanyStamp();
// Scoped to the modal — the toolbar behind it has its own "Approve & sign".
cy.get(".mantine-Modal-content")
.contains("button", /^Confirm signature$|^Approve & sign$/)
.click();
cy.contains("counter-signed", { timeout: 30000 }).should("be.visible");
// GENERAL → clearance runs per booking, contract goes straight active.
expectStatus("CONTRACT_ACTIVE");
// Both signatures recorded.
withContract((c) => {
cy.task<{ rows: Array<{ role: string }> }>("db:query", {
sql: `SELECT s.role FROM freight.contract_signatures s
WHERE s.contract_id = $1 ORDER BY s.role`,
params: [c.id],
}).then(({ rows }) => {
expect(rows.map((r) => r.role)).to.include.members(["CUSTOMER", "STAFF"]);
});
});
});
});
export {};