src/components/checkin/checkin-page-frame.render.test.tsx
Rama main · réplica de lectura del repositorio original.
import { describe, it, expect, vi, beforeEach } from "vitest";
import {
render as rtlRender,
screen,
waitFor,
within,
} from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CheckInPageFrame } from "./checkin-page-frame";
import type { User } from "@/lib/api/auth";
import type {
CheckinDetails,
CheckinDetailsUpdate,
} from "@/lib/api/checkin-details";
import { ownCheckInDiver } from "@/lib/checkin";
import { CheckinDetailsProvider } from "@/contexts/CheckinDetailsContext";
import type { Certification } from "@/lib/api/certifications";
import type { UserDiveStats } from "@/lib/api/dive-stats";
import type { SharedCheckInCertification } from "@/lib/api/checkin-links";
import { API_BASE_URL } from "@/lib/api-base";
import type { CheckinLinkControls } from "@/hooks/useCheckinLink";
// The summary is the thing a diver hands across a desk, so what a render can reach is
// what is printed and in what order - and, as much as it matters here, what is *not*
// printed. jsdom does no layout and no print media, so the `print:` classes are pinned
// as classes and the rendering itself is walked in a browser.
const auth = vi.hoisted(() => ({
user: {
uuid: "user-1",
name: "Sam Reef",
username: "sam",
email: "sam@example.com",
units: "metric",
dive_form_hidden_fields: [],
dive_form_preset_uuid: null,
} as User,
refreshUser: vi.fn(),
}));
vi.mock("@/contexts/AuthContext", () => ({
useAuth: () => auth,
}));
// The three dialogs this page hosts reach the API on save. The agency vocabulary and
// `certificationFile` stay real - the summary's own rendering is built on them.
vi.mock("@/lib/api/auth", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/api/auth")>()),
authAPI: { updateProfile: vi.fn(), getPictureBlob: vi.fn() },
}));
// The dialogs read and save the shared copy; `update` writes it the way the API does.
vi.mock("@/lib/api/checkin-details", async (importOriginal) => ({
...(await importOriginal<object>()),
checkinDetailsAPI: { getCheckinDetails: vi.fn(), update: vi.fn() },
}));
vi.mock("@/lib/api/certifications", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/api/certifications")>()),
certificationsAPI: {
createCertification: vi.fn(),
updateCertification: vi.fn(),
},
}));
vi.mock("@/lib/api/courses", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/api/courses")>()),
coursesAPI: { getCourses: vi.fn(), getCourse: vi.fn() },
}));
vi.mock("@/components/ui/use-toast", async (importOriginal) => ({
...(await importOriginal<object>()),
useToast: () => ({ toast: vi.fn(), dismiss: vi.fn(), toasts: [] }),
}));
const { authAPI } = await import("@/lib/api/auth");
const { checkinDetailsAPI } = await import("@/lib/api/checkin-details");
const updateDetails = vi.mocked(checkinDetailsAPI.update);
const { coursesAPI } = await import("@/lib/api/courses");
const updateProfile = vi.mocked(authAPI.updateProfile);
// Both the portrait and a card thumbnail fetch their bytes through this, the endpoints
// being owner-only. Answering with a URL is what puts an `<img>` on the page.
const blobUrl = vi.hoisted(() =>
vi.fn((fetchBlob: unknown) => ({
url: fetchBlob ? "blob:card" : null,
isLoading: false,
hasError: false,
error: null,
})),
);
vi.mock("@/hooks/useAuthedBlobUrl", () => ({ useAuthedBlobUrl: blobUrl }));
const certification = (over: Partial<Certification> = {}): Certification => ({
uuid: "cert-1",
agency: "padi",
name: "Rescue Diver",
user_uuid: "user-1",
created_at: "2026-01-01T00:00:00+00:00",
...over,
});
const stats: UserDiveStats = {
user_uuid: "user-1",
total_dives: 142,
max_depth: 39.6,
total_submerged_time: 360000,
species_seen: 12,
dive_site_count: 9,
first_dive_on: "2019-05-02",
last_dive_on: "2026-09-20",
created_at: "2026-01-01T00:00:00+00:00",
};
type FrameProps = Parameters<typeof CheckInPageFrame>[0];
// The diver's check-in details, as the shared copy holds them.
let details: CheckinDetails;
// Under the root layout's provider, which every dialog here saves through.
const render = (ui: React.ReactElement) =>
rtlRender(ui, { wrapper: CheckinDetailsProvider });
// What the signed-in page hands over from the session and the shared copy, read at
// render time so a test that changes the diver first draws the change.
const session = () => ({
diver: ownCheckInDiver(auth.user, details),
units: auth.user.units,
});
const loaded = (over: Partial<FrameProps> = {}) => (
<CheckInPageFrame {...session()} isLoading={false} stats={stats} {...over} />
);
// Everything a desk asks for. Most tests here are about something other than an empty
// group, and this is what keeps every section's "Not filled in yet." out of their way.
const COMPLETE: CheckinDetails = {
email: null,
date_of_birth: "1988-04-02",
phone: "+44 7700 900000",
emergency_contacts: [
{ name: "Alex Reef", phone: "+44 7700 900111", relationship: null },
],
insurance_policies: [
{ provider: "DAN Europe", number: "P-42", expires_on: null },
],
};
beforeEach(() => {
Object.assign(auth.user, {
units: "metric",
avatar_sha256: null,
portrait_sha256: null,
});
details = {
email: null,
phone: null,
date_of_birth: null,
emergency_contacts: [],
insurance_policies: [],
};
vi.mocked(checkinDetailsAPI.getCheckinDetails)
.mockReset()
.mockImplementation(async () => structuredClone(details));
updateDetails
.mockReset()
.mockImplementation(async (patch: CheckinDetailsUpdate) => {
details = { ...details, ...patch };
return structuredClone(details);
});
auth.refreshUser.mockReset().mockResolvedValue(undefined);
updateProfile.mockReset().mockResolvedValue({ message: "ok" });
vi.mocked(coursesAPI.getCourses).mockReset().mockResolvedValue({
data: [],
total_count: 0,
has_more: false,
page: 1,
items_per_page: 10,
});
});
// The column the diver's portrait sits in, beside the one their name heads.
function portraitColumn(name: string): Element {
return screen.getByRole("heading", { name }).parentElement!.parentElement!
.previousElementSibling!;
}
// Every list read label-then-value. The diver's own, under the name, marks each
// value with an icon instead and has no label track to line up.
function labelledLists(container: HTMLElement): HTMLDListElement[] {
return [...container.querySelectorAll("dl")].filter(
(list) => !list.querySelector("dt svg"),
);
}
describe("what the summary prints", () => {
it("runs from the diver down to the cards, in the order a desk reads it", () => {
Object.assign(details, {
date_of_birth: "1988-04-02",
phone: "+44 7700 900000",
emergency_contacts: [
{ name: "Alex Reef", phone: null, relationship: null },
],
insurance_policies: [
{ provider: "DAN Europe", number: null, expires_on: "2027-03-01" },
],
});
const { container } = render(
loaded({
certifications: [certification()],
lastDiveAt: "2026-08-14T09:30:00+02:00",
}),
);
// Two to a row on anything wider than a phone, so this is the order the cells
// are filled in: the diver beside their diving, then the policy beside the
// person to ring, and the cards under both.
const text = container.textContent ?? "";
const order = [
"Sam Reef",
"Date of birth",
"Phone",
"Diving",
"Dive Insurance",
"Emergency Contacts",
"Certifications",
].map((label) => text.indexOf(label));
expect(order.every((at) => at >= 0)).toBe(true);
expect([...order].sort((a, b) => a - b)).toEqual(order);
});
it("pairs the sections up on paper, however narrow the sheet", () => {
Object.assign(details, COMPLETE);
const { container } = render(
loaded({ certifications: [certification(), certification()] }),
);
// jsdom lays nothing out, so what is checkable is the pair of queries the
// columns come from. The `print:` half is the one worth pinning: `md:` under
// print media asks the paper's width, so without its twin a sheet printed from
// a phone comes off as one long column on paper with room for two.
const grids = [...container.querySelectorAll("div")].filter((el) =>
el.classList.contains("grid"),
);
expect(grids.length).toBeGreaterThanOrEqual(2);
for (const grid of grids) {
expect(grid).toHaveClass("md:grid-cols-2", "print:grid-cols-2");
}
// The four that pair up, and then the cards two to a row under them.
expect(grids[0].children).toHaveLength(4);
expect(grids[grids.length - 1].children).toHaveLength(2);
});
it("leaves out a field the diver never filled in, rather than labelling a blank", () => {
details.phone = "+44 7700 900000";
render(loaded());
expect(screen.getByText("Phone")).toBeInTheDocument();
// Not "Date of birth: —": a blank line on a page handed to a stranger reads as
// something withheld rather than something not held.
expect(screen.queryByText("Date of birth")).toBeNull();
});
it("puts the date of birth, phone and email under the name, each marked by an icon", () => {
Object.assign(details, COMPLETE, { email: "desk@example.org" });
render(loaded());
const nameColumn = screen.getByRole("heading", { name: "Sam Reef" })
.parentElement!.parentElement!;
for (const label of ["Date of birth", "Phone", "Email"]) {
const term = within(nameColumn).getByText(label);
// Heard by a screen reader, drawn as the icon beside it.
expect(term).toHaveClass("sr-only");
expect(term.closest("dt")!.querySelector("svg")).toHaveAttribute(
"aria-hidden",
);
}
expect(within(nameColumn).getByText("+44 7700 900000")).toBeInTheDocument();
expect(
within(nameColumn).getByText("desk@example.org"),
).toBeInTheDocument();
// The address the diver gives out, never the one they sign in with.
expect(screen.queryByText(auth.user.email)).toBeNull();
});
it("prints every policy and every contact, in the diver's order", () => {
Object.assign(details, COMPLETE, {
emergency_contacts: [
{ name: "First Call", phone: "0111", relationship: "Partner" },
{ name: "Second Call", phone: "0222", relationship: "Parent" },
],
insurance_policies: [
{ provider: "DAN Europe", number: "P-42", expires_on: null },
{ provider: "DiveAssure", number: "D-7", expires_on: "2027-03-01" },
],
});
const { container } = render(loaded());
const text = container.textContent ?? "";
const order = [
"DAN Europe",
"P-42",
"DiveAssure",
"D-7",
"First Call",
"Second Call",
].map((value) => text.indexOf(value));
expect(order.every((at) => at >= 0)).toBe(true);
expect([...order].sort((a, b) => a - b)).toEqual(order);
});
it("prints nothing of the details for a diver who has saved none", () => {
const { container } = render(loaded());
for (const title of ["Dive Insurance", "Emergency Contacts"]) {
expect(screen.getByText(title).closest("section")).toHaveClass(
"print:hidden",
);
}
expect(container.querySelector("dt svg")).toBeNull();
});
it("prints a card's dive center from what it is handed, and no row without one", () => {
// The frame looks nothing up itself: the name comes in beside the card, by
// the card's uuid, and a card with no entry there has no line at all.
render(
loaded({
certifications: [
certification({ uuid: "cert-1", name: "Rescue Diver" }),
certification({ uuid: "cert-2", name: "Open Water Diver" }),
],
contactNames: { "cert-1": "Blue Ocean" },
}),
);
expect(screen.getAllByText("Dive center")).toHaveLength(1);
expect(screen.getByText("Blue Ocean")).toBeInTheDocument();
});
it("keeps every section on screen, and takes the empty ones off the print", () => {
Object.assign(details, { ...COMPLETE, emergency_contacts: [] });
render(loaded({ certifications: [certification()] }));
// Every heading is there with its own control, however little is under it -
// which is what makes each group reachable without leaving the page.
for (const title of ["Certifications", "Diving", "Dive Insurance"]) {
expect(screen.getByText(title)).toBeInTheDocument();
}
expect(
screen.getByText("Dive Insurance").closest("section"),
).not.toHaveClass("print:hidden");
// A group the diver never filled in says so on screen and is gone from the
// sheet: a heading with nothing under it is the labelled blank in another form.
const emergency = screen.getByText("Emergency Contacts").closest("section");
expect(emergency).toHaveClass("print:hidden");
expect(
within(emergency as HTMLElement).getByText("Not filled in yet."),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: "Edit your emergency contacts" }),
).toBeInTheDocument();
});
it("never reads a failed fetch as an empty account", () => {
Object.assign(details, COMPLETE);
// A rejected list leaves the same empty array a diver with no cards has, and a
// rejected `/user/dive-stats` the same null - so neither section may claim
// emptiness here. The banner above says what happened and offers the retry.
render(loaded({ loadFailed: true, stats: null }));
expect(screen.queryByText("No certifications yet.")).toBeNull();
const diving = screen.getByText("Diving").closest("section");
expect(
within(diving as HTMLElement).queryByText("Not filled in yet."),
).toBeNull();
});
it("says so where a diver holds no cards at all", () => {
Object.assign(details, COMPLETE);
render(loaded());
const cards = screen.getByText("Certifications").closest("section");
expect(cards).toHaveClass("print:hidden");
expect(
within(cards as HTMLElement).getByText("No certifications yet."),
).toBeInTheDocument();
});
it("names the agency in words and never draws its mark", () => {
render(loaded({ certifications: [certification()] }));
expect(screen.getByText("PADI Rescue Diver")).toBeInTheDocument();
});
it("says a card is on file as a PDF instead of attempting a thumbnail", () => {
const { container } = render(
loaded({
certifications: [
certification({
files: [
{
uuid: "file-1",
side: "front",
content_type: "application/pdf",
byte_size: 1024,
original_filename: "card.pdf",
},
],
}),
],
}),
);
expect(screen.getByText("card on file as PDF")).toBeInTheDocument();
expect(container.querySelector("img")).toBeNull();
});
it("shows the stored front when it is an image", () => {
render(
loaded({
certifications: [
certification({
files: [
{
uuid: "file-1",
side: "front",
content_type: "image/jpeg",
byte_size: 1024,
original_filename: "card.jpg",
},
],
}),
],
}),
);
expect(
screen.getByAltText("front of certification card"),
).toBeInTheDocument();
});
it("gives the depth in the diver's own units", () => {
render(loaded());
expect(screen.getByText("39.6 m")).toBeInTheDocument();
auth.user.units = "imperial";
render(loaded());
expect(screen.getByText("130 ft")).toBeInTheDocument();
});
it("carries the last dive's date, and nothing where there is no dive", () => {
const { rerender } = render(
loaded({ lastDiveAt: "2026-08-14T09:30:00+02:00" }),
);
expect(screen.getByText("Last dive")).toBeInTheDocument();
expect(screen.getByText("Aug 14, 2026")).toBeInTheDocument();
rerender(loaded({ lastDiveAt: null }));
expect(screen.queryByText("Last dive")).toBeNull();
});
});
describe("what the print leaves behind", () => {
it("names the saved PDF after the diver and the day, and gives the tab back", () => {
Object.assign(details, COMPLETE);
const tabTitle = "OpenDiving";
document.title = tabTitle;
render(loaded({ certifications: [certification()] }));
// There is no API for the filename: the browser takes it from `document.title`
// as the dialog opens, so the swap rides `beforeprint` - which is also what
// gives Cmd+P the same name as the Print button.
window.dispatchEvent(new Event("beforeprint"));
expect(document.title).toMatch(
/^Sam Reef - diver check-in - \d{4}-\d{2}-\d{2}$/,
);
window.dispatchEvent(new Event("afterprint"));
expect(document.title).toBe(tabTitle);
});
it("marks the sheet without vouching for it", () => {
Object.assign(details, COMPLETE);
const { container } = render(loaded({ certifications: [certification()] }));
// The one page of this app that leaves it on paper, so it says where it came
// from. In the footnote and nowhere else: a mark above a stranger's card
// numbers reads as an attestation, and the sentence it shares a line with is
// the page disclaiming exactly that.
// Matched on the footnote's own text: the wordmark sits in a `<span>`, and
// testing-library's text matcher reads a node's direct text nodes only.
const footnote = screen.getByText(/own dive log/);
expect(footnote.tagName).toBe("P");
expect(footnote).toHaveTextContent(/^OpenDiving · Printed /);
expect(footnote?.querySelector("svg")).not.toBeNull();
expect(footnote).toHaveTextContent(
/verified with the agency that issued it, not here\.$/,
);
// Not in a heading, and not repeated anywhere else on the sheet.
expect(container.querySelectorAll("h1, h2, h3").length).toBeGreaterThan(0);
for (const heading of container.querySelectorAll("h1, h2, h3")) {
expect(heading.textContent).not.toContain("OpenDiving");
}
});
// `md:` measures the sheet in print, and A4 or Letter falls short of it, so the
// one line naming the document needs a size of its own there.
it("prints its heading at the page's size", () => {
render(loaded());
expect(screen.getByRole("heading", { level: 1 })).toHaveClass(
"print:text-3xl",
);
});
it("hides its own controls, keeping the summary", () => {
render(loaded());
expect(
screen.getByRole("button", { name: /print/i }).closest(".print\\:hidden"),
).not.toBeNull();
});
it("keeps each block a reader takes as one thing off a page boundary", () => {
Object.assign(details, COMPLETE);
const { container } = render(loaded({ certifications: [certification()] }));
// An emergency contact split over a fold is a name on one sheet and the number
// to ring on another. The policies and the contacts are kept whole one by one,
// their sections running to several of them.
expect(screen.getByText("Diving").closest("section")).toHaveClass(
"break-inside-avoid",
);
for (const value of ["DAN Europe", "Alex Reef"]) {
expect(screen.getByText(value).closest("dl")!.parentElement).toHaveClass(
"break-inside-avoid",
);
}
expect(screen.getByText(/own dive log/)).toHaveClass("break-inside-avoid");
// The card is its own unit, and the list under it travels with it.
expect(
container.querySelector(".space-y-2.break-inside-avoid"),
).not.toBeNull();
// Not the certifications section itself: a diver with a handful of cards is
// taller than a page, and refusing to break something that cannot fit only moves
// the break to the top and wastes the page. Its unit is the card.
expect(
screen.getByText("Certifications").closest("section"),
).not.toHaveClass("break-inside-avoid");
});
});
describe("before the requests land", () => {
it("puts each placeholder card where its row will land", () => {
const { container } = render(<CheckInPageFrame {...session()} />);
// jsdom lays nothing out, so what is checkable is that the placeholder carries
// the same geometry as `CertificationSummary` - the image slot, on a line of its
// own above the list. Without it the whole list jumps and resizes the moment the
// fetch returns.
const rows = container.querySelectorAll("[aria-hidden] .flex.gap-4");
expect(rows.length).toBeGreaterThan(0);
rows.forEach((row) => {
expect(row.firstElementChild).toHaveClass(
"w-16",
"sm:w-24",
"print:w-24",
"h-12",
);
// The gap beside the picture as well as the picture's own width: the two
// together are what put the name bar where the name lands.
expect(row).toHaveClass("gap-4");
expect(row.className).not.toMatch(/(sm|print):gap-/);
});
});
it("holds the shape with placeholders, and announces the regions as busy", () => {
const { container } = render(<CheckInPageFrame {...session()} />);
expect(container.querySelector("[aria-busy='true']")).not.toBeNull();
const bars = container.querySelectorAll(".animate-skeleton");
expect(bars.length).toBeGreaterThan(0);
bars.forEach((bar) => expect(bar).toHaveAttribute("aria-hidden", "true"));
});
it("draws the name straight away, and holds the details' shape until they land", () => {
render(
<CheckInPageFrame
diver={ownCheckInDiver(auth.user, null)}
units="metric"
details="loading"
/>,
);
expect(screen.getByText("Sam Reef")).toBeInTheDocument();
for (const title of ["Dive Insurance", "Emergency Contacts"]) {
const section = screen.getByText(title).closest("section")!;
expect(section).toHaveAttribute("aria-busy", "true");
expect(section).not.toHaveClass("print:hidden");
}
expect(screen.queryByText("Not filled in yet.")).toBeNull();
// Nothing opens a form over a copy that has not arrived.
expect(screen.queryByRole("button", { name: /^Edit your/ })).toBeNull();
expect(
screen.getByRole("button", { name: "Add a portrait" }),
).toBeDisabled();
});
it("claims nothing about the details when they failed to load", () => {
render(
<CheckInPageFrame
diver={ownCheckInDiver(auth.user, null)}
units="metric"
isLoading={false}
loadFailed
details="failed"
/>,
);
expect(screen.queryByText("Not filled in yet.")).toBeNull();
expect(screen.queryByRole("button", { name: /^Edit your/ })).toBeNull();
});
});
describe("the picture at the top", () => {
it("draws the portrait, and never the avatar", () => {
Object.assign(details, COMPLETE);
Object.assign(auth.user, {
avatar_sha256: "avatar1",
portrait_sha256: "portrait1",
});
render(loaded({ certifications: [certification()] }));
const portrait = screen.getByAltText("Portrait of Sam Reef");
expect(portrait).toHaveAttribute("src", "blob:card");
// In the name's own column, at 7:9.
const slot = portraitColumn("Sam Reef");
expect(slot).toContainElement(portrait);
expect(portrait.parentElement).toHaveClass("aspect-[7/9]");
expect(screen.queryByAltText(/avatar/i)).toBeNull();
expect(screen.queryByText("SR")).toBeNull();
});
it("puts nothing on the sheet for a diver with no portrait, whatever the avatar", () => {
// The avatar does not stand in: a desk is looking for the diver's face, and the
// avatar is whatever the diver shows the app. Nor do initials, which identify
// nobody.
Object.assign(details, COMPLETE);
auth.user.avatar_sha256 = "avatar1";
render(loaded({ certifications: [certification()] }));
expect(screen.queryByRole("img")).toBeNull();
expect(screen.queryByText("SR")).toBeNull();
// What the screen offers instead never reaches the paper.
const add = screen.getByRole("button", { name: "Add a portrait" });
expect(add.closest(".aspect-\\[7\\/9\\]")).toHaveClass("print:hidden");
});
it("opens About You, portrait and all, from the empty frame", async () => {
Object.assign(details, COMPLETE);
render(loaded({ certifications: [certification()] }));
await userEvent.click(
screen.getByRole("button", { name: "Add a portrait" }),
);
const dialog = await screen.findByRole("dialog", { name: "About You" });
expect(
await within(dialog).findByLabelText("Choose a portrait"),
).toBeInTheDocument();
expect(within(dialog).getByLabelText("Phone number")).toBeInTheDocument();
});
});
describe("labels and values line up", () => {
it("puts both halves of every pair straight into the list's own grid", () => {
Object.assign(details, COMPLETE);
const { container } = render(
loaded({
certifications: [certification({ certification_number: "12345" })],
}),
);
const lists = labelledLists(container);
expect(lists.length).toBeGreaterThan(0);
for (const list of lists) {
expect(list.className).toContain("grid-cols-[auto_1fr]");
// A pair wrapped in a `<div>` of its own would occupy one cell, and each value
// would start wherever its own label happened to end - which is the ragged
// edge the grid exists to remove. jsdom does no layout, so the structure is
// what can be pinned here.
for (const cell of list.querySelectorAll("dt, dd")) {
expect(cell.parentElement).toBe(list);
}
}
});
it("starts a name and the values under it on one edge", () => {
Object.assign(details, COMPLETE);
auth.user.portrait_sha256 = "abc123";
const { container } = render(
loaded({
certifications: [certification({ certification_number: "1" })],
}),
);
// jsdom lays nothing out, so what is checkable is the arithmetic these classes
// encode, and it is arithmetic rather than taste: the picture's column plus the
// gap beside it has to come to the label track plus the list's own column gap,
// or every name on the sheet starts eight pixels off the values under it.
// 6rem + 1rem = 6rem + 1rem. Change one of the three and this is what says the
// other two have to move.
// The picture's own column is what identifies a name row: a section heading
// carries `break-after-avoid` too and has nothing beside it.
const pictures = [...container.querySelectorAll("div")].filter((el) =>
el.classList.contains("sm:w-24"),
);
// The diver's own block and the certification.
expect(pictures).toHaveLength(2);
for (const picture of pictures) {
expect(picture).toHaveClass("print:w-24");
expect(picture.parentElement).toHaveClass("gap-4");
expect(picture.parentElement?.firstElementChild).toBe(picture);
}
for (const list of labelledLists(container)) {
expect(list.className).toContain("sm:grid-cols-[minmax(6rem,auto)_1fr]");
expect(list.className).toContain(
"print:grid-cols-[minmax(6rem,auto)_1fr]",
);
expect(list).toHaveClass("gap-x-4");
}
});
it("holds every heading row to one height, edit buttons or not", () => {
Object.assign(details, COMPLETE);
render(loaded());
// The edit buttons are off the paper and off a link's page, so no row may take
// its height from one: the name's line is taller than a heading's, and each
// heading would sit a different distance above its rows.
for (const name of [
"Sam Reef",
"Diving",
"Dive Insurance",
"Emergency Contacts",
"Certifications",
]) {
expect(screen.getByRole("heading", { name }).parentElement).toHaveClass(
"min-h-9",
);
}
});
it("holds the picture's column for a diver who stored none", () => {
Object.assign(details, COMPLETE);
render(loaded({ certifications: [certification()] }));
// Nothing in it prints, but the column stays - the name meets the same edge as
// every certification's either way.
const slot = portraitColumn("Sam Reef");
expect(slot).toHaveClass("sm:w-24");
for (const child of slot.children) {
expect(child).toHaveClass("print:hidden");
}
});
});
describe("editing from the sheet", () => {
it("opens the settings form over the summary, and gives the summary back on save", async () => {
Object.assign(details, COMPLETE);
render(loaded({ certifications: [certification()] }));
await userEvent.click(
screen.getByRole("button", {
name: "Edit your name, portrait, date of birth, phone number and email",
}),
);
const dialog = await screen.findByRole("dialog", { name: "About You" });
// The very fields `/settings` shows, because they are the same component.
expect(await within(dialog).findByLabelText("Phone number")).toHaveValue(
"+44 7700 900000",
);
expect(within(dialog).getByLabelText("Full name")).toHaveValue("Sam Reef");
await userEvent.clear(within(dialog).getByLabelText("Phone number"));
await userEvent.type(within(dialog).getByLabelText("Phone number"), "0123");
await userEvent.click(
within(dialog).getByRole("button", { name: /save changes/i }),
);
// The name is the account's and did not change, so only the object is sent:
// its About You members and no other.
await waitFor(() =>
expect(updateDetails).toHaveBeenCalledWith({
date_of_birth: "1988-04-02",
phone: "0123",
email: null,
}),
);
expect(updateProfile).not.toHaveBeenCalled();
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "About You" })).toBeNull(),
);
});
it("sends a changed name to the account first, then the object, and re-reads the user", async () => {
Object.assign(details, COMPLETE);
const sent: string[] = [];
updateProfile.mockImplementation(async () => {
sent.push("user");
return { message: "ok" };
});
updateDetails.mockImplementation(async (patch) => {
sent.push("checkin-details");
return { ...details, ...patch };
});
render(loaded());
await userEvent.click(
screen.getByRole("button", {
name: "Edit your name, portrait, date of birth, phone number and email",
}),
);
const dialog = await screen.findByRole("dialog", { name: "About You" });
const name = await within(dialog).findByLabelText("Full name");
await userEvent.clear(name);
await userEvent.type(name, "Sam Coral");
await userEvent.click(
within(dialog).getByRole("button", { name: /save changes/i }),
);
await waitFor(() => expect(auth.refreshUser).toHaveBeenCalled());
expect(updateProfile).toHaveBeenCalledWith({ name: "Sam Coral" });
expect(sent).toEqual(["user", "checkin-details"]);
});
it("gives each section a control that opens that group and nothing else", async () => {
Object.assign(details, COMPLETE);
render(loaded({ certifications: [certification()] }));
await userEvent.click(
screen.getByRole("button", { name: "Edit your insurance policies" }),
);
const insurance = await screen.findByRole("dialog", {
name: "Dive Insurance",
});
expect(
await within(insurance).findByRole("textbox", {
name: "Provider policy 1 of 1",
}),
).toHaveValue("DAN Europe");
// The emergency contacts have their own control and their own dialog, so a diver
// correcting one group is never handed the other two to scroll past.
expect(
within(insurance).queryByRole("textbox", { name: /^Relationship/ }),
).toBeNull();
await userEvent.click(
within(insurance).getByRole("button", { name: /save changes/i }),
);
await waitFor(() =>
expect(updateDetails).toHaveBeenCalledWith({
insurance_policies: [
{ provider: "DAN Europe", number: "P-42", expires_on: null },
],
}),
);
});
it("opens a card in the certification form it is edited in everywhere else", async () => {
Object.assign(details, COMPLETE);
render(loaded({ certifications: [certification()] }));
await userEvent.click(
screen.getByRole("button", { name: "Edit Rescue Diver" }),
);
const dialog = await screen.findByRole("dialog", {
name: "Edit Certification",
});
expect(within(dialog).getByLabelText("Certification *")).toHaveValue(
"Rescue Diver",
);
});
it("re-reads the list rather than editing the card it holds", async () => {
Object.assign(details, COMPLETE);
const onCertificationsChanged = vi.fn();
render(
loaded({ certifications: [certification()], onCertificationsChanged }),
);
await userEvent.click(
screen.getByRole("button", { name: "Edit Rescue Diver" }),
);
const dialog = await screen.findByRole("dialog", {
name: "Edit Certification",
});
await userEvent.clear(within(dialog).getByLabelText("Certification *"));
await userEvent.type(
within(dialog).getByLabelText("Certification *"),
"Rescue Diver (2026)",
);
await userEvent.click(
within(dialog).getByRole("button", { name: /save changes/i }),
);
// The page re-reads `GET /certifications` rather than this component patching
// its own copy: the order is the endpoint's, and a `certified_on` edited here
// has to land where that puts it.
await waitFor(() => expect(onCertificationsChanged).toHaveBeenCalled());
});
it("offers no way to add a card, cards being kept where cards are kept", () => {
Object.assign(details, COMPLETE);
render(loaded({ certifications: [certification()] }));
expect(screen.queryByRole("button", { name: "Add" })).toBeNull();
});
});
describe("correcting the diving figures", () => {
const withDiving = () =>
loaded({
certifications: [certification()],
lastDiveAt: "2026-08-14T09:30:00+02:00",
});
it("prints what the diver typed, and says it went nowhere", async () => {
Object.assign(details, COMPLETE);
render(withDiving());
expect(screen.getByText("142")).toBeInTheDocument();
await userEvent.click(
screen.getByRole("button", { name: "Correct these figures" }),
);
const dialog = await screen.findByRole("dialog", { name: "Diving" });
const dives = within(dialog).getByLabelText("Dives logged");
await userEvent.clear(dives);
await userEvent.type(dives, "310");
await userEvent.click(
within(dialog).getByRole("button", { name: /use on this summary/i }),
);
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "Diving" })).toBeNull(),
);
expect(screen.getByText("310")).toBeInTheDocument();
expect(screen.queryByText("142")).toBeNull();
expect(
screen.getByText(/nothing was saved to your log/i),
).toBeInTheDocument();
});
it("hands the log's own figures back", async () => {
Object.assign(details, COMPLETE);
render(withDiving());
await userEvent.click(
screen.getByRole("button", { name: "Correct these figures" }),
);
const dives = within(
await screen.findByRole("dialog", { name: "Diving" }),
).getByLabelText("Dives logged");
await userEvent.clear(dives);
await userEvent.type(dives, "310");
await userEvent.click(
screen.getByRole("button", { name: /use on this summary/i }),
);
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "Diving" })).toBeNull(),
);
// The undo only exists once there is something to undo, which is why it is
// looked for after the correction rather than before it.
await userEvent.click(
screen.getByRole("button", { name: "Correct these figures" }),
);
await userEvent.click(
await screen.findByRole("button", { name: /use logged figures/i }),
);
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "Diving" })).toBeNull(),
);
expect(screen.getByText("142")).toBeInTheDocument();
expect(screen.queryByText(/nothing was saved to your log/i)).toBeNull();
});
it("lets a diver whose log has nothing in it correct the count", async () => {
Object.assign(details, COMPLETE);
// What `/user/dive-stats` answers for a diver with nothing logged, which is the
// diver this dialog is for: the box opens on that zero, and a schema refusing it
// would block the submit on a field nobody touched.
render(
loaded({
stats: { ...stats, total_dives: 0, max_depth: 0 },
certifications: [certification()],
}),
);
await userEvent.click(
screen.getByRole("button", { name: "Correct these figures" }),
);
const dialog = await screen.findByRole("dialog", { name: "Diving" });
expect(within(dialog).getByLabelText("Max depth (m)")).toHaveValue(0);
const dives = within(dialog).getByLabelText("Dives logged");
await userEvent.clear(dives);
await userEvent.type(dives, "400");
await userEvent.click(
within(dialog).getByRole("button", { name: /use on this summary/i }),
);
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "Diving" })).toBeNull(),
);
expect(screen.getByText("400")).toBeInTheDocument();
});
it("keeps the way back when every figure is cleared, and drops the heading from the sheet", async () => {
Object.assign(details, COMPLETE);
render(withDiving());
await userEvent.click(
screen.getByRole("button", { name: "Correct these figures" }),
);
const dialog = await screen.findByRole("dialog", { name: "Diving" });
await userEvent.clear(within(dialog).getByLabelText("Dives logged"));
await userEvent.clear(within(dialog).getByLabelText("Max depth (m)"));
await userEvent.click(
within(dialog).getByRole("button", { name: "Clear" }),
);
await userEvent.click(
within(dialog).getByRole("button", { name: /use on this summary/i }),
);
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "Diving" })).toBeNull(),
);
expect(screen.queryByText("Dives logged")).toBeNull();
// The section that removed itself would take the only route back to the logged
// figures with it, leaving a correction in force with nothing on screen saying so.
expect(
screen.getByRole("button", { name: "Correct these figures" }),
).toBeInTheDocument();
expect(
screen.getByText(/nothing was saved to your log/i),
).toBeInTheDocument();
// Not "Not filled in yet." beside it: the one diver who can reach this state is
// the one who just emptied figures that were there.
const diving = screen.getByText("Diving").closest("section");
expect(
within(diving as HTMLElement).queryByText("Not filled in yet."),
).toBeNull();
// A desk is handed no heading with nothing under it.
expect(screen.getByText("Diving").closest("section")).toHaveClass(
"print:hidden",
);
await userEvent.click(
screen.getByRole("button", { name: "Correct these figures" }),
);
await userEvent.click(
await screen.findByRole("button", { name: /use logged figures/i }),
);
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "Diving" })).toBeNull(),
);
expect(screen.getByText("142")).toBeInTheDocument();
});
it("keeps the correction off the sheet's own ink", async () => {
Object.assign(details, COMPLETE);
render(withDiving());
await userEvent.click(
screen.getByRole("button", { name: "Correct these figures" }),
);
await userEvent.click(
within(await screen.findByRole("dialog", { name: "Diving" })).getByRole(
"button",
{ name: /use on this summary/i },
),
);
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "Diving" })).toBeNull(),
);
expect(screen.getByText(/nothing was saved to your log/i)).toHaveClass(
"print:hidden",
);
});
});
describe("sharing it as a link", () => {
const controls = (
over: Partial<CheckinLinkControls> = {},
): CheckinLinkControls => ({
live: null,
busy: false,
mint: vi.fn().mockResolvedValue(undefined),
revoke: vi.fn().mockResolvedValue(undefined),
...over,
});
it("waits for the figures, then sends the ones the page shows", async () => {
Object.assign(details, COMPLETE);
const sharing = controls();
const { rerender } = render(
<CheckInPageFrame {...session()} sharing={sharing} />,
);
// The link keeps what it is made with for its whole life, so a click before the
// figures land would publish blanks.
expect(screen.getByRole("button", { name: /share/i })).toBeDisabled();
rerender(
loaded({
sharing,
certifications: [certification()],
lastDiveAt: "2026-08-14T09:30:00+02:00",
}),
);
await userEvent.click(
screen.getByRole("button", { name: "Correct these figures" }),
);
const dives = within(
await screen.findByRole("dialog", { name: "Diving" }),
).getByLabelText("Dives logged");
await userEvent.clear(dives);
await userEvent.type(dives, "310");
await userEvent.click(
screen.getByRole("button", { name: /use on this summary/i }),
);
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "Diving" })).toBeNull(),
);
await userEvent.click(screen.getByRole("button", { name: /share/i }));
expect(sharing.mint).toHaveBeenCalledWith({
totalDives: 310,
maxDepth: 39.6,
lastDiveOn: "2026-08-14",
});
});
it("holds Share after a failed read, until the diver types the figures in", async () => {
Object.assign(details, COMPLETE);
const sharing = controls();
// A rejected `/user/dive-stats` leaves the same nulls as one still in flight,
// and a link made then would show no diving for its whole day.
render(
loaded({ sharing, stats: null, loadFailed: true, figuresFailed: true }),
);
expect(screen.getByRole("button", { name: /share/i })).toBeDisabled();
await userEvent.click(
screen.getByRole("button", { name: "Correct these figures" }),
);
const dives = within(
await screen.findByRole("dialog", { name: "Diving" }),
).getByLabelText("Dives logged");
await userEvent.type(dives, "310");
await userEvent.click(
screen.getByRole("button", { name: /use on this summary/i }),
);
await waitFor(() =>
expect(screen.queryByRole("dialog", { name: "Diving" })).toBeNull(),
);
expect(screen.getByRole("button", { name: /share/i })).toBeEnabled();
});
// The cards and their dive centers are read by the server whenever the link is
// opened, so a failure there costs the link nothing it keeps.
it("shares when only the cards failed to load", () => {
Object.assign(details, COMPLETE);
render(loaded({ sharing: controls(), loadFailed: true }));
expect(screen.getByRole("button", { name: /share/i })).toBeEnabled();
});
it("shows a link made now as a QR code and an address, and keeps both off the sheet", () => {
Object.assign(details, COMPLETE);
const url = "https://dive.example/checkin/tok";
render(
loaded({
sharing: controls({
live: { expiresAt: "2026-09-27T10:00:00Z", url },
}),
}),
);
const panel = screen.getByRole("region", { name: "Check-in link" });
expect(panel).toHaveClass("print:hidden");
expect(
within(panel).getByRole("img", { name: /QR code/ }),
).toBeInTheDocument();
expect(within(panel).getByLabelText("Link address")).toHaveValue(url);
expect(
within(panel).getByRole("button", { name: "Copy" }),
).toBeInTheDocument();
expect(
within(panel).getByRole("button", { name: "Revoke" }),
).toBeInTheDocument();
});
it("says in one sentence why a link found on a later visit has no QR code", async () => {
Object.assign(details, COMPLETE);
const sharing = controls({
live: { expiresAt: "2026-09-27T10:00:00Z", url: null },
});
render(loaded({ sharing }));
const panel = screen.getByRole("region", { name: "Check-in link" });
expect(within(panel).queryByRole("img")).toBeNull();
expect(within(panel).queryByLabelText("Link address")).toBeNull();
expect(
within(panel).getByText(
/can.t be shown again, because only a fingerprint/,
),
).toBeInTheDocument();
await userEvent.click(
within(panel).getByRole("button", { name: "Revoke" }),
);
expect(sharing.revoke).toHaveBeenCalled();
});
it("copies the address", async () => {
Object.assign(details, COMPLETE);
const writeText = vi.fn().mockResolvedValue(undefined);
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
const url = "https://dive.example/checkin/tok";
render(
loaded({
sharing: controls({
live: { expiresAt: "2026-09-27T10:00:00Z", url },
}),
}),
);
await userEvent.click(screen.getByRole("button", { name: "Copy" }));
expect(writeText).toHaveBeenCalledWith(url);
});
});
describe("on a link's page", () => {
const PORTRAIT = "portrait1";
const LAST_DIVE_AT = "2026-08-14T09:30:00+02:00";
const imageFront = {
uuid: "file-1",
side: "front" as const,
content_type: "image/jpeg",
byte_size: 1024,
original_filename: "card.jpg",
};
const pdfFront = {
...imageFront,
uuid: "file-2",
content_type: "application/pdf",
original_filename: "card.pdf",
};
// The same diver and cards twice over: as the signed-in page holds them, and as a
// link's summary carries them.
const ownCards = () => [
certification({
uuid: "cert-1",
certification_number: "12345",
certified_on: "2020-05-01",
files: [imageFront],
}),
certification({ uuid: "cert-2", name: "Nitrox", files: [pdfFront] }),
];
const sharedCards = (): SharedCheckInCertification[] => [
{
uuid: "cert-1",
agency: "padi",
name: "Rescue Diver",
certification_number: "12345",
certified_on: "2020-05-01",
contact_name: "Blue Ocean",
front_content_type: "image/jpeg",
},
{
uuid: "cert-2",
agency: "padi",
name: "Nitrox",
front_content_type: "application/pdf",
},
];
const linkView = {
token: "tok",
expiresAt: "2026-09-27T10:00:00Z",
diving: { totalDives: 142, maxDepth: 39.6, lastDiveOn: "2026-08-14" },
};
const shared = (over: Partial<FrameProps> = {}) => (
<CheckInPageFrame
diver={{
...details,
name: auth.user.name,
portrait_sha256: auth.user.portrait_sha256,
}}
units={auth.user.units}
certifications={sharedCards()}
contactNames={{ "cert-1": "Blue Ocean" }}
isLoading={false}
link={linkView}
{...over}
/>
);
// What a desk reads off either page: every heading, label and value, in order.
const printed = (container: HTMLElement) =>
[...container.querySelectorAll("h1, h2, h3, dt, dd")].map(
(el) => el.textContent,
);
it("prints the same sheet from a link's summary as from the session", () => {
Object.assign(details, COMPLETE);
auth.user.portrait_sha256 = PORTRAIT;
const { container, unmount } = render(
loaded({
certifications: ownCards(),
contactNames: { "cert-1": "Blue Ocean" },
lastDiveAt: LAST_DIVE_AT,
}),
);
const fromSession = printed(container);
const sessionFootnote = screen.getByText(/own dive log/).textContent;
unmount();
const { container: linkContainer } = render(shared());
expect(printed(linkContainer)).toEqual(fromSession);
expect(screen.getByText(/own dive log/).textContent).toBe(sessionFootnote);
expect(fromSession).toContain("Blue Ocean");
expect(fromSession).toContain("142");
});
it("offers no control that edits, and no dialog", () => {
Object.assign(details, COMPLETE);
auth.user.portrait_sha256 = PORTRAIT;
render(shared());
// Print is the one control a desk gets.
expect(
screen.getAllByRole("button").map((button) => button.textContent),
).toEqual(["Print"]);
expect(screen.queryByRole("dialog")).toBeNull();
expect(screen.queryByRole("region", { name: "Check-in link" })).toBeNull();
});
it("reads every picture from the link's own routes, and none through the session", async () => {
Object.assign(details, COMPLETE);
auth.user.portrait_sha256 = PORTRAIT;
blobUrl.mockClear();
vi.mocked(authAPI.getPictureBlob).mockClear();
const fetchMock = vi.fn(async () => new Response("png"));
vi.stubGlobal("fetch", fetchMock);
const { container } = render(shared());
expect(container.querySelectorAll("img")).toHaveLength(2);
await Promise.all(
blobUrl.mock.calls.map(([fetchBlob]) =>
(fetchBlob as () => Promise<Blob>)(),
),
);
expect(fetchMock.mock.calls).toEqual([
[`${API_BASE_URL}/checkin/tok/portrait`, { credentials: "omit" }],
[
`${API_BASE_URL}/checkin/tok/certification/cert-1/front`,
{ credentials: "omit" },
],
]);
vi.unstubAllGlobals();
// The PDF front is named, never fetched.
expect(screen.getByText("card on file as PDF")).toBeInTheDocument();
expect(authAPI.getPictureBlob).not.toHaveBeenCalled();
expect(updateProfile).not.toHaveBeenCalled();
});
it("shows the sheet as it prints, and when the link stops working", () => {
// Nothing but a name: every section is empty, and a desk sees none of them.
render(shared({ certifications: [], contactNames: {} }));
expect(screen.queryByText("Not filled in yet.")).toBeNull();
expect(screen.queryByText("No certifications yet.")).toBeNull();
for (const title of [
"Dive Insurance",
"Emergency Contacts",
"Certifications",
]) {
expect(screen.getByText(title).closest("section")).toHaveClass("hidden");
}
// The lines written to the diver go; the expiry, written to the desk, comes.
expect(screen.queryByText(/on one page you can hand over/)).toBeNull();
expect(screen.queryByText(/save this as a PDF/)).toBeNull();
expect(screen.getByText(/This link stops working on/)).toHaveClass(
"print:hidden",
);
// The portrait's column stands, empty, with no dashed offer in it.
const slot = portraitColumn(auth.user.name);
expect(slot).toHaveClass("sm:w-24");
expect(slot.children).toHaveLength(0);
});
});