mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 16:28:12 +00:00
246 lines
9.9 KiB
TypeScript
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 {};
|