Files
SnapOtter/tests/unit/web/api-extended.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

414 lines
14 KiB
TypeScript

// @vitest-environment jsdom
import { beforeEach, describe, expect, it, vi } from "vitest";
// Global mocks
const fetchMock = vi.fn();
vi.stubGlobal("fetch", fetchMock);
const storageMap = new Map<string, string>();
vi.stubGlobal("localStorage", {
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),
});
import {
apiDeleteUserFiles,
apiListFiles,
apiUploadUserFiles,
formatHeaders,
getDownloadUrl,
getFileDownloadUrl,
getFileThumbnailUrl,
parseApiError,
} from "@/lib/api";
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 _failJson(status: number, body: Record<string, unknown>) {
return Promise.resolve({
ok: false,
status,
json: () => Promise.resolve(body),
} as unknown as Response);
}
// ==========================================================================
// parseApiError
// ==========================================================================
describe("parseApiError", () => {
it("returns FeatureNotInstalledError for FEATURE_NOT_INSTALLED code", () => {
const result = parseApiError(
{
code: "FEATURE_NOT_INSTALLED",
feature: "ai-rembg",
featureName: "AI Background Remover",
estimatedSize: "500MB",
},
500,
);
expect(typeof result).toBe("object");
if (typeof result === "object") {
expect(result.type).toBe("feature_not_installed");
expect(result.feature).toBe("ai-rembg");
expect(result.featureName).toBe("AI Background Remover");
expect(result.estimatedSize).toBe("500MB");
}
});
it("returns error string when error field is present", () => {
const result = parseApiError({ error: "Something broke" }, 500);
expect(result).toBe("Something broke");
});
it("returns message field when error field is missing", () => {
const result = parseApiError({ message: "Not found" }, 404);
expect(result).toBe("Not found");
});
it("returns fallback message when no error or message", () => {
const result = parseApiError({}, 422);
expect(result).toBe("Processing failed: 422");
});
it("returns string details appended to error", () => {
const result = parseApiError({ error: "Validation", details: "field X is required" }, 400);
expect(result).toBe("Validation: field X is required");
});
it("returns array details joined with semicolons", () => {
const result = parseApiError({ error: "Errors", details: ["field A", "field B"] }, 400);
expect(result).toBe("Errors: field A; field B");
});
it("returns array of objects details with message property", () => {
const result = parseApiError(
{ error: "Validation", details: [{ message: "too short" }, { message: "too long" }] },
400,
);
expect(result).toBe("Validation: too short; too long");
});
it("returns JSON-stringified details for object arrays without message", () => {
const result = parseApiError({ error: "Err", details: [{ code: 1 }] }, 400);
expect(result).toContain('{"code":1}');
});
it("returns JSON-stringified object details", () => {
const result = parseApiError({ error: "Err", details: { nested: "value" } }, 400);
expect(result).toBe('Err: {"nested":"value"}');
});
it("returns details only when error is not a string", () => {
const result = parseApiError({ error: 123, details: "some detail" }, 400);
expect(result).toBe("some detail");
});
});
// ==========================================================================
// formatHeaders
// ==========================================================================
describe("formatHeaders", () => {
beforeEach(() => {
storageMap.clear();
});
it("includes Authorization header when token is set", () => {
storageMap.set("snapotter-token", "my-token");
const headers = formatHeaders();
expect(headers.get("Authorization")).toBe("Bearer my-token");
});
it("omits Authorization header when no token", () => {
const headers = formatHeaders();
expect(headers.get("Authorization")).toBeNull();
});
it("includes analytics consent header when no token and consent is set to true", () => {
storageMap.set("snapotter-analytics-consent", "true");
const headers = formatHeaders();
expect(headers.get("X-Analytics-Consent")).toBe("true");
});
it("includes analytics consent header when consent is false", () => {
storageMap.set("snapotter-analytics-consent", "false");
const headers = formatHeaders();
expect(headers.get("X-Analytics-Consent")).toBe("false");
});
it("does not include analytics consent header when token exists", () => {
storageMap.set("snapotter-token", "tok");
storageMap.set("snapotter-analytics-consent", "true");
const headers = formatHeaders();
expect(headers.get("X-Analytics-Consent")).toBeNull();
});
it("does not include analytics consent header when consent is not true/false", () => {
storageMap.set("snapotter-analytics-consent", "remind");
const headers = formatHeaders();
expect(headers.get("X-Analytics-Consent")).toBeNull();
});
it("merges provided HeadersInit with auth headers", () => {
storageMap.set("snapotter-token", "tok");
const headers = formatHeaders({ "Content-Type": "application/json" });
expect(headers.get("Content-Type")).toBe("application/json");
expect(headers.get("Authorization")).toBe("Bearer tok");
});
});
// ==========================================================================
// apiListFiles
// ==========================================================================
describe("apiListFiles", () => {
beforeEach(() => {
fetchMock.mockReset();
storageMap.clear();
});
it("calls correct URL without params", async () => {
fetchMock.mockReturnValueOnce(okJson({ files: [], total: 0 }));
await apiListFiles();
expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/files");
});
it("includes search param when provided", async () => {
fetchMock.mockReturnValueOnce(okJson({ files: [], total: 0 }));
await apiListFiles({ search: "sunset" });
expect(fetchMock.mock.calls[0][0]).toContain("search=sunset");
});
it("includes limit param when provided", async () => {
fetchMock.mockReturnValueOnce(okJson({ files: [], total: 0 }));
await apiListFiles({ limit: 50 });
expect(fetchMock.mock.calls[0][0]).toContain("limit=50");
});
it("includes offset param when provided", async () => {
fetchMock.mockReturnValueOnce(okJson({ files: [], total: 0 }));
await apiListFiles({ offset: 10 });
expect(fetchMock.mock.calls[0][0]).toContain("offset=10");
});
it("includes all params when provided", async () => {
fetchMock.mockReturnValueOnce(okJson({ files: [], total: 0 }));
await apiListFiles({ search: "test", limit: 10, offset: 5 });
const url = fetchMock.mock.calls[0][0] as string;
expect(url).toContain("search=test");
expect(url).toContain("limit=10");
expect(url).toContain("offset=5");
});
});
// ==========================================================================
// apiUploadUserFiles
// ==========================================================================
describe("apiUploadUserFiles", () => {
beforeEach(() => {
fetchMock.mockReset();
storageMap.clear();
});
it("sends files as FormData to upload endpoint", async () => {
const file = new File(["content"], "img.png", { type: "image/png" });
fetchMock.mockReturnValueOnce(
okJson({ files: [{ id: "1", originalName: "img.png", size: 7, version: 1 }] }),
);
const result = await apiUploadUserFiles([file]);
expect(fetchMock.mock.calls[0][0]).toBe("/api/v1/files/upload");
expect(fetchMock.mock.calls[0][1].method).toBe("POST");
expect(result.files).toHaveLength(1);
});
it("throws on non-ok response", async () => {
const file = new File(["content"], "img.png", { type: "image/png" });
fetchMock.mockReturnValueOnce(
Promise.resolve({ ok: false, status: 413, json: () => Promise.reject(new Error("no")) }),
);
await expect(apiUploadUserFiles([file])).rejects.toThrow("Upload failed: 413");
});
it("triggers disconnected on TypeError", async () => {
const file = new File(["content"], "img.png", { type: "image/png" });
fetchMock.mockRejectedValueOnce(new TypeError("Failed to fetch"));
await expect(apiUploadUserFiles([file])).rejects.toThrow("Failed to fetch");
});
});
// ==========================================================================
// apiDeleteUserFiles
// ==========================================================================
describe("apiDeleteUserFiles", () => {
beforeEach(() => {
fetchMock.mockReset();
storageMap.clear();
});
it("sends DELETE with JSON body of ids", async () => {
fetchMock.mockReturnValueOnce(okJson({ deleted: 2 }));
const result = await apiDeleteUserFiles(["id1", "id2"]);
const [url, opts] = fetchMock.mock.calls[0];
expect(url).toBe("/api/v1/files");
expect(opts.method).toBe("DELETE");
expect(JSON.parse(opts.body)).toEqual({ ids: ["id1", "id2"] });
expect(result.deleted).toBe(2);
});
it("throws on non-ok response", async () => {
fetchMock.mockReturnValueOnce(
Promise.resolve({ ok: false, status: 403, json: () => Promise.reject(new Error("no")) }),
);
await expect(apiDeleteUserFiles(["id1"])).rejects.toThrow("Delete failed: 403");
});
it("triggers disconnected on TypeError", async () => {
fetchMock.mockRejectedValueOnce(new TypeError("Failed to fetch"));
await expect(apiDeleteUserFiles(["id1"])).rejects.toThrow("Failed to fetch");
});
});
// ==========================================================================
// URL helpers
// ==========================================================================
describe("URL helpers", () => {
it("getDownloadUrl builds correct URL", () => {
expect(getDownloadUrl("job-1", "output.png")).toBe("/api/v1/download/job-1/output.png");
});
it("getFileThumbnailUrl builds correct URL", () => {
expect(getFileThumbnailUrl("file-abc")).toBe("/api/v1/files/file-abc/thumbnail");
});
it("getFileDownloadUrl builds correct URL", () => {
expect(getFileDownloadUrl("file-xyz")).toBe("/api/v1/files/file-xyz/download");
});
});
// ==========================================================================
// throwWithMessage (tested through api methods)
// ==========================================================================
describe("throwWithMessage error extraction", () => {
beforeEach(() => {
fetchMock.mockReset();
storageMap.clear();
});
it("extracts error field from response JSON", async () => {
const { apiGet } = await import("@/lib/api");
fetchMock.mockReturnValueOnce(
Promise.resolve({
ok: false,
status: 400,
json: () => Promise.resolve({ error: "Bad request" }),
}),
);
await expect(apiGet("/v1/test")).rejects.toThrow("Bad request");
});
it("extracts message field when error is absent", async () => {
const { apiGet } = await import("@/lib/api");
fetchMock.mockReturnValueOnce(
Promise.resolve({
ok: false,
status: 404,
json: () => Promise.resolve({ message: "Not found" }),
}),
);
await expect(apiGet("/v1/test")).rejects.toThrow("Not found");
});
it("falls back to status code when JSON parsing fails", async () => {
const { apiGet } = await import("@/lib/api");
fetchMock.mockReturnValueOnce(
Promise.resolve({
ok: false,
status: 502,
json: () => Promise.reject(new Error("not json")),
}),
);
await expect(apiGet("/v1/test")).rejects.toThrow("API error: 502");
});
});
// ==========================================================================
// apiGetFileDetails
// ==========================================================================
describe("apiGetFileDetails", () => {
beforeEach(() => {
fetchMock.mockReset();
storageMap.clear();
});
it("returns merged file and versions", async () => {
const { apiGetFileDetails } = await import("@/lib/api");
fetchMock.mockReturnValueOnce(
okJson({
file: {
id: "f1",
originalName: "photo.jpg",
size: 2048,
mimeType: "image/jpeg",
createdAt: "2025-01-01",
},
versions: [{ version: 1, size: 2048 }],
}),
);
const result = await apiGetFileDetails("f1");
expect(result.id).toBe("f1");
expect(result.originalName).toBe("photo.jpg");
expect(result.versions).toHaveLength(1);
});
});
// ==========================================================================
// apiDownloadBlob network error coverage
// ==========================================================================
describe("apiDownloadBlob network errors", () => {
beforeEach(() => {
fetchMock.mockReset();
storageMap.clear();
});
it("triggers disconnected on TypeError from fetch", async () => {
const { apiDownloadBlob } = await import("@/lib/api");
const { useConnectionStore } = await import("@/stores/connection-store");
useConnectionStore.setState({
status: "connected",
failedSince: null,
lastHealthCheck: null,
});
fetchMock.mockRejectedValueOnce(new TypeError("Failed to fetch"));
await expect(apiDownloadBlob("job-x", "file.png")).rejects.toThrow("Failed to fetch");
expect(useConnectionStore.getState().status).toBe("disconnected");
});
it("does NOT trigger disconnected on non-TypeError", async () => {
const { apiDownloadBlob } = await import("@/lib/api");
const { useConnectionStore } = await import("@/stores/connection-store");
useConnectionStore.setState({
status: "connected",
failedSince: null,
lastHealthCheck: null,
});
fetchMock.mockRejectedValueOnce(new Error("Some other error"));
await expect(apiDownloadBlob("job-x", "file.png")).rejects.toThrow("Some other error");
expect(useConnectionStore.getState().status).toBe("connected");
});
});