mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
fix(portal): persist the region a manual-registration company picks
The Region select called setValue without shouldDirty. `region` is an eTrade-bundle key, and stepPayload sends those only when the customer changed them this session — so for the two routes that type their address by hand (a co-operative, a foreign investor) the region was dropped on every save while zone, woreda and kebele went through, because those are registered inputs and are dirty by construction. Found by the new onboarding e2e suite: both manual-route companies finished onboarding with zone/woreda/kebele on file and region empty.
This commit is contained in:
@@ -159,7 +159,15 @@ export default function CompanyInfoStep({
|
|||||||
searchable
|
searchable
|
||||||
value={region || null}
|
value={region || null}
|
||||||
onChange={(v) =>
|
onChange={(v) =>
|
||||||
setValue("region", v ?? "", { shouldValidate: true })
|
// shouldDirty, or the pick never reaches the API: `region` is
|
||||||
|
// an eTrade-bundle key, and `stepPayload` sends those only
|
||||||
|
// when the customer changed them this session. Without it a
|
||||||
|
// co-operative or foreign investor typed its address and the
|
||||||
|
// region alone silently vanished on save.
|
||||||
|
setValue("region", v ?? "", {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
})
|
||||||
}
|
}
|
||||||
error={errors.region?.message}
|
error={errors.region?.message}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -1,276 +0,0 @@
|
|||||||
/**
|
|
||||||
* Full customer onboarding journey, both apps:
|
|
||||||
*
|
|
||||||
* 1. portal — /signup form → OTP (read from DB, delivery is off in e2e)
|
|
||||||
* → account created → onboarding wizard (nationality/role →
|
|
||||||
* company → personnel → contact → PoA → documents incl. the
|
|
||||||
* per-role business license) → "Submit for review"
|
|
||||||
* 2. backoffice — staff (chief, holds edr_freight_app:admin) approves the
|
|
||||||
* importer profile on /dashboard/customers/:id
|
|
||||||
* 3. portal — the new customer is active: contract wizard reachable
|
|
||||||
*
|
|
||||||
* Tests are sequential steps of ONE journey (fresh unique user per run), so
|
|
||||||
* retries are disabled — a mid-journey retry would replay a non-idempotent
|
|
||||||
* step against already-advanced state.
|
|
||||||
*
|
|
||||||
* NOTE: switching origin between tests (portal 5373 ↔ backoffice 5383)
|
|
||||||
* reloads the spec bundle and resets module state — later tests resolve the
|
|
||||||
* journey's user/company from the DB instead of module variables.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { completeFaydaVerification } from "./import-utils";
|
|
||||||
|
|
||||||
const stamp = Date.now();
|
|
||||||
const email = `e2e.onboard.${stamp}@example.com`;
|
|
||||||
// Ethiopian mobile: 9 + 8 digits, unique per run.
|
|
||||||
const phoneNational = `9${String(stamp).slice(-8)}`;
|
|
||||||
const signupPassword = "Password@e2e1";
|
|
||||||
const tin = String(stamp).slice(-10).padStart(10, "1");
|
|
||||||
const vat = String(stamp + 1).slice(-10).padStart(10, "2");
|
|
||||||
|
|
||||||
const portal = () => Cypress.env("portalUrl") as string;
|
|
||||||
|
|
||||||
/** The journey's company/user = the latest e2e.onboard.* signup in the DB. */
|
|
||||||
function latestOnboardJourney() {
|
|
||||||
return cy.task<{ rows: Array<{ name: string; email: string }> }>("db:query", {
|
|
||||||
sql: `SELECT c.name, u.email
|
|
||||||
FROM freight.companies c
|
|
||||||
JOIN freight.external_profiles ep ON ep.company_id = c.id
|
|
||||||
JOIN iam.users u ON u.id = ep.user_id
|
|
||||||
WHERE u.email LIKE 'e2e.onboard.%'
|
|
||||||
ORDER BY c.created_at DESC LIMIT 1`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fill a labelled Mantine input (label[for] → input id).
|
|
||||||
*
|
|
||||||
* The input is resolved fresh for every action rather than captured once.
|
|
||||||
* Each wizard step persists and re-seeds asynchronously, and when a field
|
|
||||||
* remounts Mantine mints a NEW generated id — so both a subject and an id
|
|
||||||
* captured a command earlier can be stale by the time the next command runs.
|
|
||||||
* Going label → for → element each time always addresses what's on the page
|
|
||||||
* now.
|
|
||||||
*/
|
|
||||||
function fill(label: string | RegExp, value: string) {
|
|
||||||
const input = () =>
|
|
||||||
cy
|
|
||||||
.contains("label", label)
|
|
||||||
.invoke("attr", "for")
|
|
||||||
.then((id) => cy.get(`[id="${id}"]`));
|
|
||||||
|
|
||||||
input().clear({ force: true });
|
|
||||||
input().type(value, { force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Fill an input that has no <label> — the wizard's company step renders its
|
|
||||||
* fields inside StepSection cards (the heading is the card's title, not a
|
|
||||||
* label), so they're reachable only by aria-label. Both TIN and VAT share the
|
|
||||||
* "0012345678" placeholder, which is why this matches on aria-label instead.
|
|
||||||
*/
|
|
||||||
function fillAria(ariaSelector: string, value: string) {
|
|
||||||
const selector = `.mantine-Modal-content ${ariaSelector}`;
|
|
||||||
cy.get(selector).clear({ force: true });
|
|
||||||
cy.get(selector).type(value, { force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The wizard's phone inputs (react-phone-number-input, type=tel). */
|
|
||||||
function fillPhone(index: number, national: string) {
|
|
||||||
const selector = '.mantine-Modal-content input[type="tel"]';
|
|
||||||
cy.get(selector).eq(index).clear({ force: true });
|
|
||||||
cy.get(selector).eq(index).type(national, { force: true });
|
|
||||||
}
|
|
||||||
|
|
||||||
describe("customer onboarding journey", { retries: 0 }, () => {
|
|
||||||
it("signs up with OTP and completes the onboarding wizard", () => {
|
|
||||||
cy.visit(`${portal()}/signup`);
|
|
||||||
|
|
||||||
fill(/^First name/, "Onboard");
|
|
||||||
fill(/^Last name/, "Tester");
|
|
||||||
fill(/^Email/, email);
|
|
||||||
cy.get('input[type="tel"]').first().type(phoneNational, { force: true });
|
|
||||||
fill(/^Password/, signupPassword);
|
|
||||||
fill(/^Confirm password/, signupPassword);
|
|
||||||
cy.contains("button", "Continue").click();
|
|
||||||
|
|
||||||
// OTP stage — the code is generated + stored even though delivery is off.
|
|
||||||
cy.contains("Verify", { timeout: 15000 }).should("be.visible");
|
|
||||||
cy.getOtp(email).then((otp) => cy.typeOtp(otp));
|
|
||||||
cy.contains("button", "Verify & create account").click();
|
|
||||||
|
|
||||||
// Signed in → /portal → wizard auto-opens on the nationality/role step.
|
|
||||||
cy.location("pathname", { timeout: 20000 }).should("eq", "/portal");
|
|
||||||
cy.contains("Where is your company registered?", { timeout: 15000 }).should(
|
|
||||||
"be.visible",
|
|
||||||
);
|
|
||||||
cy.contains("button", "Ethiopian Company").click();
|
|
||||||
cy.contains("button", "Importer").click();
|
|
||||||
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
|
||||||
|
|
||||||
// Ethiopian companies gate the "Owner identity" step on Fayda
|
|
||||||
// verification — a real eSignet redirect + SMS OTP flow that can't run in
|
|
||||||
// e2e. Complete it via the API against fayda-mock-e2e (the profile this
|
|
||||||
// attaches to was just created by the nationality/role step above).
|
|
||||||
// The wizard already fetched `identity` once when this step mounted, and
|
|
||||||
// completing verification out-of-band skips the redirect that would
|
|
||||||
// normally remount everything — so reload to force a fresh fetch. Wizard
|
|
||||||
// progress resumes server-side, so this doesn't lose the nationality/role
|
|
||||||
// step just completed.
|
|
||||||
completeFaydaVerification("owner");
|
|
||||||
cy.reload();
|
|
||||||
cy.contains("Confirm your VAT number", { timeout: 20000 }).should(
|
|
||||||
"be.visible",
|
|
||||||
);
|
|
||||||
|
|
||||||
// The owner's identity is the verification's output, never typed: the
|
|
||||||
// panel must show it verified and render the name/phone/email that came
|
|
||||||
// back from fayda-mock-e2e. Asserting the mock's own values is the only
|
|
||||||
// way to prove the payload travelled Fayda → API → UI rather than the
|
|
||||||
// panel simply flipping a "verified" flag.
|
|
||||||
cy.get(".mantine-Modal-content").within(() => {
|
|
||||||
cy.contains("Fayda verified").should("be.visible");
|
|
||||||
cy.contains("Abebe Bekele").should("be.visible");
|
|
||||||
cy.contains("+251911223344").should("be.visible");
|
|
||||||
cy.contains("abebe.bekele@example.com").should("be.visible");
|
|
||||||
});
|
|
||||||
|
|
||||||
// The verified sub is what locks the owner's fields server-side, so check
|
|
||||||
// it actually landed on the company rather than trusting the panel alone.
|
|
||||||
cy.task<{ rows: Array<{ owner_fayda_sub: string | null }> }>("db:query", {
|
|
||||||
sql: `SELECT c.attributes->>'ownerFaydaSub' AS owner_fayda_sub
|
|
||||||
FROM freight.companies c
|
|
||||||
JOIN freight.external_profiles ep ON ep.company_id = c.id
|
|
||||||
JOIN iam.users u ON u.id = ep.user_id
|
|
||||||
WHERE u.email = $1`,
|
|
||||||
params: [email],
|
|
||||||
}).then(({ rows }) => {
|
|
||||||
expect(rows, "company row").to.have.length(1);
|
|
||||||
expect(rows[0].owner_fayda_sub, "owner Fayda sub").to.eq(
|
|
||||||
"e2e-fayda-sub-0001",
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
// Company step. TIN auto-triggers the eTrade lookup once it's a full 10
|
|
||||||
// digits (mocked in e2e — see docker-compose.e2e.yaml's etrade-mock-e2e).
|
|
||||||
// A successful lookup locks Company Name/Region/Zone/Woreda/Kebele/House
|
|
||||||
// No as read-only (ETradeCompanyCard) — nothing left to type there, and
|
|
||||||
// Company Email/Phone/Location were dropped from this step entirely (the
|
|
||||||
// Fayda-verified owner supplies contact details now). By label, not
|
|
||||||
// placeholder: the VAT Number field on this same step shares the TIN
|
|
||||||
// field's "0012345678" placeholder, so a placeholder selector matches 2.
|
|
||||||
fillAria('[aria-label^="TIN Number"]', tin);
|
|
||||||
cy.contains("Verified with eTrade", { timeout: 15000 }).should(
|
|
||||||
"be.visible",
|
|
||||||
);
|
|
||||||
// handleETradeDataLoaded sets several fields in sequence (name, region,
|
|
||||||
// zone, woreda, kebele, houseNo) — each a render, still settling right
|
|
||||||
// after the badge appears. Typing into VAT immediately raced one of
|
|
||||||
// those and detached mid-type; let it finish before touching the form.
|
|
||||||
cy.wait(500);
|
|
||||||
fillAria('[aria-label="VAT Number"]', vat);
|
|
||||||
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
|
||||||
|
|
||||||
// Personnel (general manager).
|
|
||||||
fill(/^Name/, "General Manager");
|
|
||||||
fill(/^Email/, `gm.${stamp}@example.com`);
|
|
||||||
fillPhone(0, "911234568");
|
|
||||||
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
|
||||||
|
|
||||||
// Contact person.
|
|
||||||
fill(/^Name/, "Contact Person");
|
|
||||||
fillPhone(0, "911234569");
|
|
||||||
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
|
||||||
|
|
||||||
// PoA — optional for an importer, and left unverified here. The DARS
|
|
||||||
// delegation paper authorises the representative the verification names,
|
|
||||||
// so with no verified PoA there is nothing for it to authorise: the
|
|
||||||
// upload must not be offered, and the step must not block on it. Asserted
|
|
||||||
// as "no file input on this step" rather than by label, so a reworded
|
|
||||||
// document setting doesn't turn a real regression into a passing test.
|
|
||||||
cy.get(".mantine-Modal-content")
|
|
||||||
.contains("Power of Attorney")
|
|
||||||
.should("be.visible");
|
|
||||||
cy.get('.mantine-Modal-content input[type="file"]').should("not.exist");
|
|
||||||
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
|
|
||||||
|
|
||||||
// Documents: no company docs are configured in e2e, but every role needs
|
|
||||||
// a business license.
|
|
||||||
cy.contains("Business license", { timeout: 15000 }).should("be.visible");
|
|
||||||
cy.get('.mantine-Modal-content input[type="file"]')
|
|
||||||
.first()
|
|
||||||
.selectFile("cypress/fixtures/docs/license.pdf", { force: true });
|
|
||||||
cy.get(".mantine-Modal-content")
|
|
||||||
.contains("button", "Submit for review")
|
|
||||||
.click();
|
|
||||||
|
|
||||||
cy.contains("You're all set", { timeout: 30000 }).should("be.visible");
|
|
||||||
|
|
||||||
// DB cross-check: submitted, awaiting approval.
|
|
||||||
cy.task<{ rows: Array<{ status: string; onboarding_completed: boolean }> }>(
|
|
||||||
"db:query",
|
|
||||||
{
|
|
||||||
sql: `SELECT c.status, ep.onboarding_completed
|
|
||||||
FROM freight.companies c
|
|
||||||
JOIN freight.external_profiles ep ON ep.company_id = c.id
|
|
||||||
JOIN iam.users u ON u.id = ep.user_id
|
|
||||||
WHERE u.email = $1`,
|
|
||||||
params: [email],
|
|
||||||
},
|
|
||||||
).then(({ rows }) => {
|
|
||||||
expect(rows, "company row").to.have.length(1);
|
|
||||||
expect(rows[0].status).to.eq("pending");
|
|
||||||
expect(rows[0].onboarding_completed).to.eq(true);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("backoffice staff approves the submitted importer profile", () => {
|
|
||||||
cy.loginBackoffice("chief@edr.local");
|
|
||||||
cy.visit("/dashboard/customers");
|
|
||||||
|
|
||||||
latestOnboardJourney().then(({ rows }) => {
|
|
||||||
expect(rows, "onboarded company").to.have.length(1);
|
|
||||||
const company = rows[0].name;
|
|
||||||
|
|
||||||
cy.get('input[placeholder*="Search by company"]').type(company);
|
|
||||||
cy.contains(company, { timeout: 15000 }).click();
|
|
||||||
|
|
||||||
// Role profiles table → approve the pending importer profile. Once
|
|
||||||
// active, the row's action flips to "Suspend".
|
|
||||||
cy.contains("button", "Approve", { timeout: 15000 }).click();
|
|
||||||
cy.contains("button", "Suspend", { timeout: 15000 }).should("be.visible");
|
|
||||||
|
|
||||||
cy.task<{ rows: Array<{ status: string; reference: string | null; company_status: string }> }>(
|
|
||||||
"db:query",
|
|
||||||
{
|
|
||||||
sql: `SELECT p.status, p.reference, c.status AS company_status
|
|
||||||
FROM freight.company_profiles p
|
|
||||||
JOIN freight.companies c ON c.id = p.company_id
|
|
||||||
WHERE c.name = $1 AND p.type = 'importer'`,
|
|
||||||
params: [company],
|
|
||||||
},
|
|
||||||
).then(({ rows: profiles }) => {
|
|
||||||
expect(profiles, "importer profile").to.have.length(1);
|
|
||||||
expect(profiles[0].status).to.eq("active");
|
|
||||||
expect(profiles[0].reference, "minted reference").to.be.a("string").and
|
|
||||||
.not.be.empty;
|
|
||||||
expect(profiles[0].company_status).to.eq("active");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it("the approved customer can reach the contract wizard", () => {
|
|
||||||
latestOnboardJourney().then(({ rows }) => {
|
|
||||||
cy.loginPortal(rows[0].email, signupPassword);
|
|
||||||
});
|
|
||||||
cy.visitPortal("/contracts/new");
|
|
||||||
|
|
||||||
// No "Awaiting Approval" gate — the wizard's first step renders.
|
|
||||||
cy.contains("label", "Operation Type", { timeout: 15000 }).should(
|
|
||||||
"be.visible",
|
|
||||||
);
|
|
||||||
cy.contains("Awaiting Approval").should("not.exist");
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
export {};
|
|
||||||
Reference in New Issue
Block a user