Files
emaui/apps/e2e/src/support/applicant.ts
Nati 8eb4c38216 feat: Refactor seafarer document application flow
- Remove SeamanBookApplicationPage from router and redirect to Seaman Book page.
- Add new API endpoints for managing seafarer documents, including listing, reviewing, and issuing documents.
- Introduce new SeafarerDocumentQueuePage and SeafarerDocumentReviewPage components for document management.
- Update licensing types to accommodate optional applicationId and documentId in ApplicationPayment.
- Remove unused claimSeafarerRegistration mutation and related constants.
- Update seafarer registration status labels and types to remove 'UNDER_REVIEW'.
- Create new constants and types for seafarer documents, including status labels and colors.
- Implement document payment initiation and confirmation functionalities.
- Enhance UI components for better user experience in document management.
2026-08-20 08:39:17 +00:00

140 lines
5.7 KiB
TypeScript

import { Page, expect } from '@playwright/test';
import { logOffset, waitForOtp } from './api-log';
/**
* A new applicant, unique to this run.
*
* Every spec makes its own. Sharing one account between specs means the second
* run of the suite starts from a state the first run left behind — an
* applicant who already declared their operations, already holds a licence —
* and the assertions quietly stop meaning what they say.
*/
export interface Applicant {
email: string;
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: `${firstName} ${middleName} ${lastName}`,
firstName,
middleName,
lastName,
};
}
/**
* Fills and submits the signup form.
*
* Returns the log offset taken immediately before submitting, so the caller can
* pick up the one-time code that submission triggers without matching a stale
* one from an earlier test.
*/
export async function signUp(page: Page, applicant: Applicant): Promise<number> {
await page.goto('/signup');
// The form has shipped both as one full-name field and as first/middle/last
// parts; `applicant.name` is what the parts join to, so either is filled.
const fullName = page.getByLabel(/^(full )?name \(english\)$/i);
if (await fullName.isVisible({ timeout: 5_000 }).catch(() => false)) {
await fullName.fill(applicant.name);
} else {
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 page.getByLabel('Email address').fill(applicant.email);
await page.getByLabel('Username').fill(applicant.username);
await page.getByLabel('Phone number').fill(applicant.phoneNumber);
await page.getByLabel('Password', { exact: true }).fill(applicant.password);
await page.getByLabel('Confirm password').fill(applicant.password);
// The terms checkbox gates the submit button.
await page.getByRole('checkbox').check();
const offset = logOffset();
const submit = page.getByRole('button', { name: /create account|sign up/i });
await submit.click();
// The first request after the API boots occasionally fails in the browser
// before it reaches the server ("Network error"); the form stays filled, so
// resubmitting is exactly what a person would do.
for (let attempt = 0; attempt < 3; attempt++) {
const failed = page.getByText(/network error/i);
const outcome = await Promise.race([
page.waitForURL(/\/(otp-verify|onboarding|dashboard)/, { timeout: 15_000 }).then(() => 'navigated'),
failed.waitFor({ state: 'visible', timeout: 15_000 }).then(() => 'failed'),
]).catch(() => 'timeout');
if (outcome !== 'failed') break;
await page.waitForTimeout(2_000);
await submit.click();
}
return offset;
}
/**
* Completes phone verification by typing the real code.
*
* No-op when signup did not land on the OTP screen — whether it does depends
* on how the account was created, and a test that asserts the flow rather than
* the screen should not care.
*/
export async function verifyOtpIfPrompted(
page: Page,
sinceOffset: number,
): Promise<boolean> {
await page.waitForURL(/\/(otp-verify|onboarding|dashboard)/, { timeout: 30_000 });
if (!page.url().includes('/otp-verify')) return false;
const code = await waitForOtp(sinceOffset);
// Mantine's PinInput is one input per character and moves focus itself, so
// the code is typed rather than filled — filling each box individually
// fights the component's own focus handling.
const boxes = page.locator('input[inputmode], input[type="text"]');
await boxes.first().click();
await page.keyboard.type(code, { delay: 30 });
// `onComplete` submits on the last character; the button is the fallback for
// when it does not fire.
const submit = page.getByRole('button', { name: /^verify$/i });
await Promise.race([
page.waitForURL(/\/(onboarding|dashboard)/, { timeout: 10_000 }).catch(() => null),
submit.click({ timeout: 5_000 }).catch(() => null),
]);
return true;
}
/** Signs in an existing applicant through the login screen. */
export async function logIn(page: Page, applicant: Applicant): Promise<void> {
await page.goto('/login');
// The API takes an email here, not a username — sending a username answers
// "email should not be empty".
await page.getByLabel(/email/i).fill(applicant.email);
await page.getByLabel(/password/i).fill(applicant.password);
await page.getByRole('button', { name: /sign in|log in|login/i }).click();
await expect(page).toHaveURL(/\/(onboarding|dashboard|profile)/, { timeout: 30_000 });
}