import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import * as React from "react"; import { describe, it, expect, vi, beforeEach } from "vitest"; import { MediaPickerModal } from "../../src/components/MediaPickerModal"; import { render } from "../utils/render.tsx"; // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- const UPLOAD_BUTTON_REGEX = /Upload/; vi.mock("../../src/lib/api", async () => { const actual = await vi.importActual("../../src/lib/api"); return { ...actual, fetchMediaList: vi.fn().mockResolvedValue({ items: [ { id: "m1", filename: "photo.jpg", mimeType: "image/jpeg", url: "/media/photo.jpg", size: 1024, width: 800, height: 600, createdAt: "2024-01-01", }, { id: "m2", filename: "landscape.png", mimeType: "image/png", url: "/media/landscape.png", size: 2048, width: 1200, height: 800, createdAt: "2024-01-02", }, ], }), fetchMediaProviders: vi.fn().mockResolvedValue([]), fetchProviderMedia: vi.fn().mockResolvedValue({ items: [] }), uploadMedia: vi.fn().mockResolvedValue({ id: "m3", filename: "new.jpg" }), uploadToProvider: vi.fn().mockResolvedValue({}), updateMedia: vi.fn().mockResolvedValue({}), }; }); function QueryWrapper({ children }: { children: React.ReactNode }) { const qc = new QueryClient({ defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, }); return {children}; } function renderModal(props: Partial> = {}) { const defaultProps: React.ComponentProps = { open: true, onOpenChange: vi.fn(), onSelect: vi.fn(), ...props, }; return render( , ); } describe("MediaPickerModal", () => { beforeEach(() => { vi.clearAllMocks(); }); describe("displaying items", () => { it("shows media items when open", async () => { const screen = await renderModal({ open: true }); await expect.element(screen.getByRole("option", { name: "photo.jpg" })).toBeInTheDocument(); await expect .element(screen.getByRole("option", { name: "landscape.png" })) .toBeInTheDocument(); }); it("shows the modal title", async () => { const screen = await renderModal({ title: "Pick an Image" }); await expect.element(screen.getByText("Pick an Image")).toBeInTheDocument(); }); }); describe("selection", () => { it("single click selects item (highlighted)", async () => { const screen = await renderModal(); const option = screen.getByRole("option", { name: "photo.jpg" }); await expect.element(option).toBeInTheDocument(); // Direct DOM click to bypass inert overlay const btn = option.element().querySelector("button")!; btn.click(); // Should show selected state via aria-selected await expect.element(option).toHaveAttribute("aria-selected", "true"); // Footer should show selected filename in a tag await expect.element(screen.getByRole("strong")).toBeInTheDocument(); }); it("double click selects and calls onSelect", async () => { const onSelect = vi.fn(); const screen = await renderModal({ onSelect }); const option = screen.getByRole("option", { name: "photo.jpg" }); await expect.element(option).toBeInTheDocument(); // Use direct DOM dblclick to bypass inert overlay const btn = option.element().querySelector("button")!; btn.dispatchEvent(new MouseEvent("dblclick", { bubbles: true })); expect(onSelect).toHaveBeenCalledWith( expect.objectContaining({ id: "m1", filename: "photo.jpg" }), ); }); it("Insert button disabled when nothing selected", async () => { await renderModal(); // There are two Insert buttons — URL section and footer. // The footer Insert is the last one and should be disabled. await vi.waitFor(() => { const allInsertBtns = document.querySelectorAll("button"); const insertBtns = [...allInsertBtns].filter((b) => b.textContent?.trim() === "Insert"); // The footer Insert (last one) should be disabled const lastInsert = insertBtns.at(-1); expect(lastInsert?.disabled).toBe(true); }); }); it("Insert button enabled when item selected, calls onSelect", async () => { const onSelect = vi.fn(); const screen = await renderModal({ onSelect }); // Select an item via direct DOM click const option = screen.getByRole("option", { name: "photo.jpg" }); await expect.element(option).toBeInTheDocument(); const itemBtn = option.element().querySelector("button")!; itemBtn.click(); // Wait for selection to register await expect.element(option).toHaveAttribute("aria-selected", "true"); // Click the footer Insert button (last Insert button) await vi.waitFor(() => { const allInsertBtns = document.querySelectorAll("button"); const insertBtns = [...allInsertBtns].filter((b) => b.textContent?.trim() === "Insert"); const lastInsert = insertBtns.at(-1)!; expect(lastInsert.disabled).toBe(false); lastInsert.click(); }); expect(onSelect).toHaveBeenCalledWith( expect.objectContaining({ id: "m1", filename: "photo.jpg" }), ); }); }); describe("URL input", () => { it("invalid URL shows error", async () => { const screen = await renderModal(); // The URL input has aria-label "Image URL" const urlInput = screen.getByLabelText("Image URL"); await expect.element(urlInput).toBeInTheDocument(); // Type an invalid URL — use direct DOM since we're inside a dialog const inputEl = urlInput.element() as HTMLInputElement; // Manually set value and trigger change const nativeInputValueSetter = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, "value", )!.set!; nativeInputValueSetter.call(inputEl, "not-a-url"); inputEl.dispatchEvent(new Event("input", { bubbles: true })); inputEl.dispatchEvent(new Event("change", { bubbles: true })); // Click the URL Insert button (first Insert button) await vi.waitFor(() => { const urlInsert = [...document.querySelectorAll("button")].find( (b) => b.textContent?.trim() === "Insert", )!; expect(urlInsert.disabled).toBe(false); urlInsert.click(); }); await expect.element(screen.getByText("Please enter a valid URL")).toBeInTheDocument(); }); it("URL input: typing a URL and submitting triggers probe", async () => { const onSelect = vi.fn(); const screen = await renderModal({ onSelect }); const urlInput = screen.getByLabelText("Image URL"); const inputEl = urlInput.element() as HTMLInputElement; const nativeInputValueSetter = Object.getOwnPropertyDescriptor( HTMLInputElement.prototype, "value", )!.set!; nativeInputValueSetter.call(inputEl, "https://example.com/test.jpg"); inputEl.dispatchEvent(new Event("input", { bubbles: true })); inputEl.dispatchEvent(new Event("change", { bubbles: true })); // Click URL Insert button await vi.waitFor(() => { const urlInsert = [...document.querySelectorAll("button")].find( (b) => b.textContent?.trim() === "Insert", )!; urlInsert.click(); }); // Image probe will fail in test env, so either onSelect called or error shown await vi.waitFor( () => { const called = onSelect.mock.calls.length > 0; const hasError = document.body.textContent?.includes("Could not load image from URL") ?? false; expect(called || hasError).toBe(true); }, { timeout: 3000 }, ); }); }); describe("cancel and close", () => { it("Cancel closes modal", async () => { const onOpenChange = vi.fn(); const screen = await renderModal({ onOpenChange }); await expect.element(screen.getByText("Select Image")).toBeInTheDocument(); // Direct DOM click to bypass inert overlay const cancelEl = screen.getByText("Cancel").element(); const cancelBtn = cancelEl.closest("button")!; cancelBtn.click(); expect(onOpenChange).toHaveBeenCalledWith(false); }); }); describe("state reset", () => { it("state resets when modal reopens", async () => { const onSelect = vi.fn(); const onOpenChange = vi.fn(); const screen = await renderModal({ open: true, onSelect, onOpenChange }); // Select an item const option = screen.getByRole("option", { name: "photo.jpg" }); await expect.element(option).toBeInTheDocument(); const btn = option.element().querySelector("button")!; btn.click(); // Verify selection await expect.element(option).toHaveAttribute("aria-selected", "true"); // Close modal await screen.rerender( , ); // Reopen modal await screen.rerender( , ); // Footer Insert should be disabled (no selection after reset) await vi.waitFor(() => { const allInsertBtns = document.querySelectorAll("button"); const insertBtns = [...allInsertBtns].filter((b) => b.textContent?.trim() === "Insert"); const lastInsert = insertBtns.at(-1); expect(lastInsert?.disabled).toBe(true); }); }); }); describe("upload", () => { it("upload button and file input are present", async () => { const screen = await renderModal(); await expect .element(screen.getByRole("button", { name: UPLOAD_BUTTON_REGEX })) .toBeInTheDocument(); await expect.element(screen.getByLabelText("Upload file")).toBeInTheDocument(); }); }); });