feat(cms): add recovery history and operational reliability tools
This commit is contained in:
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);
|
||||
});
|
||||
@@ -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,
|
||||
|
||||
Reference in new issue
Block a user