import { useState } from "react"; import { beforeEach, describe, expect, it, vi } from "vitest"; import { render, screen, waitFor } from "@testing-library/react"; import userEvent from "@testing-library/user-event"; import { CourseCertificationsCard } from "./course-certifications-card"; import type { Certification } from "@/lib/api/certifications"; import type { Course } from "@/lib/api/courses"; import type { Contact } from "@/lib/api/contacts"; vi.mock("@/lib/api/certifications", async (importOriginal) => ({ ...(await importOriginal()), certificationsAPI: { getCertifications: vi.fn(), getCertification: vi.fn(), createCertification: vi.fn(), updateCertification: vi.fn(), }, })); vi.mock("@/lib/api/courses", async (importOriginal) => ({ ...(await importOriginal()), coursesAPI: { lookupCourses: vi.fn(), lookupCoursesByUuid: vi.fn(), getCourse: vi.fn(), }, })); vi.mock("@/lib/api/contacts", async (importOriginal) => ({ ...(await importOriginal()), contactsAPI: { lookupContacts: vi.fn(), getContact: vi.fn() }, })); vi.mock("@/lib/api/people", async (importOriginal) => ({ ...(await importOriginal()), peopleAPI: { lookupPeople: vi.fn(), getPerson: vi.fn() }, })); const { certificationsAPI } = await import("@/lib/api/certifications"); const { coursesAPI } = await import("@/lib/api/courses"); const { contactsAPI } = await import("@/lib/api/contacts"); const getContact = vi.mocked(contactsAPI.getContact); const { peopleAPI } = await import("@/lib/api/people"); const getPerson = vi.mocked(peopleAPI.getPerson); const ALEX = { uuid: "person-alex", name: "Alex Diver", notes: "", username: null, dive_count: 0, created_at: "2026-03-01T09:00:00Z", }; const BLUE_OCEAN: Contact = { uuid: "contact-blue", name: "Blue Ocean, Koh Tao", roles: ["school"], notes: "", user_uuid: "user-1", created_at: "2026-03-01T09:00:00Z", }; const getCertifications = vi.mocked(certificationsAPI.getCertifications); const createCertification = vi.mocked(certificationsAPI.createCertification); const getCourse = vi.mocked(coursesAPI.getCourse); const 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: [{ person_uuid: ALEX.uuid, role: "instructor" }], notes: "Ran the 21m and 30m dives on back gas.", user_uuid: "user-1", created_at: "2026-03-08T09:00:00Z", }; const CREATED: Certification = { uuid: "cert-new", agency: "tdi", agency_other: null, name: "Advanced Nitrox", certification_number: null, certified_on: null, expires_on: null, instructor_uuid: ALEX.uuid, instructor_number: "123", contact_uuid: BLUE_OCEAN.uuid, notes: "", course_uuid: COURSE.uuid, user_uuid: "user-1", created_at: "2026-03-09T09:00:00Z", }; const page = (items: T[]) => ({ data: items, total_count: items.length, has_more: false, page: 1, items_per_page: 50, }); beforeEach(() => { vi.clearAllMocks(); // `mockImplementation` rather than `mockResolvedValue`: a single shared // response object is the array React can bail out of re-rendering on, which // hides a fetch loop instead of failing on it. See DECISIONS.md, "A shared // mock response object hides a render loop". getCertifications.mockImplementation(async () => page([])); createCertification.mockImplementation(async () => CREATED); getCourse.mockImplementation(async () => COURSE); vi.mocked(coursesAPI.lookupCoursesByUuid).mockImplementation(async () => [ COURSE, ]); getContact.mockImplementation(async () => BLUE_OCEAN); getPerson.mockImplementation(async () => ALEX); }); // The page owns "is the create dialog open", because its sidebar opens the same // dialog this card's empty state does. This stands in for the page. function Harness({ course = COURSE }: { course?: Course }) { const [isAdding, setIsAdding] = useState(false); return ( <> ); } const render_ = () => render(); describe("the course's certifications card", () => { it("reads the list once, not once per render", async () => { render_(); await screen.findByText("No certifications from this course yet"); expect(getCertifications).toHaveBeenCalledTimes(1); expect(getCertifications).toHaveBeenCalledWith(1, 50, { courseUuid: COURSE.uuid, }); }); it("carries exactly one way into the dialog, worded apart from the sidebar's", async () => { // A screen reader's controls list is flat, so the page's sidebar button // ("Add a certification") and this one must not share a name. render_(); await screen.findByText("No certifications from this course yet"); expect( screen .getAllByRole("button", { name: /certification/i }) .map((button) => button.textContent), ).toEqual(["Add the first certification"]); }); it("drops its header while there is nothing to list", async () => { render_(); await screen.findByText("No certifications from this course yet"); const heading = screen.getByRole("heading", { level: 2, name: "Certifications from This Course", }); expect(heading).toHaveClass("sr-only"); expect([...heading.parentElement!.children]).toEqual([heading]); }); it("keeps it once there is", async () => { getCertifications.mockImplementation(async () => page([CREATED])); render_(); await screen.findByRole("button", { name: /Advanced Nitrox/ }); expect( screen.getByRole("heading", { level: 2, name: "Certifications from This Course", }), ).not.toHaveClass("sr-only"); }); // A private instructor's course: no agency, so nothing invites a card until // there is one. const NO_AGENCY: Course = { ...COURSE, agency: null }; it("stays hidden on a course with no agency and no certifications", async () => { render(); await waitFor(() => expect(getCertifications).toHaveBeenCalled()); expect( screen.queryByRole("heading", { name: "Certifications from This Course", }), ).not.toBeInTheDocument(); expect( screen.queryByText("No certifications from this course yet"), ).not.toBeInTheDocument(); // The sidebar's button still reaches the dialog the card owns. await userEvent.click(screen.getByRole("button", { name: "Sidebar add" })); expect(await screen.findByLabelText("Course")).toHaveValue(COURSE.name); }); it("shows on a course with no agency once it has a certification", async () => { getCertifications.mockImplementation(async () => page([CREATED])); render(); expect( await screen.findByRole("button", { name: /Advanced Nitrox/ }), ).toBeInTheDocument(); expect( screen.getByRole("heading", { level: 2, name: "Certifications from This Course", }), ).not.toHaveClass("sr-only"); }); it("shows a certification created from here without a reload", async () => { render_(); await screen.findByText("No certifications from this course yet"); await userEvent.click( screen.getByRole("button", { name: "Add the first certification" }), ); // The dialog arrives linked to this course and filled in from it, which is // the whole reason the card holds the course rather than just its uuid. expect(await screen.findByLabelText("Course")).toHaveValue(COURSE.name); await waitFor(() => expect(screen.getByLabelText("Dive center")).toHaveValue(BLUE_OCEAN.name), ); // Including the level, which the diver then corrects to what their card // actually says - the course's own name is longer than the card's. expect(screen.getByLabelText("Certification *")).toHaveValue(COURSE.name); getCertifications.mockImplementation(async () => page([CREATED])); await userEvent.clear(screen.getByLabelText("Certification *")); await userEvent.type( screen.getByLabelText("Certification *"), "Advanced Nitrox", ); await userEvent.click( screen.getByRole("button", { name: /Create certification/ }), ); await waitFor(() => expect(createCertification).toHaveBeenCalled()); expect(createCertification.mock.calls[0][0]).toMatchObject({ name: "Advanced Nitrox", course_uuid: COURSE.uuid, contact_uuid: BLUE_OCEAN.uuid, instructor_uuid: ALEX.uuid, agency: "tdi", }); // In the card's own list, with no browser reload. The card photos were part // of that same form, so there is no second dialog to close first. expect( await screen.findByRole("button", { name: /Advanced Nitrox/ }), ).toBeInTheDocument(); expect(getCertifications).toHaveBeenCalledTimes(2); }); });