Remove obsolete test artifacts and error context files for seafarer registration tests

This commit is contained in:
Nati
2026-08-19 05:14:43 +00:00
parent 99eb55c366
commit d44977cbf3
47 changed files with 1115 additions and 3026 deletions

View File

@@ -6,7 +6,11 @@ import {
verifyOtpIfPrompted,
} from './support/applicant';
import { deleteApplicant, sql, sqlValue } from './support/db';
import { approveRegistration, runWorkflow } from './support/workflow';
import {
approveRegistration,
resolveOpenRemarks,
runWorkflow,
} from './support/workflow';
/**
* Seafarer registration, applicant through to approval.
@@ -35,13 +39,20 @@ import { approveRegistration, runWorkflow } from './support/workflow';
* 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> {
async function completeProfile(
page: Page,
applicant: Applicant,
): 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');
// The account's own name parts, not invented ones: the Maritime tab refuses
// to save when they do not join to the name on the Personal tab, and it
// refuses by returning early — no request, no field error, so the failure
// surfaced only as "save produced no request".
await page.getByLabel('First Name').fill(applicant.firstName);
await page.getByLabel('Middle Name').fill(applicant.middleName);
await page.getByLabel('Last Name').fill(applicant.lastName);
await pick(page, 'Gender', /male/i);
await pickDate(page, 'Date of Birth', '1995-04-12');
await pick(page, 'Marital Status', /single/i);
@@ -49,15 +60,16 @@ async function completeProfile(page: Page): Promise<void> {
await save(page);
await openTab(page, 'Address');
await pick(page, 'ID Type', /^NID$/i);
// Matched on the option's label, not its stored value: the select shows
// "National Id" and submits `NID`, so `/^NID$/` matched no option at all.
await pick(page, 'ID Type', /^national id$/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');
// Primary Phone is deliberately not filled: it is `readOnly` here and already
// carries the account's number ("From your account, edit it in the Personal
// tab"), so `addressSchema`'s Ethiopian-format rule is already satisfied and a
// fill would only fail against a read-only input.
await save(page);
}
@@ -145,19 +157,35 @@ async function save(page: Page): Promise<void> {
// 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.
// Field errors only. `[role="alert"]` also matches Mantine's `<Alert>`, and
// the profile page renders an informational seafarer banner as one — which
// got reported as "validation errors: Seafarer registration asks for these
// details…", pointing at a form that was in fact filled in correctly.
const messages = await page
.locator('.mantine-InputWrapper-error, [role="alert"]')
.locator('.mantine-InputWrapper-error')
.allTextContents();
throw new Error(
messages.length
? `Save did not submit — validation errors: ${messages.join('; ')}`
: 'Save produced no request and reported no validation error.',
: // No field error either, so the form was valid and something else
// refused: `onSaveProfile` early-returns when the profile name does
// not match the account name, and notifies rather than marking a
// field.
'Save produced no request and reported no field error — check for a rejected notification (e.g. the profile/account name match).',
{ cause },
);
}
}
/** Signs up, declares seafarer operations, and fills the gating profile. */
/**
* Signs up, declares seafarer operations, and fills the profile.
*
* Declaring seafarer now lands on the registration wizard, not `/profile` — the
* wizard collects the identity itself. The profile is still filled here because
* these tests are about the registration workflow, and a profile with a name and
* an address is what the approval's completion effect writes onto; `/profile` is
* navigated to directly rather than waited for as a redirect.
*/
async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
const offset = await signUp(page, applicant);
await verifyOtpIfPrompted(page, offset);
@@ -167,10 +195,12 @@ async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
.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(/\/licensing\/SEAFARER_REGISTRATION\/apply/, {
timeout: 30_000,
});
await page.goto('/profile');
await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
await completeProfile(page);
await completeProfile(page, applicant);
}
test.describe('seafarer registration', () => {
@@ -229,7 +259,7 @@ test.describe('seafarer registration', () => {
const number = await waitForApplication(applicant.email);
const id = idOf(number);
await submit(id);
await submit(id, applicant);
await runWorkflow(id, [{ path: 'claim' }]);
expect(statusOf(number)).toBe('UNDER_REVIEW');
@@ -253,19 +283,36 @@ test.describe('seafarer registration', () => {
const number = await waitForApplication(applicant.email);
const id = idOf(number);
await submit(id);
await submit(id, applicant);
await runWorkflow(id, [
{ path: 'claim' },
{
path: 'request-adjustment',
data: { remarks: [{ message: 'Medical certificate is illegible.' }] },
// `RequestAdjustmentDto` takes `items`, each naming what to fix and
// where — a bare `remarks: [{ message }]` is refused with "items should
// not be empty", which reads as an empty request rather than a wrongly
// shaped one.
data: {
items: [
{
targetType: 'FORM_SECTION',
targetKey: 'medicalCertificate',
remark: 'Medical certificate is illegible.',
},
],
},
},
]);
expect(statusOf(number)).toBe('RESUBMIT_REQUIRED');
// Every flagged item has to be ticked off first: `resubmit` refuses while
// any remark is open (`unresolved_remarks`), which is what stops an
// applicant returning the same form untouched.
await resolveOpenRemarks(id, openRemarkIds(number), applicant);
// A resubmission returns to review directly — a registration has no
// earlier stage to fall back to.
await runWorkflow(id, [{ path: 'resubmit' }]);
await runWorkflow(id, [{ path: 'resubmit' }], applicant);
expect(statusOf(number)).toBe('UNDER_REVIEW');
});
@@ -275,7 +322,7 @@ test.describe('seafarer registration', () => {
const number = await waitForApplication(applicant.email);
const id = idOf(number);
await submit(id);
await submit(id, applicant);
await runWorkflow(id, [
{ path: 'claim' },
{ path: 'hold', data: { reason: 'Awaiting confirmation from the clinic.' } },
@@ -293,7 +340,7 @@ test.describe('seafarer registration', () => {
const number = await waitForApplication(applicant.email);
const id = idOf(number);
await submit(id);
await submit(id, applicant);
await runWorkflow(id, [
{ path: 'claim' },
{ path: 'reject', data: { reason: 'Basic training evidence incomplete.' } },
@@ -314,7 +361,7 @@ test.describe('seafarer registration', () => {
const number = await waitForApplication(applicant.email);
const id = idOf(number);
await submit(id);
await submit(id, applicant);
await approveRegistration(id);
expect(statusOf(number)).toBe('COMPLETED');
@@ -349,7 +396,7 @@ test.describe('seafarer registration', () => {
const number = await waitForApplication(applicant.email);
const id = idOf(number);
await submit(id);
await submit(id, applicant);
await approveRegistration(id);
const first = seafarerNumberOf(applicant.email);
@@ -367,7 +414,7 @@ test.describe('seafarer registration', () => {
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
const number = await waitForApplication(applicant.email);
await submit(idOf(number));
await submit(idOf(number), applicant);
await approveRegistration(idOf(number));
// The number is permanent and the service is not renewable, so the portal
@@ -438,6 +485,17 @@ function seafarerNumberOf(email: string): string | null {
`);
}
/** Ids of the remarks still open on the current adjustment round. */
function openRemarkIds(applicationNumber: string): string[] {
return sql(`
SELECT r.id FROM application_remarks r
JOIN license_applications a ON a.id = r.application_id
WHERE a.application_number = '${applicationNumber}'
AND r.is_resolved = false
AND r.round_number = a.adjustment_round
`).map((row) => row[0]);
}
function childrenOf(applicationNumber: string): string[][] {
return sql(`
SELECT lt.key, a.status, a.origin
@@ -452,12 +510,98 @@ function childrenOf(applicationNumber: string): string[][] {
}
/**
* Submits the draft.
* Fills the draft's answers and evidence directly, so it can be submitted.
*
* 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.
* These tests are about the workflow and its approval effects, not the wizard's
* fields — but `submit` validates the whole form and every required document, so
* an unfilled draft cannot reach the workflow at all. Driving six wizard steps
* and four uploads in each test would make them slow tests of the form instead.
*
* So the answers go in as one `form_data` write and the evidence as attachment
* rows. Deliberately not through MinIO: `getSuppliedDocumentKeys` joins
* attachments to their files and counts document keys, and nothing at submission
* reads a file's bytes — a row with a storage key is exactly as complete as an
* upload, without requiring object storage to be reachable.
*
* Values mirror the seeded schema (`seafarer-registration.seed-data.ts`); a
* required field added there fails these with `application_incomplete`, naming
* the field.
*/
async function submit(applicationId: string): Promise<void> {
await runWorkflow(applicationId, [{ path: 'submit' }]);
function fillForSubmission(applicationId: string): void {
const locationId = sqlValue(`
SELECT l.id FROM iam.locations l
JOIN iam.location_types lt ON lt.id = l.location_type_id
WHERE lt.code = 'SUBCITY' LIMIT 1
`);
if (!locationId) {
throw new Error('No SUBCITY location seeded — run the location seed.');
}
const formData = JSON.stringify({
profileSummary: {
firstName: 'Dawit',
middleName: 'Bekele',
lastName: 'Tesfaye',
gender: 'MALE',
dateOfBirth: '1995-04-12',
maritalStatus: 'SINGLE',
nationality: 'Ethiopian',
nationalIdNumber: 'FYD1234567890',
},
identity: { placeOfBirth: 'Addis Ababa', department: 'DECK' },
address: { locationId, permanentAddress: 'Bole, Addis Ababa' },
emergencyContact: {
name: 'Almaz Tesfaye',
relationship: 'Sister',
phoneNumber: '+251911222333',
},
physicalCharacteristics: {
hairColor: 'BLACK',
eyeColor: 'BROWN',
heightCm: 172,
weightKg: 68,
bloodType: 'O_POSITIVE',
},
medicalCertificate: {
certificateNumber: 'MED-2026-001',
issuerName: 'Addis Marine Clinic',
issueDate: '2026-01-15',
},
declaration: { accepted: true },
}).replace(/'/g, "''");
const documentKeys = [
'photo',
'nationalId',
'medical_certificate',
'basic_training_evidence',
];
sql(`
UPDATE license_applications
SET form_data = '${formData}'::jsonb
WHERE id = '${applicationId}';
WITH inserted AS (
INSERT INTO attachments (owner_type, owner_id, document_key, valid_from, valid_to)
SELECT 'APPLICATION', '${applicationId}', key, CURRENT_DATE, CURRENT_DATE + 365
FROM unnest(ARRAY[${documentKeys.map((d) => `'${d}'`).join(',')}]) AS key
RETURNING id
)
INSERT INTO attachment_files
(attachment_id, original_name, mime_type, size_bytes, storage_key)
SELECT id, 'evidence.pdf', 'application/pdf', 1024, 'e2e/' || id || '.pdf'
FROM inserted;
`);
}
/** Fills what submission requires, then submits as the applicant. */
async function submit(
applicationId: string,
applicant: Applicant,
): Promise<void> {
fillForSubmission(applicationId);
// As the applicant: `submit` is ownership-guarded, so the officer's token —
// which every other step here uses — is refused with `not_application_owner`.
await runWorkflow(applicationId, [{ path: 'submit' }], applicant);
}

View File

@@ -17,7 +17,11 @@ import { E2E } from '../../playwright.config';
const OTP_PATTERN = /is (\d{4,8})\./g;
/** Byte offset to read from later. Zero when the log does not exist yet. */
/**
* Byte offset to read from later. Zero when the log does not exist yet.
*
* Bytes, and read back as bytes — see `otpSince`.
*/
export function logOffset(): number {
try {
return statSync(E2E.apiLog).size;
@@ -48,7 +52,14 @@ export async function waitForOtp(
function otpSince(offset: number): string | null {
let text: string;
try {
text = readFileSync(E2E.apiLog, 'utf8').slice(offset);
// Sliced as a Buffer, then decoded — not `readFileSync(…, 'utf8').slice()`.
// `logOffset()` is a byte count from `statSync`, while slicing a string
// counts UTF-16 code units, and the API logs Amharic notification bodies:
// every multi-byte character made the offset overshoot, so a code written
// just after it was skipped and the wait timed out. The drift grows with
// the log, which is why this failed intermittently and more often later in
// a run.
text = readFileSync(E2E.apiLog).subarray(offset).toString('utf8');
} catch {
return null;
}

View File

@@ -14,18 +14,35 @@ export interface Applicant {
username: string;
phoneNumber: string;
password: string;
/** The account name, as typed at signup. Always `${firstName} ${middleName} ${lastName}`. */
name: string;
firstName: string;
middleName: string;
lastName: string;
}
export function newApplicant(label: string): Applicant {
const stamp = `${Date.now()}${Math.floor(Math.random() * 1000)}`;
// The profile's Maritime tab refuses to save unless first/middle/last join to
// exactly the account name (`ProfilePage.onSaveProfile`) — and that refusal is
// a silent early return, no request. So the parts are the source of truth here
// and the account name is composed from them, rather than the two being
// written independently and hoped to agree.
//
// Each part is at least three characters, which `profileSchema` requires.
const firstName = 'Dawit';
const middleName = 'Bekele';
const lastName = `Tesfaye${stamp.slice(-4)}`;
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)}`,
name: `${firstName} ${middleName} ${lastName}`,
firstName,
middleName,
lastName,
};
}

View File

@@ -18,14 +18,47 @@ import { OFFICER } from './officer';
/** 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 },
/**
* Resolves every open remark on an application, as the applicant.
*
* `resubmit` refuses while any remain (`unresolved_remarks`) — the applicant is
* expected to tick off each correction as they make it, which the portal does
* per section. A test that only wants the round-trip still has to do it.
*/
export async function resolveOpenRemarks(
applicationId: string,
remarkIds: string[],
applicant: { email: string; password: string },
): Promise<void> {
await runWorkflow(
applicationId,
remarkIds.map((remarkId) => ({
path: `remarks/${remarkId}/resolve`,
method: 'patch' as const,
})),
applicant,
);
}
/**
* An authenticated API context for one account.
*
* Paths built against it are relative on purpose. `E2E.apiUrl` carries the
* `/api` prefix, and a leading slash resolves against the *origin* —
* `/auth/login` against `http://host/api` requests `http://host/auth/login`,
* which 404s. Every path in this file is therefore written without one.
*/
async function contextFor(
who: string,
credentials: { email: string; password: string },
): Promise<APIRequestContext> {
const context = await request.newContext({ baseURL: `${E2E.apiUrl}/` });
const response = await context.post('auth/login', {
data: { email: credentials.email, password: credentials.password },
});
if (!response.ok()) {
throw new Error(
`Officer login failed (${response.status()}): ${await response.text()}`,
`${who} login failed (${response.status()}): ${await response.text()}`,
);
}
const body = await response.json();
@@ -36,17 +69,23 @@ async function officerContext(): Promise<APIRequestContext> {
await context.dispose();
return request.newContext({
baseURL: E2E.apiUrl,
baseURL: `${E2E.apiUrl}/`,
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
});
}
function officerContext(): Promise<APIRequestContext> {
return contextFor('Officer', OFFICER);
}
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;
/** POST unless stated; the applicant's remark-resolve route is a PATCH. */
method?: 'post' | 'patch';
}
/**
@@ -59,8 +98,29 @@ export interface WorkflowStep {
export async function runWorkflow(
applicationId: string,
steps: WorkflowStep[],
/**
* The owner, required only when a step is applicant-side. `submit` and
* `resubmit` are guarded by ownership, not permission — the officer holds
* every permission but is not the applicant, so running them on the officer's
* token is refused with `not_application_owner`.
*/
applicant?: { email: string; password: string },
): Promise<number[]> {
const api = await officerContext();
const officer = await officerContext();
const needsApplicant = steps.some(
(step) => APPLICANT_STEPS.has(step.path) || step.path.startsWith('remarks/'),
);
if (needsApplicant && !applicant) {
throw new Error(
`Steps [${steps
.filter((s) => APPLICANT_STEPS.has(s.path) || s.path.startsWith('remarks/'))
.map((s) => s.path)
.join(', ')}] act as the applicant — pass their credentials to runWorkflow.`,
);
}
const owner = needsApplicant && applicant
? await contextFor('Applicant', applicant)
: null;
const codes: number[] = [];
try {
@@ -68,13 +128,17 @@ export async function runWorkflow(
// 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)
const isApplicantStep =
APPLICANT_STEPS.has(step.path) || step.path.startsWith('remarks/');
const base = isApplicantStep
? 'license-applications'
: 'license-application-review';
const response = await api.post(
`/${base}/${applicationId}/${step.path}`,
{ data: step.data ?? {} },
);
const api = isApplicantStep && owner ? owner : officer;
const url = `${base}/${applicationId}/${step.path}`;
const response =
step.method === 'patch'
? await api.patch(url, { data: step.data ?? {} })
: await api.post(url, { data: step.data ?? {} });
codes.push(response.status());
if (!step.expectFailure && !response.ok()) {
@@ -84,7 +148,8 @@ export async function runWorkflow(
}
}
} finally {
await api.dispose();
await officer.dispose();
await owner?.dispose();
}
return codes;