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.
This commit is contained in:
Nathnael
2026-08-18 11:38:37 +00:00
parent 2f86557f61
commit 6469c7fa52
6 changed files with 1178 additions and 0 deletions

View File

@@ -0,0 +1,385 @@
/**
* 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");
});
}

View File

@@ -0,0 +1,161 @@
/**
* A co-operative union or farm: a TIN, but no business licence at all.
*
* It reaches the same typed-registration route as a foreign investor and the
* same backoffice flag, and differs from it in three ways the wizard has to
* enforce rather than merely explain — it is always Ethiopian, it cannot hold
* the freight-forwarder role, and it owes no per-role business licence. Its own
* document set stands in for the trade licence.
*
* One journey across both apps; retries off (see onboarding_ethiopian.cy.ts).
*/
import { completeFaydaVerification } from "./import-utils";
import {
apiRequest,
attachNextFile,
companyByEmail,
fill,
fillAria,
fillPhone,
latestJourney,
noLicenceTin,
openCustomer,
portalToken,
signupCustomer,
signupIdentity,
typeRegistration,
vatNumber,
wizardClick,
} from "./onboarding-utils";
const stamp = Date.now();
const who = signupIdentity("coop", stamp);
describe("onboarding — co-operative union or farm", { retries: 0 }, () => {
it("types its registration, owes no business licence, and submits", () => {
signupCustomer(who, "Cooperative");
// Before the box: both nationalities and all three roles are on offer.
cy.contains("button", "Foreign Company").should("be.visible");
cy.contains("button", "Freight Forwarder").should("be.visible");
cy.contains("We're a co-operative union or farm").click();
// A co-op is registered in Ethiopia by the co-operative promotion agency,
// holds no licence, and cannot forward freight — so none of those choices
// are offered rather than refused later by the API.
cy.contains("button", "Foreign Company").should("not.exist");
cy.contains("button", "Freight Forwarder").should("not.exist");
cy.contains("We operate on a foreign investment licence").should("not.exist");
cy.contains("button", "Importer").click();
wizardClick("Continue");
// ── Company step ────────────────────────────────────────────────────
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(`E2E Farmers Union ${stamp}`);
fillAria('[aria-label="VAT Number"]', vatNumber(stamp));
wizardClick("Continue");
// ── Owner step ──────────────────────────────────────────────────────
cy.contains("Company Owner", { timeout: 20000 }).should("be.visible");
fill(/^Owner's Name/, "Union Chairperson");
fill(/^Owner's Email/, `chair.${stamp}@example.com`);
fillPhone(0, "911234572");
wizardClick("Continue");
// ── Representation step ─────────────────────────────────────────────
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();
// Ethiopian, so Fayda is the only route — the passport alternative belongs
// to foreign companies.
cy.contains("Use a passport instead").should("not.exist");
completeFaydaVerification("owner");
cy.reload();
cy.get(".mantine-Modal-content", { timeout: 30000 }).within(() => {
cy.contains("Fayda verified").should("be.visible");
});
wizardClick("Continue");
// ── Contact step ────────────────────────────────────────────────────
cy.contains("Contact Person", { timeout: 20000 }).should("be.visible");
fill(/^Name$/, "Union Contact");
fillPhone(0, "911234573");
wizardClick("Continue");
// ── Documents step ──────────────────────────────────────────────────
// The co-operative set replaces the nationality one, and the per-role
// licence cards are not rendered at all — offering a slot nothing can fill
// reads as an unfinishable step.
cy.contains("Upload Documents", { timeout: 20000 }).should("be.visible");
cy.contains("Upload Importer Business license file(s)").should("not.exist");
cy.get('.mantine-Modal-content input[type="file"]').should(
"have.length",
1,
);
portalToken(who.email).then((token) =>
apiRequest(token, "GET", "/api/companies/onboarding/requirements").then(
(res) => {
expect(res.body.data.documentSettingCode).to.eq(
"company_onboarding_documents_cooperative",
);
expect(res.body.data.cooperative).to.eq(true);
expect(res.body.data.investorLicence).to.eq(false);
// The requirement is lifted, not merely hidden on screen.
const outstanding: string[] = res.body.data.outstanding;
expect(
outstanding.filter((o) => /business license/i.test(o)),
"no licence is owed",
).to.have.length(0);
},
),
);
attachNextFile();
wizardClick("Submit for review");
cy.contains("You're all set", { timeout: 30000 }).should("be.visible");
companyByEmail(who.email).then((company) => {
expect(company.status).to.eq("pending");
expect(company.onboarding_completed).to.eq(true);
expect(company.nationality).to.eq("ethiopian");
expect((company.attributes ?? {})["cooperative"]).to.eq(true);
expect((company.attributes ?? {})["investorLicence"]).to.be.undefined;
expect(company.licence_number, "no eTrade licence").to.be.null;
});
});
it("the backoffice flags it as a co-operative", () => {
cy.loginBackoffice("chief@edr.local");
latestJourney("coop").then((company) => {
openCustomer(company.name);
cy.contains("Manual entry · co-operative").should("be.visible");
cy.contains("Registration entered by hand — not verified against eTrade")
.should("be.visible");
cy.contains(
"Check them against the Co-operative Registration Certificate",
).should("be.visible");
cy.contains("Co-operative union / farm (no trade licence)").should(
"be.visible",
);
// The two manual routes must not be confused with one another.
cy.contains("Manual entry · investment licence").should("not.exist");
});
});
});
export {};

View File

@@ -0,0 +1,180 @@
/**
* The ordinary onboarding journey, both apps — the baseline every other
* onboarding spec is a deviation from:
*
* 1. portal signup + OTP → nationality/role → company (eTrade lookup) →
* owner → representation (Fayda) → contact → documents →
* "Submit for review"
* 2. backoffice staff approve the importer profile; the customer carries NO
* manual-entry flag, because eTrade answered for its TIN
* 3. portal the approved customer reaches the contract wizard
*
* Sequential steps of ONE journey, so retries are off — a mid-journey retry
* would replay a non-idempotent step against already-advanced state. Switching
* origin between tests (portal ↔ backoffice) re-evaluates the spec bundle and
* wipes module state, so the later tests resolve the journey from the DB.
*/
import { completeFaydaVerification } from "./import-utils";
import {
attachNextFile,
companyByEmail,
etradeTin,
expectProfileActive,
fill,
fillAria,
fillPhone,
latestJourney,
noLicenceTin,
openCustomer,
approveFirstProfile,
signupCustomer,
signupIdentity,
vatNumber,
wizardClick,
SIGNUP_PASSWORD,
} from "./onboarding-utils";
const stamp = Date.now();
const who = signupIdentity("ethiopian", stamp);
describe("onboarding — Ethiopian company, eTrade verified", { retries: 0 }, () => {
it("completes the wizard and submits for review", () => {
signupCustomer(who, "Ethiopian");
cy.contains("button", "Ethiopian Company").click();
// An Ethiopian company is never offered the investment licence — that is a
// foreign company's document, and the API refuses the pair.
cy.contains("We operate on a foreign investment licence").should("not.exist");
cy.contains("button", "Importer").click();
wizardClick("Continue");
// ── Company step ────────────────────────────────────────────────────
cy.contains("Confirm your VAT number", { timeout: 20000 }).should(
"be.visible",
);
// A TIN eTrade knows but which holds no trade licence is a dead end for an
// ordinary company: the alert is red, and Continue must refuse rather than
// carry unverified registration data forward.
fillAria('[aria-label^="TIN Number"]', noLicenceTin(stamp));
cy.contains("No matching business record", { timeout: 20000 }).should(
"be.visible",
);
wizardClick("Continue");
cy.contains("We need to confirm your TIN with eTrade before continuing.").should(
"be.visible",
);
// The real one. A successful lookup fills and locks company name, region,
// zone, woreda, kebele and house number (ETradeCompanyCard).
fillAria('[aria-label^="TIN Number"]', etradeTin(stamp));
cy.contains("Verified with eTrade", { timeout: 20000 }).should("be.visible");
// handleETradeDataLoaded sets a dozen fields in sequence — each a render.
// Typing into VAT immediately races one of those and detaches mid-type.
cy.wait(500);
fillAria('[aria-label="VAT Number"]', vatNumber(stamp));
wizardClick("Continue");
// ── Owner step ──────────────────────────────────────────────────────
// Name and phone came from the licence and are read-only; eTrade carries
// no email, so that one is asked for.
cy.contains("Company Owner", { timeout: 20000 }).should("be.visible");
fill(/^Owner's Email/, `owner.${stamp}@example.com`);
wizardClick("Continue");
// ── Representation step ─────────────────────────────────────────────
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();
// An Ethiopian company has no passport alternative — Fayda or nothing.
cy.contains("Use a passport instead").should("not.exist");
// A real eSignet redirect + SMS OTP can't run in e2e, so complete it
// against fayda-mock-e2e via the API. The step already fetched `identity`
// when it mounted, and completing out-of-band skips the redirect that
// would remount everything — reload to force a fresh fetch. Wizard
// progress is server-side, so nothing already answered is lost.
completeFaydaVerification("owner");
cy.reload();
cy.get(".mantine-Modal-content", { timeout: 30000 }).within(() => {
cy.contains("Fayda verified").should("be.visible");
// The mock's own payload — proof it travelled Fayda → API → UI rather
// than a flag simply flipping.
cy.contains("Abebe Bekele").should("be.visible");
});
// The verified sub is what locks the owner's fields server-side.
companyByEmail(who.email).then((company) => {
expect(
(company.attributes ?? {})["ownerFaydaSub"],
"owner Fayda sub",
).to.eq("e2e-fayda-sub-0001");
});
wizardClick("Continue");
// ── Contact step ────────────────────────────────────────────────────
cy.contains("Contact Person", { timeout: 20000 }).should("be.visible");
fill(/^Name$/, "Contact Person");
fillPhone(0, "911234569");
wizardClick("Continue");
// ── Documents step ──────────────────────────────────────────────────
// One company document is seeded per set on a fresh e2e database
// (FileUploadSettingsSeeder gives a new set exactly its first field), and
// every operational profile owes a business licence.
cy.contains("Upload Importer Business license file(s)", {
timeout: 20000,
}).should("be.visible");
attachNextFile();
attachNextFile();
wizardClick("Submit for review");
cy.contains("You're all set", { timeout: 30000 }).should("be.visible");
companyByEmail(who.email).then((company) => {
expect(company.status).to.eq("pending");
expect(company.onboarding_completed).to.eq(true);
expect(company.nationality).to.eq("ethiopian");
// eTrade answered, so neither manual-entry flag is set and the licence
// it returned is on file.
const attributes = company.attributes ?? {};
expect(attributes["investorLicence"]).to.be.undefined;
expect(attributes["cooperative"]).to.be.undefined;
expect(company.licence_number, "eTrade licence").to.eq("LIC-E2E-0001");
});
});
it("backoffice approves it, with no manual-entry flag anywhere", () => {
cy.loginBackoffice("chief@edr.local");
latestJourney("ethiopian").then((company) => {
openCustomer(company.name);
// The badge and banner belong to companies whose registration was typed.
// This one's came from eTrade, so neither may appear.
cy.contains("Manual entry").should("not.exist");
cy.contains("Registration entered by hand").should("not.exist");
cy.contains("eTrade trade licence").should("be.visible");
approveFirstProfile();
expectProfileActive(company.name);
});
});
it("the approved customer reaches the contract wizard", () => {
latestJourney("ethiopian").then((company) => {
cy.loginPortal(company.email, SIGNUP_PASSWORD);
});
cy.visitPortal("/contracts/new");
cy.contains("label", "Operation Type", { timeout: 20000 }).should(
"be.visible",
);
cy.contains("Awaiting Approval").should("not.exist");
});
});
export {};

View File

@@ -0,0 +1,171 @@
/**
* The states onboarding must refuse, and the one it must undo.
*
* Two halves. The API guards are cheap `cy.request` checks against the seeded
* demo customer — combinations the portal never offers, which is exactly why
* they have to be refused server-side rather than merely hidden. The second
* half is the expensive one and the reason this spec exists at all: going back
* in the wizard and un-ticking the investment licence has to cost what the
* settings switch costs, or a company finishes onboarding on registration data
* nobody verified, with no flag left to say so.
*/
import {
apiRequest,
companyByEmail,
fillAria,
portalToken,
signupCustomer,
signupIdentity,
typeRegistration,
noLicenceTin,
vatNumber,
wizardClick,
} from "./onboarding-utils";
const stamp = Date.now();
const who = signupIdentity("toggle", stamp);
/** The seeded demo customer: an ordinary company that came through eTrade. */
const DEMO_CUSTOMER = "user@gmail.com";
const demoPassword = () => Cypress.env("demoPassword") as string;
describe("onboarding — refused combinations", { retries: 0 }, () => {
it("refuses an investment licence for an Ethiopian company", () => {
portalToken(DEMO_CUSTOMER, demoPassword()).then((token) =>
apiRequest(
token,
"POST",
"/api/companies/onboarding/start",
{
companyType: "customer",
roles: ["importer"],
nationality: "ethiopian",
investorLicence: true,
},
false,
).then((res) => {
expect(res.status).to.eq(400);
expect(JSON.stringify(res.body)).to.contain(
"Only a foreign company can onboard on an investment licence",
);
}),
);
});
it("refuses a co-operative that also claims an investment licence", () => {
// Ethiopian on purpose: a co-op sent as foreign is refused by the older
// co-operative guard, which would pass this test without the new one ever
// running. Ethiopian gets past that guard and lands on this one.
portalToken(DEMO_CUSTOMER, demoPassword()).then((token) =>
apiRequest(
token,
"POST",
"/api/companies/onboarding/start",
{
companyType: "customer",
roles: ["importer"],
nationality: "ethiopian",
cooperative: true,
investorLicence: true,
},
false,
).then((res) => {
expect(res.status).to.eq(400);
expect(JSON.stringify(res.body)).to.contain(
"it cannot also onboard on a foreign investment licence",
);
}),
);
});
it("refuses to switch a company that never took the route", () => {
portalToken(DEMO_CUSTOMER, demoPassword()).then((token) =>
apiRequest(
token,
"POST",
"/api/companies/onboarding/revert-to-etrade",
undefined,
false,
).then((res) => {
expect(res.status).to.eq(400);
expect(JSON.stringify(res.body)).to.contain(
"already registered through eTrade",
);
}),
);
});
});
describe("onboarding — un-ticking the box mid-wizard", { retries: 0 }, () => {
it("clears the typed registration and reopens on the company step", () => {
signupCustomer(who, "Toggle");
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",
);
// A TIN eTrade knows but which holds no trade licence — the manual route's
// own shape. (A TIN eTrade has never heard of surfaces as an outage rather
// than as "nothing on file": the API wraps its 404 as "Failed to fetch",
// which ETradeInfo reads as unreachable. Different message, different
// test.)
fillAria('[aria-label^="TIN Number"]', noLicenceTin(stamp));
cy.contains("Nothing on file at eTrade for this TIN", {
timeout: 20000,
}).should("be.visible");
typeRegistration(`E2E Toggle Trading ${stamp}`);
fillAria('[aria-label="VAT Number"]', vatNumber(stamp));
wizardClick("Continue");
// The typed registration is now on file.
cy.contains("Company Owner", { timeout: 20000 }).should("be.visible");
companyByEmail(who.email).then((company) => {
expect(company.region, "typed address saved").to.eq("Addis Ababa");
expect((company.attributes ?? {})["investorLicence"]).to.eq(true);
});
// Back to the company step, then back again to the nationality phase.
wizardClick("Back");
cy.contains("Confirm your VAT number", { timeout: 20000 }).should(
"be.visible",
);
wizardClick("Back");
// `exist`, not `visible`: the heading scrolls under the modal's sticky
// header, and where the dialog happens to be scrolled says nothing about
// whether we are back on the nationality phase. The checkbox below is the
// thing this test actually needs to reach.
cy.contains("Where is your company registered?", { timeout: 20000 }).should(
"exist",
);
// Change of mind: this company is an ordinary foreign company after all.
cy.contains("We operate on a foreign investment licence").click();
wizardClick("Continue");
// Whatever was typed under the flag is gone, and the resume target is the
// company step — not the furthest step reached, which would skip the
// eTrade lookup the customer has just opted back into.
companyByEmail(who.email).then((company) => {
expect((company.attributes ?? {})["investorLicence"]).to.eq(false);
expect(company.region, "typed address cleared").to.be.null;
expect(company.licence_number).to.be.null;
expect(company.etrade_phone).to.be.null;
expect(company.onboarding_step).to.eq("company");
});
cy.contains("Confirm your VAT number", { timeout: 20000 }).should(
"be.visible",
);
// No typed-registration block any more: eTrade owns these fields again.
cy.contains("Nothing on file at eTrade for this TIN").should("not.exist");
cy.contains("Registration details").should("not.exist");
});
});
export {};

View File

@@ -0,0 +1,123 @@
/**
* A foreign company onboarding on an Ethiopian Investment Commission licence.
*
* The Commission licenses it, not the trade registry, so eTrade holds no
* record for its TIN: the registration is typed, the company is flagged
* `investorLicence`, and the backoffice is told in as many words that nothing
* on the screen was verified against a licence. What does NOT change is the
* business licence per operational profile — an investor holds one, unlike a
* co-operative — so the documents step still asks for it.
*
* The wizard itself is driven by `completeInvestorOnboarding`, which carries
* the step-by-step assertions (the blue "nothing on file" alert, the absent
* eTrade manager, the refusal to continue on an unproven identity) because
* they hold for every investor run. What lives here is what is specific to
* this journey: the document requirements, the persisted flag, and the
* backoffice's treatment of it.
*
* One journey across both apps; retries off (see onboarding_ethiopian.cy.ts).
*/
import {
apiRequest,
approveFirstProfile,
companyByEmail,
completeInvestorOnboarding,
expectProfileActive,
latestJourney,
portalToken,
signupIdentity,
SIGNUP_PASSWORD,
} from "./onboarding-utils";
const stamp = Date.now();
const who = signupIdentity("investor", stamp);
describe("onboarding — foreign investor, no eTrade record", { retries: 0 }, () => {
it("types its registration and submits for review", () => {
completeInvestorOnboarding(who, stamp, {
// On the documents step, everything attached, nothing submitted yet.
beforeSubmit: () => {
portalToken(who.email).then((token) =>
apiRequest(
token,
"GET",
"/api/companies/onboarding/requirements",
).then((res) => {
// The foreign set applies unchanged — it already asks for the
// investment licence itself, so no third set exists for this case.
expect(res.body.data.documentSettingCode).to.eq(
"company_onboarding_documents_foreign",
);
expect(res.body.data.investorLicence, "investorLicence flag").to.eq(
true,
);
expect(res.body.data.cooperative).to.eq(false);
// The one thing this route does NOT share with a co-operative.
expect(
res.body.data.licenseProfiles,
"per-role licence still tracked",
).to.have.length(1);
}),
);
},
});
companyByEmail(who.email).then((company) => {
expect(company.status).to.eq("pending");
expect(company.onboarding_completed).to.eq(true);
expect(company.nationality).to.eq("foreign");
expect((company.attributes ?? {})["investorLicence"]).to.eq(true);
expect(company.name).to.contain("E2E Investor Holdings");
// Typed, not fetched: the address is what the customer entered, and no
// licence number exists at all.
expect(company.region).to.eq("Addis Ababa");
expect(company.licence_number, "no eTrade licence").to.be.null;
});
});
it("the backoffice flags the typed registration, then approves", () => {
cy.loginBackoffice("chief@edr.local");
latestJourney("investor").then((company) => {
cy.visit("/dashboard/customers");
cy.get('input[placeholder*="Search by company"]').type(company.name);
cy.contains(company.name, { timeout: 20000 }).should("be.visible");
// The list is where a reviewer first meets this customer, so the flag
// has to be there and not only on the detail page.
cy.contains("Manual entry · investment licence").should("be.visible");
cy.contains(company.name).click();
cy.contains("Manual entry · investment licence").should("be.visible");
cy.contains(
"Registration entered by hand — not verified against eTrade",
).should("be.visible");
cy.contains("Check them against the Investment Licence").should(
"be.visible",
);
cy.contains(
"Foreign investment licence — typed by the customer, not from eTrade",
).should("be.visible");
cy.contains("Manual entry · co-operative").should("not.exist");
// Flagging is advisory: approval itself is not blocked.
approveFirstProfile();
expectProfileActive(company.name);
});
});
it("the approved investor reaches the contract wizard", () => {
latestJourney("investor").then((company) => {
cy.loginPortal(company.email, SIGNUP_PASSWORD);
});
cy.visitPortal("/contracts/new");
cy.contains("label", "Operation Type", { timeout: 20000 }).should(
"be.visible",
);
cy.contains("Awaiting Approval").should("not.exist");
});
});
export {};

View File

@@ -0,0 +1,158 @@
/**
* Giving the investment-licence route back.
*
* A company that ticked the box by mistake, or that has since been registered
* with the trade registry, switches from Settings → Company. It is a
* re-application rather than a settings edit, and this spec is about proving
* that literally: the typed registration is cleared (nothing on file was ever
* checked against a licence), the company returns to pending, onboarding
* reopens on the company step — and only after a real eTrade lookup does the
* backoffice stop flagging it.
*
* Each test is one leg of a single journey, in order, retries off. The
* portal ↔ backoffice hops re-evaluate the spec bundle, so every leg resolves
* the company from the database rather than from module state.
*/
import {
approveFirstProfile,
companyByEmail,
completeInvestorOnboarding,
etradeTin,
expectProfileActive,
fillAria,
latestJourney,
openCustomer,
signupIdentity,
wizardClick,
SIGNUP_PASSWORD,
} from "./onboarding-utils";
const stamp = Date.now();
const who = signupIdentity("switchback", stamp);
describe("onboarding — switching back to eTrade registration", { retries: 0 }, () => {
it("onboards on an investment licence", () => {
completeInvestorOnboarding(who, stamp, {
companyName: `E2E Switchback Trading ${stamp}`,
});
companyByEmail(who.email).then((company) => {
expect((company.attributes ?? {})["investorLicence"]).to.eq(true);
expect(company.region, "typed address").to.eq("Addis Ababa");
});
});
it("is approved by the backoffice", () => {
cy.loginBackoffice("chief@edr.local");
latestJourney("switchback").then((company) => {
openCustomer(company.name);
cy.contains("Manual entry · investment licence").should("be.visible");
approveFirstProfile();
expectProfileActive(company.name);
});
});
it("switches back from settings, which clears the typed registration", () => {
latestJourney("switchback").then((company) => {
cy.loginPortal(company.email, SIGNUP_PASSWORD);
});
cy.visitPortal("/settings");
cy.contains("Registration source", { timeout: 20000 }).should("be.visible");
cy.contains("button", "Switch to eTrade registration").click();
// The confirmation has to state the cost outright — this is the screen
// that decides whether the customer knows they are re-applying.
cy.contains("Switch to eTrade registration?").should("be.visible");
cy.contains("The registration details you typed are cleared").should(
"be.visible",
);
cy.contains("Your company goes back to pending").should("be.visible");
cy.contains("button", "Switch and re-apply").click();
cy.contains("Registration source", { timeout: 20000 }).should("not.exist");
latestJourney("switchback").then((company) => {
expect((company.attributes ?? {})["investorLicence"]).to.be.undefined;
expect(company.status).to.eq("pending");
expect(company.onboarding_completed).to.eq(false);
// The wizard treats a populated registration as a lookup that already
// passed, so leaving any of it behind would walk the customer straight
// past the eTrade step this switch exists to reach.
expect(company.region, "typed address cleared").to.be.null;
expect(company.licence_number).to.be.null;
expect(company.etrade_phone).to.be.null;
expect(company.onboarding_step, "resume target").to.eq("company");
});
});
it("re-runs onboarding through eTrade and resubmits", () => {
latestJourney("switchback").then((company) => {
cy.loginPortal(company.email, SIGNUP_PASSWORD);
});
cy.visitPortal("/portal");
// Reopened on the company step — not on the furthest step reached before.
cy.contains("Confirm your VAT number", { timeout: 30000 }).should(
"be.visible",
);
// The typed registration section is gone: this is an ordinary company now.
cy.contains("Nothing on file at eTrade for this TIN").should("not.exist");
fillAria('[aria-label^="TIN Number"]', etradeTin(stamp));
cy.contains("Verified with eTrade", { timeout: 20000 }).should("be.visible");
cy.wait(500);
wizardClick("Continue");
// Owner, representation and contact are already satisfied server-side —
// the switch keeps everything except the registration — so each step only
// needs advancing.
cy.contains("Company Owner", { timeout: 20000 }).should("be.visible");
wizardClick("Continue");
cy.contains("The owner acts for the company", { timeout: 20000 }).should(
"be.visible",
);
wizardClick("Continue");
cy.contains("Contact Person", { timeout: 20000 }).should("be.visible");
wizardClick("Continue");
// Documents and the licence were uploaded before the switch and survive
// it, so the step is already satisfied.
cy.contains("Upload Importer Business license file(s)", {
timeout: 20000,
}).should("be.visible");
wizardClick("Submit for review");
cy.contains("You're all set", { timeout: 30000 }).should("be.visible");
latestJourney("switchback").then((company) => {
expect(company.onboarding_completed).to.eq(true);
expect((company.attributes ?? {})["investorLicence"]).to.be.undefined;
// eTrade answered this time, and its licence is on file.
expect(company.licence_number).to.eq("LIC-E2E-0001");
});
});
it("the backoffice no longer flags it", () => {
cy.loginBackoffice("chief@edr.local");
latestJourney("switchback").then((company) => {
openCustomer(company.name);
cy.contains("eTrade trade licence").should("be.visible");
cy.contains("Manual entry").should("not.exist");
cy.contains("Registration entered by hand").should("not.exist");
});
});
it("offers nothing to switch to a customer who came through eTrade", () => {
// The seeded demo customer onboarded the ordinary way, so the card must
// not be on its settings page at all.
cy.loginPortal();
cy.visitPortal("/settings");
cy.contains("Operational Services", { timeout: 20000 }).should("be.visible");
cy.contains("Registration source").should("not.exist");
});
});
export {};