pelagia-portal/App/tests/integration/email-vendor.test.ts
Hardik 3edd1ffcc5
All checks were successful
PR checks / checks (pull_request) Successful in 43s
PR checks / integration (pull_request) Successful in 31s
feat(po): email PO to vendor — PDF link in an Outlook draft (#14)
Adds an "Email to vendor" button on the PO detail (available once approved,
through CLOSED, and again after payment) that opens an Outlook draft addressed
to the vendor's primary contact with a time-limited PDF download link.

Since mailto: can't attach files, the PDF is rendered and stored, and the draft
carries a link (the approach chosen for this issue):

- PdfService/: new standalone Express + Playwright microservice (GstService/
  EpfoService pattern) — POST /pdf { url } renders a page to a real PDF via
  headless Chromium. SSRF-guarded (shared token + optional origin allowlist).
- export route: accepts a server-only `svc` token (PDF_SERVICE_TOKEN) so
  PdfService can fetch /api/po/[id]/export?format=pdf without a user session;
  `pdf=1` drops the print button + window.print() auto-trigger.
- lib/pdf-service.ts renderPoPdf(); prepareVendorEmail() server action renders →
  uploads to R2 (po-pdf/…) → presigns a 7-day link → returns a mailto draft.
- po-detail: EmailVendorButton, shown when approved + vendor has a contact email.
- Gated by PDF_SERVICE_URL/PDF_SERVICE_TOKEN; friendly error if unconfigured.
- No DB model/migration. Tests: prepareVendorEmail (6, PdfService/storage mocked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 02:45:48 +05:30

134 lines
4.9 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"),
};
});
import { auth } from "@/auth";
import { db } from "@/lib/db";
import { prepareVendorEmail } from "@/app/(portal)/po/[id]/email-actions";
import { isPdfServiceConfigured } from "@/lib/pdf-service";
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("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" });
});
});