Files
edr-platform/e2e/freight/cypress/e2e/flows/onboarding-utils.ts
Nathnael 6469c7fa52 test(e2e): cover every onboarding route across portal and backoffice
Five journeys, one file each, every one of them crossing from the portal into
the backoffice and cross-checking the database rather than the screen:

- ethiopian     eTrade verified, Fayda, approved, contract wizard reachable —
                including the dead end a TIN with no trade licence is for an
                ordinary company
- investor      foreign investment licence: nothing on file at eTrade, typed
                registration, passport identity, per-role licence still owed,
                and the backoffice's manual-entry badge and banner
- cooperative   no foreign option, no freight-forwarder role, the co-operative
                document set, no licence cards, its own badge and banner
- switch_back   settings → switch to eTrade → registration cleared, company
                pending, wizard reopened on the company step → re-run through
                eTrade → the flag is gone from the backoffice
- guards        the refused combinations, and the mid-wizard un-tick that has
                to clear the typed registration

Replaces the old onboarding.cy.ts (removed a commit earlier by accident of a
staged deletion): it drove a wizard shape that no longer exists — Fayda before
the company step, a "Personnel" step — so it could only ever have been red.

Notes for whoever edits these next. Attach files to the FIRST empty dropzone,
never by index — SmartFileInput removes the input once a file is on it. Resolve
the company from the database after any cross-origin hop, never from module
state: Cypress re-evaluates the spec bundle and Date.now() with it, which is
what latestJourney's run-stamp cutoff is for. And the deliberate eTrade 400 is
ignored as an uncaught exception — the portal handles that outcome on screen
but leaves the rejected request unhandled at the promise level.
2026-08-18 11:38:37 +00:00

386 lines
15 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.

/**
* Shared machinery for the onboarding journeys (portal → backoffice).
*
* The wizard's five form steps are `company → owner → representation →
* contact → documents`, preceded by the nationality/role phase. Three routes
* run through them:
*
* ordinary eTrade answers for the TIN; the registration is read-only.
* investor a foreign company on an Investment Commission licence — eTrade
* holds nothing, the registration is typed, the per-role business
* licence is still owed.
* co-op a union or farm — eTrade holds nothing, the registration is
* typed, and no business licence exists to ask for.
*
* The eTrade mock picks its answer from the TIN's leading digit (see
* etrade-mock/server.js), so a spec chooses "verified" or "nothing on file" by
* choosing its number — `etradeTin()` / `noLicenceTin()` / `unknownTin()`.
*/
/**
* Every manual-route journey deliberately looks up a TIN eTrade holds nothing
* for, and the API answers 400. The portal handles that outcome on screen (the
* "nothing on file" alert is the whole point) but leaves the rejected request
* unhandled at the promise level, and Cypress fails a test on any unhandled
* rejection from the app. Ignored here rather than per spec: it is the expected
* response to a request these journeys make on purpose, in every one of them.
*
* Narrow on purpose — only the 400. A 500, or anything else the app throws,
* still fails the test.
*/
Cypress.on("uncaught:exception", (err) => {
if (/Request failed with status code 400/.test(err.message)) return false;
return true;
});
const apiUrl = () => Cypress.env("apiUrl") as string;
export const portalUrl = () => Cypress.env("portalUrl") as string;
/* ------------------------------------------------------------------ */
/* Field helpers */
/* ------------------------------------------------------------------ */
/**
* 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 a subject captured a command
* earlier can already be stale. Going label → for → element each time always
* addresses what is on the page now.
*/
export 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 company step renders TIN and VAT
* inside StepSection cards (the heading is the card's title, not a label), and
* both share the "0012345678" placeholder, so they are addressable only by
* aria-label.
*/
export 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). */
export 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 });
}
/** The wizard's own Continue / Submit button (never the page's). */
export function wizardClick(label: string | RegExp) {
cy.get(".mantine-Modal-content").contains("button", label).click();
}
/* ------------------------------------------------------------------ */
/* Identities */
/* ------------------------------------------------------------------ */
/** A TIN eTrade answers for: registration + one business licence. */
export const etradeTin = (stamp: number) => `1${String(stamp).slice(-9)}`;
/** A TIN eTrade knows, but which holds no trade licence (co-op / investor). */
export const noLicenceTin = (stamp: number) => `9${String(stamp).slice(-9)}`;
/** A TIN eTrade has never heard of at all. */
export const unknownTin = (stamp: number) => `8${String(stamp).slice(-9)}`;
/** VAT is 1011 digits and must not collide with the TIN. */
export const vatNumber = (stamp: number) => `2${String(stamp + 7).slice(-9)}`;
export const SIGNUP_PASSWORD = "Password@e2e1";
export interface Signup {
email: string;
/** Ethiopian mobile, national format (9 + 8 digits). */
phoneNational: string;
}
/** A unique signup identity for this run, namespaced per journey. */
export function signupIdentity(prefix: string, stamp: number): Signup {
return {
email: `e2e.${prefix}.${stamp}@example.com`,
phoneNational: `9${String(stamp).slice(-8)}`,
};
}
/* ------------------------------------------------------------------ */
/* Journey steps */
/* ------------------------------------------------------------------ */
/**
* /signup → OTP (read from the DB; delivery is off in e2e) → signed in on
* /portal with the onboarding wizard already open on the nationality step.
*/
export function signupCustomer(who: Signup, firstName = "Onboard") {
cy.visit(`${portalUrl()}/signup`);
fill(/^First name/, firstName);
fill(/^Last name/, "Tester");
fill(/^Email/, who.email);
cy.get('input[type="tel"]').first().type(who.phoneNational, { force: true });
fill(/^Password/, SIGNUP_PASSWORD);
fill(/^Confirm password/, SIGNUP_PASSWORD);
cy.contains("button", "Continue").click();
cy.contains("Verify", { timeout: 15000 }).should("be.visible");
cy.getOtp(who.email).then((otp) => cy.typeOtp(otp));
cy.contains("button", "Verify & create account").click();
cy.location("pathname", { timeout: 20000 }).should("eq", "/portal");
cy.contains("Where is your company registered?", { timeout: 15000 }).should(
"be.visible",
);
}
/**
* Fill the "Registration details" block the manual routes type by hand. Region
* is a Mantine Select; the rest are plain inputs.
*/
export function typeRegistration(companyName: string) {
fill(/^Company Name/, companyName);
cy.mantineSelect("Region", "Addis Ababa");
fill(/^Zone/, "Zone 1");
fill(/^Woreda/, "Woreda 1");
fill(/^Kebele/, "Kebele 1");
}
/**
* Attach a PDF to the next empty dropzone on the documents step.
*
* Not positional: SmartFileInput swaps its dropzone for the file row once
* something is attached, so the input disappears from the DOM and every later
* index shifts under you. "The first one still asking for a file" is the only
* stable address, and it walks the step in render order — company documents
* first, then one card per operational profile.
*/
export function attachNextFile() {
cy.get('.mantine-Modal-content input[type="file"]')
.first()
.selectFile("cypress/fixtures/docs/license.pdf", { force: true });
}
/**
* Drive the whole investor wizard, signup included, and submit it.
*
* Shared because two specs need the same customer sitting on the far side of
* onboarding: the one that is about the journey, and the one that is about
* what happens afterwards. Driving the real UI rather than posting the
* equivalent API calls keeps the second spec honest — it starts from a company
* the product itself produced.
*
* `beforeSubmit` runs on the documents step, with everything attached and the
* submit button still unpressed: the one place a caller can assert about a
* state that does not survive submission.
*/
export function completeInvestorOnboarding(
who: Signup,
stamp: number,
opts: { companyName?: string; beforeSubmit?: () => void } = {},
) {
const companyName = opts.companyName ?? `E2E Investor Holdings ${stamp}`;
signupCustomer(who, "Investor");
cy.contains("button", "Foreign Company").click();
cy.contains("We operate on a foreign investment licence").click();
cy.contains("button", "Importer").click();
wizardClick("Continue");
cy.contains("Confirm your VAT number", { timeout: 20000 }).should(
"be.visible",
);
fillAria('[aria-label^="TIN Number"]', noLicenceTin(stamp));
cy.contains("Nothing on file at eTrade for this TIN", {
timeout: 20000,
}).should("be.visible");
typeRegistration(companyName);
fillAria('[aria-label="VAT Number"]', vatNumber(stamp));
wizardClick("Continue");
// No licence, so no eTrade manager: every owner field is typed, and the
// "your licence listed no manager" hint has no business appearing.
cy.contains("Company Owner", { timeout: 20000 }).should("be.visible");
cy.contains("Your eTrade licence didn't list a manager").should("not.exist");
fill(/^Owner's Name/, "Investor Owner");
fill(/^Owner's Email/, `owner.${stamp}@example.com`);
fillPhone(0, "911234570");
wizardClick("Continue");
cy.contains("Does anyone hold power of attorney for this company?", {
timeout: 20000,
}).should("be.visible");
cy.contains("button", "No, the owner acts for us").click();
// Identity is not proven yet, so the step must hold. Asserted as "we did not
// advance" rather than on the alert text: answering the declaration refetches
// the profile, which re-seeds the form, and the form's watch subscription
// clears the alert on any value change — so the message is real but lives for
// a few milliseconds.
wizardClick("Continue");
cy.contains("Who Acts For You").should("be.visible");
cy.contains("Contact Person").should("not.exist");
// The alternative a foreign company gets and an Ethiopian one does not.
cy.contains("Use a passport instead").click();
fill(/Passport Number/, `P${String(stamp).slice(-7)}`);
wizardClick("Continue");
cy.contains("Contact Person", { timeout: 20000 }).should("be.visible");
fill(/^Name$/, "Investor Contact");
fillPhone(0, "911234571");
wizardClick("Continue");
cy.contains("Upload Importer Business license file(s)", {
timeout: 20000,
}).should("be.visible");
attachNextFile();
attachNextFile();
opts.beforeSubmit?.();
wizardClick("Submit for review");
cy.contains("You're all set", { timeout: 30000 }).should("be.visible");
}
/* ------------------------------------------------------------------ */
/* Database + API */
/* ------------------------------------------------------------------ */
export interface CompanyRow {
id: string;
name: string;
status: string;
nationality: string | null;
attributes: Record<string, unknown> | null;
licence_number: string | null;
region: string | null;
etrade_phone: string | null;
onboarding_step: string | null;
onboarding_completed: boolean;
email: string;
}
const COMPANY_SQL = `
SELECT c.id, c.name, c.status, c.nationality, c.attributes,
c.licence_number, c.region, c.etrade_phone,
ep.onboarding_step, ep.onboarding_completed, 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`;
/** The company behind one signup email. */
export function companyByEmail(email: string) {
return cy
.task<{ rows: CompanyRow[] }>("db:query", {
sql: `${COMPANY_SQL} WHERE u.email = $1`,
params: [email],
})
.then(({ rows }) => {
expect(rows, `company for ${email}`).to.have.length(1);
return cy.wrap(rows[0], { log: false });
});
}
/**
* The most recent journey of one kind, resolved from the DB rather than from
* module state: Cypress re-evaluates the spec bundle on every cross-origin
* visit, so anything held in a module variable is gone by the backoffice test.
*/
export function latestJourney(emailPrefix: string) {
// Scoped to THIS cypress run. `run:stamp` is minted by the node plugin, which
// outlives the bundle re-evaluation a cross-origin visit causes — unlike
// anything held in module state. Without the cutoff a journey that failed
// mid-way would silently hand the backoffice tests a previous run's company
// and pass on it.
return cy.task<string>("run:stamp", null, { log: false }).then((runStamp) =>
cy
.task<{ rows: CompanyRow[] }>("db:query", {
sql: `${COMPANY_SQL}
WHERE u.email LIKE $1
AND c.created_at >= to_timestamp($2::bigint / 1000.0)
ORDER BY c.created_at DESC LIMIT 1`,
params: [`e2e.${emailPrefix}.%`, runStamp],
})
.then(({ rows }) => {
expect(rows, `journey for e2e.${emailPrefix}.* in this run`).to.have.length(
1,
);
return cy.wrap(rows[0], { log: false });
}),
);
}
/** A portal access token for a customer account. */
export function portalToken(email: string, password = SIGNUP_PASSWORD) {
return cy.apiLogin(email, password, "portal").then((body) => body.token);
}
/** cy.request against the freight API with a bearer token. */
export function apiRequest(
token: string,
method: "GET" | "POST" | "PATCH",
path: string,
body?: Record<string, unknown>,
failOnStatusCode = true,
) {
return cy.request({
method,
url: `${apiUrl()}${path}`,
headers: { Authorization: `Bearer ${token}` },
body,
failOnStatusCode,
});
}
/* ------------------------------------------------------------------ */
/* Backoffice */
/* ------------------------------------------------------------------ */
/** Open a customer's detail page from the backoffice list, by company name. */
export function openCustomer(companyName: string) {
cy.visit("/dashboard/customers");
cy.get('input[placeholder*="Search by company"]').type(companyName);
cy.contains(companyName, { timeout: 15000 }).click();
}
/**
* Approve the pending role profile on the open customer page. Once active the
* row's action flips to "Suspend", which is what proves the write landed.
*/
export function approveFirstProfile() {
cy.contains("button", "Approve", { timeout: 15000 }).click();
cy.contains("button", "Suspend", { timeout: 15000 }).should("be.visible");
}
/** Assert the role profile went active and the company with it. */
export function expectProfileActive(companyName: string, type = "importer") {
return 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 = $2`,
params: [companyName, type],
})
.then(({ rows }) => {
expect(rows, `${type} profile`).to.have.length(1);
expect(rows[0].status).to.eq("active");
expect(rows[0].reference, "minted reference").to.be.a("string").and.not.be
.empty;
expect(rows[0].company_status).to.eq("active");
});
}