Files
siftlode/frontend/src/lib/http.test.ts
T
peter 6cb8231a9f fix(logs): the browser's copy of a polled read is quiet too
The server now logs polled reads at DEBUG, and the browser's copy of each
request line promises the server's level: at INFO the unread-count polls
filled My log within minutes and, at Detailed, the shipped log. The server
marks a line it logged at DEBUG with X-Log-Quiet, so the polled-route list
stays in one place.
2026-09-19 05:03:31 +02:00

88 lines
3.4 KiB
TypeScript

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<string, string> = {}) =>
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");
});
});