From 8d455a08e26fc1541098a9394afa2c157b792781 Mon Sep 17 00:00:00 2001 From: m4 Date: Fri, 31 Jul 2026 10:25:37 +0800 Subject: [PATCH] feat(webui): add /api/error-logs GET/POST/DELETE for per-user error logs Co-Authored-By: Claude Opus 4.7 --- src/app/api/error-logs/route.test.ts | 109 +++++++++++++++++++++++++++ src/app/api/error-logs/route.ts | 63 ++++++++++++++++ 2 files changed, 172 insertions(+) create mode 100644 src/app/api/error-logs/route.test.ts create mode 100644 src/app/api/error-logs/route.ts diff --git a/src/app/api/error-logs/route.test.ts b/src/app/api/error-logs/route.test.ts new file mode 100644 index 0000000..b6de2da --- /dev/null +++ b/src/app/api/error-logs/route.test.ts @@ -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 }); + }); +}); diff --git a/src/app/api/error-logs/route.ts b/src/app/api/error-logs/route.ts new file mode 100644 index 0000000..080089e --- /dev/null +++ b/src/app/api/error-logs/route.ts @@ -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); + } +}