|
|
|
|
@@ -6,50 +6,31 @@ import {
|
|
|
|
|
verifyOtpIfPrompted,
|
|
|
|
|
} from './support/applicant';
|
|
|
|
|
import { deleteApplicant, sql, sqlValue } from './support/db';
|
|
|
|
|
import {
|
|
|
|
|
approveRegistration,
|
|
|
|
|
resolveOpenRemarks,
|
|
|
|
|
runWorkflow,
|
|
|
|
|
} from './support/workflow';
|
|
|
|
|
import { approveRegistration, runRegistrationWorkflow } from './support/workflow';
|
|
|
|
|
import { act, logInAsOfficer, openInQueue } from './support/officer';
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
* Registration is its own table and its own endpoints — not a licence
|
|
|
|
|
* application. Approval is the one step with consequences beyond its row: it
|
|
|
|
|
* stamps a permanent number on the profile, activates the seafarer record,
|
|
|
|
|
* records the medical certificate, and opens the Seaman Book and BTC
|
|
|
|
|
* applications on the applicant's behalf. Those effects only fire at approval,
|
|
|
|
|
* so nothing short of driving a registration into an officer's hands
|
|
|
|
|
* exercises them.
|
|
|
|
|
*/
|
|
|
|
|
|
|
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
* Fills the profile the registration form prefills its Identity Details step
|
|
|
|
|
* from. Not a precondition — 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.
|
|
|
|
|
*/
|
|
|
|
|
async function completeProfile(
|
|
|
|
|
page: Page,
|
|
|
|
|
applicant: Applicant,
|
|
|
|
|
): Promise<void> {
|
|
|
|
|
async function completeProfile(page: Page, applicant: Applicant): Promise<void> {
|
|
|
|
|
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);
|
|
|
|
|
@@ -60,88 +41,42 @@ async function completeProfile(
|
|
|
|
|
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<void> {
|
|
|
|
|
await page.getByRole('tab', { name, exact: true }).click();
|
|
|
|
|
await expect(page.getByRole('tabpanel', { name })).toBeVisible({
|
|
|
|
|
timeout: 15_000,
|
|
|
|
|
});
|
|
|
|
|
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.
|
|
|
|
|
*/
|
|
|
|
|
/** 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 });
|
|
|
|
|
|
|
|
|
|
// `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.
|
|
|
|
|
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 });
|
|
|
|
|
|
|
|
|
|
// 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' &&
|
|
|
|
|
@@ -150,42 +85,20 @@ async function save(page: Page): Promise<void> {
|
|
|
|
|
{ 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 `<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')
|
|
|
|
|
.allTextContents();
|
|
|
|
|
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).',
|
|
|
|
|
: 'Save produced no request and reported no field error.',
|
|
|
|
|
{ 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.
|
|
|
|
|
*/
|
|
|
|
|
/** 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);
|
|
|
|
|
@@ -195,11 +108,8 @@ async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
|
|
|
|
|
.first()
|
|
|
|
|
.check();
|
|
|
|
|
await page.getByRole('button', { name: /save operations/i }).click();
|
|
|
|
|
await expect(page).toHaveURL(/\/licensing\/SEAFARER_REGISTRATION\/apply/, {
|
|
|
|
|
timeout: 30_000,
|
|
|
|
|
});
|
|
|
|
|
await expect(page).toHaveURL(/\/seafarer-registration/, { timeout: 30_000 });
|
|
|
|
|
await page.goto('/profile');
|
|
|
|
|
await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
|
|
|
|
await completeProfile(page, applicant);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -214,7 +124,7 @@ test.describe('seafarer registration', () => {
|
|
|
|
|
deleteApplicant(applicant.email);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test('selecting seafarer opens the registration wizard', async ({ page }) => {
|
|
|
|
|
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 });
|
|
|
|
|
@@ -224,215 +134,256 @@ test.describe('seafarer registration', () => {
|
|
|
|
|
.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,
|
|
|
|
|
});
|
|
|
|
|
// 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 short link lands in the same place.
|
|
|
|
|
await page.goto('/seafarer-registration');
|
|
|
|
|
await expect(page).toHaveURL(/\/licensing\/SEAFARER_REGISTRATION\/apply/, {
|
|
|
|
|
timeout: 30_000,
|
|
|
|
|
});
|
|
|
|
|
// 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 wizard creates the draft up front', async ({ page }) => {
|
|
|
|
|
test('opening the form 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 });
|
|
|
|
|
await page.goto('/seafarer-registration');
|
|
|
|
|
|
|
|
|
|
// 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);
|
|
|
|
|
// 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('a registration never reaches evaluation or inspection', async ({
|
|
|
|
|
page,
|
|
|
|
|
}) => {
|
|
|
|
|
test('an incomplete registration is refused with what is missing', async ({ page }) => {
|
|
|
|
|
await readyApplicant(page, applicant);
|
|
|
|
|
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
|
|
|
|
const number = await waitForApplication(applicant.email);
|
|
|
|
|
const id = idOf(number);
|
|
|
|
|
await page.goto('/seafarer-registration');
|
|
|
|
|
const id = idOf(await waitForRegistration(applicant.email));
|
|
|
|
|
|
|
|
|
|
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');
|
|
|
|
|
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('/licensing/SEAFARER_REGISTRATION/apply');
|
|
|
|
|
const number = await waitForApplication(applicant.email);
|
|
|
|
|
await page.goto('/seafarer-registration');
|
|
|
|
|
const number = await waitForRegistration(applicant.email);
|
|
|
|
|
const id = idOf(number);
|
|
|
|
|
|
|
|
|
|
await submit(id, applicant);
|
|
|
|
|
await runWorkflow(id, [
|
|
|
|
|
expect(statusOf(number)).toBe('SUBMITTED');
|
|
|
|
|
|
|
|
|
|
await runRegistrationWorkflow(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.',
|
|
|
|
|
},
|
|
|
|
|
],
|
|
|
|
|
},
|
|
|
|
|
},
|
|
|
|
|
{ path: 'request-changes', data: { 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.' } },
|
|
|
|
|
// Nothing can be decided while it is with the applicant.
|
|
|
|
|
const [refused] = await runRegistrationWorkflow(id, [
|
|
|
|
|
{ path: 'approve', expectFailure: true },
|
|
|
|
|
]);
|
|
|
|
|
expect(statusOf(number)).toBe('ON_HOLD');
|
|
|
|
|
expect(refused).toBeGreaterThanOrEqual(400);
|
|
|
|
|
|
|
|
|
|
// Resume restores whatever it was held from, read back from history.
|
|
|
|
|
await runWorkflow(id, [{ path: 'resume' }]);
|
|
|
|
|
expect(statusOf(number)).toBe('UNDER_REVIEW');
|
|
|
|
|
// 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('/licensing/SEAFARER_REGISTRATION/apply');
|
|
|
|
|
const number = await waitForApplication(applicant.email);
|
|
|
|
|
await page.goto('/seafarer-registration');
|
|
|
|
|
const number = await waitForRegistration(applicant.email);
|
|
|
|
|
const id = idOf(number);
|
|
|
|
|
|
|
|
|
|
await submit(id, applicant);
|
|
|
|
|
await runWorkflow(id, [
|
|
|
|
|
await runRegistrationWorkflow(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.
|
|
|
|
|
// A rejection is terminal: nothing is numbered, nothing is opened.
|
|
|
|
|
expect(seafarerNumberOf(applicant.email)).toBeNull();
|
|
|
|
|
expect(childrenOf(number).every((r) => r[1] === 'DRAFT')).toBe(true);
|
|
|
|
|
expect(childrenOf(applicant.email)).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);
|
|
|
|
|
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('COMPLETED');
|
|
|
|
|
expect(statusOf(number)).toBe('APPROVED');
|
|
|
|
|
|
|
|
|
|
const profile = sql(`
|
|
|
|
|
SELECT p.seafarer_number, p.seafarer_status
|
|
|
|
|
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 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);
|
|
|
|
|
// prove what they have just been told: both are opened, straight to payment.
|
|
|
|
|
const children = childrenOf(applicant.email);
|
|
|
|
|
expect(children.map((r) => [r[0], r[1]])).toEqual([
|
|
|
|
|
['BTC_BASIC_TRAINING', 'PAYMENT_PENDING'],
|
|
|
|
|
['SEAMAN_BOOK', 'SUBMITTED'],
|
|
|
|
|
['SEAMAN_BOOK', 'PAYMENT_PENDING'],
|
|
|
|
|
]);
|
|
|
|
|
expect(children.every((r) => r[2] === 'AUTO_SEAFARER_APPROVAL')).toBe(true);
|
|
|
|
|
|
|
|
|
|
// 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('/licensing/SEAFARER_REGISTRATION/apply');
|
|
|
|
|
const number = await waitForApplication(applicant.email);
|
|
|
|
|
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);
|
|
|
|
|
|
|
|
|
|
// Approving again must be a no-op, not a second number and a second bill.
|
|
|
|
|
await runWorkflow(id, [{ path: 'final-approve', expectFailure: true }]);
|
|
|
|
|
|
|
|
|
|
const [code] = await runRegistrationWorkflow(id, [
|
|
|
|
|
{ path: 'approve', expectFailure: true },
|
|
|
|
|
]);
|
|
|
|
|
expect(code).toBeGreaterThanOrEqual(400);
|
|
|
|
|
expect(seafarerNumberOf(applicant.email)).toBe(first);
|
|
|
|
|
expect(childrenOf(number)).toHaveLength(2);
|
|
|
|
|
expect(childrenOf(applicant.email)).toHaveLength(2);
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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 — Applicant Details.
|
|
|
|
|
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.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 3 — Emergency Contact.
|
|
|
|
|
await page.getByLabel('Full Name').fill('Almaz Tesfaye');
|
|
|
|
|
await page.getByLabel('Relationship').fill('Sister');
|
|
|
|
|
await page.getByLabel('Phone Number').fill('+251911222333');
|
|
|
|
|
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(/submitted — still correctable/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,
|
|
|
|
|
});
|
|
|
|
|
await act(page, /^claim$/i);
|
|
|
|
|
await expect(page.getByText('Under Review')).toBeVisible();
|
|
|
|
|
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('/licensing/SEAFARER_REGISTRATION/apply');
|
|
|
|
|
const number = await waitForApplication(applicant.email);
|
|
|
|
|
await page.goto('/seafarer-registration');
|
|
|
|
|
const number = await waitForRegistration(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.
|
|
|
|
|
// "Start" returns the approved registration rather than opening another.
|
|
|
|
|
await runRegistrationWorkflow(idOf(number), [], applicant);
|
|
|
|
|
await page.goto('/seafarer-registration');
|
|
|
|
|
await expect(page).not.toHaveURL(
|
|
|
|
|
/\/licensing\/SEAFARER_REGISTRATION\/apply/,
|
|
|
|
|
{ timeout: 30_000 },
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
await expect(page.getByText(/you are a registered seafarer/i)).toBeVisible({
|
|
|
|
|
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}'
|
|
|
|
|
SELECT count(*) FROM seafarer_registrations r
|
|
|
|
|
JOIN iam.users u ON u.id = r.applicant_user_id
|
|
|
|
|
WHERE u.email = '${applicant.email}'
|
|
|
|
|
`),
|
|
|
|
|
).toBe('1');
|
|
|
|
|
});
|
|
|
|
|
@@ -440,20 +391,16 @@ test.describe('seafarer registration', () => {
|
|
|
|
|
|
|
|
|
|
// ------------------------------------------------------------------ helpers
|
|
|
|
|
|
|
|
|
|
/** Waits for the draft the wizard creates on open, and returns its number. */
|
|
|
|
|
async function waitForApplication(
|
|
|
|
|
email: string,
|
|
|
|
|
timeoutMs = 30_000,
|
|
|
|
|
): Promise<string> {
|
|
|
|
|
/** 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 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
|
|
|
|
|
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));
|
|
|
|
|
@@ -461,19 +408,19 @@ async function waitForApplication(
|
|
|
|
|
throw new Error(`No seafarer registration appeared for ${email}`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function idOf(applicationNumber: string): string {
|
|
|
|
|
function idOf(registrationNumber: string): string {
|
|
|
|
|
const id = sqlValue(`
|
|
|
|
|
SELECT id FROM license_applications
|
|
|
|
|
WHERE application_number = '${applicationNumber}'
|
|
|
|
|
SELECT id FROM seafarer_registrations
|
|
|
|
|
WHERE registration_number = '${registrationNumber}'
|
|
|
|
|
`);
|
|
|
|
|
if (!id) throw new Error(`No application ${applicationNumber}`);
|
|
|
|
|
if (!id) throw new Error(`No registration ${registrationNumber}`);
|
|
|
|
|
return id;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function statusOf(applicationNumber: string): string | null {
|
|
|
|
|
function statusOf(registrationNumber: string): string | null {
|
|
|
|
|
return sqlValue(`
|
|
|
|
|
SELECT status FROM license_applications
|
|
|
|
|
WHERE application_number = '${applicationNumber}'
|
|
|
|
|
SELECT status FROM seafarer_registrations
|
|
|
|
|
WHERE registration_number = '${registrationNumber}'
|
|
|
|
|
`);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@@ -485,26 +432,14 @@ 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[][] {
|
|
|
|
|
/** The licence applications approval opened for this applicant. */
|
|
|
|
|
function childrenOf(email: 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}'
|
|
|
|
|
)
|
|
|
|
|
JOIN iam.users u ON u.id = a.applicant_user_id
|
|
|
|
|
WHERE u.email = '${email}' AND a.origin = 'AUTO_SEAFARER_APPROVAL'
|
|
|
|
|
ORDER BY lt.key
|
|
|
|
|
`);
|
|
|
|
|
}
|
|
|
|
|
@@ -512,22 +447,17 @@ function childrenOf(applicationNumber: string): string[][] {
|
|
|
|
|
/**
|
|
|
|
|
* 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.
|
|
|
|
|
* 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(applicationId: string): void {
|
|
|
|
|
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
|
|
|
|
|
@@ -537,54 +467,30 @@ function fillForSubmission(applicationId: string): void {
|
|
|
|
|
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}';
|
|
|
|
|
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 'APPLICATION', '${applicationId}', key, CURRENT_DATE, CURRENT_DATE + 365
|
|
|
|
|
SELECT 'SEAFARER_REGISTRATION', '${registrationId}', key, CURRENT_DATE, CURRENT_DATE + 365
|
|
|
|
|
FROM unnest(ARRAY[${documentKeys.map((d) => `'${d}'`).join(',')}]) AS key
|
|
|
|
|
RETURNING id
|
|
|
|
|
)
|
|
|
|
|
@@ -596,12 +502,7 @@ function fillForSubmission(applicationId: string): void {
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/** 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);
|
|
|
|
|
async function submit(registrationId: string, applicant: Applicant): Promise<void> {
|
|
|
|
|
fillForSubmission(registrationId);
|
|
|
|
|
await runRegistrationWorkflow(registrationId, [{ path: 'submit' }], applicant);
|
|
|
|
|
}
|
|
|
|
|
|