Remove obsolete test artifacts and error context files for seafarer registration tests
@@ -0,0 +1,108 @@
|
||||
import { Avatar, Badge, Group, Paper, Stack, Text } from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { ApplicationApplicant } from '@ema-platform/api';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
interface ApplicantCardProps {
|
||||
applicant: ApplicationApplicant;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who the reviewer is deciding about.
|
||||
*
|
||||
* A company licence names itself in the page title (`companyName`); a seafarer
|
||||
* registration has no company, so the officer's screen led with an application
|
||||
* number and the human behind it was somewhere in the form answers. This puts
|
||||
* the identity where it belongs on a person-centric review: name, national ID,
|
||||
* contact, and — for a seafarer who already holds one — their number and
|
||||
* standing, which is what says whether this is a first registration or a
|
||||
* duplicate.
|
||||
*
|
||||
* Read-only and sourced from the profile, not the form: this is the record the
|
||||
* registration will be written onto, so a reviewer comparing the two is exactly
|
||||
* the intended use.
|
||||
*/
|
||||
export function ApplicantCard({ applicant }: ApplicantCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const showDate = useDateDisplayer();
|
||||
|
||||
const fullName = [applicant.firstName, applicant.middleName, applicant.lastName]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
const initials = [applicant.firstName, applicant.lastName]
|
||||
.filter(Boolean)
|
||||
.map((part) => part?.[0]?.toUpperCase() ?? '')
|
||||
.join('');
|
||||
|
||||
return (
|
||||
<Paper withBorder p="md">
|
||||
<Group gap="sm" wrap="nowrap" align="flex-start" mb="sm">
|
||||
<Avatar radius="xl" color="blue" variant="light">
|
||||
{initials || '—'}
|
||||
</Avatar>
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text fw={600} size="sm" style={{ wordBreak: 'break-word' }}>
|
||||
{fullName || t('review.nameMissing', 'Name not on profile')}
|
||||
</Text>
|
||||
{applicant.seafarerNumber ? (
|
||||
<Group gap={4} mt={2}>
|
||||
<Text size="xs" c="dimmed">
|
||||
{applicant.seafarerNumber}
|
||||
</Text>
|
||||
{applicant.seafarerStatus && (
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={applicant.seafarerStatus === 'ACTIVE' ? 'teal' : 'orange'}
|
||||
>
|
||||
{applicant.seafarerStatus}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{t('review.notYetRegistered', 'Not yet registered')}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Stack gap={6}>
|
||||
<Row label={t('review.applicantGender', 'Gender')} value={applicant.gender} />
|
||||
<Row
|
||||
label={t('review.applicantDob', 'Date of birth')}
|
||||
value={applicant.dob ? showDate(applicant.dob) : null}
|
||||
/>
|
||||
<Row
|
||||
label={t('review.applicantNationality', 'Nationality')}
|
||||
value={applicant.nationality}
|
||||
/>
|
||||
<Row
|
||||
// The id type is the label, so a Fayda number is not read as a passport.
|
||||
label={applicant.idType ?? t('review.applicantId', 'National ID')}
|
||||
value={applicant.idNumber}
|
||||
/>
|
||||
<Row
|
||||
label={t('review.applicantPhone', 'Phone')}
|
||||
value={applicant.primaryPhoneNumber}
|
||||
/>
|
||||
<Row label={t('review.applicantEmail', 'Email')} value={applicant.email} />
|
||||
</Stack>
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
/** One label/value line, omitted entirely when there is nothing to show. */
|
||||
function Row({ label, value }: { label: string; value?: string | null }) {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<Group justify="space-between" gap="xs" wrap="nowrap" align="flex-start">
|
||||
<Text size="xs" c="dimmed" style={{ flexShrink: 0 }}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text size="xs" ta="right" style={{ wordBreak: 'break-word' }}>
|
||||
{value}
|
||||
</Text>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
import {
|
||||
Badge,
|
||||
Card,
|
||||
Checkbox,
|
||||
Divider,
|
||||
Grid,
|
||||
Group,
|
||||
Text,
|
||||
TextInput,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconMapPin } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
conditionHolds,
|
||||
displayFieldValue,
|
||||
useLocalized,
|
||||
type FormFieldConfig,
|
||||
type FormSectionConfig,
|
||||
} from '@ema-platform/api';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
/** A section as it will be rendered: config where there is some, key otherwise. */
|
||||
interface ResolvedSection {
|
||||
key: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
fields: { field: FormFieldConfig; value: unknown }[];
|
||||
}
|
||||
|
||||
interface FormDetailsTabProps {
|
||||
/** The application's answers, keyed by section. */
|
||||
formData: Record<string, Record<string, unknown>>;
|
||||
/** The licence type's form schema — the order and labels to render by. */
|
||||
configSections: FormSectionConfig[];
|
||||
currency?: string;
|
||||
/** sectionKey -> remark. Owned by the review page. */
|
||||
flags: Record<string, { remark: string }>;
|
||||
onToggleFlag: (sectionKey: string) => void;
|
||||
onFlagRemark: (sectionKey: string, remark: string) => void;
|
||||
/** Resolves a location id to a readable path, when the tree is loaded. */
|
||||
resolveLocation?: (locationId: string) => string | undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* What the applicant actually filled in, as the reviewing officer reads it.
|
||||
*
|
||||
* Replaces a set of bordered key/value tables built by walking `formData`.
|
||||
* Three things were wrong with that, all of them worse on a person-centric
|
||||
* registration than on a company licence:
|
||||
*
|
||||
* - Values were printed with `String(v)`, so a reviewer deciding on a seafarer
|
||||
* read `O_POSITIVE`, `DECK` and `true` — database codes, not the answers
|
||||
* anybody chose. Now resolved through the same field config that rendered
|
||||
* the input, shared with the applicant's own summary (`displayFieldValue`).
|
||||
* - Order came from jsonb key order, which is arbitrary: the declaration could
|
||||
* appear above the emergency contact. Now the schema's `sortOrder` decides,
|
||||
* which is the order the applicant filled them in.
|
||||
* - A location answer is a uuid. Shown raw it told the reviewer nothing;
|
||||
* resolved, it reads "Addis Ababa → Bole → Woreda 03".
|
||||
*/
|
||||
export function FormDetailsTab({
|
||||
formData,
|
||||
configSections,
|
||||
currency,
|
||||
flags,
|
||||
onToggleFlag,
|
||||
onFlagRemark,
|
||||
resolveLocation,
|
||||
}: FormDetailsTabProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const showDate = useDateDisplayer();
|
||||
|
||||
const sections = resolveSections();
|
||||
|
||||
/**
|
||||
* Sections in schema order, each with its fields in schema order.
|
||||
*
|
||||
* Anything present in `formData` but absent from the schema is still shown,
|
||||
* appended after the configured sections — a stale answer from a since-edited
|
||||
* form is exactly the kind of thing a reviewer needs to see, not something to
|
||||
* hide because the config moved on.
|
||||
*/
|
||||
function resolveSections(): ResolvedSection[] {
|
||||
const configured = [...configSections]
|
||||
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||
.map((section) => {
|
||||
const values = formData[section.key] ?? {};
|
||||
const fields = [...(section.fields ?? [])]
|
||||
.filter((f) => conditionHolds(f.showWhen, formData))
|
||||
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0))
|
||||
.map((field) => ({ field, value: values[field.key] }));
|
||||
return {
|
||||
key: section.key,
|
||||
title: localized(section.title) || section.key,
|
||||
description: localized(section.description) || undefined,
|
||||
fields,
|
||||
};
|
||||
})
|
||||
// A section the applicant never reached is noise on a review screen.
|
||||
.filter((s) => s.fields.some((f) => hasValue(f.value)));
|
||||
|
||||
const configuredKeys = new Set(configSections.map((s) => s.key));
|
||||
const orphans: ResolvedSection[] = Object.entries(formData)
|
||||
.filter(([key, values]) => !configuredKeys.has(key) && values)
|
||||
.map(([key, values]) => ({
|
||||
key,
|
||||
title: humanise(key),
|
||||
fields: Object.entries(values).map(([fieldKey, value]) => ({
|
||||
// No config to render by, so it is treated as free text under a
|
||||
// humanised key rather than dropped.
|
||||
field: { key: fieldKey, label: { en: humanise(fieldKey) }, type: 'TEXT' } as FormFieldConfig,
|
||||
value,
|
||||
})),
|
||||
}));
|
||||
|
||||
return [...configured, ...orphans];
|
||||
}
|
||||
|
||||
function display(field: FormFieldConfig, value: unknown): string {
|
||||
// A location is stored as a tree id; the reviewer needs the place.
|
||||
if (isLocationField(field) && typeof value === 'string' && value) {
|
||||
return resolveLocation?.(value) ?? value;
|
||||
}
|
||||
return displayFieldValue(field, value, {
|
||||
language: i18n.language,
|
||||
showDate,
|
||||
currency,
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<Grid>
|
||||
{sections.map((section) => {
|
||||
const flagged = Boolean(flags[section.key]);
|
||||
const missing = section.fields.filter((f) => !hasValue(f.value)).length;
|
||||
|
||||
return (
|
||||
<Grid.Col span={12} key={section.key}>
|
||||
<Card withBorder padding="md" radius="md">
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Group gap="xs">
|
||||
<Text fw={600} size="sm">
|
||||
{section.title}
|
||||
</Text>
|
||||
{missing > 0 && (
|
||||
<Tooltip
|
||||
label={t(
|
||||
'review.missingAnswers',
|
||||
'Left blank by the applicant',
|
||||
)}
|
||||
>
|
||||
<Badge
|
||||
size="xs"
|
||||
color="gray"
|
||||
variant="light"
|
||||
leftSection={<IconAlertTriangle size={10} />}
|
||||
>
|
||||
{missing}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Group>
|
||||
{section.description && (
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{section.description}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
<Checkbox
|
||||
size="xs"
|
||||
label={t('review.needsCorrection', 'Needs correction')}
|
||||
checked={flagged}
|
||||
onChange={() => onToggleFlag(section.key)}
|
||||
style={{ flexShrink: 0 }}
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Divider my="sm" />
|
||||
|
||||
{/* Label above value, two per row — a reviewer scans a definition
|
||||
list far faster than a bordered table of the same answers. */}
|
||||
<Grid gutter="sm">
|
||||
{section.fields.map(({ field, value }) => {
|
||||
const text = display(field, value);
|
||||
const answered = hasValue(value) && text !== '';
|
||||
return (
|
||||
<Grid.Col
|
||||
span={{ base: 12, sm: field.type === 'TEXTAREA' ? 12 : 6 }}
|
||||
key={field.key}
|
||||
>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{localized(field.label) || field.key}
|
||||
</Text>
|
||||
<Group gap={4} wrap="nowrap" align="center" mt={2}>
|
||||
{answered && isLocationField(field) && (
|
||||
<IconMapPin size={13} style={{ flexShrink: 0, opacity: 0.6 }} />
|
||||
)}
|
||||
<Text
|
||||
size="sm"
|
||||
c={answered ? undefined : 'dimmed'}
|
||||
fs={answered ? undefined : 'italic'}
|
||||
style={{ wordBreak: 'break-word' }}
|
||||
>
|
||||
{answered
|
||||
? text
|
||||
: t('review.notProvided', 'Not provided')}
|
||||
</Text>
|
||||
</Group>
|
||||
</Grid.Col>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
|
||||
{flagged && (
|
||||
<TextInput
|
||||
mt="sm"
|
||||
size="xs"
|
||||
withAsterisk
|
||||
placeholder={t(
|
||||
'review.correctionPlaceholder',
|
||||
'What must the applicant correct?',
|
||||
)}
|
||||
// Flagging without saying why is what the applicant would
|
||||
// receive: "fix this section", and nothing else.
|
||||
error={
|
||||
flags[section.key].remark.trim()
|
||||
? null
|
||||
: t('review.correctionRequired', 'Say what must be corrected')
|
||||
}
|
||||
value={flags[section.key].remark}
|
||||
onChange={(e) => {
|
||||
// Read here, not inside the updater: React nulls
|
||||
// `currentTarget` when the handler returns, and the updater
|
||||
// runs afterwards during the re-render.
|
||||
onFlagRemark(section.key, e.currentTarget.value);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
</Grid.Col>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
);
|
||||
}
|
||||
|
||||
function hasValue(value: unknown): boolean {
|
||||
return value !== null && value !== undefined && value !== '';
|
||||
}
|
||||
|
||||
/** English-pinned, like the portal's own location override. */
|
||||
function isLocationField(field: Pick<FormFieldConfig, 'key' | 'label'>): boolean {
|
||||
return (
|
||||
field.key === 'locationId' ||
|
||||
(field.label?.en ?? '').trim().toLowerCase() === 'location'
|
||||
);
|
||||
}
|
||||
|
||||
function humanise(key: string): string {
|
||||
const spaced = key.replace(/([A-Z])/g, ' $1').replace(/[_-]+/g, ' ');
|
||||
return spaced.charAt(0).toUpperCase() + spaced.slice(1).trim();
|
||||
}
|
||||
@@ -260,6 +260,28 @@ export interface ResolveContext {
|
||||
hasPendingInspection: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Action ids that are workflow events, so `availableEvents` decides them.
|
||||
*
|
||||
* The rest (`schedule-inspection`, `schedule-exam`, the secondary tools) are
|
||||
* screens and side effects rather than transitions, and the server has no
|
||||
* opinion on them — those keep using their own `from` list.
|
||||
*/
|
||||
const WORKFLOW_EVENT_IDS = new Set<ActionId>([
|
||||
'claim',
|
||||
'assign',
|
||||
'escalate',
|
||||
'hold',
|
||||
'resume',
|
||||
'complete-review',
|
||||
'approve-documents',
|
||||
'record-inspection',
|
||||
'final-approve',
|
||||
'request-adjustment',
|
||||
'reject',
|
||||
'confirm-payment',
|
||||
]);
|
||||
|
||||
/**
|
||||
* Which actions to render, and for each, whether it can fire and why not.
|
||||
*
|
||||
@@ -267,16 +289,35 @@ export interface ResolveContext {
|
||||
* are merely unavailable right now are kept and disabled with a reason, so the
|
||||
* officer can see what the next step would be rather than wondering whether
|
||||
* the screen is broken.
|
||||
*
|
||||
* For anything that is a workflow event, `detail.availableEvents` is the
|
||||
* authority on what fires from here — it comes from the same transition table
|
||||
* the server validates against, and it is workflow-profile aware. The local
|
||||
* `from` lists describe the licence course only, so a registration (which skips
|
||||
* evaluation and inspection, and approves straight out of UNDER_REVIEW) was
|
||||
* offered Complete Review — rejected server-side with
|
||||
* `event_not_available_for_service` — while Final Approve, the one action that
|
||||
* would work, was hidden.
|
||||
*/
|
||||
export function resolveActions(ctx: ResolveContext): ResolvedAction[] {
|
||||
const { detail, currentUserId, can, reasons } = ctx;
|
||||
const app = detail.application;
|
||||
const serverEvents = detail.availableEvents;
|
||||
|
||||
return ACTIONS.filter((action) => can(action.permissions)).flatMap<ResolvedAction>(
|
||||
(action) => {
|
||||
// Status-scoped actions vanish outside their stage rather than piling up
|
||||
// as a column of permanently dead buttons.
|
||||
if (action.from && !action.from.includes(app.status)) return [];
|
||||
if (WORKFLOW_EVENT_IDS.has(action.id)) {
|
||||
// Tolerate an older server that sends no list rather than rendering an
|
||||
// empty action bar.
|
||||
if (serverEvents?.length && !serverEvents.includes(action.id)) return [];
|
||||
if (!serverEvents?.length && action.from && !action.from.includes(app.status)) {
|
||||
return [];
|
||||
}
|
||||
} else if (action.from && !action.from.includes(app.status)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
// Scheduling and recording are the same slot at the same status; which
|
||||
// one applies depends on whether an inspection is already booked.
|
||||
|
||||
@@ -87,6 +87,23 @@ const PRESENTATION: Record<string, LicenseTypePresentation> = {
|
||||
// Person-centric: no company entity, no capital threshold, no staff roles.
|
||||
detailSections: ['overview', 'documents'],
|
||||
},
|
||||
// Opened automatically when a registration is approved, and reviewed like any
|
||||
// other person-centric service. Listed explicitly because neither key matches
|
||||
// the certificate prefixes below, so both fell through to the company-shaped
|
||||
// default and offered an officer Company, Financials and Staff tabs for an
|
||||
// application about one person.
|
||||
SEAMAN_BOOK: {
|
||||
key: 'SEAMAN_BOOK',
|
||||
icon: IconId,
|
||||
// Its own TRB inspection is a real stage, unlike the other personal
|
||||
// services, so the inspection tab stays.
|
||||
detailSections: ['overview', 'documents', 'inspection'],
|
||||
},
|
||||
BTC_BASIC_TRAINING: {
|
||||
key: 'BTC_BASIC_TRAINING',
|
||||
icon: IconShieldCheck,
|
||||
detailSections: ['overview', 'documents'],
|
||||
},
|
||||
VESSEL_REGISTRATION: {
|
||||
key: 'VESSEL_REGISTRATION',
|
||||
icon: IconAnchor,
|
||||
|
||||
@@ -4,8 +4,6 @@ import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Badge,
|
||||
Card,
|
||||
Checkbox,
|
||||
Container,
|
||||
Grid,
|
||||
Group,
|
||||
@@ -15,11 +13,9 @@ import {
|
||||
SegmentedControl,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Timeline,
|
||||
Title,
|
||||
@@ -38,6 +34,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
applicantOrCompanyName,
|
||||
extractErrorMessage,
|
||||
useLocalized,
|
||||
useApproveDocumentsMutation,
|
||||
@@ -77,6 +74,9 @@ import {
|
||||
} from '../../components/DecisionConfirmModal';
|
||||
import { ActivityRail } from '../../components/ActivityRail';
|
||||
import { DocumentsTab } from '../../components/DocumentsTab';
|
||||
import { FormDetailsTab } from '../../components/FormDetailsTab';
|
||||
import { ApplicantCard } from '../../components/ApplicantCard';
|
||||
import { useGetLocationsQuery } from '../../../location/api/location-api';
|
||||
import { ScheduleExamModal } from '../../components/ScheduleExamModal';
|
||||
import { computeSla } from '../../sla';
|
||||
import { reviewStaffColumns } from './columns';
|
||||
@@ -258,6 +258,45 @@ export function LicenseReviewPage() {
|
||||
});
|
||||
}, [data, currentUserId, can, flagged.length, pendingInspection, t]);
|
||||
|
||||
// Location answers are tree ids. The picker the applicant used resolves them
|
||||
// client-side from the same list, so the reviewer reads the place rather than
|
||||
// the uuid. Fetched only when the form actually has a location field.
|
||||
//
|
||||
// Above the early returns because it is a hook: React requires the same hook
|
||||
// order on every render, and the loading and error branches return before the
|
||||
// application is known.
|
||||
const needsLocations = (
|
||||
requirements?.licenseType.formSchema.sections ?? []
|
||||
).some((section) =>
|
||||
(section.fields ?? []).some(
|
||||
(f) =>
|
||||
f.key === 'locationId' ||
|
||||
(f.label?.en ?? '').trim().toLowerCase() === 'location',
|
||||
),
|
||||
);
|
||||
const { data: locationsRes } = useGetLocationsQuery(
|
||||
{ take: 10000 },
|
||||
{ skip: !needsLocations },
|
||||
);
|
||||
const resolveLocation = useMemo(() => {
|
||||
const all = locationsRes?.items ?? [];
|
||||
if (all.length === 0) return undefined;
|
||||
const byId = new Map(all.map((loc) => [loc.id, loc]));
|
||||
return (locationId: string) => {
|
||||
// Walks to the root so the answer reads as a place, not a leaf name:
|
||||
// "Woreda 03" alone does not say which sub-city it belongs to. Bounded by
|
||||
// the map size so a cyclic tree cannot spin the render.
|
||||
const path: string[] = [];
|
||||
let current = byId.get(locationId);
|
||||
let hops = 0;
|
||||
while (current && hops++ <= byId.size) {
|
||||
path.unshift(localized(current.names) || current.code);
|
||||
current = current.parentId ? byId.get(current.parentId) : undefined;
|
||||
}
|
||||
return path.length ? path.join(' → ') : undefined;
|
||||
};
|
||||
}, [locationsRes, localized]);
|
||||
|
||||
if (isLoading) {
|
||||
// Skeleton mirrors the real three-zone layout so nothing jumps on load.
|
||||
return (
|
||||
@@ -515,19 +554,40 @@ export function LicenseReviewPage() {
|
||||
}
|
||||
|
||||
const sections = presentation.detailSections;
|
||||
const formSections = Object.entries(app.formData ?? {});
|
||||
const hasFormAnswers = Object.keys(app.formData ?? {}).length > 0;
|
||||
// The bilingual section/field labels the applicant's wizard renders — this
|
||||
// page already fetches them (`requirements` above) but used to fall back to
|
||||
// the raw formData keys, so an officer saw `vesselId` instead of a label in
|
||||
// either language.
|
||||
const configSections = requirements?.licenseType.formSchema.sections ?? [];
|
||||
const sectionsByKey = new Map(configSections.map((s) => [s.key, s]));
|
||||
|
||||
// A person-centric service has no company, so the company-shaped facts are
|
||||
// not merely empty — they are the wrong question. TIN is hidden rather than
|
||||
// shown blank, and the applicant's own identity card takes its place.
|
||||
const isPersonal = !app.companyName;
|
||||
const applicant = data.applicant;
|
||||
const applicantFullName = [
|
||||
applicant?.firstName,
|
||||
applicant?.middleName,
|
||||
applicant?.lastName,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' ');
|
||||
// The profile is the reliable name for a personal service — `formData.account`
|
||||
// holds one only for applications filed after that field was added, and the
|
||||
// application number identifies the paperwork rather than the person.
|
||||
const headerName =
|
||||
app.companyName ||
|
||||
applicantFullName ||
|
||||
applicantOrCompanyName(app) ||
|
||||
app.applicationNumber;
|
||||
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={3}>{app.companyName ?? app.applicationNumber}</Title>
|
||||
<Title order={3}>{headerName}</Title>
|
||||
<Group gap="xs">
|
||||
<Text size="sm" c="dimmed">
|
||||
{app.applicationNumber}
|
||||
@@ -565,13 +625,21 @@ export function LicenseReviewPage() {
|
||||
{/* Zone 1 — sticky summary rail. */}
|
||||
<Grid.Col span={{ base: 12, md: 3 }}>
|
||||
<Stack style={{ position: 'sticky', top: 16 }}>
|
||||
{/* Who, before what: a person-centric review is about the applicant,
|
||||
and the licence facts below are the context. */}
|
||||
{isPersonal && applicant && <ApplicantCard applicant={applicant} />}
|
||||
|
||||
<Paper withBorder p="md">
|
||||
<Text fw={600} size="sm" mb="sm">
|
||||
{t('review.summary', 'Summary')}
|
||||
</Text>
|
||||
<Stack gap={6}>
|
||||
<SummaryRow label={t('review.type', 'Type')} value={localized(app.licenseType?.name)} />
|
||||
<SummaryRow label={t('review.tin', 'TIN')} value={app.tinNumber} />
|
||||
{/* A person has no TIN; showing the row blank invited the
|
||||
reviewer to wonder what was missing. */}
|
||||
{!isPersonal && (
|
||||
<SummaryRow label={t('review.tin', 'TIN')} value={app.tinNumber} />
|
||||
)}
|
||||
<SummaryRow label={t('review.kind', 'Kind')} value={t(`review.kindValues.${app.kind}`, app.kind)} />
|
||||
<SummaryRow
|
||||
label={t('review.submitted', 'Submitted')}
|
||||
@@ -653,7 +721,7 @@ export function LicenseReviewPage() {
|
||||
<Tabs defaultValue={sections[0]}>
|
||||
<Tabs.List mb="md">
|
||||
{/* Tabs with nothing behind them are not rendered at all. */}
|
||||
{sections.includes('overview') && formSections.length > 0 && (
|
||||
{sections.includes('overview') && hasFormAnswers && (
|
||||
<Tabs.Tab value="overview">{t('review.tabs.overview', 'Overview')}</Tabs.Tab>
|
||||
)}
|
||||
{sections.includes('financials') && (
|
||||
@@ -675,83 +743,20 @@ export function LicenseReviewPage() {
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="overview">
|
||||
<Stack>
|
||||
{formSections.map(([sectionKey, values]) => {
|
||||
const sectionConfig = sectionsByKey.get(sectionKey);
|
||||
const fieldsByKey = new Map(
|
||||
(sectionConfig?.fields ?? []).map((f) => [f.key, f]),
|
||||
);
|
||||
return (
|
||||
<Card withBorder key={sectionKey} padding="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fw={600} size="sm" tt="capitalize">
|
||||
{sectionConfig
|
||||
? localized(sectionConfig.title)
|
||||
: sectionKey.replace(/([A-Z])/g, ' $1')}
|
||||
</Text>
|
||||
<Checkbox
|
||||
size="xs"
|
||||
label={t('review.needsCorrection', 'Needs correction')}
|
||||
checked={Boolean(flags[sectionKey])}
|
||||
onChange={() => toggleFlag('FORM_SECTION', sectionKey)}
|
||||
/>
|
||||
</Group>
|
||||
<Table withTableBorder>
|
||||
<Table.Tbody>
|
||||
{Object.entries(values ?? {}).map(([k, v]) => {
|
||||
const fieldConfig = fieldsByKey.get(k);
|
||||
return (
|
||||
<Table.Tr key={k}>
|
||||
<Table.Td w="40%">
|
||||
<Text size="xs" c="dimmed">
|
||||
{fieldConfig ? localized(fieldConfig.label) : k}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{v === null ? '—' : String(v)}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
{flags[sectionKey] && (
|
||||
<TextInput
|
||||
mt="xs"
|
||||
size="xs"
|
||||
withAsterisk
|
||||
placeholder={t(
|
||||
'review.correctionPlaceholder',
|
||||
'What must the applicant correct?',
|
||||
)}
|
||||
// Flagging without saying why is what the applicant
|
||||
// would receive: "fix this section", and nothing else.
|
||||
error={
|
||||
flags[sectionKey].remark.trim()
|
||||
? null
|
||||
: t(
|
||||
'review.correctionRequired',
|
||||
'Say what must be corrected',
|
||||
)
|
||||
}
|
||||
value={flags[sectionKey].remark}
|
||||
onChange={(e) => {
|
||||
// Read here, not inside the updater: React nulls
|
||||
// `currentTarget` when the handler returns, and the
|
||||
// updater runs afterwards during the re-render —
|
||||
// which crashed the page on the first keystroke.
|
||||
const remark = e.currentTarget.value;
|
||||
setFlags((p) => ({
|
||||
...p,
|
||||
[sectionKey]: { ...p[sectionKey], remark },
|
||||
}));
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
<FormDetailsTab
|
||||
formData={app.formData ?? {}}
|
||||
configSections={configSections}
|
||||
currency={app.feeCurrency ?? undefined}
|
||||
flags={flags}
|
||||
onToggleFlag={(sectionKey) => toggleFlag('FORM_SECTION', sectionKey)}
|
||||
onFlagRemark={(sectionKey, remark) =>
|
||||
setFlags((p) => ({
|
||||
...p,
|
||||
[sectionKey]: { ...p[sectionKey], remark },
|
||||
}))
|
||||
}
|
||||
resolveLocation={resolveLocation}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="financials">
|
||||
@@ -905,7 +910,11 @@ export function LicenseReviewPage() {
|
||||
|
||||
<DecisionConfirmModal
|
||||
action={pendingAction}
|
||||
applicantName={app.companyName ?? t('review.theApplicant', 'the applicant')}
|
||||
applicantName={
|
||||
app.companyName ||
|
||||
applicantFullName ||
|
||||
t('review.theApplicant', 'the applicant')
|
||||
}
|
||||
applicationNumber={app.applicationNumber}
|
||||
flaggedItems={flaggedItems}
|
||||
officers={officers}
|
||||
@@ -916,7 +925,11 @@ export function LicenseReviewPage() {
|
||||
|
||||
<ScheduleExamModal
|
||||
opened={scheduleExamOpen}
|
||||
applicantName={app.companyName ?? t('review.theApplicant', 'the applicant')}
|
||||
applicantName={
|
||||
app.companyName ||
|
||||
applicantFullName ||
|
||||
t('review.theApplicant', 'the applicant')
|
||||
}
|
||||
loading={schedulingExam}
|
||||
onClose={() => setScheduleExamOpen(false)}
|
||||
onConfirm={async (payload) => {
|
||||
|
||||
@@ -6,7 +6,11 @@ import {
|
||||
verifyOtpIfPrompted,
|
||||
} from './support/applicant';
|
||||
import { deleteApplicant, sql, sqlValue } from './support/db';
|
||||
import { approveRegistration, runWorkflow } from './support/workflow';
|
||||
import {
|
||||
approveRegistration,
|
||||
resolveOpenRemarks,
|
||||
runWorkflow,
|
||||
} from './support/workflow';
|
||||
|
||||
/**
|
||||
* Seafarer registration, applicant through to approval.
|
||||
@@ -35,13 +39,20 @@ import { approveRegistration, runWorkflow } from './support/workflow';
|
||||
* be filled, and `PROFILE_FIELD_SECTION` in the auth lib is the map of which
|
||||
* field lives where.
|
||||
*/
|
||||
async function completeProfile(page: Page): Promise<void> {
|
||||
async function completeProfile(
|
||||
page: Page,
|
||||
applicant: Applicant,
|
||||
): Promise<void> {
|
||||
await page.goto('/profile');
|
||||
|
||||
await openTab(page, 'Profile');
|
||||
await page.getByLabel('First Name').fill('Dawit');
|
||||
await page.getByLabel('Middle Name').fill('Bekele');
|
||||
await page.getByLabel('Last Name').fill('Tesfaye');
|
||||
// The account's own name parts, not invented ones: the Maritime tab refuses
|
||||
// to save when they do not join to the name on the Personal tab, and it
|
||||
// refuses by returning early — no request, no field error, so the failure
|
||||
// surfaced only as "save produced no request".
|
||||
await page.getByLabel('First Name').fill(applicant.firstName);
|
||||
await page.getByLabel('Middle Name').fill(applicant.middleName);
|
||||
await page.getByLabel('Last Name').fill(applicant.lastName);
|
||||
await pick(page, 'Gender', /male/i);
|
||||
await pickDate(page, 'Date of Birth', '1995-04-12');
|
||||
await pick(page, 'Marital Status', /single/i);
|
||||
@@ -49,15 +60,16 @@ async function completeProfile(page: Page): Promise<void> {
|
||||
await save(page);
|
||||
|
||||
await openTab(page, 'Address');
|
||||
await pick(page, 'ID Type', /^NID$/i);
|
||||
// Matched on the option's label, not its stored value: the select shows
|
||||
// "National Id" and submits `NID`, so `/^NID$/` matched no option at all.
|
||||
await pick(page, 'ID Type', /^national id$/i);
|
||||
await page.getByLabel('ID Number').fill('FYD1234567890');
|
||||
// A country select, not a free-text field.
|
||||
await pick(page, 'Nationality', /ethiopia/i);
|
||||
// `addressSchema` requires this in Ethiopian format; without it the form
|
||||
// never submits and no request is made for `save` to wait on.
|
||||
await page
|
||||
.getByRole('textbox', { name: 'Primary Phone' })
|
||||
.fill('+251911234567');
|
||||
// Primary Phone is deliberately not filled: it is `readOnly` here and already
|
||||
// carries the account's number ("From your account, edit it in the Personal
|
||||
// tab"), so `addressSchema`'s Ethiopian-format rule is already satisfied and a
|
||||
// fill would only fail against a read-only input.
|
||||
await save(page);
|
||||
}
|
||||
|
||||
@@ -145,19 +157,35 @@ async function save(page: Page): Promise<void> {
|
||||
// A zod-blocked submit fires no request at all, so the bare timeout says
|
||||
// only "no response" — which reads as a backend fault rather than a form
|
||||
// that refused to submit. Surface the field errors instead.
|
||||
// Field errors only. `[role="alert"]` also matches Mantine's `<Alert>`, and
|
||||
// the profile page renders an informational seafarer banner as one — which
|
||||
// got reported as "validation errors: Seafarer registration asks for these
|
||||
// details…", pointing at a form that was in fact filled in correctly.
|
||||
const messages = await page
|
||||
.locator('.mantine-InputWrapper-error, [role="alert"]')
|
||||
.locator('.mantine-InputWrapper-error')
|
||||
.allTextContents();
|
||||
throw new Error(
|
||||
messages.length
|
||||
? `Save did not submit — validation errors: ${messages.join('; ')}`
|
||||
: 'Save produced no request and reported no validation error.',
|
||||
: // No field error either, so the form was valid and something else
|
||||
// refused: `onSaveProfile` early-returns when the profile name does
|
||||
// not match the account name, and notifies rather than marking a
|
||||
// field.
|
||||
'Save produced no request and reported no field error — check for a rejected notification (e.g. the profile/account name match).',
|
||||
{ cause },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/** Signs up, declares seafarer operations, and fills the gating profile. */
|
||||
/**
|
||||
* Signs up, declares seafarer operations, and fills the profile.
|
||||
*
|
||||
* Declaring seafarer now lands on the registration wizard, not `/profile` — the
|
||||
* wizard collects the identity itself. The profile is still filled here because
|
||||
* these tests are about the registration workflow, and a profile with a name and
|
||||
* an address is what the approval's completion effect writes onto; `/profile` is
|
||||
* navigated to directly rather than waited for as a redirect.
|
||||
*/
|
||||
async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
|
||||
const offset = await signUp(page, applicant);
|
||||
await verifyOtpIfPrompted(page, offset);
|
||||
@@ -167,10 +195,12 @@ async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
|
||||
.first()
|
||||
.check();
|
||||
await page.getByRole('button', { name: /save operations/i }).click();
|
||||
// A seafarer is taken to `/profile`, not the dashboard: registration is
|
||||
// built from the profile, and a fresh signup holds none of it yet.
|
||||
await expect(page).toHaveURL(/\/licensing\/SEAFARER_REGISTRATION\/apply/, {
|
||||
timeout: 30_000,
|
||||
});
|
||||
await page.goto('/profile');
|
||||
await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
await completeProfile(page);
|
||||
await completeProfile(page, applicant);
|
||||
}
|
||||
|
||||
test.describe('seafarer registration', () => {
|
||||
@@ -229,7 +259,7 @@ test.describe('seafarer registration', () => {
|
||||
const number = await waitForApplication(applicant.email);
|
||||
const id = idOf(number);
|
||||
|
||||
await submit(id);
|
||||
await submit(id, applicant);
|
||||
await runWorkflow(id, [{ path: 'claim' }]);
|
||||
expect(statusOf(number)).toBe('UNDER_REVIEW');
|
||||
|
||||
@@ -253,19 +283,36 @@ test.describe('seafarer registration', () => {
|
||||
const number = await waitForApplication(applicant.email);
|
||||
const id = idOf(number);
|
||||
|
||||
await submit(id);
|
||||
await submit(id, applicant);
|
||||
await runWorkflow(id, [
|
||||
{ path: 'claim' },
|
||||
{
|
||||
path: 'request-adjustment',
|
||||
data: { remarks: [{ message: 'Medical certificate is illegible.' }] },
|
||||
// `RequestAdjustmentDto` takes `items`, each naming what to fix and
|
||||
// where — a bare `remarks: [{ message }]` is refused with "items should
|
||||
// not be empty", which reads as an empty request rather than a wrongly
|
||||
// shaped one.
|
||||
data: {
|
||||
items: [
|
||||
{
|
||||
targetType: 'FORM_SECTION',
|
||||
targetKey: 'medicalCertificate',
|
||||
remark: 'Medical certificate is illegible.',
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
]);
|
||||
expect(statusOf(number)).toBe('RESUBMIT_REQUIRED');
|
||||
|
||||
// Every flagged item has to be ticked off first: `resubmit` refuses while
|
||||
// any remark is open (`unresolved_remarks`), which is what stops an
|
||||
// applicant returning the same form untouched.
|
||||
await resolveOpenRemarks(id, openRemarkIds(number), applicant);
|
||||
|
||||
// A resubmission returns to review directly — a registration has no
|
||||
// earlier stage to fall back to.
|
||||
await runWorkflow(id, [{ path: 'resubmit' }]);
|
||||
await runWorkflow(id, [{ path: 'resubmit' }], applicant);
|
||||
expect(statusOf(number)).toBe('UNDER_REVIEW');
|
||||
});
|
||||
|
||||
@@ -275,7 +322,7 @@ test.describe('seafarer registration', () => {
|
||||
const number = await waitForApplication(applicant.email);
|
||||
const id = idOf(number);
|
||||
|
||||
await submit(id);
|
||||
await submit(id, applicant);
|
||||
await runWorkflow(id, [
|
||||
{ path: 'claim' },
|
||||
{ path: 'hold', data: { reason: 'Awaiting confirmation from the clinic.' } },
|
||||
@@ -293,7 +340,7 @@ test.describe('seafarer registration', () => {
|
||||
const number = await waitForApplication(applicant.email);
|
||||
const id = idOf(number);
|
||||
|
||||
await submit(id);
|
||||
await submit(id, applicant);
|
||||
await runWorkflow(id, [
|
||||
{ path: 'claim' },
|
||||
{ path: 'reject', data: { reason: 'Basic training evidence incomplete.' } },
|
||||
@@ -314,7 +361,7 @@ test.describe('seafarer registration', () => {
|
||||
const number = await waitForApplication(applicant.email);
|
||||
const id = idOf(number);
|
||||
|
||||
await submit(id);
|
||||
await submit(id, applicant);
|
||||
await approveRegistration(id);
|
||||
|
||||
expect(statusOf(number)).toBe('COMPLETED');
|
||||
@@ -349,7 +396,7 @@ test.describe('seafarer registration', () => {
|
||||
const number = await waitForApplication(applicant.email);
|
||||
const id = idOf(number);
|
||||
|
||||
await submit(id);
|
||||
await submit(id, applicant);
|
||||
await approveRegistration(id);
|
||||
const first = seafarerNumberOf(applicant.email);
|
||||
|
||||
@@ -367,7 +414,7 @@ test.describe('seafarer registration', () => {
|
||||
await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
const number = await waitForApplication(applicant.email);
|
||||
|
||||
await submit(idOf(number));
|
||||
await submit(idOf(number), applicant);
|
||||
await approveRegistration(idOf(number));
|
||||
|
||||
// The number is permanent and the service is not renewable, so the portal
|
||||
@@ -438,6 +485,17 @@ function seafarerNumberOf(email: string): string | null {
|
||||
`);
|
||||
}
|
||||
|
||||
/** Ids of the remarks still open on the current adjustment round. */
|
||||
function openRemarkIds(applicationNumber: string): string[] {
|
||||
return sql(`
|
||||
SELECT r.id FROM application_remarks r
|
||||
JOIN license_applications a ON a.id = r.application_id
|
||||
WHERE a.application_number = '${applicationNumber}'
|
||||
AND r.is_resolved = false
|
||||
AND r.round_number = a.adjustment_round
|
||||
`).map((row) => row[0]);
|
||||
}
|
||||
|
||||
function childrenOf(applicationNumber: string): string[][] {
|
||||
return sql(`
|
||||
SELECT lt.key, a.status, a.origin
|
||||
@@ -452,12 +510,98 @@ function childrenOf(applicationNumber: string): string[][] {
|
||||
}
|
||||
|
||||
/**
|
||||
* Submits the draft.
|
||||
* Fills the draft's answers and evidence directly, so it can be submitted.
|
||||
*
|
||||
* The wizard's own sections are not filled in: what these tests are about is
|
||||
* the workflow and its approval effects, and a form-validation failure would
|
||||
* fail them for the wrong reason. Field-level rules belong in their own spec.
|
||||
* These tests are about the workflow and its approval effects, not the wizard's
|
||||
* fields — but `submit` validates the whole form and every required document, so
|
||||
* an unfilled draft cannot reach the workflow at all. Driving six wizard steps
|
||||
* and four uploads in each test would make them slow tests of the form instead.
|
||||
*
|
||||
* So the answers go in as one `form_data` write and the evidence as attachment
|
||||
* rows. Deliberately not through MinIO: `getSuppliedDocumentKeys` joins
|
||||
* attachments to their files and counts document keys, and nothing at submission
|
||||
* reads a file's bytes — a row with a storage key is exactly as complete as an
|
||||
* upload, without requiring object storage to be reachable.
|
||||
*
|
||||
* Values mirror the seeded schema (`seafarer-registration.seed-data.ts`); a
|
||||
* required field added there fails these with `application_incomplete`, naming
|
||||
* the field.
|
||||
*/
|
||||
async function submit(applicationId: string): Promise<void> {
|
||||
await runWorkflow(applicationId, [{ path: 'submit' }]);
|
||||
function fillForSubmission(applicationId: string): void {
|
||||
const locationId = sqlValue(`
|
||||
SELECT l.id FROM iam.locations l
|
||||
JOIN iam.location_types lt ON lt.id = l.location_type_id
|
||||
WHERE lt.code = 'SUBCITY' LIMIT 1
|
||||
`);
|
||||
if (!locationId) {
|
||||
throw new Error('No SUBCITY location seeded — run the location seed.');
|
||||
}
|
||||
|
||||
const formData = JSON.stringify({
|
||||
profileSummary: {
|
||||
firstName: 'Dawit',
|
||||
middleName: 'Bekele',
|
||||
lastName: 'Tesfaye',
|
||||
gender: 'MALE',
|
||||
dateOfBirth: '1995-04-12',
|
||||
maritalStatus: 'SINGLE',
|
||||
nationality: 'Ethiopian',
|
||||
nationalIdNumber: 'FYD1234567890',
|
||||
},
|
||||
identity: { placeOfBirth: 'Addis Ababa', department: 'DECK' },
|
||||
address: { locationId, permanentAddress: 'Bole, Addis Ababa' },
|
||||
emergencyContact: {
|
||||
name: 'Almaz Tesfaye',
|
||||
relationship: 'Sister',
|
||||
phoneNumber: '+251911222333',
|
||||
},
|
||||
physicalCharacteristics: {
|
||||
hairColor: 'BLACK',
|
||||
eyeColor: 'BROWN',
|
||||
heightCm: 172,
|
||||
weightKg: 68,
|
||||
bloodType: 'O_POSITIVE',
|
||||
},
|
||||
medicalCertificate: {
|
||||
certificateNumber: 'MED-2026-001',
|
||||
issuerName: 'Addis Marine Clinic',
|
||||
issueDate: '2026-01-15',
|
||||
},
|
||||
declaration: { accepted: true },
|
||||
}).replace(/'/g, "''");
|
||||
|
||||
const documentKeys = [
|
||||
'photo',
|
||||
'nationalId',
|
||||
'medical_certificate',
|
||||
'basic_training_evidence',
|
||||
];
|
||||
|
||||
sql(`
|
||||
UPDATE license_applications
|
||||
SET form_data = '${formData}'::jsonb
|
||||
WHERE id = '${applicationId}';
|
||||
|
||||
WITH inserted AS (
|
||||
INSERT INTO attachments (owner_type, owner_id, document_key, valid_from, valid_to)
|
||||
SELECT 'APPLICATION', '${applicationId}', key, CURRENT_DATE, CURRENT_DATE + 365
|
||||
FROM unnest(ARRAY[${documentKeys.map((d) => `'${d}'`).join(',')}]) AS key
|
||||
RETURNING id
|
||||
)
|
||||
INSERT INTO attachment_files
|
||||
(attachment_id, original_name, mime_type, size_bytes, storage_key)
|
||||
SELECT id, 'evidence.pdf', 'application/pdf', 1024, 'e2e/' || id || '.pdf'
|
||||
FROM inserted;
|
||||
`);
|
||||
}
|
||||
|
||||
/** Fills what submission requires, then submits as the applicant. */
|
||||
async function submit(
|
||||
applicationId: string,
|
||||
applicant: Applicant,
|
||||
): Promise<void> {
|
||||
fillForSubmission(applicationId);
|
||||
// As the applicant: `submit` is ownership-guarded, so the officer's token —
|
||||
// which every other step here uses — is refused with `not_application_owner`.
|
||||
await runWorkflow(applicationId, [{ path: 'submit' }], applicant);
|
||||
}
|
||||
|
||||
@@ -17,7 +17,11 @@ import { E2E } from '../../playwright.config';
|
||||
|
||||
const OTP_PATTERN = /is (\d{4,8})\./g;
|
||||
|
||||
/** Byte offset to read from later. Zero when the log does not exist yet. */
|
||||
/**
|
||||
* Byte offset to read from later. Zero when the log does not exist yet.
|
||||
*
|
||||
* Bytes, and read back as bytes — see `otpSince`.
|
||||
*/
|
||||
export function logOffset(): number {
|
||||
try {
|
||||
return statSync(E2E.apiLog).size;
|
||||
@@ -48,7 +52,14 @@ export async function waitForOtp(
|
||||
function otpSince(offset: number): string | null {
|
||||
let text: string;
|
||||
try {
|
||||
text = readFileSync(E2E.apiLog, 'utf8').slice(offset);
|
||||
// Sliced as a Buffer, then decoded — not `readFileSync(…, 'utf8').slice()`.
|
||||
// `logOffset()` is a byte count from `statSync`, while slicing a string
|
||||
// counts UTF-16 code units, and the API logs Amharic notification bodies:
|
||||
// every multi-byte character made the offset overshoot, so a code written
|
||||
// just after it was skipped and the wait timed out. The drift grows with
|
||||
// the log, which is why this failed intermittently and more often later in
|
||||
// a run.
|
||||
text = readFileSync(E2E.apiLog).subarray(offset).toString('utf8');
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -14,18 +14,35 @@ export interface Applicant {
|
||||
username: string;
|
||||
phoneNumber: string;
|
||||
password: string;
|
||||
/** The account name, as typed at signup. Always `${firstName} ${middleName} ${lastName}`. */
|
||||
name: string;
|
||||
firstName: string;
|
||||
middleName: string;
|
||||
lastName: string;
|
||||
}
|
||||
|
||||
export function newApplicant(label: string): Applicant {
|
||||
const stamp = `${Date.now()}${Math.floor(Math.random() * 1000)}`;
|
||||
// The profile's Maritime tab refuses to save unless first/middle/last join to
|
||||
// exactly the account name (`ProfilePage.onSaveProfile`) — and that refusal is
|
||||
// a silent early return, no request. So the parts are the source of truth here
|
||||
// and the account name is composed from them, rather than the two being
|
||||
// written independently and hoped to agree.
|
||||
//
|
||||
// Each part is at least three characters, which `profileSchema` requires.
|
||||
const firstName = 'Dawit';
|
||||
const middleName = 'Bekele';
|
||||
const lastName = `Tesfaye${stamp.slice(-4)}`;
|
||||
return {
|
||||
email: `e2e.${label}.${stamp}@example.test`,
|
||||
username: `e2e${label}${stamp}`.slice(0, 28),
|
||||
// Ethiopian mobile format; the last digits vary so two runs never collide.
|
||||
phoneNumber: `+2519${stamp.slice(-8)}`,
|
||||
password: 'E2ePassw0rd!',
|
||||
name: `E2E ${label} ${stamp.slice(-4)}`,
|
||||
name: `${firstName} ${middleName} ${lastName}`,
|
||||
firstName,
|
||||
middleName,
|
||||
lastName,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,14 +18,47 @@ import { OFFICER } from './officer';
|
||||
/** Routes served by the applicant-facing controller rather than the review one. */
|
||||
const APPLICANT_STEPS = new Set(['submit', 'resubmit']);
|
||||
|
||||
async function officerContext(): Promise<APIRequestContext> {
|
||||
const context = await request.newContext({ baseURL: E2E.apiUrl });
|
||||
const response = await context.post('/auth/login', {
|
||||
data: { email: OFFICER.email, password: OFFICER.password },
|
||||
/**
|
||||
* Resolves every open remark on an application, as the applicant.
|
||||
*
|
||||
* `resubmit` refuses while any remain (`unresolved_remarks`) — the applicant is
|
||||
* expected to tick off each correction as they make it, which the portal does
|
||||
* per section. A test that only wants the round-trip still has to do it.
|
||||
*/
|
||||
export async function resolveOpenRemarks(
|
||||
applicationId: string,
|
||||
remarkIds: string[],
|
||||
applicant: { email: string; password: string },
|
||||
): Promise<void> {
|
||||
await runWorkflow(
|
||||
applicationId,
|
||||
remarkIds.map((remarkId) => ({
|
||||
path: `remarks/${remarkId}/resolve`,
|
||||
method: 'patch' as const,
|
||||
})),
|
||||
applicant,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* An authenticated API context for one account.
|
||||
*
|
||||
* Paths built against it are relative on purpose. `E2E.apiUrl` carries the
|
||||
* `/api` prefix, and a leading slash resolves against the *origin* —
|
||||
* `/auth/login` against `http://host/api` requests `http://host/auth/login`,
|
||||
* which 404s. Every path in this file is therefore written without one.
|
||||
*/
|
||||
async function contextFor(
|
||||
who: string,
|
||||
credentials: { email: string; password: string },
|
||||
): Promise<APIRequestContext> {
|
||||
const context = await request.newContext({ baseURL: `${E2E.apiUrl}/` });
|
||||
const response = await context.post('auth/login', {
|
||||
data: { email: credentials.email, password: credentials.password },
|
||||
});
|
||||
if (!response.ok()) {
|
||||
throw new Error(
|
||||
`Officer login failed (${response.status()}): ${await response.text()}`,
|
||||
`${who} login failed (${response.status()}): ${await response.text()}`,
|
||||
);
|
||||
}
|
||||
const body = await response.json();
|
||||
@@ -36,17 +69,23 @@ async function officerContext(): Promise<APIRequestContext> {
|
||||
|
||||
await context.dispose();
|
||||
return request.newContext({
|
||||
baseURL: E2E.apiUrl,
|
||||
baseURL: `${E2E.apiUrl}/`,
|
||||
extraHTTPHeaders: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
}
|
||||
|
||||
function officerContext(): Promise<APIRequestContext> {
|
||||
return contextFor('Officer', OFFICER);
|
||||
}
|
||||
|
||||
export interface WorkflowStep {
|
||||
/** Route under the review controller, e.g. `claim`, `final-approve`. */
|
||||
path: string;
|
||||
data?: Record<string, unknown>;
|
||||
/** Set when a step is expected to be refused — the refusal is the assertion. */
|
||||
expectFailure?: boolean;
|
||||
/** POST unless stated; the applicant's remark-resolve route is a PATCH. */
|
||||
method?: 'post' | 'patch';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -59,8 +98,29 @@ export interface WorkflowStep {
|
||||
export async function runWorkflow(
|
||||
applicationId: string,
|
||||
steps: WorkflowStep[],
|
||||
/**
|
||||
* The owner, required only when a step is applicant-side. `submit` and
|
||||
* `resubmit` are guarded by ownership, not permission — the officer holds
|
||||
* every permission but is not the applicant, so running them on the officer's
|
||||
* token is refused with `not_application_owner`.
|
||||
*/
|
||||
applicant?: { email: string; password: string },
|
||||
): Promise<number[]> {
|
||||
const api = await officerContext();
|
||||
const officer = await officerContext();
|
||||
const needsApplicant = steps.some(
|
||||
(step) => APPLICANT_STEPS.has(step.path) || step.path.startsWith('remarks/'),
|
||||
);
|
||||
if (needsApplicant && !applicant) {
|
||||
throw new Error(
|
||||
`Steps [${steps
|
||||
.filter((s) => APPLICANT_STEPS.has(s.path) || s.path.startsWith('remarks/'))
|
||||
.map((s) => s.path)
|
||||
.join(', ')}] act as the applicant — pass their credentials to runWorkflow.`,
|
||||
);
|
||||
}
|
||||
const owner = needsApplicant && applicant
|
||||
? await contextFor('Applicant', applicant)
|
||||
: null;
|
||||
const codes: number[] = [];
|
||||
|
||||
try {
|
||||
@@ -68,13 +128,17 @@ export async function runWorkflow(
|
||||
// Applicant-side actions (`submit`, `resubmit`) live on the
|
||||
// applications controller; everything an officer does is on the review
|
||||
// controller. Routing by step keeps callers from having to know.
|
||||
const base = APPLICANT_STEPS.has(step.path)
|
||||
const isApplicantStep =
|
||||
APPLICANT_STEPS.has(step.path) || step.path.startsWith('remarks/');
|
||||
const base = isApplicantStep
|
||||
? 'license-applications'
|
||||
: 'license-application-review';
|
||||
const response = await api.post(
|
||||
`/${base}/${applicationId}/${step.path}`,
|
||||
{ data: step.data ?? {} },
|
||||
);
|
||||
const api = isApplicantStep && owner ? owner : officer;
|
||||
const url = `${base}/${applicationId}/${step.path}`;
|
||||
const response =
|
||||
step.method === 'patch'
|
||||
? await api.patch(url, { data: step.data ?? {} })
|
||||
: await api.post(url, { data: step.data ?? {} });
|
||||
codes.push(response.status());
|
||||
|
||||
if (!step.expectFailure && !response.ok()) {
|
||||
@@ -84,7 +148,8 @@ export async function runWorkflow(
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
await api.dispose();
|
||||
await officer.dispose();
|
||||
await owner?.dispose();
|
||||
}
|
||||
|
||||
return codes;
|
||||
|
||||
@@ -1,10 +1,23 @@
|
||||
import { Divider, Paper, Stack, Table, Text, Title } from "@mantine/core";
|
||||
import {
|
||||
Badge,
|
||||
Divider,
|
||||
Group,
|
||||
Grid,
|
||||
Paper,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
conditionHolds,
|
||||
displayFieldValue,
|
||||
type Attachment,
|
||||
type FormFieldConfig,
|
||||
type FormSectionConfig,
|
||||
type LicenseTypeRequirements,
|
||||
} from "@ema-platform/api";
|
||||
import { useDateDisplayer } from "@ema-platform/shared";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { DocumentSlots } from "./DocumentSlots";
|
||||
|
||||
interface Props {
|
||||
@@ -33,42 +46,83 @@ export function ApplicationSummary({
|
||||
attachments,
|
||||
applicationId,
|
||||
}: Props) {
|
||||
return (
|
||||
<Paper withBorder p="lg" radius="md">
|
||||
<Stack gap="lg">
|
||||
{sections.map((section) => (
|
||||
<div key={section.key}>
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mb="sm">
|
||||
{localized(section.title)}
|
||||
</Text>
|
||||
<Table withTableBorder withColumnBorders>
|
||||
<Table.Tbody>
|
||||
{(section.fields ?? [])
|
||||
.filter((f) => conditionHolds(f.showWhen, formData))
|
||||
.map((field) => (
|
||||
<Table.Tr key={field.key}>
|
||||
<Table.Td w="45%">
|
||||
<Text size="xs" c="dimmed">
|
||||
{localized(field.label)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{String(formData[section.key]?.[field.key] ?? "—")}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</div>
|
||||
))}
|
||||
const showDate = useDateDisplayer();
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
<div>
|
||||
<Divider mb="md" />
|
||||
// Shared with the officer's review screen, so the applicant and the reviewer
|
||||
// never read the same answer two different ways.
|
||||
const display = (field: FormFieldConfig, raw: unknown) =>
|
||||
displayFieldValue(field, raw, {
|
||||
language: i18n.language,
|
||||
showDate,
|
||||
currency: config.feeCurrency,
|
||||
}) || "—";
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
{sections.map((section) => {
|
||||
const fields = (section.fields ?? []).filter((f) =>
|
||||
conditionHolds(f.showWhen, formData),
|
||||
);
|
||||
if (fields.length === 0) return null;
|
||||
|
||||
return (
|
||||
<Paper withBorder p="lg" radius="md" key={section.key}>
|
||||
<Group justify="space-between" align="center" mb="xs">
|
||||
<Title order={5}>{localized(section.title)}</Title>
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{fields.length} {fields.length === 1 ? "detail" : "details"}
|
||||
</Badge>
|
||||
</Group>
|
||||
{localized(section.description) && (
|
||||
<Text fz="xs" c="dimmed" mb="sm">
|
||||
{localized(section.description)}
|
||||
</Text>
|
||||
)}
|
||||
<Divider mb="md" />
|
||||
|
||||
{/* Label above value in two columns — a definition list reads far
|
||||
better than a bordered grid when most answers are short. */}
|
||||
<Grid gutter="md">
|
||||
{fields.map((field) => {
|
||||
const value = display(
|
||||
field,
|
||||
formData[section.key]?.[field.key],
|
||||
);
|
||||
const answered = value !== "—";
|
||||
return (
|
||||
<Grid.Col
|
||||
span={{
|
||||
base: 12,
|
||||
sm: field.type === "TEXTAREA" ? 12 : 6,
|
||||
}}
|
||||
key={field.key}
|
||||
>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{localized(field.label)}
|
||||
</Text>
|
||||
<Text
|
||||
fz="sm"
|
||||
mt={2}
|
||||
c={answered ? undefined : "dimmed"}
|
||||
fs={answered ? undefined : "italic"}
|
||||
style={{ wordBreak: "break-word" }}
|
||||
>
|
||||
{answered ? value : "Not provided"}
|
||||
</Text>
|
||||
</Grid.Col>
|
||||
);
|
||||
})}
|
||||
</Grid>
|
||||
</Paper>
|
||||
);
|
||||
})}
|
||||
|
||||
<Paper withBorder p="lg" radius="md">
|
||||
<Title order={5} mb="sm">
|
||||
Documents
|
||||
</Title>
|
||||
<Divider mb="md" />
|
||||
<DocumentSlots
|
||||
requirements={config.documentRequirements}
|
||||
attachments={attachments}
|
||||
@@ -81,8 +135,7 @@ export function ApplicationSummary({
|
||||
// requires the callback.
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</Stack>
|
||||
</Paper>
|
||||
</Paper>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
} from "@mantine/core";
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconPencil,
|
||||
IconCheck,
|
||||
IconInfoCircle,
|
||||
IconPlus,
|
||||
@@ -309,7 +310,16 @@ export function LicenseApplicationPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const readOnly = !["DRAFT", "RESUBMIT_REQUIRED"].includes(application.status);
|
||||
// A submitted application stays editable until an officer takes it, which
|
||||
// mirrors the server's own rule (`assertEditable`): an applicant who spots
|
||||
// their own mistake can fix it instead of waiting to be sent back for it.
|
||||
// Once claimed it locks — the officer reading it must not have the form move
|
||||
// underneath them.
|
||||
const editableWhileSubmitted =
|
||||
application.status === "SUBMITTED" && !application.assignedOfficerId;
|
||||
const readOnly =
|
||||
!["DRAFT", "RESUBMIT_REQUIRED"].includes(application.status) &&
|
||||
!editableWhileSubmitted;
|
||||
// A DRAFT has nothing worth summarising yet, so it always opens straight
|
||||
// into the wizard; every later status (including RESUBMIT_REQUIRED) opens
|
||||
// to the summary first.
|
||||
@@ -397,6 +407,7 @@ export function LicenseApplicationPage() {
|
||||
title: "Resubmitted",
|
||||
message: "Your corrections were sent back to the reviewing officer.",
|
||||
});
|
||||
navigate("/licensing/applications");
|
||||
} else {
|
||||
await submitApplication(appId as string).unwrap();
|
||||
notifications.show({
|
||||
@@ -404,8 +415,12 @@ export function LicenseApplicationPage() {
|
||||
title: "Application submitted",
|
||||
message: "You will be notified as it progresses.",
|
||||
});
|
||||
// Stays on the application rather than dropping the applicant into a
|
||||
// list: they have just filled a long form and the useful next screen is
|
||||
// what they submitted, with its status and — while it is still
|
||||
// unclaimed — the means to correct it.
|
||||
setViewingSummary(true);
|
||||
}
|
||||
navigate("/licensing/applications");
|
||||
} catch (err) {
|
||||
const found = extractValidationIssues(err);
|
||||
setIssues(found);
|
||||
@@ -571,10 +586,11 @@ export function LicenseApplicationPage() {
|
||||
<Text size="sm" c="dimmed">
|
||||
Fee: {config.fee ?? "—"} {config.feeCurrency}
|
||||
</Text>
|
||||
{showSummary && isAdjusting && (
|
||||
{showSummary && !readOnly && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
leftSection={<IconPencil size={14} />}
|
||||
onClick={() => setViewingSummary(false)}
|
||||
>
|
||||
Edit details
|
||||
@@ -603,6 +619,19 @@ export function LicenseApplicationPage() {
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{showSummary && editableWhileSubmitted && (
|
||||
<Alert
|
||||
color="blue"
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
title="Submitted — still correctable"
|
||||
mb="md"
|
||||
>
|
||||
Your application is in the queue. You can still change any detail
|
||||
until a reviewing officer picks it up; after that, corrections happen
|
||||
only if they ask for them.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{issues.length > 0 && (
|
||||
<Alert
|
||||
color="red"
|
||||
|
||||
@@ -30,14 +30,22 @@ import {
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
|
||||
interface ApplicationSummary {
|
||||
id: string;
|
||||
applicationId: string;
|
||||
status: string;
|
||||
submittedAt: string;
|
||||
}
|
||||
|
||||
/** The seaman-book page's whole state, as `/seaman-book/my` returns it. */
|
||||
interface SeamanBookOverview {
|
||||
application: {
|
||||
id: string;
|
||||
applicationId: string;
|
||||
status: string;
|
||||
submittedAt: string;
|
||||
} | null;
|
||||
application: ApplicationSummary | null;
|
||||
/**
|
||||
* The Basic Training Certificate opened alongside the book by an approved
|
||||
* seafarer registration — a separate application, separately numbered and
|
||||
* separately billed, so it is shown as its own card rather than merged in.
|
||||
*/
|
||||
btcApplication: ApplicationSummary | null;
|
||||
book: {
|
||||
id: string;
|
||||
issuedDate: string;
|
||||
@@ -122,6 +130,76 @@ function EligibilityItem({ label, ok }: { label: string; ok: boolean }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* One in-flight application: its number, where it stands, and the stages left.
|
||||
*
|
||||
* Shared by the Seaman Book and the BTC because an approved registration opens
|
||||
* both and they move independently — the book waits on a TRB inspection while
|
||||
* the BTC goes straight to payment, so a single merged card would have to lie
|
||||
* about one of them.
|
||||
*/
|
||||
function ApplicationCard({
|
||||
title,
|
||||
application,
|
||||
children,
|
||||
}: {
|
||||
title: string;
|
||||
application: ApplicationSummary;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const activeStep = stageIndexFor(application.status);
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
|
||||
<Group gap="xs">
|
||||
<ThemeIcon variant="light" color="blue" size={36} radius="md">
|
||||
<IconBook2 size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700}>
|
||||
{title} — {application.id}
|
||||
</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{/* An approved seafarer registration opens this application as a
|
||||
draft, so it can be here before anyone has filed it. Calling
|
||||
that "Submitted" would misreport where it stands. */}
|
||||
{application.status === 'DRAFT' ? 'Opened' : 'Submitted'}{' '}
|
||||
{formatDate(application.submittedAt)}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge
|
||||
color={STATUS_COLOR[application.status] ?? 'gray'}
|
||||
variant="light"
|
||||
size="lg"
|
||||
>
|
||||
{application.status.replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Stepper active={activeStep} size="sm" color="teal">
|
||||
{STAGES.map((stage, i) => (
|
||||
<Stepper.Step
|
||||
key={stage.label}
|
||||
label={stage.label}
|
||||
description={i <= activeStep ? 'Done' : 'Pending'}
|
||||
icon={
|
||||
i <= activeStep ? (
|
||||
<IconCircleCheck size={16} />
|
||||
) : (
|
||||
<IconClock size={16} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stepper>
|
||||
|
||||
{children}
|
||||
</Paper>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Component
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -134,6 +212,7 @@ export function SeamanBookPage() {
|
||||
});
|
||||
|
||||
const application = data?.application ?? null;
|
||||
const btcApplication = data?.btcApplication ?? null;
|
||||
const eligibility = data?.eligibility;
|
||||
const bstItems = eligibility?.bstModules ?? [];
|
||||
const bstDone = bstItems.filter((b) => b.done).length;
|
||||
@@ -141,9 +220,10 @@ export function SeamanBookPage() {
|
||||
// The server decides: the same checklist gates the submission, so a screen
|
||||
// that judged eligibility for itself could offer a button the API refuses.
|
||||
const isEligible = data?.eligible ?? false;
|
||||
const submitted = Boolean(application);
|
||||
|
||||
const activeStep = stageIndexFor(application?.status);
|
||||
// Either service already being in flight means there is nothing to apply for
|
||||
// here — an approved registration opens both, so offering "Apply" alongside
|
||||
// them would invite a duplicate the server refuses anyway.
|
||||
const submitted = Boolean(application || btcApplication);
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
@@ -156,59 +236,22 @@ export function SeamanBookPage() {
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
{/* Active application status */}
|
||||
{/* Active application status — one card per service in flight. */}
|
||||
{application && (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
|
||||
<Group gap="xs">
|
||||
<ThemeIcon variant="light" color="blue" size={36} radius="md">
|
||||
<IconBook2 size={18} />
|
||||
</ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700}>Application {application.id}</Text>
|
||||
<Text fz="xs" c="dimmed">
|
||||
{/* An approved seafarer registration opens this application
|
||||
as a draft, so it can be here before anyone has filed it.
|
||||
Calling that "Submitted" would misreport where it stands. */}
|
||||
{application.status === 'DRAFT' ? 'Opened' : 'Submitted'}{' '}
|
||||
{formatDate(application.submittedAt)}
|
||||
</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge
|
||||
color={STATUS_COLOR[application.status] ?? 'gray'}
|
||||
variant="light"
|
||||
size="lg"
|
||||
>
|
||||
{application.status.replaceAll('_', ' ')}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
{/* Progress stepper */}
|
||||
<Stepper active={activeStep} size="sm" color="teal">
|
||||
{STAGES.map((stage, i) => (
|
||||
<Stepper.Step
|
||||
key={stage.label}
|
||||
label={stage.label}
|
||||
description={i <= activeStep ? 'Done' : 'Pending'}
|
||||
icon={
|
||||
i <= activeStep ? (
|
||||
<IconCircleCheck size={16} />
|
||||
) : (
|
||||
<IconClock size={16} />
|
||||
)
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</Stepper>
|
||||
|
||||
<ApplicationCard title="Seaman Book" application={application}>
|
||||
{data?.book && (
|
||||
<Alert variant="light" color="teal" icon={<IconPrinter size={17} />} mt="md">
|
||||
Your Seaman Book <strong>{data.book.id}</strong> has been issued.
|
||||
Please visit the EMA office to collect it, bringing your National ID.
|
||||
</Alert>
|
||||
)}
|
||||
</Paper>
|
||||
</ApplicationCard>
|
||||
)}
|
||||
{btcApplication && (
|
||||
<ApplicationCard
|
||||
title="Basic Training Certificate"
|
||||
application={btcApplication}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* No active application — eligibility + apply */}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { resolveTokenFromStorage } from '../../session';
|
||||
import type {
|
||||
Bilingual,
|
||||
FormFieldConfig,
|
||||
FormSectionConfig,
|
||||
LicenseApplication,
|
||||
LicenseStatus,
|
||||
@@ -178,6 +179,52 @@ export function applicantOrCompanyName(app: LicenseApplication): string | undefi
|
||||
return typeof applicantName === 'string' && applicantName ? applicantName : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* One form answer as a person should read it back.
|
||||
*
|
||||
* The stored value is not it: a SELECT holds the option's `value`, so an
|
||||
* unformatted view shows reviewers and applicants `AB_POSITIVE` and `DECK` —
|
||||
* the codes the database wants, not the words that were chosen. Shared by the
|
||||
* applicant's summary and the officer's review so the two never describe the
|
||||
* same application differently.
|
||||
*
|
||||
* `showDate` is passed in rather than imported: date display is a hook
|
||||
* (`useDateDisplayer`, Ethiopian-calendar aware) and this is a plain function.
|
||||
*/
|
||||
export function displayFieldValue(
|
||||
field: Pick<FormFieldConfig, 'type' | 'options'>,
|
||||
raw: unknown,
|
||||
opts: {
|
||||
language?: string;
|
||||
showDate?: (value: string) => string;
|
||||
currency?: string;
|
||||
} = {},
|
||||
): string {
|
||||
if (raw === null || raw === undefined || raw === '') return '';
|
||||
const { language = 'en', showDate, currency } = opts;
|
||||
|
||||
switch (field.type) {
|
||||
case 'BOOLEAN':
|
||||
return raw ? 'Yes' : 'No';
|
||||
case 'DATE':
|
||||
return showDate?.(String(raw)) || String(raw);
|
||||
case 'SELECT': {
|
||||
const option = field.options?.find((o) => o.value === raw);
|
||||
// Falls back to the stored value rather than blanking: an option removed
|
||||
// from the config since this was filed still has to show what was chosen.
|
||||
return option ? localized(option.label, language) : String(raw);
|
||||
}
|
||||
case 'MONEY': {
|
||||
const amount = Number(raw);
|
||||
return Number.isFinite(amount)
|
||||
? `${amount.toLocaleString()} ${currency ?? ''}`.trim()
|
||||
: String(raw);
|
||||
}
|
||||
default:
|
||||
return String(raw);
|
||||
}
|
||||
}
|
||||
|
||||
/** Reads a bilingual value for the active language, falling back to English. */
|
||||
export function localized(value: Bilingual | undefined, language = 'en'): string {
|
||||
if (!value) return '';
|
||||
|
||||
@@ -335,8 +335,37 @@ export interface ApplicationRemark {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Who filed the application, from their profile.
|
||||
*
|
||||
* Served alongside the form because a person-centric service (seafarer
|
||||
* registration, a certificate) has no `companyName` to identify itself by — a
|
||||
* reviewer opening one otherwise sees only an application number and has to
|
||||
* infer the human from the answers.
|
||||
*/
|
||||
export interface ApplicationApplicant {
|
||||
profileId: string;
|
||||
firstName: string | null;
|
||||
middleName: string | null;
|
||||
lastName: string | null;
|
||||
gender: string | null;
|
||||
dob: string | null;
|
||||
pob: string | null;
|
||||
maritalStatus: string | null;
|
||||
seafarerNumber: string | null;
|
||||
seafarerStatus: string | null;
|
||||
seafarerDepartment: string | null;
|
||||
nationality: string | null;
|
||||
idType: string | null;
|
||||
idNumber: string | null;
|
||||
primaryPhoneNumber: string | null;
|
||||
email: string | null;
|
||||
}
|
||||
|
||||
export interface ApplicationDetail {
|
||||
application: LicenseApplication;
|
||||
/** Null when the applicant has no profile row (never expected in practice). */
|
||||
applicant: ApplicationApplicant | null;
|
||||
staff: ApplicationStaff[];
|
||||
attachments: Attachment[];
|
||||
history: StatusHistoryEntry[];
|
||||
|
||||
@@ -1,13 +1,4 @@
|
||||
{
|
||||
"status": "failed",
|
||||
"failedTests": [
|
||||
"98dcbc0c174eb3697418-75794b7db9eaf01c737f",
|
||||
"98dcbc0c174eb3697418-34fd1a52a2c14f879d3a",
|
||||
"98dcbc0c174eb3697418-bd195edac5a95d796827",
|
||||
"98dcbc0c174eb3697418-c505dae67d8cd7469ff3",
|
||||
"98dcbc0c174eb3697418-ba62eb9d11839aca30c0",
|
||||
"98dcbc0c174eb3697418-07ce101789b6b7b7985c",
|
||||
"98dcbc0c174eb3697418-1d2cbda982bd085da606",
|
||||
"98dcbc0c174eb3697418-46dd670046a70e730e93"
|
||||
]
|
||||
"status": "passed",
|
||||
"failedTests": []
|
||||
}
|
||||
@@ -1,348 +0,0 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: seafarer-registration.spec.ts >> seafarer registration >> opening the wizard creates the draft up front
|
||||
- Location: apps/e2e/src/seafarer-registration.spec.ts:206:7
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
||||
```
|
||||
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=f1e3]:
|
||||
- banner [ref=f1e4]:
|
||||
- generic [ref=f1e5]:
|
||||
- generic [ref=f1e6]:
|
||||
- button "Toggle navigation" [ref=f1e8] [cursor=pointer]
|
||||
- generic [ref=f1e10]:
|
||||
- generic [ref=f1e11]: Dashboard
|
||||
- generic [ref=f1e13]: Profile
|
||||
- generic [ref=f1e17]:
|
||||
- button "Language" [ref=f1e18] [cursor=pointer]
|
||||
- button "Toggle light / dark mode" [ref=f1e23] [cursor=pointer]
|
||||
- button "Notifications" [ref=f1e26] [cursor=pointer]:
|
||||
- generic [ref=f1e27]: "1"
|
||||
- button "ES" [ref=f1e32] [cursor=pointer]
|
||||
- navigation [ref=f1e34]:
|
||||
- generic [ref=f1e35]:
|
||||
- img "EMA" [ref=f1e36]
|
||||
- generic [ref=f1e37]:
|
||||
- paragraph [ref=f1e38]: EMA Portal
|
||||
- paragraph [ref=f1e39]: Ethiopian Maritime Authority
|
||||
- generic [ref=f1e43]:
|
||||
- generic [ref=f1e44]:
|
||||
- generic [ref=f1e45] [cursor=pointer]: Dashboard
|
||||
- generic [ref=f1e52] [cursor=pointer]:
|
||||
- generic [ref=f1e57]: Notifications
|
||||
- generic "1 pending" [ref=f1e59]: "1"
|
||||
- generic [ref=f1e61]:
|
||||
- button [expanded] [ref=f1e62] [cursor=pointer]:
|
||||
- paragraph [ref=f1e63]: Licensing
|
||||
- generic [ref=f1e66] [cursor=pointer]: My Applications
|
||||
- generic [ref=f1e73]:
|
||||
- button [expanded] [ref=f1e74] [cursor=pointer]:
|
||||
- paragraph [ref=f1e75]: Seafarer Services
|
||||
- generic [ref=f1e78] [cursor=pointer]: Seafarer Registration
|
||||
- generic [ref=f1e82] [cursor=pointer]: My Sea Records
|
||||
- generic [ref=f1e86] [cursor=pointer]: Seaman Book
|
||||
- generic [ref=f1e92] [cursor=pointer]: Basic Training Certificate
|
||||
- generic [ref=f1e98] [cursor=pointer]: Certificates
|
||||
- generic [ref=f1e104] [cursor=pointer]: Examinations
|
||||
- generic [ref=f1e108] [cursor=pointer]: Endorsements
|
||||
- generic [ref=f1e113]:
|
||||
- button [expanded] [ref=f1e114] [cursor=pointer]:
|
||||
- paragraph [ref=f1e115]: Account
|
||||
- generic [ref=f1e118] [cursor=pointer]: My Documents
|
||||
- generic [ref=f1e123] [cursor=pointer]: Profile
|
||||
- generic [ref=f1e130] [cursor=pointer]: Help & Support
|
||||
- button "Collapse" [ref=f1e139] [cursor=pointer]
|
||||
- main [ref=f1e143]:
|
||||
- generic [ref=f1e145]:
|
||||
- generic [ref=f1e147]:
|
||||
- heading "My Profile" [level=2] [ref=f1e148]
|
||||
- paragraph [ref=f1e149]: Manage your account details and preferences.
|
||||
- alert [ref=f1e150]:
|
||||
- generic [ref=f1e151]: Profile details are needed for seafarer registration.
|
||||
- generic [ref=f1e159]:
|
||||
- paragraph [ref=f1e161]: ES
|
||||
- generic [ref=f1e162]:
|
||||
- generic [ref=f1e163]:
|
||||
- heading "E2E seafarer 3450" [level=4] [ref=f1e164]
|
||||
- generic [ref=f1e165]: Unverified
|
||||
- paragraph [ref=f1e171]: e2e.seafarer.1787042323383450@example.test
|
||||
- generic [ref=f1e172]: e2eseafarer1787042323383450
|
||||
- generic "0% complete" [ref=f1e178]:
|
||||
- paragraph [ref=f1e183]: 0%
|
||||
- generic [ref=f1e184]:
|
||||
- tablist [ref=f1e185]:
|
||||
- tab "Personal" [ref=f1e186] [cursor=pointer]
|
||||
- tab "Profile" [selected] [ref=f1e193] [cursor=pointer]
|
||||
- tab "Address" [ref=f1e199] [cursor=pointer]
|
||||
- tab "Operations" [ref=f1e205] [cursor=pointer]
|
||||
- tab "Security" [ref=f1e212] [cursor=pointer]
|
||||
- tab "Preferences" [ref=f1e218] [cursor=pointer]
|
||||
- tabpanel "Profile" [ref=f1e224]:
|
||||
- generic [ref=f1e227]:
|
||||
- generic [ref=f1e228]:
|
||||
- heading "Maritime Profile" [level=5] [ref=f1e229]
|
||||
- paragraph [ref=f1e230]: Your professional maritime details
|
||||
- generic [ref=f1e231]:
|
||||
- generic [ref=f1e232]:
|
||||
- generic [ref=f1e233]: Profession *
|
||||
- textbox "Profession" [ref=f1e235]:
|
||||
- /placeholder: Select
|
||||
- text: Master Mariner
|
||||
- generic [ref=f1e236]:
|
||||
- generic [ref=f1e237]: First Name *
|
||||
- textbox "First Name" [ref=f1e239]:
|
||||
- /placeholder: Enter first name
|
||||
- text: Dawit
|
||||
- generic [ref=f1e240]:
|
||||
- generic [ref=f1e241]: Middle Name *
|
||||
- textbox "Middle Name" [ref=f1e243]:
|
||||
- /placeholder: Enter middle name
|
||||
- text: Bekele
|
||||
- generic [ref=f1e244]:
|
||||
- generic [ref=f1e245]: Last Name *
|
||||
- textbox "Last Name" [ref=f1e247]:
|
||||
- /placeholder: Enter last name
|
||||
- text: Tesfaye
|
||||
- generic [ref=f1e248]:
|
||||
- generic [ref=f1e249]: Gender *
|
||||
- textbox "Gender" [ref=f1e251] [cursor=pointer]:
|
||||
- /placeholder: Select
|
||||
- text: MALE
|
||||
- generic [ref=f1e252]:
|
||||
- generic [ref=f1e253]: Date of Birth *
|
||||
- generic [ref=f1e254]:
|
||||
- button "Switch calendar type" [ref=f1e256] [cursor=pointer]:
|
||||
- generic [ref=f1e257]: EN
|
||||
- textbox "Date of Birth" [ref=f1e259] [cursor=pointer]: Apr 12, 1995
|
||||
- button [ref=f1e261] [cursor=pointer]
|
||||
- generic [ref=f1e266]:
|
||||
- generic [ref=f1e267]: Place of Birth
|
||||
- textbox "Place of Birth" [ref=f1e269]:
|
||||
- /placeholder: City, Region
|
||||
- generic [ref=f1e270]:
|
||||
- generic [ref=f1e271]: Marital Status *
|
||||
- textbox "Marital Status" [ref=f1e273] [cursor=pointer]:
|
||||
- /placeholder: Select
|
||||
- text: SINGLE
|
||||
- button "Save Profile" [active] [ref=f1e275] [cursor=pointer]
|
||||
```
|
||||
|
||||
# Test source
|
||||
|
||||
```ts
|
||||
46 | await openTab(page, 'Address');
|
||||
47 | await pick(page, 'ID Type', /^NID$/i);
|
||||
48 | await page.getByLabel('ID Number').fill('FYD1234567890');
|
||||
49 | // A country select, not a free-text field.
|
||||
50 | await pick(page, 'Nationality', /ethiopia/i);
|
||||
51 | // `addressSchema` requires this in Ethiopian format; without it the form
|
||||
52 | // never submits and no request is made for `save` to wait on.
|
||||
53 | await page
|
||||
54 | .getByRole('textbox', { name: 'Primary Phone' })
|
||||
55 | .fill('+251911234567');
|
||||
56 | await save(page);
|
||||
57 | }
|
||||
58 |
|
||||
59 | /** Selects a profile tab and waits for its panel to be the visible one. */
|
||||
60 | async function openTab(page: Page, name: string): Promise<void> {
|
||||
61 | await page.getByRole('tab', { name, exact: true }).click();
|
||||
62 | await expect(page.getByRole('tabpanel', { name })).toBeVisible({
|
||||
63 | timeout: 15_000,
|
||||
64 | });
|
||||
65 | }
|
||||
66 |
|
||||
67 | /**
|
||||
68 | * Picks a value from a Mantine select.
|
||||
69 | *
|
||||
70 | * The label is bound to both the input and the listbox it opens, so matching
|
||||
71 | * by label alone is ambiguous once the dropdown is showing — the textbox role
|
||||
72 | * names the control itself.
|
||||
73 | */
|
||||
74 | async function pick(page: Page, label: string, option: RegExp): Promise<void> {
|
||||
75 | await page.getByRole('textbox', { name: label }).click();
|
||||
76 | await page.getByRole('option', { name: option }).first().click();
|
||||
77 | }
|
||||
78 |
|
||||
79 | /**
|
||||
80 | * Sets the date of birth through the picker's own UI.
|
||||
81 | *
|
||||
82 | * `AmharicDatePicker` is a controlled component: it reports changes through
|
||||
83 | * `onChange`, which is what writes the value into react-hook-form. Setting the
|
||||
84 | * input's `value` natively bypasses that entirely — the field stays empty as
|
||||
85 | * far as zod is concerned, and the form silently refuses to submit.
|
||||
86 | *
|
||||
87 | * So the calendar is actually driven: open it, pick the year and month from
|
||||
88 | * the caption dropdowns, then click the day.
|
||||
89 | */
|
||||
90 | async function pickDate(page: Page, label: string, iso: string): Promise<void> {
|
||||
91 | const [year, month, day] = iso.split('-').map(Number);
|
||||
92 |
|
||||
93 | await page.getByRole('textbox', { name: label }).click();
|
||||
94 | const calendar = page.locator('.amharic-daypicker-dropdown');
|
||||
95 | await expect(calendar).toBeVisible({ timeout: 10_000 });
|
||||
96 |
|
||||
97 | // `captionLayout="dropdown"` renders native selects for month and year.
|
||||
98 | await calendar.locator('select').last().selectOption(String(year));
|
||||
99 | await calendar
|
||||
100 | .locator('select')
|
||||
101 | .first()
|
||||
102 | .selectOption({ index: month - 1 });
|
||||
103 |
|
||||
104 | // Each day is a button whose accessible name is the full date
|
||||
105 | // ("Saturday, April 1st, 1995"), not the bare number — matching on the
|
||||
106 | // number alone finds nothing. Anchored on the ordinal so 1 cannot match 11
|
||||
107 | // or 21. Resolved after the dropdowns settle, since changing year or month
|
||||
108 | // re-renders the grid.
|
||||
109 | const cell = calendar
|
||||
110 | .getByRole('button', { name: new RegExp(`\\b${day}(st|nd|rd|th),`) })
|
||||
111 | .first();
|
||||
112 | await expect(cell).toBeVisible({ timeout: 10_000 });
|
||||
113 | await cell.click();
|
||||
114 |
|
||||
115 | await expect(calendar).toBeHidden({ timeout: 10_000 });
|
||||
116 |
|
||||
117 | // The picker writes through `onChange`; if that did not land, zod still sees
|
||||
118 | // an empty field and the failure would surface later as a refused submit.
|
||||
119 | await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', {
|
||||
120 | timeout: 10_000,
|
||||
121 | });
|
||||
122 | }
|
||||
123 |
|
||||
124 | async function save(page: Page): Promise<void> {
|
||||
125 | // Matched loosely on purpose: the personal tab PATCHes a user, the profile
|
||||
126 | // tab a profile, and the address tab POSTs to `/addresss/profile/:id` — the
|
||||
127 | // route's own spelling. Any successful write from this screen is the signal.
|
||||
128 | const saved = page.waitForResponse(
|
||||
129 | (r) =>
|
||||
130 | r.request().method() !== 'GET' &&
|
||||
131 | r.status() < 400 &&
|
||||
132 | /(profile|address|user)/i.test(r.url()),
|
||||
133 | { timeout: 20_000 },
|
||||
134 | );
|
||||
135 | await page.getByRole('button', { name: /save/i }).first().click();
|
||||
136 |
|
||||
137 | try {
|
||||
138 | await saved;
|
||||
139 | } catch (cause) {
|
||||
140 | // A zod-blocked submit fires no request at all, so the bare timeout says
|
||||
141 | // only "no response" — which reads as a backend fault rather than a form
|
||||
142 | // that refused to submit. Surface the field errors instead.
|
||||
143 | const messages = await page
|
||||
144 | .locator('.mantine-InputWrapper-error, [role="alert"]')
|
||||
145 | .allTextContents();
|
||||
> 146 | throw new Error(
|
||||
| ^ Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
||||
147 | messages.length
|
||||
148 | ? `Save did not submit — validation errors: ${messages.join('; ')}`
|
||||
149 | : 'Save produced no request and reported no validation error.',
|
||||
150 | { cause },
|
||||
151 | );
|
||||
152 | }
|
||||
153 | }
|
||||
154 |
|
||||
155 | /** Signs up, declares seafarer operations, and fills the gating profile. */
|
||||
156 | async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
|
||||
157 | const offset = await signUp(page, applicant);
|
||||
158 | await verifyOtpIfPrompted(page, offset);
|
||||
159 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
||||
160 | await page
|
||||
161 | .getByRole('checkbox', { name: /seafarer registration/i })
|
||||
162 | .first()
|
||||
163 | .check();
|
||||
164 | await page.getByRole('button', { name: /save operations/i }).click();
|
||||
165 | // A seafarer is taken to `/profile`, not the dashboard: registration is
|
||||
166 | // built from the profile, and a fresh signup holds none of it yet.
|
||||
167 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
168 | await completeProfile(page);
|
||||
169 | }
|
||||
170 |
|
||||
171 | test.describe('seafarer registration', () => {
|
||||
172 | let applicant: Applicant;
|
||||
173 |
|
||||
174 | test.beforeEach(() => {
|
||||
175 | applicant = newApplicant('seafarer');
|
||||
176 | });
|
||||
177 |
|
||||
178 | test.afterEach(() => {
|
||||
179 | deleteApplicant(applicant.email);
|
||||
180 | });
|
||||
181 |
|
||||
182 | test('the wizard refuses to open until the profile it is built from is complete', async ({
|
||||
183 | page,
|
||||
184 | }) => {
|
||||
185 | const offset = await signUp(page, applicant);
|
||||
186 | await verifyOtpIfPrompted(page, offset);
|
||||
187 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
||||
188 | await page
|
||||
189 | .getByRole('checkbox', { name: /seafarer registration/i })
|
||||
190 | .first()
|
||||
191 | .check();
|
||||
192 | await page.getByRole('button', { name: /save operations/i }).click();
|
||||
193 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
194 |
|
||||
195 | // A new account holds none of the identity the registration is filled in
|
||||
196 | // from, so the gate collects it rather than opening an uncompletable form.
|
||||
197 | await page.goto('/seafarer-registration');
|
||||
198 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
199 |
|
||||
200 | // The shared wizard route is gated identically — otherwise the gate is
|
||||
201 | // decoration a deep link walks straight past.
|
||||
202 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
203 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
204 | });
|
||||
205 |
|
||||
206 | test('opening the wizard creates the draft up front', async ({ page }) => {
|
||||
207 | await readyApplicant(page, applicant);
|
||||
208 |
|
||||
209 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
210 | await expect(page).not.toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
211 |
|
||||
212 | // The draft exists before anything is filled in, so uploads have an owner
|
||||
213 | // and closing the browser mid-wizard loses nothing.
|
||||
214 | const number = await waitForApplication(applicant.email);
|
||||
215 | expect(number).toMatch(/^SFR/);
|
||||
216 | expect(statusOf(number)).toBe('DRAFT');
|
||||
217 | });
|
||||
218 |
|
||||
219 | test('a registration never reaches evaluation or inspection', async ({
|
||||
220 | page,
|
||||
221 | }) => {
|
||||
222 | await readyApplicant(page, applicant);
|
||||
223 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
224 | const number = await waitForApplication(applicant.email);
|
||||
225 | const id = idOf(number);
|
||||
226 |
|
||||
227 | await submit(id);
|
||||
228 | await runWorkflow(id, [{ path: 'claim' }]);
|
||||
229 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
||||
230 |
|
||||
231 | // The licence course's middle stages have nothing to hold in a
|
||||
232 | // registration, and the transition table is the authority regardless of
|
||||
233 | // which endpoint is called.
|
||||
234 | const refused = await runWorkflow(id, [
|
||||
235 | { path: 'complete-review', expectFailure: true },
|
||||
236 | { path: 'approve-documents', expectFailure: true },
|
||||
237 | { path: 'record-inspection', expectFailure: true },
|
||||
238 | ]);
|
||||
239 | expect(refused.every((code) => code >= 400)).toBe(true);
|
||||
240 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
||||
241 | });
|
||||
242 |
|
||||
243 | test('an officer can return a registration for correction and take it back', async ({
|
||||
244 | page,
|
||||
245 | }) => {
|
||||
246 | await readyApplicant(page, applicant);
|
||||
```
|
||||
|
Before Width: | Height: | Size: 92 KiB |
@@ -1,348 +0,0 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: seafarer-registration.spec.ts >> seafarer registration >> a registered seafarer cannot start a second registration
|
||||
- Location: apps/e2e/src/seafarer-registration.spec.ts:355:7
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
||||
```
|
||||
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=f1e3]:
|
||||
- banner [ref=f1e4]:
|
||||
- generic [ref=f1e5]:
|
||||
- generic [ref=f1e6]:
|
||||
- button "Toggle navigation" [ref=f1e8] [cursor=pointer]
|
||||
- generic [ref=f1e10]:
|
||||
- generic [ref=f1e11]: Dashboard
|
||||
- generic [ref=f1e13]: Profile
|
||||
- generic [ref=f1e17]:
|
||||
- button "Language" [ref=f1e18] [cursor=pointer]
|
||||
- button "Toggle light / dark mode" [ref=f1e23] [cursor=pointer]
|
||||
- button "Notifications" [ref=f1e26] [cursor=pointer]:
|
||||
- generic [ref=f1e27]: "1"
|
||||
- button "ES" [ref=f1e32] [cursor=pointer]
|
||||
- navigation [ref=f1e34]:
|
||||
- generic [ref=f1e35]:
|
||||
- img "EMA" [ref=f1e36]
|
||||
- generic [ref=f1e37]:
|
||||
- paragraph [ref=f1e38]: EMA Portal
|
||||
- paragraph [ref=f1e39]: Ethiopian Maritime Authority
|
||||
- generic [ref=f1e43]:
|
||||
- generic [ref=f1e44]:
|
||||
- generic [ref=f1e45] [cursor=pointer]: Dashboard
|
||||
- generic [ref=f1e52] [cursor=pointer]:
|
||||
- generic [ref=f1e57]: Notifications
|
||||
- generic "1 pending" [ref=f1e59]: "1"
|
||||
- generic [ref=f1e61]:
|
||||
- button [expanded] [ref=f1e62] [cursor=pointer]:
|
||||
- paragraph [ref=f1e63]: Licensing
|
||||
- generic [ref=f1e66] [cursor=pointer]: My Applications
|
||||
- generic [ref=f1e73]:
|
||||
- button [expanded] [ref=f1e74] [cursor=pointer]:
|
||||
- paragraph [ref=f1e75]: Seafarer Services
|
||||
- generic [ref=f1e78] [cursor=pointer]: Seafarer Registration
|
||||
- generic [ref=f1e82] [cursor=pointer]: My Sea Records
|
||||
- generic [ref=f1e86] [cursor=pointer]: Seaman Book
|
||||
- generic [ref=f1e92] [cursor=pointer]: Basic Training Certificate
|
||||
- generic [ref=f1e98] [cursor=pointer]: Certificates
|
||||
- generic [ref=f1e104] [cursor=pointer]: Examinations
|
||||
- generic [ref=f1e108] [cursor=pointer]: Endorsements
|
||||
- generic [ref=f1e113]:
|
||||
- button [expanded] [ref=f1e114] [cursor=pointer]:
|
||||
- paragraph [ref=f1e115]: Account
|
||||
- generic [ref=f1e118] [cursor=pointer]: My Documents
|
||||
- generic [ref=f1e123] [cursor=pointer]: Profile
|
||||
- generic [ref=f1e130] [cursor=pointer]: Help & Support
|
||||
- button "Collapse" [ref=f1e139] [cursor=pointer]
|
||||
- main [ref=f1e143]:
|
||||
- generic [ref=f1e145]:
|
||||
- generic [ref=f1e147]:
|
||||
- heading "My Profile" [level=2] [ref=f1e148]
|
||||
- paragraph [ref=f1e149]: Manage your account details and preferences.
|
||||
- alert [ref=f1e150]:
|
||||
- generic [ref=f1e151]: Profile details are needed for seafarer registration.
|
||||
- generic [ref=f1e159]:
|
||||
- paragraph [ref=f1e161]: ES
|
||||
- generic [ref=f1e162]:
|
||||
- generic [ref=f1e163]:
|
||||
- heading "E2E seafarer 2475" [level=4] [ref=f1e164]
|
||||
- generic [ref=f1e165]: Unverified
|
||||
- paragraph [ref=f1e171]: e2e.seafarer.1787042544962475@example.test
|
||||
- generic [ref=f1e172]: e2eseafarer1787042544962475
|
||||
- generic "0% complete" [ref=f1e178]:
|
||||
- paragraph [ref=f1e183]: 0%
|
||||
- generic [ref=f1e184]:
|
||||
- tablist [ref=f1e185]:
|
||||
- tab "Personal" [ref=f1e186] [cursor=pointer]
|
||||
- tab "Profile" [selected] [ref=f1e193] [cursor=pointer]
|
||||
- tab "Address" [ref=f1e199] [cursor=pointer]
|
||||
- tab "Operations" [ref=f1e205] [cursor=pointer]
|
||||
- tab "Security" [ref=f1e212] [cursor=pointer]
|
||||
- tab "Preferences" [ref=f1e218] [cursor=pointer]
|
||||
- tabpanel "Profile" [ref=f1e224]:
|
||||
- generic [ref=f1e227]:
|
||||
- generic [ref=f1e228]:
|
||||
- heading "Maritime Profile" [level=5] [ref=f1e229]
|
||||
- paragraph [ref=f1e230]: Your professional maritime details
|
||||
- generic [ref=f1e231]:
|
||||
- generic [ref=f1e232]:
|
||||
- generic [ref=f1e233]: Profession *
|
||||
- textbox "Profession" [ref=f1e235]:
|
||||
- /placeholder: Select
|
||||
- text: Master Mariner
|
||||
- generic [ref=f1e236]:
|
||||
- generic [ref=f1e237]: First Name *
|
||||
- textbox "First Name" [ref=f1e239]:
|
||||
- /placeholder: Enter first name
|
||||
- text: Dawit
|
||||
- generic [ref=f1e240]:
|
||||
- generic [ref=f1e241]: Middle Name *
|
||||
- textbox "Middle Name" [ref=f1e243]:
|
||||
- /placeholder: Enter middle name
|
||||
- text: Bekele
|
||||
- generic [ref=f1e244]:
|
||||
- generic [ref=f1e245]: Last Name *
|
||||
- textbox "Last Name" [ref=f1e247]:
|
||||
- /placeholder: Enter last name
|
||||
- text: Tesfaye
|
||||
- generic [ref=f1e248]:
|
||||
- generic [ref=f1e249]: Gender *
|
||||
- textbox "Gender" [ref=f1e251] [cursor=pointer]:
|
||||
- /placeholder: Select
|
||||
- text: MALE
|
||||
- generic [ref=f1e252]:
|
||||
- generic [ref=f1e253]: Date of Birth *
|
||||
- generic [ref=f1e254]:
|
||||
- button "Switch calendar type" [ref=f1e256] [cursor=pointer]:
|
||||
- generic [ref=f1e257]: EN
|
||||
- textbox "Date of Birth" [ref=f1e259] [cursor=pointer]: Apr 12, 1995
|
||||
- button [ref=f1e261] [cursor=pointer]
|
||||
- generic [ref=f1e266]:
|
||||
- generic [ref=f1e267]: Place of Birth
|
||||
- textbox "Place of Birth" [ref=f1e269]:
|
||||
- /placeholder: City, Region
|
||||
- generic [ref=f1e270]:
|
||||
- generic [ref=f1e271]: Marital Status *
|
||||
- textbox "Marital Status" [ref=f1e273] [cursor=pointer]:
|
||||
- /placeholder: Select
|
||||
- text: SINGLE
|
||||
- button "Save Profile" [active] [ref=f1e275] [cursor=pointer]
|
||||
```
|
||||
|
||||
# Test source
|
||||
|
||||
```ts
|
||||
46 | await openTab(page, 'Address');
|
||||
47 | await pick(page, 'ID Type', /^NID$/i);
|
||||
48 | await page.getByLabel('ID Number').fill('FYD1234567890');
|
||||
49 | // A country select, not a free-text field.
|
||||
50 | await pick(page, 'Nationality', /ethiopia/i);
|
||||
51 | // `addressSchema` requires this in Ethiopian format; without it the form
|
||||
52 | // never submits and no request is made for `save` to wait on.
|
||||
53 | await page
|
||||
54 | .getByRole('textbox', { name: 'Primary Phone' })
|
||||
55 | .fill('+251911234567');
|
||||
56 | await save(page);
|
||||
57 | }
|
||||
58 |
|
||||
59 | /** Selects a profile tab and waits for its panel to be the visible one. */
|
||||
60 | async function openTab(page: Page, name: string): Promise<void> {
|
||||
61 | await page.getByRole('tab', { name, exact: true }).click();
|
||||
62 | await expect(page.getByRole('tabpanel', { name })).toBeVisible({
|
||||
63 | timeout: 15_000,
|
||||
64 | });
|
||||
65 | }
|
||||
66 |
|
||||
67 | /**
|
||||
68 | * Picks a value from a Mantine select.
|
||||
69 | *
|
||||
70 | * The label is bound to both the input and the listbox it opens, so matching
|
||||
71 | * by label alone is ambiguous once the dropdown is showing — the textbox role
|
||||
72 | * names the control itself.
|
||||
73 | */
|
||||
74 | async function pick(page: Page, label: string, option: RegExp): Promise<void> {
|
||||
75 | await page.getByRole('textbox', { name: label }).click();
|
||||
76 | await page.getByRole('option', { name: option }).first().click();
|
||||
77 | }
|
||||
78 |
|
||||
79 | /**
|
||||
80 | * Sets the date of birth through the picker's own UI.
|
||||
81 | *
|
||||
82 | * `AmharicDatePicker` is a controlled component: it reports changes through
|
||||
83 | * `onChange`, which is what writes the value into react-hook-form. Setting the
|
||||
84 | * input's `value` natively bypasses that entirely — the field stays empty as
|
||||
85 | * far as zod is concerned, and the form silently refuses to submit.
|
||||
86 | *
|
||||
87 | * So the calendar is actually driven: open it, pick the year and month from
|
||||
88 | * the caption dropdowns, then click the day.
|
||||
89 | */
|
||||
90 | async function pickDate(page: Page, label: string, iso: string): Promise<void> {
|
||||
91 | const [year, month, day] = iso.split('-').map(Number);
|
||||
92 |
|
||||
93 | await page.getByRole('textbox', { name: label }).click();
|
||||
94 | const calendar = page.locator('.amharic-daypicker-dropdown');
|
||||
95 | await expect(calendar).toBeVisible({ timeout: 10_000 });
|
||||
96 |
|
||||
97 | // `captionLayout="dropdown"` renders native selects for month and year.
|
||||
98 | await calendar.locator('select').last().selectOption(String(year));
|
||||
99 | await calendar
|
||||
100 | .locator('select')
|
||||
101 | .first()
|
||||
102 | .selectOption({ index: month - 1 });
|
||||
103 |
|
||||
104 | // Each day is a button whose accessible name is the full date
|
||||
105 | // ("Saturday, April 1st, 1995"), not the bare number — matching on the
|
||||
106 | // number alone finds nothing. Anchored on the ordinal so 1 cannot match 11
|
||||
107 | // or 21. Resolved after the dropdowns settle, since changing year or month
|
||||
108 | // re-renders the grid.
|
||||
109 | const cell = calendar
|
||||
110 | .getByRole('button', { name: new RegExp(`\\b${day}(st|nd|rd|th),`) })
|
||||
111 | .first();
|
||||
112 | await expect(cell).toBeVisible({ timeout: 10_000 });
|
||||
113 | await cell.click();
|
||||
114 |
|
||||
115 | await expect(calendar).toBeHidden({ timeout: 10_000 });
|
||||
116 |
|
||||
117 | // The picker writes through `onChange`; if that did not land, zod still sees
|
||||
118 | // an empty field and the failure would surface later as a refused submit.
|
||||
119 | await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', {
|
||||
120 | timeout: 10_000,
|
||||
121 | });
|
||||
122 | }
|
||||
123 |
|
||||
124 | async function save(page: Page): Promise<void> {
|
||||
125 | // Matched loosely on purpose: the personal tab PATCHes a user, the profile
|
||||
126 | // tab a profile, and the address tab POSTs to `/addresss/profile/:id` — the
|
||||
127 | // route's own spelling. Any successful write from this screen is the signal.
|
||||
128 | const saved = page.waitForResponse(
|
||||
129 | (r) =>
|
||||
130 | r.request().method() !== 'GET' &&
|
||||
131 | r.status() < 400 &&
|
||||
132 | /(profile|address|user)/i.test(r.url()),
|
||||
133 | { timeout: 20_000 },
|
||||
134 | );
|
||||
135 | await page.getByRole('button', { name: /save/i }).first().click();
|
||||
136 |
|
||||
137 | try {
|
||||
138 | await saved;
|
||||
139 | } catch (cause) {
|
||||
140 | // A zod-blocked submit fires no request at all, so the bare timeout says
|
||||
141 | // only "no response" — which reads as a backend fault rather than a form
|
||||
142 | // that refused to submit. Surface the field errors instead.
|
||||
143 | const messages = await page
|
||||
144 | .locator('.mantine-InputWrapper-error, [role="alert"]')
|
||||
145 | .allTextContents();
|
||||
> 146 | throw new Error(
|
||||
| ^ Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
||||
147 | messages.length
|
||||
148 | ? `Save did not submit — validation errors: ${messages.join('; ')}`
|
||||
149 | : 'Save produced no request and reported no validation error.',
|
||||
150 | { cause },
|
||||
151 | );
|
||||
152 | }
|
||||
153 | }
|
||||
154 |
|
||||
155 | /** Signs up, declares seafarer operations, and fills the gating profile. */
|
||||
156 | async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
|
||||
157 | const offset = await signUp(page, applicant);
|
||||
158 | await verifyOtpIfPrompted(page, offset);
|
||||
159 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
||||
160 | await page
|
||||
161 | .getByRole('checkbox', { name: /seafarer registration/i })
|
||||
162 | .first()
|
||||
163 | .check();
|
||||
164 | await page.getByRole('button', { name: /save operations/i }).click();
|
||||
165 | // A seafarer is taken to `/profile`, not the dashboard: registration is
|
||||
166 | // built from the profile, and a fresh signup holds none of it yet.
|
||||
167 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
168 | await completeProfile(page);
|
||||
169 | }
|
||||
170 |
|
||||
171 | test.describe('seafarer registration', () => {
|
||||
172 | let applicant: Applicant;
|
||||
173 |
|
||||
174 | test.beforeEach(() => {
|
||||
175 | applicant = newApplicant('seafarer');
|
||||
176 | });
|
||||
177 |
|
||||
178 | test.afterEach(() => {
|
||||
179 | deleteApplicant(applicant.email);
|
||||
180 | });
|
||||
181 |
|
||||
182 | test('the wizard refuses to open until the profile it is built from is complete', async ({
|
||||
183 | page,
|
||||
184 | }) => {
|
||||
185 | const offset = await signUp(page, applicant);
|
||||
186 | await verifyOtpIfPrompted(page, offset);
|
||||
187 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
||||
188 | await page
|
||||
189 | .getByRole('checkbox', { name: /seafarer registration/i })
|
||||
190 | .first()
|
||||
191 | .check();
|
||||
192 | await page.getByRole('button', { name: /save operations/i }).click();
|
||||
193 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
194 |
|
||||
195 | // A new account holds none of the identity the registration is filled in
|
||||
196 | // from, so the gate collects it rather than opening an uncompletable form.
|
||||
197 | await page.goto('/seafarer-registration');
|
||||
198 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
199 |
|
||||
200 | // The shared wizard route is gated identically — otherwise the gate is
|
||||
201 | // decoration a deep link walks straight past.
|
||||
202 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
203 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
204 | });
|
||||
205 |
|
||||
206 | test('opening the wizard creates the draft up front', async ({ page }) => {
|
||||
207 | await readyApplicant(page, applicant);
|
||||
208 |
|
||||
209 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
210 | await expect(page).not.toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
211 |
|
||||
212 | // The draft exists before anything is filled in, so uploads have an owner
|
||||
213 | // and closing the browser mid-wizard loses nothing.
|
||||
214 | const number = await waitForApplication(applicant.email);
|
||||
215 | expect(number).toMatch(/^SFR/);
|
||||
216 | expect(statusOf(number)).toBe('DRAFT');
|
||||
217 | });
|
||||
218 |
|
||||
219 | test('a registration never reaches evaluation or inspection', async ({
|
||||
220 | page,
|
||||
221 | }) => {
|
||||
222 | await readyApplicant(page, applicant);
|
||||
223 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
224 | const number = await waitForApplication(applicant.email);
|
||||
225 | const id = idOf(number);
|
||||
226 |
|
||||
227 | await submit(id);
|
||||
228 | await runWorkflow(id, [{ path: 'claim' }]);
|
||||
229 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
||||
230 |
|
||||
231 | // The licence course's middle stages have nothing to hold in a
|
||||
232 | // registration, and the transition table is the authority regardless of
|
||||
233 | // which endpoint is called.
|
||||
234 | const refused = await runWorkflow(id, [
|
||||
235 | { path: 'complete-review', expectFailure: true },
|
||||
236 | { path: 'approve-documents', expectFailure: true },
|
||||
237 | { path: 'record-inspection', expectFailure: true },
|
||||
238 | ]);
|
||||
239 | expect(refused.every((code) => code >= 400)).toBe(true);
|
||||
240 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
||||
241 | });
|
||||
242 |
|
||||
243 | test('an officer can return a registration for correction and take it back', async ({
|
||||
244 | page,
|
||||
245 | }) => {
|
||||
246 | await readyApplicant(page, applicant);
|
||||
```
|
||||
|
Before Width: | Height: | Size: 92 KiB |
@@ -1,348 +0,0 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: seafarer-registration.spec.ts >> seafarer registration >> a registration never reaches evaluation or inspection
|
||||
- Location: apps/e2e/src/seafarer-registration.spec.ts:219:7
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
||||
```
|
||||
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=f1e3]:
|
||||
- banner [ref=f1e4]:
|
||||
- generic [ref=f1e5]:
|
||||
- generic [ref=f1e6]:
|
||||
- button "Toggle navigation" [ref=f1e8] [cursor=pointer]
|
||||
- generic [ref=f1e10]:
|
||||
- generic [ref=f1e11]: Dashboard
|
||||
- generic [ref=f1e13]: Profile
|
||||
- generic [ref=f1e17]:
|
||||
- button "Language" [ref=f1e18] [cursor=pointer]
|
||||
- button "Toggle light / dark mode" [ref=f1e23] [cursor=pointer]
|
||||
- button "Notifications" [ref=f1e26] [cursor=pointer]:
|
||||
- generic [ref=f1e27]: "1"
|
||||
- button "ES" [ref=f1e32] [cursor=pointer]
|
||||
- navigation [ref=f1e34]:
|
||||
- generic [ref=f1e35]:
|
||||
- img "EMA" [ref=f1e36]
|
||||
- generic [ref=f1e37]:
|
||||
- paragraph [ref=f1e38]: EMA Portal
|
||||
- paragraph [ref=f1e39]: Ethiopian Maritime Authority
|
||||
- generic [ref=f1e43]:
|
||||
- generic [ref=f1e44]:
|
||||
- generic [ref=f1e45] [cursor=pointer]: Dashboard
|
||||
- generic [ref=f1e52] [cursor=pointer]:
|
||||
- generic [ref=f1e57]: Notifications
|
||||
- generic "1 pending" [ref=f1e59]: "1"
|
||||
- generic [ref=f1e61]:
|
||||
- button [expanded] [ref=f1e62] [cursor=pointer]:
|
||||
- paragraph [ref=f1e63]: Licensing
|
||||
- generic [ref=f1e66] [cursor=pointer]: My Applications
|
||||
- generic [ref=f1e73]:
|
||||
- button [expanded] [ref=f1e74] [cursor=pointer]:
|
||||
- paragraph [ref=f1e75]: Seafarer Services
|
||||
- generic [ref=f1e78] [cursor=pointer]: Seafarer Registration
|
||||
- generic [ref=f1e82] [cursor=pointer]: My Sea Records
|
||||
- generic [ref=f1e86] [cursor=pointer]: Seaman Book
|
||||
- generic [ref=f1e92] [cursor=pointer]: Basic Training Certificate
|
||||
- generic [ref=f1e98] [cursor=pointer]: Certificates
|
||||
- generic [ref=f1e104] [cursor=pointer]: Examinations
|
||||
- generic [ref=f1e108] [cursor=pointer]: Endorsements
|
||||
- generic [ref=f1e113]:
|
||||
- button [expanded] [ref=f1e114] [cursor=pointer]:
|
||||
- paragraph [ref=f1e115]: Account
|
||||
- generic [ref=f1e118] [cursor=pointer]: My Documents
|
||||
- generic [ref=f1e123] [cursor=pointer]: Profile
|
||||
- generic [ref=f1e130] [cursor=pointer]: Help & Support
|
||||
- button "Collapse" [ref=f1e139] [cursor=pointer]
|
||||
- main [ref=f1e143]:
|
||||
- generic [ref=f1e145]:
|
||||
- generic [ref=f1e147]:
|
||||
- heading "My Profile" [level=2] [ref=f1e148]
|
||||
- paragraph [ref=f1e149]: Manage your account details and preferences.
|
||||
- alert [ref=f1e150]:
|
||||
- generic [ref=f1e151]: Profile details are needed for seafarer registration.
|
||||
- generic [ref=f1e159]:
|
||||
- paragraph [ref=f1e161]: ES
|
||||
- generic [ref=f1e162]:
|
||||
- generic [ref=f1e163]:
|
||||
- heading "E2E seafarer 8190" [level=4] [ref=f1e164]
|
||||
- generic [ref=f1e165]: Unverified
|
||||
- paragraph [ref=f1e171]: e2e.seafarer.1787042357258190@example.test
|
||||
- generic [ref=f1e172]: e2eseafarer1787042357258190
|
||||
- generic "0% complete" [ref=f1e178]:
|
||||
- paragraph [ref=f1e183]: 0%
|
||||
- generic [ref=f1e184]:
|
||||
- tablist [ref=f1e185]:
|
||||
- tab "Personal" [ref=f1e186] [cursor=pointer]
|
||||
- tab "Profile" [selected] [ref=f1e193] [cursor=pointer]
|
||||
- tab "Address" [ref=f1e199] [cursor=pointer]
|
||||
- tab "Operations" [ref=f1e205] [cursor=pointer]
|
||||
- tab "Security" [ref=f1e212] [cursor=pointer]
|
||||
- tab "Preferences" [ref=f1e218] [cursor=pointer]
|
||||
- tabpanel "Profile" [ref=f1e224]:
|
||||
- generic [ref=f1e227]:
|
||||
- generic [ref=f1e228]:
|
||||
- heading "Maritime Profile" [level=5] [ref=f1e229]
|
||||
- paragraph [ref=f1e230]: Your professional maritime details
|
||||
- generic [ref=f1e231]:
|
||||
- generic [ref=f1e232]:
|
||||
- generic [ref=f1e233]: Profession *
|
||||
- textbox "Profession" [ref=f1e235]:
|
||||
- /placeholder: Select
|
||||
- text: Master Mariner
|
||||
- generic [ref=f1e236]:
|
||||
- generic [ref=f1e237]: First Name *
|
||||
- textbox "First Name" [ref=f1e239]:
|
||||
- /placeholder: Enter first name
|
||||
- text: Dawit
|
||||
- generic [ref=f1e240]:
|
||||
- generic [ref=f1e241]: Middle Name *
|
||||
- textbox "Middle Name" [ref=f1e243]:
|
||||
- /placeholder: Enter middle name
|
||||
- text: Bekele
|
||||
- generic [ref=f1e244]:
|
||||
- generic [ref=f1e245]: Last Name *
|
||||
- textbox "Last Name" [ref=f1e247]:
|
||||
- /placeholder: Enter last name
|
||||
- text: Tesfaye
|
||||
- generic [ref=f1e248]:
|
||||
- generic [ref=f1e249]: Gender *
|
||||
- textbox "Gender" [ref=f1e251] [cursor=pointer]:
|
||||
- /placeholder: Select
|
||||
- text: MALE
|
||||
- generic [ref=f1e252]:
|
||||
- generic [ref=f1e253]: Date of Birth *
|
||||
- generic [ref=f1e254]:
|
||||
- button "Switch calendar type" [ref=f1e256] [cursor=pointer]:
|
||||
- generic [ref=f1e257]: EN
|
||||
- textbox "Date of Birth" [ref=f1e259] [cursor=pointer]: Apr 12, 1995
|
||||
- button [ref=f1e261] [cursor=pointer]
|
||||
- generic [ref=f1e266]:
|
||||
- generic [ref=f1e267]: Place of Birth
|
||||
- textbox "Place of Birth" [ref=f1e269]:
|
||||
- /placeholder: City, Region
|
||||
- generic [ref=f1e270]:
|
||||
- generic [ref=f1e271]: Marital Status *
|
||||
- textbox "Marital Status" [ref=f1e273] [cursor=pointer]:
|
||||
- /placeholder: Select
|
||||
- text: SINGLE
|
||||
- button "Save Profile" [active] [ref=f1e275] [cursor=pointer]
|
||||
```
|
||||
|
||||
# Test source
|
||||
|
||||
```ts
|
||||
46 | await openTab(page, 'Address');
|
||||
47 | await pick(page, 'ID Type', /^NID$/i);
|
||||
48 | await page.getByLabel('ID Number').fill('FYD1234567890');
|
||||
49 | // A country select, not a free-text field.
|
||||
50 | await pick(page, 'Nationality', /ethiopia/i);
|
||||
51 | // `addressSchema` requires this in Ethiopian format; without it the form
|
||||
52 | // never submits and no request is made for `save` to wait on.
|
||||
53 | await page
|
||||
54 | .getByRole('textbox', { name: 'Primary Phone' })
|
||||
55 | .fill('+251911234567');
|
||||
56 | await save(page);
|
||||
57 | }
|
||||
58 |
|
||||
59 | /** Selects a profile tab and waits for its panel to be the visible one. */
|
||||
60 | async function openTab(page: Page, name: string): Promise<void> {
|
||||
61 | await page.getByRole('tab', { name, exact: true }).click();
|
||||
62 | await expect(page.getByRole('tabpanel', { name })).toBeVisible({
|
||||
63 | timeout: 15_000,
|
||||
64 | });
|
||||
65 | }
|
||||
66 |
|
||||
67 | /**
|
||||
68 | * Picks a value from a Mantine select.
|
||||
69 | *
|
||||
70 | * The label is bound to both the input and the listbox it opens, so matching
|
||||
71 | * by label alone is ambiguous once the dropdown is showing — the textbox role
|
||||
72 | * names the control itself.
|
||||
73 | */
|
||||
74 | async function pick(page: Page, label: string, option: RegExp): Promise<void> {
|
||||
75 | await page.getByRole('textbox', { name: label }).click();
|
||||
76 | await page.getByRole('option', { name: option }).first().click();
|
||||
77 | }
|
||||
78 |
|
||||
79 | /**
|
||||
80 | * Sets the date of birth through the picker's own UI.
|
||||
81 | *
|
||||
82 | * `AmharicDatePicker` is a controlled component: it reports changes through
|
||||
83 | * `onChange`, which is what writes the value into react-hook-form. Setting the
|
||||
84 | * input's `value` natively bypasses that entirely — the field stays empty as
|
||||
85 | * far as zod is concerned, and the form silently refuses to submit.
|
||||
86 | *
|
||||
87 | * So the calendar is actually driven: open it, pick the year and month from
|
||||
88 | * the caption dropdowns, then click the day.
|
||||
89 | */
|
||||
90 | async function pickDate(page: Page, label: string, iso: string): Promise<void> {
|
||||
91 | const [year, month, day] = iso.split('-').map(Number);
|
||||
92 |
|
||||
93 | await page.getByRole('textbox', { name: label }).click();
|
||||
94 | const calendar = page.locator('.amharic-daypicker-dropdown');
|
||||
95 | await expect(calendar).toBeVisible({ timeout: 10_000 });
|
||||
96 |
|
||||
97 | // `captionLayout="dropdown"` renders native selects for month and year.
|
||||
98 | await calendar.locator('select').last().selectOption(String(year));
|
||||
99 | await calendar
|
||||
100 | .locator('select')
|
||||
101 | .first()
|
||||
102 | .selectOption({ index: month - 1 });
|
||||
103 |
|
||||
104 | // Each day is a button whose accessible name is the full date
|
||||
105 | // ("Saturday, April 1st, 1995"), not the bare number — matching on the
|
||||
106 | // number alone finds nothing. Anchored on the ordinal so 1 cannot match 11
|
||||
107 | // or 21. Resolved after the dropdowns settle, since changing year or month
|
||||
108 | // re-renders the grid.
|
||||
109 | const cell = calendar
|
||||
110 | .getByRole('button', { name: new RegExp(`\\b${day}(st|nd|rd|th),`) })
|
||||
111 | .first();
|
||||
112 | await expect(cell).toBeVisible({ timeout: 10_000 });
|
||||
113 | await cell.click();
|
||||
114 |
|
||||
115 | await expect(calendar).toBeHidden({ timeout: 10_000 });
|
||||
116 |
|
||||
117 | // The picker writes through `onChange`; if that did not land, zod still sees
|
||||
118 | // an empty field and the failure would surface later as a refused submit.
|
||||
119 | await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', {
|
||||
120 | timeout: 10_000,
|
||||
121 | });
|
||||
122 | }
|
||||
123 |
|
||||
124 | async function save(page: Page): Promise<void> {
|
||||
125 | // Matched loosely on purpose: the personal tab PATCHes a user, the profile
|
||||
126 | // tab a profile, and the address tab POSTs to `/addresss/profile/:id` — the
|
||||
127 | // route's own spelling. Any successful write from this screen is the signal.
|
||||
128 | const saved = page.waitForResponse(
|
||||
129 | (r) =>
|
||||
130 | r.request().method() !== 'GET' &&
|
||||
131 | r.status() < 400 &&
|
||||
132 | /(profile|address|user)/i.test(r.url()),
|
||||
133 | { timeout: 20_000 },
|
||||
134 | );
|
||||
135 | await page.getByRole('button', { name: /save/i }).first().click();
|
||||
136 |
|
||||
137 | try {
|
||||
138 | await saved;
|
||||
139 | } catch (cause) {
|
||||
140 | // A zod-blocked submit fires no request at all, so the bare timeout says
|
||||
141 | // only "no response" — which reads as a backend fault rather than a form
|
||||
142 | // that refused to submit. Surface the field errors instead.
|
||||
143 | const messages = await page
|
||||
144 | .locator('.mantine-InputWrapper-error, [role="alert"]')
|
||||
145 | .allTextContents();
|
||||
> 146 | throw new Error(
|
||||
| ^ Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
||||
147 | messages.length
|
||||
148 | ? `Save did not submit — validation errors: ${messages.join('; ')}`
|
||||
149 | : 'Save produced no request and reported no validation error.',
|
||||
150 | { cause },
|
||||
151 | );
|
||||
152 | }
|
||||
153 | }
|
||||
154 |
|
||||
155 | /** Signs up, declares seafarer operations, and fills the gating profile. */
|
||||
156 | async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
|
||||
157 | const offset = await signUp(page, applicant);
|
||||
158 | await verifyOtpIfPrompted(page, offset);
|
||||
159 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
||||
160 | await page
|
||||
161 | .getByRole('checkbox', { name: /seafarer registration/i })
|
||||
162 | .first()
|
||||
163 | .check();
|
||||
164 | await page.getByRole('button', { name: /save operations/i }).click();
|
||||
165 | // A seafarer is taken to `/profile`, not the dashboard: registration is
|
||||
166 | // built from the profile, and a fresh signup holds none of it yet.
|
||||
167 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
168 | await completeProfile(page);
|
||||
169 | }
|
||||
170 |
|
||||
171 | test.describe('seafarer registration', () => {
|
||||
172 | let applicant: Applicant;
|
||||
173 |
|
||||
174 | test.beforeEach(() => {
|
||||
175 | applicant = newApplicant('seafarer');
|
||||
176 | });
|
||||
177 |
|
||||
178 | test.afterEach(() => {
|
||||
179 | deleteApplicant(applicant.email);
|
||||
180 | });
|
||||
181 |
|
||||
182 | test('the wizard refuses to open until the profile it is built from is complete', async ({
|
||||
183 | page,
|
||||
184 | }) => {
|
||||
185 | const offset = await signUp(page, applicant);
|
||||
186 | await verifyOtpIfPrompted(page, offset);
|
||||
187 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
||||
188 | await page
|
||||
189 | .getByRole('checkbox', { name: /seafarer registration/i })
|
||||
190 | .first()
|
||||
191 | .check();
|
||||
192 | await page.getByRole('button', { name: /save operations/i }).click();
|
||||
193 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
194 |
|
||||
195 | // A new account holds none of the identity the registration is filled in
|
||||
196 | // from, so the gate collects it rather than opening an uncompletable form.
|
||||
197 | await page.goto('/seafarer-registration');
|
||||
198 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
199 |
|
||||
200 | // The shared wizard route is gated identically — otherwise the gate is
|
||||
201 | // decoration a deep link walks straight past.
|
||||
202 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
203 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
204 | });
|
||||
205 |
|
||||
206 | test('opening the wizard creates the draft up front', async ({ page }) => {
|
||||
207 | await readyApplicant(page, applicant);
|
||||
208 |
|
||||
209 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
210 | await expect(page).not.toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
211 |
|
||||
212 | // The draft exists before anything is filled in, so uploads have an owner
|
||||
213 | // and closing the browser mid-wizard loses nothing.
|
||||
214 | const number = await waitForApplication(applicant.email);
|
||||
215 | expect(number).toMatch(/^SFR/);
|
||||
216 | expect(statusOf(number)).toBe('DRAFT');
|
||||
217 | });
|
||||
218 |
|
||||
219 | test('a registration never reaches evaluation or inspection', async ({
|
||||
220 | page,
|
||||
221 | }) => {
|
||||
222 | await readyApplicant(page, applicant);
|
||||
223 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
224 | const number = await waitForApplication(applicant.email);
|
||||
225 | const id = idOf(number);
|
||||
226 |
|
||||
227 | await submit(id);
|
||||
228 | await runWorkflow(id, [{ path: 'claim' }]);
|
||||
229 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
||||
230 |
|
||||
231 | // The licence course's middle stages have nothing to hold in a
|
||||
232 | // registration, and the transition table is the authority regardless of
|
||||
233 | // which endpoint is called.
|
||||
234 | const refused = await runWorkflow(id, [
|
||||
235 | { path: 'complete-review', expectFailure: true },
|
||||
236 | { path: 'approve-documents', expectFailure: true },
|
||||
237 | { path: 'record-inspection', expectFailure: true },
|
||||
238 | ]);
|
||||
239 | expect(refused.every((code) => code >= 400)).toBe(true);
|
||||
240 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
||||
241 | });
|
||||
242 |
|
||||
243 | test('an officer can return a registration for correction and take it back', async ({
|
||||
244 | page,
|
||||
245 | }) => {
|
||||
246 | await readyApplicant(page, applicant);
|
||||
```
|
||||
|
Before Width: | Height: | Size: 92 KiB |
@@ -1,348 +0,0 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: seafarer-registration.spec.ts >> seafarer registration >> approval numbers the profile and opens both child applications
|
||||
- Location: apps/e2e/src/seafarer-registration.spec.ts:303:7
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
||||
```
|
||||
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=f1e3]:
|
||||
- banner [ref=f1e4]:
|
||||
- generic [ref=f1e5]:
|
||||
- generic [ref=f1e6]:
|
||||
- button "Toggle navigation" [ref=f1e8] [cursor=pointer]
|
||||
- generic [ref=f1e10]:
|
||||
- generic [ref=f1e11]: Dashboard
|
||||
- generic [ref=f1e13]: Profile
|
||||
- generic [ref=f1e17]:
|
||||
- button "Language" [ref=f1e18] [cursor=pointer]
|
||||
- button "Toggle light / dark mode" [ref=f1e23] [cursor=pointer]
|
||||
- button "Notifications" [ref=f1e26] [cursor=pointer]:
|
||||
- generic [ref=f1e27]: "1"
|
||||
- button "ES" [ref=f1e32] [cursor=pointer]
|
||||
- navigation [ref=f1e34]:
|
||||
- generic [ref=f1e35]:
|
||||
- img "EMA" [ref=f1e36]
|
||||
- generic [ref=f1e37]:
|
||||
- paragraph [ref=f1e38]: EMA Portal
|
||||
- paragraph [ref=f1e39]: Ethiopian Maritime Authority
|
||||
- generic [ref=f1e43]:
|
||||
- generic [ref=f1e44]:
|
||||
- generic [ref=f1e45] [cursor=pointer]: Dashboard
|
||||
- generic [ref=f1e52] [cursor=pointer]:
|
||||
- generic [ref=f1e57]: Notifications
|
||||
- generic "1 pending" [ref=f1e59]: "1"
|
||||
- generic [ref=f1e61]:
|
||||
- button [expanded] [ref=f1e62] [cursor=pointer]:
|
||||
- paragraph [ref=f1e63]: Licensing
|
||||
- generic [ref=f1e66] [cursor=pointer]: My Applications
|
||||
- generic [ref=f1e73]:
|
||||
- button [expanded] [ref=f1e74] [cursor=pointer]:
|
||||
- paragraph [ref=f1e75]: Seafarer Services
|
||||
- generic [ref=f1e78] [cursor=pointer]: Seafarer Registration
|
||||
- generic [ref=f1e82] [cursor=pointer]: My Sea Records
|
||||
- generic [ref=f1e86] [cursor=pointer]: Seaman Book
|
||||
- generic [ref=f1e92] [cursor=pointer]: Basic Training Certificate
|
||||
- generic [ref=f1e98] [cursor=pointer]: Certificates
|
||||
- generic [ref=f1e104] [cursor=pointer]: Examinations
|
||||
- generic [ref=f1e108] [cursor=pointer]: Endorsements
|
||||
- generic [ref=f1e113]:
|
||||
- button [expanded] [ref=f1e114] [cursor=pointer]:
|
||||
- paragraph [ref=f1e115]: Account
|
||||
- generic [ref=f1e118] [cursor=pointer]: My Documents
|
||||
- generic [ref=f1e123] [cursor=pointer]: Profile
|
||||
- generic [ref=f1e130] [cursor=pointer]: Help & Support
|
||||
- button "Collapse" [ref=f1e139] [cursor=pointer]
|
||||
- main [ref=f1e143]:
|
||||
- generic [ref=f1e145]:
|
||||
- generic [ref=f1e147]:
|
||||
- heading "My Profile" [level=2] [ref=f1e148]
|
||||
- paragraph [ref=f1e149]: Manage your account details and preferences.
|
||||
- alert [ref=f1e150]:
|
||||
- generic [ref=f1e151]: Profile details are needed for seafarer registration.
|
||||
- generic [ref=f1e159]:
|
||||
- paragraph [ref=f1e161]: ES
|
||||
- generic [ref=f1e162]:
|
||||
- generic [ref=f1e163]:
|
||||
- heading "E2E seafarer 4609" [level=4] [ref=f1e164]
|
||||
- generic [ref=f1e165]: Unverified
|
||||
- paragraph [ref=f1e171]: e2e.seafarer.1787042485274609@example.test
|
||||
- generic [ref=f1e172]: e2eseafarer1787042485274609
|
||||
- generic "0% complete" [ref=f1e178]:
|
||||
- paragraph [ref=f1e183]: 0%
|
||||
- generic [ref=f1e184]:
|
||||
- tablist [ref=f1e185]:
|
||||
- tab "Personal" [ref=f1e186] [cursor=pointer]
|
||||
- tab "Profile" [selected] [ref=f1e193] [cursor=pointer]
|
||||
- tab "Address" [ref=f1e199] [cursor=pointer]
|
||||
- tab "Operations" [ref=f1e205] [cursor=pointer]
|
||||
- tab "Security" [ref=f1e212] [cursor=pointer]
|
||||
- tab "Preferences" [ref=f1e218] [cursor=pointer]
|
||||
- tabpanel "Profile" [ref=f1e224]:
|
||||
- generic [ref=f1e227]:
|
||||
- generic [ref=f1e228]:
|
||||
- heading "Maritime Profile" [level=5] [ref=f1e229]
|
||||
- paragraph [ref=f1e230]: Your professional maritime details
|
||||
- generic [ref=f1e231]:
|
||||
- generic [ref=f1e232]:
|
||||
- generic [ref=f1e233]: Profession *
|
||||
- textbox "Profession" [ref=f1e235]:
|
||||
- /placeholder: Select
|
||||
- text: Master Mariner
|
||||
- generic [ref=f1e236]:
|
||||
- generic [ref=f1e237]: First Name *
|
||||
- textbox "First Name" [ref=f1e239]:
|
||||
- /placeholder: Enter first name
|
||||
- text: Dawit
|
||||
- generic [ref=f1e240]:
|
||||
- generic [ref=f1e241]: Middle Name *
|
||||
- textbox "Middle Name" [ref=f1e243]:
|
||||
- /placeholder: Enter middle name
|
||||
- text: Bekele
|
||||
- generic [ref=f1e244]:
|
||||
- generic [ref=f1e245]: Last Name *
|
||||
- textbox "Last Name" [ref=f1e247]:
|
||||
- /placeholder: Enter last name
|
||||
- text: Tesfaye
|
||||
- generic [ref=f1e248]:
|
||||
- generic [ref=f1e249]: Gender *
|
||||
- textbox "Gender" [ref=f1e251] [cursor=pointer]:
|
||||
- /placeholder: Select
|
||||
- text: MALE
|
||||
- generic [ref=f1e252]:
|
||||
- generic [ref=f1e253]: Date of Birth *
|
||||
- generic [ref=f1e254]:
|
||||
- button "Switch calendar type" [ref=f1e256] [cursor=pointer]:
|
||||
- generic [ref=f1e257]: EN
|
||||
- textbox "Date of Birth" [ref=f1e259] [cursor=pointer]: Apr 12, 1995
|
||||
- button [ref=f1e261] [cursor=pointer]
|
||||
- generic [ref=f1e266]:
|
||||
- generic [ref=f1e267]: Place of Birth
|
||||
- textbox "Place of Birth" [ref=f1e269]:
|
||||
- /placeholder: City, Region
|
||||
- generic [ref=f1e270]:
|
||||
- generic [ref=f1e271]: Marital Status *
|
||||
- textbox "Marital Status" [ref=f1e273] [cursor=pointer]:
|
||||
- /placeholder: Select
|
||||
- text: SINGLE
|
||||
- button "Save Profile" [active] [ref=f1e275] [cursor=pointer]
|
||||
```
|
||||
|
||||
# Test source
|
||||
|
||||
```ts
|
||||
46 | await openTab(page, 'Address');
|
||||
47 | await pick(page, 'ID Type', /^NID$/i);
|
||||
48 | await page.getByLabel('ID Number').fill('FYD1234567890');
|
||||
49 | // A country select, not a free-text field.
|
||||
50 | await pick(page, 'Nationality', /ethiopia/i);
|
||||
51 | // `addressSchema` requires this in Ethiopian format; without it the form
|
||||
52 | // never submits and no request is made for `save` to wait on.
|
||||
53 | await page
|
||||
54 | .getByRole('textbox', { name: 'Primary Phone' })
|
||||
55 | .fill('+251911234567');
|
||||
56 | await save(page);
|
||||
57 | }
|
||||
58 |
|
||||
59 | /** Selects a profile tab and waits for its panel to be the visible one. */
|
||||
60 | async function openTab(page: Page, name: string): Promise<void> {
|
||||
61 | await page.getByRole('tab', { name, exact: true }).click();
|
||||
62 | await expect(page.getByRole('tabpanel', { name })).toBeVisible({
|
||||
63 | timeout: 15_000,
|
||||
64 | });
|
||||
65 | }
|
||||
66 |
|
||||
67 | /**
|
||||
68 | * Picks a value from a Mantine select.
|
||||
69 | *
|
||||
70 | * The label is bound to both the input and the listbox it opens, so matching
|
||||
71 | * by label alone is ambiguous once the dropdown is showing — the textbox role
|
||||
72 | * names the control itself.
|
||||
73 | */
|
||||
74 | async function pick(page: Page, label: string, option: RegExp): Promise<void> {
|
||||
75 | await page.getByRole('textbox', { name: label }).click();
|
||||
76 | await page.getByRole('option', { name: option }).first().click();
|
||||
77 | }
|
||||
78 |
|
||||
79 | /**
|
||||
80 | * Sets the date of birth through the picker's own UI.
|
||||
81 | *
|
||||
82 | * `AmharicDatePicker` is a controlled component: it reports changes through
|
||||
83 | * `onChange`, which is what writes the value into react-hook-form. Setting the
|
||||
84 | * input's `value` natively bypasses that entirely — the field stays empty as
|
||||
85 | * far as zod is concerned, and the form silently refuses to submit.
|
||||
86 | *
|
||||
87 | * So the calendar is actually driven: open it, pick the year and month from
|
||||
88 | * the caption dropdowns, then click the day.
|
||||
89 | */
|
||||
90 | async function pickDate(page: Page, label: string, iso: string): Promise<void> {
|
||||
91 | const [year, month, day] = iso.split('-').map(Number);
|
||||
92 |
|
||||
93 | await page.getByRole('textbox', { name: label }).click();
|
||||
94 | const calendar = page.locator('.amharic-daypicker-dropdown');
|
||||
95 | await expect(calendar).toBeVisible({ timeout: 10_000 });
|
||||
96 |
|
||||
97 | // `captionLayout="dropdown"` renders native selects for month and year.
|
||||
98 | await calendar.locator('select').last().selectOption(String(year));
|
||||
99 | await calendar
|
||||
100 | .locator('select')
|
||||
101 | .first()
|
||||
102 | .selectOption({ index: month - 1 });
|
||||
103 |
|
||||
104 | // Each day is a button whose accessible name is the full date
|
||||
105 | // ("Saturday, April 1st, 1995"), not the bare number — matching on the
|
||||
106 | // number alone finds nothing. Anchored on the ordinal so 1 cannot match 11
|
||||
107 | // or 21. Resolved after the dropdowns settle, since changing year or month
|
||||
108 | // re-renders the grid.
|
||||
109 | const cell = calendar
|
||||
110 | .getByRole('button', { name: new RegExp(`\\b${day}(st|nd|rd|th),`) })
|
||||
111 | .first();
|
||||
112 | await expect(cell).toBeVisible({ timeout: 10_000 });
|
||||
113 | await cell.click();
|
||||
114 |
|
||||
115 | await expect(calendar).toBeHidden({ timeout: 10_000 });
|
||||
116 |
|
||||
117 | // The picker writes through `onChange`; if that did not land, zod still sees
|
||||
118 | // an empty field and the failure would surface later as a refused submit.
|
||||
119 | await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', {
|
||||
120 | timeout: 10_000,
|
||||
121 | });
|
||||
122 | }
|
||||
123 |
|
||||
124 | async function save(page: Page): Promise<void> {
|
||||
125 | // Matched loosely on purpose: the personal tab PATCHes a user, the profile
|
||||
126 | // tab a profile, and the address tab POSTs to `/addresss/profile/:id` — the
|
||||
127 | // route's own spelling. Any successful write from this screen is the signal.
|
||||
128 | const saved = page.waitForResponse(
|
||||
129 | (r) =>
|
||||
130 | r.request().method() !== 'GET' &&
|
||||
131 | r.status() < 400 &&
|
||||
132 | /(profile|address|user)/i.test(r.url()),
|
||||
133 | { timeout: 20_000 },
|
||||
134 | );
|
||||
135 | await page.getByRole('button', { name: /save/i }).first().click();
|
||||
136 |
|
||||
137 | try {
|
||||
138 | await saved;
|
||||
139 | } catch (cause) {
|
||||
140 | // A zod-blocked submit fires no request at all, so the bare timeout says
|
||||
141 | // only "no response" — which reads as a backend fault rather than a form
|
||||
142 | // that refused to submit. Surface the field errors instead.
|
||||
143 | const messages = await page
|
||||
144 | .locator('.mantine-InputWrapper-error, [role="alert"]')
|
||||
145 | .allTextContents();
|
||||
> 146 | throw new Error(
|
||||
| ^ Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
||||
147 | messages.length
|
||||
148 | ? `Save did not submit — validation errors: ${messages.join('; ')}`
|
||||
149 | : 'Save produced no request and reported no validation error.',
|
||||
150 | { cause },
|
||||
151 | );
|
||||
152 | }
|
||||
153 | }
|
||||
154 |
|
||||
155 | /** Signs up, declares seafarer operations, and fills the gating profile. */
|
||||
156 | async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
|
||||
157 | const offset = await signUp(page, applicant);
|
||||
158 | await verifyOtpIfPrompted(page, offset);
|
||||
159 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
||||
160 | await page
|
||||
161 | .getByRole('checkbox', { name: /seafarer registration/i })
|
||||
162 | .first()
|
||||
163 | .check();
|
||||
164 | await page.getByRole('button', { name: /save operations/i }).click();
|
||||
165 | // A seafarer is taken to `/profile`, not the dashboard: registration is
|
||||
166 | // built from the profile, and a fresh signup holds none of it yet.
|
||||
167 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
168 | await completeProfile(page);
|
||||
169 | }
|
||||
170 |
|
||||
171 | test.describe('seafarer registration', () => {
|
||||
172 | let applicant: Applicant;
|
||||
173 |
|
||||
174 | test.beforeEach(() => {
|
||||
175 | applicant = newApplicant('seafarer');
|
||||
176 | });
|
||||
177 |
|
||||
178 | test.afterEach(() => {
|
||||
179 | deleteApplicant(applicant.email);
|
||||
180 | });
|
||||
181 |
|
||||
182 | test('the wizard refuses to open until the profile it is built from is complete', async ({
|
||||
183 | page,
|
||||
184 | }) => {
|
||||
185 | const offset = await signUp(page, applicant);
|
||||
186 | await verifyOtpIfPrompted(page, offset);
|
||||
187 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
||||
188 | await page
|
||||
189 | .getByRole('checkbox', { name: /seafarer registration/i })
|
||||
190 | .first()
|
||||
191 | .check();
|
||||
192 | await page.getByRole('button', { name: /save operations/i }).click();
|
||||
193 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
194 |
|
||||
195 | // A new account holds none of the identity the registration is filled in
|
||||
196 | // from, so the gate collects it rather than opening an uncompletable form.
|
||||
197 | await page.goto('/seafarer-registration');
|
||||
198 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
199 |
|
||||
200 | // The shared wizard route is gated identically — otherwise the gate is
|
||||
201 | // decoration a deep link walks straight past.
|
||||
202 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
203 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
204 | });
|
||||
205 |
|
||||
206 | test('opening the wizard creates the draft up front', async ({ page }) => {
|
||||
207 | await readyApplicant(page, applicant);
|
||||
208 |
|
||||
209 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
210 | await expect(page).not.toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
211 |
|
||||
212 | // The draft exists before anything is filled in, so uploads have an owner
|
||||
213 | // and closing the browser mid-wizard loses nothing.
|
||||
214 | const number = await waitForApplication(applicant.email);
|
||||
215 | expect(number).toMatch(/^SFR/);
|
||||
216 | expect(statusOf(number)).toBe('DRAFT');
|
||||
217 | });
|
||||
218 |
|
||||
219 | test('a registration never reaches evaluation or inspection', async ({
|
||||
220 | page,
|
||||
221 | }) => {
|
||||
222 | await readyApplicant(page, applicant);
|
||||
223 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
224 | const number = await waitForApplication(applicant.email);
|
||||
225 | const id = idOf(number);
|
||||
226 |
|
||||
227 | await submit(id);
|
||||
228 | await runWorkflow(id, [{ path: 'claim' }]);
|
||||
229 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
||||
230 |
|
||||
231 | // The licence course's middle stages have nothing to hold in a
|
||||
232 | // registration, and the transition table is the authority regardless of
|
||||
233 | // which endpoint is called.
|
||||
234 | const refused = await runWorkflow(id, [
|
||||
235 | { path: 'complete-review', expectFailure: true },
|
||||
236 | { path: 'approve-documents', expectFailure: true },
|
||||
237 | { path: 'record-inspection', expectFailure: true },
|
||||
238 | ]);
|
||||
239 | expect(refused.every((code) => code >= 400)).toBe(true);
|
||||
240 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
||||
241 | });
|
||||
242 |
|
||||
243 | test('an officer can return a registration for correction and take it back', async ({
|
||||
244 | page,
|
||||
245 | }) => {
|
||||
246 | await readyApplicant(page, applicant);
|
||||
```
|
||||
|
Before Width: | Height: | Size: 92 KiB |
@@ -1,348 +0,0 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: seafarer-registration.spec.ts >> seafarer registration >> a re-fired approval renumbers nobody and opens no second pair
|
||||
- Location: apps/e2e/src/seafarer-registration.spec.ts:336:7
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
||||
```
|
||||
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=f1e3]:
|
||||
- banner [ref=f1e4]:
|
||||
- generic [ref=f1e5]:
|
||||
- generic [ref=f1e6]:
|
||||
- button "Toggle navigation" [ref=f1e8] [cursor=pointer]
|
||||
- generic [ref=f1e10]:
|
||||
- generic [ref=f1e11]: Dashboard
|
||||
- generic [ref=f1e13]: Profile
|
||||
- generic [ref=f1e17]:
|
||||
- button "Language" [ref=f1e18] [cursor=pointer]
|
||||
- button "Toggle light / dark mode" [ref=f1e23] [cursor=pointer]
|
||||
- button "Notifications" [ref=f1e26] [cursor=pointer]:
|
||||
- generic [ref=f1e27]: "1"
|
||||
- button "ES" [ref=f1e32] [cursor=pointer]
|
||||
- navigation [ref=f1e34]:
|
||||
- generic [ref=f1e35]:
|
||||
- img "EMA" [ref=f1e36]
|
||||
- generic [ref=f1e37]:
|
||||
- paragraph [ref=f1e38]: EMA Portal
|
||||
- paragraph [ref=f1e39]: Ethiopian Maritime Authority
|
||||
- generic [ref=f1e43]:
|
||||
- generic [ref=f1e44]:
|
||||
- generic [ref=f1e45] [cursor=pointer]: Dashboard
|
||||
- generic [ref=f1e52] [cursor=pointer]:
|
||||
- generic [ref=f1e57]: Notifications
|
||||
- generic "1 pending" [ref=f1e59]: "1"
|
||||
- generic [ref=f1e61]:
|
||||
- button [expanded] [ref=f1e62] [cursor=pointer]:
|
||||
- paragraph [ref=f1e63]: Licensing
|
||||
- generic [ref=f1e66] [cursor=pointer]: My Applications
|
||||
- generic [ref=f1e73]:
|
||||
- button [expanded] [ref=f1e74] [cursor=pointer]:
|
||||
- paragraph [ref=f1e75]: Seafarer Services
|
||||
- generic [ref=f1e78] [cursor=pointer]: Seafarer Registration
|
||||
- generic [ref=f1e82] [cursor=pointer]: My Sea Records
|
||||
- generic [ref=f1e86] [cursor=pointer]: Seaman Book
|
||||
- generic [ref=f1e92] [cursor=pointer]: Basic Training Certificate
|
||||
- generic [ref=f1e98] [cursor=pointer]: Certificates
|
||||
- generic [ref=f1e104] [cursor=pointer]: Examinations
|
||||
- generic [ref=f1e108] [cursor=pointer]: Endorsements
|
||||
- generic [ref=f1e113]:
|
||||
- button [expanded] [ref=f1e114] [cursor=pointer]:
|
||||
- paragraph [ref=f1e115]: Account
|
||||
- generic [ref=f1e118] [cursor=pointer]: My Documents
|
||||
- generic [ref=f1e123] [cursor=pointer]: Profile
|
||||
- generic [ref=f1e130] [cursor=pointer]: Help & Support
|
||||
- button "Collapse" [ref=f1e139] [cursor=pointer]
|
||||
- main [ref=f1e143]:
|
||||
- generic [ref=f1e145]:
|
||||
- generic [ref=f1e147]:
|
||||
- heading "My Profile" [level=2] [ref=f1e148]
|
||||
- paragraph [ref=f1e149]: Manage your account details and preferences.
|
||||
- alert [ref=f1e150]:
|
||||
- generic [ref=f1e151]: Profile details are needed for seafarer registration.
|
||||
- generic [ref=f1e159]:
|
||||
- paragraph [ref=f1e161]: ES
|
||||
- generic [ref=f1e162]:
|
||||
- generic [ref=f1e163]:
|
||||
- heading "E2E seafarer 2538" [level=4] [ref=f1e164]
|
||||
- generic [ref=f1e165]: Unverified
|
||||
- paragraph [ref=f1e171]: e2e.seafarer.1787042515032538@example.test
|
||||
- generic [ref=f1e172]: e2eseafarer1787042515032538
|
||||
- generic "0% complete" [ref=f1e178]:
|
||||
- paragraph [ref=f1e183]: 0%
|
||||
- generic [ref=f1e184]:
|
||||
- tablist [ref=f1e185]:
|
||||
- tab "Personal" [ref=f1e186] [cursor=pointer]
|
||||
- tab "Profile" [selected] [ref=f1e193] [cursor=pointer]
|
||||
- tab "Address" [ref=f1e199] [cursor=pointer]
|
||||
- tab "Operations" [ref=f1e205] [cursor=pointer]
|
||||
- tab "Security" [ref=f1e212] [cursor=pointer]
|
||||
- tab "Preferences" [ref=f1e218] [cursor=pointer]
|
||||
- tabpanel "Profile" [ref=f1e224]:
|
||||
- generic [ref=f1e227]:
|
||||
- generic [ref=f1e228]:
|
||||
- heading "Maritime Profile" [level=5] [ref=f1e229]
|
||||
- paragraph [ref=f1e230]: Your professional maritime details
|
||||
- generic [ref=f1e231]:
|
||||
- generic [ref=f1e232]:
|
||||
- generic [ref=f1e233]: Profession *
|
||||
- textbox "Profession" [ref=f1e235]:
|
||||
- /placeholder: Select
|
||||
- text: Master Mariner
|
||||
- generic [ref=f1e236]:
|
||||
- generic [ref=f1e237]: First Name *
|
||||
- textbox "First Name" [ref=f1e239]:
|
||||
- /placeholder: Enter first name
|
||||
- text: Dawit
|
||||
- generic [ref=f1e240]:
|
||||
- generic [ref=f1e241]: Middle Name *
|
||||
- textbox "Middle Name" [ref=f1e243]:
|
||||
- /placeholder: Enter middle name
|
||||
- text: Bekele
|
||||
- generic [ref=f1e244]:
|
||||
- generic [ref=f1e245]: Last Name *
|
||||
- textbox "Last Name" [ref=f1e247]:
|
||||
- /placeholder: Enter last name
|
||||
- text: Tesfaye
|
||||
- generic [ref=f1e248]:
|
||||
- generic [ref=f1e249]: Gender *
|
||||
- textbox "Gender" [ref=f1e251] [cursor=pointer]:
|
||||
- /placeholder: Select
|
||||
- text: MALE
|
||||
- generic [ref=f1e252]:
|
||||
- generic [ref=f1e253]: Date of Birth *
|
||||
- generic [ref=f1e254]:
|
||||
- button "Switch calendar type" [ref=f1e256] [cursor=pointer]:
|
||||
- generic [ref=f1e257]: EN
|
||||
- textbox "Date of Birth" [ref=f1e259] [cursor=pointer]: Apr 12, 1995
|
||||
- button [ref=f1e261] [cursor=pointer]
|
||||
- generic [ref=f1e266]:
|
||||
- generic [ref=f1e267]: Place of Birth
|
||||
- textbox "Place of Birth" [ref=f1e269]:
|
||||
- /placeholder: City, Region
|
||||
- generic [ref=f1e270]:
|
||||
- generic [ref=f1e271]: Marital Status *
|
||||
- textbox "Marital Status" [ref=f1e273] [cursor=pointer]:
|
||||
- /placeholder: Select
|
||||
- text: SINGLE
|
||||
- button "Save Profile" [active] [ref=f1e275] [cursor=pointer]
|
||||
```
|
||||
|
||||
# Test source
|
||||
|
||||
```ts
|
||||
46 | await openTab(page, 'Address');
|
||||
47 | await pick(page, 'ID Type', /^NID$/i);
|
||||
48 | await page.getByLabel('ID Number').fill('FYD1234567890');
|
||||
49 | // A country select, not a free-text field.
|
||||
50 | await pick(page, 'Nationality', /ethiopia/i);
|
||||
51 | // `addressSchema` requires this in Ethiopian format; without it the form
|
||||
52 | // never submits and no request is made for `save` to wait on.
|
||||
53 | await page
|
||||
54 | .getByRole('textbox', { name: 'Primary Phone' })
|
||||
55 | .fill('+251911234567');
|
||||
56 | await save(page);
|
||||
57 | }
|
||||
58 |
|
||||
59 | /** Selects a profile tab and waits for its panel to be the visible one. */
|
||||
60 | async function openTab(page: Page, name: string): Promise<void> {
|
||||
61 | await page.getByRole('tab', { name, exact: true }).click();
|
||||
62 | await expect(page.getByRole('tabpanel', { name })).toBeVisible({
|
||||
63 | timeout: 15_000,
|
||||
64 | });
|
||||
65 | }
|
||||
66 |
|
||||
67 | /**
|
||||
68 | * Picks a value from a Mantine select.
|
||||
69 | *
|
||||
70 | * The label is bound to both the input and the listbox it opens, so matching
|
||||
71 | * by label alone is ambiguous once the dropdown is showing — the textbox role
|
||||
72 | * names the control itself.
|
||||
73 | */
|
||||
74 | async function pick(page: Page, label: string, option: RegExp): Promise<void> {
|
||||
75 | await page.getByRole('textbox', { name: label }).click();
|
||||
76 | await page.getByRole('option', { name: option }).first().click();
|
||||
77 | }
|
||||
78 |
|
||||
79 | /**
|
||||
80 | * Sets the date of birth through the picker's own UI.
|
||||
81 | *
|
||||
82 | * `AmharicDatePicker` is a controlled component: it reports changes through
|
||||
83 | * `onChange`, which is what writes the value into react-hook-form. Setting the
|
||||
84 | * input's `value` natively bypasses that entirely — the field stays empty as
|
||||
85 | * far as zod is concerned, and the form silently refuses to submit.
|
||||
86 | *
|
||||
87 | * So the calendar is actually driven: open it, pick the year and month from
|
||||
88 | * the caption dropdowns, then click the day.
|
||||
89 | */
|
||||
90 | async function pickDate(page: Page, label: string, iso: string): Promise<void> {
|
||||
91 | const [year, month, day] = iso.split('-').map(Number);
|
||||
92 |
|
||||
93 | await page.getByRole('textbox', { name: label }).click();
|
||||
94 | const calendar = page.locator('.amharic-daypicker-dropdown');
|
||||
95 | await expect(calendar).toBeVisible({ timeout: 10_000 });
|
||||
96 |
|
||||
97 | // `captionLayout="dropdown"` renders native selects for month and year.
|
||||
98 | await calendar.locator('select').last().selectOption(String(year));
|
||||
99 | await calendar
|
||||
100 | .locator('select')
|
||||
101 | .first()
|
||||
102 | .selectOption({ index: month - 1 });
|
||||
103 |
|
||||
104 | // Each day is a button whose accessible name is the full date
|
||||
105 | // ("Saturday, April 1st, 1995"), not the bare number — matching on the
|
||||
106 | // number alone finds nothing. Anchored on the ordinal so 1 cannot match 11
|
||||
107 | // or 21. Resolved after the dropdowns settle, since changing year or month
|
||||
108 | // re-renders the grid.
|
||||
109 | const cell = calendar
|
||||
110 | .getByRole('button', { name: new RegExp(`\\b${day}(st|nd|rd|th),`) })
|
||||
111 | .first();
|
||||
112 | await expect(cell).toBeVisible({ timeout: 10_000 });
|
||||
113 | await cell.click();
|
||||
114 |
|
||||
115 | await expect(calendar).toBeHidden({ timeout: 10_000 });
|
||||
116 |
|
||||
117 | // The picker writes through `onChange`; if that did not land, zod still sees
|
||||
118 | // an empty field and the failure would surface later as a refused submit.
|
||||
119 | await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', {
|
||||
120 | timeout: 10_000,
|
||||
121 | });
|
||||
122 | }
|
||||
123 |
|
||||
124 | async function save(page: Page): Promise<void> {
|
||||
125 | // Matched loosely on purpose: the personal tab PATCHes a user, the profile
|
||||
126 | // tab a profile, and the address tab POSTs to `/addresss/profile/:id` — the
|
||||
127 | // route's own spelling. Any successful write from this screen is the signal.
|
||||
128 | const saved = page.waitForResponse(
|
||||
129 | (r) =>
|
||||
130 | r.request().method() !== 'GET' &&
|
||||
131 | r.status() < 400 &&
|
||||
132 | /(profile|address|user)/i.test(r.url()),
|
||||
133 | { timeout: 20_000 },
|
||||
134 | );
|
||||
135 | await page.getByRole('button', { name: /save/i }).first().click();
|
||||
136 |
|
||||
137 | try {
|
||||
138 | await saved;
|
||||
139 | } catch (cause) {
|
||||
140 | // A zod-blocked submit fires no request at all, so the bare timeout says
|
||||
141 | // only "no response" — which reads as a backend fault rather than a form
|
||||
142 | // that refused to submit. Surface the field errors instead.
|
||||
143 | const messages = await page
|
||||
144 | .locator('.mantine-InputWrapper-error, [role="alert"]')
|
||||
145 | .allTextContents();
|
||||
> 146 | throw new Error(
|
||||
| ^ Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
||||
147 | messages.length
|
||||
148 | ? `Save did not submit — validation errors: ${messages.join('; ')}`
|
||||
149 | : 'Save produced no request and reported no validation error.',
|
||||
150 | { cause },
|
||||
151 | );
|
||||
152 | }
|
||||
153 | }
|
||||
154 |
|
||||
155 | /** Signs up, declares seafarer operations, and fills the gating profile. */
|
||||
156 | async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
|
||||
157 | const offset = await signUp(page, applicant);
|
||||
158 | await verifyOtpIfPrompted(page, offset);
|
||||
159 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
||||
160 | await page
|
||||
161 | .getByRole('checkbox', { name: /seafarer registration/i })
|
||||
162 | .first()
|
||||
163 | .check();
|
||||
164 | await page.getByRole('button', { name: /save operations/i }).click();
|
||||
165 | // A seafarer is taken to `/profile`, not the dashboard: registration is
|
||||
166 | // built from the profile, and a fresh signup holds none of it yet.
|
||||
167 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
168 | await completeProfile(page);
|
||||
169 | }
|
||||
170 |
|
||||
171 | test.describe('seafarer registration', () => {
|
||||
172 | let applicant: Applicant;
|
||||
173 |
|
||||
174 | test.beforeEach(() => {
|
||||
175 | applicant = newApplicant('seafarer');
|
||||
176 | });
|
||||
177 |
|
||||
178 | test.afterEach(() => {
|
||||
179 | deleteApplicant(applicant.email);
|
||||
180 | });
|
||||
181 |
|
||||
182 | test('the wizard refuses to open until the profile it is built from is complete', async ({
|
||||
183 | page,
|
||||
184 | }) => {
|
||||
185 | const offset = await signUp(page, applicant);
|
||||
186 | await verifyOtpIfPrompted(page, offset);
|
||||
187 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
||||
188 | await page
|
||||
189 | .getByRole('checkbox', { name: /seafarer registration/i })
|
||||
190 | .first()
|
||||
191 | .check();
|
||||
192 | await page.getByRole('button', { name: /save operations/i }).click();
|
||||
193 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
194 |
|
||||
195 | // A new account holds none of the identity the registration is filled in
|
||||
196 | // from, so the gate collects it rather than opening an uncompletable form.
|
||||
197 | await page.goto('/seafarer-registration');
|
||||
198 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
199 |
|
||||
200 | // The shared wizard route is gated identically — otherwise the gate is
|
||||
201 | // decoration a deep link walks straight past.
|
||||
202 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
203 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
204 | });
|
||||
205 |
|
||||
206 | test('opening the wizard creates the draft up front', async ({ page }) => {
|
||||
207 | await readyApplicant(page, applicant);
|
||||
208 |
|
||||
209 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
210 | await expect(page).not.toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
211 |
|
||||
212 | // The draft exists before anything is filled in, so uploads have an owner
|
||||
213 | // and closing the browser mid-wizard loses nothing.
|
||||
214 | const number = await waitForApplication(applicant.email);
|
||||
215 | expect(number).toMatch(/^SFR/);
|
||||
216 | expect(statusOf(number)).toBe('DRAFT');
|
||||
217 | });
|
||||
218 |
|
||||
219 | test('a registration never reaches evaluation or inspection', async ({
|
||||
220 | page,
|
||||
221 | }) => {
|
||||
222 | await readyApplicant(page, applicant);
|
||||
223 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
224 | const number = await waitForApplication(applicant.email);
|
||||
225 | const id = idOf(number);
|
||||
226 |
|
||||
227 | await submit(id);
|
||||
228 | await runWorkflow(id, [{ path: 'claim' }]);
|
||||
229 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
||||
230 |
|
||||
231 | // The licence course's middle stages have nothing to hold in a
|
||||
232 | // registration, and the transition table is the authority regardless of
|
||||
233 | // which endpoint is called.
|
||||
234 | const refused = await runWorkflow(id, [
|
||||
235 | { path: 'complete-review', expectFailure: true },
|
||||
236 | { path: 'approve-documents', expectFailure: true },
|
||||
237 | { path: 'record-inspection', expectFailure: true },
|
||||
238 | ]);
|
||||
239 | expect(refused.every((code) => code >= 400)).toBe(true);
|
||||
240 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
||||
241 | });
|
||||
242 |
|
||||
243 | test('an officer can return a registration for correction and take it back', async ({
|
||||
244 | page,
|
||||
245 | }) => {
|
||||
246 | await readyApplicant(page, applicant);
|
||||
```
|
||||
|
Before Width: | Height: | Size: 92 KiB |
@@ -1,348 +0,0 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: seafarer-registration.spec.ts >> seafarer registration >> an officer can return a registration for correction and take it back
|
||||
- Location: apps/e2e/src/seafarer-registration.spec.ts:243:7
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
||||
```
|
||||
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=f1e3]:
|
||||
- banner [ref=f1e4]:
|
||||
- generic [ref=f1e5]:
|
||||
- generic [ref=f1e6]:
|
||||
- button "Toggle navigation" [ref=f1e8] [cursor=pointer]
|
||||
- generic [ref=f1e10]:
|
||||
- generic [ref=f1e11]: Dashboard
|
||||
- generic [ref=f1e13]: Profile
|
||||
- generic [ref=f1e17]:
|
||||
- button "Language" [ref=f1e18] [cursor=pointer]
|
||||
- button "Toggle light / dark mode" [ref=f1e23] [cursor=pointer]
|
||||
- button "Notifications" [ref=f1e26] [cursor=pointer]:
|
||||
- generic [ref=f1e27]: "1"
|
||||
- button "ES" [ref=f1e32] [cursor=pointer]
|
||||
- navigation [ref=f1e34]:
|
||||
- generic [ref=f1e35]:
|
||||
- img "EMA" [ref=f1e36]
|
||||
- generic [ref=f1e37]:
|
||||
- paragraph [ref=f1e38]: EMA Portal
|
||||
- paragraph [ref=f1e39]: Ethiopian Maritime Authority
|
||||
- generic [ref=f1e43]:
|
||||
- generic [ref=f1e44]:
|
||||
- generic [ref=f1e45] [cursor=pointer]: Dashboard
|
||||
- generic [ref=f1e52] [cursor=pointer]:
|
||||
- generic [ref=f1e57]: Notifications
|
||||
- generic "1 pending" [ref=f1e59]: "1"
|
||||
- generic [ref=f1e61]:
|
||||
- button [expanded] [ref=f1e62] [cursor=pointer]:
|
||||
- paragraph [ref=f1e63]: Licensing
|
||||
- generic [ref=f1e66] [cursor=pointer]: My Applications
|
||||
- generic [ref=f1e73]:
|
||||
- button [expanded] [ref=f1e74] [cursor=pointer]:
|
||||
- paragraph [ref=f1e75]: Seafarer Services
|
||||
- generic [ref=f1e78] [cursor=pointer]: Seafarer Registration
|
||||
- generic [ref=f1e82] [cursor=pointer]: My Sea Records
|
||||
- generic [ref=f1e86] [cursor=pointer]: Seaman Book
|
||||
- generic [ref=f1e92] [cursor=pointer]: Basic Training Certificate
|
||||
- generic [ref=f1e98] [cursor=pointer]: Certificates
|
||||
- generic [ref=f1e104] [cursor=pointer]: Examinations
|
||||
- generic [ref=f1e108] [cursor=pointer]: Endorsements
|
||||
- generic [ref=f1e113]:
|
||||
- button [expanded] [ref=f1e114] [cursor=pointer]:
|
||||
- paragraph [ref=f1e115]: Account
|
||||
- generic [ref=f1e118] [cursor=pointer]: My Documents
|
||||
- generic [ref=f1e123] [cursor=pointer]: Profile
|
||||
- generic [ref=f1e130] [cursor=pointer]: Help & Support
|
||||
- button "Collapse" [ref=f1e139] [cursor=pointer]
|
||||
- main [ref=f1e143]:
|
||||
- generic [ref=f1e145]:
|
||||
- generic [ref=f1e147]:
|
||||
- heading "My Profile" [level=2] [ref=f1e148]
|
||||
- paragraph [ref=f1e149]: Manage your account details and preferences.
|
||||
- alert [ref=f1e150]:
|
||||
- generic [ref=f1e151]: Profile details are needed for seafarer registration.
|
||||
- generic [ref=f1e159]:
|
||||
- paragraph [ref=f1e161]: ES
|
||||
- generic [ref=f1e162]:
|
||||
- generic [ref=f1e163]:
|
||||
- heading "E2E seafarer 5517" [level=4] [ref=f1e164]
|
||||
- generic [ref=f1e165]: Unverified
|
||||
- paragraph [ref=f1e171]: e2e.seafarer.1787042391965517@example.test
|
||||
- generic [ref=f1e172]: e2eseafarer1787042391965517
|
||||
- generic "0% complete" [ref=f1e178]:
|
||||
- paragraph [ref=f1e183]: 0%
|
||||
- generic [ref=f1e184]:
|
||||
- tablist [ref=f1e185]:
|
||||
- tab "Personal" [ref=f1e186] [cursor=pointer]
|
||||
- tab "Profile" [selected] [ref=f1e193] [cursor=pointer]
|
||||
- tab "Address" [ref=f1e199] [cursor=pointer]
|
||||
- tab "Operations" [ref=f1e205] [cursor=pointer]
|
||||
- tab "Security" [ref=f1e212] [cursor=pointer]
|
||||
- tab "Preferences" [ref=f1e218] [cursor=pointer]
|
||||
- tabpanel "Profile" [ref=f1e224]:
|
||||
- generic [ref=f1e227]:
|
||||
- generic [ref=f1e228]:
|
||||
- heading "Maritime Profile" [level=5] [ref=f1e229]
|
||||
- paragraph [ref=f1e230]: Your professional maritime details
|
||||
- generic [ref=f1e231]:
|
||||
- generic [ref=f1e232]:
|
||||
- generic [ref=f1e233]: Profession *
|
||||
- textbox "Profession" [ref=f1e235]:
|
||||
- /placeholder: Select
|
||||
- text: Master Mariner
|
||||
- generic [ref=f1e236]:
|
||||
- generic [ref=f1e237]: First Name *
|
||||
- textbox "First Name" [ref=f1e239]:
|
||||
- /placeholder: Enter first name
|
||||
- text: Dawit
|
||||
- generic [ref=f1e240]:
|
||||
- generic [ref=f1e241]: Middle Name *
|
||||
- textbox "Middle Name" [ref=f1e243]:
|
||||
- /placeholder: Enter middle name
|
||||
- text: Bekele
|
||||
- generic [ref=f1e244]:
|
||||
- generic [ref=f1e245]: Last Name *
|
||||
- textbox "Last Name" [ref=f1e247]:
|
||||
- /placeholder: Enter last name
|
||||
- text: Tesfaye
|
||||
- generic [ref=f1e248]:
|
||||
- generic [ref=f1e249]: Gender *
|
||||
- textbox "Gender" [ref=f1e251] [cursor=pointer]:
|
||||
- /placeholder: Select
|
||||
- text: MALE
|
||||
- generic [ref=f1e252]:
|
||||
- generic [ref=f1e253]: Date of Birth *
|
||||
- generic [ref=f1e254]:
|
||||
- button "Switch calendar type" [ref=f1e256] [cursor=pointer]:
|
||||
- generic [ref=f1e257]: EN
|
||||
- textbox "Date of Birth" [ref=f1e259] [cursor=pointer]: Apr 12, 1995
|
||||
- button [ref=f1e261] [cursor=pointer]
|
||||
- generic [ref=f1e266]:
|
||||
- generic [ref=f1e267]: Place of Birth
|
||||
- textbox "Place of Birth" [ref=f1e269]:
|
||||
- /placeholder: City, Region
|
||||
- generic [ref=f1e270]:
|
||||
- generic [ref=f1e271]: Marital Status *
|
||||
- textbox "Marital Status" [ref=f1e273] [cursor=pointer]:
|
||||
- /placeholder: Select
|
||||
- text: SINGLE
|
||||
- button "Save Profile" [active] [ref=f1e275] [cursor=pointer]
|
||||
```
|
||||
|
||||
# Test source
|
||||
|
||||
```ts
|
||||
46 | await openTab(page, 'Address');
|
||||
47 | await pick(page, 'ID Type', /^NID$/i);
|
||||
48 | await page.getByLabel('ID Number').fill('FYD1234567890');
|
||||
49 | // A country select, not a free-text field.
|
||||
50 | await pick(page, 'Nationality', /ethiopia/i);
|
||||
51 | // `addressSchema` requires this in Ethiopian format; without it the form
|
||||
52 | // never submits and no request is made for `save` to wait on.
|
||||
53 | await page
|
||||
54 | .getByRole('textbox', { name: 'Primary Phone' })
|
||||
55 | .fill('+251911234567');
|
||||
56 | await save(page);
|
||||
57 | }
|
||||
58 |
|
||||
59 | /** Selects a profile tab and waits for its panel to be the visible one. */
|
||||
60 | async function openTab(page: Page, name: string): Promise<void> {
|
||||
61 | await page.getByRole('tab', { name, exact: true }).click();
|
||||
62 | await expect(page.getByRole('tabpanel', { name })).toBeVisible({
|
||||
63 | timeout: 15_000,
|
||||
64 | });
|
||||
65 | }
|
||||
66 |
|
||||
67 | /**
|
||||
68 | * Picks a value from a Mantine select.
|
||||
69 | *
|
||||
70 | * The label is bound to both the input and the listbox it opens, so matching
|
||||
71 | * by label alone is ambiguous once the dropdown is showing — the textbox role
|
||||
72 | * names the control itself.
|
||||
73 | */
|
||||
74 | async function pick(page: Page, label: string, option: RegExp): Promise<void> {
|
||||
75 | await page.getByRole('textbox', { name: label }).click();
|
||||
76 | await page.getByRole('option', { name: option }).first().click();
|
||||
77 | }
|
||||
78 |
|
||||
79 | /**
|
||||
80 | * Sets the date of birth through the picker's own UI.
|
||||
81 | *
|
||||
82 | * `AmharicDatePicker` is a controlled component: it reports changes through
|
||||
83 | * `onChange`, which is what writes the value into react-hook-form. Setting the
|
||||
84 | * input's `value` natively bypasses that entirely — the field stays empty as
|
||||
85 | * far as zod is concerned, and the form silently refuses to submit.
|
||||
86 | *
|
||||
87 | * So the calendar is actually driven: open it, pick the year and month from
|
||||
88 | * the caption dropdowns, then click the day.
|
||||
89 | */
|
||||
90 | async function pickDate(page: Page, label: string, iso: string): Promise<void> {
|
||||
91 | const [year, month, day] = iso.split('-').map(Number);
|
||||
92 |
|
||||
93 | await page.getByRole('textbox', { name: label }).click();
|
||||
94 | const calendar = page.locator('.amharic-daypicker-dropdown');
|
||||
95 | await expect(calendar).toBeVisible({ timeout: 10_000 });
|
||||
96 |
|
||||
97 | // `captionLayout="dropdown"` renders native selects for month and year.
|
||||
98 | await calendar.locator('select').last().selectOption(String(year));
|
||||
99 | await calendar
|
||||
100 | .locator('select')
|
||||
101 | .first()
|
||||
102 | .selectOption({ index: month - 1 });
|
||||
103 |
|
||||
104 | // Each day is a button whose accessible name is the full date
|
||||
105 | // ("Saturday, April 1st, 1995"), not the bare number — matching on the
|
||||
106 | // number alone finds nothing. Anchored on the ordinal so 1 cannot match 11
|
||||
107 | // or 21. Resolved after the dropdowns settle, since changing year or month
|
||||
108 | // re-renders the grid.
|
||||
109 | const cell = calendar
|
||||
110 | .getByRole('button', { name: new RegExp(`\\b${day}(st|nd|rd|th),`) })
|
||||
111 | .first();
|
||||
112 | await expect(cell).toBeVisible({ timeout: 10_000 });
|
||||
113 | await cell.click();
|
||||
114 |
|
||||
115 | await expect(calendar).toBeHidden({ timeout: 10_000 });
|
||||
116 |
|
||||
117 | // The picker writes through `onChange`; if that did not land, zod still sees
|
||||
118 | // an empty field and the failure would surface later as a refused submit.
|
||||
119 | await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', {
|
||||
120 | timeout: 10_000,
|
||||
121 | });
|
||||
122 | }
|
||||
123 |
|
||||
124 | async function save(page: Page): Promise<void> {
|
||||
125 | // Matched loosely on purpose: the personal tab PATCHes a user, the profile
|
||||
126 | // tab a profile, and the address tab POSTs to `/addresss/profile/:id` — the
|
||||
127 | // route's own spelling. Any successful write from this screen is the signal.
|
||||
128 | const saved = page.waitForResponse(
|
||||
129 | (r) =>
|
||||
130 | r.request().method() !== 'GET' &&
|
||||
131 | r.status() < 400 &&
|
||||
132 | /(profile|address|user)/i.test(r.url()),
|
||||
133 | { timeout: 20_000 },
|
||||
134 | );
|
||||
135 | await page.getByRole('button', { name: /save/i }).first().click();
|
||||
136 |
|
||||
137 | try {
|
||||
138 | await saved;
|
||||
139 | } catch (cause) {
|
||||
140 | // A zod-blocked submit fires no request at all, so the bare timeout says
|
||||
141 | // only "no response" — which reads as a backend fault rather than a form
|
||||
142 | // that refused to submit. Surface the field errors instead.
|
||||
143 | const messages = await page
|
||||
144 | .locator('.mantine-InputWrapper-error, [role="alert"]')
|
||||
145 | .allTextContents();
|
||||
> 146 | throw new Error(
|
||||
| ^ Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
||||
147 | messages.length
|
||||
148 | ? `Save did not submit — validation errors: ${messages.join('; ')}`
|
||||
149 | : 'Save produced no request and reported no validation error.',
|
||||
150 | { cause },
|
||||
151 | );
|
||||
152 | }
|
||||
153 | }
|
||||
154 |
|
||||
155 | /** Signs up, declares seafarer operations, and fills the gating profile. */
|
||||
156 | async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
|
||||
157 | const offset = await signUp(page, applicant);
|
||||
158 | await verifyOtpIfPrompted(page, offset);
|
||||
159 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
||||
160 | await page
|
||||
161 | .getByRole('checkbox', { name: /seafarer registration/i })
|
||||
162 | .first()
|
||||
163 | .check();
|
||||
164 | await page.getByRole('button', { name: /save operations/i }).click();
|
||||
165 | // A seafarer is taken to `/profile`, not the dashboard: registration is
|
||||
166 | // built from the profile, and a fresh signup holds none of it yet.
|
||||
167 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
168 | await completeProfile(page);
|
||||
169 | }
|
||||
170 |
|
||||
171 | test.describe('seafarer registration', () => {
|
||||
172 | let applicant: Applicant;
|
||||
173 |
|
||||
174 | test.beforeEach(() => {
|
||||
175 | applicant = newApplicant('seafarer');
|
||||
176 | });
|
||||
177 |
|
||||
178 | test.afterEach(() => {
|
||||
179 | deleteApplicant(applicant.email);
|
||||
180 | });
|
||||
181 |
|
||||
182 | test('the wizard refuses to open until the profile it is built from is complete', async ({
|
||||
183 | page,
|
||||
184 | }) => {
|
||||
185 | const offset = await signUp(page, applicant);
|
||||
186 | await verifyOtpIfPrompted(page, offset);
|
||||
187 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
||||
188 | await page
|
||||
189 | .getByRole('checkbox', { name: /seafarer registration/i })
|
||||
190 | .first()
|
||||
191 | .check();
|
||||
192 | await page.getByRole('button', { name: /save operations/i }).click();
|
||||
193 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
194 |
|
||||
195 | // A new account holds none of the identity the registration is filled in
|
||||
196 | // from, so the gate collects it rather than opening an uncompletable form.
|
||||
197 | await page.goto('/seafarer-registration');
|
||||
198 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
199 |
|
||||
200 | // The shared wizard route is gated identically — otherwise the gate is
|
||||
201 | // decoration a deep link walks straight past.
|
||||
202 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
203 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
204 | });
|
||||
205 |
|
||||
206 | test('opening the wizard creates the draft up front', async ({ page }) => {
|
||||
207 | await readyApplicant(page, applicant);
|
||||
208 |
|
||||
209 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
210 | await expect(page).not.toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
211 |
|
||||
212 | // The draft exists before anything is filled in, so uploads have an owner
|
||||
213 | // and closing the browser mid-wizard loses nothing.
|
||||
214 | const number = await waitForApplication(applicant.email);
|
||||
215 | expect(number).toMatch(/^SFR/);
|
||||
216 | expect(statusOf(number)).toBe('DRAFT');
|
||||
217 | });
|
||||
218 |
|
||||
219 | test('a registration never reaches evaluation or inspection', async ({
|
||||
220 | page,
|
||||
221 | }) => {
|
||||
222 | await readyApplicant(page, applicant);
|
||||
223 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
224 | const number = await waitForApplication(applicant.email);
|
||||
225 | const id = idOf(number);
|
||||
226 |
|
||||
227 | await submit(id);
|
||||
228 | await runWorkflow(id, [{ path: 'claim' }]);
|
||||
229 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
||||
230 |
|
||||
231 | // The licence course's middle stages have nothing to hold in a
|
||||
232 | // registration, and the transition table is the authority regardless of
|
||||
233 | // which endpoint is called.
|
||||
234 | const refused = await runWorkflow(id, [
|
||||
235 | { path: 'complete-review', expectFailure: true },
|
||||
236 | { path: 'approve-documents', expectFailure: true },
|
||||
237 | { path: 'record-inspection', expectFailure: true },
|
||||
238 | ]);
|
||||
239 | expect(refused.every((code) => code >= 400)).toBe(true);
|
||||
240 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
||||
241 | });
|
||||
242 |
|
||||
243 | test('an officer can return a registration for correction and take it back', async ({
|
||||
244 | page,
|
||||
245 | }) => {
|
||||
246 | await readyApplicant(page, applicant);
|
||||
```
|
||||
|
Before Width: | Height: | Size: 92 KiB |
@@ -1,348 +0,0 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: seafarer-registration.spec.ts >> seafarer registration >> an officer can hold and resume a registration
|
||||
- Location: apps/e2e/src/seafarer-registration.spec.ts:267:7
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
||||
```
|
||||
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=f1e3]:
|
||||
- banner [ref=f1e4]:
|
||||
- generic [ref=f1e5]:
|
||||
- generic [ref=f1e6]:
|
||||
- button "Toggle navigation" [ref=f1e8] [cursor=pointer]
|
||||
- generic [ref=f1e10]:
|
||||
- generic [ref=f1e11]: Dashboard
|
||||
- generic [ref=f1e13]: Profile
|
||||
- generic [ref=f1e17]:
|
||||
- button "Language" [ref=f1e18] [cursor=pointer]
|
||||
- button "Toggle light / dark mode" [ref=f1e23] [cursor=pointer]
|
||||
- button "Notifications" [ref=f1e26] [cursor=pointer]:
|
||||
- generic [ref=f1e27]: "1"
|
||||
- button "ES" [ref=f1e32] [cursor=pointer]
|
||||
- navigation [ref=f1e34]:
|
||||
- generic [ref=f1e35]:
|
||||
- img "EMA" [ref=f1e36]
|
||||
- generic [ref=f1e37]:
|
||||
- paragraph [ref=f1e38]: EMA Portal
|
||||
- paragraph [ref=f1e39]: Ethiopian Maritime Authority
|
||||
- generic [ref=f1e43]:
|
||||
- generic [ref=f1e44]:
|
||||
- generic [ref=f1e45] [cursor=pointer]: Dashboard
|
||||
- generic [ref=f1e52] [cursor=pointer]:
|
||||
- generic [ref=f1e57]: Notifications
|
||||
- generic "1 pending" [ref=f1e59]: "1"
|
||||
- generic [ref=f1e61]:
|
||||
- button [expanded] [ref=f1e62] [cursor=pointer]:
|
||||
- paragraph [ref=f1e63]: Licensing
|
||||
- generic [ref=f1e66] [cursor=pointer]: My Applications
|
||||
- generic [ref=f1e73]:
|
||||
- button [expanded] [ref=f1e74] [cursor=pointer]:
|
||||
- paragraph [ref=f1e75]: Seafarer Services
|
||||
- generic [ref=f1e78] [cursor=pointer]: Seafarer Registration
|
||||
- generic [ref=f1e82] [cursor=pointer]: My Sea Records
|
||||
- generic [ref=f1e86] [cursor=pointer]: Seaman Book
|
||||
- generic [ref=f1e92] [cursor=pointer]: Basic Training Certificate
|
||||
- generic [ref=f1e98] [cursor=pointer]: Certificates
|
||||
- generic [ref=f1e104] [cursor=pointer]: Examinations
|
||||
- generic [ref=f1e108] [cursor=pointer]: Endorsements
|
||||
- generic [ref=f1e113]:
|
||||
- button [expanded] [ref=f1e114] [cursor=pointer]:
|
||||
- paragraph [ref=f1e115]: Account
|
||||
- generic [ref=f1e118] [cursor=pointer]: My Documents
|
||||
- generic [ref=f1e123] [cursor=pointer]: Profile
|
||||
- generic [ref=f1e130] [cursor=pointer]: Help & Support
|
||||
- button "Collapse" [ref=f1e139] [cursor=pointer]
|
||||
- main [ref=f1e143]:
|
||||
- generic [ref=f1e145]:
|
||||
- generic [ref=f1e147]:
|
||||
- heading "My Profile" [level=2] [ref=f1e148]
|
||||
- paragraph [ref=f1e149]: Manage your account details and preferences.
|
||||
- alert [ref=f1e150]:
|
||||
- generic [ref=f1e151]: Profile details are needed for seafarer registration.
|
||||
- generic [ref=f1e159]:
|
||||
- paragraph [ref=f1e161]: ES
|
||||
- generic [ref=f1e162]:
|
||||
- generic [ref=f1e163]:
|
||||
- heading "E2E seafarer 2368" [level=4] [ref=f1e164]
|
||||
- generic [ref=f1e165]: Unverified
|
||||
- paragraph [ref=f1e171]: e2e.seafarer.1787042424082368@example.test
|
||||
- generic [ref=f1e172]: e2eseafarer1787042424082368
|
||||
- generic "0% complete" [ref=f1e178]:
|
||||
- paragraph [ref=f1e183]: 0%
|
||||
- generic [ref=f1e184]:
|
||||
- tablist [ref=f1e185]:
|
||||
- tab "Personal" [ref=f1e186] [cursor=pointer]
|
||||
- tab "Profile" [selected] [ref=f1e193] [cursor=pointer]
|
||||
- tab "Address" [ref=f1e199] [cursor=pointer]
|
||||
- tab "Operations" [ref=f1e205] [cursor=pointer]
|
||||
- tab "Security" [ref=f1e212] [cursor=pointer]
|
||||
- tab "Preferences" [ref=f1e218] [cursor=pointer]
|
||||
- tabpanel "Profile" [ref=f1e224]:
|
||||
- generic [ref=f1e227]:
|
||||
- generic [ref=f1e228]:
|
||||
- heading "Maritime Profile" [level=5] [ref=f1e229]
|
||||
- paragraph [ref=f1e230]: Your professional maritime details
|
||||
- generic [ref=f1e231]:
|
||||
- generic [ref=f1e232]:
|
||||
- generic [ref=f1e233]: Profession *
|
||||
- textbox "Profession" [ref=f1e235]:
|
||||
- /placeholder: Select
|
||||
- text: Master Mariner
|
||||
- generic [ref=f1e236]:
|
||||
- generic [ref=f1e237]: First Name *
|
||||
- textbox "First Name" [ref=f1e239]:
|
||||
- /placeholder: Enter first name
|
||||
- text: Dawit
|
||||
- generic [ref=f1e240]:
|
||||
- generic [ref=f1e241]: Middle Name *
|
||||
- textbox "Middle Name" [ref=f1e243]:
|
||||
- /placeholder: Enter middle name
|
||||
- text: Bekele
|
||||
- generic [ref=f1e244]:
|
||||
- generic [ref=f1e245]: Last Name *
|
||||
- textbox "Last Name" [ref=f1e247]:
|
||||
- /placeholder: Enter last name
|
||||
- text: Tesfaye
|
||||
- generic [ref=f1e248]:
|
||||
- generic [ref=f1e249]: Gender *
|
||||
- textbox "Gender" [ref=f1e251] [cursor=pointer]:
|
||||
- /placeholder: Select
|
||||
- text: MALE
|
||||
- generic [ref=f1e252]:
|
||||
- generic [ref=f1e253]: Date of Birth *
|
||||
- generic [ref=f1e254]:
|
||||
- button "Switch calendar type" [ref=f1e256] [cursor=pointer]:
|
||||
- generic [ref=f1e257]: EN
|
||||
- textbox "Date of Birth" [ref=f1e259] [cursor=pointer]: Apr 12, 1995
|
||||
- button [ref=f1e261] [cursor=pointer]
|
||||
- generic [ref=f1e266]:
|
||||
- generic [ref=f1e267]: Place of Birth
|
||||
- textbox "Place of Birth" [ref=f1e269]:
|
||||
- /placeholder: City, Region
|
||||
- generic [ref=f1e270]:
|
||||
- generic [ref=f1e271]: Marital Status *
|
||||
- textbox "Marital Status" [ref=f1e273] [cursor=pointer]:
|
||||
- /placeholder: Select
|
||||
- text: SINGLE
|
||||
- button "Save Profile" [active] [ref=f1e275] [cursor=pointer]
|
||||
```
|
||||
|
||||
# Test source
|
||||
|
||||
```ts
|
||||
46 | await openTab(page, 'Address');
|
||||
47 | await pick(page, 'ID Type', /^NID$/i);
|
||||
48 | await page.getByLabel('ID Number').fill('FYD1234567890');
|
||||
49 | // A country select, not a free-text field.
|
||||
50 | await pick(page, 'Nationality', /ethiopia/i);
|
||||
51 | // `addressSchema` requires this in Ethiopian format; without it the form
|
||||
52 | // never submits and no request is made for `save` to wait on.
|
||||
53 | await page
|
||||
54 | .getByRole('textbox', { name: 'Primary Phone' })
|
||||
55 | .fill('+251911234567');
|
||||
56 | await save(page);
|
||||
57 | }
|
||||
58 |
|
||||
59 | /** Selects a profile tab and waits for its panel to be the visible one. */
|
||||
60 | async function openTab(page: Page, name: string): Promise<void> {
|
||||
61 | await page.getByRole('tab', { name, exact: true }).click();
|
||||
62 | await expect(page.getByRole('tabpanel', { name })).toBeVisible({
|
||||
63 | timeout: 15_000,
|
||||
64 | });
|
||||
65 | }
|
||||
66 |
|
||||
67 | /**
|
||||
68 | * Picks a value from a Mantine select.
|
||||
69 | *
|
||||
70 | * The label is bound to both the input and the listbox it opens, so matching
|
||||
71 | * by label alone is ambiguous once the dropdown is showing — the textbox role
|
||||
72 | * names the control itself.
|
||||
73 | */
|
||||
74 | async function pick(page: Page, label: string, option: RegExp): Promise<void> {
|
||||
75 | await page.getByRole('textbox', { name: label }).click();
|
||||
76 | await page.getByRole('option', { name: option }).first().click();
|
||||
77 | }
|
||||
78 |
|
||||
79 | /**
|
||||
80 | * Sets the date of birth through the picker's own UI.
|
||||
81 | *
|
||||
82 | * `AmharicDatePicker` is a controlled component: it reports changes through
|
||||
83 | * `onChange`, which is what writes the value into react-hook-form. Setting the
|
||||
84 | * input's `value` natively bypasses that entirely — the field stays empty as
|
||||
85 | * far as zod is concerned, and the form silently refuses to submit.
|
||||
86 | *
|
||||
87 | * So the calendar is actually driven: open it, pick the year and month from
|
||||
88 | * the caption dropdowns, then click the day.
|
||||
89 | */
|
||||
90 | async function pickDate(page: Page, label: string, iso: string): Promise<void> {
|
||||
91 | const [year, month, day] = iso.split('-').map(Number);
|
||||
92 |
|
||||
93 | await page.getByRole('textbox', { name: label }).click();
|
||||
94 | const calendar = page.locator('.amharic-daypicker-dropdown');
|
||||
95 | await expect(calendar).toBeVisible({ timeout: 10_000 });
|
||||
96 |
|
||||
97 | // `captionLayout="dropdown"` renders native selects for month and year.
|
||||
98 | await calendar.locator('select').last().selectOption(String(year));
|
||||
99 | await calendar
|
||||
100 | .locator('select')
|
||||
101 | .first()
|
||||
102 | .selectOption({ index: month - 1 });
|
||||
103 |
|
||||
104 | // Each day is a button whose accessible name is the full date
|
||||
105 | // ("Saturday, April 1st, 1995"), not the bare number — matching on the
|
||||
106 | // number alone finds nothing. Anchored on the ordinal so 1 cannot match 11
|
||||
107 | // or 21. Resolved after the dropdowns settle, since changing year or month
|
||||
108 | // re-renders the grid.
|
||||
109 | const cell = calendar
|
||||
110 | .getByRole('button', { name: new RegExp(`\\b${day}(st|nd|rd|th),`) })
|
||||
111 | .first();
|
||||
112 | await expect(cell).toBeVisible({ timeout: 10_000 });
|
||||
113 | await cell.click();
|
||||
114 |
|
||||
115 | await expect(calendar).toBeHidden({ timeout: 10_000 });
|
||||
116 |
|
||||
117 | // The picker writes through `onChange`; if that did not land, zod still sees
|
||||
118 | // an empty field and the failure would surface later as a refused submit.
|
||||
119 | await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', {
|
||||
120 | timeout: 10_000,
|
||||
121 | });
|
||||
122 | }
|
||||
123 |
|
||||
124 | async function save(page: Page): Promise<void> {
|
||||
125 | // Matched loosely on purpose: the personal tab PATCHes a user, the profile
|
||||
126 | // tab a profile, and the address tab POSTs to `/addresss/profile/:id` — the
|
||||
127 | // route's own spelling. Any successful write from this screen is the signal.
|
||||
128 | const saved = page.waitForResponse(
|
||||
129 | (r) =>
|
||||
130 | r.request().method() !== 'GET' &&
|
||||
131 | r.status() < 400 &&
|
||||
132 | /(profile|address|user)/i.test(r.url()),
|
||||
133 | { timeout: 20_000 },
|
||||
134 | );
|
||||
135 | await page.getByRole('button', { name: /save/i }).first().click();
|
||||
136 |
|
||||
137 | try {
|
||||
138 | await saved;
|
||||
139 | } catch (cause) {
|
||||
140 | // A zod-blocked submit fires no request at all, so the bare timeout says
|
||||
141 | // only "no response" — which reads as a backend fault rather than a form
|
||||
142 | // that refused to submit. Surface the field errors instead.
|
||||
143 | const messages = await page
|
||||
144 | .locator('.mantine-InputWrapper-error, [role="alert"]')
|
||||
145 | .allTextContents();
|
||||
> 146 | throw new Error(
|
||||
| ^ Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
||||
147 | messages.length
|
||||
148 | ? `Save did not submit — validation errors: ${messages.join('; ')}`
|
||||
149 | : 'Save produced no request and reported no validation error.',
|
||||
150 | { cause },
|
||||
151 | );
|
||||
152 | }
|
||||
153 | }
|
||||
154 |
|
||||
155 | /** Signs up, declares seafarer operations, and fills the gating profile. */
|
||||
156 | async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
|
||||
157 | const offset = await signUp(page, applicant);
|
||||
158 | await verifyOtpIfPrompted(page, offset);
|
||||
159 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
||||
160 | await page
|
||||
161 | .getByRole('checkbox', { name: /seafarer registration/i })
|
||||
162 | .first()
|
||||
163 | .check();
|
||||
164 | await page.getByRole('button', { name: /save operations/i }).click();
|
||||
165 | // A seafarer is taken to `/profile`, not the dashboard: registration is
|
||||
166 | // built from the profile, and a fresh signup holds none of it yet.
|
||||
167 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
168 | await completeProfile(page);
|
||||
169 | }
|
||||
170 |
|
||||
171 | test.describe('seafarer registration', () => {
|
||||
172 | let applicant: Applicant;
|
||||
173 |
|
||||
174 | test.beforeEach(() => {
|
||||
175 | applicant = newApplicant('seafarer');
|
||||
176 | });
|
||||
177 |
|
||||
178 | test.afterEach(() => {
|
||||
179 | deleteApplicant(applicant.email);
|
||||
180 | });
|
||||
181 |
|
||||
182 | test('the wizard refuses to open until the profile it is built from is complete', async ({
|
||||
183 | page,
|
||||
184 | }) => {
|
||||
185 | const offset = await signUp(page, applicant);
|
||||
186 | await verifyOtpIfPrompted(page, offset);
|
||||
187 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
||||
188 | await page
|
||||
189 | .getByRole('checkbox', { name: /seafarer registration/i })
|
||||
190 | .first()
|
||||
191 | .check();
|
||||
192 | await page.getByRole('button', { name: /save operations/i }).click();
|
||||
193 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
194 |
|
||||
195 | // A new account holds none of the identity the registration is filled in
|
||||
196 | // from, so the gate collects it rather than opening an uncompletable form.
|
||||
197 | await page.goto('/seafarer-registration');
|
||||
198 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
199 |
|
||||
200 | // The shared wizard route is gated identically — otherwise the gate is
|
||||
201 | // decoration a deep link walks straight past.
|
||||
202 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
203 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
204 | });
|
||||
205 |
|
||||
206 | test('opening the wizard creates the draft up front', async ({ page }) => {
|
||||
207 | await readyApplicant(page, applicant);
|
||||
208 |
|
||||
209 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
210 | await expect(page).not.toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
211 |
|
||||
212 | // The draft exists before anything is filled in, so uploads have an owner
|
||||
213 | // and closing the browser mid-wizard loses nothing.
|
||||
214 | const number = await waitForApplication(applicant.email);
|
||||
215 | expect(number).toMatch(/^SFR/);
|
||||
216 | expect(statusOf(number)).toBe('DRAFT');
|
||||
217 | });
|
||||
218 |
|
||||
219 | test('a registration never reaches evaluation or inspection', async ({
|
||||
220 | page,
|
||||
221 | }) => {
|
||||
222 | await readyApplicant(page, applicant);
|
||||
223 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
224 | const number = await waitForApplication(applicant.email);
|
||||
225 | const id = idOf(number);
|
||||
226 |
|
||||
227 | await submit(id);
|
||||
228 | await runWorkflow(id, [{ path: 'claim' }]);
|
||||
229 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
||||
230 |
|
||||
231 | // The licence course's middle stages have nothing to hold in a
|
||||
232 | // registration, and the transition table is the authority regardless of
|
||||
233 | // which endpoint is called.
|
||||
234 | const refused = await runWorkflow(id, [
|
||||
235 | { path: 'complete-review', expectFailure: true },
|
||||
236 | { path: 'approve-documents', expectFailure: true },
|
||||
237 | { path: 'record-inspection', expectFailure: true },
|
||||
238 | ]);
|
||||
239 | expect(refused.every((code) => code >= 400)).toBe(true);
|
||||
240 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
||||
241 | });
|
||||
242 |
|
||||
243 | test('an officer can return a registration for correction and take it back', async ({
|
||||
244 | page,
|
||||
245 | }) => {
|
||||
246 | await readyApplicant(page, applicant);
|
||||
```
|
||||
|
Before Width: | Height: | Size: 92 KiB |
@@ -1,348 +0,0 @@
|
||||
# Instructions
|
||||
|
||||
- Following Playwright test failed.
|
||||
- Explain why, be concise, respect Playwright best practices.
|
||||
- Provide a snippet of code with the fix, if possible.
|
||||
|
||||
# Test info
|
||||
|
||||
- Name: seafarer-registration.spec.ts >> seafarer registration >> an officer can reject a registration with a reason
|
||||
- Location: apps/e2e/src/seafarer-registration.spec.ts:285:7
|
||||
|
||||
# Error details
|
||||
|
||||
```
|
||||
Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
||||
```
|
||||
|
||||
# Page snapshot
|
||||
|
||||
```yaml
|
||||
- generic [ref=f1e3]:
|
||||
- banner [ref=f1e4]:
|
||||
- generic [ref=f1e5]:
|
||||
- generic [ref=f1e6]:
|
||||
- button "Toggle navigation" [ref=f1e8] [cursor=pointer]
|
||||
- generic [ref=f1e10]:
|
||||
- generic [ref=f1e11]: Dashboard
|
||||
- generic [ref=f1e13]: Profile
|
||||
- generic [ref=f1e17]:
|
||||
- button "Language" [ref=f1e18] [cursor=pointer]
|
||||
- button "Toggle light / dark mode" [ref=f1e23] [cursor=pointer]
|
||||
- button "Notifications" [ref=f1e26] [cursor=pointer]:
|
||||
- generic [ref=f1e27]: "1"
|
||||
- button "ES" [ref=f1e32] [cursor=pointer]
|
||||
- navigation [ref=f1e34]:
|
||||
- generic [ref=f1e35]:
|
||||
- img "EMA" [ref=f1e36]
|
||||
- generic [ref=f1e37]:
|
||||
- paragraph [ref=f1e38]: EMA Portal
|
||||
- paragraph [ref=f1e39]: Ethiopian Maritime Authority
|
||||
- generic [ref=f1e43]:
|
||||
- generic [ref=f1e44]:
|
||||
- generic [ref=f1e45] [cursor=pointer]: Dashboard
|
||||
- generic [ref=f1e52] [cursor=pointer]:
|
||||
- generic [ref=f1e57]: Notifications
|
||||
- generic "1 pending" [ref=f1e59]: "1"
|
||||
- generic [ref=f1e61]:
|
||||
- button [expanded] [ref=f1e62] [cursor=pointer]:
|
||||
- paragraph [ref=f1e63]: Licensing
|
||||
- generic [ref=f1e66] [cursor=pointer]: My Applications
|
||||
- generic [ref=f1e73]:
|
||||
- button [expanded] [ref=f1e74] [cursor=pointer]:
|
||||
- paragraph [ref=f1e75]: Seafarer Services
|
||||
- generic [ref=f1e78] [cursor=pointer]: Seafarer Registration
|
||||
- generic [ref=f1e82] [cursor=pointer]: My Sea Records
|
||||
- generic [ref=f1e86] [cursor=pointer]: Seaman Book
|
||||
- generic [ref=f1e92] [cursor=pointer]: Basic Training Certificate
|
||||
- generic [ref=f1e98] [cursor=pointer]: Certificates
|
||||
- generic [ref=f1e104] [cursor=pointer]: Examinations
|
||||
- generic [ref=f1e108] [cursor=pointer]: Endorsements
|
||||
- generic [ref=f1e113]:
|
||||
- button [expanded] [ref=f1e114] [cursor=pointer]:
|
||||
- paragraph [ref=f1e115]: Account
|
||||
- generic [ref=f1e118] [cursor=pointer]: My Documents
|
||||
- generic [ref=f1e123] [cursor=pointer]: Profile
|
||||
- generic [ref=f1e130] [cursor=pointer]: Help & Support
|
||||
- button "Collapse" [ref=f1e139] [cursor=pointer]
|
||||
- main [ref=f1e143]:
|
||||
- generic [ref=f1e145]:
|
||||
- generic [ref=f1e147]:
|
||||
- heading "My Profile" [level=2] [ref=f1e148]
|
||||
- paragraph [ref=f1e149]: Manage your account details and preferences.
|
||||
- alert [ref=f1e150]:
|
||||
- generic [ref=f1e151]: Profile details are needed for seafarer registration.
|
||||
- generic [ref=f1e159]:
|
||||
- paragraph [ref=f1e161]: ES
|
||||
- generic [ref=f1e162]:
|
||||
- generic [ref=f1e163]:
|
||||
- heading "E2E seafarer 9173" [level=4] [ref=f1e164]
|
||||
- generic [ref=f1e165]: Unverified
|
||||
- paragraph [ref=f1e171]: e2e.seafarer.1787042455309173@example.test
|
||||
- generic [ref=f1e172]: e2eseafarer1787042455309173
|
||||
- generic "0% complete" [ref=f1e178]:
|
||||
- paragraph [ref=f1e183]: 0%
|
||||
- generic [ref=f1e184]:
|
||||
- tablist [ref=f1e185]:
|
||||
- tab "Personal" [ref=f1e186] [cursor=pointer]
|
||||
- tab "Profile" [selected] [ref=f1e193] [cursor=pointer]
|
||||
- tab "Address" [ref=f1e199] [cursor=pointer]
|
||||
- tab "Operations" [ref=f1e205] [cursor=pointer]
|
||||
- tab "Security" [ref=f1e212] [cursor=pointer]
|
||||
- tab "Preferences" [ref=f1e218] [cursor=pointer]
|
||||
- tabpanel "Profile" [ref=f1e224]:
|
||||
- generic [ref=f1e227]:
|
||||
- generic [ref=f1e228]:
|
||||
- heading "Maritime Profile" [level=5] [ref=f1e229]
|
||||
- paragraph [ref=f1e230]: Your professional maritime details
|
||||
- generic [ref=f1e231]:
|
||||
- generic [ref=f1e232]:
|
||||
- generic [ref=f1e233]: Profession *
|
||||
- textbox "Profession" [ref=f1e235]:
|
||||
- /placeholder: Select
|
||||
- text: Master Mariner
|
||||
- generic [ref=f1e236]:
|
||||
- generic [ref=f1e237]: First Name *
|
||||
- textbox "First Name" [ref=f1e239]:
|
||||
- /placeholder: Enter first name
|
||||
- text: Dawit
|
||||
- generic [ref=f1e240]:
|
||||
- generic [ref=f1e241]: Middle Name *
|
||||
- textbox "Middle Name" [ref=f1e243]:
|
||||
- /placeholder: Enter middle name
|
||||
- text: Bekele
|
||||
- generic [ref=f1e244]:
|
||||
- generic [ref=f1e245]: Last Name *
|
||||
- textbox "Last Name" [ref=f1e247]:
|
||||
- /placeholder: Enter last name
|
||||
- text: Tesfaye
|
||||
- generic [ref=f1e248]:
|
||||
- generic [ref=f1e249]: Gender *
|
||||
- textbox "Gender" [ref=f1e251] [cursor=pointer]:
|
||||
- /placeholder: Select
|
||||
- text: MALE
|
||||
- generic [ref=f1e252]:
|
||||
- generic [ref=f1e253]: Date of Birth *
|
||||
- generic [ref=f1e254]:
|
||||
- button "Switch calendar type" [ref=f1e256] [cursor=pointer]:
|
||||
- generic [ref=f1e257]: EN
|
||||
- textbox "Date of Birth" [ref=f1e259] [cursor=pointer]: Apr 12, 1995
|
||||
- button [ref=f1e261] [cursor=pointer]
|
||||
- generic [ref=f1e266]:
|
||||
- generic [ref=f1e267]: Place of Birth
|
||||
- textbox "Place of Birth" [ref=f1e269]:
|
||||
- /placeholder: City, Region
|
||||
- generic [ref=f1e270]:
|
||||
- generic [ref=f1e271]: Marital Status *
|
||||
- textbox "Marital Status" [ref=f1e273] [cursor=pointer]:
|
||||
- /placeholder: Select
|
||||
- text: SINGLE
|
||||
- button "Save Profile" [active] [ref=f1e275] [cursor=pointer]
|
||||
```
|
||||
|
||||
# Test source
|
||||
|
||||
```ts
|
||||
46 | await openTab(page, 'Address');
|
||||
47 | await pick(page, 'ID Type', /^NID$/i);
|
||||
48 | await page.getByLabel('ID Number').fill('FYD1234567890');
|
||||
49 | // A country select, not a free-text field.
|
||||
50 | await pick(page, 'Nationality', /ethiopia/i);
|
||||
51 | // `addressSchema` requires this in Ethiopian format; without it the form
|
||||
52 | // never submits and no request is made for `save` to wait on.
|
||||
53 | await page
|
||||
54 | .getByRole('textbox', { name: 'Primary Phone' })
|
||||
55 | .fill('+251911234567');
|
||||
56 | await save(page);
|
||||
57 | }
|
||||
58 |
|
||||
59 | /** Selects a profile tab and waits for its panel to be the visible one. */
|
||||
60 | async function openTab(page: Page, name: string): Promise<void> {
|
||||
61 | await page.getByRole('tab', { name, exact: true }).click();
|
||||
62 | await expect(page.getByRole('tabpanel', { name })).toBeVisible({
|
||||
63 | timeout: 15_000,
|
||||
64 | });
|
||||
65 | }
|
||||
66 |
|
||||
67 | /**
|
||||
68 | * Picks a value from a Mantine select.
|
||||
69 | *
|
||||
70 | * The label is bound to both the input and the listbox it opens, so matching
|
||||
71 | * by label alone is ambiguous once the dropdown is showing — the textbox role
|
||||
72 | * names the control itself.
|
||||
73 | */
|
||||
74 | async function pick(page: Page, label: string, option: RegExp): Promise<void> {
|
||||
75 | await page.getByRole('textbox', { name: label }).click();
|
||||
76 | await page.getByRole('option', { name: option }).first().click();
|
||||
77 | }
|
||||
78 |
|
||||
79 | /**
|
||||
80 | * Sets the date of birth through the picker's own UI.
|
||||
81 | *
|
||||
82 | * `AmharicDatePicker` is a controlled component: it reports changes through
|
||||
83 | * `onChange`, which is what writes the value into react-hook-form. Setting the
|
||||
84 | * input's `value` natively bypasses that entirely — the field stays empty as
|
||||
85 | * far as zod is concerned, and the form silently refuses to submit.
|
||||
86 | *
|
||||
87 | * So the calendar is actually driven: open it, pick the year and month from
|
||||
88 | * the caption dropdowns, then click the day.
|
||||
89 | */
|
||||
90 | async function pickDate(page: Page, label: string, iso: string): Promise<void> {
|
||||
91 | const [year, month, day] = iso.split('-').map(Number);
|
||||
92 |
|
||||
93 | await page.getByRole('textbox', { name: label }).click();
|
||||
94 | const calendar = page.locator('.amharic-daypicker-dropdown');
|
||||
95 | await expect(calendar).toBeVisible({ timeout: 10_000 });
|
||||
96 |
|
||||
97 | // `captionLayout="dropdown"` renders native selects for month and year.
|
||||
98 | await calendar.locator('select').last().selectOption(String(year));
|
||||
99 | await calendar
|
||||
100 | .locator('select')
|
||||
101 | .first()
|
||||
102 | .selectOption({ index: month - 1 });
|
||||
103 |
|
||||
104 | // Each day is a button whose accessible name is the full date
|
||||
105 | // ("Saturday, April 1st, 1995"), not the bare number — matching on the
|
||||
106 | // number alone finds nothing. Anchored on the ordinal so 1 cannot match 11
|
||||
107 | // or 21. Resolved after the dropdowns settle, since changing year or month
|
||||
108 | // re-renders the grid.
|
||||
109 | const cell = calendar
|
||||
110 | .getByRole('button', { name: new RegExp(`\\b${day}(st|nd|rd|th),`) })
|
||||
111 | .first();
|
||||
112 | await expect(cell).toBeVisible({ timeout: 10_000 });
|
||||
113 | await cell.click();
|
||||
114 |
|
||||
115 | await expect(calendar).toBeHidden({ timeout: 10_000 });
|
||||
116 |
|
||||
117 | // The picker writes through `onChange`; if that did not land, zod still sees
|
||||
118 | // an empty field and the failure would surface later as a refused submit.
|
||||
119 | await expect(page.getByRole('textbox', { name: label })).not.toHaveValue('', {
|
||||
120 | timeout: 10_000,
|
||||
121 | });
|
||||
122 | }
|
||||
123 |
|
||||
124 | async function save(page: Page): Promise<void> {
|
||||
125 | // Matched loosely on purpose: the personal tab PATCHes a user, the profile
|
||||
126 | // tab a profile, and the address tab POSTs to `/addresss/profile/:id` — the
|
||||
127 | // route's own spelling. Any successful write from this screen is the signal.
|
||||
128 | const saved = page.waitForResponse(
|
||||
129 | (r) =>
|
||||
130 | r.request().method() !== 'GET' &&
|
||||
131 | r.status() < 400 &&
|
||||
132 | /(profile|address|user)/i.test(r.url()),
|
||||
133 | { timeout: 20_000 },
|
||||
134 | );
|
||||
135 | await page.getByRole('button', { name: /save/i }).first().click();
|
||||
136 |
|
||||
137 | try {
|
||||
138 | await saved;
|
||||
139 | } catch (cause) {
|
||||
140 | // A zod-blocked submit fires no request at all, so the bare timeout says
|
||||
141 | // only "no response" — which reads as a backend fault rather than a form
|
||||
142 | // that refused to submit. Surface the field errors instead.
|
||||
143 | const messages = await page
|
||||
144 | .locator('.mantine-InputWrapper-error, [role="alert"]')
|
||||
145 | .allTextContents();
|
||||
> 146 | throw new Error(
|
||||
| ^ Error: Save did not submit — validation errors: Profile details are needed for seafarer registration.
|
||||
147 | messages.length
|
||||
148 | ? `Save did not submit — validation errors: ${messages.join('; ')}`
|
||||
149 | : 'Save produced no request and reported no validation error.',
|
||||
150 | { cause },
|
||||
151 | );
|
||||
152 | }
|
||||
153 | }
|
||||
154 |
|
||||
155 | /** Signs up, declares seafarer operations, and fills the gating profile. */
|
||||
156 | async function readyApplicant(page: Page, applicant: Applicant): Promise<void> {
|
||||
157 | const offset = await signUp(page, applicant);
|
||||
158 | await verifyOtpIfPrompted(page, offset);
|
||||
159 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
||||
160 | await page
|
||||
161 | .getByRole('checkbox', { name: /seafarer registration/i })
|
||||
162 | .first()
|
||||
163 | .check();
|
||||
164 | await page.getByRole('button', { name: /save operations/i }).click();
|
||||
165 | // A seafarer is taken to `/profile`, not the dashboard: registration is
|
||||
166 | // built from the profile, and a fresh signup holds none of it yet.
|
||||
167 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
168 | await completeProfile(page);
|
||||
169 | }
|
||||
170 |
|
||||
171 | test.describe('seafarer registration', () => {
|
||||
172 | let applicant: Applicant;
|
||||
173 |
|
||||
174 | test.beforeEach(() => {
|
||||
175 | applicant = newApplicant('seafarer');
|
||||
176 | });
|
||||
177 |
|
||||
178 | test.afterEach(() => {
|
||||
179 | deleteApplicant(applicant.email);
|
||||
180 | });
|
||||
181 |
|
||||
182 | test('the wizard refuses to open until the profile it is built from is complete', async ({
|
||||
183 | page,
|
||||
184 | }) => {
|
||||
185 | const offset = await signUp(page, applicant);
|
||||
186 | await verifyOtpIfPrompted(page, offset);
|
||||
187 | await expect(page).toHaveURL(/\/onboarding\/operations/, { timeout: 30_000 });
|
||||
188 | await page
|
||||
189 | .getByRole('checkbox', { name: /seafarer registration/i })
|
||||
190 | .first()
|
||||
191 | .check();
|
||||
192 | await page.getByRole('button', { name: /save operations/i }).click();
|
||||
193 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
194 |
|
||||
195 | // A new account holds none of the identity the registration is filled in
|
||||
196 | // from, so the gate collects it rather than opening an uncompletable form.
|
||||
197 | await page.goto('/seafarer-registration');
|
||||
198 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
199 |
|
||||
200 | // The shared wizard route is gated identically — otherwise the gate is
|
||||
201 | // decoration a deep link walks straight past.
|
||||
202 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
203 | await expect(page).toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
204 | });
|
||||
205 |
|
||||
206 | test('opening the wizard creates the draft up front', async ({ page }) => {
|
||||
207 | await readyApplicant(page, applicant);
|
||||
208 |
|
||||
209 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
210 | await expect(page).not.toHaveURL(/\/profile/, { timeout: 30_000 });
|
||||
211 |
|
||||
212 | // The draft exists before anything is filled in, so uploads have an owner
|
||||
213 | // and closing the browser mid-wizard loses nothing.
|
||||
214 | const number = await waitForApplication(applicant.email);
|
||||
215 | expect(number).toMatch(/^SFR/);
|
||||
216 | expect(statusOf(number)).toBe('DRAFT');
|
||||
217 | });
|
||||
218 |
|
||||
219 | test('a registration never reaches evaluation or inspection', async ({
|
||||
220 | page,
|
||||
221 | }) => {
|
||||
222 | await readyApplicant(page, applicant);
|
||||
223 | await page.goto('/licensing/SEAFARER_REGISTRATION/apply');
|
||||
224 | const number = await waitForApplication(applicant.email);
|
||||
225 | const id = idOf(number);
|
||||
226 |
|
||||
227 | await submit(id);
|
||||
228 | await runWorkflow(id, [{ path: 'claim' }]);
|
||||
229 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
||||
230 |
|
||||
231 | // The licence course's middle stages have nothing to hold in a
|
||||
232 | // registration, and the transition table is the authority regardless of
|
||||
233 | // which endpoint is called.
|
||||
234 | const refused = await runWorkflow(id, [
|
||||
235 | { path: 'complete-review', expectFailure: true },
|
||||
236 | { path: 'approve-documents', expectFailure: true },
|
||||
237 | { path: 'record-inspection', expectFailure: true },
|
||||
238 | ]);
|
||||
239 | expect(refused.every((code) => code >= 400)).toBe(true);
|
||||
240 | expect(statusOf(number)).toBe('UNDER_REVIEW');
|
||||
241 | });
|
||||
242 |
|
||||
243 | test('an officer can return a registration for correction and take it back', async ({
|
||||
244 | page,
|
||||
245 | }) => {
|
||||
246 | await readyApplicant(page, applicant);
|
||||
```
|
||||
|
Before Width: | Height: | Size: 92 KiB |