| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224 |
- /* @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");
- });
- });
|