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:
@@ -8,8 +8,9 @@
|
|||||||
# Interactive from host: pnpm --filter @edr/freight-e2e cy:open
|
# Interactive from host: pnpm --filter @edr/freight-e2e cy:open
|
||||||
# Teardown: docker compose -f docker-compose.e2e.yaml down -v --remove-orphans
|
# Teardown: docker compose -f docker-compose.e2e.yaml down -v --remove-orphans
|
||||||
#
|
#
|
||||||
# Host ports (chosen to never collide with the dev stack):
|
# Host ports (chosen to never collide with the dev stacks — 5273/5283/3221 are
|
||||||
# freight-api 3101 portal 5273 backoffice 5283
|
# taken by the second dev checkout in ~/projects/nathnael/edr-platform):
|
||||||
|
# freight-api 3101 portal 5373 backoffice 5383
|
||||||
# postgres 5533 minio 9310 (console 9311)
|
# postgres 5533 minio 9310 (console 9311)
|
||||||
name: edr-freight-e2e
|
name: edr-freight-e2e
|
||||||
|
|
||||||
@@ -47,6 +48,18 @@ services:
|
|||||||
timeout: 5s
|
timeout: 5s
|
||||||
retries: 12
|
retries: 12
|
||||||
|
|
||||||
|
# tmpfs wipes MinIO on every restart — recreate the app bucket each boot.
|
||||||
|
minio-init-e2e:
|
||||||
|
image: minio/mc:latest
|
||||||
|
depends_on:
|
||||||
|
minio-e2e:
|
||||||
|
condition: service_healthy
|
||||||
|
entrypoint:
|
||||||
|
- /bin/sh
|
||||||
|
- -c
|
||||||
|
- mc alias set e2e http://minio-e2e:9000 e2e-minio e2e-minio-secret && mc mb --ignore-existing e2e/fhc
|
||||||
|
restart: "no"
|
||||||
|
|
||||||
freight-api-e2e:
|
freight-api-e2e:
|
||||||
build:
|
build:
|
||||||
context: .
|
context: .
|
||||||
@@ -58,6 +71,8 @@ services:
|
|||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
minio-e2e:
|
minio-e2e:
|
||||||
condition: service_healthy
|
condition: service_healthy
|
||||||
|
minio-init-e2e:
|
||||||
|
condition: service_completed_successfully
|
||||||
environment:
|
environment:
|
||||||
PORT: "3001"
|
PORT: "3001"
|
||||||
DB_HOST: postgres-freight-e2e
|
DB_HOST: postgres-freight-e2e
|
||||||
@@ -89,7 +104,10 @@ services:
|
|||||||
# External integrations off
|
# External integrations off
|
||||||
RABBITMQ_ENABLED: "false"
|
RABBITMQ_ENABLED: "false"
|
||||||
FAYDA_ENABLED: "false"
|
FAYDA_ENABLED: "false"
|
||||||
FREIGHT_PORTAL_URL: http://localhost:5273
|
# SMS strategy has no kill switch and defaults to a real dev endpoint —
|
||||||
|
# blackhole it so e2e never sends SMS (failures are logged, non-fatal).
|
||||||
|
OZIKING_SMS_URL: http://127.0.0.1:9/sms
|
||||||
|
FREIGHT_PORTAL_URL: http://localhost:5373
|
||||||
ports:
|
ports:
|
||||||
- "3101:3001"
|
- "3101:3001"
|
||||||
healthcheck:
|
healthcheck:
|
||||||
@@ -124,7 +142,7 @@ services:
|
|||||||
secrets:
|
secrets:
|
||||||
- npmrc
|
- npmrc
|
||||||
ports:
|
ports:
|
||||||
- "5273:80"
|
- "5373:80"
|
||||||
|
|
||||||
freight-backoffice-e2e:
|
freight-backoffice-e2e:
|
||||||
build:
|
build:
|
||||||
@@ -142,7 +160,7 @@ services:
|
|||||||
secrets:
|
secrets:
|
||||||
- npmrc
|
- npmrc
|
||||||
ports:
|
ports:
|
||||||
- "5283:80"
|
- "5383:80"
|
||||||
|
|
||||||
# Headless runner — opt-in via `--profile cypress`. host network so the
|
# Headless runner — opt-in via `--profile cypress`. host network so the
|
||||||
# in-container browser uses the exact same localhost URLs as `cypress open`
|
# in-container browser uses the exact same localhost URLs as `cypress open`
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ both headless-in-Docker and interactively from the host against the same URLs.
|
|||||||
| Service | Host port | Notes |
|
| Service | Host port | Notes |
|
||||||
| ----------------------- | --------- | ---------------------------------------------- |
|
| ----------------------- | --------- | ---------------------------------------------- |
|
||||||
| `freight-api-e2e` | 3101 | migrations + seeders run at boot |
|
| `freight-api-e2e` | 3101 | migrations + seeders run at boot |
|
||||||
| `freight-portal-e2e` | 5273 | nginx static build, API baked to `:3101` |
|
| `freight-portal-e2e` | 5373 | nginx static build, API baked to `:3101` |
|
||||||
| `freight-backoffice-e2e`| 5283 | 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` |
|
| `postgres-freight-e2e` | 5533 | `edr_freight_e2e`, tmpfs — gone on `down` |
|
||||||
| `minio-e2e` | 9310/9311 | object storage for file features |
|
| `minio-e2e` | 9310/9311 | object storage for file features |
|
||||||
| `cypress` | (host net)| profile `cypress`, headless chrome |
|
| `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
|
`cy.loginBackoffice(email?)` / `cy.loginPortal(email?)` — `cy.session`-cached
|
||||||
(across specs), `POST /api/auth/login`, sets the `auth-token` /
|
(across specs), `POST /api/auth/login`, sets the `auth-token` /
|
||||||
`refresh-token` cookies the apps read.
|
`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
|
the absolute portal URL; a test that touches *both* apps wraps portal steps
|
||||||
in `cy.origin()` (different port = different origin). Cookies ignore ports —
|
in `cy.origin()` (different port = different origin). Cookies ignore ports —
|
||||||
always call the matching login command right before switching apps so
|
always call the matching login command right before switching apps so
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { Client } from "pg";
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Freight e2e suite. Three origins:
|
* Freight e2e suite. Three origins:
|
||||||
* backoffice http://localhost:5283 (baseUrl — most specs live here)
|
* backoffice http://localhost:5383 (baseUrl — most specs live here)
|
||||||
* portal http://localhost:5273 (env.portalUrl; portal specs cy.visit it,
|
* portal http://localhost:5373 (env.portalUrl; portal specs cy.visit it,
|
||||||
* cross-app flows reach it via cy.origin)
|
* cross-app flows reach it via cy.origin)
|
||||||
* api http://localhost:3101 (env.apiUrl; cy.request only)
|
* api http://localhost:3101 (env.apiUrl; cy.request only)
|
||||||
*
|
*
|
||||||
@@ -17,7 +17,7 @@ import { Client } from "pg";
|
|||||||
*/
|
*/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
e2e: {
|
e2e: {
|
||||||
baseUrl: process.env.CYPRESS_BASE_URL ?? "http://localhost:5283",
|
baseUrl: process.env.CYPRESS_BASE_URL ?? "http://localhost:5383",
|
||||||
specPattern: "cypress/e2e/**/*.cy.ts",
|
specPattern: "cypress/e2e/**/*.cy.ts",
|
||||||
supportFile: "cypress/support/e2e.ts",
|
supportFile: "cypress/support/e2e.ts",
|
||||||
video: process.env.CI === "true" || process.env.CYPRESS_VIDEO === "true",
|
video: process.env.CI === "true" || process.env.CYPRESS_VIDEO === "true",
|
||||||
@@ -29,8 +29,8 @@ export default defineConfig({
|
|||||||
retries: { runMode: 1, openMode: 0 },
|
retries: { runMode: 1, openMode: 0 },
|
||||||
env: {
|
env: {
|
||||||
apiUrl: process.env.CYPRESS_API_URL ?? "http://localhost:3101",
|
apiUrl: process.env.CYPRESS_API_URL ?? "http://localhost:3101",
|
||||||
portalUrl: process.env.CYPRESS_PORTAL_URL ?? "http://localhost:5273",
|
portalUrl: process.env.CYPRESS_PORTAL_URL ?? "http://localhost:5373",
|
||||||
backofficeUrl: process.env.CYPRESS_BASE_URL ?? "http://localhost:5283",
|
backofficeUrl: process.env.CYPRESS_BASE_URL ?? "http://localhost:5383",
|
||||||
// Staff users: DEFAULT_PASSWORD from docker-compose.e2e.yaml.
|
// Staff users: DEFAULT_PASSWORD from docker-compose.e2e.yaml.
|
||||||
defaultPassword: process.env.CYPRESS_DEFAULT_PASSWORD ?? "password@tria",
|
defaultPassword: process.env.CYPRESS_DEFAULT_PASSWORD ?? "password@tria",
|
||||||
// Demo portal users: hardcoded in DemoUsersSeeder.
|
// Demo portal users: hardcoded in DemoUsersSeeder.
|
||||||
@@ -61,14 +61,17 @@ export default defineConfig({
|
|||||||
*/
|
*/
|
||||||
async "db:seedUsers"() {
|
async "db:seedUsers"() {
|
||||||
// cwd = the e2e/freight project root when Cypress runs.
|
// cwd = the e2e/freight project root when Cypress runs.
|
||||||
const sql = readFileSync(
|
// seed-company.sql depends on rows from seed-users.sql — keep order.
|
||||||
join(process.cwd(), "cypress", "fixtures", "seed-users.sql"),
|
|
||||||
"utf8",
|
|
||||||
);
|
|
||||||
const client = new Client({ connectionString: dbUrl });
|
const client = new Client({ connectionString: dbUrl });
|
||||||
await client.connect();
|
await client.connect();
|
||||||
try {
|
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;
|
return true;
|
||||||
} finally {
|
} finally {
|
||||||
await client.end();
|
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 —
|
* 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
|
* Different ports = different origins, so portal steps inside a test that
|
||||||
* also touches backoffice run inside cy.origin().
|
* 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).
|
* UI login for the customer portal (seeded demo user).
|
||||||
* Form: apps/edr-freight-web/portal/src/pages/accounts/LoginPage.tsx.
|
* 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.
|
* URL — each test here stays on that single origin, no cy.origin needed.
|
||||||
*/
|
*/
|
||||||
const portal = () => Cypress.env("portalUrl") as string;
|
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'
|
where u.email like '%@edr.local'
|
||||||
and not exists (select 1 from iam.employees e where e.user_id = u.id);
|
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)
|
-- start_date must be set: the login query filters positions on
|
||||||
select gen_random_uuid(), false, true, 'APPROVED', un.id, e.id, p.id
|
-- 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
|
from (values
|
||||||
('linestaff@edr.local', 'operation'),
|
('linestaff@edr.local', 'operation'),
|
||||||
('chief@edr.local', 'chief'),
|
('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 (
|
where not exists (
|
||||||
select 1 from iam.employee_positions ep where ep.employee_id = e.id and ep.position_id = p.id
|
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}`);
|
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 {
|
declare global {
|
||||||
// eslint-disable-next-line @typescript-eslint/no-namespace
|
// eslint-disable-next-line @typescript-eslint/no-namespace
|
||||||
namespace Cypress {
|
namespace Cypress {
|
||||||
@@ -88,6 +161,14 @@ declare global {
|
|||||||
loginPortal(email?: string, pass?: string): Chainable<void>;
|
loginPortal(email?: string, pass?: string): Chainable<void>;
|
||||||
/** cy.visit against the portal origin (env.portalUrl). */
|
/** cy.visit against the portal origin (env.portalUrl). */
|
||||||
visitPortal(path?: string): Chainable<void>;
|
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