Fixes: 1. media.ts: wrap placeholder generation in try-catch 2. toolbar.ts: check r.ok, display error message in popover
294 lines
9.6 KiB
TypeScript
294 lines
9.6 KiB
TypeScript
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 <QueryClientProvider client={qc}>{children}</QueryClientProvider>;
|
|
}
|
|
|
|
function renderModal(props: Partial<React.ComponentProps<typeof MediaPickerModal>> = {}) {
|
|
const defaultProps: React.ComponentProps<typeof MediaPickerModal> = {
|
|
open: true,
|
|
onOpenChange: vi.fn(),
|
|
onSelect: vi.fn(),
|
|
...props,
|
|
};
|
|
return render(
|
|
<QueryWrapper>
|
|
<MediaPickerModal {...defaultProps} />
|
|
</QueryWrapper>,
|
|
);
|
|
}
|
|
|
|
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 <strong> 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(
|
|
<QueryWrapper>
|
|
<MediaPickerModal open={false} onOpenChange={onOpenChange} onSelect={onSelect} />
|
|
</QueryWrapper>,
|
|
);
|
|
|
|
// Reopen modal
|
|
await screen.rerender(
|
|
<QueryWrapper>
|
|
<MediaPickerModal open={true} onOpenChange={onOpenChange} onSelect={onSelect} />
|
|
</QueryWrapper>,
|
|
);
|
|
|
|
// 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();
|
|
});
|
|
});
|
|
});
|