mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
feat(e2e): add freight e2e docker-compose stack with isolated services
This commit is contained in:
80
e2e/freight/README.md
Normal file
80
e2e/freight/README.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# @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 | Host port | Notes |
|
||||
| ----------------------- | --------- | ---------------------------------------------- |
|
||||
| `freight-api-e2e` | 3101 | migrations + seeders run at boot |
|
||||
| `freight-portal-e2e` | 5273 | nginx static build, API baked to `:3101` |
|
||||
| `freight-backoffice-e2e`| 5283 | nginx static build, API baked to `:3101` |
|
||||
| `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 deliberately avoid the dev stack (3001/5173/5183/5433). The dev database
|
||||
is never touched.
|
||||
|
||||
## Usage (from repo root)
|
||||
|
||||
```bash
|
||||
pnpm e2e:freight:up # build + start stack, wait for healthchecks
|
||||
pnpm e2e:freight:open # interactive Cypress on the host
|
||||
pnpm e2e:freight:run # headless run from the host
|
||||
pnpm e2e:freight:ci # headless run inside the cypress container
|
||||
pnpm e2e:freight:down # teardown, drop all data
|
||||
```
|
||||
|
||||
First `up` is slow (image builds + 240 migrations + seeders — healthcheck
|
||||
allows 3 min). Requires the same root `.npmrc` (GitHub Packages auth for
|
||||
`@tria-plc`) as the main compose file.
|
||||
|
||||
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` (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`
|
||||
|
||||
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 (5283). 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.
|
||||
- **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)
|
||||
|
||||
## 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.
|
||||
80
e2e/freight/cypress.config.ts
Normal file
80
e2e/freight/cypress.config.ts
Normal file
@@ -0,0 +1,80 @@
|
||||
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:5283 (baseUrl — most specs live here)
|
||||
* portal http://localhost:5273 (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:5283",
|
||||
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:5273",
|
||||
backofficeUrl: process.env.CYPRESS_BASE_URL ?? "http://localhost:5283",
|
||||
// Staff users: DEFAULT_PASSWORD from docker-compose.e2e.yaml.
|
||||
defaultPassword: process.env.CYPRESS_DEFAULT_PASSWORD ?? "password@tria",
|
||||
// Demo portal users: hardcoded in DemoUsersSeeder.
|
||||
demoPassword: "12345678",
|
||||
},
|
||||
setupNodeEvents(on) {
|
||||
const dbUrl =
|
||||
process.env.E2E_DB_URL ??
|
||||
"postgres://edr_e2e:edr_e2e@localhost:5533/edr_freight_e2e";
|
||||
|
||||
on("task", {
|
||||
/** Run an arbitrary SQL statement against the ephemeral e2e database. */
|
||||
async "db:query"({ sql, params = [] }: { sql: string; params?: unknown[] }) {
|
||||
const client = new Client({ connectionString: dbUrl });
|
||||
await client.connect();
|
||||
try {
|
||||
const result = await client.query(sql, params as never[]);
|
||||
return { rowCount: result.rowCount, rows: result.rows };
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Seed the test users (staff + demo) directly in SQL. The API's
|
||||
* user seeders are disabled in app code, so the fixture replicates
|
||||
* their output. Idempotent — safe to run before every spec file.
|
||||
*/
|
||||
async "db:seedUsers"() {
|
||||
// cwd = the e2e/freight project root when Cypress runs.
|
||||
const sql = readFileSync(
|
||||
join(process.cwd(), "cypress", "fixtures", "seed-users.sql"),
|
||||
"utf8",
|
||||
);
|
||||
const client = new Client({ connectionString: dbUrl });
|
||||
await client.connect();
|
||||
try {
|
||||
await client.query(sql);
|
||||
return true;
|
||||
} finally {
|
||||
await client.end();
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
});
|
||||
79
e2e/freight/cypress/e2e/api/health-and-auth.cy.ts
Normal file
79
e2e/freight/cypress/e2e/api/health-and-auth.cy.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
/**
|
||||
* API contract smoke — no browser, pure cy.request against freight-api.
|
||||
* Verifies the containerized stack booted: migrations ran, seeders ran,
|
||||
* auth issues tokens.
|
||||
*/
|
||||
const api = () => Cypress.env("apiUrl") as string;
|
||||
|
||||
describe("freight-api: health + auth contract", () => {
|
||||
it("GET /api/health responds", () => {
|
||||
cy.request(`${api()}/api/health`).its("status").should("eq", 200);
|
||||
});
|
||||
|
||||
it("rejects bad credentials", () => {
|
||||
cy.request({
|
||||
method: "POST",
|
||||
url: `${api()}/api/auth/login`,
|
||||
body: { email: "nobody@edr.local", password: "wrong-password" },
|
||||
failOnStatusCode: false,
|
||||
})
|
||||
.its("status")
|
||||
.should("be.oneOf", [400, 401, 404]);
|
||||
});
|
||||
|
||||
it("logs in every seeded staff user", () => {
|
||||
cy.fixture("users.json").then((users) => {
|
||||
Object.values<{ email: string }>(users.staff).forEach(({ email }) => {
|
||||
cy.apiLogin(email);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("staff token can read /api/me", () => {
|
||||
cy.apiLogin("ceo@edr.local").then(({ token }) => {
|
||||
cy.request({
|
||||
url: `${api()}/api/me`,
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
}).then((response) => {
|
||||
expect(response.status).to.eq(200);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it("refresh-token rotates the session", () => {
|
||||
cy.apiLogin("chief@edr.local").then(({ refreshToken }) => {
|
||||
cy.request("POST", `${api()}/api/auth/refresh-token`, { refreshToken })
|
||||
.its("body.token")
|
||||
.should("be.a", "string");
|
||||
});
|
||||
});
|
||||
|
||||
it("demo portal users are seeded", () => {
|
||||
// DemoUsersSeeder hardcodes this password (staff users use DEFAULT_PASSWORD)
|
||||
cy.apiLogin("user@gmail.com", Cypress.env("demoPassword"));
|
||||
cy.apiLogin("user2@gmail.com", Cypress.env("demoPassword"));
|
||||
});
|
||||
});
|
||||
|
||||
describe("freight-api: seeded database", () => {
|
||||
it("migrations table is populated", () => {
|
||||
cy.task<{ rowCount: number }>("db:query", {
|
||||
sql: "select count(*)::int as count from migrations",
|
||||
}).then(({ rows }: any) => {
|
||||
expect(rows[0].count).to.be.greaterThan(100);
|
||||
});
|
||||
});
|
||||
|
||||
it("staff users exist with credentials", () => {
|
||||
cy.task("db:query", {
|
||||
sql: `select u.email from iam.users u
|
||||
join iam.user_credentials c on c.user_id = u.id
|
||||
where u.email like '%@edr.local' order by u.email`,
|
||||
}).then(({ rows }: any) => {
|
||||
const emails = rows.map((row: { email: string }) => row.email);
|
||||
expect(emails).to.include.members(["ceo@edr.local", "linestaff@edr.local"]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
||||
41
e2e/freight/cypress/e2e/backoffice/login.cy.ts
Normal file
41
e2e/freight/cypress/e2e/backoffice/login.cy.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
/**
|
||||
* The one UI-driven login spec for backoffice — every other spec uses the
|
||||
* programmatic cy.loginBackoffice() session.
|
||||
* Login form: apps/edr-freight-web/backoffice/src/pages/auth/LoginPage.tsx
|
||||
* (Mantine inputs, matched by placeholder).
|
||||
*/
|
||||
describe("backoffice: UI login", () => {
|
||||
it("redirects unauthenticated users to /auth", () => {
|
||||
cy.clearCookies();
|
||||
cy.visit("/dashboard/overview");
|
||||
cy.location("pathname").should("eq", "/auth");
|
||||
});
|
||||
|
||||
it("logs in via the form and lands on the dashboard", () => {
|
||||
cy.clearCookies();
|
||||
cy.visit("/auth");
|
||||
cy.get('input[placeholder="name@company.com or 09XXXXXXXX"]').type("ceo@edr.local");
|
||||
cy.get('input[placeholder="Enter your password"]').type(
|
||||
Cypress.env("defaultPassword"),
|
||||
{ log: false },
|
||||
);
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.location("pathname", { timeout: 20000 }).should("match", /^\/dashboard/);
|
||||
cy.getCookie("auth-token").should("exist");
|
||||
cy.getCookie("refresh-token").should("exist");
|
||||
});
|
||||
|
||||
it("shows an error for wrong credentials and stays on /auth", () => {
|
||||
cy.clearCookies();
|
||||
cy.visit("/auth");
|
||||
cy.get('input[placeholder="name@company.com or 09XXXXXXXX"]').type("ceo@edr.local");
|
||||
cy.get('input[placeholder="Enter your password"]').type("definitely-wrong");
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.location("pathname").should("eq", "/auth");
|
||||
cy.getCookie("auth-token").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
||||
49
e2e/freight/cypress/e2e/backoffice/smoke.cy.ts
Normal file
49
e2e/freight/cypress/e2e/backoffice/smoke.cy.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
/**
|
||||
* Route-level smoke over the backoffice shell: each key module page loads
|
||||
* without bouncing back to /auth and without an unhandled crash. Deep
|
||||
* per-module behavior belongs in dedicated specs — this catches the broad
|
||||
* "page is broken / route is dead / guard rejects seeded role" class.
|
||||
* Routes from apps/edr-freight-web/backoffice/src/App.tsx.
|
||||
*/
|
||||
const ROUTES = [
|
||||
"/dashboard/overview",
|
||||
"/dashboard/booking-requests",
|
||||
"/dashboard/customers",
|
||||
"/dashboard/invoices",
|
||||
"/dashboard/contract-requests",
|
||||
"/dashboard/shipment-requests",
|
||||
"/dashboard/profile",
|
||||
];
|
||||
|
||||
describe("backoffice: route smoke (ceo)", () => {
|
||||
beforeEach(() => {
|
||||
cy.loginBackoffice("ceo@edr.local");
|
||||
});
|
||||
|
||||
ROUTES.forEach((route) => {
|
||||
it(`renders ${route}`, () => {
|
||||
cy.visit(route);
|
||||
cy.location("pathname").should("not.eq", "/auth");
|
||||
cy.location("pathname").should("contain", "/dashboard");
|
||||
cy.get("#root").should("not.be.empty");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("backoffice: role-based access", () => {
|
||||
it("line staff can reach the dashboard shell", () => {
|
||||
cy.loginBackoffice("linestaff@edr.local");
|
||||
cy.visit("/dashboard/overview");
|
||||
cy.location("pathname").should("not.eq", "/auth");
|
||||
cy.get("#root").should("not.be.empty");
|
||||
});
|
||||
|
||||
it("operations officer can reach the dashboard shell", () => {
|
||||
cy.loginBackoffice("operation@edr.local");
|
||||
cy.visit("/dashboard/overview");
|
||||
cy.location("pathname").should("not.eq", "/auth");
|
||||
cy.get("#root").should("not.be.empty");
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
||||
69
e2e/freight/cypress/e2e/flows/cross-app.cy.ts
Normal file
69
e2e/freight/cypress/e2e/flows/cross-app.cy.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
/**
|
||||
* Cross-app flow: same business objects seen from both directions —
|
||||
* customer (portal, port 5273) and staff (backoffice, port 5283 = 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 {};
|
||||
36
e2e/freight/cypress/e2e/portal/login.cy.ts
Normal file
36
e2e/freight/cypress/e2e/portal/login.cy.ts
Normal file
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* UI login for the customer portal (seeded demo user).
|
||||
* Form: apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx.
|
||||
* Portal is a different origin (port 5273), so specs visit it via absolute
|
||||
* URL — each test here stays on that single origin, no cy.origin needed.
|
||||
*/
|
||||
const portal = () => Cypress.env("portalUrl") as string;
|
||||
|
||||
describe("portal: UI login", () => {
|
||||
it("logs in via the form", () => {
|
||||
cy.clearCookies();
|
||||
cy.visit(`${portal()}/login`);
|
||||
cy.get('input[placeholder="name@company.com or 09XXXXXXXX"]').type("user@gmail.com");
|
||||
cy.get('input[placeholder="Enter your password"]').type(
|
||||
Cypress.env("demoPassword"),
|
||||
{ log: false },
|
||||
);
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.location("pathname", { timeout: 20000 }).should("not.eq", "/login");
|
||||
cy.getCookie("auth-token").should("exist");
|
||||
});
|
||||
|
||||
it("rejects wrong credentials", () => {
|
||||
cy.clearCookies();
|
||||
cy.visit(`${portal()}/login`);
|
||||
cy.get('input[placeholder="name@company.com or 09XXXXXXXX"]').type("user@gmail.com");
|
||||
cy.get('input[placeholder="Enter your password"]').type("definitely-wrong");
|
||||
cy.get('button[type="submit"]').click();
|
||||
|
||||
cy.location("pathname").should("eq", "/login");
|
||||
cy.getCookie("auth-token").should("not.exist");
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
||||
29
e2e/freight/cypress/e2e/portal/smoke.cy.ts
Normal file
29
e2e/freight/cypress/e2e/portal/smoke.cy.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
/**
|
||||
* Route-level smoke over the customer portal.
|
||||
* Routes from apps/edr-freight-web/portal/src/App.tsx.
|
||||
*/
|
||||
const portal = () => Cypress.env("portalUrl") as string;
|
||||
|
||||
const ROUTES = ["/portal", "/bookings", "/contracts", "/billing", "/tracking"];
|
||||
|
||||
describe("portal: route smoke (demo customer)", () => {
|
||||
beforeEach(() => {
|
||||
cy.loginPortal("user@gmail.com");
|
||||
});
|
||||
|
||||
ROUTES.forEach((route) => {
|
||||
it(`renders ${route}`, () => {
|
||||
cy.visit(`${portal()}${route}`);
|
||||
cy.location("pathname").should("not.eq", "/login");
|
||||
cy.get("#root").should("not.be.empty");
|
||||
});
|
||||
});
|
||||
|
||||
it("new booking wizard opens", () => {
|
||||
cy.visit(`${portal()}/bookings/new`);
|
||||
cy.location("pathname").should("eq", "/bookings/new");
|
||||
cy.get("#root").should("not.be.empty");
|
||||
});
|
||||
});
|
||||
|
||||
export {};
|
||||
130
e2e/freight/cypress/fixtures/seed-users.sql
Normal file
130
e2e/freight/cypress/fixtures/seed-users.sql
Normal file
@@ -0,0 +1,130 @@
|
||||
-- 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);
|
||||
|
||||
insert into iam.employee_positions (id, is_delegate, is_current, status, unit_id, employee_id, position_id)
|
||||
select gen_random_uuid(), false, true, 'APPROVED', 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
|
||||
);
|
||||
16
e2e/freight/cypress/fixtures/users.json
Normal file
16
e2e/freight/cypress/fixtures/users.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"staff": {
|
||||
"lineStaff": { "email": "linestaff@edr.local", "role": "edr_line_staff" },
|
||||
"chief": { "email": "chief@edr.local", "role": "edr_org_manager" },
|
||||
"director": { "email": "director@edr.local", "role": "edr_director" },
|
||||
"ceo": { "email": "ceo@edr.local", "role": "edr_ceo" },
|
||||
"marketer": { "email": "marketer@edr.local", "role": "edr_marketing" },
|
||||
"operation": { "email": "operation@edr.local", "role": "edr_operations_officer" },
|
||||
"glEthiopia": { "email": "gl-et@edr.local", "role": "edr_gl_ethiopia" },
|
||||
"glDjibouti": { "email": "gl-dj@edr.local", "role": "edr_gl_djibouti" }
|
||||
},
|
||||
"customers": {
|
||||
"demo1": { "email": "user@gmail.com" },
|
||||
"demo2": { "email": "user2@gmail.com" }
|
||||
}
|
||||
}
|
||||
95
e2e/freight/cypress/support/commands.ts
Normal file
95
e2e/freight/cypress/support/commands.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/**
|
||||
* 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}`);
|
||||
});
|
||||
|
||||
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>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
18
e2e/freight/cypress/support/e2e.ts
Normal file
18
e2e/freight/cypress/support/e2e.ts
Normal file
@@ -0,0 +1,18 @@
|
||||
import "./commands";
|
||||
|
||||
// The API's user seeders are disabled in app code — the SQL fixture creates
|
||||
// the staff + demo test users instead. Idempotent, runs before each spec file.
|
||||
before(() => {
|
||||
cy.task("db:seedUsers");
|
||||
});
|
||||
|
||||
// Third-party noise (PostHog, Google Maps, socket.io reconnects) can throw
|
||||
// uncaught exceptions that are irrelevant to the assertion under test. App
|
||||
// errors still fail tests via failed assertions / failed intercepts.
|
||||
Cypress.on("uncaught:exception", (err) => {
|
||||
const ignorable = [/posthog/i, /google/i, /websocket/i, /socket\.io/i, /ResizeObserver/i];
|
||||
if (ignorable.some((pattern) => pattern.test(err.message))) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
22
e2e/freight/package.json
Normal file
22
e2e/freight/package.json
Normal file
@@ -0,0 +1,22 @@
|
||||
{
|
||||
"name": "@edr/freight-e2e",
|
||||
"version": "0.0.0",
|
||||
"private": true,
|
||||
"description": "Cypress end-to-end tests for the EDR freight system (portal + backoffice + API)",
|
||||
"scripts": {
|
||||
"cy:open": "cypress open --e2e",
|
||||
"cy:run": "cypress run",
|
||||
"cy:run:backoffice": "cypress run --spec 'cypress/e2e/backoffice/**'",
|
||||
"cy:run:portal": "cypress run --spec 'cypress/e2e/portal/**'",
|
||||
"cy:run:api": "cypress run --spec 'cypress/e2e/api/**'",
|
||||
"cy:run:flows": "cypress run --spec 'cypress/e2e/flows/**'",
|
||||
"type-check": "tsc --noEmit"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cypress": "^15.3.0",
|
||||
"pg": "^8.13.0",
|
||||
"typescript": "^5.5.4",
|
||||
"@types/node": "^22.0.0",
|
||||
"@types/pg": "^8.11.0"
|
||||
}
|
||||
}
|
||||
16
e2e/freight/tsconfig.json
Normal file
16
e2e/freight/tsconfig.json
Normal file
@@ -0,0 +1,16 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"lib": ["ES2022", "DOM"],
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"types": ["cypress", "node"]
|
||||
},
|
||||
"include": ["cypress/**/*.ts", "cypress.config.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user