diff --git a/docker-compose.e2e.yaml b/docker-compose.e2e.yaml index 5b8e37daf..50a4f807e 100644 --- a/docker-compose.e2e.yaml +++ b/docker-compose.e2e.yaml @@ -8,8 +8,9 @@ # Interactive from host: pnpm --filter @edr/freight-e2e cy:open # Teardown: docker compose -f docker-compose.e2e.yaml down -v --remove-orphans # -# Host ports (chosen to never collide with the dev stack): -# freight-api 3101 portal 5273 backoffice 5283 +# Host ports (chosen to never collide with the dev stacks — 5273/5283/3221 are +# taken by the second dev checkout in ~/projects/nathnael/edr-platform): +# freight-api 3101 portal 5373 backoffice 5383 # postgres 5533 minio 9310 (console 9311) name: edr-freight-e2e @@ -47,6 +48,18 @@ services: timeout: 5s retries: 12 + # tmpfs wipes MinIO on every restart — recreate the app bucket each boot. + minio-init-e2e: + image: minio/mc:latest + depends_on: + minio-e2e: + condition: service_healthy + entrypoint: + - /bin/sh + - -c + - mc alias set e2e http://minio-e2e:9000 e2e-minio e2e-minio-secret && mc mb --ignore-existing e2e/fhc + restart: "no" + freight-api-e2e: build: context: . @@ -58,6 +71,8 @@ services: condition: service_healthy minio-e2e: condition: service_healthy + minio-init-e2e: + condition: service_completed_successfully environment: PORT: "3001" DB_HOST: postgres-freight-e2e @@ -89,7 +104,10 @@ services: # External integrations off RABBITMQ_ENABLED: "false" FAYDA_ENABLED: "false" - FREIGHT_PORTAL_URL: http://localhost:5273 + # SMS strategy has no kill switch and defaults to a real dev endpoint — + # blackhole it so e2e never sends SMS (failures are logged, non-fatal). + OZIKING_SMS_URL: http://127.0.0.1:9/sms + FREIGHT_PORTAL_URL: http://localhost:5373 ports: - "3101:3001" healthcheck: @@ -124,7 +142,7 @@ services: secrets: - npmrc ports: - - "5273:80" + - "5373:80" freight-backoffice-e2e: build: @@ -142,7 +160,7 @@ services: secrets: - npmrc ports: - - "5283:80" + - "5383:80" # Headless runner — opt-in via `--profile cypress`. host network so the # in-container browser uses the exact same localhost URLs as `cypress open` diff --git a/e2e/freight/README.md b/e2e/freight/README.md index 6cefcdcfa..69a97d704 100644 --- a/e2e/freight/README.md +++ b/e2e/freight/README.md @@ -9,8 +9,8 @@ both headless-in-Docker and interactively from the host against the same URLs. | Service | Host port | Notes | | ----------------------- | --------- | ---------------------------------------------- | | `freight-api-e2e` | 3101 | migrations + seeders run at boot | -| `freight-portal-e2e` | 5273 | nginx static build, API baked to `:3101` | -| `freight-backoffice-e2e`| 5283 | nginx static build, API baked to `:3101` | +| `freight-portal-e2e` | 5373 | nginx static build, API baked to `:3101` | +| `freight-backoffice-e2e`| 5383 | nginx static build, API baked to `:3101` | | `postgres-freight-e2e` | 5533 | `edr_freight_e2e`, tmpfs — gone on `down` | | `minio-e2e` | 9310/9311 | object storage for file features | | `cypress` | (host net)| profile `cypress`, headless chrome | @@ -58,7 +58,7 @@ Full map in `cypress/fixtures/users.json`. `cy.loginBackoffice(email?)` / `cy.loginPortal(email?)` — `cy.session`-cached (across specs), `POST /api/auth/login`, sets the `auth-token` / `refresh-token` cookies the apps read. -- **Origins**: `baseUrl` is the backoffice (5283). Portal specs `cy.visit` +- **Origins**: `baseUrl` is the backoffice (5383). Portal specs `cy.visit` the absolute portal URL; a test that touches *both* apps wraps portal steps in `cy.origin()` (different port = different origin). Cookies ignore ports — always call the matching login command right before switching apps so diff --git a/e2e/freight/cypress.config.ts b/e2e/freight/cypress.config.ts index 56c0f758a..a2acffe24 100644 --- a/e2e/freight/cypress.config.ts +++ b/e2e/freight/cypress.config.ts @@ -5,8 +5,8 @@ import { Client } from "pg"; /** * Freight e2e suite. Three origins: - * backoffice http://localhost:5283 (baseUrl — most specs live here) - * portal http://localhost:5273 (env.portalUrl; portal specs cy.visit it, + * backoffice http://localhost:5383 (baseUrl — most specs live here) + * portal http://localhost:5373 (env.portalUrl; portal specs cy.visit it, * cross-app flows reach it via cy.origin) * api http://localhost:3101 (env.apiUrl; cy.request only) * @@ -17,7 +17,7 @@ import { Client } from "pg"; */ export default defineConfig({ e2e: { - baseUrl: process.env.CYPRESS_BASE_URL ?? "http://localhost:5283", + baseUrl: process.env.CYPRESS_BASE_URL ?? "http://localhost:5383", specPattern: "cypress/e2e/**/*.cy.ts", supportFile: "cypress/support/e2e.ts", video: process.env.CI === "true" || process.env.CYPRESS_VIDEO === "true", @@ -29,8 +29,8 @@ export default defineConfig({ retries: { runMode: 1, openMode: 0 }, env: { apiUrl: process.env.CYPRESS_API_URL ?? "http://localhost:3101", - portalUrl: process.env.CYPRESS_PORTAL_URL ?? "http://localhost:5273", - backofficeUrl: process.env.CYPRESS_BASE_URL ?? "http://localhost:5283", + portalUrl: process.env.CYPRESS_PORTAL_URL ?? "http://localhost:5373", + backofficeUrl: process.env.CYPRESS_BASE_URL ?? "http://localhost:5383", // Staff users: DEFAULT_PASSWORD from docker-compose.e2e.yaml. defaultPassword: process.env.CYPRESS_DEFAULT_PASSWORD ?? "password@tria", // Demo portal users: hardcoded in DemoUsersSeeder. @@ -61,14 +61,17 @@ export default defineConfig({ */ async "db:seedUsers"() { // cwd = the e2e/freight project root when Cypress runs. - const sql = readFileSync( - join(process.cwd(), "cypress", "fixtures", "seed-users.sql"), - "utf8", - ); + // seed-company.sql depends on rows from seed-users.sql — keep order. const client = new Client({ connectionString: dbUrl }); await client.connect(); try { - await client.query(sql); + for (const file of ["seed-users.sql", "seed-company.sql"]) { + const sql = readFileSync( + join(process.cwd(), "cypress", "fixtures", file), + "utf8", + ); + await client.query(sql); + } return true; } finally { await client.end(); diff --git a/e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts b/e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts new file mode 100644 index 000000000..0c730b95f --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts @@ -0,0 +1,213 @@ +/** + * Contract creation → finalization, spanning portal + backoffice: + * + * 1. portal (user@gmail.com, company seeded active by seed-company.sql): + * wizard → GENERAL / Import / Container / 20ft → submit + approve quote + * 2. backoffice marketer: "Accept for approval" (validity) + approve the + * LINE_STAFF step + * 3. backoffice director: approve the DIRECTOR step → PDF → CONTRACT_READY + * 4. portal customer: scroll contract, agree, draw signature, OTP-sign + * → SIGNED_CUSTOMER + * 5. backoffice marketer: counter-sign as staff → GENERAL contract goes + * CONTRACT_ACTIVE (per-booking clearance, no contract-level gate) + * + * Sequential steps of one journey — retries off (steps are not idempotent). + */ + +const customer = "user@gmail.com"; +const companyTin = "0102030405"; // seed-company.sql + +/** + * The journey's contract = the seeded company's latest contract. Each test + * resolves it from the DB instead of sharing module state — tests stay + * independently runnable against the current DB state. + */ +function dbContract() { + return cy.task<{ rows: Array<{ id: string; reference: string; status: string }> }>( + "db:query", + { + sql: `SELECT ct.id, ct.reference, ct.status + FROM freight.contracts ct + JOIN freight.companies c ON c.id = ct.company_id + WHERE c.tin = $1 + ORDER BY ct.created_at DESC LIMIT 1`, + params: [companyTin], + }, + ); +} + +function withContract(fn: (c: { id: string; reference: string; status: string }) => void) { + dbContract().then(({ rows }) => { + expect(rows, "latest contract for the seeded company").to.have.length(1); + fn(rows[0]); + }); +} + +function expectStatus(expected: string) { + dbContract().then(({ rows }) => { + expect(rows[0]?.status, `contract status`).to.eq(expected); + }); +} + +describe("contract lifecycle: creation to finalization", { retries: 0 }, () => { + it("customer creates and submits a GENERAL import container contract", () => { + cy.loginPortal(customer); + cy.visitPortal("/contracts/new"); + + // Step 0 — Setup. + cy.mantineSelect(/^Operation Type/, /^Import$/); + cy.mantineSelect(/^Contract Kind/, "General Contract"); + cy.mantineSelect(/^New or Renewal/, "New Contract"); + cy.contains("Rail Transport Only", { timeout: 15000 }).click(); + cy.mantineSelect(/^Payment Currency/, /^ETB/); + cy.contains("button", "Continue").click({ force: true }); + + // Step 1 — Cargo & Route. + cy.mantineSelect(/^Cargo Scope/, /Containerized/); + cy.get('[role="checkbox"][aria-label="20ft Container"]').click(); + cy.get('textarea[placeholder*="Electronics"]').type( + "E2E electronics shipment scope", + ); + cy.mantineSelect(/^Origin Yard/, "Djibouti Port Terminal"); + cy.mantineSelect(/^Destination Yard/, "Mojo Dry Port"); + cy.contains("button", "Continue").click({ force: true }); + + // Step 2 — Review & Submit → quotation modal. + cy.contains("button", "Submit").click({ force: true }); + cy.contains("Approve your quotation", { timeout: 30000 }).should( + "be.visible", + ); + cy.contains("button", "Approve & submit").click(); + + cy.location("pathname", { timeout: 20000 }).should("eq", "/contracts"); + cy.contains("Submitted", { timeout: 15000 }).should("be.visible"); + + dbContract().then(({ rows }) => { + expect(rows, "contract row").to.have.length(1); + expect(rows[0].status).to.eq("SUBMITTED"); + expect(rows[0].reference).to.match(/^CTR-/); + }); + }); + + it("marketer accepts the submission and approves the LINE_STAFF step", () => { + cy.loginBackoffice("marketer@edr.local"); + withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`)); + + cy.contains("button", "Accept for approval", { timeout: 20000 }).click(); + // Validity defaults to the first configured option in the accept modal. + cy.contains("button", "Accept & start approval", { timeout: 20000 }) + .should("not.be.disabled") + .click(); + + // Approval chain instantiated: LINE_STAFF → DIRECTOR. Approve step 1. + cy.contains("Approval chain", { timeout: 20000 }).should("be.visible"); + cy.contains("button", "Approve", { timeout: 20000 }).click(); + cy.contains("button", "Confirm approval").click(); + cy.contains("1/2", { timeout: 20000 }).should("be.visible"); + + expectStatus("PENDING_APPROVAL"); + }); + + it("director approves the final step — contract PDF becomes ready", () => { + cy.loginBackoffice("director@edr.local"); + withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`)); + + cy.contains("button", "Approve", { timeout: 20000 }).click(); + cy.contains("button", "Confirm approval").click(); + + // Final approval renders the contract PDF synchronously → CONTRACT_READY. + // The approval-chain card unmounts once the contract leaves approval, so + // assert on the signing CTA that replaces it. + cy.contains("button", "View & sign", { timeout: 30000 }).should("exist"); + + expectStatus("CONTRACT_READY"); + }); + + it("customer signs the contract with OTP", () => { + cy.loginPortal(customer); + withContract((c) => cy.visitPortal(`/contracts/${c.id}/view`)); + + // Scroll the contract iframe to the bottom so the consent bar unlocks. + // Retried because the iframe can re-render (query refetch) after a scroll. + const unlockConsent = (attempt: number) => { + cy.get('iframe[title="Contract document"]', { timeout: 30000 }).then( + ($f) => { + const win = ($f[0] as HTMLIFrameElement).contentWindow; + // documentElement can be null while the srcDoc is (re)parsing — + // skip this round and let the retry pick it up. + const el = + win?.document?.scrollingElement ?? win?.document?.documentElement; + if (win && el) { + el.scrollTop = el.scrollHeight; + win.dispatchEvent(new Event("scroll")); + } + }, + ); + cy.wait(500).then(() => { + cy.get("body").then(($b) => { + if ($b.text().includes("I have read the entire contract")) return; + expect(attempt, "consent bar unlocked").to.be.lessThan(20); + unlockConsent(attempt + 1); + }); + }); + }; + unlockConsent(0); + + cy.contains("I have read the entire contract", { timeout: 15000 }).click(); + cy.contains("button", /^Sign contract$|^Approve & sign$/).click(); + + // Signature modal: name + drawn signature. + cy.contains("label", "Full name") + .invoke("attr", "for") + .then((id) => { + cy.get(`[id="${id}"]`).clear().type("Demo User"); + }); + cy.drawSignature(); + cy.contains("button", "Continue to verification").click(); + + // OTP modal — code goes to the signer's registered contacts (email only + // for the seeded demo user); read it from the DB. + cy.contains("Verify it's you", { timeout: 20000 }).should("be.visible"); + cy.getOtp(customer).then((otp) => cy.typeOtp(otp)); + cy.contains("button", "Verify & sign").click(); + + cy.contains("Your signature has been recorded", { timeout: 30000 }).should( + "be.visible", + ); + expectStatus("SIGNED_CUSTOMER"); + }); + + it("staff counter-signs — GENERAL contract becomes CONTRACT_ACTIVE", () => { + cy.loginBackoffice("marketer@edr.local"); + withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}/view`)); + + cy.contains("button", /^Sign as staff$|^Approve & sign$/, { + timeout: 30000, + }).click(); + cy.contains("label", "Full name") + .invoke("attr", "for") + .then((id) => { + cy.get(`[id="${id}"]`).clear().type("EDR Marketer"); + }); + cy.drawSignature(); + cy.contains("button", /^Confirm signature$|^Approve & sign$/).click(); + + cy.contains("counter-signed", { timeout: 30000 }).should("be.visible"); + + // GENERAL → clearance runs per booking, contract goes straight active. + expectStatus("CONTRACT_ACTIVE"); + + // Both signatures recorded. + withContract((c) => { + cy.task<{ rows: Array<{ role: string }> }>("db:query", { + sql: `SELECT s.role FROM freight.contract_signatures s + WHERE s.contract_id = $1 ORDER BY s.role`, + params: [c.id], + }).then(({ rows }) => { + expect(rows.map((r) => r.role)).to.include.members(["CUSTOMER", "STAFF"]); + }); + }); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/e2e/flows/cross-app.cy.ts b/e2e/freight/cypress/e2e/flows/cross-app.cy.ts index 954ed4762..fe60170ba 100644 --- a/e2e/freight/cypress/e2e/flows/cross-app.cy.ts +++ b/e2e/freight/cypress/e2e/flows/cross-app.cy.ts @@ -1,6 +1,6 @@ /** * Cross-app flow: same business objects seen from both directions — - * customer (portal, port 5273) and staff (backoffice, port 5283 = baseUrl). + * customer (portal, port 5373) and staff (backoffice, port 5383 = baseUrl). * Different ports = different origins, so portal steps inside a test that * also touches backoffice run inside cy.origin(). * diff --git a/e2e/freight/cypress/e2e/flows/onboarding.cy.ts b/e2e/freight/cypress/e2e/flows/onboarding.cy.ts new file mode 100644 index 000000000..ed6df900b --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/onboarding.cy.ts @@ -0,0 +1,203 @@ +/** + * 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. + */ + +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 companyName = `E2E Onboard Co ${stamp}`; +const tin = String(stamp).slice(-10).padStart(10, "1"); +const vat = String(stamp + 1).slice(-10).padStart(10, "2"); +const fan = String(stamp).slice(-13).padStart(16, "3"); + +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). */ +function fill(label: string | RegExp, value: string) { + cy.contains("label", label) + .invoke("attr", "for") + .then((id) => { + cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true }); + }); +} + +/** The wizard's phone inputs (react-phone-number-input, type=tel). */ +function fillPhone(index: number, national: string) { + cy.get('.mantine-Modal-content input[type="tel"]') + .eq(index) + .clear({ force: true }) + .type(national, { force: true }); +} + +describe("customer onboarding journey", { retries: 0 }, () => { + it("signs up with OTP and completes the onboarding wizard", () => { + // The eTrade TIN lookup 400s in e2e (external service unreachable). The + // form handles it ("fill in the details manually") but axios also throws + // an uncaught rejection — ignore just that one. + cy.on("uncaught:exception", (err) => + err.message.includes("Request failed with status code 400") ? false : true, + ); + 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(); + + // Company step. TIN first — the eTrade auto-lookup fails in e2e (no + // external network) and the form allows manual entry. + cy.get('input[placeholder="0012345678"]', { timeout: 15000 }).type(tin); + fill(/^Company Name/, companyName); + fill(/^Company Email/, `ops.${stamp}@example.com`); + fillPhone(0, "911234567"); + fill(/^Location/, "Addis Ababa, Ethiopia"); + fill(/^VAT Number/, vat); + cy.get('input[placeholder="1234567890123456"]').type(fan); + cy.mantineSelect(/^Region/, "Addis Ababa"); + fill(/^Zone/, "Zone 1"); + fill(/^Woreda/, "Woreda 1"); + fill(/^Kebele/, "Kebele 1"); + fill(/^House No/, "123"); + 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. + 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 {}; diff --git a/e2e/freight/cypress/e2e/portal/login.cy.ts b/e2e/freight/cypress/e2e/portal/login.cy.ts index c26365a22..55ef4bc21 100644 --- a/e2e/freight/cypress/e2e/portal/login.cy.ts +++ b/e2e/freight/cypress/e2e/portal/login.cy.ts @@ -1,7 +1,7 @@ /** * UI login for the customer portal (seeded demo user). * Form: apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx. - * Portal is a different origin (port 5273), so specs visit it via absolute + * Portal is a different origin (port 5373), so specs visit it via absolute * URL — each test here stays on that single origin, no cy.origin needed. */ const portal = () => Cypress.env("portalUrl") as string; diff --git a/e2e/freight/cypress/fixtures/docs/license.pdf b/e2e/freight/cypress/fixtures/docs/license.pdf new file mode 100644 index 000000000..8a4f1376d --- /dev/null +++ b/e2e/freight/cypress/fixtures/docs/license.pdf @@ -0,0 +1,11 @@ +%PDF-1.4 +1 0 obj<>endobj +2 0 obj<>endobj +3 0 obj<>endobj +xref +0 4 +0000000000 65535 f +trailer<> +startxref +0 +%%EOF diff --git a/e2e/freight/cypress/fixtures/seed-company.sql b/e2e/freight/cypress/fixtures/seed-company.sql new file mode 100644 index 000000000..73b01bee1 --- /dev/null +++ b/e2e/freight/cypress/fixtures/seed-company.sql @@ -0,0 +1,51 @@ +-- Arrange-data for the contract lifecycle specs, applied after seed-users.sql. +-- Idempotent. Two things the app cannot provide without manual steps: +-- +-- 1. chief gets `edr_freight_app:admin` (customer-profile approval is +-- FreightAdmin-guarded and no seeded position carries it). +-- 2. user@gmail.com gets an ACTIVE company + approved importer profile so the +-- contract wizard is reachable without first running the onboarding journey. + +-- 1. chief → edr_freight_app:admin +INSERT INTO iam.position_permissions (id, position_id, permission_id) +SELECT gen_random_uuid(), p.id, perm.id +FROM iam.positions p +JOIN iam.permissions perm ON perm.key = 'edr_freight_app:admin' +WHERE p.key = 'chief' + AND NOT EXISTS ( + SELECT 1 FROM iam.position_permissions pp + WHERE pp.position_id = p.id AND pp.permission_id = perm.id + ); + +-- 2a. Active customer company (TIN is the idempotency key). +INSERT INTO freight.companies + (id, name, type, status, tin, fan_number, country, address, phone, email, + nationality, kind, attributes) +SELECT gen_random_uuid(), 'E2E Logistics PLC', 'customer', 'active', + '0102030405', '1234567890123456', 'Ethiopia', 'Addis Ababa, Ethiopia', + '+251911000001', 'ops@e2e-logistics.test', 'ethiopian', 'commercial', + '{"contactPersonName":"Test Contact","contactPersonPhone":"+251911000002","generalManagerName":"Test GM","generalManagerEmail":"gm@e2e-logistics.test","generalManagerPhone":"+251911000003"}'::jsonb +WHERE NOT EXISTS (SELECT 1 FROM freight.companies WHERE tin = '0102030405'); + +-- 2b. Approved importer profile (reference normally minted on approval). +INSERT INTO freight.company_profiles (id, company_id, type, status, reference) +SELECT gen_random_uuid(), c.id, 'importer', 'active', 'IMP-E2E-0001' +FROM freight.companies c +WHERE c.tin = '0102030405' + AND NOT EXISTS ( + SELECT 1 FROM freight.company_profiles p + WHERE p.company_id = c.id AND p.type = 'importer' + ); + +-- 2c. Link the demo portal user to the company, onboarding already done. +INSERT INTO freight.external_profiles + (id, user_id, company_id, first_name, last_name, is_primary_contact, + active_profile_type, onboarding_step, onboarding_completed) +SELECT gen_random_uuid(), u.id, c.id, 'Demo', 'User', true, + 'importer', 'done', true +FROM iam.users u +JOIN freight.companies c ON c.tin = '0102030405' +WHERE u.email = 'user@gmail.com' + AND NOT EXISTS ( + SELECT 1 FROM freight.external_profiles ep WHERE ep.user_id = u.id + ); diff --git a/e2e/freight/cypress/fixtures/seed-users.sql b/e2e/freight/cypress/fixtures/seed-users.sql index a9f60b286..5f42e3d5f 100644 --- a/e2e/freight/cypress/fixtures/seed-users.sql +++ b/e2e/freight/cypress/fixtures/seed-users.sql @@ -109,8 +109,11 @@ join iam.units un on un.key = 'edr_freight_app' and un.organization_id = o.id where u.email like '%@edr.local' and not exists (select 1 from iam.employees e where e.user_id = u.id); -insert into iam.employee_positions (id, is_delegate, is_current, status, unit_id, employee_id, position_id) -select gen_random_uuid(), false, true, 'APPROVED', un.id, e.id, p.id +-- start_date must be set: the login query filters positions on +-- start_date <= NOW(), and a NULL start_date silently drops the position +-- (and with it every permission) from the JWT. +insert into iam.employee_positions (id, is_delegate, is_current, status, start_date, unit_id, employee_id, position_id) +select gen_random_uuid(), false, true, 'APPROVED', now() - interval '1 day', un.id, e.id, p.id from (values ('linestaff@edr.local', 'operation'), ('chief@edr.local', 'chief'), @@ -128,3 +131,7 @@ join iam.positions p on p.key = v.position_key and p.unit_id = un.id where not exists ( select 1 from iam.employee_positions ep where ep.employee_id = e.id and ep.position_id = p.id ); + +-- Backfill for rows created before start_date was included above. +update iam.employee_positions set start_date = now() - interval '1 day' +where start_date is null; diff --git a/e2e/freight/cypress/support/commands.ts b/e2e/freight/cypress/support/commands.ts index 9fc1b83ee..6a958d5be 100644 --- a/e2e/freight/cypress/support/commands.ts +++ b/e2e/freight/cypress/support/commands.ts @@ -76,6 +76,79 @@ Cypress.Commands.add("visitPortal", (path = "/") => { cy.visit(`${Cypress.env("portalUrl")}${path}`); }); +/** + * Read the latest OTP the API generated for a contact. SMS/email delivery is + * disabled in e2e (RABBITMQ_ENABLED=false) but the code is still stored in + * freight.otp_verifications — keyed by normalized email (lowercased) or E.164 + * phone. Polls because the row is written async to the UI action. + */ +Cypress.Commands.add("getOtp", (target: string) => { + const read = (attempt: number): Cypress.Chainable => + cy + .task<{ rows: Array<{ otp: string }> }>( + "db:query", + { + sql: `SELECT otp FROM freight.otp_verifications + WHERE email = $1 OR phone = $1 + ORDER BY updated_at DESC LIMIT 1`, + params: [target], + }, + { log: false }, + ) + .then((res) => { + if (res.rows.length > 0) return cy.wrap(res.rows[0].otp, { log: false }); + expect(attempt, `OTP row for ${target}`).to.be.lessThan(20); + return cy.wait(500, { log: false }).then(() => read(attempt + 1)); + }); + return read(0); +}); + +/** Open a Mantine