Merge remote-tracking branch 'origin/dev' into tests

This commit is contained in:
Muluhabt
2026-07-22 16:33:07 +03:00
212 changed files with 11497 additions and 3447 deletions

108
e2e/freight/README.md Normal file
View 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.

View File

@@ -0,0 +1,99 @@
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.
*/
/** Apply one idempotent SQL fixture from cypress/fixtures (arrange-data). */
async "db:seedFile"(file: string) {
const client = new Client({ connectionString: dbUrl });
await client.connect();
try {
const sql = readFileSync(
join(process.cwd(), "cypress", "fixtures", file),
"utf8",
);
await client.query(sql);
return true;
} finally {
await client.end();
}
},
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();
}
},
});
},
},
});

View 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 {};

View 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 {};

View 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 {};

View 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 {};

View 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 {};

View File

@@ -0,0 +1,524 @@
/**
* Export ONE_TIME journeys — two contracts ride the same export train
* (Mojo Dry Port → Dire Dawa Yard → Djibouti Port Terminal):
*
* A. CONTAINER (20ft + 40ft):
* portal wizard → staff approval chain → OTP sign → counter-sign
* → AWAITING_CLEARANCE_DOCUMENTS (export self-clear has no required
* docs in e2e) → ops finalize → FULLY_EXECUTED → customer books
* 2 × 20ft + 1 × 40ft picking a real Shipment day → ops accepts the
* operation request → EXPORT is FCFS, so accept reserves the train slot
* immediately: SELECTED_FOR_BATCH with a pay deadline clamped to the
* export window close → staff mark-paid → PAID + SCHEDULED + linked.
*
* B. BULK (E2E Wheat, 60 tons): same journey through the bulk wizard and
* bulk booking form, riding CW4 covered wagons on the same train.
*
* Infrastructure (route, built train, distances, rates, cargo types) comes
* from seed-intercity.sql + seed-export.sql; the export route and the
* departing-today schedule are created through the UI when missing.
*
* Sequential steps of one journey — retries off (steps are not idempotent).
*/
const customer = "user@gmail.com";
const companyTin = "0102030405"; // seed-company.sql
const opsStaff = "operation@edr.local";
const ORIGIN_YARD = "Mojo Dry Port";
const MID_YARD = "Dire Dawa Yard";
const PORT_YARD = "Djibouti Port Terminal";
const TRAIN_CODE = "TRN-E2E-1";
// Container numbers must be ISO (4 letters + 7 digits) and unused — stamp per run.
const stamp = String(Date.now());
const isoNumber = (prefix: string, offset: number) =>
`${prefix}${String(Number(stamp.slice(-7)) + offset).padStart(7, "0")}`;
const apiUrl = () => Cypress.env("apiUrl") as string;
function dbContract(freight: "CONTAINER" | "BULK") {
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 AND ct.trade_direction = 'EXPORT' AND ct.freight_type = $2
ORDER BY ct.created_at DESC LIMIT 1`,
params: [companyTin, freight],
},
);
}
function withContract(
freight: "CONTAINER" | "BULK",
fn: (c: { id: string; reference: string; status: string }) => void,
) {
dbContract(freight).then(({ rows }) => {
expect(rows, `latest EXPORT ${freight} contract`).to.have.length(1);
fn(rows[0]);
});
}
function expectContractStatus(freight: "CONTAINER" | "BULK", expected: string) {
dbContract(freight).then(({ rows }) => {
expect(rows[0]?.status, "contract status").to.eq(expected);
});
}
function dbBooking(freight: "CONTAINER" | "BULK") {
return cy.task<{
rows: Array<{
id: string;
reference: string;
status: string;
scheduling_status: string;
train_schedule_id: string | null;
payment_deadline: string | null;
scheduled_date: string | null;
}>;
}>("db:query", {
sql: `SELECT b.id, b.reference, b.status, b.scheduling_status,
b.train_schedule_id, b.payment_deadline, b.scheduled_date
FROM freight.bookings b
JOIN freight.companies c ON c.id = b.company_id
WHERE c.tin = $1 AND b.trade_direction = 'EXPORT' AND b.freight_type = $2
ORDER BY b.created_at DESC LIMIT 1`,
params: [companyTin, freight],
});
}
function withBooking(
freight: "CONTAINER" | "BULK",
fn: (b: {
id: string;
reference: string;
status: string;
scheduling_status: string;
train_schedule_id: string | null;
payment_deadline: string | null;
scheduled_date: string | null;
}) => void,
) {
dbBooking(freight).then(({ rows }) => {
expect(rows, `EXPORT ${freight} booking`).to.have.length(1);
fn(rows[0]);
});
}
/**
* The journey's export schedule. Export trains must be scheduled ≥ the booking
* lead (24h) ahead, and their window OPENS at departure lead — so the spec
* departs at now + 24h + a couple of minutes: creatable now, window opens
* minutes later. (The intercity spec's train leaves in 2 days — outside 25h.)
*/
function dbUpcomingSchedule() {
return cy.task<{
rows: Array<{ id: string; window_closes_at: string; booking_window_status: string }>;
}>("db:query", {
sql: `SELECT ts.id, ts.window_closes_at, ts.booking_window_status
FROM freight.train_schedules ts
WHERE ts.direction = 'EXPORT' AND ts.deleted_at IS NULL
AND ts.scheduled_departure_date > now()
AND ts.scheduled_departure_date < now() + interval '25 hours'
ORDER BY ts.created_at DESC LIMIT 1`,
});
}
/** The train departs ~24h out — bookings ride its departure day (tomorrow). */
const SHIPMENT_DAY = new Date(Date.now() + 24 * 3_600_000 + 150_000);
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 });
});
}
/** Fill the N-th input whose label matches (two container-size editors both say "Quantity *"). */
function fillNth(label: RegExp, index: number, value: string) {
cy.get("label").then(($labels) => {
const matches = $labels.filter((_, el) => label.test(el.textContent ?? ""));
expect(matches.length, `labels matching ${label}`).to.be.greaterThan(index);
const id = matches.eq(index).attr("for");
cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true });
});
}
/**
* Choose the train's departure day on the Schedule card's INLINE calendar
* (cargo-aware: days only unlock once the cargo details above are valid).
*/
function pickShipmentDay() {
cy.contains(/available day/, { timeout: 30000 }).should("exist");
// Day cells are plain buttons in a div grid; only bookable days are enabled
// (out-of-month duplicates stay disabled).
const day = String(SHIPMENT_DAY.getDate());
cy.get("button:not(:disabled)", { timeout: 15000 })
.contains(new RegExp(`^${day}$`))
.click({ force: true });
}
/** Shared staff steps: accept + LINE_STAFF approve, then director approve. */
function approveChain(freight: "CONTAINER" | "BULK") {
cy.loginBackoffice("marketer@edr.local");
withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
cy.contains("button", "Accept for approval", { timeout: 20000 }).click();
cy.contains("button", "Accept & start approval", { timeout: 20000 })
.should("not.be.disabled")
.click();
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");
cy.loginBackoffice("director@edr.local");
withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
cy.contains("button", "Approve", { timeout: 20000 }).click();
cy.contains("button", "Confirm approval").click();
cy.contains("button", "View & sign", { timeout: 30000 }).should("exist");
expectContractStatus(freight, "CONTRACT_READY");
}
/** Shared customer OTP-signature step. */
function customerSigns(freight: "CONTAINER" | "BULK") {
cy.loginPortal(customer);
withContract(freight, (c) => cy.visitPortal(`/contracts/${c.id}/view`));
const unlockConsent = (attempt: number) => {
cy.get('iframe[title="Contract document"]', { timeout: 30000 }).then(($f) => {
const win = ($f[0] as HTMLIFrameElement).contentWindow;
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();
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();
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");
expectContractStatus(freight, "SIGNED_CUSTOMER");
}
/** Shared counter-sign + ops finalize (export self-clear: no required docs in e2e). */
function counterSignAndFinalize(freight: "CONTAINER" | "BULK") {
cy.loginBackoffice("marketer@edr.local");
withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}/view`));
cy.contains("button", /^Sign as staff$|^Approve & sign$/, { timeout: 30000 }).click();
cy.contains("label", "Full name")
.invoke("attr", "for")
.then((id) => {
cy.get(`[id="${id}"]`).clear().type("EDR Marketer");
});
cy.drawSignature();
cy.get(".mantine-Modal-content")
.contains("button", /^Confirm signature$|^Approve & sign$/)
.click();
cy.contains("counter-signed", { timeout: 30000 }).should("be.visible");
expectContractStatus(freight, "AWAITING_CLEARANCE_DOCUMENTS");
cy.loginBackoffice(opsStaff);
withContract(freight, (c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
cy.contains('[role="tab"]', "Clearance Review", { timeout: 30000 }).click();
cy.contains("button", "Finalize document approval", { timeout: 30000 })
.should("not.be.disabled")
.click();
cy.contains("finalized", { timeout: 30000 }).should("be.visible");
expectContractStatus(freight, "FULLY_EXECUTED");
}
/** Ops accept: EXPORT is FCFS — accept reserves the slot and opens the pay window. */
function acceptAndAssertReserved(freight: "CONTAINER" | "BULK") {
cy.loginBackoffice(opsStaff);
withBooking(freight, (b) => cy.visit(`/dashboard/booking-requests/${b.id}`));
cy.contains("button", /Accept operation|^Accept$/, { timeout: 20000 }).click();
cy.contains("Accept operation request?", { timeout: 15000 }).should("be.visible");
cy.get(".mantine-Modal-content").contains("button", /^Accept$/).click();
cy.get(".mantine-Modal-content", { timeout: 30000 }).should("not.exist");
dbUpcomingSchedule().then(({ rows: schedules }) => {
expect(schedules, "departing-today export schedule").to.have.length(1);
withBooking(freight, (b) => {
expect(b.status, "FCFS reservation").to.eq("SELECTED_FOR_BATCH");
expect(b.train_schedule_id).to.eq(schedules[0].id);
// Export parity: the pay window never outlives the booking window close.
expect(b.payment_deadline, "pay deadline set").to.be.a("string");
expect(new Date(b.payment_deadline!).getTime()).to.be.at.most(
new Date(schedules[0].window_closes_at).getTime(),
);
});
});
}
function markPaidAndAssertAllocated(freight: "CONTAINER" | "BULK") {
withBooking(freight, (b) => {
cy.apiLogin(opsStaff).then(({ token }) => {
cy.request({
method: "POST",
url: `${apiUrl()}/api/train-scheduling/bookings/${b.id}/mark-paid`,
headers: { Authorization: `Bearer ${token}` },
})
.its("status")
.should("be.oneOf", [200, 201]);
});
});
withBooking(freight, (b) => {
expect(b.status).to.eq("PAID");
expect(b.scheduling_status).to.eq("SCHEDULED");
cy.task<{ rows: Array<{ n: string }> }>("db:query", {
sql: `SELECT count(*) AS n FROM freight.train_schedule_bookings
WHERE booking_id = $1 AND deleted_at IS NULL`,
params: [b.id],
}).then(({ rows }) => {
expect(Number(rows[0].n), "train_schedule_bookings link").to.eq(1);
});
});
}
describe("export one-time journeys: container + bulk on one train", { retries: 0 }, () => {
before(() => {
cy.task("db:seedFile", "seed-intercity.sql");
cy.task("db:seedFile", "seed-export.sql");
});
// ── Shared infrastructure ─────────────────────────────────────────────────
it("operations ensures the export route exists", () => {
cy.loginBackoffice(opsStaff);
cy.task<{ rows: Array<{ n: string }> }>("db:query", {
sql: `SELECT count(*) AS n
FROM freight.routes r
JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO'
JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = 'DJIB_PORT'
WHERE r.deleted_at IS NULL`,
}).then(({ rows }) => {
if (Number(rows[0].n) > 0) return;
cy.visit("/dashboard/routes");
cy.contains("button", "Add route", { timeout: 20000 }).click();
cy.contains("Add Route", { timeout: 15000 }).should("be.visible");
cy.get(".mantine-Modal-content").contains("button", "Add milestone").click();
const pickYard = (index: number, yard: string) => {
cy.get('.mantine-Modal-content input[placeholder="Select yard"]')
.eq(index)
.click({ force: true });
cy.get('[role="option"]:visible').contains(yard).click();
};
pickYard(0, ORIGIN_YARD);
pickYard(1, MID_YARD);
pickYard(2, PORT_YARD);
cy.get(".mantine-Modal-content").contains("button", "Save").click();
});
});
it("operations schedules the export train — booking window opens", () => {
cy.loginBackoffice(opsStaff);
dbUpcomingSchedule().then(({ rows }) => {
if (rows.length > 0) return;
cy.visit("/dashboard/operations/train-scheduling-v2");
cy.contains("button", "New schedule", { timeout: 20000 }).click();
cy.contains("Create train schedule", { timeout: 15000 }).should("be.visible");
cy.mantineSelect(/^Route$/, new RegExp(ORIGIN_YARD));
// Just past the 24h scheduling lead: creatable now, and the export
// window (opens departure lead) flips OPEN a couple of minutes later.
const local = new Date(
SHIPMENT_DAY.getTime() - SHIPMENT_DAY.getTimezoneOffset() * 60000,
)
.toISOString()
.slice(0, 16);
cy.get('.mantine-Modal-content input[type="datetime-local"]')
.clear({ force: true })
.type(local, { force: true });
cy.mantineSelect(/^Train$/, new RegExp(TRAIN_CODE));
cy.get(".mantine-Modal-content").contains("button", "Create").click();
cy.location("pathname", { timeout: 30000 }).should(
"match",
/\/dashboard\/operations\/train-scheduling-v2\/.+/,
);
});
// The 10s window tick flips PRE_WINDOW → OPEN once the lead moment passes.
const waitForOpenWindow = (attempt: number) => {
dbUpcomingSchedule().then(({ rows }) => {
expect(rows, "upcoming export schedule").to.have.length(1);
if (rows[0].booking_window_status === "OPEN") return;
expect(attempt, "export booking window OPEN").to.be.lessThan(40);
cy.wait(10000).then(() => waitForOpenWindow(attempt + 1));
});
};
waitForOpenWindow(0);
});
// ── Journey A: container 20ft + 40ft ──────────────────────────────────────
it("customer submits an export container contract (20ft + 40ft)", () => {
cy.loginPortal(customer);
cy.visitPortal("/contracts/new");
cy.mantineSelect(/^Operation Type/, /^Export$/);
cy.mantineSelect(/^Contract Kind/, "One-Time Contract");
cy.mantineSelect(/^New or Renewal/, "New Contract");
cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click({ force: true });
cy.mantineSelect(/^Payment Currency/, /^ETB/);
cy.contains("button", "Continue").click({ force: true });
cy.mantineSelect(/^Cargo Scope/, /Containerized/);
cy.get('[role="checkbox"][aria-label="20ft Container"]').click();
cy.get('[role="checkbox"][aria-label="40ft Container"]').click();
cy.get('textarea[placeholder*="Electronics"]').type("E2E export electronics");
cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD);
cy.mantineSelect(/^Destination Yard/, PORT_YARD);
cy.contains("button", "Continue").click({ force: true });
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");
expectContractStatus("CONTAINER", "SUBMITTED");
});
it("staff approve the container contract (marketer + director)", () => {
approveChain("CONTAINER");
});
it("customer signs the container contract with OTP", () => {
customerSigns("CONTAINER");
});
it("staff counter-sign and operations finalize the container contract", () => {
counterSignAndFinalize("CONTAINER");
});
it("customer books 2 × 20ft + 1 × 40ft with a shipment day", () => {
cy.loginPortal(customer);
withContract("CONTAINER", (c) => cy.visitPortal(`/contracts/${c.id}/bookings/new`));
cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible");
// Two size editors, each with its own "Quantity *" (20ft first, then 40ft).
fillNth(/^Quantity/, 0, "2");
fillNth(/^Quantity/, 1, "1");
cy.get('input[placeholder*="MSCU"]', { timeout: 15000 }).should("have.length", 3);
cy.get('input[placeholder*="MSCU"]').eq(0).type(isoNumber("MSCU", 0));
cy.get('input[placeholder*="MSCU"]').eq(1).type(isoNumber("TCLU", 1));
cy.get('input[placeholder*="MSCU"]').eq(2).type(isoNumber("FSCU", 2));
cy.get('input[placeholder*="24.5"]').each(($input) => {
cy.wrap($input).clear({ force: true }).type("10", { force: true });
});
pickShipmentDay();
cy.contains("button", "Review price & book").should("not.be.disabled").click();
cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible");
cy.contains("button", "Confirm & book").click();
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
withBooking("CONTAINER", (b) => {
expect(b.status).to.eq("OPERATION_REQUEST_PENDING");
expect(b.scheduled_date, "export bookings carry a shipment day").to.be.a("string");
});
});
it("operations accepts the container request — FCFS reserves today's train", () => {
acceptAndAssertReserved("CONTAINER");
});
it("staff mark the container booking paid — allocated onto the train", () => {
markPaidAndAssertAllocated("CONTAINER");
});
// ── Journey B: bulk (E2E Wheat) ───────────────────────────────────────────
it("customer submits an export bulk contract (wheat)", () => {
cy.loginPortal(customer);
cy.visitPortal("/contracts/new");
cy.mantineSelect(/^Operation Type/, /^Export$/);
cy.mantineSelect(/^Contract Kind/, "One-Time Contract");
cy.mantineSelect(/^New or Renewal/, "New Contract");
cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click({ force: true });
cy.mantineSelect(/^Payment Currency/, /^ETB/);
cy.contains("button", "Continue").click({ force: true });
cy.mantineSelect(/^Cargo Scope/, /General \/ Bulk cargo/);
cy.mantineSelect(/^Bulk Cargo Type/, "E2E Grains");
cy.mantineSelect(/^Commodity/, "E2E Wheat");
cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD);
cy.mantineSelect(/^Destination Yard/, PORT_YARD);
cy.contains("button", "Continue").click({ force: true });
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");
expectContractStatus("BULK", "SUBMITTED");
});
it("staff approve the bulk contract (marketer + director)", () => {
approveChain("BULK");
});
it("customer signs the bulk contract with OTP", () => {
customerSigns("BULK");
});
it("staff counter-sign and operations finalize the bulk contract", () => {
counterSignAndFinalize("BULK");
});
it("customer books 60 tons of wheat with a shipment day", () => {
cy.loginPortal(customer);
withContract("BULK", (c) => cy.visitPortal(`/contracts/${c.id}/bookings/new`));
cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible");
fill(/^Quantity \(tons\)/, "60");
pickShipmentDay();
cy.contains("button", "Review price & book").should("not.be.disabled").click();
cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible");
cy.contains("button", "Confirm & book").click();
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
withBooking("BULK", (b) => {
expect(b.status).to.eq("OPERATION_REQUEST_PENDING");
});
});
it("operations accepts the bulk request — FCFS reserves today's train", () => {
acceptAndAssertReserved("BULK");
});
it("staff mark the bulk booking paid — allocated onto the train", () => {
markPaidAndAssertAllocated("BULK");
});
});
export {};

View File

@@ -0,0 +1,574 @@
/**
* Intercity ONE_TIME journey — the full life of a domestic ride-along shipment:
*
* 1. portal — customer creates an INTERCITY / One-Time / Container contract
* (Mojo Dry Port → Dire Dawa Yard) and submits it
* 2. backoffice — marketer REJECTS it with a reason
* 3. portal — customer sees the rejection banner + reason, then submits a
* fresh contract
* 4. backoffice — marketer accepts + approves LINE_STAFF, director approves
* → CONTRACT_READY
* 5. portal — customer OTP-signs → SIGNED_CUSTOMER
* 6. backoffice — marketer counter-signs → AWAITING_CLEARANCE_DOCUMENTS
* (ONE_TIME intercity always routes through the
* intercity-documents step; the fixture seeds one REQUIRED
* document so the step is real)
* 6b. portal — customer uploads the required intercity document
* → CLEARANCE_UNDER_REVIEW
* 7. backoffice — operations approves the document, then finalizes document
* approval → FULLY_EXECUTED
* 8. portal — customer books 2 × 20ft under the contract (intercity has
* no shipment date) → booking OPERATION_REQUEST_PENDING
* 9. backoffice — operations accepts the operation request → booking
* FULLY_EXECUTED (intercity waiting pool)
* 10. backoffice — operations creates the EXPORT route
* Mojo → Dire Dawa → Djibouti Port (distances seeded)
* 11. backoffice — operations schedules the export train (built Train-Builder
* train seeded by seed-intercity.sql)
* 12. backoffice — operations accepts the intercity booking onto the train
* (Workspace → Intercity ride-along) → SELECTED_FOR_BATCH with
* a pay deadline that never outlives the export window close
* 13. staff mark-paid (API — the batch panel has no mounted UI button)
* → PAID + SCHEDULED + linked to the schedule
*
* Sequential steps of one journey — retries off (steps are not idempotent).
*/
const customer = "user@gmail.com";
const companyTin = "0102030405"; // seed-company.sql
const opsStaff = "operation@edr.local";
const ORIGIN_YARD = "Mojo Dry Port";
const DEST_YARD = "Dire Dawa Yard";
const PORT_YARD = "Djibouti Port Terminal";
const TRAIN_CODE = "TRN-E2E-1";
const apiUrl = () => Cypress.env("apiUrl") as string;
/** Latest contract of the seeded company — the journey's contract. */
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 AND ct.trade_direction = 'DOMESTIC'
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 expectContractStatus(expected: string) {
dbContract().then(({ rows }) => {
expect(rows[0]?.status, "contract status").to.eq(expected);
});
}
/** Latest DOMESTIC booking of the seeded company — the journey's booking. */
function dbBooking() {
return cy.task<{
rows: Array<{
id: string;
reference: string;
status: string;
scheduling_status: string;
train_schedule_id: string | null;
payment_deadline: string | null;
scheduled_date: string | null;
}>;
}>("db:query", {
sql: `SELECT b.id, b.reference, b.status, b.scheduling_status,
b.train_schedule_id, b.payment_deadline, b.scheduled_date
FROM freight.bookings b
JOIN freight.companies c ON c.id = b.company_id
WHERE c.tin = $1 AND b.trade_direction = 'DOMESTIC'
ORDER BY b.created_at DESC LIMIT 1`,
params: [companyTin],
});
}
function withBooking(
fn: (b: {
id: string;
reference: string;
status: string;
scheduling_status: string;
train_schedule_id: string | null;
payment_deadline: string | null;
scheduled_date: string | null;
}) => void,
) {
dbBooking().then(({ rows }) => {
expect(rows, "intercity booking for the seeded company").to.have.length(1);
fn(rows[0]);
});
}
/** Latest export schedule created by this journey. */
function dbSchedule() {
return cy.task<{
rows: Array<{ id: string; status: string; direction: string; window_closes_at: string }>;
}>("db:query", {
sql: `SELECT ts.id, ts.status, ts.direction, ts.window_closes_at
FROM freight.train_schedules ts
ORDER BY ts.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 });
});
}
/**
* Run the portal wizard for an INTERCITY / One-Time / Container contract and
* submit it. Reused for the initial (to-be-rejected) and the second contract.
*/
function createIntercityContract() {
cy.loginPortal(customer);
cy.visitPortal("/contracts/new");
// Step 0 — Setup. Intercity forces ETB and hides the customs section.
cy.mantineSelect(/^Operation Type/, /^Intercity$/);
cy.mantineSelect(/^Contract Kind/, "One-Time Contract");
cy.mantineSelect(/^New or Renewal/, "New Contract");
cy.contains("button", "Rail Transport Only", { timeout: 15000 }).click();
cy.mantineSelect(/^Payment Currency/, /^ETB/);
cy.contains("button", "Continue").click({ force: true });
// Step 1 — Cargo & Route (Ethiopian yards only for intercity).
cy.mantineSelect(/^Cargo Scope/, /Containerized/);
cy.get('[role="checkbox"][aria-label="20ft Container"]').click();
cy.get('textarea[placeholder*="Electronics"]').type(
"E2E intercity electronics between Ethiopian yards",
);
cy.mantineSelect(/^Origin Yard/, ORIGIN_YARD);
cy.mantineSelect(/^Destination Yard/, DEST_YARD);
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");
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-/);
});
}
describe("intercity one-time journey: contract → booking → export train", { retries: 0 }, () => {
before(() => {
// Container types, locomotives, built train, yard distances — the
// infrastructure the UI journey cannot create in-flow.
cy.task("db:seedFile", "seed-intercity.sql");
});
// ── Contract: submit → reject → resubmit → approve → sign ────────────────
it("customer submits an intercity one-time contract", () => {
createIntercityContract();
});
it("marketer rejects the submission with a reason", () => {
cy.loginBackoffice("marketer@edr.local");
withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
cy.contains("button", "Reject contract", { timeout: 20000 }).click();
cy.get(".mantine-Modal-content")
.contains("label", "Reason for rejection")
.invoke("attr", "for")
.then((id) => {
cy.get(`[id="${id}"]`).type("E2E rejection — cargo details incomplete");
});
cy.get(".mantine-Modal-content").contains("button", /^Reject$/).click();
// Modal closes on success; the status pill can sit inside clipped layout,
// so the authoritative check is the DB row.
cy.get(".mantine-Modal-content", { timeout: 20000 }).should("not.exist");
expectContractStatus("REJECTED");
});
it("customer sees the rejection reason on the contracts list", () => {
cy.loginPortal(customer);
cy.visitPortal("/contracts");
// The list is a collapsed table — expand the rejected contract's row to
// reveal its step banner with the staff reason.
withContract((c) => {
cy.contains("tr", c.reference, { timeout: 20000 })
.find("button")
.first()
.click();
});
cy.contains("This contract was rejected.", { timeout: 20000 }).should("be.visible");
cy.contains("Reason: E2E rejection — cargo details incomplete").should("be.visible");
});
it("customer submits a fresh intercity contract", () => {
createIntercityContract();
});
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();
cy.contains("button", "Accept & start approval", { timeout: 20000 })
.should("not.be.disabled")
.click();
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");
expectContractStatus("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();
cy.contains("button", "View & sign", { timeout: 30000 }).should("exist");
expectContractStatus("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.
const unlockConsent = (attempt: number) => {
cy.get('iframe[title="Contract document"]', { timeout: 30000 }).then(($f) => {
const win = ($f[0] as HTMLIFrameElement).contentWindow;
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();
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();
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");
expectContractStatus("SIGNED_CUSTOMER");
});
it("marketer counter-signs — intercity one-time enters the documents step", () => {
cy.loginBackoffice("marketer@edr.local");
withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}/view`));
cy.contains("button", /^Sign as staff$|^Approve & sign$/, { timeout: 30000 }).click();
cy.contains("label", "Full name")
.invoke("attr", "for")
.then((id) => {
cy.get(`[id="${id}"]`).clear().type("EDR Marketer");
});
cy.drawSignature();
cy.get(".mantine-Modal-content")
.contains("button", /^Confirm signature$|^Approve & sign$/)
.click();
cy.contains("counter-signed", { timeout: 30000 }).should("be.visible");
// DOMESTIC one-time always routes through the intercity-documents step —
// unlike GENERAL, it does NOT go straight to CONTRACT_ACTIVE.
expectContractStatus("AWAITING_CLEARANCE_DOCUMENTS");
});
it("customer uploads the required intercity document", () => {
cy.loginPortal(customer);
// Deep link auto-opens the clearance documents modal.
withContract((c) => cy.visitPortal(`/contracts/${c.id}?action=clearance`));
cy.contains("Cargo Manifest", { timeout: 30000 }).should("exist");
cy.get('.mantine-Modal-content input[type="file"]')
.first()
.selectFile("cypress/fixtures/docs/license.pdf", { force: true });
cy.contains("button", "Submit documents", { timeout: 15000 })
.should("not.be.disabled")
.click();
// Upload hands the contract to Operations review — the card flips to
// "Under review" (the host modal may linger while queries refetch).
cy.contains("Under review", { timeout: 30000 }).should("exist");
expectContractStatus("CLEARANCE_UNDER_REVIEW");
});
it("operations approves the document and finalizes — contract fully executed", () => {
cy.loginBackoffice(opsStaff);
withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
// The clearance review section lives behind its own tab on the detail page.
cy.contains('[role="tab"]', "Clearance Review", { timeout: 30000 }).click();
// Approve the uploaded Cargo Manifest, then finalize.
cy.contains("button", /Approve all/, { timeout: 30000 }).click();
cy.contains("1/1 approved", { timeout: 30000 }).should("exist");
cy.contains("button", "Finalize document approval", { timeout: 30000 })
.should("not.be.disabled")
.click();
cy.contains("finalized", { timeout: 30000 }).should("be.visible");
expectContractStatus("FULLY_EXECUTED");
});
// ── Booking under the contract ────────────────────────────────────────────
it("customer books 2 × 20ft under the contract (no shipment date for intercity)", () => {
cy.loginPortal(customer);
withContract((c) => cy.visitPortal(`/contracts/${c.id}/bookings/new`));
cy.contains("New Shipment Booking", { timeout: 20000 }).should("be.visible");
// 20ft quantities must be even (pairs share a wagon).
fill(/^Quantity/, "2");
// One ISO container number per unit.
cy.get('input[placeholder*="MSCU"]', { timeout: 15000 }).should("have.length", 2);
cy.get('input[placeholder*="MSCU"]').eq(0).type("MSCU1234567");
cy.get('input[placeholder*="MSCU"]').eq(1).type("TCLU7654321");
// VGM per unit — column inputs carry a placeholder, not a linked label.
cy.get('input[placeholder*="24.5"]').each(($input) => {
cy.wrap($input).clear({ force: true }).type("10", { force: true });
});
// Intercity: no "Shipment day" picker — the ride-along note renders instead.
cy.contains("Shipment day").should("not.exist");
cy.contains("button", "Review price & book").should("not.be.disabled").click();
cy.contains("Confirm shipment price", { timeout: 30000 }).should("be.visible");
cy.contains("button", "Confirm & book").click();
cy.location("pathname", { timeout: 30000 }).should("match", /^\/bookings\/.+/);
withBooking((b) => {
expect(b.status).to.eq("OPERATION_REQUEST_PENDING");
expect(b.scheduled_date, "intercity bookings carry no scheduled date").to.eq(null);
});
});
it("operations accepts the operation request — booking joins the intercity pool", () => {
cy.loginBackoffice(opsStaff);
withBooking((b) => cy.visit(`/dashboard/booking-requests/${b.id}`));
cy.contains("button", /Accept operation|^Accept$/, { timeout: 20000 }).click();
cy.contains("Accept operation request?", { timeout: 15000 }).should("be.visible");
cy.get(".mantine-Modal-content").contains("button", /^Accept$/).click();
withBooking((b) => {
expect(b.status, "accepted intercity booking waits in the pool").to.eq("FULLY_EXECUTED");
expect(b.train_schedule_id).to.eq(null);
});
});
// ── Route + export schedule ───────────────────────────────────────────────
it("operations creates the export route Mojo → Dire Dawa → Djibouti Port", () => {
cy.loginBackoffice(opsStaff);
// Skip creation when a previous run already added the route (unique yards
// pair) — the journey stays re-runnable against a warm DB.
cy.task<{ rows: Array<{ n: string }> }>("db:query", {
sql: `SELECT count(*) AS n
FROM freight.routes r
JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO'
JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = 'DJIB_PORT'
WHERE r.deleted_at IS NULL`,
}).then(({ rows }) => {
if (Number(rows[0].n) > 0) return;
cy.visit("/dashboard/routes");
cy.contains("button", "Add route", { timeout: 20000 }).click();
cy.contains("Add Route", { timeout: 15000 }).should("be.visible");
// Third stop row, then fill Origin / Milestone / Destination in order.
cy.get(".mantine-Modal-content").contains("button", "Add milestone").click();
const pickYard = (index: number, yard: string) => {
cy.get('.mantine-Modal-content input[placeholder="Select yard"]')
.eq(index)
.click({ force: true });
// Three yard selects share option texts — only the open dropdown counts.
cy.get('[role="option"]:visible').contains(yard).click();
};
pickYard(0, ORIGIN_YARD);
pickYard(1, DEST_YARD);
pickYard(2, PORT_YARD);
// Distances (when the build has them) resolve from the seeded rows.
cy.get(".mantine-Modal-content").contains("button", "Save").click();
});
cy.task<{ rows: Array<{ direction: string }> }>("db:query", {
sql: `SELECT r.direction
FROM freight.routes r
JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO'
JOIN freight.yards d ON d.id = r.destination_yard_id AND d.code = 'DJIB_PORT'
WHERE r.deleted_at IS NULL`,
}).then(({ rows }) => {
expect(rows, "export route").to.have.length.at.least(1);
expect(rows[0].direction).to.eq("EXPORT");
});
});
it("operations schedules the export train from the built consist", () => {
cy.loginBackoffice(opsStaff);
// One departure per route per day — a warm DB from a previous run already
// has this train scheduled, so only create when none is live.
cy.task<{ rows: Array<{ n: string }> }>("db:query", {
sql: `SELECT count(*) AS n
FROM freight.train_schedules ts
JOIN freight.routes r ON r.id = ts.route_id
JOIN freight.yards o ON o.id = r.origin_yard_id AND o.code = 'MOJO'
WHERE ts.status IN ('DRAFT', 'SCHEDULED') AND ts.deleted_at IS NULL`,
}).then(({ rows }) => {
if (Number(rows[0].n) > 0) return;
cy.visit("/dashboard/operations/train-scheduling-v2");
cy.contains("button", "New schedule", { timeout: 20000 }).click();
cy.contains("Create train schedule", { timeout: 15000 }).should("be.visible");
cy.mantineSelect(/^Route$/, new RegExp(ORIGIN_YARD));
// Two days out, local datetime-local format.
const departure = new Date(Date.now() + 2 * 86400000);
const local = new Date(departure.getTime() - departure.getTimezoneOffset() * 60000)
.toISOString()
.slice(0, 16);
cy.get('.mantine-Modal-content input[type="datetime-local"]')
.clear({ force: true })
.type(local, { force: true });
cy.mantineSelect(/^Train$/, new RegExp(TRAIN_CODE));
cy.get(".mantine-Modal-content").contains("button", "Create").click();
// Create navigates straight to the new schedule's detail page.
cy.location("pathname", { timeout: 30000 }).should(
"match",
/\/dashboard\/operations\/train-scheduling-v2\/.+/,
);
});
dbSchedule().then(({ rows }) => {
expect(rows, "created schedule").to.have.length(1);
expect(rows[0].direction).to.eq("EXPORT");
});
});
// ── Intercity ride-along: accept → pay → allocated ────────────────────────
it("operations accepts the intercity booking onto the export train", () => {
cy.loginBackoffice(opsStaff);
dbSchedule().then(({ rows: schedules }) => {
cy.visit(`/dashboard/operations/train-scheduling-v2/${schedules[0].id}`);
});
cy.contains('[role="tab"]', "Workspace", { timeout: 30000 }).click();
// Presence, not viewport visibility — the panel can sit below the fold /
// inside clipped layout once earlier runs' rows stack up.
cy.contains("Intercity ride-along", { timeout: 30000 }).should("exist");
withBooking((b) => {
cy.contains("tr", b.reference, { timeout: 30000 })
.find('input[type="checkbox"]')
.check({ force: true });
cy.contains("button", /Accept .*onto this train/).click();
// Accepted table shows the pay-window state (inside a horizontal
// Table.ScrollContainer — assert presence, not viewport visibility).
cy.contains("Awaiting payment", { timeout: 30000 }).should("exist");
});
// Export parity: the ride-along's pay deadline never outlives the export
// booking window (reserve() clamps it to window_closes_at).
dbSchedule().then(({ rows: schedules }) => {
withBooking((b) => {
expect(b.status).to.eq("SELECTED_FOR_BATCH");
expect(b.train_schedule_id).to.eq(schedules[0].id);
expect(b.payment_deadline, "pay deadline set").to.be.a("string");
expect(new Date(b.payment_deadline!).getTime()).to.be.at.most(
new Date(schedules[0].window_closes_at).getTime(),
);
});
});
});
it("staff mark the ride-along paid — booking allocates onto the train", () => {
// ScheduleBatchPanel (the only "Mark paid" button) is not mounted in the
// current UI, so drive the staff override endpoint directly.
withBooking((b) => {
cy.apiLogin(opsStaff).then(({ token }) => {
cy.request({
method: "POST",
url: `${apiUrl()}/api/train-scheduling/bookings/${b.id}/mark-paid`,
headers: { Authorization: `Bearer ${token}` },
})
.its("status")
.should("be.oneOf", [200, 201]);
});
});
withBooking((b) => {
expect(b.status).to.eq("PAID");
expect(b.scheduling_status).to.eq("SCHEDULED");
expect(b.train_schedule_id, "still pinned to the export train").to.be.a("string");
// The schedule↔booking link row is what makes the booking visible on the
// train board, in yard work, and to the wagon planner.
cy.task<{ rows: Array<{ n: string }> }>("db:query", {
sql: `SELECT count(*) AS n FROM freight.train_schedule_bookings
WHERE booking_id = $1 AND deleted_at IS NULL`,
params: [b.id],
}).then(({ rows }) => {
expect(Number(rows[0].n), "train_schedule_bookings link").to.eq(1);
});
});
});
});
export {};

View 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 {};

View 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 {};

View 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 {};

View 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

View 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
);

View File

@@ -0,0 +1,85 @@
-- Arrange-data for flows/export_one_time.cy.ts. Idempotent.
-- Run AFTER seed-intercity.sql (reuses its container types, locomotives,
-- built train TRN-E2E-1 and yard distances).
--
-- 1. bulk cargo hierarchy: group "E2E Grains" → commodity "E2E Wheat",
-- carried on CW4 covered wagons
-- 2. two CW4 wagons coupled onto the train (bulk capacity)
-- 3. LIVE export rates for Mojo → Djibouti Port: container (per container)
-- and bulk (per ton) — booking pricing hard-blocks without them
-- 0. Approved exporter profile — picking "Export" in the wizard opens the
-- "Set up your exporter profile" modal unless the company already has an
-- active exporter profile (seed-company.sql only creates the importer).
INSERT INTO freight.company_profiles (id, company_id, type, status, reference)
SELECT gen_random_uuid(), c.id, 'exporter', 'active', 'EXP-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 = 'exporter'
);
-- 1a. Cargo type group + commodity.
INSERT INTO freight.cargo_types (id, code, cargo_type_name, is_active)
SELECT gen_random_uuid(), 'E2E_GRAINS', 'E2E Grains', true
WHERE NOT EXISTS (SELECT 1 FROM freight.cargo_types WHERE code = 'E2E_GRAINS');
INSERT INTO freight.cargo_types (id, code, cargo_type_name, parent_group_id, is_active)
SELECT gen_random_uuid(), 'E2E_WHEAT', 'E2E Wheat', g.id, true
FROM freight.cargo_types g
WHERE g.code = 'E2E_GRAINS'
AND NOT EXISTS (SELECT 1 FROM freight.cargo_types WHERE code = 'E2E_WHEAT');
-- 1b. Wheat rides CW4 covered wagons.
INSERT INTO freight.cargo_type_wagon_types (cargo_type_id, wagon_type_id)
SELECT ct.id, wt.id
FROM freight.cargo_types ct
JOIN freight.wagon_types wt ON wt.code = 'CW4'
WHERE ct.code IN ('E2E_WHEAT', 'E2E_GRAINS')
AND NOT EXISTS (
SELECT 1 FROM freight.cargo_type_wagon_types x
WHERE x.cargo_type_id = ct.id AND x.wagon_type_id = wt.id
);
-- 2. Couple two free CW4 wagons onto the train, parked at Mojo with it.
UPDATE freight.wagons w
SET train_id = t.id,
sequence_number = 100 + sub.rn,
current_yard_id = (SELECT id FROM freight.yards WHERE code = 'MOJO')
FROM freight.trains t,
LATERAL (
SELECT w2.id, row_number() OVER (ORDER BY w2.wagon_number) AS rn
FROM freight.wagons w2
JOIN freight.wagon_types wt ON wt.id = w2.wagon_type_id AND wt.code = 'CW4'
WHERE w2.train_id IS NULL AND w2.deleted_at IS NULL
ORDER BY w2.wagon_number
LIMIT 2
) sub
WHERE t.code = 'TRN-E2E-1'
AND w.id = sub.id
AND NOT EXISTS (
SELECT 1 FROM freight.wagons wx
JOIN freight.wagon_types wxt ON wxt.id = wx.wagon_type_id AND wxt.code = 'CW4'
WHERE wx.train_id = t.id
);
-- 3. LIVE export rates Mojo → Djibouti Port.
INSERT INTO freight.rates
(id, rate_type, applies_to, trigger, currency, rate_value, rate_unit, status,
origin_yard_id, destination_yard_id, proposed_by_staff_id)
SELECT gen_random_uuid(), v.rate_type, v.applies_to, 'ALWAYS', 'USD', v.value,
v.unit, 'LIVE', a.id, b.id, u.id
FROM (VALUES
('CONTAINER_EXPORT', 'CONTAINER', 600, 'PER_CONTAINER'),
('BULK_EXPORT', 'BULK', 25, 'PER_TON')
) AS v(rate_type, applies_to, value, unit)
JOIN freight.yards a ON a.code = 'MOJO'
JOIN freight.yards b ON b.code = 'DJIB_PORT'
JOIN iam.users u ON u.email = 'operation@edr.local'
WHERE NOT EXISTS (
SELECT 1 FROM freight.rates r
WHERE r.rate_type = v.rate_type
AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id
AND r.deleted_at IS NULL
);

View File

@@ -0,0 +1,131 @@
-- Arrange-data for flows/intercity_one_time.cy.ts. Idempotent.
--
-- The e2e DB boots with yards + a wagon fleet only: no container types, no
-- locomotives, no Train-Builder train, no yard distances, no routes. The spec
-- drives route + schedule creation through the UI; this fixture provides only
-- the infrastructure the UI journey cannot reasonably create in-flow:
--
-- 1. container types (booking form resolves 20ft/40ft by size_ft)
-- 2. container-type → wagon-type allow-list (wagon planner)
-- 3. two locomotives (a schedulable train needs >= 2)
-- 4. a built Train-Builder train at Mojo with four NW5 flat wagons
-- 5. yard distances for MojoDire DawaDjibouti Port (route creation
-- refuses unconfigured pairs)
-- 1. Container types.
INSERT INTO freight.container_types (id, code, label, size_ft, is_active)
SELECT gen_random_uuid(), v.code, v.label, v.size_ft, true
FROM (VALUES ('20FT', '20FT', 20), ('40FT', '40FT', 40)) AS v(code, label, size_ft)
WHERE NOT EXISTS (SELECT 1 FROM freight.container_types t WHERE t.code = v.code);
-- 2. 20ft/40ft containers ride NW5 flat wagons.
INSERT INTO freight.container_type_wagon_types (container_type_id, wagon_type_id)
SELECT ct.id, wt.id
FROM freight.container_types ct
JOIN freight.wagon_types wt ON wt.code = 'NW5'
WHERE ct.code IN ('20FT', '40FT')
AND NOT EXISTS (
SELECT 1 FROM freight.container_type_wagon_types x
WHERE x.container_type_id = ct.id AND x.wagon_type_id = wt.id
);
-- 3. Two locomotives at Mojo (status defaults to AVAILABLE).
INSERT INTO freight.locomotives
(id, code, max_pull_weight_tons, max_train_length_meters, current_yard_id)
SELECT gen_random_uuid(), v.code, 4000, 760, y.id
FROM (VALUES ('LOCO-E2E-1'), ('LOCO-E2E-2')) AS v(code)
JOIN freight.yards y ON y.code = 'MOJO'
WHERE NOT EXISTS (SELECT 1 FROM freight.locomotives l WHERE l.code = v.code);
-- 4a. Built train at Mojo (status defaults to AVAILABLE).
INSERT INTO freight.trains (id, code, train_name, capacity_tons, current_yard_id)
SELECT gen_random_uuid(), 'TRN-E2E-1', 'E2E Export Carrier', 2000, y.id
FROM freight.yards y
WHERE y.code = 'MOJO'
AND NOT EXISTS (SELECT 1 FROM freight.trains t WHERE t.code = 'TRN-E2E-1');
-- 4b. Couple both locomotives (available-trains filter requires >= 2).
INSERT INTO freight.train_locomotives (id, train_id, locomotive_id, sequence_no)
SELECT gen_random_uuid(), t.id, l.id,
row_number() OVER (ORDER BY l.code) - 1
FROM freight.trains t
JOIN freight.locomotives l ON l.code IN ('LOCO-E2E-1', 'LOCO-E2E-2')
WHERE t.code = 'TRN-E2E-1'
AND NOT EXISTS (
SELECT 1 FROM freight.train_locomotives tl
WHERE tl.train_id = t.id AND tl.locomotive_id = l.id
);
-- 4c. Couple four free NW5 flat wagons onto the train and park them at Mojo
-- with it (the seeded fleet sits at Doraleh; the planner reads the consist by
-- train_id, the yard only matters for warnings).
UPDATE freight.wagons w
SET train_id = t.id,
sequence_number = sub.rn,
current_yard_id = (SELECT id FROM freight.yards WHERE code = 'MOJO')
FROM freight.trains t,
LATERAL (
SELECT w2.id, row_number() OVER (ORDER BY w2.wagon_number) AS rn
FROM freight.wagons w2
JOIN freight.wagon_types wt ON wt.id = w2.wagon_type_id AND wt.code = 'NW5'
WHERE w2.train_id IS NULL AND w2.deleted_at IS NULL
ORDER BY w2.wagon_number
LIMIT 4
) sub
WHERE t.code = 'TRN-E2E-1'
AND w.id = sub.id
AND NOT EXISTS (SELECT 1 FROM freight.wagons wx WHERE wx.train_id = t.id);
-- 4d. One REQUIRED intercity clearance document, so the journey exercises the
-- real customer-upload → ops-review → finalize step (the seeder leaves the
-- intercity_documents setting empty).
INSERT INTO freight.file_upload_fields
(id, setting_id, file_key, file_label, is_required, is_multiple, max_files,
allowed_extensions, max_size_mb, display_order)
SELECT gen_random_uuid(), s.id, 'cargo_manifest', 'Cargo Manifest', true, false, 1,
'{pdf,jpg,jpeg,png}'::text[], 10, 1
FROM freight.file_upload_settings s
WHERE s.code = 'intercity_documents'
AND NOT EXISTS (
SELECT 1 FROM freight.file_upload_fields f
WHERE f.setting_id = s.id AND f.file_key = 'cargo_manifest' AND f.deleted_at IS NULL
);
-- 5. LIVE intercity container rate for Mojo → Dire Dawa (booking pricing
-- hard-blocks any container line without a rate on its exact leg; rates are
-- configured in USD and converted to the booking currency).
INSERT INTO freight.rates
(id, rate_type, applies_to, trigger, currency, rate_value, rate_unit, status,
origin_yard_id, destination_yard_id, proposed_by_staff_id)
SELECT gen_random_uuid(), 'INTERCITY_CONTAINER', 'INTERCITY', 'ALWAYS', 'USD', 500,
'PER_CONTAINER', 'LIVE', a.id, b.id, u.id
FROM freight.yards a
JOIN freight.yards b ON b.code = 'DIRE_DAWA'
JOIN iam.users u ON u.email = 'operation@edr.local'
WHERE a.code = 'MOJO'
AND NOT EXISTS (
SELECT 1 FROM freight.rates r
WHERE r.rate_type = 'INTERCITY_CONTAINER'
AND r.origin_yard_id = a.id AND r.destination_yard_id = b.id
AND r.deleted_at IS NULL
);
-- 6. Segment distances (symmetric — one row covers both directions). Guarded:
-- an e2e image built from a branch that predates the yard_distances feature
-- has no table, and its route form doesn't require distances either.
DO $$
BEGIN
IF to_regclass('freight.yard_distances') IS NOT NULL THEN
INSERT INTO freight.yard_distances (id, from_yard_id, to_yard_id, distance_km)
SELECT gen_random_uuid(), a.id, b.id, v.km
FROM (VALUES ('MOJO', 'DIRE_DAWA', 300), ('DIRE_DAWA', 'DJIB_PORT', 450))
AS v(from_code, to_code, km)
JOIN freight.yards a ON a.code = v.from_code
JOIN freight.yards b ON b.code = v.to_code
WHERE NOT EXISTS (
SELECT 1 FROM freight.yard_distances d
WHERE (d.from_yard_id = a.id AND d.to_yard_id = b.id)
OR (d.from_yard_id = b.id AND d.to_yard_id = a.id)
);
END IF;
END $$;

View 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;

View 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" }
}
}

View File

@@ -0,0 +1,179 @@
/**
* 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 });
});
// :visible — closed dropdowns can linger in the DOM, and two selects on one
// page may list the same option text (e.g. the intercity wizard's origin +
// destination both list every Ethiopian yard).
cy.get('[role="option"]:visible').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 {};

View 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
View 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
View 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
View 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"]
}