mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
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.
872 lines
31 KiB
TypeScript
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();
|
|
});
|
|
});
|