feat(e2e): add freight e2e docker-compose stack with isolated services

This commit is contained in:
Nathnael
2026-07-21 11:00:02 +00:00
parent 2a35ee97fa
commit b8b8c4adf7
19 changed files with 1701 additions and 54 deletions

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

View 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();
}
},
});
},
},
});

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

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

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

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

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

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