import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { ApiForbiddenError, ApiUnauthorizedError, apiFetch, } from "./client"; import { storeTokens } from "../auth/oidc"; import type { AppConfig } from "../config"; const app: AppConfig = { issuer: "https://auth.example.com/application/o/nestquest", clientId: "test-client-id", apiBaseUrl: "https://api.example.com", authorizeEndpoint: "https://auth.example.com/application/o/authorize/", tokenEndpoint: "https://auth.example.com/application/o/token/", userinfoEndpoint: "https://auth.example.com/application/o/userinfo/", endSessionEndpoint: "https://auth.example.com/application/o/nestquest/end-session/", redirectUri: "http://localhost:5173/auth/callback", }; function jsonResponse(body: unknown, status = 200): Response { return new Response(JSON.stringify(body), { status, headers: { "Content-Type": "application/json" }, }); } describe("API client", () => { beforeEach(() => { sessionStorage.clear(); }); afterEach(() => { vi.restoreAllMocks(); vi.unstubAllGlobals(); }); it("attaches the Bearer token to API calls", async () => { storeTokens({ access_token: "at-123", expires_in: 600 }); const fetchMock = vi.fn().mockResolvedValue(jsonResponse([])); await apiFetch("/api/v1/children", {}, app, fetchMock as any); expect(fetchMock).toHaveBeenCalledTimes(1); const [url, init] = fetchMock.mock.calls[0]; expect(url).toBe("https://api.example.com/api/v1/children"); expect(init.headers.Authorization).toBe("Bearer at-123"); }); it("throws ApiForbiddenError on 403 (authenticated but refused)", async () => { storeTokens({ access_token: "at-123", expires_in: 600 }); const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 403 })); await expect( apiFetch("/api/v1/children", {}, app, fetchMock as any), ).rejects.toBeInstanceOf(ApiForbiddenError); }); it("triggers a login redirect on 401 with no refresh token", async () => { storeTokens({ access_token: "stale", expires_in: 600 }); const assign = vi.fn(); vi.stubGlobal("location", { ...window.location, assign }); const fetchMock = vi.fn().mockResolvedValue(new Response("", { status: 401 })); await expect( apiFetch("/api/v1/children", {}, app, fetchMock as any), ).rejects.toBeInstanceOf(ApiUnauthorizedError); expect(assign).toHaveBeenCalledTimes(1); const url = new URL(assign.mock.calls[0][0] as string); expect(url.origin + url.pathname).toBe( "https://auth.example.com/application/o/authorize/", ); expect(url.searchParams.get("code_challenge_method")).toBe("S256"); }); it("preserves non-401/403 statuses on the post-refresh retry (500 is not mislabeled)", async () => { storeTokens({ access_token: "stale", refresh_token: "rt-1", expires_in: 600 }); const fetchMock = vi .fn() .mockResolvedValueOnce(new Response("", { status: 401 })) // initial API call .mockResolvedValueOnce( jsonResponse({ access_token: "fresh", expires_in: 600 }), // refresh grant ) .mockResolvedValueOnce(new Response("", { status: 500 })); // retry const response = await apiFetch("/api/v1/children", {}, app, fetchMock as any); expect(fetchMock).toHaveBeenCalledTimes(3); expect(fetchMock.mock.calls[1][0]).toBe(app.tokenEndpoint); expect(fetchMock.mock.calls[2][1].headers.Authorization).toBe("Bearer fresh"); // A server failure must surface as the real status, not ApiUnauthorizedError. expect(response.status).toBe(500); }); });