Files
edr-platform/e2e/freight/cypress/support/commands.ts

180 lines
6.7 KiB
TypeScript

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