feat(api): add stock-purchase attachments upload route

Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
SJCoetz 2026-08-06 23:21:00 +02:00
parent 70c158b374
commit a72c5ca0a7
2 changed files with 166 additions and 0 deletions

View file

@ -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/);
});
});

View file

@ -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 },
);
}
}