import { afterEach, describe, expect, it, vi } from "vitest"; import { pathTemplate, send } from "./http"; import { clear, events, setLevel } from "./log"; afterEach(() => { setLevel("INFO"); clear(); vi.unstubAllGlobals(); }); const answer = (status: number, headers: Record = {}) => new Response(status === 204 ? null : "{}", { status, headers }); describe("send", () => { it("sends a request id and logs the answer under it, grouped by the server's template", async () => { const fetchMock = vi.fn(async () => answer(200, { "X-Route": "/api/videos/{video_id}" })); vi.stubGlobal("fetch", fetchMock); await send("/api/videos/dQw4w9WgXcQ?x=1", { headers: { "Content-Type": "application/json" } }); const sent = new Headers( (fetchMock.mock.calls[0] as unknown as [string, RequestInit])[1].headers, ); expect(sent.get("Content-Type")).toBe("application/json"); // the caller's headers survive const [e] = events(); expect(e).toMatchObject({ level: "INFO", event: "http.request", request_id: sent.get("X-Request-Id"), attrs: { method: "GET", path: "/api/videos/{video_id}", status: 200 }, }); expect(typeof e!.attrs!.duration_ms).toBe("number"); }); it("logs a refused request at the server's level for it", async () => { vi.stubGlobal("fetch", async () => answer(404)); await send("/api/x"); vi.stubGlobal("fetch", async () => answer(503)); await send("/api/y", { method: "post" }); expect(events().map((e) => [e.level, e.attrs!.method, e.attrs!.status])).toEqual([ ["WARNING", "GET", 404], ["ERROR", "POST", 503], ]); }); // The server logs the reads the app polls at DEBUG and says so (`X-Log-Quiet`); at INFO the // browser's copies filled My log with unread-count polls within minutes (prod UAT 2026-09-19). it("logs a read the server called quiet at DEBUG, so the base level keeps it out", async () => { vi.stubGlobal("fetch", async () => answer(200, { "X-Log-Quiet": "1" })); await send("/api/messages/unread_count"); expect(events()).toEqual([]); setLevel("DEBUG"); await send("/api/messages/unread_count"); expect(events().map((e) => e.level)).toEqual(["DEBUG"]); }); it("logs a network failure without the concrete path, and still rejects", async () => { vi.stubGlobal("fetch", async () => { throw new TypeError("Failed to fetch"); }); await expect(send("/api/public/watch/Ab3_xYz9-token/unlock")).rejects.toThrow(TypeError); expect(events()[0]).toMatchObject({ level: "WARNING", attrs: { path: "/api/public/watch/{}/unlock", failed: "network" }, }); }); it("logs a cancelled request as a cancel, not a failure", async () => { vi.stubGlobal("fetch", async () => { throw new DOMException("aborted", "AbortError"); }); await expect(send("/api/x")).rejects.toThrow(); expect(events()[0]).toMatchObject({ level: "INFO", attrs: { failed: "aborted" } }); }); }); describe("pathTemplate", () => { it("keeps plain words and replaces every value, dropping query and fragment", () => { expect(pathTemplate("/api/channels/UCabc123/tags/7?q=secret#reset=x")).toBe( "/api/channels/{}/tags/{}", ); expect(pathTemplate("/api/plex/stream/42/hls/s1/index.m3u8")).toBe( "/api/plex/stream/{}/hls/{}/{}", ); expect(pathTemplate("/api/watch-later/playback_state")).toBe("/api/watch-later/playback_state"); }); });