实现报销材料连续预览(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
@@ -44,19 +44,22 @@ function buildMultipartRequest({ files, fields = {} }) {
|
||||
};
|
||||
}
|
||||
|
||||
async function buildApp({ reimbursementStatus = "draft" } = {}) {
|
||||
async function buildApp({ reimbursementStatus = "draft", materialPreviewStore = null } = {}) {
|
||||
const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), "hyb-oa-r111-reimbursement-attachments-"));
|
||||
const reimbursementId = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
|
||||
const createdAttachments = [];
|
||||
|
||||
const prisma = {
|
||||
reimbursementForm: {
|
||||
findUnique: vi.fn().mockResolvedValue({
|
||||
findUnique: vi.fn().mockImplementation(async ({ select }) => ({
|
||||
id: reimbursementId,
|
||||
owner_id: ACTOR_ID,
|
||||
submitter_id: ACTOR_ID,
|
||||
status: reimbursementStatus,
|
||||
}),
|
||||
...(select?.invoice_pool_items
|
||||
? { invoice_pool_items: [], attachments: createdAttachments }
|
||||
: {}),
|
||||
})),
|
||||
},
|
||||
reimbursementAttachment: {
|
||||
create: vi.fn().mockImplementation(async ({ data, select }) => {
|
||||
@@ -141,6 +144,9 @@ async function buildApp({ reimbursementStatus = "draft" } = {}) {
|
||||
|
||||
const app = Fastify();
|
||||
app.decorate("prisma", prisma);
|
||||
if (materialPreviewStore) {
|
||||
app.decorate("reimbursementMaterialPreviewStore", materialPreviewStore);
|
||||
}
|
||||
app.decorate("config", {
|
||||
appTimezone: "Asia/Shanghai",
|
||||
storageDir: tempDir,
|
||||
@@ -612,10 +618,18 @@ describe("reimbursement attachment routes", () => {
|
||||
headers: { authorization: "Bearer test", "content-type": "application/json" },
|
||||
payload: {},
|
||||
});
|
||||
const missingBundleReason = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/reimbursements/${reimbursementId}/materials/preview-bundle`,
|
||||
headers: { authorization: "Bearer test", "content-type": "application/json" },
|
||||
payload: {},
|
||||
});
|
||||
|
||||
expect(unauthenticated.statusCode).toBe(401);
|
||||
expect(missingReason.statusCode).toBe(400);
|
||||
expect(missingReason.json().error.details).toEqual({ reason: "required" });
|
||||
expect(missingBundleReason.statusCode).toBe(400);
|
||||
expect(missingBundleReason.json().error.details).toEqual({ reason: "required" });
|
||||
expect(prisma.auditLog.create).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
await app.close();
|
||||
@@ -682,4 +696,109 @@ describe("reimbursement attachment routes", () => {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("issues an ordered image bundle and audits each token-bound page access", async () => {
|
||||
const bundleId = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee";
|
||||
const previewBuffer = Buffer.from("synthetic-png-page");
|
||||
const materialPreviewStore = {
|
||||
createBundle: vi.fn().mockImplementation(async ({ actorId, reimbursementId, materials, expiresAt }) => ({
|
||||
bundleId,
|
||||
bundle: {
|
||||
actorId,
|
||||
reimbursementId,
|
||||
expiresAt,
|
||||
pages: [{
|
||||
buffer: previewBuffer,
|
||||
materialId: materials[0].id,
|
||||
materialType: materials[0].materialType,
|
||||
materialIndex: materials[0].materialIndex,
|
||||
pageNumber: 1,
|
||||
}],
|
||||
},
|
||||
})),
|
||||
getPage: vi.fn(),
|
||||
};
|
||||
const { app, prisma, tempDir, reimbursementId } = await buildApp({ materialPreviewStore });
|
||||
|
||||
try {
|
||||
const requestPayload = buildMultipartRequest({
|
||||
files: [{ filename: "payment.png", contentType: "image/png", content: "png-content" }],
|
||||
});
|
||||
await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/reimbursements/${reimbursementId}/attachments`,
|
||||
headers: { authorization: "Bearer test", ...requestPayload.headers },
|
||||
payload: requestPayload.payload,
|
||||
});
|
||||
|
||||
const issued = await app.inject({
|
||||
method: "POST",
|
||||
url: `/api/reimbursements/${reimbursementId}/materials/preview-bundle`,
|
||||
headers: { authorization: "Bearer test", "content-type": "application/json" },
|
||||
payload: {},
|
||||
});
|
||||
expect(issued.statusCode).toBe(200);
|
||||
expect(issued.json().data.pages).toEqual([
|
||||
expect.objectContaining({ materialType: "attachment", materialIndex: 1, pageNumber: 1 }),
|
||||
]);
|
||||
const pageUrl = new URL(issued.json().data.pages[0].url);
|
||||
const verified = verifyToken(pageUrl.searchParams.get("previewToken"), JWT_SECRET);
|
||||
expect(verified).toEqual(expect.objectContaining({
|
||||
ok: true,
|
||||
payload: expect.objectContaining({
|
||||
purpose: "reimbursement_material_preview",
|
||||
reimbursementId,
|
||||
bundleId,
|
||||
pageIndex: 0,
|
||||
actorId: ACTOR_ID,
|
||||
}),
|
||||
}));
|
||||
|
||||
const issuedToken = pageUrl.searchParams.get("previewToken");
|
||||
const tamperedToken = `${issuedToken.slice(0, -1)}${issuedToken.endsWith("a") ? "b" : "a"}`;
|
||||
const tamperedResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `${pageUrl.pathname}?previewToken=${encodeURIComponent(tamperedToken)}`,
|
||||
});
|
||||
expect(tamperedResponse.statusCode).toBe(403);
|
||||
expect(materialPreviewStore.getPage).not.toHaveBeenCalled();
|
||||
|
||||
const missingBundleResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `${pageUrl.pathname}${pageUrl.search}`,
|
||||
});
|
||||
expect(missingBundleResponse.statusCode).toBe(403);
|
||||
|
||||
materialPreviewStore.getPage.mockReturnValue({
|
||||
bundle: {
|
||||
actorId: ACTOR_ID,
|
||||
reimbursementId,
|
||||
expiresAt: verified.payload.exp,
|
||||
},
|
||||
page: {
|
||||
buffer: previewBuffer,
|
||||
materialId: "bbbbbbbb-bbbb-4bbb-8bbb-000000000001",
|
||||
materialType: "attachment",
|
||||
materialIndex: 1,
|
||||
pageNumber: 1,
|
||||
},
|
||||
});
|
||||
const pageResponse = await app.inject({ method: "GET", url: `${pageUrl.pathname}${pageUrl.search}` });
|
||||
expect(pageResponse.statusCode).toBe(200);
|
||||
expect(pageResponse.rawPayload).toEqual(previewBuffer);
|
||||
expect(pageResponse.headers["content-type"]).toContain("image/png");
|
||||
expect(pageResponse.headers["cache-control"]).toBe("private, no-store");
|
||||
expect(prisma.auditLog.create).toHaveBeenCalledWith(expect.objectContaining({
|
||||
data: expect.objectContaining({ action: "REIMBURSEMENT_MATERIAL_PREVIEWED", actor_id: ACTOR_ID }),
|
||||
}));
|
||||
|
||||
prisma.auditLog.create.mockRejectedValueOnce(new Error("synthetic audit outage"));
|
||||
const auditFailureResponse = await app.inject({ method: "GET", url: `${pageUrl.pathname}${pageUrl.search}` });
|
||||
expect(auditFailureResponse.statusCode).toBe(500);
|
||||
expect(auditFailureResponse.body).not.toContain(previewBuffer.toString());
|
||||
} finally {
|
||||
await app.close();
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,25 @@
|
||||
const { mapInvoiceDocumentMeta } = require("../../../src/services/invoiceDocumentMeta");
|
||||
|
||||
describe("invoice document metadata", () => {
|
||||
it("exposes only supported document categories and a bounded subtype", () => {
|
||||
expect(mapInvoiceDocumentMeta({
|
||||
ocr_result: {
|
||||
result_json: {
|
||||
documentCategory: " electronic_train_ticket ",
|
||||
documentSubtype: `ElectronicTrainTicketFull${"x".repeat(80)}`,
|
||||
raw: { sensitive: true },
|
||||
},
|
||||
},
|
||||
})).toEqual({
|
||||
documentCategory: "electronic_train_ticket",
|
||||
documentSubtype: `ElectronicTrainTicketFull${"x".repeat(39)}`,
|
||||
});
|
||||
});
|
||||
|
||||
it("fails closed for unknown, blank, or malformed OCR metadata", () => {
|
||||
expect(mapInvoiceDocumentMeta({
|
||||
ocr_result: { result_json: { documentCategory: "unknown_invoice", documentSubtype: " " } },
|
||||
})).toEqual({ documentCategory: null, documentSubtype: null });
|
||||
expect(mapInvoiceDocumentMeta(null)).toEqual({ documentCategory: null, documentSubtype: null });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,65 @@
|
||||
const {
|
||||
MaterialPreviewError,
|
||||
createReimbursementMaterialPreviewStore,
|
||||
} = require("../../../src/services/reimbursementMaterialPreview");
|
||||
|
||||
describe("reimbursement material preview store", () => {
|
||||
it("keeps material and PDF page order in a bounded short-lived bundle", 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({
|
||||
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");
|
||||
|
||||
currentTime = 2_001;
|
||||
expect(store.getPage({ bundleId: created.bundleId, pageIndex: 0 })).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")]),
|
||||
});
|
||||
|
||||
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 },
|
||||
{ id: "two", materialType: "attachment", materialIndex: 1, mimeType: "image/png", fileSize: 6 },
|
||||
],
|
||||
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"),
|
||||
})).rejects.toBeInstanceOf(MaterialPreviewError);
|
||||
});
|
||||
});
|
||||
在新工单中引用
屏蔽一个用户