mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-27 00:52:50 +00:00
Merge pull request #880 from Tria-plc/freight/feat/cypress
Freight/feat/cypress
This commit is contained in:
8
.gitignore
vendored
8
.gitignore
vendored
@@ -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
|
||||
|
||||
194
docker-compose.e2e.yaml
Normal file
194
docker-compose.e2e.yaml
Normal file
@@ -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
|
||||
108
e2e/freight/README.md
Normal file
108
e2e/freight/README.md
Normal file
@@ -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.
|
||||
83
e2e/freight/cypress.config.ts
Normal file
83
e2e/freight/cypress.config.ts
Normal file
@@ -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();
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
79
e2e/freight/cypress/e2e/api/health-and-auth.cy.ts
Normal file
79
e2e/freight/cypress/e2e/api/health-and-auth.cy.ts
Normal file
@@ -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 {};
|
||||
41
e2e/freight/cypress/e2e/backoffice/login.cy.ts
Normal file
41
e2e/freight/cypress/e2e/backoffice/login.cy.ts
Normal file
@@ -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 {};
|
||||
49
e2e/freight/cypress/e2e/backoffice/smoke.cy.ts
Normal file
49
e2e/freight/cypress/e2e/backoffice/smoke.cy.ts
Normal file
@@ -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 {};
|
||||
216
e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts
Normal file
216
e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts
Normal file
@@ -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 {};
|
||||
69
e2e/freight/cypress/e2e/flows/cross-app.cy.ts
Normal file
69
e2e/freight/cypress/e2e/flows/cross-app.cy.ts
Normal file
@@ -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 {};
|
||||
203
e2e/freight/cypress/e2e/flows/onboarding.cy.ts
Normal file
203
e2e/freight/cypress/e2e/flows/onboarding.cy.ts
Normal file
@@ -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 {};
|
||||
36
e2e/freight/cypress/e2e/portal/login.cy.ts
Normal file
36
e2e/freight/cypress/e2e/portal/login.cy.ts
Normal file
@@ -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 {};
|
||||
29
e2e/freight/cypress/e2e/portal/smoke.cy.ts
Normal file
29
e2e/freight/cypress/e2e/portal/smoke.cy.ts
Normal file
@@ -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 {};
|
||||
11
e2e/freight/cypress/fixtures/docs/license.pdf
Normal file
11
e2e/freight/cypress/fixtures/docs/license.pdf
Normal file
@@ -0,0 +1,11 @@
|
||||
%PDF-1.4
|
||||
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
|
||||
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
|
||||
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]>>endobj
|
||||
xref
|
||||
0 4
|
||||
0000000000 65535 f
|
||||
trailer<</Size 4/Root 1 0 R>>
|
||||
startxref
|
||||
0
|
||||
%%EOF
|
||||
51
e2e/freight/cypress/fixtures/seed-company.sql
Normal file
51
e2e/freight/cypress/fixtures/seed-company.sql
Normal file
@@ -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
|
||||
);
|
||||
137
e2e/freight/cypress/fixtures/seed-users.sql
Normal file
137
e2e/freight/cypress/fixtures/seed-users.sql
Normal file
@@ -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;
|
||||
16
e2e/freight/cypress/fixtures/users.json
Normal file
16
e2e/freight/cypress/fixtures/users.json
Normal file
@@ -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" }
|
||||
}
|
||||
}
|
||||
176
e2e/freight/cypress/support/commands.ts
Normal file
176
e2e/freight/cypress/support/commands.ts
Normal file
@@ -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 <token>`.
|
||||
* - 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<LoginBody> {
|
||||
return cy
|
||||
.request<LoginBody>("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<string> =>
|
||||
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 <Select> by its label and pick an option by exact text. */
|
||||
Cypress.Commands.add("mantineSelect", (label: string | RegExp, option: string | RegExp) => {
|
||||
cy.contains("label", label)
|
||||
.invoke("attr", "for")
|
||||
.then((id) => {
|
||||
cy.get(`[id="${id}"]`).click({ force: true });
|
||||
});
|
||||
cy.get('[role="option"]').contains(option).click();
|
||||
});
|
||||
|
||||
/** Type a 6-digit code into a Mantine PinInput. */
|
||||
Cypress.Commands.add("typeOtp", (code: string) => {
|
||||
cy.get(".mantine-PinInput-root input").should("have.length.at.least", code.length);
|
||||
code.split("").forEach((digit, i) => {
|
||||
cy.get(".mantine-PinInput-root input").eq(i).type(digit, { force: true });
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Draw a squiggle on the signature-pad canvas (mouse events). When the account
|
||||
* already has a saved signature the modal opens in "Approve signature" mode
|
||||
* with no canvas — nothing to draw, the saved image is used as-is.
|
||||
*/
|
||||
Cypress.Commands.add("drawSignature", () => {
|
||||
cy.get(".mantine-Modal-content").then(($modals) => {
|
||||
if ($modals.find("canvas").length === 0) return;
|
||||
drawOnCanvas();
|
||||
});
|
||||
});
|
||||
|
||||
function drawOnCanvas() {
|
||||
cy.get(".mantine-Modal-content canvas")
|
||||
.first()
|
||||
.then(($canvas) => {
|
||||
const rect = $canvas[0].getBoundingClientRect();
|
||||
const midX = rect.left + rect.width / 2;
|
||||
const midY = rect.top + rect.height / 2;
|
||||
cy.wrap($canvas)
|
||||
.trigger("mousedown", { clientX: midX - 60, clientY: midY, force: true })
|
||||
.trigger("mousemove", { clientX: midX - 20, clientY: midY - 15, force: true })
|
||||
.trigger("mousemove", { clientX: midX + 20, clientY: midY + 15, force: true })
|
||||
.trigger("mousemove", { clientX: midX + 60, clientY: midY, force: true })
|
||||
.trigger("mouseup", { force: true });
|
||||
});
|
||||
}
|
||||
|
||||
declare global {
|
||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||
namespace Cypress {
|
||||
interface Chainable {
|
||||
/** POST /api/auth/login, returns the flattened token body. */
|
||||
apiLogin(email: string, pass?: string): Chainable<LoginBody>;
|
||||
/** Cached programmatic staff session (default ceo@edr.local). */
|
||||
loginBackoffice(email?: string, pass?: string): Chainable<void>;
|
||||
/** Cached programmatic customer session (default user@gmail.com). */
|
||||
loginPortal(email?: string, pass?: string): Chainable<void>;
|
||||
/** cy.visit against the portal origin (env.portalUrl). */
|
||||
visitPortal(path?: string): Chainable<void>;
|
||||
/** Latest OTP stored for an email/phone (delivery is off in e2e). */
|
||||
getOtp(target: string): Chainable<string>;
|
||||
/** Open a Mantine Select by label, pick an option. */
|
||||
mantineSelect(label: string | RegExp, option: string | RegExp): Chainable<void>;
|
||||
/** Fill a Mantine PinInput with a code. */
|
||||
typeOtp(code: string): Chainable<void>;
|
||||
/** Scribble on the signature-pad canvas inside the open modal. */
|
||||
drawSignature(): Chainable<void>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
18
e2e/freight/cypress/support/e2e.ts
Normal file
18
e2e/freight/cypress/support/e2e.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import "./commands";
|
||||
|
||||
// The API's user seeders are disabled in app code — the SQL fixture creates
|
||||
// the staff + demo test users instead. Idempotent, runs before each spec file.
|
||||
before(() => {
|
||||
cy.task("db:seedUsers");
|
||||
});
|
||||
|
||||
// Third-party noise (PostHog, Google Maps, socket.io reconnects) can throw
|
||||
// uncaught exceptions that are irrelevant to the assertion under test. App
|
||||
// errors still fail tests via failed assertions / failed intercepts.
|
||||
Cypress.on("uncaught:exception", (err) => {
|
||||
const ignorable = [/posthog/i, /google/i, /websocket/i, /socket\.io/i, /ResizeObserver/i];
|
||||
if (ignorable.some((pattern) => pattern.test(err.message))) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
22
e2e/freight/package.json
Normal file
22
e2e/freight/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@edr/freight-e2e",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Cypress end-to-end tests for the EDR freight system (portal + backoffice + API)",
|
||||
"scripts": {
|
||||
"cy:open": "cypress open --e2e",
|
||||
"cy:run": "cypress run",
|
||||
"cy:run:backoffice": "cypress run --spec 'cypress/e2e/backoffice/**'",
|
||||
"cy:run:portal": "cypress run --spec 'cypress/e2e/portal/**'",
|
||||
"cy:run:api": "cypress run --spec 'cypress/e2e/api/**'",
|
||||
"cy:run:flows": "cypress run --spec 'cypress/e2e/flows/**'",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cypress": "^15.3.0",
|
||||
"pg": "^8.13.0",
|
||||
"typescript": "^5.5.4",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/pg": "^8.11.0"
|
||||
}
|
||||
}
|
||||
193
e2e/freight/scripts/e2e.mjs
Normal file
193
e2e/freight/scripts/e2e.mjs
Normal file
@@ -0,0 +1,193 @@
|
||||
#!/usr/bin/env node
|
||||
/**
|
||||
* Freight e2e launcher — one command, no manual steps:
|
||||
*
|
||||
* node e2e/freight/scripts/e2e.mjs <up|run|open|ci|down> [cypress args...]
|
||||
*
|
||||
* - run/open/ci auto-start the docker stack (build + wait healthy) if it
|
||||
* isn't already running, then launch Cypress pointed at the right ports.
|
||||
* - Host ports default to 3101/5373/5383/5533/9310/9311; any default that is
|
||||
* busy is replaced by the next free port. Chosen ports are written to
|
||||
* e2e/freight/.e2e-ports.json (gitignored) and reused while the stack is
|
||||
* up, so cypress and compose always agree.
|
||||
* - Extra args are forwarded to Cypress: `pnpm e2e:freight:run --spec ...`.
|
||||
*
|
||||
* No dependencies — plain Node, spawns `docker compose` and `pnpm`.
|
||||
*/
|
||||
|
||||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { createServer } from "node:net";
|
||||
import { dirname, join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const e2eDir = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const repoRoot = resolve(e2eDir, "..", "..");
|
||||
const stateFile = join(e2eDir, ".e2e-ports.json");
|
||||
const composeBase = ["compose", "-f", join(repoRoot, "docker-compose.e2e.yaml")];
|
||||
|
||||
const DEFAULT_PORTS = {
|
||||
E2E_API_PORT: 3101,
|
||||
E2E_PORTAL_PORT: 5373,
|
||||
E2E_BACKOFFICE_PORT: 5383,
|
||||
E2E_DB_PORT: 5533,
|
||||
E2E_MINIO_PORT: 9310,
|
||||
E2E_MINIO_CONSOLE_PORT: 9311,
|
||||
};
|
||||
|
||||
// Long-running services that must be up before tests (minio-init exits).
|
||||
const SERVICES = [
|
||||
"postgres-freight-e2e",
|
||||
"minio-e2e",
|
||||
"freight-api-e2e",
|
||||
"freight-portal-e2e",
|
||||
"freight-backoffice-e2e",
|
||||
];
|
||||
|
||||
function fail(msg) {
|
||||
console.error(`\ne2e: ${msg}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
function preflight() {
|
||||
try {
|
||||
execFileSync("docker", ["info"], { stdio: "ignore" });
|
||||
} catch {
|
||||
fail("docker is not running (or not installed) — start Docker and retry.");
|
||||
}
|
||||
if (!existsSync(join(repoRoot, ".npmrc"))) {
|
||||
fail(
|
||||
".npmrc missing at repo root — image builds need GitHub Packages auth " +
|
||||
"for @tria-plc (same file the main docker-compose.yaml uses).",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
function isPortFree(port) {
|
||||
return new Promise((res) => {
|
||||
const srv = createServer();
|
||||
srv.once("error", () => res(false));
|
||||
srv.once("listening", () => srv.close(() => res(true)));
|
||||
srv.listen(port);
|
||||
});
|
||||
}
|
||||
|
||||
function stackRunning(env) {
|
||||
try {
|
||||
const out = execFileSync(
|
||||
"docker",
|
||||
[...composeBase, "ps", "--services", "--status", "running"],
|
||||
{ encoding: "utf8", env, stdio: ["ignore", "pipe", "ignore"] },
|
||||
);
|
||||
const running = new Set(out.split("\n").filter(Boolean));
|
||||
return SERVICES.every((s) => running.has(s));
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* While the stack runs, ports are whatever it was started with (state file,
|
||||
* else the defaults — a hand-started stack used the compose defaults). Only a
|
||||
* fresh start gets to scan for free ports.
|
||||
*/
|
||||
async function resolvePorts() {
|
||||
if (stackRunning(process.env)) {
|
||||
return existsSync(stateFile)
|
||||
? JSON.parse(readFileSync(stateFile, "utf8"))
|
||||
: { ...DEFAULT_PORTS };
|
||||
}
|
||||
const ports = {};
|
||||
const taken = new Set();
|
||||
for (const [name, preferred] of Object.entries(DEFAULT_PORTS)) {
|
||||
let port = preferred;
|
||||
while (taken.has(port) || !(await isPortFree(port))) port += 1;
|
||||
taken.add(port);
|
||||
ports[name] = port;
|
||||
if (port !== preferred)
|
||||
console.log(`e2e: port ${preferred} busy → ${name}=${port}`);
|
||||
}
|
||||
return ports;
|
||||
}
|
||||
|
||||
function envFor(ports) {
|
||||
return {
|
||||
...process.env,
|
||||
...Object.fromEntries(
|
||||
Object.entries(ports).map(([k, v]) => [k, String(v)]),
|
||||
),
|
||||
CYPRESS_BASE_URL: `http://localhost:${ports.E2E_BACKOFFICE_PORT}`,
|
||||
CYPRESS_API_URL: `http://localhost:${ports.E2E_API_PORT}`,
|
||||
CYPRESS_PORTAL_URL: `http://localhost:${ports.E2E_PORTAL_PORT}`,
|
||||
E2E_DB_URL: `postgres://edr_e2e:edr_e2e@localhost:${ports.E2E_DB_PORT}/edr_freight_e2e`,
|
||||
};
|
||||
}
|
||||
|
||||
function compose(args, env) {
|
||||
const { status } = spawnSync("docker", [...composeBase, ...args], {
|
||||
stdio: "inherit",
|
||||
env,
|
||||
});
|
||||
return status ?? 1;
|
||||
}
|
||||
|
||||
function up(ports, env) {
|
||||
preflight();
|
||||
console.log(
|
||||
`e2e: starting stack — api :${ports.E2E_API_PORT} portal :${ports.E2E_PORTAL_PORT} backoffice :${ports.E2E_BACKOFFICE_PORT} db :${ports.E2E_DB_PORT}`,
|
||||
);
|
||||
const status = compose(["up", "-d", "--build", "--wait"], env);
|
||||
if (status !== 0)
|
||||
fail(
|
||||
"stack failed to become healthy. Inspect with:\n" +
|
||||
" docker compose -f docker-compose.e2e.yaml logs freight-api-e2e",
|
||||
);
|
||||
writeFileSync(stateFile, JSON.stringify(ports, null, 2) + "\n");
|
||||
}
|
||||
|
||||
function ensureUp(ports, env) {
|
||||
if (stackRunning(env)) return;
|
||||
up(ports, env);
|
||||
}
|
||||
|
||||
function runPnpm(script, extra, env) {
|
||||
const { status } = spawnSync(
|
||||
"pnpm",
|
||||
["--filter", "@edr/freight-e2e", "run", script, ...extra],
|
||||
{ cwd: repoRoot, stdio: "inherit", env },
|
||||
);
|
||||
process.exit(status ?? 1);
|
||||
}
|
||||
|
||||
const [cmd, ...extra] = process.argv.slice(2);
|
||||
const ports = await resolvePorts();
|
||||
const env = envFor(ports);
|
||||
|
||||
switch (cmd) {
|
||||
case "up":
|
||||
up(ports, env);
|
||||
break;
|
||||
case "run":
|
||||
ensureUp(ports, env);
|
||||
runPnpm("cy:run", extra, env);
|
||||
break;
|
||||
case "open":
|
||||
ensureUp(ports, env);
|
||||
runPnpm("cy:open", extra, env);
|
||||
break;
|
||||
case "ci":
|
||||
ensureUp(ports, env);
|
||||
process.exit(compose(["--profile", "cypress", "run", "--rm", "cypress", ...extra], env));
|
||||
break;
|
||||
case "down":
|
||||
process.exit(
|
||||
(() => {
|
||||
const status = compose(["down", "-v", "--remove-orphans"], env);
|
||||
rmSync(stateFile, { force: true });
|
||||
return status;
|
||||
})(),
|
||||
);
|
||||
break;
|
||||
default:
|
||||
fail(`unknown command "${cmd ?? ""}" — use up | run | open | ci | down`);
|
||||
}
|
||||
16
e2e/freight/tsconfig.json
Normal file
16
e2e/freight/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"types": ["cypress", "node"]
|
||||
},
|
||||
"include": ["cypress/**/*.ts", "cypress.config.ts"]
|
||||
}
|
||||
@@ -23,6 +23,11 @@
|
||||
"format": "prettier --write \"**/*.{ts,tsx,json,md}\"",
|
||||
"docker:build": "docker compose build",
|
||||
"docker:up": "docker compose up -d",
|
||||
"e2e:freight:up": "node e2e/freight/scripts/e2e.mjs up",
|
||||
"e2e:freight:open": "node e2e/freight/scripts/e2e.mjs open",
|
||||
"e2e:freight:run": "node e2e/freight/scripts/e2e.mjs run",
|
||||
"e2e:freight:ci": "node e2e/freight/scripts/e2e.mjs ci",
|
||||
"e2e:freight:down": "node e2e/freight/scripts/e2e.mjs down",
|
||||
"prepare": "husky"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
812
pnpm-lock.yaml
generated
812
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
@@ -4,6 +4,7 @@ packages:
|
||||
- "apps/edr-passenger-web/*"
|
||||
- "packages/*"
|
||||
- "packages/config/*"
|
||||
- "e2e/*"
|
||||
verifyDepsBeforeRun: warn
|
||||
allowBuilds:
|
||||
"@nestjs/core": true
|
||||
@@ -14,6 +15,7 @@ allowBuilds:
|
||||
argon2: true
|
||||
bcrypt: true
|
||||
core-js: true
|
||||
cypress: true
|
||||
es5-ext: true
|
||||
esbuild: true
|
||||
highlight.js: true
|
||||
|
||||
Reference in New Issue
Block a user