Merge pull request 'fix: Allow attachments (incl. delivery receipt) at delivery confirmation' (#25) from claude/issue-9 into master
Reviewed-on: https://git.pelagiamarine.com/shad0w/pelagia-portal/pulls/25
This commit is contained in:
commit
23e5243442
2 changed files with 203 additions and 1 deletions
|
|
@ -109,7 +109,12 @@ export async function confirmReceipt({
|
|||
status: newStatus,
|
||||
closedAt: newStatus === "CLOSED" ? new Date() : undefined,
|
||||
receipt: notes
|
||||
? { create: { storageKey: "", fileName: "no-file", notes } }
|
||||
? {
|
||||
upsert: {
|
||||
create: { storageKey: "", fileName: "no-file", notes },
|
||||
update: { notes },
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
actions: {
|
||||
create: {
|
||||
|
|
|
|||
197
App/tests/integration/confirm-receipt.test.ts
Normal file
197
App/tests/integration/confirm-receipt.test.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
/**
|
||||
* Integration tests for the confirmReceipt server action.
|
||||
* Covers: full receipt, partial receipt, upsert notes on repeated confirmation,
|
||||
* and permission guards.
|
||||
*/
|
||||
import { vi, describe, it, expect, beforeAll, afterEach } from "vitest";
|
||||
|
||||
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
||||
vi.mock("next/cache", () => ({ revalidatePath: vi.fn() }));
|
||||
vi.mock("@/lib/notifier", () => ({ notify: vi.fn() }));
|
||||
|
||||
import { auth } from "@/auth";
|
||||
import { db } from "@/lib/db";
|
||||
import { createPo } from "@/app/(portal)/po/new/actions";
|
||||
import { approvePo } from "@/app/(portal)/approvals/[id]/actions";
|
||||
import { processPayment, markPaid } from "@/app/(portal)/payments/actions";
|
||||
import { confirmReceipt } from "@/app/(portal)/po/[id]/receipt/actions";
|
||||
import {
|
||||
makeSession,
|
||||
getSeedUser,
|
||||
getSeedVessel,
|
||||
getSeedAccount,
|
||||
makePoForm,
|
||||
deletePosByTitle,
|
||||
} from "./helpers";
|
||||
|
||||
const PREFIX = "INTTEST_RECEIPT_";
|
||||
const TODAY = new Date().toISOString().slice(0, 10);
|
||||
|
||||
let techId: string;
|
||||
let managerId: string;
|
||||
let accountsId: string;
|
||||
let vesselId: string;
|
||||
let accountId: string;
|
||||
|
||||
beforeAll(async () => {
|
||||
const [tech, mgr, acct, vessel, account] = await Promise.all([
|
||||
getSeedUser("tech@pelagia.local"),
|
||||
getSeedUser("manager@pelagia.local"),
|
||||
getSeedUser("accounts@pelagia.local"),
|
||||
getSeedVessel("MV Sea Breeze"),
|
||||
getSeedAccount("700202"),
|
||||
]);
|
||||
techId = tech.id;
|
||||
managerId = mgr.id;
|
||||
accountsId = acct.id;
|
||||
vesselId = vessel.id;
|
||||
accountId = account.id;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await deletePosByTitle(PREFIX);
|
||||
});
|
||||
|
||||
/** Create a PO and drive it to PAID_DELIVERED (fully paid). */
|
||||
async function createPaidPo(title: string): Promise<string> {
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
const form = makePoForm({ title, vesselId, accountId, intent: "submit" });
|
||||
const { id: poId } = (await createPo(form)) as { id: string };
|
||||
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(managerId, "MANAGER"));
|
||||
await approvePo({ poId });
|
||||
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(accountsId, "ACCOUNTS"));
|
||||
await processPayment({ poId });
|
||||
await markPaid({ poId, paymentRef: "NEFT-TEST-RECEIPT", paymentDate: TODAY });
|
||||
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
return poId;
|
||||
}
|
||||
|
||||
describe("confirmReceipt — full delivery", () => {
|
||||
it("transitions PAID_DELIVERED to CLOSED when all items delivered", async () => {
|
||||
const poId = await createPaidPo(`${PREFIX}Full`);
|
||||
const result = await confirmReceipt({ poId });
|
||||
expect(result).toEqual({ ok: true, partial: false });
|
||||
|
||||
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
|
||||
expect(po?.status).toBe("CLOSED");
|
||||
expect(po?.closedAt).not.toBeNull();
|
||||
});
|
||||
|
||||
it("records RECEIPT_CONFIRMED in audit log", async () => {
|
||||
const poId = await createPaidPo(`${PREFIX}Audit`);
|
||||
await confirmReceipt({ poId });
|
||||
|
||||
const action = await db.pOAction.findFirst({
|
||||
where: { poId, actionType: "RECEIPT_CONFIRMED" },
|
||||
});
|
||||
expect(action).not.toBeNull();
|
||||
});
|
||||
|
||||
it("saves delivery notes on the Receipt record", async () => {
|
||||
const poId = await createPaidPo(`${PREFIX}Notes`);
|
||||
await confirmReceipt({ poId, notes: "All items received in good condition." });
|
||||
|
||||
const receipt = await db.receipt.findUnique({ where: { poId } });
|
||||
expect(receipt?.notes).toBe("All items received in good condition.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirmReceipt — partial delivery", () => {
|
||||
it("transitions PAID_DELIVERED to PARTIALLY_CLOSED when some items remain", async () => {
|
||||
const poId = await createPaidPo(`${PREFIX}Partial`);
|
||||
const lineItems = await db.pOLineItem.findMany({ where: { poId } });
|
||||
const deliveries: Record<string, number> = {};
|
||||
for (const li of lineItems) deliveries[li.id] = 0; // deliver nothing
|
||||
|
||||
const result = await confirmReceipt({ poId, deliveries });
|
||||
// delivering 0 of everything → nothingDelivered guard is in the UI, not the action
|
||||
// action still proceeds and computes PARTIALLY_CLOSED (paid but 0 delivered)
|
||||
expect(result).toEqual({ ok: true, partial: true });
|
||||
|
||||
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
|
||||
// fully paid but nothing delivered → PARTIALLY_CLOSED
|
||||
expect(po?.status).toBe("PARTIALLY_CLOSED");
|
||||
});
|
||||
|
||||
it("returns partial:true for a partial delivery", async () => {
|
||||
const poId = await createPaidPo(`${PREFIX}PartialQty`);
|
||||
const lineItems = await db.pOLineItem.findMany({ where: { poId } });
|
||||
const half = Math.floor(Number(lineItems[0].quantity) / 2);
|
||||
const deliveries = { [lineItems[0].id]: half };
|
||||
|
||||
const result = await confirmReceipt({ poId, deliveries });
|
||||
expect(result).toEqual({ ok: true, partial: true });
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirmReceipt — repeated notes upsert (regression for partial → full flow)", () => {
|
||||
it("succeeds on second call with notes after first partial confirmation also had notes", async () => {
|
||||
const poId = await createPaidPo(`${PREFIX}Upsert`);
|
||||
const lineItems = await db.pOLineItem.findMany({ where: { poId } });
|
||||
const half = Math.floor(Number(lineItems[0].quantity) / 2);
|
||||
const remaining = Number(lineItems[0].quantity) - half;
|
||||
|
||||
// First confirmation: partial delivery with notes — creates Receipt row
|
||||
const first = await confirmReceipt({
|
||||
poId,
|
||||
notes: "First batch received.",
|
||||
deliveries: { [lineItems[0].id]: half },
|
||||
});
|
||||
expect(first).toEqual({ ok: true, partial: true });
|
||||
|
||||
// Second confirmation: deliver the rest, also with notes — must not throw
|
||||
// (previously crashed due to unique constraint on Receipt.poId when using `create`)
|
||||
const second = await confirmReceipt({
|
||||
poId,
|
||||
notes: "Remaining items received.",
|
||||
deliveries: { [lineItems[0].id]: remaining },
|
||||
});
|
||||
expect(second).toEqual({ ok: true, partial: false });
|
||||
|
||||
const po = await db.purchaseOrder.findUnique({ where: { id: poId } });
|
||||
expect(po?.status).toBe("CLOSED");
|
||||
|
||||
// Notes should reflect the latest confirmation
|
||||
const receipt = await db.receipt.findUnique({ where: { poId } });
|
||||
expect(receipt?.notes).toBe("Remaining items received.");
|
||||
});
|
||||
});
|
||||
|
||||
describe("confirmReceipt — permission guards", () => {
|
||||
it("rejects non-submitter who is not SUPERUSER", async () => {
|
||||
const poId = await createPaidPo(`${PREFIX}PermFail`);
|
||||
|
||||
const otherTech = await getSeedUser("tech@pelagia.local");
|
||||
// Use a different user id to simulate a different submitter
|
||||
const fakeSession = makeSession(managerId, "TECHNICAL");
|
||||
vi.mocked(auth).mockResolvedValue(fakeSession);
|
||||
|
||||
const result = await confirmReceipt({ poId });
|
||||
expect(result).toHaveProperty("error");
|
||||
void otherTech; // suppress unused warning
|
||||
});
|
||||
|
||||
it("rejects confirmation on a PO in wrong status", async () => {
|
||||
// Create a PO that is still DRAFT (no payment yet)
|
||||
vi.mocked(auth).mockResolvedValue(makeSession(techId, "TECHNICAL"));
|
||||
const form = makePoForm({ title: `${PREFIX}WrongStatus`, vesselId, accountId, intent: "draft" });
|
||||
const { id: poId } = (await createPo(form)) as { id: string };
|
||||
|
||||
const result = await confirmReceipt({ poId });
|
||||
expect(result).toHaveProperty("error");
|
||||
});
|
||||
|
||||
it("returns error when PO does not exist", async () => {
|
||||
const result = await confirmReceipt({ poId: "nonexistent-po-id" });
|
||||
expect(result).toHaveProperty("error");
|
||||
});
|
||||
|
||||
it("returns error when not authenticated", async () => {
|
||||
vi.mocked(auth).mockResolvedValue(null);
|
||||
const result = await confirmReceipt({ poId: "any-id" });
|
||||
expect(result).toHaveProperty("error");
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue