Ctrl K
ComunidadGuías…

src/components/contacts/contact-dialog.render.test.tsx

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

Archivo original ↗Preguntar a KiBot
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 { ContactDialog } from "./contact-dialog";
import { ContactCombobox } from "./contact-combobox";
import type { Contact } from "@/lib/api/contacts";

vi.mock("@/lib/api/contacts", async (importOriginal) => ({
  // The role vocabulary and its labels are real: the boxes come from them.
  ...(await importOriginal<typeof import("@/lib/api/contacts")>()),
  contactsAPI: {
    createContact: vi.fn(),
    updateContact: vi.fn(),
    lookupContacts: vi.fn(),
    getContact: vi.fn(),
  },
}));

// The map is WebGL and `map-picker.browser.test.tsx`'s; here it is a stand-in
// that says it was drawn, with the subject it was given, and can hand a pick
// over the way the real one does - as the form's strings.
const mapField = vi.fn();
vi.mock("@/components/map/map-field", () => ({
  MapField: (props: {
    subject: string;
    onPick: (position: { latitude: string; longitude: string }) => void;
  }) => {
    mapField(props);
    return (
      <button
        type="button"
        onClick={() =>
          props.onPick({ latitude: "28.5717", longitude: "34.5372" })
        }
      >
        place {props.subject}
      </button>
    );
  },
}));

const { contactsAPI } = await import("@/lib/api/contacts");
const createContact = vi.mocked(contactsAPI.createContact);
const updateContact = vi.mocked(contactsAPI.updateContact);
const lookupContacts = vi.mocked(contactsAPI.lookupContacts);
const getContact = vi.mocked(contactsAPI.getContact);

const EXISTING: Contact = {
  uuid: "contact-1",
  name: "Blue Ocean",
  roles: ["dive_center", "accommodation"],
  phone: "+20 69 364 0000",
  email: "info@blueocean.example",
  website: "https://blueocean.example",
  address: {
    street: null,
    city: "Dahab",
    postcode: null,
    region: "South Sinai",
    country: "Egypt",
  },
  notes: "Ask for Ahmed.",
  user_uuid: "user-1",
  created_at: "2026-03-01T09:00:00Z",
};

beforeEach(() => {
  vi.clearAllMocks();
  createContact.mockImplementation(async (body) => ({
    ...EXISTING,
    ...body,
    uuid: "contact-new",
    roles: body.roles ?? [],
    notes: body.notes ?? "",
  }));
  updateContact.mockResolvedValue({ message: "Contact updated" });
  lookupContacts.mockResolvedValue({
    data: [EXISTING],
    total_count: 1,
    has_more: false,
    page: 1,
    items_per_page: 25,
  });
  getContact.mockResolvedValue(EXISTING);
});

function open(props: Partial<Parameters<typeof ContactDialog>[0]> = {}) {
  const onSaved = vi.fn();
  render(
    <ContactDialog open onOpenChange={vi.fn()} onSaved={onSaved} {...props} />,
  );
  return onSaved;
}

const ticked = () =>
  screen
    .getAllByRole("checkbox")
    .filter((box) => (box as HTMLInputElement).checked)
    .map((box) => box.closest("label")?.textContent);

const create = () =>
  userEvent.click(screen.getByRole("button", { name: "Create contact" }));

describe("ContactDialog", () => {
  it("starts a new contact on the roles its host hands it, and no others", () => {
    open({ initialRoles: ["school"] });

    expect(ticked()).toEqual(["School"]);
  });

  it("starts a contact made from the contacts page on no role at all", () => {
    open();

    expect(ticked()).toEqual([]);
  });

  it("sends a bare host with its scheme, and every empty field as null", async () => {
    const onSaved = open({ initialRoles: ["dive_center"] });

    await userEvent.type(screen.getByLabelText("Name *"), "Sea Dragon");
    await userEvent.type(screen.getByLabelText("Website"), "seadragon.example");
    await create();

    await waitFor(() => expect(createContact).toHaveBeenCalled());
    expect(createContact.mock.calls[0][0]).toEqual({
      name: "Sea Dragon",
      roles: ["dive_center"],
      phone: null,
      email: null,
      website: "https://seadragon.example",
      address: null,
      latitude: null,
      longitude: null,
      notes: "",
    });
    expect(onSaved).toHaveBeenCalledWith(
      expect.objectContaining({ uuid: "contact-new" }),
    );
  });

  it("refuses a name of spaces alone, and sends a name without its padding", async () => {
    open();

    await userEvent.type(screen.getByLabelText("Name *"), "   ");
    await create();

    expect(await screen.findByText("Name is required")).toBeVisible();
    expect(createContact).not.toHaveBeenCalled();

    await userEvent.type(screen.getByLabelText("Name *"), "Sea Dragon  ");
    await create();

    await waitFor(() => expect(createContact).toHaveBeenCalled());
    expect(createContact.mock.calls[0][0].name).toBe("Sea Dragon");
  });

  it("refuses an address without its country, and focuses the box", async () => {
    open();

    await userEvent.type(screen.getByLabelText("Name *"), "Grandma's house");
    await userEvent.type(screen.getByLabelText("Address"), "12 Harbour Road");
    await create();

    expect(
      await screen.findByText("An address needs its country"),
    ).toBeVisible();
    await waitFor(() =>
      expect(screen.getByLabelText("Country *")).toHaveFocus(),
    );
    expect(createContact).not.toHaveBeenCalled();
  });

  it("opens an existing contact with its address showing, and saves every field", async () => {
    const onSaved = open({ contact: EXISTING });

    await waitFor(() =>
      expect(screen.getByLabelText("Name *")).toHaveValue("Blue Ocean"),
    );
    expect(screen.getByLabelText("City")).toBeVisible();
    expect(ticked()).toEqual(["Dive center", "Accommodation"]);

    // Clearing a field is sending null for it: the update leaves an absent key
    // alone, so an omitted phone would survive a save that reported success.
    await userEvent.clear(screen.getByLabelText("Phone"));
    await userEvent.click(screen.getByLabelText("Shop"));
    await userEvent.click(screen.getByRole("button", { name: "Save changes" }));

    await waitFor(() => expect(updateContact).toHaveBeenCalled());
    expect(updateContact.mock.calls[0]).toEqual([
      "contact-1",
      {
        name: "Blue Ocean",
        roles: ["dive_center", "accommodation", "shop"],
        phone: null,
        email: "info@blueocean.example",
        website: "https://blueocean.example",
        address: {
          street: null,
          city: "Dahab",
          postcode: null,
          region: "South Sinai",
          country: "Egypt",
        },
        latitude: null,
        longitude: null,
        notes: "Ask for Ahmed.",
      },
    ]);
    // Handed back in the vocabulary's order, which is how the API stores them.
    expect(onSaved.mock.calls[0][0].roles).toEqual([
      "dive_center",
      "shop",
      "accommodation",
    ]);
  });

  it("clears a stored address with an explicit null", async () => {
    open({ contact: EXISTING });
    await waitFor(() =>
      expect(screen.getByLabelText("City")).toHaveValue("Dahab"),
    );

    await userEvent.clear(screen.getByLabelText("City"));
    await userEvent.clear(screen.getByLabelText("Region"));
    await userEvent.clear(screen.getByLabelText(/^Country/));
    await userEvent.click(screen.getByRole("button", { name: "Save changes" }));

    await waitFor(() => expect(updateContact).toHaveBeenCalled());
    expect(updateContact.mock.calls[0][1]).toMatchObject({ address: null });
  });
});

describe("ContactDialog's position", () => {
  const POSITIONED: Contact = {
    ...EXISTING,
    latitude: 28.4939,
    longitude: 34.5131,
  };
  const latitude = () => screen.getByLabelText("Latitude");
  const longitude = () => screen.getByLabelText("Longitude");
  const showMap = () =>
    userEvent.click(screen.getByRole("button", { name: "Show map" }));

  // The map's tiles come from the basemap host, and this dialog opens from the
  // dive form and every other picker of a contact: drawing it on open would
  // contact that host for a diver who never asked for a map.
  it("opens a positioned contact with its pair, and no map until asked", async () => {
    open({ contact: POSITIONED });

    await waitFor(() => expect(latitude()).toHaveValue("28.4939"));
    expect(longitude()).toHaveValue("34.5131");
    expect(mapField).not.toHaveBeenCalled();

    await showMap();

    expect(mapField).toHaveBeenCalled();
    expect(
      screen.getByRole("button", { name: "place the contact" }),
    ).toBeVisible();
    expect(
      screen.queryByRole("button", { name: "Show map" }),
    ).not.toBeInTheDocument();
  });

  it("keeps the pair through a save that never opened the map", async () => {
    open({ contact: POSITIONED });
    await waitFor(() => expect(latitude()).toHaveValue("28.4939"));

    await userEvent.click(screen.getByRole("button", { name: "Save changes" }));

    await waitFor(() => expect(updateContact).toHaveBeenCalled());
    expect(updateContact.mock.calls[0][1]).toMatchObject({
      latitude: 28.4939,
      longitude: 34.5131,
    });
    expect(mapField).not.toHaveBeenCalled();
  });

  it("fills both inputs from a pin placed on the map, and sends the pair", async () => {
    open();
    await userEvent.type(screen.getByLabelText("Name *"), "Sea Dragon");

    await showMap();
    await userEvent.click(
      screen.getByRole("button", { name: "place the contact" }),
    );

    expect(latitude()).toHaveValue("28.5717");
    expect(longitude()).toHaveValue("34.5372");
    await create();
    await waitFor(() => expect(createContact).toHaveBeenCalled());
    expect(createContact.mock.calls[0][0]).toMatchObject({
      latitude: 28.5717,
      longitude: 34.5372,
    });
  });

  it("fills both inputs from a pair pasted into either", async () => {
    open();

    await userEvent.click(longitude());
    await userEvent.paste("27.8506, 34.3136");

    expect(latitude()).toHaveValue("27.8506");
    expect(longitude()).toHaveValue("34.3136");
    expect(mapField).not.toHaveBeenCalled();
  });

  it.each([
    ["Latitude", "27.7", "Longitude is required when latitude is given"],
    ["Longitude", "34.3", "Latitude is required when longitude is given"],
  ])(
    "refuses half a pair typed into %s, naming the missing half",
    async (label, value, message) => {
      open();
      await userEvent.type(screen.getByLabelText("Name *"), "Sea Dragon");

      await userEvent.type(screen.getByLabelText(label), value);
      await create();

      expect(await screen.findByText(message)).toBeVisible();
      expect(createContact).not.toHaveBeenCalled();
    },
  );

  it("clears a stored pair with an explicit null for each half", async () => {
    open({ contact: POSITIONED });
    await waitFor(() => expect(latitude()).toHaveValue("28.4939"));

    await userEvent.clear(latitude());
    await userEvent.clear(longitude());
    await userEvent.click(screen.getByRole("button", { name: "Save changes" }));

    await waitFor(() => expect(updateContact).toHaveBeenCalled());
    expect(updateContact.mock.calls[0][1]).toMatchObject({
      latitude: null,
      longitude: null,
    });
  });
});

describe("ContactCombobox", () => {
  it("hands what was typed to the dialog as the new contact's name", async () => {
    render(
      <ContactCombobox
        aria-label="Dive center"
        value={null}
        onChange={vi.fn()}
        addNewLabel="Add dive center..."
      />,
    );

    await userEvent.click(screen.getByRole("combobox"));
    await userEvent.type(screen.getByRole("combobox"), "Sea Dragon");
    await userEvent.click(
      await screen.findByRole("option", { name: "Add dive center..." }),
    );

    const dialog = await screen.findByRole("dialog", { name: "New Contact" });
    expect(within(dialog).getByLabelText("Name *")).toHaveValue("Sea Dragon");
  });

  it("does not file the contact already picked as a new one's name", async () => {
    render(
      <ContactCombobox
        aria-label="Dive center"
        value={EXISTING.uuid}
        onChange={vi.fn()}
        addNewLabel="Add dive center..."
      />,
    );
    await waitFor(() =>
      expect(screen.getByRole("combobox")).toHaveValue("Blue Ocean"),
    );

    await userEvent.click(screen.getByRole("combobox"));
    await userEvent.click(
      await screen.findByRole("option", { name: "Add dive center..." }),
    );

    const dialog = await screen.findByRole("dialog", { name: "New Contact" });
    expect(within(dialog).getByLabelText("Name *")).toHaveValue("");
  });

  it("selects the contact the dialog made", async () => {
    const onChange = vi.fn();
    render(
      <ContactCombobox
        aria-label="Dive center"
        value={null}
        onChange={onChange}
        initialRoles={["dive_center"]}
        addNewLabel="Add dive center..."
      />,
    );

    await userEvent.click(screen.getByRole("combobox"));
    await userEvent.click(
      await screen.findByRole("option", { name: "Add dive center..." }),
    );
    const dialog = await screen.findByRole("dialog", { name: "New Contact" });
    await userEvent.type(within(dialog).getByLabelText("Name *"), "Sea Dragon");
    await userEvent.click(
      within(dialog).getByRole("button", { name: "Create contact" }),
    );

    await waitFor(() => expect(onChange).toHaveBeenCalledWith("contact-new"));
  });

  it("asks the lookup at the record's date, and lists a pin first", async () => {
    const pinned: Contact = {
      ...EXISTING,
      uuid: "contact-pin",
      name: "Sea Dragon",
    };
    getContact.mockResolvedValue(pinned);
    lookupContacts.mockResolvedValue({
      data: [EXISTING, pinned],
      total_count: 2,
      has_more: false,
      page: 1,
      items_per_page: 25,
    });
    render(
      <ContactCombobox
        aria-label="Dive center"
        value={null}
        onChange={vi.fn()}
        until="2024-05-01"
        pinnedUuids={[pinned.uuid]}
      />,
    );
    await waitFor(() => expect(getContact).toHaveBeenCalledWith(pinned.uuid));

    await userEvent.click(screen.getByRole("combobox"));

    await screen.findByRole("option", { name: /Blue Ocean/ });
    expect(lookupContacts).toHaveBeenCalledWith(1, 25, {
      search: "",
      until: "2024-05-01",
    });
    expect(
      within(screen.getByRole("listbox"))
        .getAllByRole("option")
        .map((option) => option.textContent)
        .filter((text) => !text?.startsWith("Add ")),
    ).toEqual(["Sea Dragon, Dahab, Egypt", "Blue Ocean, Dahab, Egypt"]);
  });
});