feat(api): rewrite upload route to write data_import_batches

POST /api/purchase-orders/upload no longer forwards the file to n8n
synchronously. It now validates size/MIME, writes a data_import_batches
row (batch_type='purchase_orders', status='pending', attempts=0) with the
uploaded file attached, and returns 202 { batch_id, status: "queued",
filename } immediately. n8n's 5-minute cron claims pending rows and calls
back to /api/purchase-orders/process-batch; the browser polls
/api/data-import-batches/[id]/status.

Adds route tests covering validation guards, the queued 202 contract,
the batch-create failure path, and that no synchronous fetch to n8n
happens.

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
DEls 2026-08-19 19:47:24 +02:00
parent f1c285a149
commit f0f7c82a09
2 changed files with 170 additions and 52 deletions

View file

@ -0,0 +1,140 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { MAX_FILE_BYTES } from "@/lib/purchase-order-schema";
// Mock pbAdmin so the route doesn't hit a real PocketBase instance.
const createMock = vi.fn();
vi.mock("@/lib/pb", () => ({
pbAdmin: vi.fn(async () => ({
collection: (name: string) => {
if (name !== "data_import_batches") {
throw new Error(`unexpected collection in test: ${name}`);
}
return { create: createMock };
},
})),
}));
import { POST } from "./route";
function makeRequest(form: FormData): Request {
return new Request("http://x/api/purchase-orders/upload", {
method: "POST",
body: form,
});
}
function pdf(bytes = 128, type = "application/pdf"): File {
return new File([new Uint8Array(bytes)], "po-1234.pdf", { type });
}
describe("POST /api/purchase-orders/upload", () => {
beforeEach(() => {
createMock.mockReset();
});
it("returns 400 when the body is not multipart form data", async () => {
const res = await POST(
new Request("http://x/api/purchase-orders/upload", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ nope: true }),
}),
);
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "invalid_form_data" });
expect(createMock).not.toHaveBeenCalled();
});
it("returns 400 when no file field is present", async () => {
const res = await POST(makeRequest(new FormData()));
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "missing_file" });
expect(createMock).not.toHaveBeenCalled();
});
it("returns 400 for an empty file", async () => {
const form = new FormData();
form.append("file", pdf(0));
const res = await POST(makeRequest(form));
expect(res.status).toBe(400);
expect(await res.json()).toEqual({ error: "empty_file" });
});
it("returns 400 when the file exceeds the size cap", async () => {
const file = pdf();
// Avoid allocating 20 MB just to trip the guard. The faked `size` only
// survives if the form is handed to the route directly — a real Request
// re-parses the multipart body and rebuilds the File.
Object.defineProperty(file, "size", { value: MAX_FILE_BYTES + 1 });
const form = new FormData();
form.append("file", file);
const res = await POST({ formData: async () => form } as Request);
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toBe("file_too_large");
expect(body.max_bytes).toBe(MAX_FILE_BYTES);
});
it("returns 400 for an unsupported MIME type", async () => {
const form = new FormData();
form.append("file", new File([new Uint8Array(16)], "po.docx", {
type: "application/vnd.openxmlformats-officedocument.wordprocessingml.document",
}));
const res = await POST(makeRequest(form));
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toBe("unsupported_type");
expect(body.got).toContain("wordprocessingml");
});
it("queues a pending batch and returns 202 with the batch id", async () => {
createMock.mockResolvedValueOnce({ id: "abcdefghijklmno" });
const form = new FormData();
form.append("file", pdf());
const res = await POST(makeRequest(form));
expect(res.status).toBe(202);
expect(await res.json()).toEqual({
batch_id: "abcdefghijklmno",
status: "queued",
filename: "po-1234.pdf",
});
expect(createMock).toHaveBeenCalledTimes(1);
const payload = createMock.mock.calls[0][0];
expect(payload.batch_type).toBe("purchase_orders");
expect(payload.status).toBe("pending");
expect(payload.attempts).toBe(0);
expect(payload.content_type).toBe("application/pdf");
expect(payload.original_file).toBeInstanceOf(File);
expect(payload.uploaded_by).toBe("web-upload");
expect(typeof payload.date_uploaded).toBe("string");
});
it("does not call n8n synchronously", async () => {
const fetchSpy = vi.spyOn(globalThis, "fetch");
createMock.mockResolvedValueOnce({ id: "abcdefghijklmno" });
const form = new FormData();
form.append("file", pdf());
await POST(makeRequest(form));
expect(fetchSpy).not.toHaveBeenCalled();
fetchSpy.mockRestore();
});
it("returns 500 when the batch row cannot be created", async () => {
createMock.mockRejectedValueOnce(new Error("pb down"));
const errSpy = vi.spyOn(console, "error").mockImplementation(() => {});
const form = new FormData();
form.append("file", pdf());
const res = await POST(makeRequest(form));
expect(res.status).toBe(500);
expect(await res.json()).toEqual({ error: "batch_create_failed" });
errSpy.mockRestore();
});
});

View file

@ -1,9 +1,23 @@
import { NextResponse } from "next/server";
import { pbAdmin } from "@/lib/pb";
import { ALLOWED_MIME, MAX_FILE_BYTES } from "@/lib/purchase-order-schema";
export const dynamic = "force-dynamic";
export const runtime = "nodejs";
/**
* POST /api/purchase-orders/upload
*
* Accepts multipart/form-data with a single `file` field (PDF or image).
*
* Writes the file to data_import_batches with status='pending',
* batch_type='purchase_orders', and returns the batch_id. n8n's
* 5-minute cron will pick it up, download the file from PB, extract
* PO data via AI Agent, and callback to /api/purchase-orders/process-batch.
*
* The browser polls /api/data-import-batches/[id]/status to render
* the import state to the user.
*/
export async function POST(request: Request) {
const start = Date.now();
@ -34,69 +48,33 @@ export async function POST(request: Request) {
);
}
const n8nUrl = process.env.N8N_PO_INGEST_URL;
if (!n8nUrl) {
console.error("[po-upload] N8N_PO_INGEST_URL not configured");
return NextResponse.json({ error: "n8n_unconfigured" }, { status: 500 });
}
// Forward to n8n as multipart
const n8nForm = new FormData();
n8nForm.append("file", file, file.name);
n8nForm.append("filename", file.name);
n8nForm.append("contentType", file.type);
n8nForm.append("size", String(file.size));
let n8nRes: Response;
const controller = new AbortController();
const timeoutId = setTimeout(() => controller.abort(), 10_000);
const pb = await pbAdmin();
let batch: { id: string };
try {
n8nRes = await fetch(n8nUrl, {
method: "POST",
body: n8nForm,
signal: controller.signal,
batch = await pb.collection("data_import_batches").create({
batch_type: "purchase_orders",
content_type: file.type,
original_file: file,
date_uploaded: new Date().toISOString(),
uploaded_by: "web-upload",
status: "pending",
attempts: 0,
});
} catch (err) {
clearTimeout(timeoutId);
const isAbort = err instanceof Error && err.name === "AbortError";
console.error("[po-upload] n8n unreachable", {
filename: file.name,
timed_out: isAbort,
err: String(err),
});
return NextResponse.json(
{ error: isAbort ? "n8n_timeout" : "n8n_unreachable" },
{ status: isAbort ? 504 : 502 },
);
}
clearTimeout(timeoutId);
if (!n8nRes.ok) {
console.error("[po-upload] n8n rejected", {
filename: file.name,
n8n_status: n8nRes.status,
statusText: n8nRes.statusText,
});
return NextResponse.json(
{
error: "n8n_rejected",
n8n_status: n8nRes.status,
statusText: n8nRes.statusText,
},
{ status: 502 },
);
console.error("[po-upload] batch_create failed", { err: String(err) });
return NextResponse.json({ error: "batch_create_failed" }, { status: 500 });
}
console.log("[po-upload] forwarded", {
console.log("[po-upload] queued", {
batch_id: batch.id,
filename: file.name,
size: file.size,
contentType: file.type,
n8n_status: n8nRes.status,
duration_ms: Date.now() - start,
});
return NextResponse.json(
{ status: "processing", filename: file.name },
{ batch_id: batch.id, status: "queued", filename: file.name },
{ status: 202 },
);
}
}