/* @vitest-environment node */ import { describe, it, expect, vi, beforeEach } from "vitest"; import { apiFetch, ApiClientError, getFiles, login, getMe, search, changePassword, } 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"); }); it("changePassword calls /api/auth/change-password with POST and JSON body", async () => { fetch.mockResolvedValue( new Response(JSON.stringify({ ok: true }), { status: 200, headers: { "Content-Type": "application/json" }, }), ); await changePassword({ currentPassword: "OldPassword123", newPassword: "StrongPassword123", }); expect(fetch).toHaveBeenCalledTimes(1); const [url, init] = fetch.mock.calls[0]; expect(url).toBe("/api/auth/change-password"); 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({ currentPassword: "OldPassword123", newPassword: "StrongPassword123", }), ); expect(init.credentials).toBe("include"); expect(init.cache).toBe("no-store"); }); it("search builds the expected query string for branch scope", async () => { fetch.mockResolvedValue( new Response(JSON.stringify({ items: [], nextCursor: null }), { status: 200, headers: { "Content-Type": "application/json" }, }), ); await search({ q: "bridgestone", branch: "NL01", limit: 100 }); expect(fetch).toHaveBeenCalledTimes(1); const [url, init] = fetch.mock.calls[0]; const u = new URL(url, "http://localhost"); expect(u.pathname).toBe("/api/search"); expect(u.searchParams.get("q")).toBe("bridgestone"); expect(u.searchParams.get("branch")).toBe("NL01"); expect(u.searchParams.get("limit")).toBe("100"); expect(init.method).toBe("GET"); expect(init.credentials).toBe("include"); expect(init.cache).toBe("no-store"); }); it("search supports multi scope + branches + cursor", async () => { fetch.mockResolvedValue( new Response(JSON.stringify({ items: [], nextCursor: null }), { status: 200, headers: { "Content-Type": "application/json" }, }), ); await search({ q: " reifen ", scope: "multi", branches: ["NL06", "NL20"], cursor: "abc", }); const [url] = fetch.mock.calls[0]; const u = new URL(url, "http://localhost"); expect(u.pathname).toBe("/api/search"); expect(u.searchParams.get("q")).toBe("reifen"); expect(u.searchParams.get("scope")).toBe("multi"); expect(u.searchParams.get("branches")).toBe("NL06,NL20"); expect(u.searchParams.get("cursor")).toBe("abc"); }); });