mirror of
https://github.com/snapotter-hq/SnapOtter.git
synced 2026-08-03 07:46:42 +02:00
681 lines
24 KiB
TypeScript
681 lines
24 KiB
TypeScript
// @vitest-environment jsdom
|
|||
|
|
import { describe, it, expect, vi, beforeEach, afterEach } 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 { useFileStore } from "@/stores/file-store";
|
||
|
|
import {
|
||
|
|
apiGet,
|
||
|
|
apiPost,
|
||
|
|
apiUpload,
|
||
|
|
apiDownloadBlob,
|
||
|
|
setToken,
|
||
|
|
clearToken,
|
||
|
|
getDownloadUrl,
|
||
|
|
apiPut,
|
||
|
|
apiDelete,
|
||
|
|
} from "@/lib/api";
|
||
|
|
|
||
|
|
// ---------------------------------------------------------------------------
|
||
|
|
// 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.resolve({ error: "bad" }),
|
||
|
|
blob: () => Promise.resolve(new Blob()),
|
||
|
|
} as unknown as Response);
|
||
|
|
}
|
||
|
|
|
||
|
|
// ==========================================================================
|
||
|
|
// FileStore
|
||
|
|
// ==========================================================================
|
||
|
|
|
||
|
|
describe("FileStore", () => {
|
||
|
|
beforeEach(() => {
|
||
|
|
// Reset the store to initial state before every test.
|
||
|
|
// Zustand keeps state across calls, so we manually reset.
|
||
|
|
useFileStore.getState().reset();
|
||
|
|
vi.clearAllMocks();
|
||
|
|
// After reset, createObjectURL/revokeObjectURL calls are from reset itself;
|
||
|
|
// clear them so each test starts clean.
|
||
|
|
createObjectURL.mockClear();
|
||
|
|
revokeObjectURL.mockClear();
|
||
|
|
});
|
||
|
|
|
||
|
|
// -- Initial state -------------------------------------------------------
|
||
|
|
|
||
|
|
it("has correct initial state (everything null/empty/false)", () => {
|
||
|
|
const s = useFileStore.getState();
|
||
|
|
expect(s.files).toEqual([]);
|
||
|
|
expect(s.jobId).toBeNull();
|
||
|
|
expect(s.processedUrl).toBeNull();
|
||
|
|
expect(s.originalBlobUrl).toBeNull();
|
||
|
|
expect(s.processing).toBe(false);
|
||
|
|
expect(s.error).toBeNull();
|
||
|
|
expect(s.originalSize).toBeNull();
|
||
|
|
expect(s.processedSize).toBeNull();
|
||
|
|
expect(s.selectedFileName).toBeNull();
|
||
|
|
expect(s.selectedFileSize).toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
// -- setFiles -------------------------------------------------------------
|
||
|
|
|
||
|
|
it("setFiles stores files, creates blob URL, sets selectedFileName/Size, clears error", () => {
|
||
|
|
// Seed an error first so we can verify it gets cleared
|
||
|
|
useFileStore.getState().setError("old error");
|
||
|
|
expect(useFileStore.getState().error).toBe("old error");
|
||
|
|
|
||
|
|
const file = makeFile("photo.png", 2048);
|
||
|
|
useFileStore.getState().setFiles([file]);
|
||
|
|
|
||
|
|
const s = useFileStore.getState();
|
||
|
|
expect(s.files).toHaveLength(1);
|
||
|
|
expect(s.files[0]).toBe(file);
|
||
|
|
expect(createObjectURL).toHaveBeenCalledWith(file);
|
||
|
|
expect(s.originalBlobUrl).toBe("blob:fake-url");
|
||
|
|
expect(s.selectedFileName).toBe("photo.png");
|
||
|
|
expect(s.selectedFileSize).toBe(2048);
|
||
|
|
expect(s.error).toBeNull(); // error cleared
|
||
|
|
});
|
||
|
|
|
||
|
|
it("setFiles revokes the previous blob URL when new files are set", () => {
|
||
|
|
createObjectURL
|
||
|
|
.mockReturnValueOnce("blob:first-url")
|
||
|
|
.mockReturnValueOnce("blob:second-url");
|
||
|
|
|
||
|
|
useFileStore.getState().setFiles([makeFile("a.png")]);
|
||
|
|
expect(useFileStore.getState().originalBlobUrl).toBe("blob:first-url");
|
||
|
|
|
||
|
|
useFileStore.getState().setFiles([makeFile("b.png")]);
|
||
|
|
expect(revokeObjectURL).toHaveBeenCalledWith("blob:first-url");
|
||
|
|
expect(useFileStore.getState().originalBlobUrl).toBe("blob:second-url");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("setFiles with empty array does NOT create a blob URL", () => {
|
||
|
|
useFileStore.getState().setFiles([]);
|
||
|
|
|
||
|
|
const s = useFileStore.getState();
|
||
|
|
expect(createObjectURL).not.toHaveBeenCalled();
|
||
|
|
expect(s.originalBlobUrl).toBeNull();
|
||
|
|
expect(s.selectedFileName).toBeNull();
|
||
|
|
expect(s.selectedFileSize).toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("setFiles with empty array after prior files still revokes old URL", () => {
|
||
|
|
createObjectURL.mockReturnValueOnce("blob:old");
|
||
|
|
useFileStore.getState().setFiles([makeFile("old.png")]);
|
||
|
|
revokeObjectURL.mockClear();
|
||
|
|
|
||
|
|
useFileStore.getState().setFiles([]);
|
||
|
|
expect(revokeObjectURL).toHaveBeenCalledWith("blob:old");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("setFiles uses the FIRST file for blob URL when given multiple files", () => {
|
||
|
|
const f1 = makeFile("first.png", 100);
|
||
|
|
const f2 = makeFile("second.png", 200);
|
||
|
|
useFileStore.getState().setFiles([f1, f2]);
|
||
|
|
|
||
|
|
// createObjectURL is called exactly once (only for the first file)
|
||
|
|
expect(createObjectURL).toHaveBeenCalledTimes(1);
|
||
|
|
// Verify the argument was f1 by identity (same reference)
|
||
|
|
expect(createObjectURL.mock.calls[0][0]).toBe(f1);
|
||
|
|
expect(useFileStore.getState().selectedFileName).toBe("first.png");
|
||
|
|
expect(useFileStore.getState().selectedFileSize).toBe(100);
|
||
|
|
});
|
||
|
|
|
||
|
|
// -- setJobId -------------------------------------------------------------
|
||
|
|
|
||
|
|
it("setJobId stores the job ID", () => {
|
||
|
|
useFileStore.getState().setJobId("job-abc");
|
||
|
|
expect(useFileStore.getState().jobId).toBe("job-abc");
|
||
|
|
});
|
||
|
|
|
||
|
|
// -- setProcessedUrl ------------------------------------------------------
|
||
|
|
|
||
|
|
it("setProcessedUrl stores a URL", () => {
|
||
|
|
useFileStore.getState().setProcessedUrl("blob:processed");
|
||
|
|
expect(useFileStore.getState().processedUrl).toBe("blob:processed");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("setProcessedUrl can clear URL with null", () => {
|
||
|
|
useFileStore.getState().setProcessedUrl("blob:x");
|
||
|
|
useFileStore.getState().setProcessedUrl(null);
|
||
|
|
expect(useFileStore.getState().processedUrl).toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
// -- setProcessing --------------------------------------------------------
|
||
|
|
|
||
|
|
it("setProcessing sets the processing flag", () => {
|
||
|
|
useFileStore.getState().setProcessing(true);
|
||
|
|
expect(useFileStore.getState().processing).toBe(true);
|
||
|
|
useFileStore.getState().setProcessing(false);
|
||
|
|
expect(useFileStore.getState().processing).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
// -- setError -------------------------------------------------------------
|
||
|
|
|
||
|
|
it("setError sets error AND forces processing to false", () => {
|
||
|
|
useFileStore.getState().setProcessing(true);
|
||
|
|
expect(useFileStore.getState().processing).toBe(true);
|
||
|
|
|
||
|
|
useFileStore.getState().setError("something broke");
|
||
|
|
const s = useFileStore.getState();
|
||
|
|
expect(s.error).toBe("something broke");
|
||
|
|
expect(s.processing).toBe(false); // critical side-effect
|
||
|
|
});
|
||
|
|
|
||
|
|
it("setError(null) clears error but still forces processing to false", () => {
|
||
|
|
useFileStore.getState().setProcessing(true);
|
||
|
|
useFileStore.getState().setError(null);
|
||
|
|
expect(useFileStore.getState().error).toBeNull();
|
||
|
|
expect(useFileStore.getState().processing).toBe(false);
|
||
|
|
});
|
||
|
|
|
||
|
|
// -- setSizes -------------------------------------------------------------
|
||
|
|
|
||
|
|
it("setSizes sets both originalSize and processedSize", () => {
|
||
|
|
useFileStore.getState().setSizes(5000, 2500);
|
||
|
|
const s = useFileStore.getState();
|
||
|
|
expect(s.originalSize).toBe(5000);
|
||
|
|
expect(s.processedSize).toBe(2500);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("setSizes with zero values stores zeros (not null)", () => {
|
||
|
|
useFileStore.getState().setSizes(0, 0);
|
||
|
|
expect(useFileStore.getState().originalSize).toBe(0);
|
||
|
|
expect(useFileStore.getState().processedSize).toBe(0);
|
||
|
|
});
|
||
|
|
|
||
|
|
// -- undoProcessing -------------------------------------------------------
|
||
|
|
|
||
|
|
it("undoProcessing clears processedUrl, jobId, processedSize, error but KEEPS files and originalBlobUrl", () => {
|
||
|
|
createObjectURL.mockReturnValueOnce("blob:orig");
|
||
|
|
|
||
|
|
// Set up full state
|
||
|
|
const file = makeFile("keep-me.png", 3000);
|
||
|
|
useFileStore.getState().setFiles([file]);
|
||
|
|
useFileStore.getState().setJobId("job-1");
|
||
|
|
useFileStore.getState().setProcessedUrl("blob:result");
|
||
|
|
useFileStore.getState().setSizes(3000, 1500);
|
||
|
|
useFileStore.getState().setError("transient error");
|
||
|
|
|
||
|
|
useFileStore.getState().undoProcessing();
|
||
|
|
|
||
|
|
const s = useFileStore.getState();
|
||
|
|
// Cleared
|
||
|
|
expect(s.processedUrl).toBeNull();
|
||
|
|
expect(s.jobId).toBeNull();
|
||
|
|
expect(s.processedSize).toBeNull();
|
||
|
|
expect(s.error).toBeNull();
|
||
|
|
// Preserved
|
||
|
|
expect(s.files).toHaveLength(1);
|
||
|
|
expect(s.files[0]).toBe(file);
|
||
|
|
expect(s.originalBlobUrl).toBe("blob:orig");
|
||
|
|
expect(s.selectedFileName).toBe("keep-me.png");
|
||
|
|
expect(s.selectedFileSize).toBe(3000);
|
||
|
|
// originalSize is NOT cleared by undoProcessing (only processedSize is)
|
||
|
|
expect(s.originalSize).toBe(3000);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("undoProcessing does NOT revoke the originalBlobUrl", () => {
|
||
|
|
createObjectURL.mockReturnValueOnce("blob:keep-alive");
|
||
|
|
useFileStore.getState().setFiles([makeFile("x.png")]);
|
||
|
|
revokeObjectURL.mockClear();
|
||
|
|
|
||
|
|
useFileStore.getState().undoProcessing();
|
||
|
|
expect(revokeObjectURL).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
// -- reset ----------------------------------------------------------------
|
||
|
|
|
||
|
|
it("reset clears everything and revokes the blob URL", () => {
|
||
|
|
createObjectURL.mockReturnValueOnce("blob:to-revoke");
|
||
|
|
useFileStore.getState().setFiles([makeFile("doomed.png")]);
|
||
|
|
useFileStore.getState().setJobId("job-x");
|
||
|
|
useFileStore.getState().setProcessedUrl("blob:proc");
|
||
|
|
useFileStore.getState().setProcessing(true);
|
||
|
|
useFileStore.getState().setError("oops");
|
||
|
|
useFileStore.getState().setSizes(100, 50);
|
||
|
|
revokeObjectURL.mockClear();
|
||
|
|
|
||
|
|
useFileStore.getState().reset();
|
||
|
|
|
||
|
|
expect(revokeObjectURL).toHaveBeenCalledWith("blob:to-revoke");
|
||
|
|
|
||
|
|
const s = useFileStore.getState();
|
||
|
|
expect(s.files).toEqual([]);
|
||
|
|
expect(s.jobId).toBeNull();
|
||
|
|
expect(s.processedUrl).toBeNull();
|
||
|
|
expect(s.originalBlobUrl).toBeNull();
|
||
|
|
expect(s.processing).toBe(false);
|
||
|
|
expect(s.error).toBeNull();
|
||
|
|
expect(s.originalSize).toBeNull();
|
||
|
|
expect(s.processedSize).toBeNull();
|
||
|
|
expect(s.selectedFileName).toBeNull();
|
||
|
|
expect(s.selectedFileSize).toBeNull();
|
||
|
|
});
|
||
|
|
|
||
|
|
it("reset when originalBlobUrl is already null does NOT call revokeObjectURL", () => {
|
||
|
|
// Start from a clean state (no files set)
|
||
|
|
revokeObjectURL.mockClear();
|
||
|
|
useFileStore.getState().reset();
|
||
|
|
expect(revokeObjectURL).not.toHaveBeenCalled();
|
||
|
|
});
|
||
|
|
|
||
|
|
// -- State transition sequences -------------------------------------------
|
||
|
|
|
||
|
|
it("setFiles -> setProcessing(true) -> setError -> processing is false", () => {
|
||
|
|
useFileStore.getState().setFiles([makeFile("t.png")]);
|
||
|
|
useFileStore.getState().setProcessing(true);
|
||
|
|
expect(useFileStore.getState().processing).toBe(true);
|
||
|
|
|
||
|
|
useFileStore.getState().setError("fail");
|
||
|
|
expect(useFileStore.getState().processing).toBe(false);
|
||
|
|
expect(useFileStore.getState().error).toBe("fail");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("setFiles -> setProcessing(true) -> setProcessedUrl -> setProcessing(false) (happy path)", () => {
|
||
|
|
useFileStore.getState().setFiles([makeFile("t.png")]);
|
||
|
|
useFileStore.getState().setProcessing(true);
|
||
|
|
expect(useFileStore.getState().processing).toBe(true);
|
||
|
|
|
||
|
|
useFileStore.getState().setProcessedUrl("blob:done");
|
||
|
|
// processedUrl does NOT auto-clear processing
|
||
|
|
expect(useFileStore.getState().processing).toBe(true);
|
||
|
|
|
||
|
|
useFileStore.getState().setProcessing(false);
|
||
|
|
expect(useFileStore.getState().processing).toBe(false);
|
||
|
|
expect(useFileStore.getState().processedUrl).toBe("blob:done");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("rapid setFiles calls only keep the latest state and revoke each prior URL", () => {
|
||
|
|
createObjectURL
|
||
|
|
.mockReturnValueOnce("blob:1")
|
||
|
|
.mockReturnValueOnce("blob:2")
|
||
|
|
.mockReturnValueOnce("blob:3");
|
||
|
|
|
||
|
|
useFileStore.getState().setFiles([makeFile("a.png")]);
|
||
|
|
useFileStore.getState().setFiles([makeFile("b.png")]);
|
||
|
|
useFileStore.getState().setFiles([makeFile("c.png")]);
|
||
|
|
|
||
|
|
expect(revokeObjectURL).toHaveBeenCalledWith("blob:1");
|
||
|
|
expect(revokeObjectURL).toHaveBeenCalledWith("blob:2");
|
||
|
|
expect(revokeObjectURL).toHaveBeenCalledTimes(2);
|
||
|
|
expect(useFileStore.getState().originalBlobUrl).toBe("blob:3");
|
||
|
|
expect(useFileStore.getState().selectedFileName).toBe("c.png");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("setError during processing, then undoProcessing, then retry cycle works", () => {
|
||
|
|
useFileStore.getState().setFiles([makeFile("retry.png")]);
|
||
|
|
useFileStore.getState().setProcessing(true);
|
||
|
|
useFileStore.getState().setError("timeout");
|
||
|
|
expect(useFileStore.getState().processing).toBe(false);
|
||
|
|
|
||
|
|
useFileStore.getState().undoProcessing();
|
||
|
|
expect(useFileStore.getState().error).toBeNull();
|
||
|
|
expect(useFileStore.getState().files).toHaveLength(1);
|
||
|
|
|
||
|
|
// Retry
|
||
|
|
useFileStore.getState().setProcessing(true);
|
||
|
|
expect(useFileStore.getState().processing).toBe(true);
|
||
|
|
useFileStore.getState().setProcessedUrl("blob:retry-ok");
|
||
|
|
useFileStore.getState().setProcessing(false);
|
||
|
|
expect(useFileStore.getState().processedUrl).toBe("blob:retry-ok");
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// ==========================================================================
|
||
|
|
// 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 'stirling-token'", () => {
|
||
|
|
setToken("my-secret");
|
||
|
|
expect(localStorageMock.setItem).toHaveBeenCalledWith(
|
||
|
|
"stirling-token",
|
||
|
|
"my-secret",
|
||
|
|
);
|
||
|
|
expect(storageMap.get("stirling-token")).toBe("my-secret");
|
||
|
|
});
|
||
|
|
|
||
|
|
it("clearToken removes 'stirling-token' from localStorage", () => {
|
||
|
|
setToken("to-remove");
|
||
|
|
clearToken();
|
||
|
|
expect(localStorageMock.removeItem).toHaveBeenCalledWith(
|
||
|
|
"stirling-token",
|
||
|
|
);
|
||
|
|
expect(storageMap.has("stirling-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");
|
||
|
|
|
||
|
|
expect(fetchMock).toHaveBeenCalledWith("/api/v1/health", {
|
||
|
|
headers: { Authorization: "Bearer tok-123" },
|
||
|
|
});
|
||
|
|
expect(result).toEqual({ data: "ok" });
|
||
|
|
});
|
||
|
|
|
||
|
|
it("sends empty Bearer when no token is set", async () => {
|
||
|
|
fetchMock.mockReturnValueOnce(okJson({}));
|
||
|
|
await apiGet("/v1/anything");
|
||
|
|
|
||
|
|
const callArgs = fetchMock.mock.calls[0];
|
||
|
|
expect(callArgs[1].headers.Authorization).toBe("Bearer ");
|
||
|
|
});
|
||
|
|
|
||
|
|
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["Content-Type"]).toBe("application/json");
|
||
|
|
expect(opts.headers.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["Content-Type"]).toBe("application/json");
|
||
|
|
expect(opts.headers.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.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.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["Content-Type"]).toBeUndefined();
|
||
|
|
});
|
||
|
|
|
||
|
|
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.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",
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
|
||
|
|
// -- 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.Authorization).toBe(
|
||
|
|
"Bearer first-token",
|
||
|
|
);
|
||
|
|
|
||
|
|
setToken("second-token");
|
||
|
|
fetchMock.mockReturnValueOnce(okJson({}));
|
||
|
|
await apiGet("/v1/b");
|
||
|
|
expect(fetchMock.mock.calls[1][1].headers.Authorization).toBe(
|
||
|
|
"Bearer second-token",
|
||
|
|
);
|
||
|
|
});
|
||
|
|
|
||
|
|
it("uses empty Bearer immediately after clearToken", async () => {
|
||
|
|
setToken("about-to-die");
|
||
|
|
clearToken();
|
||
|
|
fetchMock.mockReturnValueOnce(okJson({}));
|
||
|
|
await apiGet("/v1/c");
|
||
|
|
expect(fetchMock.mock.calls[0][1].headers.Authorization).toBe(
|
||
|
|
"Bearer ",
|
||
|
|
);
|
||
|
|
});
|
||
|
|
});
|
||
|
|
});
|