pelagia-portal/App/tests/integration/email-vendor.test.ts
Hardik a9fd927c1f
All checks were successful
PR checks / checks (pull_request) Successful in 47s
PR checks / integration (pull_request) Successful in 31s
feat(pdf): cache the PO PDF per vendor email, refresh only the link timer
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>
2026-06-24 15:01:25 +05:30

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" });
});
});