# @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. ### The 40-scenario suite (`flows/g1_*` … `flows/g10_*`) S1–S40 from the scenario document, one file per group after Group 1: | File | Scenarios | Subject | | --- | --- | --- | | `g1_s1_expiry_promotes_waitlist.cy.ts` | S1 | expiry frees exactly the waitlist's space | | `g1_s2_exact_fill.cy.ts` | S2 | four bookings fill the train to the slot | | `g1_s3_underfill_day_stays_open.cy.ts` | S3 | under-filled day stays bookable | | `g1_s4_split_closes_gap.cy.ts` | S4 | a split closes the last gap | | `g1_s5_cascading_expiry.cy.ts` | S5 | one expiry cascades into a second promotion | | `g1_s6_s8_offers_and_priority.cy.ts` | S6–S8 | declined split, government preemption, priority tiers | | `g2_weight.cy.ts` | S9–S12 | weight vs slots, and the tolerance rules | | `g3_export.cy.ts` | S13–S18 | export FCFS, whole-or-nothing | | `g4_multi_schedule.cy.ts` | S19–S21 | two trains on one day | | `g5_waitlist.cy.ts` | S22–S24 | recovery: rebooking, split remainders, queue walking | | `g6_corridor.cy.ts` | S25–S29 | the run: alighting, tracking, checkpoints | | `g7_disruptions.cy.ts` | S30–S33 | cancel, wagon shortage, breakage, under-filled dispatch | | `g8_import_customs.cy.ts` | S34–S37 | the clearance chain | | `g9_delivery.cy.ts` | S38–S39 | self-haul trucks and last-mile | | `g10_validation.cy.ts` | S40 | line validation and the parked re-priced booking | **Read `SCENARIO_ENGINE_NOTES.md` before changing any of these.** Five scenarios describe behaviour the engine does not implement (out-of-order checkpoints, second-duty gating, hazardous/reefer clamping) or invert what it does (mid-corridor intercity). Those are written as a passing test of CURRENT behaviour plus an adjacent `it.skip` naming the desired behaviour — un-skipping one is the definition of done for the corresponding fix, not a test repair. Three specs are also flag- or policy-dependent and say so in their headers: `g3_export` needs `FREIGHT_EXPORT_SPLIT` off, and `g4_multi_schedule` asserts the whole-placement policy (S20) rather than the fill-first one (S21). ### Group 1 conventions (`flows/g1_*.cy.ts`) The visual counterpart to the corridor suite — helpers in `flows/g1-utils.ts`, arrange-data in `fixtures/seed-g1-train.sql` (run it AFTER `seed-import-corridor.sql`). Two things make these different from the older flow specs: - **A 53-wagon BUILT train** (`TRN-G1-1`), not a loco pair. A loco-pair schedule cannot hold 53: `syncScheduleMaxWagons` recomputes `max_wagons` from locomotive length (`floor(760 / 13.966) = 54` on this corridor). A built train's physical consist wins outright — see `booking-batch.service.ts:4152`. The consist staff marshal IS the capacity. - **The configuration phase and every capacity verdict run through the UI**: the consist is seen in the Train Builder, the schedule is created through the real "New schedule" form, the batch is run from the `Doc review complete — run batch` button, and FULL/NOT FULL is read off the batch board's Priority Tracking tab — which renders the literal `Capacity line · 53/53 wagons · FULL` divider plus `In the batch` / `Waiting list` / `Expired` lanes. Bulk cargo still goes through the API (`bookContainers`): a 30-wagon booking is 30-60 ISO-number inputs, which tests the form rather than the engine. Each scenario books its ONE small booking visually via `bookContainersVisually()`. **Payment is always API-driven** — the portal has no mock payment path; "Pay now" redirects off-origin to a real gateway, which Cypress cannot follow. ## 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.