const Fastify = require("fastify"); const exportsRoutes = require("../../../src/routes/exports"); const { AppError } = require("../../../src/utils/AppError"); async function buildApp({ allowed = true } = {}) { const app = Fastify(); const actor = { id: "synthetic-viewer", roleCodes: ["finance"], permissionCodes: allowed ? ["report.dashboard.view"] : [] }; const prisma = { reimbursementForm: { count: vi.fn().mockResolvedValue(0), findMany: vi.fn().mockResolvedValue([]) }, auditLog: { create: vi.fn() }, }; app.decorate("prisma", prisma); app.decorate("authenticate", async (request) => { request.employee = actor; }); app.decorate("hasPermission", (employee, permission) => employee.permissionCodes.includes(permission)); app.decorate("requirePermission", (permission) => async (request) => { request.employee = actor; if (!actor.permissionCodes.includes(permission)) throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); }); app.setErrorHandler((error, request, reply) => reply.status(error.statusCode || 500).send({ error: { code: error.code, details: error.details } })); await app.register(exportsRoutes, { prefix: "/api" }); return { app, prisma }; } describe("dashboard export calendar contract", () => { it.each(["csv", "xlsx"].flatMap((format) => ["dateFrom", "dateTo"].flatMap((field) => ["2026-02-29", "2026-04-31", "2026-13-01", ""].map((date) => [format, field, date]))))("rejects %s %s=%s before reads or export audit", async (format, field, date) => { const { app, prisma } = await buildApp(); try { const response = await app.inject({ method: "GET", url: `/api/exports/dashboard/reimbursements?format=${format}&${field}=${date}` }); expect(response.statusCode).toBe(400); expect(response.json().error.details.fieldErrors[field].length).toBeGreaterThan(0); expect(prisma.reimbursementForm.count).not.toHaveBeenCalled(); expect(prisma.reimbursementForm.findMany).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); expect(response.headers["content-disposition"]).toBeUndefined(); } finally { await app.close(); } }); it.each(["csv", "xlsx"])("preserves %s leap-day/month-end UTC bounds", async (format) => { const { app, prisma } = await buildApp(); try { const response = await app.inject({ method: "GET", url: `/api/exports/dashboard/reimbursements?format=${format}&dateFrom=2024-02-29&dateTo=2024-04-30` }); expect(response.statusCode).toBe(200); expect(prisma.reimbursementForm.count.mock.calls[0][0].where.AND).toContainEqual({ reimbursement_date: { gte: new Date("2024-02-29T00:00:00.000Z"), lte: new Date("2024-04-30T23:59:59.999Z") } }); expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); } finally { await app.close(); } }); it("preserves omitted dates and reversed-range status/details", async () => { const { app, prisma } = await buildApp(); try { const omitted = await app.inject({ method: "GET", url: "/api/exports/dashboard/reimbursements" }); expect(omitted.statusCode).toBe(200); expect(prisma.reimbursementForm.count.mock.calls[0][0].where.AND.some((clause) => Object.hasOwn(clause, "reimbursement_date"))).toBe(false); for (const fn of [...Object.values(prisma.reimbursementForm), prisma.auditLog.create]) fn.mockClear(); const reversed = await app.inject({ method: "GET", url: "/api/exports/dashboard/reimbursements?dateFrom=2024-05-01&dateTo=2024-04-30" }); expect(reversed.statusCode).toBe(400); expect(reversed.json().error.details).toEqual({ dateTo: "must_be_after_dateFrom", reasonCode: "export.dashboard_date_range_invalid" }); for (const fn of [...Object.values(prisma.reimbursementForm), prisma.auditLog.create]) expect(fn).not.toHaveBeenCalled(); } finally { await app.close(); } }); it("preserves permission rejection without export reads", async () => { const { app, prisma } = await buildApp({ allowed: false }); try { const response = await app.inject({ method: "GET", url: "/api/exports/dashboard/reimbursements?dateFrom=2024-02-29" }); expect(response.statusCode).toBe(403); for (const fn of [...Object.values(prisma.reimbursementForm), prisma.auditLog.create]) expect(fn).not.toHaveBeenCalled(); } finally { await app.close(); } }); });