/* @vitest-environment node */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { apiFetch, ApiClientError, getFiles, login, getMe, } from "./apiClient.js"; beforeEach(() => { // Restore mocks between tests to avoid cross-test pollution. vi.restoreAllMocks(); // In these unit tests we stub the global fetch implementation. // This allows us to validate: // - request defaults (credentials/cache) // - URL building // - error mapping global.fetch = vi.fn(); }); describe("lib/frontend/apiClient", () => { it("apiFetch uses credentials=include and cache=no-store", async () => { fetch.mockResolvedValue( new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" }, }) ); await apiFetch("/api/health"); expect(fetch).toHaveBeenCalledTimes(1); const [url, init] = fetch.mock.calls[0]; expect(url).toBe("/api/health"); expect(init.credentials).toBe("include"); expect(init.cache).toBe("no-store"); }); it("apiFetch serializes JSON bodies and sets Content-Type", async () => { fetch.mockResolvedValue( new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" }, }) ); await login({ username: "u", password: "p" }); const [, init] = fetch.mock.calls[0]; expect(init.method).toBe("POST"); expect(init.headers.Accept).toBe("application/json"); expect(init.headers["Content-Type"]).toBe("application/json"); expect(init.body).toBe(JSON.stringify({ username: "u", password: "p" })); }); it("apiFetch throws ApiClientError for standardized backend error payloads", async () => { fetch.mockResolvedValue( new Response( JSON.stringify({ error: { message: "Unauthorized", code: "AUTH_UNAUTHENTICATED" }, }), { status: 401, headers: { "Content-Type": "application/json" } } ) ); await expect(apiFetch("/api/branches")).rejects.toMatchObject({ name: "ApiClientError", status: 401, code: "AUTH_UNAUTHENTICATED", message: "Unauthorized", }); }); it("apiFetch maps network failures to CLIENT_NETWORK_ERROR", async () => { fetch.mockRejectedValue(new Error("connection refused")); try { await apiFetch("/api/branches"); throw new Error("Expected apiFetch to throw"); } catch (err) { expect(err).toBeInstanceOf(ApiClientError); expect(err.code).toBe("CLIENT_NETWORK_ERROR"); expect(err.status).toBe(0); } }); it("getFiles builds the expected query string", async () => { fetch.mockResolvedValue( new Response( JSON.stringify({ branch: "NL01", year: "2024", month: "10", day: "23", files: [], }), { status: 200, headers: { "Content-Type": "application/json" } } ) ); await getFiles("NL01", "2024", "10", "23"); const [url] = fetch.mock.calls[0]; // We do not rely on param ordering beyond URLSearchParams defaults. expect(url).toContain("/api/files?"); expect(url).toContain("branch=NL01"); expect(url).toContain("year=2024"); expect(url).toContain("month=10"); expect(url).toContain("day=23"); }); it("getMe calls /api/auth/me and returns the parsed payload", async () => { // /api/auth/me returns 200 with { user: null } or { user: {...} }. // For this unit test we only need to ensure: // - correct endpoint // - correct method // - response is returned as parsed JSON fetch.mockResolvedValue( new Response(JSON.stringify({ user: null }), { status: 200, headers: { "Content-Type": "application/json" }, }) ); const res = await getMe(); expect(res).toEqual({ user: null }); expect(fetch).toHaveBeenCalledTimes(1); const [url, init] = fetch.mock.calls[0]; expect(url).toBe("/api/auth/me"); expect(init.method).toBe("GET"); // Ensure our global defaults are still enforced for session-based calls. expect(init.credentials).toBe("include"); expect(init.cache).toBe("no-store"); }); });