feat(cms): add recovery history and operational reliability tools
CI / check (push) Failing after 22s
CI / deploy (push) Skipped
CI / publish-container (push) Skipped

This commit is contained in:
Simo committed 2026-09-09 21:57:56 +02:00
1 parent 89526af344
commit 1ef405be0a
92 files changed
+6384 -267

No files matched your search

@@ -7,6 +7,8 @@ const mocks = vi.hoisted(() => ({
guard: vi.fn(),
create: vi.fn(),
list: vi.fn(),
page: vi.fn(),
retry: vi.fn(),
cancel: vi.fn(),
after: vi.fn(),
ping: vi.fn(),
@@ -31,6 +33,8 @@ vi.mock("@/lib/services/furni-job-store", async (original) => ({
ImportJobStore: class {
create = mocks.create;
list = mocks.list;
page = mocks.page;
retry = mocks.retry;
requestCancellation = mocks.cancel;
},
}));
@@ -129,3 +133,49 @@ it("does not reveal other owners' jobs", async () => {
it("rejects unsafe cancellation IDs", async () => {
expect((await PATCH(request({ id: "../other" }), ctx)).status).toBe(400);
});
it("delegates retry to owner-scoped server selection, ignoring client items", async () => {
const id = randomUUID();
mocks.retry.mockResolvedValue({ id });
const response = await PATCH(
request({ id, action: "retry", items: [item] }),
ctx,
);
expect(response.status).toBe(200);
expect(mocks.retry).toHaveBeenCalledWith(id, 7);
});
it("rejects retries while the queue is offline", async () => {
mocks.ping.mockResolvedValue(null);
mocks.retry.mockClear();
expect(
(await PATCH(request({ id: randomUUID(), action: "retry" }), ctx)).status,
).toBe(503);
expect(mocks.retry).not.toHaveBeenCalled();
});
it("loads a page only for the authenticated operator", async () => {
mocks.page.mockResolvedValue({ jobs: [], nextCursor: null });
const response = await GET(
new NextRequest(
"http://localhost/api/admin/studio/import-jobs?page=1&userId=99",
),
ctx,
);
expect(response.status).toBe(200);
expect(mocks.page).toHaveBeenCalledWith({
userId: 7,
limit: 20,
before: undefined,
});
});
it("rejects malformed history cursors", async () => {
expect(
(
await GET(
new NextRequest(
"http://localhost/api/admin/studio/import-jobs?page=1&before=broken",
),
ctx,
)
).status,
).toBe(400);
});
+40 -1
View File
@@ -36,7 +36,26 @@ const schema = z.object({
});
export const GET = withAdmin(
{ permission: PERMS.ASSETS_IMPORT },
async (_request, ctx) => {
async (request, ctx) => {
const query = new URL(request.url).searchParams;
if (query.has("page")) {
const before = query.get("before") || undefined;
if (
before &&
!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z\|[0-9a-f-]{36}$/i.test(
before,
)
)
return apiError("Invalid history cursor", 400);
after(drainFurnitureImports);
return apiOk(
await new ImportJobStore().page({
userId: ctx.session.user.id,
limit: 20,
before,
}),
);
}
after(drainFurnitureImports);
const jobs = await new ImportJobStore().list({
userId: ctx.session.user.id,
@@ -88,6 +107,26 @@ export const PATCH = withAdmin(
async (request, ctx) => {
const body = await request.json().catch(() => null);
if (!validJobId(body?.id)) return apiError("Invalid import ID", 400);
if (body.action === "retry") {
if (!redis || (await redis.ping().catch(() => null)) !== "PONG")
return apiError(
"Background import queue is temporarily unavailable",
503,
);
const job = await new ImportJobStore().retry(
body.id,
ctx.session.user.id,
);
if (!job)
return apiError(
"No safe remaining items to retry, or import job not found",
404,
);
after(drainFurnitureImports);
return apiOk({ job });
}
if (body.action && body.action !== "cancel")
return apiError("Invalid job action", 400);
const job = await new ImportJobStore().requestCancellation(
body.id,
ctx.session.user.id,