Ctrl K
ComunidadGuías…

src/components/dives/delete-with-reassign-dialog.render.test.tsx

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

Archivo original ↗Preguntar a KiBot
import { describe, expect, it, vi, beforeEach } from "vitest";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { DeleteWithReassignDialog } from "./delete-with-reassign-dialog";

vi.mock("@/lib/api/trips", () => ({
  tripsAPI: { lookupTrips: vi.fn() },
}));

vi.mock("@/lib/api/dive-sites", async (importOriginal) => ({
  ...(await importOriginal<typeof import("@/lib/api/dive-sites")>()),
  diveSitesAPI: { lookupDiveSites: vi.fn() },
}));

const { tripsAPI } = await import("@/lib/api/trips");
const { diveSitesAPI } = await import("@/lib/api/dive-sites");
const lookupTrips = vi.mocked(tripsAPI.lookupTrips);
const lookupDiveSites = vi.mocked(diveSitesAPI.lookupDiveSites);

const OTHER_TRIP = { uuid: "trip-2", name: "Cebu 2026" };
const OTHER_SITE = {
  uuid: "site-2",
  name: "Blue Hole",
  location: { name: "Dahab, Egypt" },
  owner: "account",
  saved: true,
  attribution: null,
};
const COMMONS_SITE = {
  uuid: "commons-1",
  name: "Canyon",
  location: { name: "Dahab, Egypt" },
  owner: "opendivingcommons",
  saved: false,
  attribution:
    "[Data © OpenStreetMap contributors, ODbL 1.0.](https://osm.org/copyright)",
};

const onePageOf = (data: unknown[]) =>
  ({
    data,
    total_count: data.length,
    has_more: false,
    page: 1,
    items_per_page: 25,
  }) as never;

beforeEach(() => {
  lookupTrips.mockReset();
  lookupDiveSites.mockReset();
  lookupTrips.mockResolvedValue(onePageOf([OTHER_TRIP]));
  lookupDiveSites.mockResolvedValue(onePageOf([OTHER_SITE]));
});

function Dialog({
  onConfirm = vi.fn(),
  targetId = "trip-1",
}: {
  onConfirm?: (moveDivesTo?: string, name?: string) => void | Promise<void>;
  targetId?: string | null;
}) {
  return (
    <DeleteWithReassignDialog
      kind="trip"
      targetId={targetId}
      isDeleting={false}
      onCancel={vi.fn()}
      onConfirm={onConfirm}
    />
  );
}

const deleteButton = () => screen.getByRole("button", { name: "Delete" });

// Picks "Cebu 2026" out of the replacement picker.
const pickReplacement = async () => {
  await userEvent.click(screen.getByRole("combobox"));
  await userEvent.click(await screen.findByRole("option", { name: /Cebu/ }));
};

describe("DeleteWithReassignDialog", () => {
  it("says what deleting does and offers a destination straight away", async () => {
    // Both without asking the API anything first: the sentence is true at any
    // number of dives, so there is nothing to wait for before showing either.
    render(<Dialog />);

    expect(
      screen.getByText(/removes this trip from every dive logged on it/i),
    ).toBeVisible();
    expect(screen.getByRole("combobox")).toBeVisible();
    expect(deleteButton()).toBeEnabled();
  });

  it("deletes without moving anything while the picker is empty", async () => {
    // The picker's empty state *is* the "just delete it" option, so this has to
    // send no `move_dives_to` argument at all rather than an empty one.
    const onConfirm = vi.fn();
    render(<Dialog onConfirm={onConfirm} />);

    await userEvent.click(deleteButton());

    await waitFor(() => expect(onConfirm).toHaveBeenCalledWith());
  });

  it("opens without the picker grabbing focus", async () => {
    // Base UI focuses the first tabbable descendant on open, and this dialog's
    // only child is a combobox that opens its menu on focus. That menu is
    // absolutely positioned over the footer, so a click aimed at Delete would
    // land on a trip instead, quietly filling in a destination nobody chose -
    // and every confirmation would search, including plain deletes.
    render(<Dialog />);

    await waitFor(() =>
      expect(screen.getByRole("button", { name: "Cancel" })).toHaveFocus(),
    );
    expect(screen.queryByRole("listbox")).not.toBeInTheDocument();
    expect(lookupTrips).not.toHaveBeenCalled();
  });

  it("refuses to delete while a destination is half-typed", async () => {
    // "Ceb" with "Cebu 2026" in the menu is not a choice: the combobox clears
    // its selection on every keystroke and only an exact match re-fills it, and
    // the click on Delete blurs the field without committing a prefix. Left
    // clickable, this would delete the trip and drop the move the diver was in
    // the middle of asking for - neither of which can be undone from the UI.
    const onConfirm = vi.fn();
    render(<Dialog onConfirm={onConfirm} />);

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

    await waitFor(() => expect(deleteButton()).toBeDisabled());
    expect(screen.getByText(/Pick a trip from the list/)).toBeVisible();

    // And clearing the field is the way back out to a plain delete.
    await userEvent.clear(screen.getByRole("combobox"));

    await waitFor(() => expect(deleteButton()).toBeEnabled());
    await userEvent.click(deleteButton());
    await waitFor(() => expect(onConfirm).toHaveBeenCalledWith());
  });

  it("hands back the chosen replacement, and its name for the toast", async () => {
    // The uuid becomes `move_dives_to` on the delete; the name is only carried so
    // the toast afterwards can say where the dives went, since the API has no
    // reason to know what the destination is called.
    const onConfirm = vi.fn();
    render(<Dialog onConfirm={onConfirm} />);

    await pickReplacement();
    await userEvent.click(deleteButton());

    await waitFor(() =>
      expect(onConfirm).toHaveBeenCalledWith("trip-2", "Cebu 2026"),
    );
  });

  it("keeps the target out of its own replacement list", async () => {
    // Moving a trip's dives onto the trip being deleted is the one choice that
    // cannot work - and the API answers it with a 422 rather than a silent no-op,
    // so this is the difference between an impossible option and an error.
    render(<Dialog />);
    await userEvent.click(screen.getByRole("combobox"));

    await screen.findByRole("option", { name: /Cebu/ });
    expect(
      screen.queryByRole("option", { name: /trip-1/ }),
    ).not.toBeInTheDocument();
  });

  it("will not resolve a typed name to the target itself", async () => {
    // Two trips of the same name is the merge case this feature is for, and the
    // combobox resolves typed text against its *result list*, not against the
    // menu. Before the target was filtered out of the results, typing the shared
    // name here picked the trip being deleted: the request went out with
    // `move_dives_to` equal to the uuid being deleted - a 422.
    lookupTrips.mockResolvedValue(
      onePageOf([
        { uuid: "trip-1", name: "Cebu 2026" },
        { uuid: "trip-2", name: "Cebu 2026" },
      ]),
    );
    const onConfirm = vi.fn();
    render(<Dialog onConfirm={onConfirm} />);

    await userEvent.click(screen.getByRole("combobox"));
    await userEvent.paste("Cebu 2026");
    await userEvent.tab();

    // Whatever it resolved to, it cannot be the trip being deleted.
    await userEvent.click(deleteButton());
    await waitFor(() => expect(onConfirm).toHaveBeenCalled());
    expect(onConfirm).not.toHaveBeenCalledWith("trip-1", expect.anything());
  });

  it("offers only one option when a duplicate name is the target's own", async () => {
    lookupTrips.mockResolvedValue(
      onePageOf([
        { uuid: "trip-1", name: "Cebu 2026" },
        { uuid: "trip-2", name: "Cebu 2026" },
      ]),
    );
    render(<Dialog />);

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

    await waitFor(() =>
      expect(screen.getAllByRole("option", { name: /Cebu/ })).toHaveLength(1),
    );
  });

  it("starts clean when reopened on a different trip", async () => {
    // The picked replacement belongs to the trip it was picked for. Carried over,
    // the dialog would offer to move the new trip's dives onto a destination the
    // diver chose for the old one - and confirming would send it.
    const onConfirm = vi.fn();
    const { rerender } = render(<Dialog onConfirm={onConfirm} />);
    await pickReplacement();

    rerender(<Dialog onConfirm={onConfirm} targetId="trip-9" />);
    await userEvent.click(deleteButton());

    await waitFor(() => expect(onConfirm).toHaveBeenCalledWith());
  });
});

// The site half of the component is the same flow through a different half of
// `COPY` - its own endpoint, its own wording - and none of that is exercised by
// the trip tests above.
describe("DeleteWithReassignDialog, deleting a dive site", () => {
  const SiteDialog = ({ onConfirm = vi.fn() }) => (
    <DeleteWithReassignDialog
      kind="dive-site"
      targetId="site-1"
      isDeleting={false}
      onCancel={vi.fn()}
      onConfirm={onConfirm}
    />
  );

  it("searches dive sites, not trips", async () => {
    const onConfirm = vi.fn();
    render(<SiteDialog onConfirm={onConfirm} />);

    expect(
      screen.getByText(/removes this site from every dive logged here/i),
    ).toBeVisible();

    await userEvent.click(screen.getByRole("combobox"));
    // The location rides along as the option's hint, which is what tells two
    // sites of the same name apart in the menu.
    await userEvent.click(
      await screen.findByRole("option", { name: /Blue Hole.*Dahab/ }),
    );
    await userEvent.click(deleteButton());

    await waitFor(() =>
      expect(onConfirm).toHaveBeenCalledWith("site-2", "Blue Hole"),
    );
    expect(lookupDiveSites).toHaveBeenCalledWith(1, 25, { search: "" });
    expect(lookupTrips).not.toHaveBeenCalled();
  });
});

describe("DeleteWithReassignDialog for a dive site", () => {
  const openPicker = async () => {
    await userEvent.click(screen.getByRole("combobox"));
    await screen.findByRole("option", { name: /Blue Hole/ });
  };

  it("marks and credits a Commons record among the destinations", async () => {
    lookupDiveSites.mockResolvedValue(onePageOf([OTHER_SITE, COMMONS_SITE]));
    render(
      <DeleteWithReassignDialog
        kind="dive-site"
        targetId="site-1"
        isDeleting={false}
        onCancel={vi.fn()}
        onConfirm={vi.fn()}
      />,
    );

    await openPicker();

    expect(screen.getByRole("option", { name: /Canyon/ })).toHaveTextContent(
      "Commons",
    );
    expect(
      screen.getByRole("option", { name: /Blue Hole/ }),
    ).not.toHaveTextContent("Commons");
    expect(
      screen.getByRole("link", {
        name: "Data © OpenStreetMap contributors, ODbL 1.0.",
      }),
    ).toBeInTheDocument();
  });

  it("says a saved Commons record stays when it leaves the list", async () => {
    const onConfirm = vi.fn();
    render(
      <DeleteWithReassignDialog
        kind="saved-commons-site"
        targetId="commons-1"
        isDeleting={false}
        onCancel={vi.fn()}
        onConfirm={onConfirm}
      />,
    );

    expect(
      screen.getByRole("heading", { name: "Remove from your sites" }),
    ).toBeInTheDocument();
    expect(screen.getByRole("dialog")).toHaveTextContent(
      "the site stays in OpenDiving Commons",
    );

    await userEvent.click(screen.getByRole("button", { name: "Remove" }));
    expect(onConfirm).toHaveBeenCalledWith();
  });
});