// @vitest-environment jsdom import { beforeEach, describe, expect, it, vi } from "vitest"; // --------------------------------------------------------------------------- // Global mocks that must exist BEFORE the modules under test are imported // --------------------------------------------------------------------------- const revokeObjectURL = vi.fn(); const createObjectURL = vi.fn((_obj: Blob | MediaSource) => "blob:fake-url"); vi.stubGlobal("URL", { ...globalThis.URL, createObjectURL, revokeObjectURL, }); // fetch is mocked per-test; start with a stub so the module can load const fetchMock = vi.fn(); vi.stubGlobal("fetch", fetchMock); // localStorage mock (jsdom provides one, but we need spy access) const storageMap = new Map(); const localStorageMock = { getItem: vi.fn((key: string) => storageMap.get(key) ?? null), setItem: vi.fn((key: string, val: string) => storageMap.set(key, val)), removeItem: vi.fn((key: string) => storageMap.delete(key)), clear: vi.fn(() => storageMap.clear()), get length() { return storageMap.size; }, key: vi.fn((_i: number) => null), }; vi.stubGlobal("localStorage", localStorageMock); // --------------------------------------------------------------------------- // Imports (after mocks) // --------------------------------------------------------------------------- import { apiDelete, apiDownloadBlob, apiGet, apiPost, apiPut, apiUpload, clearToken, getDownloadUrl, setToken, } from "@/lib/api"; import { useFileStore } from "@/stores/file-store"; // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- function makeFile(name: string, size = 1024, type = "image/png"): File { const buf = new ArrayBuffer(size); return new File([buf], name, { type }); } function okJson(data: unknown) { return Promise.resolve({ ok: true, status: 200, json: () => Promise.resolve(data), blob: () => Promise.resolve(new Blob(["bytes"])), } as unknown as Response); } function failResponse(status: number) { return Promise.resolve({ ok: false, status, json: () => Promise.reject(new Error("no body")), blob: () => Promise.resolve(new Blob()), } as unknown as Response); } // ========================================================================== // FileStore // ========================================================================== describe("FileStore", () => { beforeEach(() => { useFileStore.getState().reset(); vi.clearAllMocks(); createObjectURL.mockClear(); revokeObjectURL.mockClear(); // Reset the mock to return incrementing URLs let urlCounter = 0; createObjectURL.mockImplementation((_obj: Blob | MediaSource) => `blob:url-${++urlCounter}`); }); // -- Initial state ------------------------------------------------------- it("has correct initial state", () => { const s = useFileStore.getState(); expect(s.entries).toEqual([]); expect(s.selectedIndex).toBe(0); expect(s.batchZipBlob).toBeNull(); expect(s.batchZipFilename).toBeNull(); expect(s.processing).toBe(false); expect(s.error).toBeNull(); }); // -- setFiles ------------------------------------------------------------- it("setFiles creates entries with blob URLs", () => { const f1 = makeFile("a.png", 100); const f2 = makeFile("b.png", 200); useFileStore.getState().setFiles([f1, f2]); const s = useFileStore.getState(); expect(s.entries).toHaveLength(2); expect(s.entries[0].file).toBe(f1); expect(s.entries[0].blobUrl).toBe("blob:url-1"); expect(s.entries[0].originalSize).toBe(100); expect(s.entries[0].status).toBe("pending"); expect(s.entries[0].processedUrl).toBeNull(); expect(s.entries[0].processedSize).toBeNull(); expect(s.entries[0].error).toBeNull(); expect(s.entries[1].file).toBe(f2); expect(s.entries[1].blobUrl).toBe("blob:url-2"); expect(createObjectURL).toHaveBeenCalledTimes(2); }); it("setFiles revokes old blob URLs", () => { useFileStore.getState().setFiles([makeFile("a.png")]); const oldUrl = useFileStore.getState().entries[0].blobUrl; revokeObjectURL.mockClear(); useFileStore.getState().setFiles([makeFile("b.png")]); expect(revokeObjectURL).toHaveBeenCalledWith(oldUrl); }); it("setFiles clears on empty array", () => { useFileStore.getState().setFiles([makeFile("a.png")]); revokeObjectURL.mockClear(); const oldUrl = useFileStore.getState().entries[0].blobUrl; useFileStore.getState().setFiles([]); expect(useFileStore.getState().entries).toEqual([]); expect(revokeObjectURL).toHaveBeenCalledWith(oldUrl); }); it("setFiles resets selectedIndex to 0", () => { useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); useFileStore.getState().setSelectedIndex(1); expect(useFileStore.getState().selectedIndex).toBe(1); useFileStore.getState().setFiles([makeFile("c.png")]); expect(useFileStore.getState().selectedIndex).toBe(0); }); it("setFiles clears error", () => { useFileStore.getState().setError("old error"); useFileStore.getState().setFiles([makeFile("a.png")]); expect(useFileStore.getState().error).toBeNull(); }); // -- addFiles ------------------------------------------------------------- it("addFiles appends new entries without revoking existing", () => { useFileStore.getState().setFiles([makeFile("a.png", 100)]); revokeObjectURL.mockClear(); createObjectURL.mockClear(); const f2 = makeFile("b.png", 200); useFileStore.getState().addFiles([f2]); expect(revokeObjectURL).not.toHaveBeenCalled(); expect(useFileStore.getState().entries).toHaveLength(2); expect(useFileStore.getState().entries[1].file).toBe(f2); expect(createObjectURL).toHaveBeenCalledTimes(1); }); // -- removeFile ----------------------------------------------------------- it("removeFile removes entry and revokes its blob URLs", () => { useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); const removedUrl = useFileStore.getState().entries[0].blobUrl; revokeObjectURL.mockClear(); useFileStore.getState().removeFile(0); expect(useFileStore.getState().entries).toHaveLength(1); expect(useFileStore.getState().entries[0].file.name).toBe("b.png"); expect(revokeObjectURL).toHaveBeenCalledWith(removedUrl); }); it("removeFile adjusts selectedIndex when removing before it", () => { useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png"), makeFile("c.png")]); useFileStore.getState().setSelectedIndex(2); useFileStore.getState().removeFile(0); expect(useFileStore.getState().selectedIndex).toBe(1); }); it("removeFile clamps selectedIndex if it was the last entry", () => { useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); useFileStore.getState().setSelectedIndex(1); useFileStore.getState().removeFile(1); expect(useFileStore.getState().selectedIndex).toBe(0); }); it("removeFile revokes processedUrl if present", () => { useFileStore.getState().setFiles([makeFile("a.png")]); useFileStore.getState().updateEntry(0, { processedUrl: "blob:processed", status: "completed", }); revokeObjectURL.mockClear(); useFileStore.getState().removeFile(0); expect(revokeObjectURL).toHaveBeenCalledWith("blob:processed"); }); // -- Navigation ----------------------------------------------------------- it("navigateNext advances selectedIndex", () => { useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); expect(useFileStore.getState().selectedIndex).toBe(0); useFileStore.getState().navigateNext(); expect(useFileStore.getState().selectedIndex).toBe(1); }); it("navigateNext does not exceed bounds", () => { useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); useFileStore.getState().setSelectedIndex(1); useFileStore.getState().navigateNext(); expect(useFileStore.getState().selectedIndex).toBe(1); }); it("navigatePrev decrements selectedIndex", () => { useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); useFileStore.getState().setSelectedIndex(1); useFileStore.getState().navigatePrev(); expect(useFileStore.getState().selectedIndex).toBe(0); }); it("navigatePrev does not go below 0", () => { useFileStore.getState().setFiles([makeFile("a.png")]); useFileStore.getState().navigatePrev(); expect(useFileStore.getState().selectedIndex).toBe(0); }); // -- updateEntry ---------------------------------------------------------- it("updateEntry merges partial data into the entry at index", () => { useFileStore.getState().setFiles([makeFile("a.png", 500)]); useFileStore.getState().updateEntry(0, { status: "completed", processedUrl: "blob:done", processedSize: 250, }); const entry = useFileStore.getState().entries[0]; expect(entry.status).toBe("completed"); expect(entry.processedUrl).toBe("blob:done"); expect(entry.processedSize).toBe(250); expect(entry.file.name).toBe("a.png"); // unchanged }); // -- setBatchZip ---------------------------------------------------------- it("setBatchZip stores blob and filename", () => { const blob = new Blob(["zip-data"]); useFileStore.getState().setBatchZip(blob, "results.zip"); const s = useFileStore.getState(); expect(s.batchZipBlob).toBe(blob); expect(s.batchZipFilename).toBe("results.zip"); }); // -- undoProcessing ------------------------------------------------------- it("undoProcessing resets all entries to pending and revokes processed blob URLs", () => { useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); useFileStore.getState().updateEntry(0, { status: "completed", processedUrl: "blob:proc-a", processedSize: 50, }); useFileStore.getState().updateEntry(1, { status: "completed", processedUrl: "blob:proc-b", processedSize: 75, }); revokeObjectURL.mockClear(); useFileStore.getState().undoProcessing(); const s = useFileStore.getState(); // All entries reset to pending expect(s.entries[0].status).toBe("pending"); expect(s.entries[0].processedUrl).toBeNull(); expect(s.entries[0].processedSize).toBeNull(); expect(s.entries[0].error).toBeNull(); expect(s.entries[1].status).toBe("pending"); expect(s.entries[1].processedUrl).toBeNull(); // Processed URLs revoked expect(revokeObjectURL).toHaveBeenCalledWith("blob:proc-a"); expect(revokeObjectURL).toHaveBeenCalledWith("blob:proc-b"); }); it("undoProcessing keeps original blob URLs", () => { useFileStore.getState().setFiles([makeFile("a.png")]); const origUrl = useFileStore.getState().entries[0].blobUrl; revokeObjectURL.mockClear(); useFileStore.getState().undoProcessing(); // Should NOT revoke original blob URL expect(revokeObjectURL).not.toHaveBeenCalledWith(origUrl); expect(useFileStore.getState().entries[0].blobUrl).toBe(origUrl); }); // -- reset ---------------------------------------------------------------- it("reset clears everything and revokes all blob URLs", () => { useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); useFileStore.getState().updateEntry(0, { processedUrl: "blob:proc" }); const origUrl0 = useFileStore.getState().entries[0].blobUrl; const origUrl1 = useFileStore.getState().entries[1].blobUrl; revokeObjectURL.mockClear(); useFileStore.getState().reset(); expect(revokeObjectURL).toHaveBeenCalledWith(origUrl0); expect(revokeObjectURL).toHaveBeenCalledWith("blob:proc"); expect(revokeObjectURL).toHaveBeenCalledWith(origUrl1); const s = useFileStore.getState(); expect(s.entries).toEqual([]); expect(s.selectedIndex).toBe(0); expect(s.batchZipBlob).toBeNull(); expect(s.batchZipFilename).toBeNull(); expect(s.processing).toBe(false); expect(s.error).toBeNull(); }); it("reset with no entries does not call revokeObjectURL", () => { revokeObjectURL.mockClear(); useFileStore.getState().reset(); expect(revokeObjectURL).not.toHaveBeenCalled(); }); // -- Backward compat getters ---------------------------------------------- it("files getter maps entries to File[]", () => { const f1 = makeFile("a.png"); const f2 = makeFile("b.png"); useFileStore.getState().setFiles([f1, f2]); const s = useFileStore.getState(); expect(s.files).toEqual([f1, f2]); expect(s.files[0]).toBe(f1); }); it("currentEntry returns entry at selectedIndex", () => { useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); useFileStore.getState().setSelectedIndex(1); expect(useFileStore.getState().currentEntry?.file.name).toBe("b.png"); }); it("currentEntry returns undefined when no entries", () => { expect(useFileStore.getState().currentEntry).toBeUndefined(); }); it("selectedFileName returns current entry file name", () => { useFileStore.getState().setFiles([makeFile("photo.png")]); expect(useFileStore.getState().selectedFileName).toBe("photo.png"); }); it("selectedFileName returns null when no entries", () => { expect(useFileStore.getState().selectedFileName).toBeNull(); }); it("selectedFileSize returns current entry file size", () => { useFileStore.getState().setFiles([makeFile("photo.png", 2048)]); expect(useFileStore.getState().selectedFileSize).toBe(2048); }); it("selectedFileSize returns null when no entries", () => { expect(useFileStore.getState().selectedFileSize).toBeNull(); }); it("originalBlobUrl returns current entry blobUrl", () => { useFileStore.getState().setFiles([makeFile("a.png")]); expect(useFileStore.getState().originalBlobUrl).toBe( useFileStore.getState().entries[0].blobUrl, ); }); it("originalBlobUrl returns null when no entries", () => { expect(useFileStore.getState().originalBlobUrl).toBeNull(); }); it("processedUrl returns current entry processedUrl", () => { useFileStore.getState().setFiles([makeFile("a.png")]); useFileStore.getState().updateEntry(0, { processedUrl: "blob:done" }); expect(useFileStore.getState().processedUrl).toBe("blob:done"); }); it("originalSize returns current entry originalSize", () => { useFileStore.getState().setFiles([makeFile("a.png", 999)]); expect(useFileStore.getState().originalSize).toBe(999); }); it("processedSize returns current entry processedSize", () => { useFileStore.getState().setFiles([makeFile("a.png")]); useFileStore.getState().updateEntry(0, { processedSize: 500 }); expect(useFileStore.getState().processedSize).toBe(500); }); it("entries.length reflects file count", () => { expect(useFileStore.getState().entries.length).toBe(0); useFileStore.getState().setFiles([makeFile("a.png")]); expect(useFileStore.getState().entries.length).toBe(1); }); it("all entries completed can be derived from entries", () => { useFileStore.getState().setFiles([makeFile("a.png"), makeFile("b.png")]); const allDone = () => useFileStore.getState().entries.length > 0 && useFileStore.getState().entries.every((e) => e.status === "completed"); expect(allDone()).toBe(false); useFileStore.getState().updateEntry(0, { status: "completed" }); expect(allDone()).toBe(false); useFileStore.getState().updateEntry(1, { status: "completed" }); expect(allDone()).toBe(true); }); it("empty entries means nothing is processed", () => { const allDone = useFileStore.getState().entries.length > 0 && useFileStore.getState().entries.every((e) => e.status === "completed"); expect(allDone).toBe(false); }); // -- HEIC async preview in setFiles ----------------------------------------- it("setFiles triggers async preview for HEIC files", async () => { const previewBlob = new Blob(["decoded"], { type: "image/png" }); fetchMock.mockResolvedValueOnce({ ok: true, blob: () => Promise.resolve(previewBlob), }); const heicFile = makeFile("photo.heic", 500, "image/heic"); useFileStore.getState().setFiles([heicFile]); // Entry should have previewLoading=true initially expect(useFileStore.getState().entries[0].previewLoading).toBe(true); // Wait for async preview to resolve await vi.waitFor(() => { expect(useFileStore.getState().entries[0].previewLoading).toBe(false); }); }); it("addFiles triggers async preview for HEIC files", async () => { // First add a normal file useFileStore.getState().setFiles([makeFile("normal.png", 100)]); const previewBlob = new Blob(["decoded"], { type: "image/png" }); fetchMock.mockResolvedValueOnce({ ok: true, blob: () => Promise.resolve(previewBlob), }); const heicFile = makeFile("photo2.heic", 500, "image/heic"); useFileStore.getState().addFiles([heicFile]); // Second entry should have previewLoading=true initially expect(useFileStore.getState().entries[1].previewLoading).toBe(true); // Wait for async preview to resolve await vi.waitFor(() => { expect(useFileStore.getState().entries[1].previewLoading).toBe(false); }); }); // -- setProcessedUrl (backward compat, updates current entry) ------------- it("setProcessedUrl updates current entry processedUrl and status", () => { useFileStore.getState().setFiles([makeFile("a.png")]); useFileStore.getState().setProcessedUrl("blob:result"); const entry = useFileStore.getState().entries[0]; expect(entry.processedUrl).toBe("blob:result"); expect(entry.status).toBe("completed"); }); it("setProcessedUrl with null resets current entry", () => { useFileStore.getState().setFiles([makeFile("a.png")]); useFileStore.getState().setProcessedUrl("blob:result"); useFileStore.getState().setProcessedUrl(null); const entry = useFileStore.getState().entries[0]; expect(entry.processedUrl).toBeNull(); expect(entry.status).toBe("pending"); }); // -- setSizes (backward compat, updates current entry) -------------------- it("setSizes updates current entry sizes", () => { useFileStore.getState().setFiles([makeFile("a.png", 1000)]); useFileStore.getState().setSizes(1000, 500); const entry = useFileStore.getState().entries[0]; expect(entry.originalSize).toBe(1000); expect(entry.processedSize).toBe(500); }); // -- setJobId (no-op for compat) ------------------------------------------ it("setJobId is a no-op (does not throw)", () => { expect(() => useFileStore.getState().setJobId("job-abc")).not.toThrow(); }); }); // ========================================================================== // API lib // ========================================================================== describe("API lib", () => { beforeEach(() => { fetchMock.mockReset(); storageMap.clear(); localStorageMock.getItem.mockClear(); localStorageMock.setItem.mockClear(); localStorageMock.removeItem.mockClear(); }); // -- Token management ---------------------------------------------------- describe("token management", () => { it("setToken stores in localStorage under 'snapotter-token'", () => { setToken("my-secret"); expect(localStorageMock.setItem).toHaveBeenCalledWith("snapotter-token", "my-secret"); expect(storageMap.get("snapotter-token")).toBe("my-secret"); }); it("clearToken removes 'snapotter-token' from localStorage", () => { setToken("to-remove"); clearToken(); expect(localStorageMock.removeItem).toHaveBeenCalledWith("snapotter-token"); expect(storageMap.has("snapotter-token")).toBe(false); }); it("clearToken is idempotent (no throw when key missing)", () => { expect(() => clearToken()).not.toThrow(); }); }); // -- apiGet --------------------------------------------------------------- describe("apiGet", () => { it("sends GET with Bearer token from localStorage", async () => { setToken("tok-123"); fetchMock.mockReturnValueOnce(okJson({ data: "ok" })); const result = await apiGet<{ data: string }>("/v1/health"); const [url, opts] = fetchMock.mock.calls[0]; expect(url).toBe("/api/v1/health"); expect(opts.headers.get("Authorization")).toBe("Bearer tok-123"); expect(result).toEqual({ data: "ok" }); }); it("omits Authorization header when no token is set", async () => { fetchMock.mockReturnValueOnce(okJson({})); await apiGet("/v1/anything"); const callArgs = fetchMock.mock.calls[0]; expect(callArgs[1].headers.get("Authorization")).toBeNull(); }); it("throws on non-ok response (e.g., 401)", async () => { fetchMock.mockReturnValueOnce(failResponse(401)); await expect(apiGet("/v1/secret")).rejects.toThrow("API error: 401"); }); it("throws on non-ok response (e.g., 500)", async () => { fetchMock.mockReturnValueOnce(failResponse(500)); await expect(apiGet("/v1/broken")).rejects.toThrow("API error: 500"); }); it("throws when fetch itself rejects (network error)", async () => { fetchMock.mockRejectedValueOnce(new TypeError("Failed to fetch")); await expect(apiGet("/v1/anything")).rejects.toThrow("Failed to fetch"); }); }); // -- apiPost -------------------------------------------------------------- describe("apiPost", () => { it("sends POST with JSON body and Bearer token", async () => { setToken("post-tok"); fetchMock.mockReturnValueOnce(okJson({ id: 1 })); const result = await apiPost("/v1/items", { name: "test" }); const [url, opts] = fetchMock.mock.calls[0]; expect(url).toBe("/api/v1/items"); expect(opts.method).toBe("POST"); expect(opts.headers.get("Content-Type")).toBe("application/json"); expect(opts.headers.get("Authorization")).toBe("Bearer post-tok"); expect(opts.body).toBe(JSON.stringify({ name: "test" })); expect(result).toEqual({ id: 1 }); }); it("sends POST with undefined body when no body argument", async () => { fetchMock.mockReturnValueOnce(okJson({})); await apiPost("/v1/trigger"); const [, opts] = fetchMock.mock.calls[0]; expect(opts.body).toBeUndefined(); }); it("throws on non-ok response", async () => { fetchMock.mockReturnValueOnce(failResponse(422)); await expect(apiPost("/v1/items", {})).rejects.toThrow("API error: 422"); }); }); // -- apiPut --------------------------------------------------------------- describe("apiPut", () => { it("sends PUT with JSON body and Bearer token", async () => { setToken("put-tok"); fetchMock.mockReturnValueOnce(okJson({ updated: true })); const result = await apiPut("/v1/items/1", { name: "updated" }); const [url, opts] = fetchMock.mock.calls[0]; expect(url).toBe("/api/v1/items/1"); expect(opts.method).toBe("PUT"); expect(opts.headers.get("Content-Type")).toBe("application/json"); expect(opts.headers.get("Authorization")).toBe("Bearer put-tok"); expect(opts.body).toBe(JSON.stringify({ name: "updated" })); expect(result).toEqual({ updated: true }); }); it("throws on non-ok response", async () => { fetchMock.mockReturnValueOnce(failResponse(404)); await expect(apiPut("/v1/items/999", {})).rejects.toThrow("API error: 404"); }); }); // -- apiDelete ------------------------------------------------------------ describe("apiDelete", () => { it("sends DELETE with Bearer token (no body)", async () => { setToken("del-tok"); fetchMock.mockReturnValueOnce(okJson({ deleted: true })); const result = await apiDelete("/v1/items/1"); const [url, opts] = fetchMock.mock.calls[0]; expect(url).toBe("/api/v1/items/1"); expect(opts.method).toBe("DELETE"); expect(opts.headers.get("Authorization")).toBe("Bearer del-tok"); expect(opts.body).toBeUndefined(); expect(result).toEqual({ deleted: true }); }); it("throws on non-ok response", async () => { fetchMock.mockReturnValueOnce(failResponse(403)); await expect(apiDelete("/v1/items/1")).rejects.toThrow("API error: 403"); }); }); // -- apiUpload ------------------------------------------------------------ describe("apiUpload", () => { it("sends FormData with files to /api/v1/upload", async () => { setToken("up-tok"); const payload = { jobId: "j1", files: [{ name: "img.png", size: 1024, format: "png" }], }; fetchMock.mockReturnValueOnce(okJson(payload)); const f = makeFile("img.png", 1024); const result = await apiUpload([f]); const [url, opts] = fetchMock.mock.calls[0]; expect(url).toBe("/api/v1/upload"); expect(opts.method).toBe("POST"); expect(opts.headers.get("Authorization")).toBe("Bearer up-tok"); // Body should be FormData expect(opts.body).toBeInstanceOf(FormData); const fd = opts.body as FormData; expect(fd.getAll("files")).toHaveLength(1); expect(result).toEqual(payload); }); it("appends multiple files to FormData under the same 'files' key", async () => { fetchMock.mockReturnValueOnce(okJson({ jobId: "j2", files: [{}, {}] })); await apiUpload([makeFile("a.png"), makeFile("b.jpg")]); const fd = fetchMock.mock.calls[0][1].body as FormData; expect(fd.getAll("files")).toHaveLength(2); }); it("does NOT set Content-Type (browser sets multipart boundary)", async () => { fetchMock.mockReturnValueOnce(okJson({ jobId: "j", files: [] })); await apiUpload([makeFile("x.png")]); const headers = fetchMock.mock.calls[0][1].headers; expect(headers.get("Content-Type")).toBeNull(); }); it("throws on non-ok response with status in message", async () => { fetchMock.mockReturnValueOnce(failResponse(413)); await expect(apiUpload([makeFile("big.png")])).rejects.toThrow("Upload failed: 413"); }); it("sends empty FormData when given empty file array", async () => { fetchMock.mockReturnValueOnce(okJson({ jobId: "j", files: [] })); await apiUpload([]); const fd = fetchMock.mock.calls[0][1].body as FormData; expect(fd.getAll("files")).toHaveLength(0); }); }); // -- apiDownloadBlob ------------------------------------------------------ describe("apiDownloadBlob", () => { it("returns a Blob from the download URL", async () => { setToken("dl-tok"); const blob = new Blob(["image-data"], { type: "image/png" }); fetchMock.mockReturnValueOnce( Promise.resolve({ ok: true, status: 200, blob: () => Promise.resolve(blob), }), ); const result = await apiDownloadBlob("job-1", "result.png"); const [url, opts] = fetchMock.mock.calls[0]; expect(url).toBe("/api/v1/download/job-1/result.png"); expect(opts.headers.get("Authorization")).toBe("Bearer dl-tok"); expect(result).toBe(blob); }); it("throws on non-ok response", async () => { fetchMock.mockReturnValueOnce(failResponse(404)); await expect(apiDownloadBlob("job-x", "gone.png")).rejects.toThrow("Download failed: 404"); }); it("handles special characters in filename", async () => { fetchMock.mockReturnValueOnce( Promise.resolve({ ok: true, status: 200, blob: () => Promise.resolve(new Blob()), }), ); await apiDownloadBlob("j1", "my file (1).png"); const url = fetchMock.mock.calls[0][0]; // The function does raw string concatenation, so special chars pass through expect(url).toBe("/api/v1/download/j1/my file (1).png"); }); }); // -- getDownloadUrl ------------------------------------------------------- describe("getDownloadUrl", () => { it("constructs the correct URL", () => { expect(getDownloadUrl("abc", "out.png")).toBe("/api/v1/download/abc/out.png"); }); }); // -- api network error → connection store --------------------------------- describe("api network error → connection store", () => { it("triggers disconnected state on TypeError from fetch", async () => { const { useConnectionStore } = await import("@/stores/connection-store"); useConnectionStore.setState({ status: "connected", failedSince: null, lastHealthCheck: null, }); fetchMock.mockRejectedValueOnce(new TypeError("Failed to fetch")); await expect(apiGet("/v1/test")).rejects.toThrow("Failed to fetch"); expect(useConnectionStore.getState().status).toBe("disconnected"); }); it("does NOT trigger disconnected on HTTP errors", async () => { const { useConnectionStore } = await import("@/stores/connection-store"); useConnectionStore.setState({ status: "connected", failedSince: null, lastHealthCheck: null, }); fetchMock.mockResolvedValueOnce( new Response(JSON.stringify({ error: "Not found" }), { status: 404 }), ); await expect(apiGet("/v1/test")).rejects.toThrow(); expect(useConnectionStore.getState().status).toBe("connected"); }); }); // -- Cross-cutting: token is read fresh on every call -------------------- describe("token freshness", () => { it("reads the token from localStorage on each request, not cached", async () => { setToken("first-token"); fetchMock.mockReturnValueOnce(okJson({})); await apiGet("/v1/a"); expect(fetchMock.mock.calls[0][1].headers.get("Authorization")).toBe("Bearer first-token"); setToken("second-token"); fetchMock.mockReturnValueOnce(okJson({})); await apiGet("/v1/b"); expect(fetchMock.mock.calls[1][1].headers.get("Authorization")).toBe("Bearer second-token"); }); it("omits Authorization header after clearToken", async () => { setToken("about-to-die"); clearToken(); fetchMock.mockReturnValueOnce(okJson({})); await apiGet("/v1/c"); expect(fetchMock.mock.calls[0][1].headers.get("Authorization")).toBeNull(); }); }); }); // ========================================================================== // useAuth security // ========================================================================== describe("useAuth security — network error", () => { beforeEach(() => { fetchMock.mockReset(); }); it("does NOT grant admin when API is unreachable", async () => { fetchMock.mockRejectedValue(new TypeError("Failed to fetch")); const { renderHook, act } = await import("@testing-library/react"); const { useAuth } = await import("@/hooks/use-auth"); const { result } = renderHook(() => useAuth()); // Flush the async checkAuth() call await act(async () => {}); // loading stays true — setState was never called expect(result.current.loading).toBe(true); expect(result.current.isAuthenticated).toBe(false); expect(result.current.role).toBeNull(); }); });