chore: more tests

This commit is contained in:
Nathnael
2026-07-21 12:49:36 +00:00
parent b8b8c4adf7
commit 20a6ef7906
11 changed files with 609 additions and 22 deletions

View File

@@ -9,8 +9,8 @@ both headless-in-Docker and interactively from the host against the same URLs.
| 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` |
| `freight-portal-e2e` | 5373 | nginx static build, API baked to `:3101` |
| `freight-backoffice-e2e`| 5383 | 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 |
@@ -58,7 +58,7 @@ Full map in `cypress/fixtures/users.json`.
`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`
- **Origins**: `baseUrl` is the backoffice (5383). 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

View File

@@ -5,8 +5,8 @@ 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,
* backoffice http://localhost:5383 (baseUrl — most specs live here)
* portal http://localhost:5373 (env.portalUrl; portal specs cy.visit it,
* cross-app flows reach it via cy.origin)
* api http://localhost:3101 (env.apiUrl; cy.request only)
*
@@ -17,7 +17,7 @@ import { Client } from "pg";
*/
export default defineConfig({
e2e: {
baseUrl: process.env.CYPRESS_BASE_URL ?? "http://localhost:5283",
baseUrl: process.env.CYPRESS_BASE_URL ?? "http://localhost:5383",
specPattern: "cypress/e2e/**/*.cy.ts",
supportFile: "cypress/support/e2e.ts",
video: process.env.CI === "true" || process.env.CYPRESS_VIDEO === "true",
@@ -29,8 +29,8 @@ export default defineConfig({
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",
portalUrl: process.env.CYPRESS_PORTAL_URL ?? "http://localhost:5373",
backofficeUrl: process.env.CYPRESS_BASE_URL ?? "http://localhost:5383",
// Staff users: DEFAULT_PASSWORD from docker-compose.e2e.yaml.
defaultPassword: process.env.CYPRESS_DEFAULT_PASSWORD ?? "password@tria",
// Demo portal users: hardcoded in DemoUsersSeeder.
@@ -61,14 +61,17 @@ export default defineConfig({
*/
async "db:seedUsers"() {
// cwd = the e2e/freight project root when Cypress runs.
const sql = readFileSync(
join(process.cwd(), "cypress", "fixtures", "seed-users.sql"),
"utf8",
);
// seed-company.sql depends on rows from seed-users.sql — keep order.
const client = new Client({ connectionString: dbUrl });
await client.connect();
try {
await client.query(sql);
for (const file of ["seed-users.sql", "seed-company.sql"]) {
const sql = readFileSync(
join(process.cwd(), "cypress", "fixtures", file),
"utf8",
);
await client.query(sql);
}
return true;
} finally {
await client.end();

View File

@@ -0,0 +1,213 @@
/**
* Contract creation → finalization, spanning portal + backoffice:
*
* 1. portal (user@gmail.com, company seeded active by seed-company.sql):
* wizard → GENERAL / Import / Container / 20ft → submit + approve quote
* 2. backoffice marketer: "Accept for approval" (validity) + approve the
* LINE_STAFF step
* 3. backoffice director: approve the DIRECTOR step → PDF → CONTRACT_READY
* 4. portal customer: scroll contract, agree, draw signature, OTP-sign
* → SIGNED_CUSTOMER
* 5. backoffice marketer: counter-sign as staff → GENERAL contract goes
* CONTRACT_ACTIVE (per-booking clearance, no contract-level gate)
*
* Sequential steps of one journey — retries off (steps are not idempotent).
*/
const customer = "user@gmail.com";
const companyTin = "0102030405"; // seed-company.sql
/**
* The journey's contract = the seeded company's latest contract. Each test
* resolves it from the DB instead of sharing module state — tests stay
* independently runnable against the current DB state.
*/
function dbContract() {
return cy.task<{ rows: Array<{ id: string; reference: string; status: string }> }>(
"db:query",
{
sql: `SELECT ct.id, ct.reference, ct.status
FROM freight.contracts ct
JOIN freight.companies c ON c.id = ct.company_id
WHERE c.tin = $1
ORDER BY ct.created_at DESC LIMIT 1`,
params: [companyTin],
},
);
}
function withContract(fn: (c: { id: string; reference: string; status: string }) => void) {
dbContract().then(({ rows }) => {
expect(rows, "latest contract for the seeded company").to.have.length(1);
fn(rows[0]);
});
}
function expectStatus(expected: string) {
dbContract().then(({ rows }) => {
expect(rows[0]?.status, `contract status`).to.eq(expected);
});
}
describe("contract lifecycle: creation to finalization", { retries: 0 }, () => {
it("customer creates and submits a GENERAL import container contract", () => {
cy.loginPortal(customer);
cy.visitPortal("/contracts/new");
// Step 0 — Setup.
cy.mantineSelect(/^Operation Type/, /^Import$/);
cy.mantineSelect(/^Contract Kind/, "General Contract");
cy.mantineSelect(/^New or Renewal/, "New Contract");
cy.contains("Rail Transport Only", { timeout: 15000 }).click();
cy.mantineSelect(/^Payment Currency/, /^ETB/);
cy.contains("button", "Continue").click({ force: true });
// Step 1 — Cargo & Route.
cy.mantineSelect(/^Cargo Scope/, /Containerized/);
cy.get('[role="checkbox"][aria-label="20ft Container"]').click();
cy.get('textarea[placeholder*="Electronics"]').type(
"E2E electronics shipment scope",
);
cy.mantineSelect(/^Origin Yard/, "Djibouti Port Terminal");
cy.mantineSelect(/^Destination Yard/, "Mojo Dry Port");
cy.contains("button", "Continue").click({ force: true });
// Step 2 — Review & Submit → quotation modal.
cy.contains("button", "Submit").click({ force: true });
cy.contains("Approve your quotation", { timeout: 30000 }).should(
"be.visible",
);
cy.contains("button", "Approve & submit").click();
cy.location("pathname", { timeout: 20000 }).should("eq", "/contracts");
cy.contains("Submitted", { timeout: 15000 }).should("be.visible");
dbContract().then(({ rows }) => {
expect(rows, "contract row").to.have.length(1);
expect(rows[0].status).to.eq("SUBMITTED");
expect(rows[0].reference).to.match(/^CTR-/);
});
});
it("marketer accepts the submission and approves the LINE_STAFF step", () => {
cy.loginBackoffice("marketer@edr.local");
withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
cy.contains("button", "Accept for approval", { timeout: 20000 }).click();
// Validity defaults to the first configured option in the accept modal.
cy.contains("button", "Accept & start approval", { timeout: 20000 })
.should("not.be.disabled")
.click();
// Approval chain instantiated: LINE_STAFF → DIRECTOR. Approve step 1.
cy.contains("Approval chain", { timeout: 20000 }).should("be.visible");
cy.contains("button", "Approve", { timeout: 20000 }).click();
cy.contains("button", "Confirm approval").click();
cy.contains("1/2", { timeout: 20000 }).should("be.visible");
expectStatus("PENDING_APPROVAL");
});
it("director approves the final step — contract PDF becomes ready", () => {
cy.loginBackoffice("director@edr.local");
withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}`));
cy.contains("button", "Approve", { timeout: 20000 }).click();
cy.contains("button", "Confirm approval").click();
// Final approval renders the contract PDF synchronously → CONTRACT_READY.
// The approval-chain card unmounts once the contract leaves approval, so
// assert on the signing CTA that replaces it.
cy.contains("button", "View & sign", { timeout: 30000 }).should("exist");
expectStatus("CONTRACT_READY");
});
it("customer signs the contract with OTP", () => {
cy.loginPortal(customer);
withContract((c) => cy.visitPortal(`/contracts/${c.id}/view`));
// Scroll the contract iframe to the bottom so the consent bar unlocks.
// Retried because the iframe can re-render (query refetch) after a scroll.
const unlockConsent = (attempt: number) => {
cy.get('iframe[title="Contract document"]', { timeout: 30000 }).then(
($f) => {
const win = ($f[0] as HTMLIFrameElement).contentWindow;
// documentElement can be null while the srcDoc is (re)parsing —
// skip this round and let the retry pick it up.
const el =
win?.document?.scrollingElement ?? win?.document?.documentElement;
if (win && el) {
el.scrollTop = el.scrollHeight;
win.dispatchEvent(new Event("scroll"));
}
},
);
cy.wait(500).then(() => {
cy.get("body").then(($b) => {
if ($b.text().includes("I have read the entire contract")) return;
expect(attempt, "consent bar unlocked").to.be.lessThan(20);
unlockConsent(attempt + 1);
});
});
};
unlockConsent(0);
cy.contains("I have read the entire contract", { timeout: 15000 }).click();
cy.contains("button", /^Sign contract$|^Approve & sign$/).click();
// Signature modal: name + drawn signature.
cy.contains("label", "Full name")
.invoke("attr", "for")
.then((id) => {
cy.get(`[id="${id}"]`).clear().type("Demo User");
});
cy.drawSignature();
cy.contains("button", "Continue to verification").click();
// OTP modal — code goes to the signer's registered contacts (email only
// for the seeded demo user); read it from the DB.
cy.contains("Verify it's you", { timeout: 20000 }).should("be.visible");
cy.getOtp(customer).then((otp) => cy.typeOtp(otp));
cy.contains("button", "Verify & sign").click();
cy.contains("Your signature has been recorded", { timeout: 30000 }).should(
"be.visible",
);
expectStatus("SIGNED_CUSTOMER");
});
it("staff counter-signs — GENERAL contract becomes CONTRACT_ACTIVE", () => {
cy.loginBackoffice("marketer@edr.local");
withContract((c) => cy.visit(`/dashboard/contract-requests/${c.id}/view`));
cy.contains("button", /^Sign as staff$|^Approve & sign$/, {
timeout: 30000,
}).click();
cy.contains("label", "Full name")
.invoke("attr", "for")
.then((id) => {
cy.get(`[id="${id}"]`).clear().type("EDR Marketer");
});
cy.drawSignature();
cy.contains("button", /^Confirm signature$|^Approve & sign$/).click();
cy.contains("counter-signed", { timeout: 30000 }).should("be.visible");
// GENERAL → clearance runs per booking, contract goes straight active.
expectStatus("CONTRACT_ACTIVE");
// Both signatures recorded.
withContract((c) => {
cy.task<{ rows: Array<{ role: string }> }>("db:query", {
sql: `SELECT s.role FROM freight.contract_signatures s
WHERE s.contract_id = $1 ORDER BY s.role`,
params: [c.id],
}).then(({ rows }) => {
expect(rows.map((r) => r.role)).to.include.members(["CUSTOMER", "STAFF"]);
});
});
});
});
export {};

View File

@@ -1,6 +1,6 @@
/**
* Cross-app flow: same business objects seen from both directions —
* customer (portal, port 5273) and staff (backoffice, port 5283 = baseUrl).
* customer (portal, port 5373) and staff (backoffice, port 5383 = baseUrl).
* Different ports = different origins, so portal steps inside a test that
* also touches backoffice run inside cy.origin().
*

View File

@@ -0,0 +1,203 @@
/**
* Full customer onboarding journey, both apps:
*
* 1. portal — /signup form → OTP (read from DB, delivery is off in e2e)
* → account created → onboarding wizard (nationality/role →
* company → personnel → contact → PoA → documents incl. the
* per-role business license) → "Submit for review"
* 2. backoffice — staff (chief, holds edr_freight_app:admin) approves the
* importer profile on /dashboard/customers/:id
* 3. portal — the new customer is active: contract wizard reachable
*
* Tests are sequential steps of ONE journey (fresh unique user per run), so
* retries are disabled — a mid-journey retry would replay a non-idempotent
* step against already-advanced state.
*
* NOTE: switching origin between tests (portal 5373 ↔ backoffice 5383)
* reloads the spec bundle and resets module state — later tests resolve the
* journey's user/company from the DB instead of module variables.
*/
const stamp = Date.now();
const email = `e2e.onboard.${stamp}@example.com`;
// Ethiopian mobile: 9 + 8 digits, unique per run.
const phoneNational = `9${String(stamp).slice(-8)}`;
const signupPassword = "Password@e2e1";
const companyName = `E2E Onboard Co ${stamp}`;
const tin = String(stamp).slice(-10).padStart(10, "1");
const vat = String(stamp + 1).slice(-10).padStart(10, "2");
const fan = String(stamp).slice(-13).padStart(16, "3");
const portal = () => Cypress.env("portalUrl") as string;
/** The journey's company/user = the latest e2e.onboard.* signup in the DB. */
function latestOnboardJourney() {
return cy.task<{ rows: Array<{ name: string; email: string }> }>("db:query", {
sql: `SELECT c.name, u.email
FROM freight.companies c
JOIN freight.external_profiles ep ON ep.company_id = c.id
JOIN iam.users u ON u.id = ep.user_id
WHERE u.email LIKE 'e2e.onboard.%'
ORDER BY c.created_at DESC LIMIT 1`,
});
}
/** Fill a labelled Mantine input (label[for] → input id). */
function fill(label: string | RegExp, value: string) {
cy.contains("label", label)
.invoke("attr", "for")
.then((id) => {
cy.get(`[id="${id}"]`).clear({ force: true }).type(value, { force: true });
});
}
/** The wizard's phone inputs (react-phone-number-input, type=tel). */
function fillPhone(index: number, national: string) {
cy.get('.mantine-Modal-content input[type="tel"]')
.eq(index)
.clear({ force: true })
.type(national, { force: true });
}
describe("customer onboarding journey", { retries: 0 }, () => {
it("signs up with OTP and completes the onboarding wizard", () => {
// The eTrade TIN lookup 400s in e2e (external service unreachable). The
// form handles it ("fill in the details manually") but axios also throws
// an uncaught rejection — ignore just that one.
cy.on("uncaught:exception", (err) =>
err.message.includes("Request failed with status code 400") ? false : true,
);
cy.visit(`${portal()}/signup`);
fill(/^First name/, "Onboard");
fill(/^Last name/, "Tester");
fill(/^Email/, email);
cy.get('input[type="tel"]').first().type(phoneNational, { force: true });
fill(/^Password/, signupPassword);
fill(/^Confirm password/, signupPassword);
cy.contains("button", "Continue").click();
// OTP stage — the code is generated + stored even though delivery is off.
cy.contains("Verify", { timeout: 15000 }).should("be.visible");
cy.getOtp(email).then((otp) => cy.typeOtp(otp));
cy.contains("button", "Verify & create account").click();
// Signed in → /portal → wizard auto-opens on the nationality/role step.
cy.location("pathname", { timeout: 20000 }).should("eq", "/portal");
cy.contains("Where is your company registered?", { timeout: 15000 }).should(
"be.visible",
);
cy.contains("button", "Ethiopian Company").click();
cy.contains("button", "Importer").click();
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
// Company step. TIN first — the eTrade auto-lookup fails in e2e (no
// external network) and the form allows manual entry.
cy.get('input[placeholder="0012345678"]', { timeout: 15000 }).type(tin);
fill(/^Company Name/, companyName);
fill(/^Company Email/, `ops.${stamp}@example.com`);
fillPhone(0, "911234567");
fill(/^Location/, "Addis Ababa, Ethiopia");
fill(/^VAT Number/, vat);
cy.get('input[placeholder="1234567890123456"]').type(fan);
cy.mantineSelect(/^Region/, "Addis Ababa");
fill(/^Zone/, "Zone 1");
fill(/^Woreda/, "Woreda 1");
fill(/^Kebele/, "Kebele 1");
fill(/^House No/, "123");
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
// Personnel (general manager).
fill(/^Name/, "General Manager");
fill(/^Email/, `gm.${stamp}@example.com`);
fillPhone(0, "911234568");
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
// Contact person.
fill(/^Name/, "Contact Person");
fillPhone(0, "911234569");
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
// PoA — optional for an importer.
cy.get(".mantine-Modal-content").contains("button", "Continue").click();
// Documents: no company docs are configured in e2e, but every role needs
// a business license.
cy.contains("Business license", { timeout: 15000 }).should("be.visible");
cy.get('.mantine-Modal-content input[type="file"]')
.first()
.selectFile("cypress/fixtures/docs/license.pdf", { force: true });
cy.get(".mantine-Modal-content")
.contains("button", "Submit for review")
.click();
cy.contains("You're all set", { timeout: 30000 }).should("be.visible");
// DB cross-check: submitted, awaiting approval.
cy.task<{ rows: Array<{ status: string; onboarding_completed: boolean }> }>(
"db:query",
{
sql: `SELECT c.status, ep.onboarding_completed
FROM freight.companies c
JOIN freight.external_profiles ep ON ep.company_id = c.id
JOIN iam.users u ON u.id = ep.user_id
WHERE u.email = $1`,
params: [email],
},
).then(({ rows }) => {
expect(rows, "company row").to.have.length(1);
expect(rows[0].status).to.eq("pending");
expect(rows[0].onboarding_completed).to.eq(true);
});
});
it("backoffice staff approves the submitted importer profile", () => {
cy.loginBackoffice("chief@edr.local");
cy.visit("/dashboard/customers");
latestOnboardJourney().then(({ rows }) => {
expect(rows, "onboarded company").to.have.length(1);
const company = rows[0].name;
cy.get('input[placeholder*="Search by company"]').type(company);
cy.contains(company, { timeout: 15000 }).click();
// Role profiles table → approve the pending importer profile. Once
// active, the row's action flips to "Suspend".
cy.contains("button", "Approve", { timeout: 15000 }).click();
cy.contains("button", "Suspend", { timeout: 15000 }).should("be.visible");
cy.task<{ rows: Array<{ status: string; reference: string | null; company_status: string }> }>(
"db:query",
{
sql: `SELECT p.status, p.reference, c.status AS company_status
FROM freight.company_profiles p
JOIN freight.companies c ON c.id = p.company_id
WHERE c.name = $1 AND p.type = 'importer'`,
params: [company],
},
).then(({ rows: profiles }) => {
expect(profiles, "importer profile").to.have.length(1);
expect(profiles[0].status).to.eq("active");
expect(profiles[0].reference, "minted reference").to.be.a("string").and
.not.be.empty;
expect(profiles[0].company_status).to.eq("active");
});
});
});
it("the approved customer can reach the contract wizard", () => {
latestOnboardJourney().then(({ rows }) => {
cy.loginPortal(rows[0].email, signupPassword);
});
cy.visitPortal("/contracts/new");
// No "Awaiting Approval" gate — the wizard's first step renders.
cy.contains("label", "Operation Type", { timeout: 15000 }).should(
"be.visible",
);
cy.contains("Awaiting Approval").should("not.exist");
});
});
export {};

View File

@@ -1,7 +1,7 @@
/**
* 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
* Portal is a different origin (port 5373), 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;

View File

@@ -0,0 +1,11 @@
%PDF-1.4
1 0 obj<</Type/Catalog/Pages 2 0 R>>endobj
2 0 obj<</Type/Pages/Kids[3 0 R]/Count 1>>endobj
3 0 obj<</Type/Page/Parent 2 0 R/MediaBox[0 0 200 200]>>endobj
xref
0 4
0000000000 65535 f
trailer<</Size 4/Root 1 0 R>>
startxref
0
%%EOF

View File

@@ -0,0 +1,51 @@
-- Arrange-data for the contract lifecycle specs, applied after seed-users.sql.
-- Idempotent. Two things the app cannot provide without manual steps:
--
-- 1. chief gets `edr_freight_app:admin` (customer-profile approval is
-- FreightAdmin-guarded and no seeded position carries it).
-- 2. user@gmail.com gets an ACTIVE company + approved importer profile so the
-- contract wizard is reachable without first running the onboarding journey.
-- 1. chief → edr_freight_app:admin
INSERT INTO iam.position_permissions (id, position_id, permission_id)
SELECT gen_random_uuid(), p.id, perm.id
FROM iam.positions p
JOIN iam.permissions perm ON perm.key = 'edr_freight_app:admin'
WHERE p.key = 'chief'
AND NOT EXISTS (
SELECT 1 FROM iam.position_permissions pp
WHERE pp.position_id = p.id AND pp.permission_id = perm.id
);
-- 2a. Active customer company (TIN is the idempotency key).
INSERT INTO freight.companies
(id, name, type, status, tin, fan_number, country, address, phone, email,
nationality, kind, attributes)
SELECT gen_random_uuid(), 'E2E Logistics PLC', 'customer', 'active',
'0102030405', '1234567890123456', 'Ethiopia', 'Addis Ababa, Ethiopia',
'+251911000001', 'ops@e2e-logistics.test', 'ethiopian', 'commercial',
'{"contactPersonName":"Test Contact","contactPersonPhone":"+251911000002","generalManagerName":"Test GM","generalManagerEmail":"gm@e2e-logistics.test","generalManagerPhone":"+251911000003"}'::jsonb
WHERE NOT EXISTS (SELECT 1 FROM freight.companies WHERE tin = '0102030405');
-- 2b. Approved importer profile (reference normally minted on approval).
INSERT INTO freight.company_profiles (id, company_id, type, status, reference)
SELECT gen_random_uuid(), c.id, 'importer', 'active', 'IMP-E2E-0001'
FROM freight.companies c
WHERE c.tin = '0102030405'
AND NOT EXISTS (
SELECT 1 FROM freight.company_profiles p
WHERE p.company_id = c.id AND p.type = 'importer'
);
-- 2c. Link the demo portal user to the company, onboarding already done.
INSERT INTO freight.external_profiles
(id, user_id, company_id, first_name, last_name, is_primary_contact,
active_profile_type, onboarding_step, onboarding_completed)
SELECT gen_random_uuid(), u.id, c.id, 'Demo', 'User', true,
'importer', 'done', true
FROM iam.users u
JOIN freight.companies c ON c.tin = '0102030405'
WHERE u.email = 'user@gmail.com'
AND NOT EXISTS (
SELECT 1 FROM freight.external_profiles ep WHERE ep.user_id = u.id
);

View File

@@ -109,8 +109,11 @@ 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
-- start_date must be set: the login query filters positions on
-- start_date <= NOW(), and a NULL start_date silently drops the position
-- (and with it every permission) from the JWT.
insert into iam.employee_positions (id, is_delegate, is_current, status, start_date, unit_id, employee_id, position_id)
select gen_random_uuid(), false, true, 'APPROVED', now() - interval '1 day', un.id, e.id, p.id
from (values
('linestaff@edr.local', 'operation'),
('chief@edr.local', 'chief'),
@@ -128,3 +131,7 @@ 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
);
-- Backfill for rows created before start_date was included above.
update iam.employee_positions set start_date = now() - interval '1 day'
where start_date is null;

View File

@@ -76,6 +76,79 @@ 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 });
});
cy.get('[role="option"]').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 {
@@ -88,6 +161,14 @@ declare global {
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>;
}
}
}