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

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