mirror of
https://github.com/Tria-plc/edr-platform.git
synced 2026-08-26 18:42:49 +00:00
chore: more tests
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
213
e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts
Normal file
213
e2e/freight/cypress/e2e/flows/contract-lifecycle.cy.ts
Normal 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 {};
|
||||
@@ -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().
|
||||
*
|
||||
|
||||
203
e2e/freight/cypress/e2e/flows/onboarding.cy.ts
Normal file
203
e2e/freight/cypress/e2e/flows/onboarding.cy.ts
Normal 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 {};
|
||||
@@ -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;
|
||||
|
||||
11
e2e/freight/cypress/fixtures/docs/license.pdf
Normal file
11
e2e/freight/cypress/fixtures/docs/license.pdf
Normal 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
|
||||
51
e2e/freight/cypress/fixtures/seed-company.sql
Normal file
51
e2e/freight/cypress/fixtures/seed-company.sql
Normal 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
|
||||
);
|
||||
@@ -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;
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user