const { PDFDocument } = require("pdf-lib"); const sharp = require("sharp"); const { mergeMaterialsToPdf } = require("../../../src/services/reimbursementMaterialPdf"); const { MATERIAL_PREVIEW_MERGER_VERSION, MaterialPreviewError, createReimbursementMaterialPreviewStore, runMergeWorker, } = require("../../../src/services/reimbursementMaterialPreview"); const limits = { maxPages: 40, maxImagePixels: 40_000_000, maxOutputBytes: 10 * 1024 * 1024 }; const ENCRYPTED_PDF = Buffer.from("JVBERi0xLjMKJeLjz9MKMSAwIG9iago8PAovUHJvZHVjZXIgPGQ1MTZlOTU0YzA+Cj4+CmVuZG9iagoyIDAgb2JqCjw8Ci9UeXBlIC9QYWdlcwovQ291bnQgMQovS2lkcyBbIDQgMCBSIF0KPj4KZW5kb2JqCjMgMCBvYmoKPDwKL1R5cGUgL0NhdGFsb2cKL1BhZ2VzIDIgMCBSCj4+CmVuZG9iago0IDAgb2JqCjw8Ci9UeXBlIC9QYWdlCi9SZXNvdXJjZXMgPDwKPj4KL01lZGlhQm94IFsgMC4wIDAuMCAyMDAgMjAwIF0KL1BhcmVudCAyIDAgUgo+PgplbmRvYmoKNSAwIG9iago8PAovViAyCi9SIDMKL0xlbmd0aCAxMjgKL1AgNDI5NDk2NzI5MgovRmlsdGVyIC9TdGFuZGFyZAovTyA8YTgxN2MwMzIxY2ViOTJjMDQ3MDkyMDc2MTA0NjJkZjViZWIwMzJjYzBhYTQ4YTY3MGY5YWVmNTM3NTk2MzdkNj4KL1UgPDIyOWI4ZWJjYTUzMTFkZWYzZjhiNzM4MzQ1MzExZTdmMjhiZjRlNWU0ZTc1OGE0MTY0MDA0ZTU2ZmZmYTAxMDg+Cj4+CmVuZG9iagp4cmVmCjAgNgowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMTUgMDAwMDAgbiAKMDAwMDAwMDA1OSAwMDAwMCBuIAowMDAwMDAwMTE4IDAwMDAwIG4gCjAwMDAwMDAxNjcgMDAwMDAgbiAKMDAwMDAwMDI2MSAwMDAwMCBuIAp0cmFpbGVyCjw8Ci9TaXplIDYKL1Jvb3QgMyAwIFIKL0luZm8gMSAwIFIKL0lEIFsgPDM1Mzk2MzMyMzA2MjYyNjE2NTYzMzgzMjY1MzE2MjM1NjMzNjMzNjM2MTYyNjU2NjM1NjE2NjYxNjU2NjMxMzE+IDwzNTM5NjMzMjMwNjI2MjYxNjU2MzM4MzI2NTMxNjIzNTYzMzYzMzYzNjE2MjY1NjYzNTYxNjY2MTY1NjYzMTMxPiBdCi9FbmNyeXB0IDUgMCBSCj4+CnN0YXJ0eHJlZgo0NzYKJSVFT0YK", "base64"); async function samplePdf(pageCount = 2) { const pdf = await PDFDocument.create(); for (let index = 0; index < pageCount; index += 1) pdf.addPage([300 + index, 200 + index]); return Buffer.from(await pdf.save({ useObjectStreams: true })); } describe("reimbursement material PDF preview", () => { it("uses normalized output dimensions for EXIF-rotated and ordinary images", async () => { const oriented6 = await sharp({ create: { width: 400, height: 200, channels: 3, background: "white" } }) .jpeg() .withMetadata({ orientation: 6 }) .toBuffer(); const oriented8 = await sharp({ create: { width: 600, height: 300, channels: 3, background: "white" } }) .jpeg() .withMetadata({ orientation: 8 }) .toBuffer(); const verticalJpeg = await sharp({ create: { width: 120, height: 240, channels: 3, background: "white" } }) .jpeg() .toBuffer(); const horizontalWebp = await sharp({ create: { width: 160, height: 80, channels: 3, background: "white" } }) .webp() .toBuffer(); const result = await mergeMaterialsToPdf([ { id: "orientation-6", materialType: "attachment", mimeType: "image/jpeg", source: oriented6 }, { id: "orientation-8", materialType: "attachment", mimeType: "image/jpeg", source: oriented8 }, { id: "vertical-jpeg", materialType: "attachment", mimeType: "image/jpeg", source: verticalJpeg }, { id: "horizontal-webp", materialType: "attachment", mimeType: "image/webp", source: horizontalWebp }, ], limits); const pages = (await PDFDocument.load(result.buffer)).getPages(); expect(pages.map((page) => [page.getWidth(), page.getHeight()])).toEqual([ [200, 400], [300, 600], [120, 240], [160, 80], ]); expect(MATERIAL_PREVIEW_MERGER_VERSION).toBe("pdf-v2"); }); it("copies every PDF page, embeds images, and produces deterministic bytes", async () => { const pdf = await samplePdf(2); const png = await sharp({ create: { width: 80, height: 40, channels: 3, background: "white" } }).png().toBuffer(); const materials = [ { id: "invoice-1", materialType: "invoice", mimeType: "application/pdf", source: pdf }, { id: "attachment-1", materialType: "attachment", mimeType: "image/png", source: png }, ]; const first = await mergeMaterialsToPdf(materials, limits); const second = await mergeMaterialsToPdf(materials, limits); expect(first.pageCount).toBe(3); expect(first.buffer.equals(second.buffer)).toBe(true); expect((await PDFDocument.load(first.buffer)).getPageCount()).toBe(3); }); it("keeps one cached byte sequence per ordered collection and expires its grant", async () => { let currentTime = 1_000; const merged = Buffer.from("deterministic-pdf"); const mergePdf = vi.fn().mockResolvedValue({ buffer: merged, pageCount: 3 }); const store = createReimbursementMaterialPreviewStore({ now: () => currentTime, randomUUID: () => "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa", mergePdf }); const args = { actorId: "actor-1", reimbursementId: "reimbursement-1", expiresAt: 2_000, materials: [{ id: "invoice-1", materialType: "invoice", mimeType: "application/pdf", fileSize: 10, fileSha256: "a" }], readSource: async () => Buffer.from("source"), }; const created = await store.createBundle(args); expect(created.fileSize).toBe(merged.length); expect(store.getBundle({ bundleId: created.bundleId })?.artifact.buffer).toBe(merged); expect(mergePdf).toHaveBeenCalledTimes(1); currentTime = 2_001; expect(store.getBundle({ bundleId: created.bundleId })).toBeNull(); }); it("deduplicates concurrent generation for the same ordered collection", async () => { const mergePdf = vi.fn(async () => ({ buffer: Buffer.from("one-pdf"), pageCount: 1 })); let sequence = 0; const store = createReimbursementMaterialPreviewStore({ mergePdf, randomUUID: () => `bundle-${sequence += 1}` }); const args = { actorId: "actor-1", reimbursementId: "reimbursement-1", expiresAt: Date.now() + 10_000, materials: [{ id: "invoice-1", materialType: "invoice", mimeType: "application/pdf", fileSize: 6, fileSha256: "same" }], readSource: async () => Buffer.from("source"), }; await Promise.all([store.createBundle(args), store.createBundle(args)]); expect(mergePdf).toHaveBeenCalledTimes(1); }); it("rejects material/source limits and never keeps a partial bundle", async () => { const store = createReimbursementMaterialPreviewStore({ limits: { maxMaterials: 1, maxSourceBytes: 5 }, mergePdf: vi.fn() }); await expect(store.createBundle({ actorId: "actor-1", reimbursementId: "reimbursement-1", expiresAt: Date.now() + 1_000, materials: [ { id: "one", materialType: "invoice", mimeType: "application/pdf", fileSize: 3 }, { id: "two", materialType: "attachment", mimeType: "image/png", fileSize: 3 }, ], readSource: async () => Buffer.from("source"), })).rejects.toMatchObject({ code: "material_preview_material_limit" }); await expect(store.createBundle({ actorId: "actor-1", reimbursementId: "reimbursement-1", expiresAt: Date.now() + 1_000, materials: [{ id: "one", materialType: "invoice", mimeType: "application/pdf", fileSize: 3 }], readSource: async () => Buffer.from("source-too-large"), })).rejects.toBeInstanceOf(MaterialPreviewError); }); it("fails the whole merge for a damaged PDF", async () => { await expect(mergeMaterialsToPdf([ { id: "bad", materialType: "invoice", mimeType: "application/pdf", source: Buffer.from("not-a-pdf") }, ], limits)).rejects.toThrow("material_preview_pdf_invalid"); }); it("fails the whole merge for an encrypted PDF without ignoreEncryption", async () => { await expect(mergeMaterialsToPdf([ { id: "encrypted", materialType: "invoice", mimeType: "application/pdf", source: ENCRYPTED_PDF }, ], limits)).rejects.toThrow("material_preview_pdf_invalid"); }); it("merges a 25-page synthetic document in worker_threads without losing pages", async () => { const pdf = await samplePdf(25); const result = await runMergeWorker([ { id: "multi-page", materialType: "invoice", mimeType: "application/pdf", source: pdf }, ], { ...limits, mergeTimeoutMs: 10_000 }); expect(result.pageCount).toBe(25); expect((await PDFDocument.load(result.buffer)).getPageCount()).toBe(25); }); });