import { ApiError, deleteGroup, getOverview, getQueries, listGroups, login, putGroupSources } from "@/lib/api"; function jsonResponse(payload: unknown, status = 200, headers: Record = {}): Response { return new Response(JSON.stringify(payload), { status, headers: { "content-type": "application/json", ...headers }, }); } const fetchMock = vi.fn(); beforeEach(() => { fetchMock.mockReset(); vi.stubGlobal("fetch", fetchMock); }); afterEach(() => { vi.unstubAllGlobals(); }); test("sends same-origin credentials and parses JSON", async () => { fetchMock.mockResolvedValue(jsonResponse({ authenticated: true, auth_required: true })); const response = await login({ password: "hunter2" }); expect(response.auth_required).toBe(true); const [url, init] = fetchMock.mock.calls[0]!; expect(url).toBe("/api/auth/login"); expect(init?.credentials).toBe("same-origin"); expect(init?.method).toBe("POST"); expect(init?.body).toBe(JSON.stringify({ password: "hunter2" })); const headers = (init?.headers ?? {}) as Record; expect(headers["content-type"]).toBe("application/json"); }); test("throws ApiError with the {error} envelope message", async () => { fetchMock.mockResolvedValue(jsonResponse({ error: "duplicate group name" }, 409)); const failure = await listGroups().catch((e: unknown) => e); expect(failure).toBeInstanceOf(ApiError); expect((failure as ApiError).status).toBe(409); expect((failure as ApiError).message).toBe("duplicate group name"); expect((failure as ApiError).retryAfter).toBeUndefined(); }); test("falls back to a status message on a non-JSON error body", async () => { fetchMock.mockResolvedValue(new Response("bad gateway", { status: 502 })); const failure = await listGroups().catch((e: unknown) => e); expect(failure).toBeInstanceOf(ApiError); expect((failure as ApiError).message).toBe("HTTP 502"); }); test("parses Retry-After on 429", async () => { fetchMock.mockResolvedValue(jsonResponse({ error: "rate limited" }, 429, { "Retry-After": "17" })); const failure = await getOverview("1h").catch((e: unknown) => e); expect(failure).toBeInstanceOf(ApiError); expect((failure as ApiError).status).toBe(429); expect((failure as ApiError).retryAfter).toBe(17); }); test("ignores a malformed Retry-After header", async () => { fetchMock.mockResolvedValue(jsonResponse({ error: "rate limited" }, 429, { "Retry-After": "soon" })); const failure = await getOverview().catch((e: unknown) => e); expect((failure as ApiError).retryAfter).toBeUndefined(); }); test("resolves void on 204", async () => { fetchMock.mockResolvedValue(new Response(null, { status: 204 })); await expect(deleteGroup(3)).resolves.toBeUndefined(); expect(fetchMock.mock.calls[0]![0]).toBe("/api/groups/3"); expect(fetchMock.mock.calls[0]![1]?.method).toBe("DELETE"); }); test("unwraps list envelopes", async () => { fetchMock.mockResolvedValue(jsonResponse({ groups: [{ id: 1, name: "default", safe_search: false }] })); const groups = await listGroups(); expect(groups).toEqual([{ id: 1, name: "default", safe_search: false }]); }); test("serializes query filters, omitting undefined", async () => { fetchMock.mockResolvedValue(jsonResponse({ queries: [], next_before: null })); await getQueries({ limit: 50, blocked: true, domain: "ads.example", before: undefined }); expect(fetchMock.mock.calls[0]![0]).toBe("/api/queries?limit=50&blocked=true&domain=ads.example"); }); test("requests with no filters carry no query string", async () => { fetchMock.mockResolvedValue(jsonResponse({ queries: [], next_before: null })); await getQueries(); expect(fetchMock.mock.calls[0]![0]).toBe("/api/queries"); }); test("wraps and unwraps group sources", async () => { fetchMock.mockResolvedValue(jsonResponse({ source_ids: [2, 5] })); const stored = await putGroupSources(4, [5, 2]); expect(stored).toEqual([2, 5]); expect(fetchMock.mock.calls[0]![0]).toBe("/api/groups/4/sources"); expect(fetchMock.mock.calls[0]![1]?.body).toBe(JSON.stringify({ source_ids: [5, 2] })); });