Adds NEXT_PUBLIC_CLOSED_PO_ATTACHMENTS_ENABLED. When on, a CLOSED PO's own
submitter -- plus Accounts / Manager / SuperUser -- can attach documents to
it, so POs whose uploads were lost to the document-upload bug can be fixed
without reopening them. Off by default, so production stays unchanged until
enabled.
- lib/permissions.ts: canAddClosedPoAttachment(role, { isSubmitter }) gated
by the flag; allowed roles are ACCOUNTS/MANAGER/SUPERUSER (plus the PO's
own submitter regardless of role).
- uploadPoDocuments: a CLOSED PO is otherwise immutable, so it now enforces
the permission server-side; the normal create/receipt flows upload while
the PO is pre-CLOSED and are unaffected.
- po-detail.tsx: when allowed, the Attachments card renders an uploader
(ClosedPoAttachmentUploader) and shows even when the PO has no docs yet.
- Enabled on staging (staging-up.sh) so the remediation can be exercised;
documented in .env.example and CLAUDE.md.
Tests: closed-po-attachments.test.ts covers the flag-on role matrix (own
submitter / Accounts / Manager / SuperUser allowed; other submitter-role and
auditor refused; non-closed PO unaffected); po-document-upload.test.ts adds
the flag-off case (closed PO stays immutable). Full unit + integration suites
green; tsc clean.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
154 lines
6.6 KiB
TypeScript
154 lines
6.6 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();
|
|
});
|
|
|
|
// The closed-PO remediation flag is OFF here (no mock; env unset), so a CLOSED PO
|
|
// stays immutable even for its own submitter. The flag-ON matrix lives in
|
|
// closed-po-attachments.test.ts.
|
|
it("refuses to attach to a CLOSED PO while the flag is off", async () => {
|
|
const poId = await makePo(`${PREFIX}ClosedOff`);
|
|
await db.purchaseOrder.update({ where: { id: poId }, data: { status: "CLOSED" } });
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(submitterId, "MANAGER"));
|
|
|
|
const err = await uploadPoDocuments(poId, [pdf("late.pdf")]);
|
|
expect(err).toEqual({ error: "Adding attachments to a closed purchase order isn't allowed." });
|
|
expect(uploadBuffer).not.toHaveBeenCalled();
|
|
expect(await db.pODocument.count({ where: { poId } })).toBe(0);
|
|
});
|
|
});
|