Add error context and media files for seafarer registration tests

- Created error context markdown files for failed tests in seafarer registration, detailing validation errors related to profile details.
- Added binary files (PNG, ZIP, WEBM) for test results, including snapshots and traces for debugging purposes.
- Updated test cases to include new error context and media files for better analysis of failures.
This commit is contained in:
Fistum
2026-08-18 09:20:36 +00:00
parent c7bd69eecb
commit 1931e69594
42 changed files with 17179 additions and 40 deletions

View File

@@ -0,0 +1,455 @@
import { test, expect, Page } from '@playwright/test';
import {
Applicant,
newApplicant,
signUp,
verifyOtpIfPrompted,
} from './support/applicant';
import { deleteApplicant, sql, sqlValue } from './support/db';
import { approveRegistration, runWorkflow } from './support/workflow';
/**
* Seafarer registration, applicant through to approval.
*
* The registration is the one service whose approval has consequences beyond
* its own row: it stamps a permanent number on the profile, activates the
* seafarer record, and opens the Seaman Book and BTC applications on the
* applicant's behalf. Those effects only fire at final approval, so nothing
* short of driving a registration into an officer's hands exercises them.
*
* The workflow is deliberately shorter than a licence's — no evaluation stage,
* no inspection — so which actions an officer is *refused* is as much the
* subject here as which ones work.
*/
/**
* Fills the fields `RequireSeafarerProfile` refuses to open the wizard without.
*
* They are split across two tabs, and every tab's panel is in the DOM whether
* or not it is showing — so each one has to be selected before its inputs can
* be filled, and `PROFILE_FIELD_SECTION` in the auth lib is the map of which
* field lives where.
*/
async function completeProfile(page: Page): Promise<void> {
await page.goto('/profile');
await openTab(page, 'Profile');
await page.getByLabel('First Name').fill('Dawit');
await page.getByLabel('Middle Name').fill('Bekele');
await page.getByLabel('Last Name').fill('Tesfaye');
await pick(page, 'Gender', /male/i);
await pickDate(page, 'Date of Birth', '1995-04-12');
await pick(page, 'Marital Status', /single/i);
await pick(page, 'Profession', /./);
await save(page);
await openTab(page, 'Address');
await pick(page, 'ID Type', /^NID$/i);
await page.getByLabel('ID Number').fill('FYD1234567890');
// A country select, not a free-text field.
await pick(page, 'Nationality', /ethiopia/i);
// `addressSchema` requires this in Ethiopian format; without it the form
// never submits and no request is made for `save` to wait on.
await page
.getByRole('textbox', { name: 'Primary Phone' })
.fill('+251911234567');
await save(page);
}
/** Selects a profile tab and waits for its panel to be the visible one. */
async function openTab(page: Page, name: string): Promise<void> {
await page.getByRole('tab', { name, exact: true }).click();
await expect(page.getByRole('tabpanel', { name })).toBeVisible({
timeout: 15_000,
});
}
/**
* Picks a value from a Mantine select.
*
* The label is bound to both the input and the listbox it opens, so matching
* by label alone is ambiguous once the dropdown is showing — the textbox role
* names the control itself.
*/
async function pick(page: Page, label: string, option: RegExp): Promise<void> {
await page.getByRole('textbox', { name: label }).click();
await page.getByRole('option', { name: option }).first().click();
}
/**
* Sets the date of birth through the picker's own UI.
*
* `AmharicDatePicker` is a controlled component: it reports changes through
* `onChange`, which is what writes the value into react-hook-form. Setting the
* input's `value` natively bypasses that entirely — the field stays empty as
* far as zod is concerned, and the form silently refuses to submit.
*
* So the calendar is actually driven: open it, pick the year and month from
* the caption dropdowns, then click the day.
*/
async function pickDate(page: Page, label: string, iso: string): Promise<void> {
const [year, month, day] = iso.split('-').map(Number);
await page.getByRole('textbox', { name: label }).click();
const calendar = page.locator('.amharic-daypicker-dropdown');
await expect(calendar).toBeVisible({ timeout: 10_000 });
// `captionLayout="dropdown"` renders native selects for month and year.
await calendar.locator('select').last().selectOption(String(year));
await calendar
.locator('select')
.first()
.selectOption({ index: month - 1 });
// Each day is a button whose accessible name is the full date
// ("Saturday, April 1st, 1995"), not the bare number — matching on the
// number alone finds nothing. Anchored on the ordinal so 1 cannot match 11
// or 21. Resolved after the dropdowns settle, since changing year or month
// re-renders the grid.
const cell = calendar
.getByRole('button', { name: new RegExp(`\\b${day}(st|nd|rd|th),`) })
.first();
await expect(cell).toBeVisible({ timeout: 10_000 });
await cell.click();
await expect(calendar).toBeHidden({ timeout: 10_000 });
// The picker writes through `onChange`; if that did not land, zod still sees
// an empty field and the failure would surface later as a refused submit.
await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', {
timeout: 10_000,
});
}
async function save(page: Page): Promise<void> {
// Matched loosely on purpose: the personal tab PATCHes a user, the profile
// tab a profile, and the address tab POSTs to `/addresss/profile/:id` — the
// route's own spelling. Any successful write from this screen is the signal.
const saved = page.waitForResponse(
(r) =>
r.request().method() !== 'GET' &&
r.status() < 400 &&
/(profile|address|user)/i.test(r.url()),
{ timeout: 20_000 },
);
await page.getByRole('button', { name: /save/i }).first().click();
try {
await saved;
} catch (cause) {
// A zod-blocked submit fires no request at all, so the bare timeout says
// only "no response" — which reads as a backend fault rather than a form
// that refused to submit. Surface the field errors instead.
const messages = await page
.locator('.mantine-InputWrapper-error, [role="alert"]')
.allTextContents();
throw new Error(
messages.length
? `Save did not submit — validation errors: ${messages.join('; ')}`
: 'Save produced no request and reported no validation error.',
{ cause },
);
}
}
/** Signs up, declares seafarer operations, and fills the gating profile. */
async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
const offset = await signUp(page, applicant);
await verifyOtpIfPrompted(page, offset);
await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
await page
.getByRole('checkbox', { name: /seafarer registration/i })
.first()
.check();
await page.getByRole('button', { name: /save operations/i }).click();
// A seafarer is taken to `/profile`, not the dashboard: registration is
// built from the profile, and a fresh signup holds none of it yet.
await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
await completeProfile(page);
}
test.describe('seafarer registration', () => {
let applicant: Applicant;
test.beforeEach(() => {
applicant = newApplicant('seafarer');
});
test.afterEach(() => {
deleteApplicant(applicant.email);
});
test('the wizard refuses to open until the profile it is built from is complete', 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: /seafarer registration/i })
.first()
.check();
await page.getByRole('button', { name: /save operations/i }).click();
await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
// A new account holds none of the identity the registration is filled in
// from, so the gate collects it rather than opening an uncompletable form.
await page.goto('/seafarer-registration');
await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
// The shared wizard route is gated identically — otherwise the gate is
// decoration a deep link walks straight past.
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
});
test('opening the wizard creates the draft up front', async ({ page }) => {
await readyApplicant(page, applicant);
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
await expect(page).not.toHaveURL(/\/profile/, { timeout: 30_000 });
// The draft exists before anything is filled in, so uploads have an owner
// and closing the browser mid-wizard loses nothing.
const number = await waitForApplication(applicant.email);
expect(number).toMatch(/^SFR/);
expect(statusOf(number)).toBe('DRAFT');
});
test('a registration never reaches evaluation or inspection', async ({
page,
}) => {
await readyApplicant(page, applicant);
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
const number = await waitForApplication(applicant.email);
const id = idOf(number);
await submit(id);
await runWorkflow(id, [{ path: 'claim' }]);
expect(statusOf(number)).toBe('UNDER_REVIEW');
// The licence course's middle stages have nothing to hold in a
// registration, and the transition table is the authority regardless of
// which endpoint is called.
const refused = await runWorkflow(id, [
{ path: 'complete-review', expectFailure: true },
{ path: 'approve-documents', expectFailure: true },
{ path: 'record-inspection', expectFailure: true },
]);
expect(refused.every((code) => code >= 400)).toBe(true);
expect(statusOf(number)).toBe('UNDER_REVIEW');
});
test('an officer can return a registration for correction and take it back', async ({
page,
}) => {
await readyApplicant(page, applicant);
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
const number = await waitForApplication(applicant.email);
const id = idOf(number);
await submit(id);
await runWorkflow(id, [
{ path: 'claim' },
{
path: 'request-adjustment',
data: { remarks: [{ message: 'Medical certificate is illegible.' }] },
},
]);
expect(statusOf(number)).toBe('RESUBMIT_REQUIRED');
// A resubmission returns to review directly — a registration has no
// earlier stage to fall back to.
await runWorkflow(id, [{ path: 'resubmit' }]);
expect(statusOf(number)).toBe('UNDER_REVIEW');
});
test('an officer can hold and resume a registration', async ({ page }) => {
await readyApplicant(page, applicant);
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
const number = await waitForApplication(applicant.email);
const id = idOf(number);
await submit(id);
await runWorkflow(id, [
{ path: 'claim' },
{ path: 'hold', data: { reason: 'Awaiting confirmation from the clinic.' } },
]);
expect(statusOf(number)).toBe('ON_HOLD');
// Resume restores whatever it was held from, read back from history.
await runWorkflow(id, [{ path: 'resume' }]);
expect(statusOf(number)).toBe('UNDER_REVIEW');
});
test('an officer can reject a registration with a reason', async ({ page }) => {
await readyApplicant(page, applicant);
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
const number = await waitForApplication(applicant.email);
const id = idOf(number);
await submit(id);
await runWorkflow(id, [
{ path: 'claim' },
{ path: 'reject', data: { reason: 'Basic training evidence incomplete.' } },
]);
expect(statusOf(number)).toBe('REJECTED');
// A rejection is terminal: nothing is numbered and no children open.
expect(seafarerNumberOf(applicant.email)).toBeNull();
expect(childrenOf(number)).toHaveLength(0);
});
test('approval numbers the profile and opens both child applications', async ({
page,
}) => {
await readyApplicant(page, applicant);
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
const number = await waitForApplication(applicant.email);
const id = idOf(number);
await submit(id);
await approveRegistration(id);
expect(statusOf(number)).toBe('COMPLETED');
const profile = sql(`
SELECT p.seafarer_number, p.seafarer_status
FROM profiles p
JOIN iam.users u ON u.id = p.user_id
WHERE u.email = '${applicant.email}'
`);
expect(profile[0][0]).toBeTruthy();
expect(profile[0][1]).toBe('ACTIVE');
// The applicant is not made to apply twice more for the documents that
// prove what they have just been told.
const children = childrenOf(number);
expect(children.map((r) => r[0])).toEqual([
'BTC_BASIC_TRAINING',
'SEAMAN_BOOK',
]);
expect(children.every((r) => r[1] === 'SUBMITTED')).toBe(true);
expect(children.every((r) => r[2] === 'AUTO_SEAFARER_APPROVAL')).toBe(true);
});
test('a re-fired approval renumbers nobody and opens no second pair', async ({
page,
}) => {
await readyApplicant(page, applicant);
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
const number = await waitForApplication(applicant.email);
const id = idOf(number);
await submit(id);
await approveRegistration(id);
const first = seafarerNumberOf(applicant.email);
// Approving again must be a no-op, not a second number and a second bill.
await runWorkflow(id, [{ path: 'final-approve', expectFailure: true }]);
expect(seafarerNumberOf(applicant.email)).toBe(first);
expect(childrenOf(number)).toHaveLength(2);
});
test('a registered seafarer cannot start a second registration', async ({
page,
}) => {
await readyApplicant(page, applicant);
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
const number = await waitForApplication(applicant.email);
await submit(idOf(number));
await approveRegistration(idOf(number));
// The number is permanent and the service is not renewable, so the portal
// stops offering it rather than letting them file a second registration an
// officer would review for no outcome.
await page.goto('/seafarer-registration');
await expect(page).not.toHaveURL(
/\/licensing\/SEAFARER_REGISTRATION\/apply/,
{ timeout: 30_000 },
);
expect(
sqlValue(`
SELECT count(*) FROM license_applications a
JOIN license_types lt ON lt.id = a.license_type_id
JOIN iam.users u ON u.id = a.applicant_user_id
WHERE lt.key = 'SEAFARER_REGISTRATION'
AND u.email = '${applicant.email}'
`),
).toBe('1');
});
});
// ------------------------------------------------------------------ helpers
/** Waits for the draft the wizard creates on open, and returns its number. */
async function waitForApplication(
email: string,
timeoutMs = 30_000,
): Promise<string> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const found = sqlValue(`
SELECT a.application_number
FROM license_applications a
JOIN license_types lt ON lt.id = a.license_type_id
JOIN iam.users u ON u.id = a.applicant_user_id
WHERE lt.key = 'SEAFARER_REGISTRATION' AND u.email = '${email}'
ORDER BY a.created_at DESC LIMIT 1
`);
if (found) return found;
await new Promise((r) => setTimeout(r, 500));
}
throw new Error(`No seafarer registration appeared for ${email}`);
}
function idOf(applicationNumber: string): string {
const id = sqlValue(`
SELECT id FROM license_applications
WHERE application_number = '${applicationNumber}'
`);
if (!id) throw new Error(`No application ${applicationNumber}`);
return id;
}
function statusOf(applicationNumber: string): string | null {
return sqlValue(`
SELECT status FROM license_applications
WHERE application_number = '${applicationNumber}'
`);
}
function seafarerNumberOf(email: string): string | null {
return sqlValue(`
SELECT p.seafarer_number FROM profiles p
JOIN iam.users u ON u.id = p.user_id
WHERE u.email = '${email}'
`);
}
function childrenOf(applicationNumber: string): string[][] {
return sql(`
SELECT lt.key, a.status, a.origin
FROM license_applications a
JOIN license_types lt ON lt.id = a.license_type_id
WHERE a.parent_application_id = (
SELECT id FROM license_applications
WHERE application_number = '${applicationNumber}'
)
ORDER BY lt.key
`);
}
/**
* Submits the draft.
*
* The wizard's own sections are not filled in: what these tests are about is
* the workflow and its approval effects, and a form-validation failure would
* fail them for the wrong reason. Field-level rules belong in their own spec.
*/
async function submit(applicationId: string): Promise<void> {
await runWorkflow(applicationId, [{ path: 'submit' }]);
}

View File

@@ -1,4 +1,5 @@
import { execFileSync } from 'node:child_process';
import { resolve } from 'node:path';
import { E2E } from '../../playwright.config';
/**
@@ -12,21 +13,77 @@ import { E2E } from '../../playwright.config';
const PSQL_ENV = {
...process.env,
PGPASSWORD: process.env.E2E_DB_PASSWORD ?? 'TradingTria@2090',
PGOPTIONS: `--search_path=${process.env.E2E_DB_SCHEMA ?? 'ema'},public`,
};
/**
* Where `psql` lives.
*
* A local client is used when there is one. When there is not — Postgres
* running only as a container is the common setup — the same query goes
* through `docker compose exec` instead, so the suite does not require a
* developer to install a database client for the sake of a few assertions.
*
* Set `E2E_DB_CONTAINER` to the compose service name to force the container
* path, or `E2E_PSQL=1` to insist on a local binary.
*/
const DB_CONTAINER = process.env.E2E_DB_CONTAINER ?? 'ema-postgres';
const COMPOSE_DIR =
process.env.E2E_COMPOSE_DIR ??
// Anchored on the repo root rather than this file: Playwright may load the
// suite as ESM, where `__dirname` does not exist. `process.cwd()` is the
// emaui workspace when the config is run from there.
resolve(process.cwd(), '../emaapi');
function hasLocalPsql(): boolean {
if (process.env.E2E_PSQL === '1') return true;
try {
execFileSync('psql', ['--version'], { stdio: 'ignore' });
return true;
} catch {
return false;
}
}
const USE_CONTAINER = !hasLocalPsql();
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' },
);
// Queries name EMA tables unqualified (`license_applications`), which only
// resolves with the app's own schema on the path. `iam.` stays explicit.
//
// Passed as a connection option rather than a leading `SET` statement: psql
// prints a result row per command, and a `SET` would land in every caller's
// rows as a phantom first entry.
const schema = process.env.E2E_DB_SCHEMA ?? 'ema';
const psqlArgs = [
'-U', process.env.E2E_DB_USER ?? 'postgres',
'-d', E2E.database,
'-v', 'ON_ERROR_STOP=1',
'-tAF', '\t',
'-c', query,
];
const out = USE_CONTAINER
? execFileSync(
'docker',
[
'compose', 'exec', '-T',
// `exec` does not forward the caller's environment, so the schema
// search path has to be handed across explicitly.
'-e', `PGOPTIONS=${PSQL_ENV.PGOPTIONS}`,
DB_CONTAINER, 'psql', ...psqlArgs,
],
{ cwd: COMPOSE_DIR, env: PSQL_ENV, encoding: 'utf8' },
)
: execFileSync(
'psql',
[
'-h', process.env.E2E_DB_HOST ?? 'localhost',
'-p', process.env.E2E_DB_PORT ?? '5432',
...psqlArgs,
],
{ env: PSQL_ENV, encoding: 'utf8' },
);
return out
.split('\n')
.filter((line) => line.trim() !== '')

View File

@@ -0,0 +1,68 @@
import { Page, expect } from '@playwright/test';
import { E2E } from '../../playwright.config';
/**
* The backoffice side of a flow.
*
* Every registration test needs an officer to act on what the applicant filed,
* and the only account the IAM seed creates is the super admin — which holds
* every permission, so it can claim, return, reject and approve without a
* fixture inventing a position first.
*
* That breadth is also a limitation worth naming: these tests prove the actions
* work, not that a *review officer* specifically may perform them. Per-role
* authorisation needs its own accounts and belongs in its own spec.
*/
export const OFFICER = {
email: process.env.E2E_OFFICER_EMAIL ?? 'superadmin@tria.com',
password: process.env.E2E_OFFICER_PASSWORD ?? 'password@tria',
};
/** Signs the officer into the backoffice, which is a separate origin. */
export async function logInAsOfficer(page: Page): Promise<void> {
await page.goto(`${E2E.backofficeUrl}/login`);
await page.getByLabel(/email/i).fill(OFFICER.email);
await page.getByLabel(/password/i).fill(OFFICER.password);
await page.getByRole('button', { name: /sign in|log in|login/i }).click();
await expect(page).not.toHaveURL(/\/login/, { timeout: 30_000 });
}
/**
* Opens one application's review screen by its application number.
*
* Goes through the seafarer registration queue rather than deep-linking by id:
* the queue is what an officer actually uses, and a test that skips it would
* not notice the application failing to appear there at all.
*/
export async function openInQueue(
page: Page,
applicationNumber: string,
): Promise<void> {
await page.goto(`${E2E.backofficeUrl}/licence-review/type/SEAFARER_REGISTRATION`);
const row = page.getByRole('row', { name: new RegExp(applicationNumber, 'i') });
await expect(row).toBeVisible({ timeout: 30_000 });
await row.click();
}
/**
* Clicks a workflow action and waits for the status to settle.
*
* Actions live behind buttons whose labels the design owns, so each is matched
* by a loose pattern rather than an exact string — a rename should not read as
* a broken workflow.
*/
export async function act(
page: Page,
action: RegExp,
confirm: RegExp = /confirm|submit|yes|save|approve|reject|send/i,
): Promise<void> {
await page.getByRole('button', { name: action }).first().click();
// Most actions raise a modal; some apply directly. Either is fine.
const dialog = page.getByRole('dialog');
if (await dialog.isVisible({ timeout: 3_000 }).catch(() => false)) {
const button = dialog.getByRole('button', { name: confirm }).first();
if (await button.isVisible().catch(() => false)) await button.click();
}
}

View File

@@ -0,0 +1,99 @@
import { APIRequestContext, request } from '@playwright/test';
import { E2E } from '../../playwright.config';
import { OFFICER } from './officer';
/**
* The workflow endpoints, called as the officer.
*
* Used where a test's subject is what an approval *does* — the seafarer
* number, the activated record, the two child applications — rather than which
* buttons produce it. Driving six sections of wizard and a review screen to
* reach a completion effect would make those tests about forms.
*
* Nothing here writes to the database directly. The completion effect runs
* inside the API's approval transaction, so a test that faked the status would
* assert against an approval that never happened.
*/
/** Routes served by the applicant-facing controller rather than the review one. */
const APPLICANT_STEPS = new Set(['submit', 'resubmit']);
async function officerContext(): Promise<APIRequestContext> {
const context = await request.newContext({ baseURL: E2E.apiUrl });
const response = await context.post('/auth/login', {
data: { email: OFFICER.email, password: OFFICER.password },
});
if (!response.ok()) {
throw new Error(
`Officer login failed (${response.status()}): ${await response.text()}`,
);
}
const body = await response.json();
const token = body?.token ?? body?.accessToken ?? body?.access_token;
if (!token) {
throw new Error(`No access token in login response: ${JSON.stringify(body)}`);
}
await context.dispose();
return request.newContext({
baseURL: E2E.apiUrl,
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
});
}
export interface WorkflowStep {
/** Route under the review controller, e.g. `claim`, `final-approve`. */
path: string;
data?: Record<string, unknown>;
/** Set when a step is expected to be refused — the refusal is the assertion. */
expectFailure?: boolean;
}
/**
* Runs a sequence of workflow calls against one application.
*
* Returns each step's status code so a caller can assert on a refusal as
* readily as on a success — "an officer may not evaluate a registration" is a
* result worth checking, not an error to swallow.
*/
export async function runWorkflow(
applicationId: string,
steps: WorkflowStep[],
): Promise<number[]> {
const api = await officerContext();
const codes: number[] = [];
try {
for (const step of steps) {
// Applicant-side actions (`submit`, `resubmit`) live on the
// applications controller; everything an officer does is on the review
// controller. Routing by step keeps callers from having to know.
const base = APPLICANT_STEPS.has(step.path)
? 'license-applications'
: 'license-application-review';
const response = await api.post(
`/${base}/${applicationId}/${step.path}`,
{ data: step.data ?? {} },
);
codes.push(response.status());
if (!step.expectFailure && !response.ok()) {
throw new Error(
`Step "${step.path}" failed (${response.status()}): ${await response.text()}`,
);
}
}
} finally {
await api.dispose();
}
return codes;
}
/** Claim then final-approve — the whole officer path for a registration. */
export async function approveRegistration(applicationId: string): Promise<void> {
await runWorkflow(applicationId, [
{ path: 'claim' },
{ path: 'final-approve', data: { remark: 'E2E approval' } },
]);
}