src/components/certifications/certification-dialog.render.test.tsx
Rama main · réplica de lectura del repositorio original.
import { beforeEach, describe, expect, it, vi } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CertificationDialog } from "./certification-dialog";
import type { Certification } from "@/lib/api/certifications";
import type { Course, CourseLookupItem } from "@/lib/api/courses";
import type { Contact } from "@/lib/api/contacts";
import type { Person } from "@/lib/api/people";
vi.mock("@/lib/api/certifications", async (importOriginal) => ({
// The agency vocabulary, its labels and the form's default are real: the
// picker's options and the create form's starting value both come from them,
// and stubbing them would make this test agree with itself rather than with
// the API.
...(await importOriginal<typeof import("@/lib/api/certifications")>()),
certificationsAPI: {
createCertification: vi.fn(),
updateCertification: vi.fn(),
getCertification: vi.fn(),
uploadCertificationFile: vi.fn(),
deleteCertificationFile: vi.fn(),
getCertificationFileBlob: vi.fn(() => new Promise<Blob>(() => {})),
},
}));
// jsdom has neither canvas nor an image decoder, so the two steps between picking
// a file and holding its cropped bytes stand in.
vi.mock("@/lib/image-crop", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/image-crop")>()),
cropToBlob: vi.fn(),
decodeImage: vi.fn(),
}));
// The cropper measures itself with a ResizeObserver that reports zeroes here, so
// it stands in as the one thing the form cares about: a Save that hands back a
// crop rectangle.
vi.mock("@/components/ui/image-crop-dialog", () => ({
ImageCropDialog: ({
saveLabel,
onSave,
}: {
saveLabel: string;
onSave: (area: {
x: number;
y: number;
width: number;
height: number;
}) => void;
}) => (
<button
type="button"
onClick={() => onSave({ x: 0, y: 0, width: 1013, height: 638 })}
>
{saveLabel}
</button>
),
}));
const toast = vi.fn();
vi.mock("@/components/ui/use-toast", () => ({
useToast: () => ({ toast }),
}));
vi.mock("@/lib/api/courses", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/api/courses")>()),
coursesAPI: {
lookupCourses: vi.fn(),
lookupCoursesByUuid: vi.fn(),
getCourse: vi.fn(),
createCourse: vi.fn(),
},
}));
vi.mock("@/lib/api/contacts", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/api/contacts")>()),
contactsAPI: {
lookupContacts: vi.fn(),
getContact: vi.fn(),
createContact: vi.fn(),
},
}));
vi.mock("@/lib/api/people", async (importOriginal) => ({
...(await importOriginal<typeof import("@/lib/api/people")>()),
peopleAPI: {
lookupPeople: vi.fn(),
getPerson: vi.fn(),
createPerson: vi.fn(),
},
}));
const { certificationsAPI } = await import("@/lib/api/certifications");
const { coursesAPI } = await import("@/lib/api/courses");
const { contactsAPI } = await import("@/lib/api/contacts");
const { peopleAPI } = await import("@/lib/api/people");
const { cropToBlob, decodeImage } = await import("@/lib/image-crop");
const createCertification = vi.mocked(certificationsAPI.createCertification);
const updateCertification = vi.mocked(certificationsAPI.updateCertification);
const getCertification = vi.mocked(certificationsAPI.getCertification);
const uploadFile = vi.mocked(certificationsAPI.uploadCertificationFile);
const deleteFile = vi.mocked(certificationsAPI.deleteCertificationFile);
const crop = vi.mocked(cropToBlob);
const decode = vi.mocked(decodeImage);
const CROPPED = new Blob(["cropped"], { type: "image/webp" });
const lookupCourses = vi.mocked(coursesAPI.lookupCourses);
const getCourse = vi.mocked(coursesAPI.getCourse);
const createCourse = vi.mocked(coursesAPI.createCourse);
const lookupContacts = vi.mocked(contactsAPI.lookupContacts);
const getContact = vi.mocked(contactsAPI.getContact);
const createContact = vi.mocked(contactsAPI.createContact);
const lookupPeople = vi.mocked(peopleAPI.lookupPeople);
const getPerson = vi.mocked(peopleAPI.getPerson);
const createPerson = vi.mocked(peopleAPI.createPerson);
const contact = (uuid: string, name: string): Contact => ({
uuid,
name,
roles: ["school"],
notes: "",
user_uuid: "user-1",
created_at: "2026-03-01T09:00:00Z",
});
const BLUE_OCEAN = contact("contact-blue", "Blue Ocean, Koh Tao");
const RED_SEA = contact("contact-red", "Red Sea Divers");
const MY_SHOP = contact("contact-mine", "My Shop");
const OLD_SHOP = contact("contact-old", "Old Shop");
const NO_SHOP = contact("contact-none", "No shop");
const CONTACTS = [BLUE_OCEAN, RED_SEA, MY_SHOP, OLD_SHOP, NO_SHOP];
const person = (uuid: string, name: string): Person => ({
uuid,
name,
notes: "",
username: null,
dive_count: 0,
created_at: "2026-03-01T09:00:00Z",
});
const ALEX = person("person-alex", "Alex Diver");
const SAM = person("person-sam", "Sam Reef");
const KIM = person("person-kim", "Kim Solo");
const JO = person("person-jo", "Jo Teacher");
const PEOPLE = [ALEX, SAM, KIM, JO];
const STUDENT = person("person-student", "Pat Student");
// A course's instructor is a reference on its people, and not necessarily the
// first one - the card takes the first *instructor*.
const instructedBy = (who: Person) => [
{ person_uuid: STUDENT.uuid, role: "student" },
{ person_uuid: who.uuid, role: "instructor" },
];
const course = (overrides: Partial<Course> = {}): Course => ({
uuid: "course-1",
name: "Advanced Nitrox + Deco",
agency: "tdi",
agency_other: null,
status: "completed",
start_date: "2026-03-02",
end_date: "2026-03-06",
instructor_number: "123",
contact_uuid: BLUE_OCEAN.uuid,
people: instructedBy(ALEX),
notes: "Ran the 21m and 30m dives on back gas.",
user_uuid: "user-1",
created_at: "2026-03-08T09:00:00Z",
...overrides,
});
// A referral: a different centre and instructor, no instructor number, and an
// agency the certification form does not default to either.
const OTHER_COURSE = course({
uuid: "course-2",
name: "Rescue Diver, Dahab",
agency: "ssi",
people: instructedBy(SAM),
instructor_number: null,
contact_uuid: RED_SEA.uuid,
notes: "",
});
// A course run by a private instructor: no agency to hand over, while every
// other prefillable field is its own.
const AGENCYLESS_COURSE = course({
uuid: "course-4",
name: "Sidemount, with Kim",
agency: null,
people: instructedBy(KIM),
instructor_number: "IND-3",
contact_uuid: NO_SHOP.uuid,
});
// A course that names no contact at all.
const CONTACTLESS_COURSE = course({
uuid: "course-5",
name: "Deep Diver, self-study",
contact_uuid: null,
});
const COURSE = course();
const EXISTING: Certification = {
uuid: "cert-1",
agency: "padi",
agency_other: null,
name: "Open Water Diver",
certification_number: "OW-1",
certified_on: "2020-05-01",
expires_on: null,
instructor_uuid: JO.uuid,
instructor_number: "OLD-9",
contact_uuid: OLD_SHOP.uuid,
notes: "",
course_uuid: null,
user_uuid: "user-1",
created_at: "2020-05-02T09:00:00Z",
};
const page = <T,>(items: T[]) => ({
data: items,
total_count: items.length,
has_more: false,
page: 1,
items_per_page: 25,
});
beforeEach(() => {
vi.clearAllMocks();
createCertification.mockResolvedValue({ ...EXISTING, uuid: "cert-new" });
updateCertification.mockResolvedValue({ message: "ok" });
getCertification.mockResolvedValue(EXISTING);
uploadFile.mockResolvedValue({
uuid: "file-1",
side: "front",
content_type: "image/webp",
byte_size: 7,
original_filename: "card-front.webp",
});
deleteFile.mockResolvedValue({ message: "ok" });
decode.mockResolvedValue({} as HTMLImageElement);
crop.mockResolvedValue(CROPPED);
// The server filters by `search`; the stub answers every query with both
// courses, so a second pick doesn't depend on re-typing the exact name.
listCourses([COURSE, OTHER_COURSE]);
// The picker shows a contact's name once it has looked the uuid up, which is
// what these tests read the field's value off.
lookupContacts.mockImplementation(async () => page(CONTACTS));
getContact.mockImplementation(async (uuid: string) => {
const found = CONTACTS.find((one) => one.uuid === uuid);
if (!found) throw new Error("not found");
return found;
});
// And the instructor's, the same way.
lookupPeople.mockImplementation(async () => page(PEOPLE));
getPerson.mockImplementation(async (uuid: string) => {
const found = PEOPLE.find((one) => one.uuid === uuid);
if (!found) throw new Error("not found");
return found;
});
});
// The menu lists lookup rows and a held uuid resolves through the lookup, both
// from the one list a test sets up, holding only what the lookup carries.
function listCourses(courses: Course[]) {
const rows = courses.map(lookupRow);
lookupCourses.mockImplementation(async () => page(rows));
vi.mocked(coursesAPI.lookupCoursesByUuid).mockImplementation(async (uuids) =>
rows.filter((one) => uuids.includes(one.uuid)),
);
}
const lookupRow = ({
uuid,
name,
agency,
agency_other,
instructor_number,
contact_uuid,
people,
}: Course): CourseLookupItem => ({
uuid,
name,
agency,
agency_other,
instructor_number,
contact_uuid,
people,
});
function open({
certification,
initialCourse,
}: { certification?: Certification; initialCourse?: Course } = {}) {
const onSaved = vi.fn();
render(
<CertificationDialog
open
onOpenChange={vi.fn()}
certification={certification}
initialCourse={initialCourse}
onSaved={onSaved}
/>,
);
return onSaved;
}
// Opens the course menu and picks one by name. Clicking rather than typing:
// the menu's own search runs on focus, and a pick is the path every one of
// these invariants is about.
async function pickCourse(name: string) {
await userEvent.click(screen.getByLabelText("Course"));
await userEvent.click(await screen.findByRole("option", { name }));
}
// The diver's own choice of dive center, made the same way.
async function pickDiveCenter(name: string) {
await userEvent.click(diveCenter());
await userEvent.click(await screen.findByRole("option", { name }));
}
const diveCenter = () => screen.getByLabelText("Dive center");
// A picker's own Clear button, which sits beside its input - the date fields
// carry one each too.
const clearOf = (input: HTMLElement) =>
within(input.parentElement!).getByRole("button", { name: "Clear" });
const instructor = () => screen.getByLabelText("Instructor");
const instructorNumber = () => screen.getByLabelText("Instructor number");
const agency = () => screen.getByLabelText("Agency *");
const certificationName = () => screen.getByLabelText("Certification *");
const save = () =>
userEvent.click(
screen.getByRole("button", { name: /Create certification|Save changes/ }),
);
describe("the card's two dates are a range", () => {
it("empties the expiry when the certified date moves past it", async () => {
open({ certification: { ...EXISTING, expires_on: "2022-05-01" } });
const certifiedOn = screen.getByLabelText("Certified on");
await waitFor(() => expect(certifiedOn).toHaveValue("2020-05-01"));
await userEvent.clear(certifiedOn);
await userEvent.type(certifiedOn, "2023-01-15");
await userEvent.tab();
expect(screen.getByLabelText("Expires on")).toHaveValue("");
await save();
await waitFor(() => expect(updateCertification).toHaveBeenCalled());
expect(updateCertification.mock.calls[0][1]).toMatchObject({
certified_on: "2023-01-15",
expires_on: null,
});
});
});
describe("picking a course fills the card's own fields in", () => {
it("copies the level, dive center, instructor and agency across from the lookup row", async () => {
open();
await pickCourse(COURSE.name);
expect(getCourse).not.toHaveBeenCalled();
await waitFor(() => expect(diveCenter()).toHaveValue(BLUE_OCEAN.name));
expect(certificationName()).toHaveValue("Advanced Nitrox + Deco");
// The course's instructor, not the first person on it.
await waitFor(() => expect(instructor()).toHaveValue("Alex Diver"));
expect(instructorNumber()).toHaveValue("123");
// Despite `padi` being the create form's default rather than an empty box,
// which is what rules a fill-only-if-empty prefill out.
expect(agency()).toHaveTextContent("TDI");
});
it("leaves the notes for the diver to write about the card", async () => {
// A course's notes describe the training and a card's describe the card, so
// a course value here would be plausible-but-wrong and saved unread. The
// level is prefilled and the notes are not, which is the whole difference.
open();
await pickCourse(COURSE.name);
await waitFor(() => expect(diveCenter()).toHaveValue(BLUE_OCEAN.name));
expect(screen.getByLabelText("Notes")).toHaveValue("");
});
it("keeps a level the diver typed off the card in their hand", async () => {
// The course name is often longer than what the card says, and one course
// can issue two differently-named cards - so this is the field the
// typed-into guard matters most for.
open();
await userEvent.type(certificationName(), "Advanced Nitrox");
await pickCourse(COURSE.name);
await waitFor(() => expect(diveCenter()).toHaveValue(BLUE_OCEAN.name));
expect(certificationName()).toHaveValue("Advanced Nitrox");
});
it("never overwrites a dive center the diver picked", async () => {
open();
await pickDiveCenter(MY_SHOP.name);
await pickCourse(COURSE.name);
await waitFor(() => expect(instructor()).toHaveValue("Alex Diver"));
expect(diveCenter()).toHaveValue(MY_SHOP.name);
await save();
await waitFor(() => expect(createCertification).toHaveBeenCalled());
expect(createCertification.mock.calls[0][0]).toMatchObject({
contact_uuid: MY_SHOP.uuid,
});
});
it("replaces its own earlier prefill when the course changes, and empties what the new one lacks", async () => {
open();
await pickCourse(COURSE.name);
await waitFor(() => expect(instructorNumber()).toHaveValue("123"));
await waitFor(() => expect(diveCenter()).toHaveValue(BLUE_OCEAN.name));
await pickCourse(OTHER_COURSE.name);
await waitFor(() => expect(instructor()).toHaveValue("Sam Reef"));
// The second course has no instructor number, so the first one's must go -
// keeping it would attribute course A's instructor number to course B.
expect(instructorNumber()).toHaveValue("");
expect(agency()).toHaveTextContent("SSI");
await waitFor(() => expect(diveCenter()).toHaveValue(RED_SEA.name));
});
it("keeps the diver's dive center two courses later", async () => {
open();
await pickCourse(COURSE.name);
await waitFor(() => expect(diveCenter()).toHaveValue(BLUE_OCEAN.name));
await pickDiveCenter(MY_SHOP.name);
await pickCourse(OTHER_COURSE.name);
await waitFor(() => expect(instructor()).toHaveValue("Sam Reef"));
expect(diveCenter()).toHaveValue(MY_SHOP.name);
});
it("takes back a dive center it copied when the next course names none", async () => {
// What the dialog wrote is the course's, and a course naming nobody has no
// contact to leave behind - course A's must not be filed under course B.
listCourses([COURSE, CONTACTLESS_COURSE]);
open();
await pickCourse(COURSE.name);
await waitFor(() => expect(diveCenter()).toHaveValue(BLUE_OCEAN.name));
await pickCourse(CONTACTLESS_COURSE.name);
await waitFor(() => expect(diveCenter()).toHaveValue(""));
await save();
await waitFor(() => expect(createCertification).toHaveBeenCalled());
expect(createCertification.mock.calls[0][0]).toMatchObject({
course_uuid: CONTACTLESS_COURSE.uuid,
contact_uuid: null,
});
});
it("still replaces its own prefill after an unrelated field is cleared back to empty", async () => {
// The regression this guards is not hypothetical: react-hook-form recomputes
// `dirtyFields` for the *whole* form the moment any field is edited back to
// its default, and a silently prefilled field differs from its default by
// construction - so a dirtiness-based prefill marks every prefilled field
// dirty here and quietly stops replacing them. See DECISIONS.md, "A silently
// prefilled field is not a clean field".
open();
await pickCourse(COURSE.name);
await waitFor(() => expect(diveCenter()).toHaveValue(BLUE_OCEAN.name));
await userEvent.type(certificationName(), "x");
await userEvent.clear(certificationName());
await pickCourse(OTHER_COURSE.name);
await waitFor(() => expect(diveCenter()).toHaveValue(RED_SEA.name));
await waitFor(() => expect(instructor()).toHaveValue("Sam Reef"));
});
it("keeps every field when the course is cleared, and unlinks only", async () => {
// Clearing asserts "no logged course", not "those facts are wrong" - a
// certification is designed to stand alone.
open();
await pickCourse(COURSE.name);
await waitFor(() => expect(diveCenter()).toHaveValue(BLUE_OCEAN.name));
await userEvent.click(clearOf(screen.getByLabelText("Course")));
expect(diveCenter()).toHaveValue(BLUE_OCEAN.name);
await userEvent.type(certificationName(), "Advanced Nitrox");
await save();
await waitFor(() => expect(createCertification).toHaveBeenCalled());
expect(createCertification.mock.calls[0][0]).toMatchObject({
course_uuid: null,
contact_uuid: BLUE_OCEAN.uuid,
instructor_uuid: ALEX.uuid,
agency: "tdi",
});
});
it("takes back an instructor it copied when the next course names none", async () => {
listCourses([
COURSE,
course({ uuid: "course-6", name: "Self-study", people: [] }),
]);
open();
await pickCourse(COURSE.name);
await waitFor(() => expect(instructor()).toHaveValue("Alex Diver"));
await pickCourse("Self-study");
await waitFor(() => expect(instructor()).toHaveValue(""));
await save();
await waitFor(() => expect(createCertification).toHaveBeenCalled());
expect(createCertification.mock.calls[0][0]).toMatchObject({
instructor_uuid: null,
});
});
it("sends no instructor's name, only the person", async () => {
// The name went with the text field; the pick has said who it is.
open();
await pickCourse(COURSE.name);
await waitFor(() => expect(instructor()).toHaveValue("Alex Diver"));
await save();
await waitFor(() => expect(createCertification).toHaveBeenCalled());
expect(createCertification.mock.calls[0][0]).not.toHaveProperty(
"instructor_name",
);
});
it("sends no training center, only the contact", async () => {
// A name sent as text would be matched to a contact, or make one, on the
// API's side; the picker has already said which contact it is.
open();
await pickCourse(COURSE.name);
await waitFor(() => expect(diveCenter()).toHaveValue(BLUE_OCEAN.name));
await save();
await waitFor(() => expect(createCertification).toHaveBeenCalled());
expect(createCertification.mock.calls[0][0]).not.toHaveProperty(
"training_center",
);
});
it("leaves the card's own agency alone for a course that names none", async () => {
// A course's agency is optional and a certification's is required, so the
// one field the prefill must not empty is this one.
listCourses([AGENCYLESS_COURSE]);
open();
await pickCourse(AGENCYLESS_COURSE.name);
await waitFor(() => expect(diveCenter()).toHaveValue(NO_SHOP.name));
await waitFor(() => expect(instructor()).toHaveValue("Kim Solo"));
expect(agency()).toHaveTextContent("PADI");
expect(screen.queryByLabelText("Agency name *")).not.toBeInTheDocument();
await userEvent.type(certificationName(), "Sidemount Diver");
await save();
await waitFor(() => expect(createCertification).toHaveBeenCalled());
expect(createCertification.mock.calls[0][0]).toMatchObject({
agency: "padi",
agency_other: null,
contact_uuid: NO_SHOP.uuid,
});
});
it("keeps an agency it copied itself when the next course names none", async () => {
// The one exception to "switching A -> B empties what B lacks": neither
// half of the pair is blanked, because the result would be unsubmittable.
listCourses([COURSE, AGENCYLESS_COURSE]);
open();
await pickCourse(COURSE.name);
await waitFor(() => expect(agency()).toHaveTextContent("TDI"));
await pickCourse(AGENCYLESS_COURSE.name);
await waitFor(() => expect(instructor()).toHaveValue("Kim Solo"));
expect(instructorNumber()).toHaveValue("IND-3");
expect(agency()).toHaveTextContent("TDI");
});
it("sends the agency pair the API validates together", async () => {
// A named agency carrying an `agency_other` is a 422, and so is "other"
// without one - so the two are only ever copied as a pair.
const namedOther = course({
uuid: "course-3",
name: "Nitrox, FFESSM",
agency: "other",
agency_other: "FFESSM",
});
listCourses([namedOther]);
open();
await pickCourse(namedOther.name);
// The picker itself reads "Other"; the name it stands for is the second
// field, which only exists while the agency is "other" - so a prefill that
// moved one without the other would leave the form unsubmittable.
await waitFor(() => expect(agency()).toHaveTextContent("Other"));
expect(await screen.findByLabelText("Agency name *")).toHaveValue("FFESSM");
await userEvent.type(certificationName(), "Nitrox");
await save();
await waitFor(() => expect(createCertification).toHaveBeenCalled());
expect(createCertification.mock.calls[0][0]).toMatchObject({
agency: "other",
agency_other: "FFESSM",
});
});
});
describe("the edit dialog's values are a pure function of the card being edited", () => {
it("changes nothing but the link when the course is relinked", async () => {
open({ certification: EXISTING });
await waitFor(() => expect(diveCenter()).toHaveValue(OLD_SHOP.name));
await pickCourse(COURSE.name);
// Every field still the card's own, however tempting the course's are.
await waitFor(() =>
expect(screen.getByLabelText("Course")).toHaveValue(COURSE.name),
);
expect(diveCenter()).toHaveValue(OLD_SHOP.name);
await waitFor(() => expect(instructor()).toHaveValue("Jo Teacher"));
expect(instructorNumber()).toHaveValue("OLD-9");
expect(agency()).toHaveTextContent("PADI");
await save();
await waitFor(() => expect(updateCertification).toHaveBeenCalled());
expect(updateCertification.mock.calls[0][1]).toMatchObject({
course_uuid: COURSE.uuid,
contact_uuid: OLD_SHOP.uuid,
instructor_uuid: JO.uuid,
agency: "padi",
});
});
it("unlinks the instructor when the diver clears it", async () => {
open({ certification: EXISTING });
await waitFor(() => expect(instructor()).toHaveValue("Jo Teacher"));
await userEvent.click(clearOf(instructor()));
await save();
await waitFor(() => expect(updateCertification).toHaveBeenCalled());
expect(updateCertification.mock.calls[0][1]).toMatchObject({
instructor_uuid: null,
});
});
it("unlinks the contact when the diver clears it", async () => {
// `null`, not an omitted key: the update leaves an absent key alone, so a
// cleared picker has to say so for the link to go.
open({ certification: EXISTING });
await waitFor(() => expect(diveCenter()).toHaveValue(OLD_SHOP.name));
await userEvent.click(clearOf(diveCenter()));
await save();
await waitFor(() => expect(updateCertification).toHaveBeenCalled());
expect(updateCertification.mock.calls[0][1]).toMatchObject({
contact_uuid: null,
});
});
});
describe("a dialog opened from a course page starts on that course", () => {
it("opens pre-linked and prefilled", async () => {
open({ initialCourse: COURSE });
await waitFor(() => expect(diveCenter()).toHaveValue(BLUE_OCEAN.name));
await waitFor(() => expect(instructor()).toHaveValue("Alex Diver"));
expect(instructorNumber()).toHaveValue("123");
expect(agency()).toHaveTextContent("TDI");
expect(certificationName()).toHaveValue("Advanced Nitrox + Deco");
// The seeded level is a starting point, not an answer: this course issues a
// card whose printed name is shorter than the course's own.
await userEvent.clear(certificationName());
await userEvent.type(certificationName(), "Advanced Nitrox");
await save();
await waitFor(() => expect(createCertification).toHaveBeenCalled());
expect(createCertification.mock.calls[0][0]).toMatchObject({
name: "Advanced Nitrox",
course_uuid: COURSE.uuid,
contact_uuid: BLUE_OCEAN.uuid,
});
});
it("opens on its own agency when the seed course names none", async () => {
// The seeded path is the only one that could open a create dialog with its
// required agency unset, so it answers the field from the form's default.
open({ initialCourse: AGENCYLESS_COURSE });
await waitFor(() => expect(diveCenter()).toHaveValue(NO_SHOP.name));
expect(agency()).toHaveTextContent("PADI");
await userEvent.clear(certificationName());
await userEvent.type(certificationName(), "Sidemount Diver");
await save();
await waitFor(() => expect(createCertification).toHaveBeenCalled());
expect(createCertification.mock.calls[0][0]).toMatchObject({
agency: "padi",
agency_other: null,
course_uuid: AGENCYLESS_COURSE.uuid,
});
});
it("still protects what the diver picks over the seeded values", async () => {
open({ initialCourse: COURSE });
await waitFor(() => expect(diveCenter()).toHaveValue(BLUE_OCEAN.name));
await pickDiveCenter(MY_SHOP.name);
await pickCourse(OTHER_COURSE.name);
await waitFor(() => expect(instructor()).toHaveValue("Sam Reef"));
expect(diveCenter()).toHaveValue(MY_SHOP.name);
});
});
describe("the instructor is a person", () => {
it("makes a typed name a person on Enter, and names them by uuid", async () => {
// Naming a new instructor takes the steps typing one did: the name, then
// Enter - no dialog.
const created = person("person-new", "Robin Reef");
lookupPeople.mockImplementation(async () => page([]));
createPerson.mockResolvedValue(created);
open();
await userEvent.type(certificationName(), "Rescue Diver");
await userEvent.click(instructor());
await screen.findByText(/No people yet/);
await userEvent.type(instructor(), "Robin Reef{Enter}");
await waitFor(() => expect(instructor()).toHaveValue("Robin Reef"));
expect(createPerson).toHaveBeenCalledWith({ name: "Robin Reef" });
await save();
await waitFor(() => expect(createCertification).toHaveBeenCalled());
expect(createCertification.mock.calls[0][0]).toMatchObject({
instructor_uuid: created.uuid,
});
});
it("files no half-typed name on the way out of the field", async () => {
lookupPeople.mockImplementation(async () => page([]));
open();
await userEvent.click(instructor());
await userEvent.type(instructor(), "Rob");
await userEvent.click(certificationName());
expect(createPerson).not.toHaveBeenCalled();
});
});
describe("a contact made three dialogs deep", () => {
it("comes back through the course dialog into the certification", async () => {
// Certification -> "Add course..." -> "Add dive center...": three forms, one
// above the other, each portalled to the body. Each inner save has to land
// in the form that opened it and nowhere else - the course dialog selects
// the new contact, the certification selects the new course and copies the
// contact across from it, and the certification's own field keeps its text.
const created = contact("contact-new", "Sea Dragon Diving");
createContact.mockResolvedValue(created);
getContact.mockImplementation(async (uuid: string) => {
const found = [...CONTACTS, created].find((one) => one.uuid === uuid);
if (!found) throw new Error("not found");
return found;
});
createCourse.mockImplementation(async (body) =>
course({
uuid: "course-new",
name: body.name,
contact_uuid: body.contact_uuid ?? null,
people: [],
instructor_number: null,
}),
);
open();
await userEvent.type(certificationName(), "Deep Diver");
await userEvent.click(screen.getByLabelText("Course"));
await userEvent.click(
await screen.findByRole("option", { name: "Add course..." }),
);
const courseDialog = await screen.findByRole("dialog", {
name: "New Course",
});
await userEvent.type(
within(courseDialog).getByLabelText("Course *"),
"Deep Specialty",
);
await userEvent.click(within(courseDialog).getByLabelText("Dive center"));
await userEvent.click(
await screen.findByRole("option", { name: "Add dive center..." }),
);
const contactDialog = await screen.findByRole("dialog", {
name: "New Contact",
});
// The role a course's contact starts with, already ticked.
expect(within(contactDialog).getByLabelText("School")).toBeChecked();
await userEvent.type(
within(contactDialog).getByLabelText("Name *"),
created.name,
);
await userEvent.click(
within(contactDialog).getByRole("button", { name: "Create contact" }),
);
await waitFor(() =>
expect(
screen.queryByRole("dialog", { name: "New Contact" }),
).not.toBeInTheDocument(),
);
expect(createContact).toHaveBeenCalledWith(
expect.objectContaining({ name: created.name, roles: ["school"] }),
);
expect(within(courseDialog).getByLabelText("Dive center")).toHaveValue(
created.name,
);
expect(createCertification).not.toHaveBeenCalled();
await userEvent.click(
within(courseDialog).getByRole("button", { name: "Create course" }),
);
await waitFor(() =>
expect(
screen.queryByRole("dialog", { name: "New Course" }),
).not.toBeInTheDocument(),
);
expect(createCourse).toHaveBeenCalledWith(
expect.objectContaining({ contact_uuid: created.uuid }),
);
expect(screen.getByLabelText("Course")).toHaveValue("Deep Specialty");
await waitFor(() => expect(diveCenter()).toHaveValue(created.name));
expect(certificationName()).toHaveValue("Deep Diver");
expect(createCertification).not.toHaveBeenCalled();
});
});
// A photo file, small enough to pass the size check this form makes before the
// API's own.
const photo = () =>
new File([new Uint8Array([1, 2, 3])], "card.jpg", { type: "image/jpeg" });
const frontPicker = () =>
screen.getByLabelText("Choose a front card image") as HTMLInputElement;
// Pick an image for the front slot and take the stub cropper's crop.
async function pickFrontImage() {
await userEvent.upload(frontPicker(), photo());
await userEvent.click(
await screen.findByRole("button", { name: "Use this crop" }),
);
}
// Picking an image sends nothing: a diver who cancels leaves the stored cards as
// they found them, and a card being created has no uuid to upload against until
// the save resolves.
describe("card images ride on the form's own save", () => {
it("sends nothing while the diver is still picking", async () => {
open({ certification: EXISTING });
await pickFrontImage();
expect(await screen.findByText(/Added when you save/)).toBeInTheDocument();
expect(uploadFile).not.toHaveBeenCalled();
expect(updateCertification).not.toHaveBeenCalled();
});
it("sends a struck-off image nowhere until the form is saved", async () => {
open({
certification: {
...EXISTING,
files: [
{
uuid: "file-1",
side: "front",
content_type: "image/webp",
byte_size: 7,
original_filename: "padi-ow.webp",
},
],
},
});
await userEvent.click(
screen.getByRole("button", { name: /Remove the front image/ }),
);
expect(screen.getByText("Deleted when you save")).toBeInTheDocument();
expect(deleteFile).not.toHaveBeenCalled();
await save();
await waitFor(() =>
expect(deleteFile).toHaveBeenCalledWith("cert-1", "front"),
);
});
it("uploads the cropped bytes after the details, against the card just created", async () => {
// The ordering the API forces: `PUT .../file/{side}` needs a uuid, and a card
// being created does not have one until `createCertification` resolves.
open();
await userEvent.type(certificationName(), "Advanced Nitrox");
await pickFrontImage();
await save();
await waitFor(() => expect(uploadFile).toHaveBeenCalled());
expect(uploadFile).toHaveBeenCalledWith(
"cert-new",
"front",
CROPPED,
"card-front.webp",
);
});
it("hands back a re-read card, the embedded file metadata being stale either way", async () => {
// Only the API knows which sides landed, and the list and the check-in sheet
// both render their thumbnails from that embedded metadata.
const withFile = { ...EXISTING, files: [] };
getCertification.mockResolvedValue(withFile);
const onSaved = open({ certification: EXISTING });
await pickFrontImage();
await save();
await waitFor(() => expect(onSaved).toHaveBeenCalledWith(withFile));
expect(getCertification).toHaveBeenCalledWith("cert-1");
});
it("re-reads nothing when no image was touched", async () => {
const onSaved = open({ certification: EXISTING });
await save();
await waitFor(() => expect(onSaved).toHaveBeenCalled());
expect(getCertification).not.toHaveBeenCalled();
});
it("keeps the save when an image fails, and says which side", async () => {
// The details are already written by then, so failing the whole save would
// make the diver fill the form in again to retry one picture.
uploadFile.mockRejectedValue(new Error("nope"));
const onSaved = open({ certification: EXISTING });
await pickFrontImage();
await save();
await waitFor(() => expect(onSaved).toHaveBeenCalled());
expect(toast).toHaveBeenCalledWith(
expect.objectContaining({
title: expect.stringContaining("front image did not"),
}),
);
});
});