diff --git a/.gitignore b/.gitignore index ca2a5b7af..21edddcd0 100644 --- a/.gitignore +++ b/.gitignore @@ -29,3 +29,11 @@ coverage/ \#*\# .\#* docker-compose.override.yml + +# cypress e2e artifacts +e2e/**/cypress/videos/ +e2e/**/cypress/screenshots/ +e2e/**/cypress/downloads/ + +# e2e launcher state (ports of the running stack) +e2e/freight/.e2e-ports.json diff --git a/docker-compose.e2e.yaml b/docker-compose.e2e.yaml new file mode 100644 index 000000000..b00402761 --- /dev/null +++ b/docker-compose.e2e.yaml @@ -0,0 +1,194 @@ +# EDR Freight — ephemeral Cypress e2e stack. +# Fully isolated from dev: own ports, own throwaway Postgres (tmpfs — data +# vanishes on `down`), seeded test users. Requires the same .npmrc as the main +# docker-compose.yaml (GitHub Packages auth for @tria-plc). +# +# Preferred entrypoint: the launcher (auto-up + free-port picking): +# pnpm e2e:freight:run|open|ci|up|down → e2e/freight/scripts/e2e.mjs +# +# Host ports are env-parameterized (E2E_*_PORT). Defaults below avoid the dev +# stacks (5273/5283/3221 are taken by the second dev checkout in +# ~/projects/nathnael/edr-platform); when a default is busy the launcher scans +# upward for a free port and remembers the choice in e2e/freight/.e2e-ports.json +# while the stack is up: +# freight-api 3101 portal 5373 backoffice 5383 +# postgres 5533 minio 9310 (console 9311) +name: edr-freight-e2e + +services: + postgres-freight-e2e: + image: postgres:16-alpine + environment: + POSTGRES_DB: edr_freight_e2e + POSTGRES_USER: edr_e2e + POSTGRES_PASSWORD: edr_e2e + tmpfs: + - /var/lib/postgresql/data + ports: + - "${E2E_DB_PORT:-5533}:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U edr_e2e -d edr_freight_e2e"] + interval: 2s + timeout: 3s + retries: 30 + + minio-e2e: + image: minio/minio:latest + command: server /data --console-address ":9001" + environment: + MINIO_ROOT_USER: e2e-minio + MINIO_ROOT_PASSWORD: e2e-minio-secret + tmpfs: + - /data + ports: + - "${E2E_MINIO_PORT:-9310}:9000" + - "${E2E_MINIO_CONSOLE_PORT:-9311}:9001" + healthcheck: + test: ["CMD", "mc", "ready", "local"] + interval: 5s + 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: . + dockerfile: apps/edr-freight-api/Dockerfile + secrets: + - npmrc + depends_on: + postgres-freight-e2e: + condition: service_healthy + minio-e2e: + condition: service_healthy + minio-init-e2e: + condition: service_completed_successfully + environment: + PORT: "3001" + DB_HOST: postgres-freight-e2e + DB_PORT: "5432" + DB_USER: edr_e2e + DB_PASSWORD: edr_e2e + DB_NAME: edr_freight_e2e + # e2e-only secrets — never reuse outside this stack + JWT_SECRET: e2e-jwt-secret + JWT_ACCESS_TOKEN_SECRET: e2e-access-secret + JWT_REFRESH_TOKEN_SECRET: e2e-refresh-secret + JWT_EXPIRES_IN: 1d + JWT_ACCESS_TOKEN_EXPIRES: 1d + JWT_REFRESH_TOKEN_EXPIRES: 7d + SERVICE_AUTH_TOKEN: e2e-service-token + # Org/unit/position boot seeders (env-gated in app code). Test USERS are + # NOT seeded by the API — Cypress inserts them via + # e2e/freight/cypress/fixtures/seed-users.sql before specs run. + SEED_EDR_ORG: "true" + SUPER_ADMIN_EMAIL: superadmin@tria.com + SUPER_ADMIN_PHONE: "+251900000000" + # Object storage + MINIO_ENDPOINT: minio-e2e + MINIO_PORT: "9000" + MINIO_USE_SSL: "false" + MINIO_ACCESS_KEY: e2e-minio + MINIO_SECRET_KEY: e2e-minio-secret + MINIO_REGION: us-east-1 + # External integrations off + RABBITMQ_ENABLED: "false" + FAYDA_ENABLED: "false" + # 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:${E2E_PORTAL_PORT:-5373} + ports: + - "${E2E_API_PORT:-3101}:3001" + healthcheck: + # Boot runs 240+ migrations + seeders on first start — generous start_period. + test: + [ + "CMD", + "node", + "-e", + "fetch('http://localhost:3001/api/health').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))", + ] + interval: 5s + timeout: 5s + retries: 12 + start_period: 180s + + freight-portal-e2e: + build: + context: . + dockerfile: infrastructure/docker/Dockerfile.web + args: + TURBO_FILTER: "@edr/freight-portal" + APP_PATH: apps/edr-freight-web/portal + # Baked at build time: browser (host or host-networked cypress + # container) reaches the API through the published host port. A + # non-default API port therefore forces a web image rebuild. + VITE_API_URL: http://localhost:${E2E_API_PORT:-3101} + VITE_BASE_API_URL: http://localhost:${E2E_API_PORT:-3101} + VITE_USER_MANAGEMENT_BASE: /_um + VITE_GOOGLE_MAPS_API_KEY: "" + VITE_POSTHOG_KEY: "" + VITE_POSTHOG_HOST: "" + secrets: + - npmrc + ports: + - "${E2E_PORTAL_PORT:-5373}:80" + + freight-backoffice-e2e: + build: + context: . + dockerfile: infrastructure/docker/Dockerfile.web + args: + TURBO_FILTER: "@edr/freight-backoffice" + APP_PATH: apps/edr-freight-web/backoffice + VITE_API_URL: http://localhost:${E2E_API_PORT:-3101} + VITE_BASE_API_URL: http://localhost:${E2E_API_PORT:-3101} + VITE_USER_MANAGEMENT_BASE: /_um + VITE_GOOGLE_MAPS_API_KEY: "" + VITE_POSTHOG_KEY: "" + VITE_POSTHOG_HOST: "" + secrets: + - npmrc + ports: + - "${E2E_BACKOFFICE_PORT:-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` + # on the host (Linux only; on macOS/Windows run Cypress from the host). + cypress: + # Keep in sync with the cypress version in e2e/freight/package.json. + image: cypress/included:${CYPRESS_VERSION:-15.18.1} + profiles: ["cypress"] + network_mode: host + depends_on: + freight-api-e2e: + condition: service_healthy + working_dir: /repo/e2e/freight + # NOTE: host network shares the abstract X-socket namespace with the host. + # Cypress spawns its Xvfb on :99 — run only ONE cypress container at a + # time, and don't run it on a host whose X server occupies :99. + entrypoint: ["cypress", "run", "--browser", "chrome"] + environment: + CI: "true" + E2E_DB_URL: postgres://edr_e2e:edr_e2e@localhost:${E2E_DB_PORT:-5533}/edr_freight_e2e + CYPRESS_BASE_URL: http://localhost:${E2E_BACKOFFICE_PORT:-5383} + CYPRESS_API_URL: http://localhost:${E2E_API_PORT:-3101} + CYPRESS_PORTAL_URL: http://localhost:${E2E_PORTAL_PORT:-5373} + volumes: + - .:/repo + +secrets: + npmrc: + file: .npmrc diff --git a/e2e/freight/README.md b/e2e/freight/README.md new file mode 100644 index 000000000..75b877b84 --- /dev/null +++ b/e2e/freight/README.md @@ -0,0 +1,108 @@ +# @edr/freight-e2e — Cypress e2e suite for the freight system + +Containerized, fully isolated e2e environment: throwaway Postgres (tmpfs), +MinIO, freight-api, portal, and backoffice — plus a Cypress runner that works +both headless-in-Docker and interactively from the host against the same URLs. + +## Stack (`docker-compose.e2e.yaml`, project name `edr-freight-e2e`) + +| Service | Default port | Notes | +| ----------------------- | ------------ | ---------------------------------------------- | +| `freight-api-e2e` | 3101 | migrations + seeders run at boot | +| `freight-portal-e2e` | 5373 | nginx static build, API URL baked at build | +| `freight-backoffice-e2e`| 5383 | nginx static build, API URL baked at build | +| `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 | + +Ports are env-parameterized (`E2E_API_PORT`, `E2E_PORTAL_PORT`, +`E2E_BACKOFFICE_PORT`, `E2E_DB_PORT`, `E2E_MINIO_PORT`, +`E2E_MINIO_CONSOLE_PORT`). Defaults avoid the dev stacks; if a default is +busy anyway, the launcher scans upward for a free port, remembers the choice +in `.e2e-ports.json` (gitignored) while the stack is up, and passes matching +URLs to both compose and Cypress. The dev database is never touched. + +## Usage (from repo root) + +One command — the launcher (`scripts/e2e.mjs`) auto-builds and starts the +stack if it isn't running, waits for healthchecks, then runs Cypress against +whatever ports were picked: + +```bash +pnpm e2e:freight:run # headless run from the host (auto-up) +pnpm e2e:freight:open # interactive Cypress on the host (auto-up) +pnpm e2e:freight:ci # headless run inside the cypress container (auto-up) +pnpm e2e:freight:up # just start the stack +pnpm e2e:freight:down # teardown, drop all data + forget ports +pnpm e2e:freight:run --spec 'cypress/e2e/flows/**' # extra args → cypress +``` + +First `up` is slow (image builds + 240 migrations + seeders — healthcheck +allows 3 min). Later runs against a live stack skip docker entirely. Requires +the same root `.npmrc` (GitHub Packages auth for `@tria-plc`) as the main +compose file. Note: a non-default API port forces a web-image rebuild (the +API URL is baked into the static builds). + +The `cypress` service uses `network_mode: host` (Linux). On macOS/Windows run +Cypress from the host (`e2e:freight:open` / `e2e:freight:run`) instead of the +container. + +## Test users + +Inserted by Cypress itself — a global `before()` hook runs +`cy.task("db:seedUsers")`, which executes `cypress/fixtures/seed-users.sql` +then `cypress/fixtures/seed-company.sql` (idempotent, pre-hashed argon2 +passwords) against the e2e database. No API code is involved; the app's user +seeders stay disabled. The API's always-on boot seeders must have run first +(org/unit/positions) — guaranteed once `freight-api-e2e` is healthy. + +- Staff (backoffice): `linestaff|chief|director|ceo|marketer|operation|gl-et|gl-dj@edr.local` + — password `password@tria` +- Customers (portal): `user@gmail.com`, `user2@gmail.com` + — password `12345678` + +`seed-company.sql` additionally gives `user@gmail.com` an ACTIVE company +("E2E Logistics PLC", TIN `0102030405`) with an approved importer profile — +the contract wizard's precondition — and grants `chief` the +`edr_freight_app:admin` permission (customer-profile approval is +FreightAdmin-guarded and no seeded position carries it otherwise). + +Full map in `cypress/fixtures/users.json`. + +## Conventions + +- **Programmatic login** everywhere except the two dedicated UI-login specs: + `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 (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 + `cy.session` restores the right cookie snapshot. +- **DB access**: `cy.task("db:query", { sql, params })` runs SQL against the + e2e database (`E2E_DB_URL`, default `localhost:5533`). Use for seeding + edge-case data and asserting side effects — it can never reach the dev DB. +- **OTPs**: SMS/email delivery is disabled in e2e, but codes are still stored + in `freight.otp_verifications` — `cy.getOtp(emailOrPhone)` polls them out. + Used by signup verification and contract customer-signing. +- **Spec layout**: + - `cypress/e2e/api/` — API contract via `cy.request` (no browser) + - `cypress/e2e/backoffice/` — staff app + - `cypress/e2e/portal/` — customer app + - `cypress/e2e/flows/` — cross-app journeys (both directions): + - `onboarding.cy.ts` — signup → OTP → wizard (docs + license upload) → + backoffice approval → customer can contract + - `contract-lifecycle.cy.ts` — wizard → submit → accept → 2-step approval + → PDF → customer OTP-sign → staff counter-sign → `CONTRACT_ACTIVE` +- **Journey specs** (`flows/onboarding`, `flows/contract-lifecycle`) run with + `retries: 0` and resolve mid-journey state (user, company, contract) from + the DB at the start of each test: switching origin between tests reloads + the spec bundle, so module-level variables do NOT survive across tests. + +## Extending + +Deep module flows (booking wizard → staff approval → scheduling → billing) +belong in `flows/`. Pattern: arrange via API/`db:query`, act through the UI of +one app, assert through the UI of the other + a `db:query` cross-check. Prefer +adding `data-testid` attributes to app code over brittle text selectors. diff --git a/e2e/freight/cypress.config.ts b/e2e/freight/cypress.config.ts new file mode 100644 index 000000000..a2acffe24 --- /dev/null +++ b/e2e/freight/cypress.config.ts @@ -0,0 +1,83 @@ +import { defineConfig } from "cypress"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; +import { Client } from "pg"; + +/** + * Freight e2e suite. Three origins: + * 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) + * + * All URLs are host-published ports from docker-compose.e2e.yaml. The cypress + * container in that compose file runs with network_mode: host, so the same + * localhost URLs work identically for `cypress open` on the host and for the + * containerized headless run. + */ +export default defineConfig({ + e2e: { + 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", + screenshotOnRunFailure: true, + viewportWidth: 1440, + viewportHeight: 900, + defaultCommandTimeout: 10000, + requestTimeout: 15000, + retries: { runMode: 1, openMode: 0 }, + env: { + apiUrl: process.env.CYPRESS_API_URL ?? "http://localhost:3101", + 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. + demoPassword: "12345678", + }, + setupNodeEvents(on) { + const dbUrl = + process.env.E2E_DB_URL ?? + "postgres://edr_e2e:edr_e2e@localhost:5533/edr_freight_e2e"; + + on("task", { + /** Run an arbitrary SQL statement against the ephemeral e2e database. */ + async "db:query"({ sql, params = [] }: { sql: string; params?: unknown[] }) { + const client = new Client({ connectionString: dbUrl }); + await client.connect(); + try { + const result = await client.query(sql, params as never[]); + return { rowCount: result.rowCount, rows: result.rows }; + } finally { + await client.end(); + } + }, + + /** + * Seed the test users (staff + demo) directly in SQL. The API's + * user seeders are disabled in app code, so the fixture replicates + * their output. Idempotent — safe to run before every spec file. + */ + async "db:seedUsers"() { + // cwd = the e2e/freight project root when Cypress runs. + // seed-company.sql depends on rows from seed-users.sql — keep order. + const client = new Client({ connectionString: dbUrl }); + await client.connect(); + try { + 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/api/health-and-auth.cy.ts b/e2e/freight/cypress/e2e/api/health-and-auth.cy.ts new file mode 100644 index 000000000..c183a11df --- /dev/null +++ b/e2e/freight/cypress/e2e/api/health-and-auth.cy.ts @@ -0,0 +1,79 @@ +/** + * API contract smoke — no browser, pure cy.request against freight-api. + * Verifies the containerized stack booted: migrations ran, seeders ran, + * auth issues tokens. + */ +const api = () => Cypress.env("apiUrl") as string; + +describe("freight-api: health + auth contract", () => { + it("GET /api/health responds", () => { + cy.request(`${api()}/api/health`).its("status").should("eq", 200); + }); + + it("rejects bad credentials", () => { + cy.request({ + method: "POST", + url: `${api()}/api/auth/login`, + body: { email: "nobody@edr.local", password: "wrong-password" }, + failOnStatusCode: false, + }) + .its("status") + .should("be.oneOf", [400, 401, 404]); + }); + + it("logs in every seeded staff user", () => { + cy.fixture("users.json").then((users) => { + Object.values<{ email: string }>(users.staff).forEach(({ email }) => { + cy.apiLogin(email); + }); + }); + }); + + it("staff token can read /api/me", () => { + cy.apiLogin("ceo@edr.local").then(({ token }) => { + cy.request({ + url: `${api()}/api/me`, + headers: { Authorization: `Bearer ${token}` }, + }).then((response) => { + expect(response.status).to.eq(200); + }); + }); + }); + + it("refresh-token rotates the session", () => { + cy.apiLogin("chief@edr.local").then(({ refreshToken }) => { + cy.request("POST", `${api()}/api/auth/refresh-token`, { refreshToken }) + .its("body.token") + .should("be.a", "string"); + }); + }); + + it("demo portal users are seeded", () => { + // DemoUsersSeeder hardcodes this password (staff users use DEFAULT_PASSWORD) + cy.apiLogin("user@gmail.com", Cypress.env("demoPassword")); + cy.apiLogin("user2@gmail.com", Cypress.env("demoPassword")); + }); +}); + +describe("freight-api: seeded database", () => { + it("migrations table is populated", () => { + cy.task<{ rowCount: number }>("db:query", { + sql: "select count(*)::int as count from migrations", + }).then(({ rows }: any) => { + expect(rows[0].count).to.be.greaterThan(100); + }); + }); + + it("staff users exist with credentials", () => { + cy.task("db:query", { + sql: `select u.email from iam.users u + join iam.user_credentials c on c.user_id = u.id + where u.email like '%@edr.local' order by u.email`, + }).then(({ rows }: any) => { + const emails = rows.map((row: { email: string }) => row.email); + expect(emails).to.include.members(["ceo@edr.local", "linestaff@edr.local"]); + }); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/e2e/backoffice/login.cy.ts b/e2e/freight/cypress/e2e/backoffice/login.cy.ts new file mode 100644 index 000000000..0a729bdae --- /dev/null +++ b/e2e/freight/cypress/e2e/backoffice/login.cy.ts @@ -0,0 +1,41 @@ +/** + * The one UI-driven login spec for backoffice — every other spec uses the + * programmatic cy.loginBackoffice() session. + * Login form: apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx + * (Mantine inputs, matched by placeholder). + */ +describe("backoffice: UI login", () => { + it("redirects unauthenticated users to /auth", () => { + cy.clearCookies(); + cy.visit("/dashboard/overview"); + cy.location("pathname").should("eq", "/auth"); + }); + + it("logs in via the form and lands on the dashboard", () => { + cy.clearCookies(); + cy.visit("/auth"); + cy.get('input[placeholder="name@company.com or 09XXXXXXXX"]').type("ceo@edr.local"); + cy.get('input[placeholder="Enter your password"]').type( + Cypress.env("defaultPassword"), + { log: false }, + ); + cy.get('button[type="submit"]').click(); + + cy.location("pathname", { timeout: 20000 }).should("match", /^\/dashboard/); + cy.getCookie("auth-token").should("exist"); + cy.getCookie("refresh-token").should("exist"); + }); + + it("shows an error for wrong credentials and stays on /auth", () => { + cy.clearCookies(); + cy.visit("/auth"); + cy.get('input[placeholder="name@company.com or 09XXXXXXXX"]').type("ceo@edr.local"); + cy.get('input[placeholder="Enter your password"]').type("definitely-wrong"); + cy.get('button[type="submit"]').click(); + + cy.location("pathname").should("eq", "/auth"); + cy.getCookie("auth-token").should("not.exist"); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/e2e/backoffice/smoke.cy.ts b/e2e/freight/cypress/e2e/backoffice/smoke.cy.ts new file mode 100644 index 000000000..db3086e71 --- /dev/null +++ b/e2e/freight/cypress/e2e/backoffice/smoke.cy.ts @@ -0,0 +1,49 @@ +/** + * Route-level smoke over the backoffice shell: each key module page loads + * without bouncing back to /auth and without an unhandled crash. Deep + * per-module behavior belongs in dedicated specs — this catches the broad + * "page is broken / route is dead / guard rejects seeded role" class. + * Routes from apps/edr-freight-web/backoffice/src/App.tsx. + */ +const ROUTES = [ + "/dashboard/overview", + "/dashboard/booking-requests", + "/dashboard/customers", + "/dashboard/invoices", + "/dashboard/contract-requests", + "/dashboard/shipment-requests", + "/dashboard/profile", +]; + +describe("backoffice: route smoke (ceo)", () => { + beforeEach(() => { + cy.loginBackoffice("ceo@edr.local"); + }); + + ROUTES.forEach((route) => { + it(`renders ${route}`, () => { + cy.visit(route); + cy.location("pathname").should("not.eq", "/auth"); + cy.location("pathname").should("contain", "/dashboard"); + cy.get("#root").should("not.be.empty"); + }); + }); +}); + +describe("backoffice: role-based access", () => { + it("line staff can reach the dashboard shell", () => { + cy.loginBackoffice("linestaff@edr.local"); + cy.visit("/dashboard/overview"); + cy.location("pathname").should("not.eq", "/auth"); + cy.get("#root").should("not.be.empty"); + }); + + it("operations officer can reach the dashboard shell", () => { + cy.loginBackoffice("operation@edr.local"); + cy.visit("/dashboard/overview"); + cy.location("pathname").should("not.eq", "/auth"); + cy.get("#root").should("not.be.empty"); + }); +}); + +export {}; 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..e78fe1905 --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts @@ -0,0 +1,216 @@ +/** + * 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(); + // Scoped to the modal — the toolbar behind it has its own "Approve & sign". + cy.get(".mantine-Modal-content") + .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 new file mode 100644 index 000000000..fe60170ba --- /dev/null +++ b/e2e/freight/cypress/e2e/flows/cross-app.cy.ts @@ -0,0 +1,69 @@ +/** + * Cross-app flow: same business objects seen from both directions — + * 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(). + * + * Cookie caveat: cookies ignore ports, so both apps share the localhost + * cookie jar. Always call the matching login command immediately before + * switching apps — cy.session restores the right cookie snapshot. + * + * This spec is the template for full journeys (booking → approval → + * scheduling → billing). It verifies both sides of the fence against + * seeded data via UI + API cross-checks. + */ +const portal = () => Cypress.env("portalUrl") as string; +const api = () => Cypress.env("apiUrl") as string; + +describe("flow: customer and staff see the same world", () => { + it("staff views booking requests, customer views bookings", () => { + // Staff side on the primary origin (baseUrl) first — the first origin a + // test visits becomes primary; every other origin needs cy.origin(). + cy.loginBackoffice("ceo@edr.local"); + cy.visit("/dashboard/booking-requests"); + cy.location("pathname").should("eq", "/dashboard/booking-requests"); + cy.get("#root").should("not.be.empty"); + + // Customer side — switch session first, then enter the portal origin. + cy.loginPortal("user@gmail.com"); + cy.origin(portal(), () => { + cy.visit("/bookings"); + cy.location("pathname").should("not.eq", "/login"); + cy.get("#root").should("not.be.empty"); + }); + }); + + it("staff and customer both resolve their own /api/me identity", () => { + cy.apiLogin("ceo@edr.local").then(({ token }) => { + cy.request({ + url: `${api()}/api/me`, + headers: { Authorization: `Bearer ${token}` }, + }) + .its("status") + .should("eq", 200); + }); + cy.apiLogin("user@gmail.com", Cypress.env("demoPassword")).then(({ token }) => { + cy.request({ + url: `${api()}/api/me`, + headers: { Authorization: `Bearer ${token}` }, + }) + .its("status") + .should("eq", 200); + }); + }); + + it("cy.origin: staff dashboard then portal in a single test", () => { + cy.loginBackoffice("ceo@edr.local"); + cy.visit("/dashboard/overview"); + cy.get("#root").should("not.be.empty"); + + cy.loginPortal("user@gmail.com"); + cy.origin(Cypress.env("portalUrl") as string, () => { + cy.visit("/portal"); + cy.location("pathname").should("not.eq", "/login"); + cy.get("#root").should("not.be.empty"); + }); + }); +}); + +export {}; 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 new file mode 100644 index 000000000..55ef4bc21 --- /dev/null +++ b/e2e/freight/cypress/e2e/portal/login.cy.ts @@ -0,0 +1,36 @@ +/** + * 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 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; + +describe("portal: UI login", () => { + it("logs in via the form", () => { + cy.clearCookies(); + cy.visit(`${portal()}/login`); + cy.get('input[placeholder="name@company.com or 09XXXXXXXX"]').type("user@gmail.com"); + cy.get('input[placeholder="Enter your password"]').type( + Cypress.env("demoPassword"), + { log: false }, + ); + cy.get('button[type="submit"]').click(); + + cy.location("pathname", { timeout: 20000 }).should("not.eq", "/login"); + cy.getCookie("auth-token").should("exist"); + }); + + it("rejects wrong credentials", () => { + cy.clearCookies(); + cy.visit(`${portal()}/login`); + cy.get('input[placeholder="name@company.com or 09XXXXXXXX"]').type("user@gmail.com"); + cy.get('input[placeholder="Enter your password"]').type("definitely-wrong"); + cy.get('button[type="submit"]').click(); + + cy.location("pathname").should("eq", "/login"); + cy.getCookie("auth-token").should("not.exist"); + }); +}); + +export {}; diff --git a/e2e/freight/cypress/e2e/portal/smoke.cy.ts b/e2e/freight/cypress/e2e/portal/smoke.cy.ts new file mode 100644 index 000000000..5372d7359 --- /dev/null +++ b/e2e/freight/cypress/e2e/portal/smoke.cy.ts @@ -0,0 +1,29 @@ +/** + * Route-level smoke over the customer portal. + * Routes from apps/edr-freight-web/portal/src/App.tsx. + */ +const portal = () => Cypress.env("portalUrl") as string; + +const ROUTES = ["/portal", "/bookings", "/contracts", "/billing", "/tracking"]; + +describe("portal: route smoke (demo customer)", () => { + beforeEach(() => { + cy.loginPortal("user@gmail.com"); + }); + + ROUTES.forEach((route) => { + it(`renders ${route}`, () => { + cy.visit(`${portal()}${route}`); + cy.location("pathname").should("not.eq", "/login"); + cy.get("#root").should("not.be.empty"); + }); + }); + + it("new booking wizard opens", () => { + cy.visit(`${portal()}/bookings/new`); + cy.location("pathname").should("eq", "/bookings/new"); + cy.get("#root").should("not.be.empty"); + }); +}); + +export {}; 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 new file mode 100644 index 000000000..5f42e3d5f --- /dev/null +++ b/e2e/freight/cypress/fixtures/seed-users.sql @@ -0,0 +1,137 @@ +-- Test users for the freight e2e stack — replicates what the (disabled) +-- FreightStaffUsersSeeder + DemoUsersSeeder would write, without touching +-- API code. Idempotent: every insert is guarded by WHERE NOT EXISTS. +-- +-- Prerequisites (created by the API's always-on boot seeders): +-- iam.organizations key='edr_freight', iam.units key='edr_freight_app', +-- iam.positions keys ceo/chief/director/marketer/operation/ethiopian_gl/djibouti_gl. +-- +-- Passwords are pre-hashed (argon2id): +-- staff (@edr.local) → password@tria +-- demo (gmail.com) → 12345678 + +-- ── Demo organization ──────────────────────────────────────────────────────── +insert into iam.organizations (id, name, key, is_super_admin, is_government_organization, status) +select gen_random_uuid(), '{"en":"Demo IAM"}'::jsonb, 'demo_iam', false, true, 'Active' +where not exists (select 1 from iam.organizations where key = 'demo_iam'); + +-- ── Roles ──────────────────────────────────────────────────────────────────── +insert into iam.roles (id, key, name) +select gen_random_uuid(), v.key, jsonb_build_object('en', v.name) +from (values + ('edr_line_staff', 'edr_line_staff'), + ('edr_org_manager', 'edr_org_manager'), + ('edr_director', 'edr_director'), + ('edr_ceo', 'edr_ceo'), + ('edr_marketing', 'edr_marketing'), + ('edr_operations_officer', 'edr_operations_officer'), + ('edr_gl_ethiopia', 'edr_gl_ethiopia'), + ('edr_gl_djibouti', 'edr_gl_djibouti'), + ('demo_user1', 'Demo User1'), + ('demo_user2', 'Demo User2') +) v(key, name) +where not exists (select 1 from iam.roles r where r.key = v.key); + +-- ── Demo permissions + role grants ─────────────────────────────────────────── +insert into iam.permissions (id, key, name) +select gen_random_uuid(), v.key, jsonb_build_object('en', v.name) +from (values + ('can:demo:user1', 'Can access demo user1'), + ('can:demo:user2', 'Can access demo user2') +) v(key, name) +where not exists (select 1 from iam.permissions p where p.key = v.key); + +insert into iam.role_permissions (id, role_id, permission_id) +select gen_random_uuid(), r.id, p.id +from (values ('demo_user1', 'can:demo:user1'), ('demo_user2', 'can:demo:user2')) v(role_key, perm_key) +join iam.roles r on r.key = v.role_key +join iam.permissions p on p.key = v.perm_key +where not exists ( + select 1 from iam.role_permissions rp where rp.role_id = r.id and rp.permission_id = p.id +); + +-- ── Users ──────────────────────────────────────────────────────────────────── +insert into iam.users (id, email, username, name, status, is_active, has_set_password, user_type) +select gen_random_uuid(), v.email, v.username, jsonb_build_object('en', v.display), + 'accepted', true, true, 'employee' +from (values + ('linestaff@edr.local', 'linestaff', 'linestaff'), + ('chief@edr.local', 'chief', 'chief'), + ('director@edr.local', 'director', 'director'), + ('ceo@edr.local', 'ceo', 'ceo'), + ('marketer@edr.local', 'marketer', 'marketer'), + ('operation@edr.local', 'operation', 'operation'), + ('gl-et@edr.local', 'gl_et', 'gl_et'), + ('gl-dj@edr.local', 'gl_dj', 'gl_dj'), + ('user@gmail.com', 'user', 'Demo User 1'), + ('user2@gmail.com', 'user2', 'Demo User 2') +) v(email, username, display) +where not exists (select 1 from iam.users u where u.email = v.email); + +-- ── Credentials ────────────────────────────────────────────────────────────── +insert into iam.user_credentials (id, user_id, password, is_active) +select gen_random_uuid(), u.id, + case when u.email like '%@edr.local' + then '$argon2id$v=19$m=65536,t=3,p=4$JFEcHu4Kp55fsrVDPbHDPg$0NfnGzaE39T/qdmzte73oCkohC0Ri+f8DcrvAF4kyH4' -- password@tria + else '$argon2id$v=19$m=65536,t=3,p=4$aBwVFf7I74pqSJqe9cBoig$gCIKa+6dCAb2X86G+0IjgPtil127cx6A6mwhvLj00Bw' -- 12345678 + end, + true +from iam.users u +where (u.email like '%@edr.local' or u.email in ('user@gmail.com', 'user2@gmail.com')) + and not exists (select 1 from iam.user_credentials c where c.user_id = u.id); + +-- ── User → role (staff under edr_freight, demo under demo_iam) ────────────── +insert into iam.user_roles (id, user_id, role_id, organization_id) +select gen_random_uuid(), u.id, r.id, o.id +from (values + ('linestaff@edr.local', 'edr_line_staff', 'edr_freight'), + ('chief@edr.local', 'edr_org_manager', 'edr_freight'), + ('director@edr.local', 'edr_director', 'edr_freight'), + ('ceo@edr.local', 'edr_ceo', 'edr_freight'), + ('marketer@edr.local', 'edr_marketing', 'edr_freight'), + ('operation@edr.local', 'edr_operations_officer', 'edr_freight'), + ('gl-et@edr.local', 'edr_gl_ethiopia', 'edr_freight'), + ('gl-dj@edr.local', 'edr_gl_djibouti', 'edr_freight'), + ('user@gmail.com', 'demo_user1', 'demo_iam'), + ('user2@gmail.com', 'demo_user2', 'demo_iam') +) v(email, role_key, org_key) +join iam.users u on u.email = v.email +join iam.roles r on r.key = v.role_key +join iam.organizations o on o.key = v.org_key +where not exists (select 1 from iam.user_roles ur where ur.user_id = u.id and ur.role_id = r.id); + +-- ── Staff employees + position assignment (drives permissions) ─────────────── +insert into iam.employees (id, is_current, status, name, organization_id, unit_id, user_id) +select gen_random_uuid(), true, 'pending', u.name, o.id, un.id, u.id +from iam.users u +join iam.organizations o on o.key = 'edr_freight' +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); + +-- 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'), + ('director@edr.local', 'director'), + ('ceo@edr.local', 'ceo'), + ('marketer@edr.local', 'marketer'), + ('operation@edr.local', 'operation'), + ('gl-et@edr.local', 'ethiopian_gl'), + ('gl-dj@edr.local', 'djibouti_gl') +) v(email, position_key) +join iam.users u on u.email = v.email +join iam.employees e on e.user_id = u.id +join iam.units un on un.key = 'edr_freight_app' +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/fixtures/users.json b/e2e/freight/cypress/fixtures/users.json new file mode 100644 index 000000000..88c56149f --- /dev/null +++ b/e2e/freight/cypress/fixtures/users.json @@ -0,0 +1,16 @@ +{ + "staff": { + "lineStaff": { "email": "linestaff@edr.local", "role": "edr_line_staff" }, + "chief": { "email": "chief@edr.local", "role": "edr_org_manager" }, + "director": { "email": "director@edr.local", "role": "edr_director" }, + "ceo": { "email": "ceo@edr.local", "role": "edr_ceo" }, + "marketer": { "email": "marketer@edr.local", "role": "edr_marketing" }, + "operation": { "email": "operation@edr.local", "role": "edr_operations_officer" }, + "glEthiopia": { "email": "gl-et@edr.local", "role": "edr_gl_ethiopia" }, + "glDjibouti": { "email": "gl-dj@edr.local", "role": "edr_gl_djibouti" } + }, + "customers": { + "demo1": { "email": "user@gmail.com" }, + "demo2": { "email": "user2@gmail.com" } + } +} diff --git a/e2e/freight/cypress/support/commands.ts b/e2e/freight/cypress/support/commands.ts new file mode 100644 index 000000000..6a958d5be --- /dev/null +++ b/e2e/freight/cypress/support/commands.ts @@ -0,0 +1,176 @@ +/** + * Auth model (see apps/edr-freight-api + freight web apps): + * - POST {api}/api/auth/login { email, password } + * → flattened body { success, token, refreshToken } (response interceptor + * flattens /api/auth responses — no .data nesting). + * - Both web apps read cookies `auth-token` / `refresh-token` and attach + * `Authorization: Bearer `. + * - Cookies are port-agnostic on localhost, so portal and backoffice share + * one cookie jar. cy.session snapshots/restores cookies per session id, + * which keeps staff and customer sessions from clobbering each other — + * but inside a single test, switching apps requires re-invoking the + * matching login command first (see flows specs). + */ + +export interface LoginBody { + success: boolean; + token: string; + refreshToken: string; +} + +const apiUrl = () => Cypress.env("apiUrl") as string; +const password = () => Cypress.env("defaultPassword") as string; + +function apiLogin(email: string, pass?: string): Cypress.Chainable { + return cy + .request("POST", `${apiUrl()}/api/auth/login`, { + email, + password: pass ?? password(), + }) + .then((response) => { + expect(response.status).to.eq(201); + expect(response.body.token, "login token").to.be.a("string"); + return cy.wrap(response.body, { log: false }); + }); +} + +function sessionFor(app: "backoffice" | "portal", email: string, pass?: string) { + cy.session( + [app, email], + () => { + apiLogin(email, pass).then(({ token, refreshToken }) => { + cy.setCookie("auth-token", token); + cy.setCookie("refresh-token", refreshToken); + }); + }, + { + cacheAcrossSpecs: true, + validate() { + cy.getCookie("auth-token").then((cookie) => { + expect(cookie, "auth-token cookie").to.exist; + cy.request({ + url: `${apiUrl()}/api/me`, + headers: { Authorization: `Bearer ${cookie!.value}` }, + }) + .its("status") + .should("eq", 200); + }); + }, + }, + ); +} + +Cypress.Commands.add("apiLogin", (email: string, pass?: string) => apiLogin(email, pass)); + +Cypress.Commands.add("loginBackoffice", (email = "ceo@edr.local", pass?: string) => { + sessionFor("backoffice", email, pass); +}); + +Cypress.Commands.add("loginPortal", (email = "user@gmail.com", pass?: string) => { + // Demo portal users are seeded with a hardcoded password (DemoUsersSeeder), + // unlike staff users which use DEFAULT_PASSWORD. + sessionFor("portal", email, pass ?? (Cypress.env("demoPassword") as string)); +}); + +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