实现报销材料合并预览与差旅概要(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
@@ -1,65 +1,100 @@
|
||||
const {
|
||||
MaterialPreviewError,
|
||||
createReimbursementMaterialPreviewStore,
|
||||
} = require("../../../src/services/reimbursementMaterialPreview");
|
||||
const { PDFDocument } = require("pdf-lib");
|
||||
const sharp = require("sharp");
|
||||
const { mergeMaterialsToPdf } = require("../../../src/services/reimbursementMaterialPdf");
|
||||
const { MaterialPreviewError, createReimbursementMaterialPreviewStore, runMergeWorker } = require("../../../src/services/reimbursementMaterialPreview");
|
||||
|
||||
describe("reimbursement material preview store", () => {
|
||||
it("keeps material and PDF page order in a bounded short-lived bundle", async () => {
|
||||
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("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 store = createReimbursementMaterialPreviewStore({
|
||||
now: () => currentTime,
|
||||
randomUUID: () => "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa",
|
||||
rasterizePdf: vi.fn().mockResolvedValue([Buffer.from("pdf-page-1"), Buffer.from("pdf-page-2")]),
|
||||
normalizeImage: vi.fn().mockResolvedValue(Buffer.from("image-page")),
|
||||
});
|
||||
|
||||
const created = await store.createBundle({
|
||||
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", materialIndex: 1, mimeType: "application/pdf", fileSize: 10 },
|
||||
{ id: "attachment-1", materialType: "attachment", materialIndex: 1, mimeType: "image/png", fileSize: 10 },
|
||||
],
|
||||
readSource: async (material) => Buffer.from(material.id),
|
||||
});
|
||||
|
||||
expect(created.bundle.pages.map((page) => [page.materialType, page.materialIndex, page.pageNumber])).toEqual([
|
||||
["invoice", 1, 1],
|
||||
["invoice", 1, 2],
|
||||
["attachment", 1, 1],
|
||||
]);
|
||||
expect(store.getPage({ bundleId: created.bundleId, pageIndex: 1 })?.page.buffer.toString()).toBe("pdf-page-2");
|
||||
|
||||
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.getPage({ bundleId: created.bundleId, pageIndex: 0 })).toBeNull();
|
||||
expect(store.getBundle({ bundleId: created.bundleId })).toBeNull();
|
||||
});
|
||||
|
||||
it("rejects source and page limits without keeping a partial bundle", async () => {
|
||||
const store = createReimbursementMaterialPreviewStore({
|
||||
limits: { maxMaterials: 1, maxSourceBytes: 10, maxPages: 1 },
|
||||
rasterizePdf: vi.fn().mockResolvedValue([Buffer.from("one"), Buffer.from("two")]),
|
||||
});
|
||||
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,
|
||||
actorId: "actor-1", reimbursementId: "reimbursement-1", expiresAt: Date.now() + 1_000,
|
||||
materials: [
|
||||
{ id: "one", materialType: "invoice", materialIndex: 1, mimeType: "application/pdf", fileSize: 6 },
|
||||
{ id: "two", materialType: "attachment", materialIndex: 1, mimeType: "image/png", fileSize: 6 },
|
||||
{ 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", materialIndex: 1, mimeType: "application/pdf", fileSize: 6 },
|
||||
],
|
||||
readSource: async () => Buffer.from("source"),
|
||||
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);
|
||||
});
|
||||
});
|
||||
|
||||
在新工单中引用
屏蔽一个用户