mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 21:08:12 +00:00
The onboarding journey had been failing at the company step for a while: that step was restructured into StepSection cards, so its TIN and VAT fields no longer have <label> elements and the label-based fill() helper could not find them. Match on aria-label instead, which also sidesteps the "0012345678" placeholder both fields share. Two further staleness bugs were hiding behind that one, both in fill() itself. It chained clear() into type() on a subject captured beforehand, so a step-persist PATCH resolving between the two detached it; and re-querying by the captured id was no better, because the fields remount rather than re-render and Mantine mints a fresh generated id when they do. Resolve label -> for -> element afresh for each action. fillPhone gets the same treatment. Tighten what the journey proves about Fayda. It verified out-of-band and then never checked the result reached the UI, so assert the panel renders fayda-mock's own payload — name, phone and email — which only holds if it travelled Fayda -> API -> UI, and cross-check ownerFaydaSub on the company, since that sub is what locks the owner's fields server-side. The PoA step now asserts no file input exists while the PoA is unverified, covering the DARS gating; asserted structurally so rewording the document setting cannot turn a regression into a passing test.
277 lines
12 KiB
TypeScript
277 lines
12 KiB
TypeScript
/**
|
|
* Full customer onboarding journey, both apps:
|
|
*
|
|
* 1. portal — /signup form → OTP (read from DB, delivery is off in e2e)
|
|
* → account created → onboarding wizard (nationality/role →
|
|
* company → personnel → contact → PoA → documents incl. the
|
|
* per-role business license) → "Submit for review"
|
|
* 2. backoffice — staff (chief, holds edr_freight_app:admin) approves the
|
|
* importer profile on /dashboard/customers/:id
|
|
* 3. portal — the new customer is active: contract wizard reachable
|
|
*
|
|
* Tests are sequential steps of ONE journey (fresh unique user per run), so
|
|
* retries are disabled — a mid-journey retry would replay a non-idempotent
|
|
* step against already-advanced state.
|
|
*
|
|
* NOTE: switching origin between tests (portal 5373 ↔ backoffice 5383)
|
|
* reloads the spec bundle and resets module state — later tests resolve the
|
|
* journey's user/company from the DB instead of module variables.
|
|
*/
|
|
|
|
import { completeFaydaVerification } from "./import-utils";
|
|
|
|
const stamp = Date.now();
|
|
const email = `e2e.onboard.${stamp}@example.com`;
|
|
// Ethiopian mobile: 9 + 8 digits, unique per run.
|
|
const phoneNational = `9${String(stamp).slice(-8)}`;
|
|
const signupPassword = "Password@e2e1";
|
|
const tin = String(stamp).slice(-10).padStart(10, "1");
|
|
const vat = String(stamp + 1).slice(-10).padStart(10, "2");
|
|
|
|
const portal = () => Cypress.env("portalUrl") as string;
|
|
|
|
/** The journey's company/user = the latest e2e.onboard.* signup in the DB. */
|
|
function latestOnboardJourney() {
|
|
return cy.task<{ rows: Array<{ name: string; email: string }> }>("db:query", {
|
|
sql: `SELECT c.name, u.email
|
|
FROM freight.companies c
|
|
JOIN freight.external_profiles ep ON ep.company_id = c.id
|
|
JOIN iam.users u ON u.id = ep.user_id
|
|
WHERE u.email LIKE 'e2e.onboard.%'
|
|
ORDER BY c.created_at DESC LIMIT 1`,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Fill a labelled Mantine input (label[for] → input id).
|
|
*
|
|
* The input is resolved fresh for every action rather than captured once.
|
|
* Each wizard step persists and re-seeds asynchronously, and when a field
|
|
* remounts Mantine mints a NEW generated id — so both a subject and an id
|
|
* captured a command earlier can be stale by the time the next command runs.
|
|
* Going label → for → element each time always addresses what's on the page
|
|
* now.
|
|
*/
|
|
function fill(label: string | RegExp, value: string) {
|
|
const input = () =>
|
|
cy
|
|
.contains("label", label)
|
|
.invoke("attr", "for")
|
|
.then((id) => cy.get(`[id="${id}"]`));
|
|
|
|
input().clear({ force: true });
|
|
input().type(value, { force: true });
|
|
}
|
|
|
|
/**
|
|
* Fill an input that has no <label> — the wizard's company step renders its
|
|
* fields inside StepSection cards (the heading is the card's title, not a
|
|
* label), so they're reachable only by aria-label. Both TIN and VAT share the
|
|
* "0012345678" placeholder, which is why this matches on aria-label instead.
|
|
*/
|
|
function fillAria(ariaSelector: string, value: string) {
|
|
const selector = `.mantine-Modal-content ${ariaSelector}`;
|
|
cy.get(selector).clear({ force: true });
|
|
cy.get(selector).type(value, { force: true });
|
|
}
|
|
|
|
/** The wizard's phone inputs (react-phone-number-input, type=tel). */
|
|
function fillPhone(index: number, national: string) {
|
|
const selector = '.mantine-Modal-content input[type="tel"]';
|
|
cy.get(selector).eq(index).clear({ force: true });
|
|
cy.get(selector).eq(index).type(national, { force: true });
|
|
}
|
|
|
|
describe("customer onboarding journey", { retries: 0 }, () => {
|
|
it("signs up with OTP and completes the onboarding wizard", () => {
|
|
cy.visit(`${portal()}/signup`);
|
|
|
|
fill(/^First name/, "Onboard");
|
|
fill(/^Last name/, "Tester");
|
|
fill(/^Email/, email);
|
|
cy.get('input[type="tel"]').first().type(phoneNational, { force: true });
|
|
fill(/^Password/, signupPassword);
|
|
fill(/^Confirm password/, signupPassword);
|
|
cy.contains("button", "Continue").click();
|
|
|
|
// OTP stage — the code is generated + stored even though delivery is off.
|
|
cy.contains("Verify", { timeout: 15000 }).should("be.visible");
|
|
cy.getOtp(email).then((otp) => cy.typeOtp(otp));
|
|
cy.contains("button", "Verify & create account").click();
|
|
|
|
// Signed in → /portal → wizard auto-opens on the nationality/role step.
|
|
cy.location("pathname", { timeout: 20000 }).should("eq", "/portal");
|
|
cy.contains("Where is your company registered?", { timeout: 15000 }).should(
|
|
"be.visible",
|
|
);
|
|
cy.contains("button", "Ethiopian Company").click();
|
|
cy.contains("button", "Importer").click();
|
|
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
|
|
|
// Ethiopian companies gate the "Owner identity" step on Fayda
|
|
// verification — a real eSignet redirect + SMS OTP flow that can't run in
|
|
// e2e. Complete it via the API against fayda-mock-e2e (the profile this
|
|
// attaches to was just created by the nationality/role step above).
|
|
// The wizard already fetched `identity` once when this step mounted, and
|
|
// completing verification out-of-band skips the redirect that would
|
|
// normally remount everything — so reload to force a fresh fetch. Wizard
|
|
// progress resumes server-side, so this doesn't lose the nationality/role
|
|
// step just completed.
|
|
completeFaydaVerification("owner");
|
|
cy.reload();
|
|
cy.contains("Confirm your VAT number", { timeout: 20000 }).should(
|
|
"be.visible",
|
|
);
|
|
|
|
// The owner's identity is the verification's output, never typed: the
|
|
// panel must show it verified and render the name/phone/email that came
|
|
// back from fayda-mock-e2e. Asserting the mock's own values is the only
|
|
// way to prove the payload travelled Fayda → API → UI rather than the
|
|
// panel simply flipping a "verified" flag.
|
|
cy.get(".mantine-Modal-content").within(() => {
|
|
cy.contains("Fayda verified").should("be.visible");
|
|
cy.contains("Abebe Bekele").should("be.visible");
|
|
cy.contains("+251911223344").should("be.visible");
|
|
cy.contains("abebe.bekele@example.com").should("be.visible");
|
|
});
|
|
|
|
// The verified sub is what locks the owner's fields server-side, so check
|
|
// it actually landed on the company rather than trusting the panel alone.
|
|
cy.task<{ rows: Array<{ owner_fayda_sub: string | null }> }>("db:query", {
|
|
sql: `SELECT c.attributes->>'ownerFaydaSub' AS owner_fayda_sub
|
|
FROM freight.companies c
|
|
JOIN freight.external_profiles ep ON ep.company_id = c.id
|
|
JOIN iam.users u ON u.id = ep.user_id
|
|
WHERE u.email = $1`,
|
|
params: [email],
|
|
}).then(({ rows }) => {
|
|
expect(rows, "company row").to.have.length(1);
|
|
expect(rows[0].owner_fayda_sub, "owner Fayda sub").to.eq(
|
|
"e2e-fayda-sub-0001",
|
|
);
|
|
});
|
|
|
|
// Company step. TIN auto-triggers the eTrade lookup once it's a full 10
|
|
// digits (mocked in e2e — see docker-compose.e2e.yaml's etrade-mock-e2e).
|
|
// A successful lookup locks Company Name/Region/Zone/Woreda/Kebele/House
|
|
// No as read-only (ETradeCompanyCard) — nothing left to type there, and
|
|
// Company Email/Phone/Location were dropped from this step entirely (the
|
|
// Fayda-verified owner supplies contact details now). By label, not
|
|
// placeholder: the VAT Number field on this same step shares the TIN
|
|
// field's "0012345678" placeholder, so a placeholder selector matches 2.
|
|
fillAria('[aria-label^="TIN Number"]', tin);
|
|
cy.contains("Verified with eTrade", { timeout: 15000 }).should(
|
|
"be.visible",
|
|
);
|
|
// handleETradeDataLoaded sets several fields in sequence (name, region,
|
|
// zone, woreda, kebele, houseNo) — each a render, still settling right
|
|
// after the badge appears. Typing into VAT immediately raced one of
|
|
// those and detached mid-type; let it finish before touching the form.
|
|
cy.wait(500);
|
|
fillAria('[aria-label="VAT Number"]', vat);
|
|
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
|
|
|
// Personnel (general manager).
|
|
fill(/^Name/, "General Manager");
|
|
fill(/^Email/, `gm.${stamp}@example.com`);
|
|
fillPhone(0, "911234568");
|
|
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
|
|
|
// Contact person.
|
|
fill(/^Name/, "Contact Person");
|
|
fillPhone(0, "911234569");
|
|
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
|
|
|
// PoA — optional for an importer, and left unverified here. The DARS
|
|
// delegation paper authorises the representative the verification names,
|
|
// so with no verified PoA there is nothing for it to authorise: the
|
|
// upload must not be offered, and the step must not block on it. Asserted
|
|
// as "no file input on this step" rather than by label, so a reworded
|
|
// document setting doesn't turn a real regression into a passing test.
|
|
cy.get(".mantine-Modal-content")
|
|
.contains("Power of Attorney")
|
|
.should("be.visible");
|
|
cy.get('.mantine-Modal-content input[type="file"]').should("not.exist");
|
|
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
|
|
|
// Documents: no company docs are configured in e2e, but every role needs
|
|
// a business license.
|
|
cy.contains("Business license", { timeout: 15000 }).should("be.visible");
|
|
cy.get('.mantine-Modal-content input[type="file"]')
|
|
.first()
|
|
.selectFile("cypress/fixtures/docs/license.pdf", { force: true });
|
|
cy.get(".mantine-Modal-content")
|
|
.contains("button", "Submit for review")
|
|
.click();
|
|
|
|
cy.contains("You're all set", { timeout: 30000 }).should("be.visible");
|
|
|
|
// DB cross-check: submitted, awaiting approval.
|
|
cy.task<{ rows: Array<{ status: string; onboarding_completed: boolean }> }>(
|
|
"db:query",
|
|
{
|
|
sql: `SELECT c.status, ep.onboarding_completed
|
|
FROM freight.companies c
|
|
JOIN freight.external_profiles ep ON ep.company_id = c.id
|
|
JOIN iam.users u ON u.id = ep.user_id
|
|
WHERE u.email = $1`,
|
|
params: [email],
|
|
},
|
|
).then(({ rows }) => {
|
|
expect(rows, "company row").to.have.length(1);
|
|
expect(rows[0].status).to.eq("pending");
|
|
expect(rows[0].onboarding_completed).to.eq(true);
|
|
});
|
|
});
|
|
|
|
it("backoffice staff approves the submitted importer profile", () => {
|
|
cy.loginBackoffice("chief@edr.local");
|
|
cy.visit("/dashboard/customers");
|
|
|
|
latestOnboardJourney().then(({ rows }) => {
|
|
expect(rows, "onboarded company").to.have.length(1);
|
|
const company = rows[0].name;
|
|
|
|
cy.get('input[placeholder*="Search by company"]').type(company);
|
|
cy.contains(company, { timeout: 15000 }).click();
|
|
|
|
// Role profiles table → approve the pending importer profile. Once
|
|
// active, the row's action flips to "Suspend".
|
|
cy.contains("button", "Approve", { timeout: 15000 }).click();
|
|
cy.contains("button", "Suspend", { timeout: 15000 }).should("be.visible");
|
|
|
|
cy.task<{ rows: Array<{ status: string; reference: string | null; company_status: string }> }>(
|
|
"db:query",
|
|
{
|
|
sql: `SELECT p.status, p.reference, c.status AS company_status
|
|
FROM freight.company_profiles p
|
|
JOIN freight.companies c ON c.id = p.company_id
|
|
WHERE c.name = $1 AND p.type = 'importer'`,
|
|
params: [company],
|
|
},
|
|
).then(({ rows: profiles }) => {
|
|
expect(profiles, "importer profile").to.have.length(1);
|
|
expect(profiles[0].status).to.eq("active");
|
|
expect(profiles[0].reference, "minted reference").to.be.a("string").and
|
|
.not.be.empty;
|
|
expect(profiles[0].company_status).to.eq("active");
|
|
});
|
|
});
|
|
});
|
|
|
|
it("the approved customer can reach the contract wizard", () => {
|
|
latestOnboardJourney().then(({ rows }) => {
|
|
cy.loginPortal(rows[0].email, signupPassword);
|
|
});
|
|
cy.visitPortal("/contracts/new");
|
|
|
|
// No "Awaiting Approval" gate — the wizard's first step renders.
|
|
cy.contains("label", "Operation Type", { timeout: 15000 }).should(
|
|
"be.visible",
|
|
);
|
|
cy.contains("Awaiting Approval").should("not.exist");
|
|
});
|
|
});
|
|
|
|
export {};
|