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.
88 lines
3.4 KiB
TypeScript
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");
|
|
});
|
|
});
|