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:
parent
f1c285a149
commit
f0f7c82a09
2 changed files with 170 additions and 52 deletions
140
src/app/api/purchase-orders/upload/route.test.ts
Normal file
140
src/app/api/purchase-orders/upload/route.test.ts
Normal 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();
|
||||
});
|
||||
});
|
||||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue