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()), 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; targetId?: string | null; }) { return ( ); } 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); 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(); await pickReplacement(); rerender(); 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() }) => ( ); it("searches dive sites, not trips", async () => { const onConfirm = vi.fn(); render(); 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( , ); 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( , ); 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(); }); });