Files
kandev/apps/web/lib/api/domains/frontend-error-log-api.test.ts
T
2026-09-06 11:35:51 +01:00

102 lines
3.4 KiB
TypeScript

import { afterEach, describe, expect, it, vi } from "vitest";
import {
buildFrontendErrorReport,
deriveTaskID,
scheduleFrontendErrorReport,
sendFrontendErrorReport,
} from "./frontend-error-log-api";
describe("frontend error log API", () => {
afterEach(() => {
vi.restoreAllMocks();
window.history.replaceState({}, "", "/");
});
it.each([
["/t/task-one", "task-one"],
["/tasks/task-two", "task-two"],
["/office/tasks/task-three", "task-three"],
["/settings?taskId=query-task", "query-task"],
["/office/projects/task-four", undefined],
])("derives task IDs only from recognized routes: %s", (route, expected) => {
expect(deriveTaskID(new URL(route, "http://localhost"))).toBe(expected);
});
it("builds bounded browser context without serializing arbitrary React nodes", () => {
window.history.replaceState({}, "", "/t/task-one?token=secret#private");
const report = buildFrontendErrorReport({
source: "sonner",
title: "Failed to save",
description: { type: "div", props: { secret: "must-not-be-walked" } },
error: new TypeError("Failed to fetch"),
});
expect(report.source).toBe("sonner");
expect(report.title).toBe("Failed to save");
expect(report.description).toBe("[object]");
expect(report.task_id).toBe("task-one");
expect(report.error).toMatchObject({ name: "TypeError", message: "Failed to fetch" });
expect(report.url).toBe(`${window.location.origin}/t/task-one`);
expect(report.url).not.toContain("secret");
expect(report.viewport).toEqual({ width: window.innerWidth, height: window.innerHeight });
expect(JSON.stringify(report)).not.toContain("must-not-be-walked");
});
it("builds backend-reload reports without a synthetic toast stack", () => {
const report = buildFrontendErrorReport({
source: "backend-reload",
title: "boot_id_changed",
});
expect(report.stack).toBeUndefined();
});
it("omits error objects from backend-reload reports", () => {
const report = buildFrontendErrorReport({
source: "backend-reload",
title: "boot_id_changed",
error: new Error("not a toast error"),
});
expect(report.error).toBeUndefined();
});
it("omits descriptions from backend-reload reports", () => {
const report = buildFrontendErrorReport({
source: "backend-reload",
title: "boot_id_changed",
description: "should be suppressed",
});
expect(report.description).toBeUndefined();
});
it("uses an authenticated two-second request and silently accepts 204", async () => {
const fetchMock = vi
.spyOn(globalThis, "fetch")
.mockResolvedValue(new Response(null, { status: 204 }));
await sendFrontendErrorReport({
source: "sonner",
title: "Failed",
client_timestamp: new Date().toISOString(),
});
expect(fetchMock).toHaveBeenCalledWith(
expect.stringContaining("/api/v1/system/logs/frontend-errors"),
expect.objectContaining({
method: "POST",
credentials: "include",
signal: expect.any(AbortSignal),
}),
);
});
it("schedules collection off the toast call stack and swallows failures", async () => {
const fetchMock = vi.spyOn(globalThis, "fetch").mockRejectedValue(new Error("offline"));
scheduleFrontendErrorReport({ source: "toast-provider", title: "Failed" });
expect(fetchMock).not.toHaveBeenCalled();
await vi.waitFor(() => expect(fetchMock).toHaveBeenCalledOnce());
});
});