pelagia-portal/App/tests/integration/po-document-upload.test.ts
Hardik 2afdeec6ad
Some checks failed
PR checks / checks (pull_request) Failing after 2m14s
PR checks / integration (pull_request) Failing after 2m13s
fix(po): upload attachments server-side so they persist & show
PO/receipt attachments were uploaded via a browser presigned PUT straight
to R2 (POST /api/files/sign -> PUT uploadUrl -> linkDocument). That direct
browser->R2 PUT only succeeds when the bucket carries a CORS policy
allowing PUT from the portal origin; in production that policy was missing,
so the browser silently blocked the PUT, linkDocument never ran, and no
PODocument row was created -- "documents uploaded but not visible anywhere"
(0 PODocument rows in prod/staging).

Route uploads through a server action (uploadPoDocuments) that writes the
file with uploadBuffer and creates the PODocument row atomically -- the
same pattern the crewing module already uses for CV/crew-document uploads,
and within the 10mb serverActions.bodySizeLimit. This removes the R2-CORS
dependency and guarantees a created row always has its stored file.

Removes the now-dead presigned path (lib/upload-files.ts,
app/actions/link-document.ts, api/files/sign/route.ts).

Adds an integration test that drives the action against a real DB and
asserts the PODocument row is created and surfaced by the exact include
the PO detail page renders from (i.e. the attachment is visible).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 00:28:16 +05:30

140 lines
5.8 KiB
TypeScript

/**
* Integration test for PO document upload + visibility (regression for the
* "documents uploaded but not visible anywhere" report).
*
* Drives the real `uploadPoDocuments` server action against a real DB and asserts
* that a `PODocument` row is created AND surfaced by the exact include the PO
* detail page uses — i.e. the attachment is actually visible. Storage I/O
* (`uploadBuffer`) is stubbed so the test doesn't depend on R2 / the filesystem;
* the bug was the missing DB row, which is asserted here for real.
*/
import { vi, describe, it, expect, beforeAll, afterEach } from "vitest";
vi.mock("@/auth", () => ({ auth: vi.fn() }));
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
// Keep buildStorageKey real (it shapes the storage key the UI groups on); stub
// only the actual storage write so the test is hermetic.
vi.mock("@/lib/storage", async (importOriginal) => {
const actual = await importOriginal<typeof import("@/lib/storage")>();
return { ...actual, uploadBuffer: vi.fn().mockResolvedValue(undefined) };
});
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { uploadBuffer } from "@/lib/storage";
import { createPo } from "@/app/(portal)/po/new/actions";
import { uploadPoDocuments } from "@/app/actions/upload-po-documents";
import { makeSession, getSeedUser, getSeedVessel, getSeedAccount, makePoForm, deletePosByTitle } from "./helpers";
const PREFIX = "INTTEST_PODOC_";
let submitterId: string;
let vesselId: string;
let accountId: string;
beforeAll(async () => {
const [user, vessel, account] = await Promise.all([
getSeedUser("manager@pelagia.local"),
getSeedVessel("MV Pelagia Star"),
getSeedAccount("700201"),
]);
submitterId = user.id;
vesselId = vessel.id;
accountId = account.id;
});
afterEach(async () => {
await deletePosByTitle(PREFIX);
vi.clearAllMocks();
});
async function makePo(title: string): Promise<string> {
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(submitterId, "MANAGER"));
const result = await createPo(makePoForm({ title, vesselId, accountId, intent: "draft" }));
expect(result).not.toHaveProperty("error");
return (result as { id: string }).id;
}
function pdf(name: string, contents = "%PDF-1.4 hello"): File {
return new File([contents], name, { type: "application/pdf" });
}
describe("uploadPoDocuments", () => {
it("creates a PODocument row and stores the file, so it is visible on the PO", async () => {
const poId = await makePo(`${PREFIX}Visible`);
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(submitterId, "MANAGER"));
const file = pdf("invoice.pdf");
const err = await uploadPoDocuments(poId, [file]);
expect(err).toBeNull();
// The file was actually handed to storage with its bytes + mime type.
expect(uploadBuffer).toHaveBeenCalledTimes(1);
const [key, buffer, mime] = vi.mocked(uploadBuffer).mock.calls[0];
expect(key).toMatch(/^po-document\//);
expect(mime).toBe("application/pdf");
expect(Buffer.isBuffer(buffer)).toBe(true);
// The row exists — this is what was missing in the broken flow.
const docs = await db.pODocument.findMany({ where: { poId } });
expect(docs).toHaveLength(1);
expect(docs[0]).toMatchObject({
fileName: "invoice.pdf",
mimeType: "application/pdf",
storageKey: key,
});
expect(docs[0].fileSize).toBeGreaterThan(0);
// And it is surfaced by the exact include the PO detail page renders from.
const po = await db.purchaseOrder.findUnique({
where: { id: poId },
include: { documents: { orderBy: { uploadedAt: "desc" } } },
});
expect(po?.documents.map((d) => d.fileName)).toEqual(["invoice.pdf"]);
});
it("tags receipt uploads with the receipt prefix (delivery group)", async () => {
const poId = await makePo(`${PREFIX}Receipt`);
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(submitterId, "MANAGER"));
const err = await uploadPoDocuments(poId, [pdf("delivery-note.pdf")], "receipt");
expect(err).toBeNull();
const doc = await db.pODocument.findFirstOrThrow({ where: { poId } });
expect(doc.storageKey).toMatch(/^receipt\//);
});
it("stores every file when several are uploaded at once", async () => {
const poId = await makePo(`${PREFIX}Multi`);
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(submitterId, "MANAGER"));
const err = await uploadPoDocuments(poId, [pdf("a.pdf"), pdf("b.pdf"), pdf("c.pdf")]);
expect(err).toBeNull();
expect(await db.pODocument.count({ where: { poId } })).toBe(3);
});
it("skips empty files without creating rows", async () => {
const poId = await makePo(`${PREFIX}Empty`);
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(submitterId, "MANAGER"));
const err = await uploadPoDocuments(poId, [new File([], "blank.pdf", { type: "application/pdf" })]);
expect(err).toBeNull();
expect(uploadBuffer).not.toHaveBeenCalled();
expect(await db.pODocument.count({ where: { poId } })).toBe(0);
});
it("rejects an unauthenticated caller and writes nothing", async () => {
const poId = await makePo(`${PREFIX}NoAuth`);
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(null);
const err = await uploadPoDocuments(poId, [pdf("x.pdf")]);
expect(err).toEqual({ error: "Unauthorized" });
expect(await db.pODocument.count({ where: { poId } })).toBe(0);
});
it("errors when the PO does not exist", async () => {
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(submitterId, "MANAGER"));
const err = await uploadPoDocuments("nonexistent-po-id", [pdf("x.pdf")]);
expect(err).toEqual({ error: "PO not found" });
expect(uploadBuffer).not.toHaveBeenCalled();
});
});