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:
@@ -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 });
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user