mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
330 lines
12 KiB
TypeScript
330 lines
12 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,
|
|
app: "backoffice" | "portal" = "backoffice",
|
|
): Cypress.Chainable<LoginBody> {
|
|
return cy
|
|
.request<LoginBody>({
|
|
method: "POST",
|
|
url: `${apiUrl()}/api/auth/login`,
|
|
body: { email, password: pass ?? password() },
|
|
headers: { "x-client-app": app },
|
|
})
|
|
.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, app).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, app: "backoffice" | "portal" = "backoffice") =>
|
|
apiLogin(email, pass, app),
|
|
);
|
|
|
|
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 });
|
|
});
|
|
// Scope to the OPEN listbox: closed dropdowns 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).
|
|
//
|
|
// The scope has to be the listbox rather than the options themselves. A long
|
|
// list scrolls inside a max-height dropdown, and Cypress counts the clipped
|
|
// rows as not-visible — matching on `[role="option"]:visible` silently drops
|
|
// whatever sits past the fold (this hid the alphabetically-last trains).
|
|
// force: the click still has to land on a row that needs scrolling to.
|
|
cy.get('[role="listbox"]:visible')
|
|
.last()
|
|
.contains('[role="option"]', option)
|
|
.click({ force: true });
|
|
});
|
|
|
|
/**
|
|
* Fill the accept-contract modal's validity window. The modal used to offer a
|
|
* dropdown of configured durations that defaulted to the first option; it now
|
|
* takes explicit Start/End dates and pre-fills neither, so "Accept & start
|
|
* approval" stays disabled until both are set.
|
|
*
|
|
* The fields are Mantine DateTimePickers — a button that opens a calendar
|
|
* popover, not a typeable input. Zoom out to the decade view (2 clicks on the
|
|
* header's middle control, which has no [data-direction]) then drill back
|
|
* down year → month → day, and confirm with the popover's submit (check)
|
|
* button — clicking a day alone only stages the value, it does not close
|
|
* the popover or commit the pick.
|
|
*/
|
|
const MONTH_ABBR = [
|
|
"Jan", "Feb", "Mar", "Apr", "May", "Jun",
|
|
"Jul", "Aug", "Sep", "Oct", "Nov", "Dec",
|
|
] as const;
|
|
const MONTH_NAMES = [
|
|
"January", "February", "March", "April", "May", "June",
|
|
"July", "August", "September", "October", "November", "December",
|
|
] as const;
|
|
|
|
Cypress.Commands.add("acceptValidityWindow", (days = 365) => {
|
|
const start = new Date();
|
|
const end = new Date(start.getTime() + days * 24 * 60 * 60 * 1000);
|
|
|
|
const pickDate = (label: string, date: Date) => {
|
|
cy.contains("label", label)
|
|
.invoke("attr", "for")
|
|
.then((id) => cy.get(`[id="${id}"]`).click({ force: true }));
|
|
|
|
// month view -> year view -> decade view.
|
|
for (let i = 0; i < 2; i++) {
|
|
cy.get('.mantine-Popover-dropdown [data-direction="previous"]')
|
|
.siblings("button")
|
|
.first()
|
|
.click({ force: true });
|
|
}
|
|
|
|
cy.get(".mantine-Popover-dropdown")
|
|
.contains("button", new RegExp(`^${date.getFullYear()}$`))
|
|
.click({ force: true });
|
|
cy.get(".mantine-Popover-dropdown")
|
|
.contains("button", new RegExp(`^${MONTH_ABBR[date.getMonth()]}$`))
|
|
.click({ force: true });
|
|
|
|
const dayAriaLabel = `${date.getDate()} ${MONTH_NAMES[date.getMonth()]} ${date.getFullYear()}`;
|
|
cy.get(`.mantine-Popover-dropdown [aria-label="${dayAriaLabel}"]`).click({
|
|
force: true,
|
|
});
|
|
cy.get(".mantine-DateTimePicker-submitButton").click({ force: true });
|
|
};
|
|
|
|
pickDate("Start date", start);
|
|
pickDate("End date", end);
|
|
});
|
|
|
|
/**
|
|
* Attach a company stamp in the open sign-contract modal. The stamp became a
|
|
* REQUIRED field on signing — "Continue to verification" stays disabled without
|
|
* one — and StampUpload only checks the MIME type and size before reading the
|
|
* file as a data URL, so the smallest valid PNG is enough. The input is
|
|
* `hidden` (a dropzone drives it), hence force.
|
|
*/
|
|
const STAMP_PNG =
|
|
"iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mP8z8BQDwAEhQGAhKmMIQAAAABJRU5ErkJggg==";
|
|
|
|
Cypress.Commands.add("uploadCompanyStamp", () => {
|
|
cy.get('.mantine-Modal-content input[type="file"]')
|
|
.first()
|
|
.selectFile(
|
|
{
|
|
contents: Cypress.Buffer.from(STAMP_PNG, "base64"),
|
|
fileName: "stamp.png",
|
|
mimeType: "image/png",
|
|
},
|
|
{ force: true },
|
|
);
|
|
});
|
|
|
|
/**
|
|
* Fill the per-booking "Cargo description" on the new-shipment form. It is
|
|
* REQUIRED for container shipments (it moved from the contract to the booking),
|
|
* and the form validates through react-hook-form's handleSubmit — so leaving it
|
|
* blank aborts silently: no price modal, no request, no error toast.
|
|
* No-op for bulk shipments, which have no such field.
|
|
*/
|
|
Cypress.Commands.add("fillCargoDescription", (text = "Electronics") => {
|
|
cy.get("body").then(($b) => {
|
|
const field = $b.find('[placeholder^="e.g. Electronics"]');
|
|
if (!field.length) return;
|
|
cy.wrap(field.first()).clear({ force: true }).type(text, { force: true });
|
|
});
|
|
});
|
|
|
|
/**
|
|
* Retire a previous run's contracts so a spec is re-runnable against a warm DB.
|
|
*
|
|
* The API allows one active contract per customer + service type + route, so
|
|
* yesterday's contract 409s today's at creation — and staff accept contracts
|
|
* with a year's validity, so it would keep doing so for a year.
|
|
*
|
|
* The cutoff is frozen on first use: Cypress re-evaluates the spec bundle on
|
|
* cross-origin visits, so `before()` fires again mid-run, and a naive "cancel
|
|
* this shape" would then cancel the contract THIS run had just created.
|
|
* Anything created after the spec started is ours and must survive.
|
|
*/
|
|
Cypress.Commands.add(
|
|
"retireStaleContracts",
|
|
(opts: { tin: string; kind: string; direction: string }) => {
|
|
// The once-per-run guard lives in the Node task (see cypress.config.ts).
|
|
cy.task("db:retireStaleContracts", opts);
|
|
},
|
|
);
|
|
|
|
/** 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,
|
|
app?: "backoffice" | "portal",
|
|
): 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 the accept-contract modal's Start/End validity dates. */
|
|
acceptValidityWindow(days?: number): Chainable<void>;
|
|
/** Attach the required company stamp in the sign-contract modal. */
|
|
uploadCompanyStamp(): Chainable<void>;
|
|
/** Fill the required per-booking cargo description (container only). */
|
|
fillCargoDescription(text?: string): Chainable<void>;
|
|
/** Cancel a previous run's contracts of this shape (warm-DB re-runs). */
|
|
retireStaleContracts(opts: {
|
|
tin: string;
|
|
kind: string;
|
|
direction: string;
|
|
}): 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 {};
|