src/components/courses/course-dialog.render.test.tsx
Rama main · réplica de lectura del repositorio original.
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, waitFor, within } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { CourseDialog } from "./course-dialog";
import type { Course } from "@/lib/api/courses";
import type { Contact } from "@/lib/api/contacts";
import type { Person } from "@/lib/api/people";
vi.mock("@/lib/api/courses", async (importOriginal) => ({
// The status vocabulary and its 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/courses")>()),
coursesAPI: { createCourse: vi.fn(), updateCourse: 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(),
createPerson: vi.fn(),
},
}));
const { coursesAPI } = await import("@/lib/api/courses");
const { contactsAPI } = await import("@/lib/api/contacts");
const { peopleAPI } = await import("@/lib/api/people");
const createCourse = vi.mocked(coursesAPI.createCourse);
const updateCourse = vi.mocked(coursesAPI.updateCourse);
const getContact = vi.mocked(contactsAPI.getContact);
const lookupPeople = vi.mocked(peopleAPI.lookupPeople);
const getPerson = vi.mocked(peopleAPI.getPerson);
const createPerson = vi.mocked(peopleAPI.createPerson);
const person = (uuid: string, name: string): Person => ({
uuid,
name,
notes: "",
username: null,
dive_count: 0,
created_at: "2026-03-01T09:00:00Z",
});
const ANA = person("person-ana", "Ana Ruiz");
const BEN = person("person-ben", "Ben Student");
const PEOPLE = [ANA, BEN];
const page = <T,>(items: T[]) => ({
data: items,
total_count: items.length,
has_more: false,
page: 1,
items_per_page: 25,
});
const BLUE_OCEAN: Contact = {
uuid: "contact-blue",
name: "Blue Ocean, Koh Tao",
roles: ["dive_center", "school"],
notes: "",
user_uuid: "user-1",
created_at: "2026-03-01T09:00:00Z",
};
const EXISTING: Course = {
uuid: "course-1",
name: "Advanced Nitrox + Decompression Procedures",
agency: "tdi",
agency_other: null,
status: "completed",
start_date: "2026-03-02",
end_date: "2026-03-06",
instructor_number: "TDI-99871",
contact_uuid: BLUE_OCEAN.uuid,
// The instructor second, as an import may have them: the dialog finds them
// by role, not by place.
people: [
{ person_uuid: BEN.uuid, role: "student" },
{ person_uuid: ANA.uuid, role: "instructor" },
],
notes: "Ran the 21m and 30m dives on back gas.",
user_uuid: "user-1",
created_at: "2026-03-08T09:00:00Z",
};
beforeEach(() => {
createCourse.mockReset();
updateCourse.mockReset();
createCourse.mockResolvedValue(EXISTING);
updateCourse.mockResolvedValue({ message: "Course updated" });
getContact.mockImplementation(async () => BLUE_OCEAN);
lookupPeople.mockReset().mockImplementation(async () => page(PEOPLE));
getPerson.mockReset().mockImplementation(async (uuid: string) => {
const found = PEOPLE.find((one) => one.uuid === uuid);
if (!found) throw new Error("not found");
return found;
});
createPerson.mockReset();
});
const instructor = () => screen.getByLabelText("Instructor");
const peopleField = () => screen.getByLabelText("People");
function open(course?: Course, onSaved = vi.fn()) {
render(
<CourseDialog
open
onOpenChange={vi.fn()}
course={course}
onSaved={onSaved}
/>,
);
return onSaved;
}
const save = () =>
userEvent.click(screen.getByRole("button", { name: /Create course|Save/ }));
describe("CourseDialog", () => {
it("seeds every field from the course being edited", async () => {
open(EXISTING);
await waitFor(() =>
expect(screen.getByLabelText("Course *")).toHaveValue(EXISTING.name),
);
await waitFor(() =>
expect(screen.getByLabelText("Dive center")).toHaveValue(BLUE_OCEAN.name),
);
expect(screen.getByLabelText("Instructor number")).toHaveValue("TDI-99871");
await waitFor(() => expect(instructor()).toHaveValue("Ana Ruiz"));
// Everyone else on the list below, the instructor not twice.
expect(
await screen.findByRole("combobox", { name: "Role of Ben Student" }),
).toHaveValue("student");
expect(
screen.queryByRole("combobox", { name: "Role of Ana Ruiz" }),
).toBeNull();
});
it("writes the instructor back first, as the instructor, with the rest behind", async () => {
open(EXISTING);
await waitFor(() => expect(instructor()).toHaveValue("Ana Ruiz"));
await save();
await waitFor(() => expect(updateCourse).toHaveBeenCalled());
expect(updateCourse.mock.calls[0][1].people).toEqual([
{ person_uuid: ANA.uuid, role: "instructor" },
{ person_uuid: BEN.uuid, role: "student" },
]);
expect(updateCourse.mock.calls[0][1]).not.toHaveProperty("instructor_name");
});
it("names a new instructor in one step: the name, then Enter", async () => {
// What typing a name did while the field was text, and no more.
const created = person("person-new", "Robin Reef");
lookupPeople.mockImplementation(async () => page([]));
createPerson.mockResolvedValue(created);
open();
await userEvent.type(screen.getByLabelText("Course *"), "Deep Specialty");
await userEvent.click(instructor());
await screen.findByText(/No people yet/);
await userEvent.type(instructor(), "Robin Reef{Enter}");
await waitFor(() => expect(instructor()).toHaveValue("Robin Reef"));
await save();
await waitFor(() => expect(createCourse).toHaveBeenCalled());
expect(createCourse.mock.calls[0][0].people).toEqual([
{ person_uuid: created.uuid, role: "instructor" },
]);
});
it("adds the rest of the class as students", async () => {
open();
await userEvent.type(screen.getByLabelText("Course *"), "Open Water");
await userEvent.click(peopleField());
await userEvent.click(
await screen.findByRole("option", { name: "Ben Student" }),
);
expect(
await screen.findByRole("combobox", { name: "Role of Ben Student" }),
).toHaveValue("student");
await save();
await waitFor(() => expect(createCourse).toHaveBeenCalled());
expect(createCourse.mock.calls[0][0].people).toEqual([
{ person_uuid: BEN.uuid, role: "student" },
]);
});
it("moves a person made the instructor off the list below", async () => {
open(EXISTING);
await screen.findByRole("combobox", { name: "Role of Ben Student" });
await userEvent.click(instructor());
await userEvent.click(
await screen.findByRole("option", { name: "Ben Student" }),
);
await waitFor(() => expect(instructor()).toHaveValue("Ben Student"));
expect(
screen.queryByRole("combobox", { name: "Role of Ben Student" }),
).toBeNull();
await save();
await waitFor(() => expect(updateCourse).toHaveBeenCalled());
expect(updateCourse.mock.calls[0][1].people).toEqual([
{ person_uuid: BEN.uuid, role: "instructor" },
]);
});
it("sends an explicit null for every field the diver cleared", async () => {
// The whole reason the submit maps `""` back to `null`: an omitted key
// leaves the stored value alone, so clearing the instructor number of a
// course would report success and change nothing. A cleared instructor is
// simply one no longer on the people it sends.
open(EXISTING);
await waitFor(() =>
expect(screen.getByLabelText("Instructor number")).toHaveValue(
"TDI-99871",
),
);
await waitFor(() => expect(instructor()).toHaveValue("Ana Ruiz"));
await userEvent.clear(screen.getByLabelText("Instructor number"));
await userEvent.click(
within(instructor().parentElement!).getByRole("button", {
name: "Clear",
}),
);
await save();
await waitFor(() => expect(updateCourse).toHaveBeenCalled());
expect(updateCourse.mock.calls[0][1]).toMatchObject({
instructor_number: null,
contact_uuid: BLUE_OCEAN.uuid,
people: [{ person_uuid: BEN.uuid, role: "student" }],
});
expect(updateCourse.mock.calls[0][1]).not.toHaveProperty("training_center");
});
it("unlinks a cleared dive center with an explicit null", async () => {
open(EXISTING);
const diveCenter = screen.getByLabelText("Dive center");
await waitFor(() => expect(diveCenter).toHaveValue(BLUE_OCEAN.name));
await userEvent.click(
within(diveCenter.parentElement!).getByRole("button", { name: "Clear" }),
);
await save();
await waitFor(() => expect(updateCourse).toHaveBeenCalled());
expect(updateCourse.mock.calls[0][1]).toMatchObject({ contact_uuid: null });
});
it("creates a course with the API's own default status", async () => {
const onSaved = open();
await userEvent.type(screen.getByLabelText("Course *"), "PADI Open Water");
await save();
await waitFor(() => expect(createCourse).toHaveBeenCalled());
expect(createCourse.mock.calls[0][0]).toMatchObject({
name: "PADI Open Water",
status: "completed",
// Both dates absent rather than empty strings - a course can legitimately
// have neither, and the API's date columns are nullable for exactly that.
start_date: null,
end_date: null,
});
expect(onSaved).toHaveBeenCalledWith(EXISTING);
});
it("asks which agency ran the course only when the agency is 'other'", async () => {
open(EXISTING);
expect(screen.queryByLabelText("Agency name *")).not.toBeInTheDocument();
await userEvent.selectOptions(screen.getByLabelText("Agency"), "Other");
expect(await screen.findByLabelText("Agency name *")).toBeInTheDocument();
});
it("creates a course with no agency when the diver never opens the picker", async () => {
// The create form opens on no agency rather than PADI, so a diver who
// never looks at the field stores nothing instead of a fabricated one.
open();
expect(screen.getByLabelText("Agency")).toHaveDisplayValue("No agency");
await userEvent.type(screen.getByLabelText("Course *"), "Nitrox, at home");
await save();
await waitFor(() => expect(createCourse).toHaveBeenCalled());
expect(createCourse.mock.calls[0][0]).toMatchObject({
name: "Nitrox, at home",
agency: null,
agency_other: null,
});
});
it("clears a stored agency with an explicit null", async () => {
// An omitted key would leave `tdi` in place and report success, the same
// trap the text fields' `""` -> `null` mapping exists for.
open(EXISTING);
await waitFor(() =>
expect(screen.getByLabelText("Agency")).toHaveDisplayValue("TDI"),
);
await userEvent.selectOptions(screen.getByLabelText("Agency"), "No agency");
await save();
await waitFor(() => expect(updateCourse).toHaveBeenCalled());
expect(updateCourse.mock.calls[0][1]).toMatchObject({
agency: null,
agency_other: null,
});
});
it("reopens a course with no agency on the no-agency state, and leaves it there", async () => {
open({ ...EXISTING, agency: null });
await waitFor(() =>
expect(screen.getByLabelText("Agency")).toHaveTextContent("No agency"),
);
await save();
await waitFor(() => expect(updateCourse).toHaveBeenCalled());
expect(updateCourse.mock.calls[0][1]).toMatchObject({ agency: null });
});
it("refuses an end date before the start date, beside the end date", async () => {
// The API refuses this outright behind a 422; catching it here is what puts
// the complaint next to the field the diver would fix.
open({ ...EXISTING, start_date: "2026-03-06", end_date: "2026-03-02" });
await waitFor(() =>
expect(screen.getByLabelText("Course *")).toHaveValue(EXISTING.name),
);
await save();
expect(
await screen.findByText("End date must be on or after start date"),
).toBeInTheDocument();
expect(updateCourse).not.toHaveBeenCalled();
});
it("empties the end date when the start moves past it, and saves it cleared", async () => {
open(EXISTING);
const start = screen.getByLabelText("Start date");
await waitFor(() => expect(start).toHaveValue("2026-03-02"));
await userEvent.clear(start);
await userEvent.type(start, "2026-03-10");
await userEvent.tab();
expect(screen.getByLabelText("End date")).toHaveValue("");
await save();
await waitFor(() => expect(updateCourse).toHaveBeenCalled());
expect(updateCourse.mock.calls[0][1]).toMatchObject({
start_date: "2026-03-10",
end_date: null,
});
});
it("ranks its people pickers at the course's start date", async () => {
open(EXISTING);
await userEvent.click(instructor());
await waitFor(() =>
expect(lookupPeople).toHaveBeenCalledWith(1, 25, {
search: "",
until: EXISTING.start_date,
}),
);
});
});