mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 16:28:13 +00:00
555 lines
22 KiB
TypeScript
555 lines
22 KiB
TypeScript
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<void> {
|
|
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<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 });
|
|
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<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();
|
|
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<string> {
|
|
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<void> {
|
|
fillForSubmission(registrationId);
|
|
await runRegistrationWorkflow(registrationId, [{ path: 'submit' }], applicant);
|
|
}
|