mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
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:
385
e2e/freight/cypress/e2e/flows/onboarding-utils.ts
Normal file
385
e2e/freight/cypress/e2e/flows/onboarding-utils.ts
Normal 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 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");
|
||||||
|
});
|
||||||
|
}
|
||||||
161
e2e/freight/cypress/e2e/flows/onboarding_cooperative.cy.ts
Normal file
161
e2e/freight/cypress/e2e/flows/onboarding_cooperative.cy.ts
Normal 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 {};
|
||||||
180
e2e/freight/cypress/e2e/flows/onboarding_ethiopian.cy.ts
Normal file
180
e2e/freight/cypress/e2e/flows/onboarding_ethiopian.cy.ts
Normal 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 {};
|
||||||
171
e2e/freight/cypress/e2e/flows/onboarding_guards.cy.ts
Normal file
171
e2e/freight/cypress/e2e/flows/onboarding_guards.cy.ts
Normal 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 {};
|
||||||
123
e2e/freight/cypress/e2e/flows/onboarding_investor.cy.ts
Normal file
123
e2e/freight/cypress/e2e/flows/onboarding_investor.cy.ts
Normal 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 {};
|
||||||
158
e2e/freight/cypress/e2e/flows/onboarding_switch_back.cy.ts
Normal file
158
e2e/freight/cypress/e2e/flows/onboarding_switch_back.cy.ts
Normal 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 {};
|
||||||
Reference in New Issue
Block a user