// @vitest-environment jsdom import { beforeEach, describe, expect, it, vi } from "vitest"; // --------------------------------------------------------------------------- // Global mocks // --------------------------------------------------------------------------- const revokeObjectURL = vi.fn(); const createObjectURL = vi.fn((_obj: Blob | MediaSource) => "blob:preview-url"); vi.stubGlobal("URL", { ...globalThis.URL, createObjectURL, revokeObjectURL, }); const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); vi.stubGlobal("localStorage", { getItem: vi.fn(() => null), setItem: vi.fn(), removeItem: vi.fn(), clear: vi.fn(), get length() { return 0; }, key: vi.fn(() => null), }); // ========================================================================== // fetchDecodedPreview & revokePreviewUrl (image-preview.ts) // ========================================================================== import { fetchDecodedPreview, needsServerPreview, revokePreviewUrl } from "@/lib/image-preview"; describe("needsServerPreview", () => { it("returns true for HEIC files", () => { const file = new File(["data"], "photo.heic", { type: "image/heic" }); expect(needsServerPreview(file)).toBe(true); }); it("returns true for HEIF files", () => { const file = new File(["data"], "photo.heif", { type: "image/heif" }); expect(needsServerPreview(file)).toBe(true); }); it("returns true for HIF files", () => { const file = new File(["data"], "photo.hif", { type: "image/heif" }); expect(needsServerPreview(file)).toBe(true); }); it("returns true for JXL files", () => { const file = new File(["data"], "photo.jxl", { type: "image/jxl" }); expect(needsServerPreview(file)).toBe(true); }); it("returns true for RAW formats (dng, cr2, nef, arw, orf, rw2)", () => { for (const ext of ["dng", "cr2", "nef", "arw", "orf", "rw2"]) { const file = new File(["data"], `photo.${ext}`, { type: "image/raw" }); expect(needsServerPreview(file)).toBe(true); } }); it("returns true for ICO files", () => { const file = new File(["data"], "icon.ico", { type: "image/x-icon" }); expect(needsServerPreview(file)).toBe(true); }); it("returns true for TGA files", () => { const file = new File(["data"], "texture.tga", { type: "image/tga" }); expect(needsServerPreview(file)).toBe(true); }); it("returns true for PSD files", () => { const file = new File(["data"], "design.psd", { type: "image/vnd.adobe.photoshop" }); expect(needsServerPreview(file)).toBe(true); }); it("returns true for EXR files", () => { const file = new File(["data"], "render.exr", { type: "image/x-exr" }); expect(needsServerPreview(file)).toBe(true); }); it("returns true for HDR files", () => { const file = new File(["data"], "panorama.hdr", { type: "image/vnd.radiance" }); expect(needsServerPreview(file)).toBe(true); }); it("returns false for common browser-decodable formats", () => { expect(needsServerPreview(new File(["data"], "photo.png", { type: "image/png" }))).toBe(false); expect(needsServerPreview(new File(["data"], "photo.jpg", { type: "image/jpeg" }))).toBe(false); expect(needsServerPreview(new File(["data"], "photo.webp", { type: "image/webp" }))).toBe( false, ); expect(needsServerPreview(new File(["data"], "photo.gif", { type: "image/gif" }))).toBe(false); }); it("returns false for files without extension", () => { const file = new File(["data"], "noextension", { type: "application/octet-stream" }); expect(needsServerPreview(file)).toBe(false); }); it("handles uppercase extensions (case insensitive)", () => { const file = new File(["data"], "photo.HEIC", { type: "image/heic" }); expect(needsServerPreview(file)).toBe(true); }); }); describe("fetchDecodedPreview", () => { beforeEach(() => { fetchMock.mockReset(); createObjectURL.mockClear(); }); it("sends POST to /api/v1/preview with the file as FormData", async () => { const blob = new Blob(["image-data"], { type: "image/png" }); fetchMock.mockResolvedValueOnce({ ok: true, blob: () => Promise.resolve(blob), headers: { get: () => null }, }); const file = new File(["heic-data"], "photo.heic", { type: "image/heic" }); const result = await fetchDecodedPreview(file); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, opts] = fetchMock.mock.calls[0]; expect(url).toBe("/api/v1/preview"); expect(opts.method).toBe("POST"); expect(opts.body).toBeInstanceOf(FormData); const formData = opts.body as FormData; expect(formData.get("file")).toBe(file); expect(result?.url).toBe("blob:preview-url"); expect(createObjectURL).toHaveBeenCalledWith(blob); }); it("returns null when response is not ok", async () => { fetchMock.mockResolvedValueOnce({ ok: false, status: 500, }); const file = new File(["data"], "photo.heic", { type: "image/heic" }); const result = await fetchDecodedPreview(file); expect(result).toBeNull(); expect(createObjectURL).not.toHaveBeenCalled(); }); it("returns null when fetch throws", async () => { fetchMock.mockRejectedValueOnce(new TypeError("Network error")); const file = new File(["data"], "photo.heic", { type: "image/heic" }); const result = await fetchDecodedPreview(file); expect(result).toBeNull(); }); }); describe("revokePreviewUrl", () => { beforeEach(() => { revokeObjectURL.mockClear(); }); it("calls URL.revokeObjectURL with the given URL", () => { revokePreviewUrl("blob:some-preview-url"); expect(revokeObjectURL).toHaveBeenCalledWith("blob:some-preview-url"); }); it("calls URL.revokeObjectURL for any string", () => { revokePreviewUrl("blob:another-url"); expect(revokeObjectURL).toHaveBeenCalledWith("blob:another-url"); expect(revokeObjectURL).toHaveBeenCalledTimes(1); }); }); // ========================================================================== // triggerDownload (download.ts) // ========================================================================== import { triggerDownload } from "@/lib/download"; describe("triggerDownload", () => { beforeEach(() => { // Remove any leftover anchor tags from body for (const a of document.body.querySelectorAll("a")) { a.remove(); } }); it("creates an anchor element, clicks it, and removes it", () => { const clickSpy = vi.fn(); const originalCreateElement = document.createElement.bind(document); vi.spyOn(document, "createElement").mockImplementation((tag: string) => { const el = originalCreateElement(tag); if (tag === "a") { vi.spyOn(el, "click").mockImplementation(clickSpy); } return el; }); triggerDownload("blob:download-url", "output.png"); expect(clickSpy).toHaveBeenCalledTimes(1); // The anchor should have been removed from the document after click expect(document.body.querySelectorAll("a")).toHaveLength(0); vi.restoreAllMocks(); }); it("sets the href and download attributes on the anchor", () => { let capturedHref = ""; let capturedDownload = ""; const originalCreateElement = document.createElement.bind(document); vi.spyOn(document, "createElement").mockImplementation((tag: string) => { const el = originalCreateElement(tag); if (tag === "a") { vi.spyOn(el, "click").mockImplementation(() => { capturedHref = el.getAttribute("href") ?? ""; capturedDownload = el.getAttribute("download") ?? ""; }); } return el; }); triggerDownload("blob:file-url", "result.webp"); expect(capturedHref).toBe("blob:file-url"); expect(capturedDownload).toBe("result.webp"); vi.restoreAllMocks(); }); });