feat(webui): add /api/error-logs GET/POST/DELETE for per-user error logs

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
m4
2026-07-31 10:25:37 +08:00
parent 61c2aba187
commit 8d455a08e2
2 changed files with 172 additions and 0 deletions
+109
View File
@@ -0,0 +1,109 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import { NextRequest } from "next/server";
const mocks = vi.hoisted(() => ({
requireActor: vi.fn(),
appendErrorLog: vi.fn(),
listErrorLogs: vi.fn(),
clearErrorLogs: vi.fn(),
}));
vi.mock("server-only", () => ({}));
vi.mock("@/lib/server/actor", () => ({
requireActor: mocks.requireActor,
ActorError: class ActorError extends Error {
constructor(
message: string,
readonly status: 401 | 403 = 401
) {
super(message);
}
},
}));
vi.mock("@/lib/server/errorLogStore", () => ({
appendErrorLog: mocks.appendErrorLog,
listErrorLogs: mocks.listErrorLogs,
clearErrorLogs: mocks.clearErrorLogs,
}));
const routes = await import("./route");
function request(
method: string,
url = "http://localhost/api/error-logs",
body?: unknown
): NextRequest {
return new NextRequest(url, {
method,
...(body !== undefined ? { body: JSON.stringify(body) } : {}),
});
}
describe("error-logs route", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.requireActor.mockReturnValue({ sub: "alice", role: "admin" });
});
it("GET lists the current user's logs newest-first", async () => {
mocks.listErrorLogs.mockReturnValue([
{ id: 2, userId: "alice", createdAt: "t2", source: "s", code: null, message: "m2", details: null },
]);
const response = await routes.GET(request("GET", "http://localhost/api/error-logs?limit=10"));
expect(response.status).toBe(200);
expect(mocks.listErrorLogs).toHaveBeenCalledWith("alice", 10);
expect(await response.json()).toEqual({
logs: [expect.objectContaining({ id: 2, message: "m2" })],
});
});
it("GET returns 401 when unauthenticated", async () => {
const { ActorError } = await import("@/lib/server/actor");
mocks.requireActor.mockImplementation(() => {
throw new ActorError("Authentication required.", 401);
});
const response = await routes.GET(request("GET"));
expect(response.status).toBe(401);
});
it("POST appends a validated entry for the current user", async () => {
mocks.appendErrorLog.mockReturnValue({
id: 7,
userId: "alice",
createdAt: "t",
source: "chat.send",
code: null,
message: "boom",
details: null,
});
const response = await routes.POST(
request("POST", "http://localhost/api/error-logs", {
source: "chat.send",
message: "boom",
})
);
expect(response.status).toBe(201);
expect(mocks.appendErrorLog).toHaveBeenCalledWith("alice", {
source: "chat.send",
code: null,
message: "boom",
details: null,
});
});
it("POST rejects a missing message", async () => {
const response = await routes.POST(
request("POST", "http://localhost/api/error-logs", { source: "s" })
);
expect(response.status).toBe(400);
expect(mocks.appendErrorLog).not.toHaveBeenCalled();
});
it("DELETE clears only the current user's logs", async () => {
mocks.clearErrorLogs.mockReturnValue({ deleted: 3 });
const response = await routes.DELETE(request("DELETE"));
expect(response.status).toBe(200);
expect(mocks.clearErrorLogs).toHaveBeenCalledWith("alice");
expect(await response.json()).toEqual({ deleted: 3 });
});
});
+63
View File
@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from "next/server";
import { requireActor } from "@/lib/server/actor";
import { NO_STORE, routeErrorResponse } from "@/lib/server/routeErrors";
import {
appendErrorLog,
clearErrorLogs,
listErrorLogs,
} from "@/lib/server/errorLogStore";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
/** Per-user error log: the UI's toast errors are reported here so a user can
* review (and clear) everything that went wrong on their account. */
export async function GET(request: NextRequest) {
try {
const actor = requireActor(request);
const limit = Number(request.nextUrl.searchParams.get("limit") ?? 100) || 100;
return NextResponse.json(
{ logs: listErrorLogs(actor.sub, limit) },
{ headers: NO_STORE }
);
} catch (error) {
return routeErrorResponse(error);
}
}
export async function POST(request: NextRequest) {
try {
const actor = requireActor(request);
const body = (await request.json().catch(() => null)) as {
source?: unknown;
code?: unknown;
message?: unknown;
details?: unknown;
} | null;
const message = typeof body?.message === "string" ? body.message.trim() : "";
if (!message) {
return NextResponse.json(
{ code: "INVALID_REQUEST", message: "message is required." },
{ status: 400, headers: NO_STORE }
);
}
const entry = appendErrorLog(actor.sub, {
source: typeof body?.source === "string" ? body.source : "unknown",
code: typeof body?.code === "string" ? body.code : null,
message,
details: typeof body?.details === "string" ? body.details : null,
});
return NextResponse.json(entry, { status: 201, headers: NO_STORE });
} catch (error) {
return routeErrorResponse(error);
}
}
export async function DELETE(request: NextRequest) {
try {
const actor = requireActor(request);
return NextResponse.json(clearErrorLogs(actor.sub), { headers: NO_STORE });
} catch (error) {
return routeErrorResponse(error);
}
}