Previously every "Email to vendor" click re-rendered the PO via PdfService and re-uploaded to R2 under a timestamped key — wasteful, and it orphaned a new object each time. Now the PDF is stored at a deterministic per-PO key (buildPoPdfKey → po-pdf/<poId>/<slug>.pdf). On each send, statObject() checks for an existing copy: if it exists and is at least as new as the PO's updatedAt, it's reused (no re-render, no re-upload) and only a fresh presigned URL is minted — refreshing the 7-day download timer. It re-renders only when there's no copy yet or the PO changed since the cached one (so an edited PO never emails a stale PDF). - lib/storage.ts: buildPoPdfKey (deterministic) + statObject (HEAD/stat, no body transfer; null when absent). - email-actions.ts: reuse-or-render decision keyed on updatedAt; always re-presign. - Tests: +2 (reuse-on-second-send-only-refreshes-link, re-render-when-changed). email-vendor suite 8 green; tsc clean. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
164 lines
6.6 KiB
TypeScript
164 lines
6.6 KiB
TypeScript
/**
|
|
* Integration tests for prepareVendorEmail (issue #14) — the "Email to vendor"
|
|
* action that renders the PO PDF, stores it, and returns an Outlook mailto draft
|
|
* with a download link. PdfService + storage are mocked (no Chromium / R2).
|
|
*/
|
|
import { vi, describe, it, expect, beforeAll, afterEach, afterAll } from "vitest";
|
|
|
|
vi.mock("@/auth", () => ({ auth: vi.fn() }));
|
|
vi.mock("@/lib/pdf-service", () => ({
|
|
renderPoPdf: vi.fn(async () => Buffer.from("%PDF-1.4 fake")),
|
|
isPdfServiceConfigured: vi.fn(() => true),
|
|
PdfServiceError: class PdfServiceError extends Error {},
|
|
}));
|
|
vi.mock("@/lib/storage", async (importOriginal) => {
|
|
const actual = await importOriginal<typeof import("@/lib/storage")>();
|
|
return {
|
|
...actual,
|
|
uploadBuffer: vi.fn(async () => {}),
|
|
generateDownloadUrl: vi.fn(async () => "https://files.example/po.pdf?sig=abc"),
|
|
statObject: vi.fn(async () => null), // default: no cached object → render
|
|
};
|
|
});
|
|
|
|
import { auth } from "@/auth";
|
|
import { db } from "@/lib/db";
|
|
import { prepareVendorEmail } from "@/app/(portal)/po/[id]/email-actions";
|
|
import { isPdfServiceConfigured, renderPoPdf } from "@/lib/pdf-service";
|
|
import { statObject, uploadBuffer, generateDownloadUrl } from "@/lib/storage";
|
|
import { makeSession, getSeedUser, getSeedVessel, getSeedAccount } from "./helpers";
|
|
|
|
const PREFIX = "INTTEST_EMAILVENDOR_";
|
|
let techId: string;
|
|
let vesselId: string;
|
|
let accountId: string;
|
|
let vendorWithEmailId: string;
|
|
let vendorNoEmailId: string;
|
|
|
|
const as = (userId: string, role: "TECHNICAL" | "MANAGER") =>
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(makeSession(userId, role));
|
|
|
|
async function makePo(status: string, vendorId: string): Promise<string> {
|
|
const po = await db.purchaseOrder.create({
|
|
data: {
|
|
poNumber: `${PREFIX}${status}-${Date.now()}-${Math.round(Math.random() * 1e6)}`,
|
|
title: `${PREFIX}PO`,
|
|
status: status as never,
|
|
totalAmount: 1000,
|
|
currency: "INR",
|
|
vesselId,
|
|
accountId,
|
|
submitterId: techId,
|
|
vendorId,
|
|
},
|
|
});
|
|
return po.id;
|
|
}
|
|
|
|
beforeAll(async () => {
|
|
const [tech, vessel, account] = await Promise.all([
|
|
getSeedUser("tech@pelagia.local"),
|
|
getSeedVessel("MV Poseidon"),
|
|
getSeedAccount("700201"),
|
|
]);
|
|
techId = tech.id;
|
|
vesselId = vessel.id;
|
|
accountId = account.id;
|
|
|
|
const withEmail = await db.vendor.create({
|
|
data: { name: `${PREFIX}WithEmail`, contacts: { create: { name: "Vinod", email: "vinod@vendor.test", isPrimary: true } } },
|
|
});
|
|
vendorWithEmailId = withEmail.id;
|
|
const noEmail = await db.vendor.create({ data: { name: `${PREFIX}NoEmail` } });
|
|
vendorNoEmailId = noEmail.id;
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.mocked(isPdfServiceConfigured).mockReturnValue(true);
|
|
vi.clearAllMocks();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await db.purchaseOrder.deleteMany({ where: { title: { startsWith: PREFIX } } });
|
|
await db.vendorContact.deleteMany({ where: { vendor: { name: { startsWith: PREFIX } } } });
|
|
await db.vendor.deleteMany({ where: { name: { startsWith: PREFIX } } });
|
|
});
|
|
|
|
describe("prepareVendorEmail", () => {
|
|
it("builds a mailto draft to the vendor's primary contact with the PDF link", async () => {
|
|
as(techId, "TECHNICAL");
|
|
const poId = await makePo("MGR_APPROVED", vendorWithEmailId);
|
|
|
|
const result = await prepareVendorEmail(poId);
|
|
expect("ok" in result && result.ok).toBe(true);
|
|
if (!("ok" in result)) throw new Error(result.error);
|
|
|
|
expect(result.to).toBe("vinod@vendor.test");
|
|
expect(result.mailto.startsWith("mailto:vinod%40vendor.test?")).toBe(true);
|
|
// Subject is the PO number; body carries the (mocked) download link.
|
|
expect(decodeURIComponent(result.mailto)).toContain("Purchase Order");
|
|
expect(decodeURIComponent(result.mailto)).toContain("https://files.example/po.pdf?sig=abc");
|
|
});
|
|
|
|
it("reuses the cached PDF on a second send and only refreshes the link (7-day timer)", async () => {
|
|
as(techId, "TECHNICAL");
|
|
const poId = await makePo("MGR_APPROVED", vendorWithEmailId);
|
|
|
|
// 1st send: no cached object → render + upload once.
|
|
vi.mocked(statObject).mockResolvedValueOnce(null);
|
|
expect("ok" in (await prepareVendorEmail(poId))).toBe(true);
|
|
expect(vi.mocked(renderPoPdf)).toHaveBeenCalledTimes(1);
|
|
expect(vi.mocked(uploadBuffer)).toHaveBeenCalledTimes(1);
|
|
|
|
// 2nd send: a cached object newer than the PO → reuse, no re-render, fresh link.
|
|
vi.mocked(statObject).mockResolvedValueOnce({ lastModified: new Date(Date.now() + 60_000) });
|
|
expect("ok" in (await prepareVendorEmail(poId))).toBe(true);
|
|
expect(vi.mocked(renderPoPdf)).toHaveBeenCalledTimes(1); // unchanged — reused
|
|
expect(vi.mocked(uploadBuffer)).toHaveBeenCalledTimes(1); // unchanged — reused
|
|
expect(vi.mocked(generateDownloadUrl)).toHaveBeenCalledTimes(2); // re-presigned each send
|
|
});
|
|
|
|
it("re-renders when the PO changed since the cached copy", async () => {
|
|
as(techId, "TECHNICAL");
|
|
const poId = await makePo("MGR_APPROVED", vendorWithEmailId);
|
|
// Cached object older than the PO's updatedAt → stale → re-render.
|
|
vi.mocked(statObject).mockResolvedValueOnce({ lastModified: new Date(0) });
|
|
expect("ok" in (await prepareVendorEmail(poId))).toBe(true);
|
|
expect(vi.mocked(renderPoPdf)).toHaveBeenCalledTimes(1);
|
|
expect(vi.mocked(uploadBuffer)).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("is available once payment is recorded too (PARTIALLY_PAID)", async () => {
|
|
as(techId, "TECHNICAL");
|
|
const poId = await makePo("PARTIALLY_PAID", vendorWithEmailId);
|
|
expect("ok" in (await prepareVendorEmail(poId))).toBe(true);
|
|
});
|
|
|
|
it("refuses a PO that is not yet approved (DRAFT)", async () => {
|
|
as(techId, "TECHNICAL");
|
|
const poId = await makePo("DRAFT", vendorWithEmailId);
|
|
const result = await prepareVendorEmail(poId);
|
|
expect("error" in result).toBe(true);
|
|
});
|
|
|
|
it("errors when the vendor has no primary contact email", async () => {
|
|
as(techId, "TECHNICAL");
|
|
const poId = await makePo("MGR_APPROVED", vendorNoEmailId);
|
|
const result = await prepareVendorEmail(poId);
|
|
expect("error" in result).toBe(true);
|
|
});
|
|
|
|
it("errors when the PDF service is not configured", async () => {
|
|
vi.mocked(isPdfServiceConfigured).mockReturnValue(false);
|
|
as(techId, "TECHNICAL");
|
|
const poId = await makePo("MGR_APPROVED", vendorWithEmailId);
|
|
const result = await prepareVendorEmail(poId);
|
|
expect("error" in result).toBe(true);
|
|
});
|
|
|
|
it("rejects an unauthenticated caller", async () => {
|
|
vi.mocked(auth as unknown as () => Promise<unknown>).mockResolvedValue(null);
|
|
const poId = await makePo("MGR_APPROVED", vendorWithEmailId);
|
|
expect(await prepareVendorEmail(poId)).toEqual({ error: "Unauthorized" });
|
|
});
|
|
});
|