feat(api): add stock-purchase attachments upload route
Co-authored-by: Orca <help@stably.ai>
This commit is contained in:
parent
70c158b374
commit
a72c5ca0a7
2 changed files with 166 additions and 0 deletions
|
|
@ -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/);
|
||||
});
|
||||
});
|
||||
|
|
@ -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 },
|
||||
);
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue