Ctrl K
ComunidadGuías…

src/components/courses/course-certifications-card.render.test.tsx

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<typeof import("@/lib/api/certifications")>()),
  certificationsAPI: {
    getCertifications: vi.fn(),
    getCertification: vi.fn(),
    createCertification: vi.fn(),
    updateCertification: vi.fn(),
  },
}));

vi.mock("@/lib/api/courses", async (importOriginal) => ({
  ...(await importOriginal<typeof import("@/lib/api/courses")>()),
  coursesAPI: {
    lookupCourses: vi.fn(),
    lookupCoursesByUuid: vi.fn(),
    getCourse: vi.fn(),
  },
}));

vi.mock("@/lib/api/contacts", async (importOriginal) => ({
  ...(await importOriginal<typeof import("@/lib/api/contacts")>()),
  contactsAPI: { lookupContacts: vi.fn(), getContact: vi.fn() },
}));

vi.mock("@/lib/api/people", async (importOriginal) => ({
  ...(await importOriginal<typeof import("@/lib/api/people")>()),
  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 = <T,>(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<Certification>([]));
  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 (
    <>
      <button type="button" onClick={() => setIsAdding(true)}>
        Sidebar add
      </button>
      <CourseCertificationsCard
        course={course}
        isAdding={isAdding}
        onAddingChange={setIsAdding}
      />
    </>
  );
}

const render_ = () => render(<Harness />);

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(<Harness course={NO_AGENCY} />);
    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(<Harness course={NO_AGENCY} />);

    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);
  });
});