From c39124c637873ce8421a2b652a8f08335e28035c Mon Sep 17 00:00:00 2001 From: DEls Date: Wed, 19 Aug 2026 19:36:29 +0200 Subject: [PATCH] feat(api): add data-import-batches manual retry endpoint POST /api/data-import-batches/[id]/retry resets a failed or dead batch back to pending (attempts=0, last_error/next_retry_at cleared) so the next cron sweep picks it up. Non-retryable states return 409 with the current status; legacy rows with no status default to processed. Co-Authored-By: Claude --- .../[id]/retry/route.test.ts | 119 ++++++++++++++++++ .../data-import-batches/[id]/retry/route.ts | 63 ++++++++++ 2 files changed, 182 insertions(+) create mode 100644 src/app/api/data-import-batches/[id]/retry/route.test.ts create mode 100644 src/app/api/data-import-batches/[id]/retry/route.ts diff --git a/src/app/api/data-import-batches/[id]/retry/route.test.ts b/src/app/api/data-import-batches/[id]/retry/route.test.ts new file mode 100644 index 0000000..b2bcb4f --- /dev/null +++ b/src/app/api/data-import-batches/[id]/retry/route.test.ts @@ -0,0 +1,119 @@ +import { describe, it, expect, vi, beforeAll, afterAll, beforeEach } from "vitest"; + +// Mock pbAdmin so the route doesn't hit a real PocketBase instance. +const getOneMock = vi.fn(); +const updateMock = vi.fn(); + +vi.mock("@/lib/pb", () => ({ + pbAdmin: vi.fn(async () => ({ + collection: () => ({ + getOne: getOneMock, + update: updateMock, + }), + })), +})); + +import { POST } from "./route"; + +const ID = "abcdefghijklmno"; + +function post(id: string) { + return POST(new Request("http://x", { method: "POST" }), { + params: Promise.resolve({ id }), + }); +} + +describe("POST /api/data-import-batches/[id]/retry", () => { + beforeAll(() => { + vi.stubEnv("POCKETBASE_ADMIN_EMAIL", "test@example.com"); + vi.stubEnv("POCKETBASE_ADMIN_PASSWORD", "test-password"); + }); + + afterAll(() => { + vi.unstubAllEnvs(); + }); + + beforeEach(() => { + getOneMock.mockReset(); + updateMock.mockReset(); + }); + + it("returns 400 for a bad batch id format", async () => { + const res = await post("not-a-pb-id"); + expect(res.status).toBe(400); + expect(await res.json()).toEqual({ error: "bad_id" }); + expect(getOneMock).not.toHaveBeenCalled(); + }); + + it("returns 404 when the batch does not exist", async () => { + getOneMock.mockRejectedValueOnce(new Error("404 Not Found")); + const res = await post(ID); + expect(res.status).toBe(404); + expect(await res.json()).toEqual({ error: "not_found" }); + expect(updateMock).not.toHaveBeenCalled(); + }); + + it("resets a failed batch back to pending", async () => { + getOneMock.mockResolvedValueOnce({ id: ID, status: "failed" }); + updateMock.mockResolvedValueOnce({ id: ID }); + const res = await post(ID); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ id: ID, status: "pending" }); + expect(updateMock).toHaveBeenCalledWith(ID, { + status: "pending", + attempts: 0, + last_error: null, + next_retry_at: null, + }); + }); + + it("resets a dead batch back to pending", async () => { + getOneMock.mockResolvedValueOnce({ id: ID, status: "dead" }); + updateMock.mockResolvedValueOnce({ id: ID }); + const res = await post(ID); + expect(res.status).toBe(200); + expect(await res.json()).toEqual({ id: ID, status: "pending" }); + expect(updateMock).toHaveBeenCalledTimes(1); + }); + + it.each(["pending", "processing", "processed"])( + "returns 409 for a batch already in '%s'", + async (status) => { + getOneMock.mockResolvedValueOnce({ id: ID, status }); + const res = await post(ID); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ + error: "not_retryable", + current_status: status, + }); + expect(updateMock).not.toHaveBeenCalled(); + }, + ); + + it("treats a legacy row with no status as processed and 409s", async () => { + getOneMock.mockResolvedValueOnce({ id: ID }); + const res = await post(ID); + expect(res.status).toBe(409); + expect(await res.json()).toEqual({ + error: "not_retryable", + current_status: "processed", + }); + expect(updateMock).not.toHaveBeenCalled(); + }); + + it("returns 500 on unexpected lookup errors", async () => { + getOneMock.mockRejectedValueOnce(new Error("connection refused")); + const res = await post(ID); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: "lookup_failed" }); + expect(updateMock).not.toHaveBeenCalled(); + }); + + it("returns 500 when the update fails", async () => { + getOneMock.mockResolvedValueOnce({ id: ID, status: "failed" }); + updateMock.mockRejectedValueOnce(new Error("write conflict")); + const res = await post(ID); + expect(res.status).toBe(500); + expect(await res.json()).toEqual({ error: "update_failed" }); + }); +}); diff --git a/src/app/api/data-import-batches/[id]/retry/route.ts b/src/app/api/data-import-batches/[id]/retry/route.ts new file mode 100644 index 0000000..a9f0729 --- /dev/null +++ b/src/app/api/data-import-batches/[id]/retry/route.ts @@ -0,0 +1,63 @@ +import { NextResponse } from "next/server"; +import { pbAdmin } from "@/lib/pb"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +const ID_PATTERN = /^[a-z0-9]{15}$/; + +/** + * POST /api/data-import-batches/[id]/retry + * + * Resets a failed/dead batch back to pending so the next cron tick + * picks it up again. Idempotent for already-pending batches — but we + * 409 those because there's nothing to retry yet. + */ +export async function POST( + _request: Request, + ctx: { params: Promise<{ id: string }> }, +) { + const { id } = await ctx.params; + if (!ID_PATTERN.test(id)) { + return NextResponse.json({ error: "bad_id" }, { status: 400 }); + } + + const pb = await pbAdmin(); + + let batch: { status?: string }; + try { + batch = await pb + .collection("data_import_batches") + .getOne(id, { fields: "id,status" }); + } catch (err) { + if (err instanceof Error && /404/.test(err.message)) { + return NextResponse.json({ error: "not_found" }, { status: 404 }); + } + console.error("[batch-retry] lookup failed", { id, err: String(err) }); + return NextResponse.json({ error: "lookup_failed" }, { status: 500 }); + } + + const current = batch.status ?? "processed"; + if (current !== "failed" && current !== "dead") { + return NextResponse.json( + { + error: "not_retryable", + current_status: current, + }, + { status: 409 }, + ); + } + + try { + await pb.collection("data_import_batches").update(id, { + status: "pending", + attempts: 0, + last_error: null, + next_retry_at: null, + }); + return NextResponse.json({ id, status: "pending" }); + } catch (err) { + console.error("[batch-retry] update failed", { id, err: String(err) }); + return NextResponse.json({ error: "update_failed" }, { status: 500 }); + } +}