实现报销材料合并预览与差旅概要(07-27-yang-jun-reimbursement-readiness)
这个提交包含在:
@@ -697,9 +697,9 @@ describe("reimbursement attachment routes", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("issues an ordered image bundle and audits each token-bound page access", async () => {
|
||||
it("issues one token-bound PDF bundle with exact size and audits access", async () => {
|
||||
const bundleId = "eeeeeeee-eeee-4eee-8eee-eeeeeeeeeeee";
|
||||
const previewBuffer = Buffer.from("synthetic-png-page");
|
||||
const previewBuffer = Buffer.from("%PDF synthetic merged preview");
|
||||
const materialPreviewStore = {
|
||||
createBundle: vi.fn().mockImplementation(async ({ actorId, reimbursementId, materials, expiresAt }) => ({
|
||||
bundleId,
|
||||
@@ -707,16 +707,11 @@ describe("reimbursement attachment routes", () => {
|
||||
actorId,
|
||||
reimbursementId,
|
||||
expiresAt,
|
||||
pages: [{
|
||||
buffer: previewBuffer,
|
||||
materialId: materials[0].id,
|
||||
materialType: materials[0].materialType,
|
||||
materialIndex: materials[0].materialIndex,
|
||||
pageNumber: 1,
|
||||
}],
|
||||
pageCount: 1,
|
||||
},
|
||||
fileSize: previewBuffer.length,
|
||||
})),
|
||||
getPage: vi.fn(),
|
||||
getBundle: vi.fn(),
|
||||
};
|
||||
const { app, prisma, tempDir, reimbursementId } = await buildApp({ materialPreviewStore });
|
||||
|
||||
@@ -738,62 +733,58 @@ describe("reimbursement attachment routes", () => {
|
||||
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(issued.json().data).toEqual(expect.objectContaining({
|
||||
mimeType: "application/pdf",
|
||||
fileSize: previewBuffer.length,
|
||||
pageCount: 1,
|
||||
}));
|
||||
const previewUrl = new URL(issued.json().data.signedUrl);
|
||||
const verified = verifyToken(previewUrl.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 issuedToken = previewUrl.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)}`,
|
||||
url: `${previewUrl.pathname}?previewToken=${encodeURIComponent(tamperedToken)}`,
|
||||
});
|
||||
expect(tamperedResponse.statusCode).toBe(403);
|
||||
expect(materialPreviewStore.getPage).not.toHaveBeenCalled();
|
||||
expect(materialPreviewStore.getBundle).not.toHaveBeenCalled();
|
||||
|
||||
const missingBundleResponse = await app.inject({
|
||||
method: "GET",
|
||||
url: `${pageUrl.pathname}${pageUrl.search}`,
|
||||
url: `${previewUrl.pathname}${previewUrl.search}`,
|
||||
});
|
||||
expect(missingBundleResponse.statusCode).toBe(403);
|
||||
|
||||
materialPreviewStore.getPage.mockReturnValue({
|
||||
materialPreviewStore.getBundle.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,
|
||||
},
|
||||
artifact: { buffer: previewBuffer },
|
||||
});
|
||||
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");
|
||||
const previewResponse = await app.inject({ method: "GET", url: `${previewUrl.pathname}${previewUrl.search}` });
|
||||
expect(previewResponse.statusCode).toBe(200);
|
||||
expect(previewResponse.rawPayload).toEqual(previewBuffer);
|
||||
expect(previewResponse.headers["content-type"]).toContain("application/pdf");
|
||||
expect(Number(previewResponse.headers["content-length"])).toBe(previewBuffer.length);
|
||||
expect(previewResponse.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}` });
|
||||
const auditFailureResponse = await app.inject({ method: "GET", url: `${previewUrl.pathname}${previewUrl.search}` });
|
||||
expect(auditFailureResponse.statusCode).toBe(500);
|
||||
expect(auditFailureResponse.body).not.toContain(previewBuffer.toString());
|
||||
} finally {
|
||||
|
||||
@@ -238,6 +238,13 @@ describe("GET /api/reimbursements/:id detail enhancements", () => {
|
||||
},
|
||||
createdAt: "2026-04-10T09:00:00.000Z",
|
||||
submittedAt: "2026-04-10T10:00:00.000Z",
|
||||
travelSummary: {
|
||||
isTravel: true,
|
||||
purpose: "客户拜访报销",
|
||||
location: null,
|
||||
startDate: "2026-04-09",
|
||||
endDate: "2026-04-09",
|
||||
},
|
||||
invoices: [
|
||||
{
|
||||
id: "invoice-1",
|
||||
|
||||
@@ -99,6 +99,9 @@ async function buildApp(options = {}) {
|
||||
invoicePool: {
|
||||
count: vi.fn(async () => 1),
|
||||
},
|
||||
reimbursementItem: {
|
||||
findFirst: vi.fn(async () => (options.travelItem ? { id: "travel-item" } : null)),
|
||||
},
|
||||
budgetLedgerEntry: {
|
||||
create: vi.fn(async () => ({ id: "budget-ledger-1" })),
|
||||
},
|
||||
@@ -230,6 +233,18 @@ describe("reimbursements routes expense standard integration", () => {
|
||||
resolveRecipientsForAssigneeMock.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("blocks travel reimbursement submit until the structured summary is complete", async () => {
|
||||
const { app, tx } = await buildApp({ travelItem: true });
|
||||
const response = await app.inject({ method: "POST", url: `/api/reimbursements/${REIMBURSEMENT_ID}/submit` });
|
||||
expect(response.statusCode).toBe(400);
|
||||
expect(response.json().error.details).toEqual(expect.objectContaining({
|
||||
travelSummary: "incomplete",
|
||||
missingFields: ["travelPurpose", "travelLocation", "travelStartDate", "travelEndDate"],
|
||||
}));
|
||||
expect(tx.workflowInstance.create).not.toHaveBeenCalled();
|
||||
await app.close();
|
||||
});
|
||||
|
||||
it("returns standardChecks from reimbursement precheck", async () => {
|
||||
resolveEntityPrecheckMock.mockResolvedValue(
|
||||
buildPrecheck({
|
||||
|
||||
在新工单中引用
屏蔽一个用户