The switch clears the registration but not the tax number, so the company step reopens with the old TIN and RHF re-seeds the field when the profile refetch lands. A TIN typed before that arrived was silently replaced by the stored one and the lookup ran against the wrong number. The spec now waits for the rehydrated VAT value, then asserts the field holds what it typed before waiting on eTrade.
@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:
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— passwordpassword@tria - Customers (portal):
user@gmail.com,user2@gmail.com— password12345678
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 theauth-token/refresh-tokencookies the apps read. - Origins:
baseUrlis the backoffice (5383). Portal specscy.visitthe absolute portal URL; a test that touches both apps wraps portal steps incy.origin()(different port = different origin). Cookies ignore ports — always call the matching login command right before switching apps socy.sessionrestores the right cookie snapshot. - DB access:
cy.task("db:query", { sql, params })runs SQL against the e2e database (E2E_DB_URL, defaultlocalhost: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 viacy.request(no browser)cypress/e2e/backoffice/— staff appcypress/e2e/portal/— customer appcypress/e2e/flows/— cross-app journeys (both directions):onboarding.cy.ts— signup → OTP → wizard (docs + license upload) → backoffice approval → customer can contractcontract-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 withretries: 0and 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:syncScheduleMaxWagonsrecomputesmax_wagonsfrom locomotive length (floor(760 / 13.966) = 54on this corridor). A built train's physical consist wins outright — seebooking-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 batchbutton, and FULL/NOT FULL is read off the batch board's Priority Tracking tab — which renders the literalCapacity line · 53/53 wagons · FULLdivider plusIn the batch/Waiting list/Expiredlanes.
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.