mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-29 03:40:56 +00:00
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.
386 lines
15 KiB
TypeScript
386 lines
15 KiB
TypeScript
/**
|
||
* 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 10–11 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");
|
||
});
|
||
}
|