mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 12:58:15 +00:00
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:
455
apps/e2e/src/seafarer-registration.spec.ts
Normal file
455
apps/e2e/src/seafarer-registration.spec.ts
Normal 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' }]);
|
||||
}
|
||||
@@ -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() !== '')
|
||||
|
||||
68
apps/e2e/src/support/officer.ts
Normal file
68
apps/e2e/src/support/officer.ts
Normal 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();
|
||||
}
|
||||
}
|
||||
99
apps/e2e/src/support/workflow.ts
Normal file
99
apps/e2e/src/support/workflow.ts
Normal 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' } },
|
||||
]);
|
||||
}
|
||||
Reference in New Issue
Block a user