mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-08 17:08:20 +00:00
Adding all the license feature and renewal
This commit is contained in:
88
apps/e2e/README.md
Normal file
88
apps/e2e/README.md
Normal file
@@ -0,0 +1,88 @@
|
||||
# End-to-end browser tests
|
||||
|
||||
Playwright, driving the portal and the backoffice in a real browser against a
|
||||
real API and a real database.
|
||||
|
||||
```bash
|
||||
# from emaui/
|
||||
npx nx e2e @ema-platform/e2e # or: npx playwright test --config apps/e2e/playwright.config.ts
|
||||
npx playwright test --config apps/e2e/playwright.config.ts --headed --debug
|
||||
npx playwright show-report ../../dist/e2e-report
|
||||
```
|
||||
|
||||
## What it runs against
|
||||
|
||||
Nothing shared with your own dev stack. The suite starts its own servers, on its
|
||||
own ports, against its own database:
|
||||
|
||||
| | port | notes |
|
||||
|---|---|---|
|
||||
| API | 3011 | `node dist/main.js` with explicit env |
|
||||
| Portal | 4302 | `vite build --mode e2e` then `vite preview` |
|
||||
| Backoffice | 4303 | same |
|
||||
| Database | — | `ema_e2e` |
|
||||
|
||||
This is deliberate. A developer's stack is usually already up on 3000/4200/4201,
|
||||
and `dev/start.sh` **rewrites** `emaapi/apps/server/emaapi/.env` and the apps'
|
||||
`.env.local` on every run — a suite that read those files would point at
|
||||
whichever stack was started last. The API is launched with `DATABASE_NAME`,
|
||||
`PORT` and the payment flags passed directly, and the frontends are built with
|
||||
`--mode e2e`, which picks up `apps/*/.env.e2e.local` (higher precedence than
|
||||
`.env.local`, so your own config is left alone).
|
||||
|
||||
`vite preview` on a production build rather than `nx serve`, because Nx
|
||||
serialises `serve` targets per project: with your dev portal already running,
|
||||
a second `nx serve @ema-platform/portal` waits forever on the first.
|
||||
|
||||
### First-time setup
|
||||
|
||||
The E2E database has to exist and be bootstrapped once:
|
||||
|
||||
```bash
|
||||
DB_NAME=ema_e2e ./dev/start.sh # creates it, migrates, seeds, then starts a stack you can Ctrl-C
|
||||
```
|
||||
|
||||
Afterwards the suite manages its own servers; you do not need `start.sh` again.
|
||||
Note that this run leaves the shared `.env` files pointing at the E2E database
|
||||
and its ports — rerun `./dev/start.sh` plain to put them back.
|
||||
|
||||
## How it gets its data
|
||||
|
||||
- **Accounts are created through the UI.** Every spec signs up its own applicant
|
||||
with a timestamped email, and deletes it in `afterEach` (`deleteApplicant`).
|
||||
Nothing is shared between specs, so specs can run in any order and the second
|
||||
run of the suite behaves exactly like the first.
|
||||
- **The one-time code is scraped from the API log.** There is no local SMS
|
||||
gateway, `iam.user_verifications.verification_code` is argon2-**hashed**, and
|
||||
the notification rows it is delivered through carry an empty body — the log
|
||||
line is the only plaintext. The API is therefore started with its output teed
|
||||
to `/tmp/ema-e2e-api.log`, and `support/api-log.ts` reads codes written after
|
||||
a recorded offset. The test still types the real code into the real screen.
|
||||
Because the log line names no recipient, matching is by position, which is
|
||||
sound only while the suite runs single-worker — hence `workers: 1`.
|
||||
- **Database reads use `psql`**, shelled out from `support/db.ts`, rather than
|
||||
adding a `pg` dependency to the frontend workspace for the sake of a few
|
||||
assertions.
|
||||
|
||||
## Conventions
|
||||
|
||||
- One worker, serial. The officer queue is global state and the log-offset trick
|
||||
needs ordering; determinism is worth the wall-clock.
|
||||
- Assertions are on what the user sees — headings, badges, buttons — with
|
||||
database checks only where the point is that something was *persisted*.
|
||||
- No `waitForTimeout`. Wait for a condition.
|
||||
- Traces, screenshots and video are retained for failures only.
|
||||
|
||||
## Debugging a failure
|
||||
|
||||
Playwright writes `test-results/<test>/error-context.md` with a full
|
||||
accessibility snapshot of the page at the moment it failed — usually enough on
|
||||
its own. Otherwise `npx playwright show-trace test-results/<test>/trace.zip`.
|
||||
|
||||
## Status
|
||||
|
||||
Implemented: signup → OTP → operations gate → dashboard, and the empty-state
|
||||
dashboard. See the parent prompt (`dev/prompts/e2e-browser-tests-prompt.md`) for
|
||||
the flows still to be written: catalogue filtering, the full application
|
||||
lifecycle per licence type, officer review, payment bypass, certificate
|
||||
download, expiry, renewal and reminders.
|
||||
115
apps/e2e/playwright.config.ts
Normal file
115
apps/e2e/playwright.config.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
/**
|
||||
* End-to-end suite, driving the portal and the backoffice in a real browser.
|
||||
*
|
||||
* Both frontends are in one project on purpose: the flows that matter cross
|
||||
* them — an applicant files, an officer decides, the applicant collects the
|
||||
* certificate — and per-app projects cannot express that.
|
||||
*
|
||||
* Nothing here reads the shared `.env` files. A developer's own stack is
|
||||
* usually already running against `ema_db` on the default ports, and
|
||||
* `dev/start.sh` rewrites those files on every run; a suite that read them
|
||||
* would point at whichever stack was started last. Instead the servers below
|
||||
* are launched with explicit environment, on ports of their own, against a
|
||||
* database of their own.
|
||||
*/
|
||||
|
||||
const API_PORT = Number(process.env.E2E_API_PORT ?? 3011);
|
||||
const PORTAL_PORT = Number(process.env.E2E_PORTAL_PORT ?? 4302);
|
||||
const BACKOFFICE_PORT = Number(process.env.E2E_BACKOFFICE_PORT ?? 4303);
|
||||
|
||||
export const E2E = {
|
||||
apiUrl: `http://localhost:${API_PORT}/api`,
|
||||
portalUrl: `http://localhost:${PORTAL_PORT}`,
|
||||
backofficeUrl: `http://localhost:${BACKOFFICE_PORT}`,
|
||||
database: process.env.E2E_DB_NAME ?? 'ema_e2e',
|
||||
/**
|
||||
* Where the API's own output is teed.
|
||||
*
|
||||
* The one-time code an applicant types at signup exists in exactly one
|
||||
* readable place: this log. `iam.user_verifications` stores it argon2-hashed,
|
||||
* and the notification rows it is delivered through carry an empty body — so
|
||||
* the log line is not a convenience here, it is the only source.
|
||||
*/
|
||||
apiLog: process.env.E2E_API_LOG ?? '/tmp/ema-e2e-api.log',
|
||||
};
|
||||
|
||||
const emaapi =
|
||||
'../../../emaapi/apps/server/emaapi';
|
||||
|
||||
/** Env the API is started with. Explicit, so no `.env` can redirect it. */
|
||||
const apiEnv = {
|
||||
...process.env,
|
||||
PORT: String(API_PORT),
|
||||
DATABASE_NAME: E2E.database,
|
||||
// The API applies migrations on boot, which is what bootstraps a fresh E2E
|
||||
// database — but it must never be the thing that decides what the schema is.
|
||||
MIGRATIONS_RUN: 'false',
|
||||
NODE_ENV: 'development',
|
||||
FRONTEND_URLS: `${E2E.portalUrl},${E2E.backofficeUrl}`,
|
||||
PUBLIC_VERIFY_URL: `${E2E.portalUrl}/verify`,
|
||||
PORTAL_BASE_URL: E2E.portalUrl,
|
||||
PAYMENT_AUTO_CONFIRM: 'true',
|
||||
ALLOW_PAYMENT_BYPASS: 'true',
|
||||
};
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './src',
|
||||
// Flows share one database, and an officer queue is global state. Serial
|
||||
// execution costs wall-clock and buys determinism.
|
||||
workers: 1,
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
timeout: 90_000,
|
||||
expect: { timeout: 15_000 },
|
||||
reporter: [['list'], ['html', { outputFolder: '../../dist/e2e-report', open: 'never' }]],
|
||||
|
||||
use: {
|
||||
baseURL: E2E.portalUrl,
|
||||
// Kept only for failures — a trace per passing test is noise nobody reads.
|
||||
trace: 'retain-on-failure',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
actionTimeout: 15_000,
|
||||
},
|
||||
|
||||
projects: [
|
||||
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
||||
],
|
||||
|
||||
webServer: [
|
||||
{
|
||||
name: 'api',
|
||||
// Teed rather than piped to Playwright's own stdout, because the OTP
|
||||
// helper has to read it back.
|
||||
command: `sh -c 'node dist/main.js 2>&1 | tee ${E2E.apiLog}'`,
|
||||
cwd: emaapi,
|
||||
env: apiEnv,
|
||||
url: `${E2E.apiUrl}/license-types`,
|
||||
// 401 is the healthy answer: the route exists and demands a session.
|
||||
ignoreHTTPSErrors: true,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
stdout: 'pipe',
|
||||
stderr: 'pipe',
|
||||
},
|
||||
{
|
||||
name: 'portal',
|
||||
command: `npx vite build --config apps/portal/vite.config.mts --mode e2e && npx vite preview --config apps/portal/vite.config.mts --mode e2e --port ${PORTAL_PORT} --strictPort`,
|
||||
cwd: '../..',
|
||||
url: E2E.portalUrl,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 180_000,
|
||||
},
|
||||
{
|
||||
name: 'backoffice',
|
||||
command: `npx vite build --config apps/backoffice/vite.config.mts --mode e2e && npx vite preview --config apps/backoffice/vite.config.mts --mode e2e --port ${BACKOFFICE_PORT} --strictPort`,
|
||||
cwd: '../..',
|
||||
url: E2E.backofficeUrl,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 180_000,
|
||||
},
|
||||
],
|
||||
});
|
||||
15
apps/e2e/project.json
Normal file
15
apps/e2e/project.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "@ema-platform/e2e",
|
||||
"$schema": "../../node_modules/nx/schemas/project-schema.json",
|
||||
"projectType": "application",
|
||||
"sourceRoot": "apps/e2e/src",
|
||||
"targets": {
|
||||
"e2e": {
|
||||
"executor": "nx:run-commands",
|
||||
"options": {
|
||||
"command": "npx playwright test --config apps/e2e/playwright.config.ts",
|
||||
"cwd": "."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
102
apps/e2e/src/onboarding.spec.ts
Normal file
102
apps/e2e/src/onboarding.spec.ts
Normal file
@@ -0,0 +1,102 @@
|
||||
import { test, expect } from '@playwright/test';
|
||||
import {
|
||||
Applicant,
|
||||
newApplicant,
|
||||
signUp,
|
||||
verifyOtpIfPrompted,
|
||||
} from './support/applicant';
|
||||
import { deleteApplicant, sql } from './support/db';
|
||||
|
||||
/**
|
||||
* Signing up, and being asked what you operate as before anything else.
|
||||
*
|
||||
* The gate is the whole point: the catalogue is filtered by the answer and the
|
||||
* API refuses an application for a mode the profile does not hold, so an
|
||||
* applicant who skipped this would reach a dashboard that offers them nothing
|
||||
* and explains nothing.
|
||||
*/
|
||||
test.describe('signup and onboarding', () => {
|
||||
let applicant: Applicant;
|
||||
|
||||
test.beforeEach(() => {
|
||||
applicant = newApplicant('onboard');
|
||||
});
|
||||
|
||||
test.afterEach(() => {
|
||||
deleteApplicant(applicant.email);
|
||||
});
|
||||
|
||||
test('a new applicant is asked for their operations before reaching the dashboard', async ({
|
||||
page,
|
||||
}) => {
|
||||
const offset = await signUp(page, applicant);
|
||||
await verifyOtpIfPrompted(page, offset);
|
||||
|
||||
// The account exists in IAM ...
|
||||
const rows = sql(
|
||||
`SELECT email FROM iam.users WHERE email = '${applicant.email}'`,
|
||||
);
|
||||
expect(rows).toHaveLength(1);
|
||||
|
||||
// ... and the first thing behind the session is the question, not the app.
|
||||
await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /what do you operate as/i }),
|
||||
).toBeVisible();
|
||||
|
||||
// The dashboard stays out of reach until it is answered.
|
||||
await page.goto('/dashboard');
|
||||
await expect(page).toHaveURL(/\/onboarding\/operations/);
|
||||
|
||||
// Answer it.
|
||||
await page
|
||||
.getByRole('checkbox', { name: /freight forwarder license/i })
|
||||
.first()
|
||||
.check();
|
||||
await page.getByRole('button', { name: /save operations/i }).click();
|
||||
|
||||
await expect(page).toHaveURL(/\/dashboard/, { timeout: 30_000 });
|
||||
|
||||
// And it stuck: stored against the profile, keyed by the IAM user id.
|
||||
const declared = sql(`
|
||||
SELECT lt.key
|
||||
FROM profile_operator_types pot
|
||||
JOIN profiles p ON p.id = pot.profile_id
|
||||
JOIN iam.users u ON u.id = p.user_id
|
||||
JOIN license_types lt ON lt.id = pot.license_type_id
|
||||
WHERE u.email = '${applicant.email}' AND pot.deleted_at IS NULL
|
||||
`);
|
||||
expect(declared.map((r) => r[0])).toEqual(['FREIGHT_FORWARDER']);
|
||||
});
|
||||
|
||||
test('the dashboard leads with a get-started panel when there is nothing to show', async ({
|
||||
page,
|
||||
}) => {
|
||||
const offset = await signUp(page, applicant);
|
||||
await verifyOtpIfPrompted(page, offset);
|
||||
await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
||||
|
||||
await page
|
||||
.getByRole('checkbox', { name: /freight forwarder license/i })
|
||||
.first()
|
||||
.check();
|
||||
await page.getByRole('button', { name: /save operations/i }).click();
|
||||
await expect(page).toHaveURL(/\/dashboard/, { timeout: 30_000 });
|
||||
|
||||
// Nothing filed and nothing held: one panel, not two empty sections.
|
||||
await expect(page.getByRole('heading', { name: 'Get started' })).toBeVisible();
|
||||
await expect(
|
||||
page.getByRole('heading', { name: 'My applications' }),
|
||||
).toHaveCount(0);
|
||||
await expect(page.getByRole('heading', { name: 'My licences' })).toHaveCount(
|
||||
0,
|
||||
);
|
||||
|
||||
// The catalogue is directly beneath it, filtered to what they declared.
|
||||
await expect(
|
||||
page.getByRole('heading', { name: /apply for a licence/i }),
|
||||
).toBeVisible();
|
||||
await expect(page.getByText('Freight Forwarder License').first()).toBeVisible();
|
||||
await expect(page.getByText('Shipping Agent License')).toHaveCount(0);
|
||||
});
|
||||
});
|
||||
58
apps/e2e/src/support/api-log.ts
Normal file
58
apps/e2e/src/support/api-log.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { readFileSync, statSync } from 'node:fs';
|
||||
import { E2E } from '../../playwright.config';
|
||||
|
||||
/**
|
||||
* Reading one-time codes back out of the API's own output.
|
||||
*
|
||||
* There is no local SMS gateway, and the code is not recoverable anywhere else:
|
||||
* `iam.user_verifications.verification_code` holds an argon2 hash, and the
|
||||
* notification rows it is delivered through store an empty body. The log line
|
||||
* the message composer emits is the only plaintext.
|
||||
*
|
||||
* Codes are matched by *position in the log*, not by recipient — the line names
|
||||
* no user. Callers take an offset before the action that triggers the send and
|
||||
* only read what was written after it, which is sound because the suite runs
|
||||
* single-worker and serially.
|
||||
*/
|
||||
|
||||
const OTP_PATTERN = /is (\d{4,8})\./g;
|
||||
|
||||
/** Byte offset to read from later. Zero when the log does not exist yet. */
|
||||
export function logOffset(): number {
|
||||
try {
|
||||
return statSync(E2E.apiLog).size;
|
||||
} catch {
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
/** Waits for a code to appear after `offset`, and returns the last one seen. */
|
||||
export async function waitForOtp(
|
||||
offset: number,
|
||||
timeoutMs = 20_000,
|
||||
): Promise<string> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
|
||||
while (Date.now() < deadline) {
|
||||
const found = otpSince(offset);
|
||||
if (found) return found;
|
||||
await new Promise((resolve) => setTimeout(resolve, 250));
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`No one-time code appeared in ${E2E.apiLog} within ${timeoutMs}ms. ` +
|
||||
`Is the API teeing its output there?`,
|
||||
);
|
||||
}
|
||||
|
||||
function otpSince(offset: number): string | null {
|
||||
let text: string;
|
||||
try {
|
||||
text = readFileSync(E2E.apiLog, 'utf8').slice(offset);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const codes = [...text.matchAll(OTP_PATTERN)].map((m) => m[1]);
|
||||
return codes.length > 0 ? codes[codes.length - 1] : null;
|
||||
}
|
||||
99
apps/e2e/src/support/applicant.ts
Normal file
99
apps/e2e/src/support/applicant.ts
Normal file
@@ -0,0 +1,99 @@
|
||||
import { Page, expect } from '@playwright/test';
|
||||
import { logOffset, waitForOtp } from './api-log';
|
||||
|
||||
/**
|
||||
* A new applicant, unique to this run.
|
||||
*
|
||||
* Every spec makes its own. Sharing one account between specs means the second
|
||||
* run of the suite starts from a state the first run left behind — an
|
||||
* applicant who already declared their operations, already holds a licence —
|
||||
* and the assertions quietly stop meaning what they say.
|
||||
*/
|
||||
export interface Applicant {
|
||||
email: string;
|
||||
username: string;
|
||||
phoneNumber: string;
|
||||
password: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export function newApplicant(label: string): Applicant {
|
||||
const stamp = `${Date.now()}${Math.floor(Math.random() * 1000)}`;
|
||||
return {
|
||||
email: `e2e.${label}.${stamp}@example.test`,
|
||||
username: `e2e${label}${stamp}`.slice(0, 28),
|
||||
// Ethiopian mobile format; the last digits vary so two runs never collide.
|
||||
phoneNumber: `+2519${stamp.slice(-8)}`,
|
||||
password: 'E2ePassw0rd!',
|
||||
name: `E2E ${label} ${stamp.slice(-4)}`,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fills and submits the signup form.
|
||||
*
|
||||
* Returns the log offset taken immediately before submitting, so the caller can
|
||||
* pick up the one-time code that submission triggers without matching a stale
|
||||
* one from an earlier test.
|
||||
*/
|
||||
export async function signUp(page: Page, applicant: Applicant): Promise<number> {
|
||||
await page.goto('/signup');
|
||||
|
||||
await page.getByLabel('Name (English)').fill(applicant.name);
|
||||
await page.getByLabel('Email address').fill(applicant.email);
|
||||
await page.getByLabel('Username').fill(applicant.username);
|
||||
await page.getByLabel('Phone number').fill(applicant.phoneNumber);
|
||||
await page.getByLabel('Password', { exact: true }).fill(applicant.password);
|
||||
await page.getByLabel('Confirm password').fill(applicant.password);
|
||||
|
||||
// The terms checkbox gates the submit button.
|
||||
await page.getByRole('checkbox').check();
|
||||
|
||||
const offset = logOffset();
|
||||
await page.getByRole('button', { name: /create account|sign up/i }).click();
|
||||
return offset;
|
||||
}
|
||||
|
||||
/**
|
||||
* Completes phone verification by typing the real code.
|
||||
*
|
||||
* No-op when signup did not land on the OTP screen — whether it does depends
|
||||
* on how the account was created, and a test that asserts the flow rather than
|
||||
* the screen should not care.
|
||||
*/
|
||||
export async function verifyOtpIfPrompted(
|
||||
page: Page,
|
||||
sinceOffset: number,
|
||||
): Promise<boolean> {
|
||||
await page.waitForURL(/\/(otp-verify|onboarding|dashboard)/, { timeout: 30_000 });
|
||||
if (!page.url().includes('/otp-verify')) return false;
|
||||
|
||||
const code = await waitForOtp(sinceOffset);
|
||||
|
||||
// Mantine's PinInput is one input per character and moves focus itself, so
|
||||
// the code is typed rather than filled — filling each box individually
|
||||
// fights the component's own focus handling.
|
||||
const boxes = page.locator('input[inputmode], input[type="text"]');
|
||||
await boxes.first().click();
|
||||
await page.keyboard.type(code, { delay: 30 });
|
||||
|
||||
// `onComplete` submits on the last character; the button is the fallback for
|
||||
// when it does not fire.
|
||||
const submit = page.getByRole('button', { name: /^verify$/i });
|
||||
await Promise.race([
|
||||
page.waitForURL(/\/(onboarding|dashboard)/, { timeout: 10_000 }).catch(() => null),
|
||||
submit.click({ timeout: 5_000 }).catch(() => null),
|
||||
]);
|
||||
return true;
|
||||
}
|
||||
|
||||
/** Signs in an existing applicant through the login screen. */
|
||||
export async function logIn(page: Page, applicant: Applicant): Promise<void> {
|
||||
await page.goto('/login');
|
||||
// The API takes an email here, not a username — sending a username answers
|
||||
// "email should not be empty".
|
||||
await page.getByLabel(/email/i).fill(applicant.email);
|
||||
await page.getByLabel(/password/i).fill(applicant.password);
|
||||
await page.getByRole('button', { name: /sign in|log in|login/i }).click();
|
||||
await expect(page).toHaveURL(/\/(onboarding|dashboard|profile)/, { timeout: 30_000 });
|
||||
}
|
||||
60
apps/e2e/src/support/db.ts
Normal file
60
apps/e2e/src/support/db.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { execFileSync } from 'node:child_process';
|
||||
import { E2E } from '../../playwright.config';
|
||||
|
||||
/**
|
||||
* Direct access to the E2E database.
|
||||
*
|
||||
* Shelling out to `psql` rather than adding a `pg` dependency to the frontend
|
||||
* workspace: the suite needs a handful of reads and a cleanup delete, and a
|
||||
* driver in `emaui/package.json` would follow the frontend build around
|
||||
* forever for the sake of them.
|
||||
*/
|
||||
const PSQL_ENV = {
|
||||
...process.env,
|
||||
PGPASSWORD: process.env.E2E_DB_PASSWORD ?? 'TradingTria@2090',
|
||||
};
|
||||
|
||||
export function sql(query: string): string[][] {
|
||||
const out = execFileSync(
|
||||
'psql',
|
||||
[
|
||||
'-h', process.env.E2E_DB_HOST ?? 'localhost',
|
||||
'-p', process.env.E2E_DB_PORT ?? '5432',
|
||||
'-U', process.env.E2E_DB_USER ?? 'postgres',
|
||||
'-d', E2E.database,
|
||||
'-tAF', '\t',
|
||||
'-c', query,
|
||||
],
|
||||
{ env: PSQL_ENV, encoding: 'utf8' },
|
||||
);
|
||||
return out
|
||||
.split('\n')
|
||||
.filter((line) => line.trim() !== '')
|
||||
.map((line) => line.split('\t'));
|
||||
}
|
||||
|
||||
/** First column of the first row, or null when the query found nothing. */
|
||||
export function sqlValue(query: string): string | null {
|
||||
const rows = sql(query);
|
||||
return rows.length > 0 ? rows[0][0] : null;
|
||||
}
|
||||
|
||||
/** Removes everything a run created, so the next run starts from the same place. */
|
||||
export function deleteApplicant(email: string): void {
|
||||
const safe = email.replace(/'/g, "''");
|
||||
const userId = sqlValue(`SELECT id FROM iam.users WHERE email = '${safe}'`);
|
||||
if (!userId) return;
|
||||
|
||||
sql(`
|
||||
DELETE FROM licenses WHERE holder_user_id = '${userId}';
|
||||
DELETE FROM license_applications WHERE applicant_user_id = '${userId}';
|
||||
DELETE FROM profile_operator_types
|
||||
WHERE profile_id IN (SELECT id FROM profiles WHERE user_id = '${userId}');
|
||||
DELETE FROM profiles WHERE user_id = '${userId}';
|
||||
DELETE FROM iam.notifications WHERE recipient_id = '${userId}';
|
||||
DELETE FROM iam.user_verifications WHERE user_id = '${userId}';
|
||||
DELETE FROM iam.user_credentials WHERE user_id = '${userId}';
|
||||
DELETE FROM iam.user_roles WHERE user_id = '${userId}';
|
||||
DELETE FROM iam.users WHERE id = '${userId}';
|
||||
`);
|
||||
}
|
||||
11
apps/e2e/tsconfig.json
Normal file
11
apps/e2e/tsconfig.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"extends": "../../tsconfig.base.json",
|
||||
"compilerOptions": {
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"types": ["node"],
|
||||
"allowJs": true,
|
||||
"noEmit": true
|
||||
},
|
||||
"include": ["src/**/*.ts", "playwright.config.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user