Adding all the license feature and renewal

This commit is contained in:
Mulu Mehari
2026-08-03 12:49:48 +03:00
parent c62ec59655
commit 77bfb01664
21 changed files with 1256 additions and 59 deletions

View 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;
}

View 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 });
}

View 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}';
`);
}