import { test, expect, Page } from '@playwright/test'; import { Applicant, newApplicant, signUp, verifyOtpIfPrompted, } from './support/applicant'; import { deleteApplicant, sql, sqlValue } from './support/db'; import { approveRegistration, resolveOpenRemarks, 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 profile the seafarer wizard prefills its Identity Details step * from. * * No longer a precondition for reaching the wizard — that redirect is gone and * the step collects these itself — but a populated profile is the returning * applicant's case, and it is the prefill that keeps them from retyping. * * 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, applicant: Applicant, ): Promise { await page.goto('/profile'); await openTab(page, 'Profile'); // 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); await pick(page, 'Profession', /./); await save(page); await openTab(page, 'Address'); // 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); // 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); } /** Selects a profile tab and waits for its panel to be the visible one. */ async function openTab(page: Page, name: string): Promise { 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 { 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 { 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 { // 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. // Field errors only. `[role="alert"]` also matches Mantine's ``, 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') .allTextContents(); throw new Error( messages.length ? `Save did not submit — validation errors: ${messages.join('; ')}` : // 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 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 { 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(/\/licensing\/SEAFARER_REGISTRATION\/apply/, { timeout: 30_000, }); await page.goto('/profile'); await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 }); await completeProfile(page, applicant); } test.describe('seafarer registration', () => { let applicant: Applicant; test.beforeEach(() => { applicant = newApplicant('seafarer'); }); test.afterEach(() => { deleteApplicant(applicant.email); }); test('selecting seafarer opens the registration wizard', 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(); // Straight to the form they came for. The wizard collects the identity // itself (Identity Details), so a brand-new account with an empty profile // is a thing it fills rather than a reason to be sent to /profile first. await expect(page).toHaveURL(/\/licensing\/SEAFARER_REGISTRATION\/apply/, { timeout: 30_000, }); // The short link lands in the same place. await page.goto('/seafarer-registration'); await expect(page).toHaveURL(/\/licensing\/SEAFARER_REGISTRATION\/apply/, { 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, applicant); 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, applicant); await runWorkflow(id, [ { path: 'claim' }, { path: 'request-adjustment', // `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' }], applicant); 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, applicant); 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, applicant); 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 the children submit // opened stay drafts — never filed, never billed, nothing an officer sees. expect(seafarerNumberOf(applicant.email)).toBeNull(); expect(childrenOf(number).every((r) => r[1] === 'DRAFT')).toBe(true); }); 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, applicant); 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. Both were opened as drafts when the // registration was submitted; approval is what puts them in flight — the // BTC straight to payment, the Seaman Book into the queue for the TRB // inspection it still owes. const children = childrenOf(number); expect(children.map((r) => [r[0], r[1]])).toEqual([ ['BTC_BASIC_TRAINING', 'PAYMENT_PENDING'], ['SEAMAN_BOOK', 'SUBMITTED'], ]); 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, applicant); 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), applicant); 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 { 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}' `); } /** 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 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 `); } /** * Fills the draft's answers and evidence directly, so it can be submitted. * * 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. */ 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 { 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); }