Files
SnapOtter/tests/unit/web/stores.test.ts
T
SnapOtter a0556772e8 test: expand coverage across all layers -- 1,268 new tests, fix replace-color div-by-zero
14-agent parallel test expansion covering integration, unit, E2E, E2E-Docker,
cross-format matrix, adversarial, GUI navigation/tools/settings/visual/a11y/perf.

- Integration: expand 23 tool test files with HEIC, stress, batch, edge cases
- Unit: close coverage gaps in image-engine, stores, lib (metadata, auto-enhance,
  connection-store, lazy-with-retry, collage/file-store HEIC preview)
- AI bridge: 141 new tests for dispatcher buffering, crash recovery, OOM/segfault
- Cross-format: 794 parameterized tests (16 formats x 12 tools + no-crash matrix)
- Adversarial: memory stress (50x large file), zero-byte, corrupted headers, unicode
- E2E-Docker: expand 8 spec files with dimension verification, pipeline chains
- GUI E2E: tool UI settings/interactions for all 47 tools, remove all test.skip,
  RBAC per-role verification, visual screenshot naming, cross-browser smoke tests,
  a11y ARIA/focus/contrast, performance budgets, 15-tool stability test
- Fix: replace-color.ts tolerance=0 caused division-by-zero producing NaN pixels

Total: 8,958 tests passing across 202 files. Zero failures, zero skips.
2026-05-09 18:02:58 +08:00

872 lines
31 KiB
TypeScript

// @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<string, string>();
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();
});
});