From a72c5ca0a75a2db40069c6ccb6e5ae58872ab260 Mon Sep 17 00:00:00 2001 From: SJCoetz Date: Thu, 6 Aug 2026 23:21:00 +0200 Subject: [PATCH] feat(api): add stock-purchase attachments upload route Co-authored-by: Orca --- .../[id]/attachments/upload/route.test.ts | 87 +++++++++++++++++++ .../[id]/attachments/upload/route.ts | 79 +++++++++++++++++ 2 files changed, 166 insertions(+) create mode 100644 src/app/api/v1/stock-purchases/[id]/attachments/upload/route.test.ts create mode 100644 src/app/api/v1/stock-purchases/[id]/attachments/upload/route.ts diff --git a/src/app/api/v1/stock-purchases/[id]/attachments/upload/route.test.ts b/src/app/api/v1/stock-purchases/[id]/attachments/upload/route.test.ts new file mode 100644 index 0000000..f2a5b09 --- /dev/null +++ b/src/app/api/v1/stock-purchases/[id]/attachments/upload/route.test.ts @@ -0,0 +1,87 @@ +// @vitest-environment node +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { NextRequest } from "next/server"; + +vi.mock("@/lib/server/stock-purchases-service", () => ({ + addStockPurchaseAttachment: vi.fn(), +})); + +import { POST } from "./route"; +import { addStockPurchaseAttachment } from "@/lib/server/stock-purchases-service"; + +const addMock = addStockPurchaseAttachment as unknown as ReturnType< + typeof vi.fn +>; + +function makeReq(file: File | null) { + const fd = new FormData(); + if (file) fd.append("file", file); + return new NextRequest("http://localhost/api/v1/stock-purchases/sp-1/attachments/upload", { + method: "POST", + body: fd, + }); +} + +describe("POST /api/v1/stock-purchases/[id]/attachments/upload", () => { + beforeEach(() => { + addMock.mockReset(); + }); + afterEach(() => { + vi.clearAllMocks(); + }); + + it("400s when no file is in the request", async () => { + const res = await POST(makeReq(null), { + params: Promise.resolve({ id: "sp-1" }), + }); + expect(res.status).toBe(400); + expect(addMock).not.toHaveBeenCalled(); + }); + + it("calls the service helper and returns { ok, attachments, added }", async () => { + addMock.mockResolvedValueOnce({ + attachments: ["existing.pdf", "fresh.pdf"], + added: ["fresh.pdf"], + }); + + const res = await POST( + makeReq( + new File(["data"], "invoice.pdf", { type: "application/pdf" }), + ), + { params: Promise.resolve({ id: "sp-1" }) }, + ); + + expect(res.status).toBe(201); + expect(addMock).toHaveBeenCalledTimes(1); + const body = await res.json(); + expect(body).toEqual({ + ok: true, + attachments: ["existing.pdf", "fresh.pdf"], + added: ["fresh.pdf"], + }); + }); + + it("rejects files over 25 MB", async () => { + const big = new File([new Uint8Array(26 * 1024 * 1024)], "big.pdf", { + type: "application/pdf", + }); + const res = await POST(makeReq(big), { + params: Promise.resolve({ id: "sp-1" }), + }); + expect(res.status).toBe(413); + expect(addMock).not.toHaveBeenCalled(); + }); + + it("wraps service errors as 500", async () => { + addMock.mockRejectedValueOnce(new Error("PB unreachable")); + const res = await POST( + makeReq( + new File(["data"], "invoice.pdf", { type: "application/pdf" }), + ), + { params: Promise.resolve({ id: "sp-1" }) }, + ); + expect(res.status).toBe(500); + const body = await res.json(); + expect(body.error).toMatch(/PB unreachable/); + }); +}); diff --git a/src/app/api/v1/stock-purchases/[id]/attachments/upload/route.ts b/src/app/api/v1/stock-purchases/[id]/attachments/upload/route.ts new file mode 100644 index 0000000..d6335aa --- /dev/null +++ b/src/app/api/v1/stock-purchases/[id]/attachments/upload/route.ts @@ -0,0 +1,79 @@ +import { NextRequest, NextResponse } from "next/server"; +import { addStockPurchaseAttachment } from "@/lib/server/stock-purchases-service"; + +/** + * POST /api/v1/stock-purchases/[id]/attachments/upload + * + * Append a free-form attachment to a stock purchase. Mirrors + * `/api/v1/purchase-orders/[id]/attachments/upload` — same 25 MB + * cap, same response shape, same multi-file array write. + * + * The new file is appended to `stock_purchases.additional_attachments` + * (a sibling of `purchase_orders.additional_attachments`). + * `stock_purchases.documents` / `documents_history` are intentionally + * NOT touched — those are the versioned-PDF ledger. + * + * Request: multipart/form-data with field `file` (the binary). + * Response: 201 { ok: true, attachments: string[], added: string[] } + * 400 { error } no file in request + * 413 { error } file > 25 MB + * 500 { error } PB unreachable / service threw + */ + +export const runtime = "nodejs"; +export const dynamic = "force-dynamic"; + +const MAX_BYTES = 25 * 1024 * 1024; + +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + const { id: purchaseId } = await params; + + let formData: FormData; + try { + formData = await req.formData(); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Invalid form data" }, + { status: 400 }, + ); + } + + const file = formData.get("file"); + if ( + !file || + typeof file === "string" || + typeof (file as File).name !== "string" || + typeof (file as File).size !== "number" + ) { + return NextResponse.json( + { error: "No file in request (expected field 'file')" }, + { status: 400 }, + ); + } + + if ((file as File).size > MAX_BYTES) { + return NextResponse.json( + { error: `File too large (max ${MAX_BYTES / 1024 / 1024} MB)` }, + { status: 413 }, + ); + } + + try { + const { attachments, added } = await addStockPurchaseAttachment({ + purchaseId, + file: file as File, + }); + return NextResponse.json( + { ok: true, attachments, added }, + { status: 201 }, + ); + } catch (err) { + return NextResponse.json( + { error: err instanceof Error ? err.message : "Upload failed" }, + { status: 500 }, + ); + } +}