import { test, expect, Page } from '@playwright/test'; import { Applicant, newApplicant, signUp, verifyOtpIfPrompted, } from './support/applicant'; import { deleteApplicant, sql, sqlValue } from './support/db'; import { approveRegistration, runDocumentWorkflow, runRegistrationWorkflow, } from './support/workflow'; import { act, logInAsOfficer, openInQueue } from './support/officer'; /** * Seafarer registration, applicant through to approval. * * Registration is its own table and its own endpoints — not a licence * application. Submitting one requests a Seaman Book and a BTC by default * (`seafarer_documents`); approval stamps a permanent number on the profile, * activates the seafarer record, records the medical certificate, and releases * those two requests to payment. Those effects only fire at approval, so * nothing short of driving a registration into an officer's hands exercises * them. */ /** * Gives the applicant the profile the registration form prefills from. * * Written directly rather than through `/profile`: these tests are about the * registration, and the profile form is a separate surface with its own * tests — driving its tabs here made every registration test fail whenever * that form changed. The rows are what the Address and Maritime tabs save. */ function completeProfile(applicant: Applicant): void { const email = applicant.email.replace(/'/g, "''"); sql(` WITH addr AS ( INSERT INTO addresses (id_type, id_number, nationality, primary_phone_number, email) VALUES ('NID', 'FYD1234567890', 'Ethiopian', '${applicant.phoneNumber}', '${email}') RETURNING id ) UPDATE profiles p SET first_name = '${applicant.firstName}', middle_name = '${applicant.middleName}', last_name = '${applicant.lastName}', gender = 'MALE', dob = '1995-04-12', marital_status = 'SINGLE', address_id = (SELECT id FROM addr) WHERE p.user_id = (SELECT id FROM iam.users WHERE email = '${email}') `); } function profileIdOf(email: string): string | null { return sqlValue(` SELECT p.id FROM profiles p JOIN iam.users u ON u.id = p.user_id WHERE u.email = '${email}' `); } async function pick(page: Page, label: string, option: RegExp): Promise { await page.getByRole('textbox', { name: label }).click(); // Matched on text, not accessible name: CountrySelect renders each option's // label inside a nested element, which leaves the option itself unnamed. await page.getByRole('option').filter({ hasText: option }).first().click(); } /** Drives the AmharicDatePicker's own UI — a native `value` write bypasses `onChange`. */ 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 }); await calendar.locator('select').last().selectOption(String(year)); await calendar.locator('select').first().selectOption({ index: month - 1 }); 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 }); await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', { timeout: 10_000, }); } /** Signs up, declares seafarer operations, and fills the profile. */ 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(/\/seafarer-registration/, { timeout: 30_000 }); // The portal has provisioned the profile by now (the page read it); fill it. await expect.poll(() => profileIdOf(applicant.email), { timeout: 30_000 }).toBeTruthy(); completeProfile(applicant); } test.describe('seafarer registration', () => { let applicant: Applicant; test.beforeEach(() => { applicant = newApplicant('seafarer'); }); test.afterEach(() => { deleteApplicant(applicant.email); }); test('selecting seafarer opens the registration form', 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 — its own page, not the licence wizard. await expect(page).toHaveURL(/\/seafarer-registration$/, { timeout: 30_000 }); await expect(page.getByRole('heading', { name: /seafarer registration/i })).toBeVisible(); // The old licence-wizard link lands in the same place. await page.goto('/licensing/SEAFARER_REGISTRATION/apply'); await expect(page).toHaveURL(/\/seafarer-registration$/, { timeout: 30_000 }); }); test('opening the form creates the draft up front', async ({ page }) => { await readyApplicant(page, applicant); await page.goto('/seafarer-registration'); // The draft exists before anything is filled in, so uploads have an owner // and closing the browser mid-form loses nothing. const number = await waitForRegistration(applicant.email); expect(number).toMatch(/^SFR/); expect(statusOf(number)).toBe('DRAFT'); // Prefilled from the profile the applicant just completed. await expect(page.getByLabel('First Name')).toHaveValue(applicant.firstName); }); test('an incomplete registration is refused with what is missing', async ({ page }) => { await readyApplicant(page, applicant); await page.goto('/seafarer-registration'); const id = idOf(await waitForRegistration(applicant.email)); const [code] = await runRegistrationWorkflow( id, [{ path: 'submit', expectFailure: true }], applicant, ); expect(code).toBe(400); }); test('an officer can return a registration for correction and take it back', async ({ page, }) => { await readyApplicant(page, applicant); await page.goto('/seafarer-registration'); const number = await waitForRegistration(applicant.email); const id = idOf(number); await submit(id, applicant); expect(statusOf(number)).toBe('SUBMITTED'); await runRegistrationWorkflow(id, [ { path: 'request-changes', data: { remark: 'Medical certificate is illegible.' } }, ]); expect(statusOf(number)).toBe('RESUBMIT_REQUIRED'); // Nothing can be decided while it is with the applicant. const [refused] = await runRegistrationWorkflow(id, [ { path: 'approve', expectFailure: true }, ]); expect(refused).toBeGreaterThanOrEqual(400); // A resubmission returns to the queue. await runRegistrationWorkflow(id, [{ path: 'submit' }], applicant); expect(statusOf(number)).toBe('SUBMITTED'); }); test('an officer can reject a registration with a reason', async ({ page }) => { await readyApplicant(page, applicant); await page.goto('/seafarer-registration'); const number = await waitForRegistration(applicant.email); const id = idOf(number); await submit(id, applicant); await runRegistrationWorkflow(id, [ { path: 'reject', data: { reason: 'Basic training evidence incomplete.' } }, ]); expect(statusOf(number)).toBe('REJECTED'); // A rejection is terminal: nothing is numbered, and the documents requested // with the registration are withdrawn rather than left waiting. expect(seafarerNumberOf(applicant.email)).toBeNull(); expect(documentsOf(applicant.email).map((r) => r[1])).toEqual(['CANCELLED', 'CANCELLED']); }); test('approval numbers the profile and opens both child applications', async ({ page, }) => { await readyApplicant(page, applicant); await page.goto('/seafarer-registration'); const number = await waitForRegistration(applicant.email); const id = idOf(number); await submit(id, applicant); await approveRegistration(id); expect(statusOf(number)).toBe('APPROVED'); const profile = sql(` SELECT p.seafarer_number, p.seafarer_status, p.seafarer_department 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'); expect(profile[0][2]).toBe('DECK'); // The medical details become a verified certificate on the profile. expect( sqlValue(` SELECT m.status FROM medical_certificates m JOIN profiles p ON p.id = m.profile_id JOIN iam.users u ON u.id = p.user_id WHERE u.email = '${applicant.email}' `), ).toBe('VERIFIED'); // The applicant is not made to apply twice more for the documents that // prove what they have just been told: both were requested at submission // and are now released to payment, each with its own fee. const documents = documentsOf(applicant.email); expect(documents.map((r) => [r[0], r[1], r[2]])).toEqual([ ['BTC_BASIC_TRAINING', 'PAYMENT_PENDING', '250.00'], ['SEAMAN_BOOK', 'PAYMENT_PENDING', '400.00'], ]); // The portal now shows the outcome rather than a form. await page.goto('/seafarer-registration'); await expect(page.getByText(/you are a registered seafarer/i)).toBeVisible({ timeout: 30_000, }); }); test('a re-fired approval renumbers nobody and opens no second pair', async ({ page, }) => { await readyApplicant(page, applicant); await page.goto('/seafarer-registration'); const number = await waitForRegistration(applicant.email); const id = idOf(number); await submit(id, applicant); await approveRegistration(id); const first = seafarerNumberOf(applicant.email); const [code] = await runRegistrationWorkflow(id, [ { path: 'approve', expectFailure: true }, ]); expect(code).toBeGreaterThanOrEqual(400); expect(seafarerNumberOf(applicant.email)).toBe(first); expect(documentsOf(applicant.email)).toHaveLength(2); }); test('a released document is paid, scheduled and issued from its own queue', async ({ page, }) => { await readyApplicant(page, applicant); await page.goto('/seafarer-registration'); const number = await waitForRegistration(applicant.email); const id = idOf(number); await submit(id, applicant); // Requested with the submission, held until approval. expect(documentsOf(applicant.email).map((r) => r[1])).toEqual([ 'AWAITING_REGISTRATION', 'AWAITING_REGISTRATION', ]); await approveRegistration(id); const btc = documentsOf(applicant.email).find((r) => r[0] === 'BTC_BASIC_TRAINING'); if (!btc) throw new Error('No BTC request opened'); const btcId = btc[3]; // Nothing can be issued before the fee is settled. const [refused] = await runDocumentWorkflow(btcId, [{ path: 'issue', expectFailure: true }]); expect(refused).toBeGreaterThanOrEqual(400); // The test bypass settles the fee as the applicant; PAYMENT_AUTO_CONFIRM in // the suite's environment confirms it without a finance officer. await runDocumentWorkflow(btcId, [{ path: 'payments/bypass' }], applicant); expect(documentStatus(btcId)).toBe('PAYMENT_CONFIRMED'); await runDocumentWorkflow(btcId, [ { path: 'schedule-issuance', data: { scheduledDate: '2026-09-01' } }, ]); expect(documentStatus(btcId)).toBe('SCHEDULED'); await runDocumentWorkflow(btcId, [{ path: 'issue' }]); const issued = sql(` SELECT status, document_number, expiry_date, verification_code FROM seafarer_documents WHERE id = '${btcId}' `)[0]; expect(issued[0]).toBe('ISSUED'); expect(issued[1]).toMatch(/^BTC/); expect(issued[2]).toBeTruthy(); expect(issued[3]).toBeTruthy(); // The portal shows the number and offers the PDF. await page.goto('/basic-training-certificate'); await expect(page.getByText(issued[1], { exact: true })).toBeVisible({ timeout: 30_000 }); await expect(page.getByRole('button', { name: /download pdf/i })).toBeVisible(); // Issued once: a second issue is refused and the number stands. const [again] = await runDocumentWorkflow(btcId, [{ path: 'issue', expectFailure: true }]); expect(again).toBeGreaterThanOrEqual(400); expect(documentStatus(btcId)).toBe('ISSUED'); }); test('the form can be completed in the browser and approved from the backoffice', async ({ page, }) => { await readyApplicant(page, applicant); await page.goto('/seafarer-registration'); const number = await waitForRegistration(applicant.email); const id = idOf(number); // Uploads need object storage, which this suite does not stand up; the // evidence rows go in directly and the page is reopened so it sees them. insertDocuments(id); await page.reload(); // Step 1 — Identity Details: prefilled from the profile, nothing to type. await expect(page.getByLabel('First Name')).toHaveValue(applicant.firstName); await expect(page.getByLabel('National ID (Fayda) Number')).toHaveValue('FYD1234567890'); await page.getByRole('button', { name: /^continue$/i }).click(); // Step 2 — Details: address and physical characteristics. await page.getByLabel('Place of Birth').fill('Addis Ababa'); await pick(page, 'Department', /deck/i); await pick(page, 'City', /addis ababa/i); await pick(page, 'Sub-City', /arada/i); await pick(page, 'Hair Colour', /black/i); await pick(page, 'Eye Colour', /brown/i); await page.getByLabel('Height (cm)').fill('172'); await page.getByLabel('Weight (kg)').fill('68'); await page.getByRole('button', { name: /^continue$/i }).click(); // Step 3 — Contact & Medical. await page.getByLabel('Full Name').fill('Almaz Tesfaye'); await page.getByLabel('Relationship').fill('Sister'); await page.getByLabel('Phone Number').fill('+251911222333'); await page.getByLabel('Certificate Number').fill('MED-2026-001'); await page.getByLabel('Issuing Clinic or Practitioner').fill('Addis Marine Clinic'); await pickDate(page, 'Issue Date', '2026-01-15'); await page.getByRole('button', { name: /^continue$/i }).click(); // Step 4 — Documents: all four required slots show as uploaded. await expect(page.getByText('uploaded')).toHaveCount(4); await page.getByRole('button', { name: /^continue$/i }).click(); // Step 5 — Review: the answers typed above, then the declaration. await expect(page.getByText('Addis Marine Clinic')).toBeVisible(); await page.getByRole('checkbox', { name: /i declare/i }).check(); await page.getByRole('button', { name: /submit registration/i }).click(); await expect(page.getByText(/with the authority for review/i)).toBeVisible({ timeout: 30_000, }); expect(statusOf(number)).toBe('SUBMITTED'); // What was typed is what was stored — typed columns, no form blob. const stored = sql(` SELECT place_of_birth, department, hair_color, height_cm, medical_issue_date, emergency_contact_name FROM seafarer_registrations WHERE id = '${id}' `)[0]; expect(stored).toEqual([ 'Addis Ababa', 'DECK', 'BLACK', '172.0', '2026-01-15', 'Almaz Tesfaye', ]); // The officer's side, through its own queue and review screen. await logInAsOfficer(page); await openInQueue(page, number); await expect(page.getByRole('heading', { name: applicant.name })).toBeVisible({ timeout: 30_000, }); // No claim step: the decision is taken straight off the queue. await act(page, /^approve$/i, /^confirm$/i); await expect(page.getByText('Approved', { exact: true })).toBeVisible({ timeout: 30_000, }); expect(statusOf(number)).toBe('APPROVED'); expect(seafarerNumberOf(applicant.email)).toBeTruthy(); }); test('a registered seafarer cannot start a second registration', async ({ page, }) => { await readyApplicant(page, applicant); await page.goto('/seafarer-registration'); const number = await waitForRegistration(applicant.email); await submit(idOf(number), applicant); await approveRegistration(idOf(number)); // "Start" returns the approved registration rather than opening another. await runRegistrationWorkflow(idOf(number), [], applicant); await page.goto('/seafarer-registration'); await expect(page.getByText(/you are a registered seafarer/i)).toBeVisible({ timeout: 30_000, }); expect( sqlValue(` SELECT count(*) FROM seafarer_registrations r JOIN iam.users u ON u.id = r.applicant_user_id WHERE u.email = '${applicant.email}' `), ).toBe('1'); }); }); // ------------------------------------------------------------------ helpers /** Waits for the draft the form creates on open, and returns its number. */ async function waitForRegistration(email: string, timeoutMs = 30_000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { const found = sqlValue(` SELECT r.registration_number FROM seafarer_registrations r JOIN iam.users u ON u.id = r.applicant_user_id WHERE u.email = '${email}' ORDER BY r.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(registrationNumber: string): string { const id = sqlValue(` SELECT id FROM seafarer_registrations WHERE registration_number = '${registrationNumber}' `); if (!id) throw new Error(`No registration ${registrationNumber}`); return id; } function statusOf(registrationNumber: string): string | null { return sqlValue(` SELECT status FROM seafarer_registrations WHERE registration_number = '${registrationNumber}' `); } 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}' `); } /** The Seaman Book / BTC requests opened for this applicant: kind, status, fee, id. */ function documentsOf(email: string): string[][] { return sql(` SELECT d.kind, d.status, d.fee_amount, d.id FROM seafarer_documents d JOIN iam.users u ON u.id = d.applicant_user_id WHERE u.email = '${email}' ORDER BY d.kind::text `); } function documentStatus(documentId: string): string | null { return sqlValue(`SELECT status FROM seafarer_documents WHERE id = '${documentId}'`); } /** * 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 form's * fields. The answers go in as one UPDATE and the evidence as attachment rows * — a row with a storage key is exactly as complete as an upload to the * submission check, without requiring object storage to be reachable. */ function fillForSubmission(registrationId: string): void { fillAnswers(registrationId); insertDocuments(registrationId); } function fillAnswers(registrationId: 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.'); } sql(` UPDATE seafarer_registrations SET first_name = 'Dawit', middle_name = 'Bekele', last_name = 'Tesfaye', gender = 'MALE', date_of_birth = '1995-04-12', marital_status = 'SINGLE', nationality = 'Ethiopian', national_id_number = 'FYD1234567890', place_of_birth = 'Addis Ababa', department = 'DECK', location_id = '${locationId}', permanent_address = 'Bole, Addis Ababa', emergency_contact_name = 'Almaz Tesfaye', emergency_contact_relationship = 'Sister', emergency_contact_phone = '+251911222333', hair_color = 'BLACK', eye_color = 'BROWN', height_cm = 172, weight_kg = 68, blood_type = 'O_POSITIVE', medical_certificate_number = 'MED-2026-001', medical_issuer_name = 'Addis Marine Clinic', medical_issue_date = '2026-01-15', declaration_accepted = true WHERE id = '${registrationId}'; `); } /** The four required evidence rows, as attachment rows with a storage key. */ function insertDocuments(registrationId: string): void { const documentKeys = ['photo', 'nationalId', 'medical_certificate', 'basic_training_evidence']; sql(` WITH inserted AS ( INSERT INTO attachments (owner_type, owner_id, document_key, valid_from, valid_to) SELECT 'SEAFARER_REGISTRATION', '${registrationId}', 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(registrationId: string, applicant: Applicant): Promise { fillForSubmission(registrationId); await runRegistrationWorkflow(registrationId, [{ path: 'submit' }], applicant); }