Files
edr-platform/e2e/freight/cypress/e2e/flows/onboarding.cy.ts
Nathnael b356433886 test(freight-e2e): fix stale onboarding selectors, tighten Fayda assertions
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.
2026-08-04 12:43:25 +00:00

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