const Fastify = require("fastify"); const dashboardRoutes = require("../../../src/routes/dashboard"); const { AppError } = require("../../../src/utils/AppError"); const paths = ["expense-overview", "trends", "top-categories", "approval-efficiency"]; async function buildApp(allowed = true) { const app = Fastify(); const prisma = { reimbursementForm: { count: vi.fn().mockResolvedValue(0), aggregate: vi.fn().mockResolvedValue({ _sum: { total_amount: null } }), groupBy: vi.fn().mockResolvedValue([]), findMany: vi.fn().mockResolvedValue([]) }, reimbursementItem: { groupBy: vi.fn().mockResolvedValue([]) }, expenseType: { findMany: vi.fn().mockResolvedValue([]) }, }; app.decorate("prisma", prisma); app.decorate("requirePermission", () => async (request) => { request.employee = { id: "synthetic", roleCodes: ["finance"], permissionCodes: ["finance.ledger.view"] }; if (!allowed) throw new AppError({ code: "PERM_001", statusCode: 403, message: "无权限" }); }); app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({ error: { code: error.code, details: error.details } })); await app.register(dashboardRoutes, { prefix: "/api" }); return { app, queries: Object.values(prisma).flatMap(Object.values) }; } describe("dashboard calendar and explanatory read errors", () => { it.each(paths)("%s rejects bad dates and reversed bounds before reads", async (path) => { const { app, queries } = await buildApp(); try { for (const field of ["dateFrom", "dateTo"]) for (const value of ["2026-02-29", "2026-04-31", "2026-13-01"]) { const response = await app.inject({ method: "GET", url: `/api/dashboard/${path}?${field}=${value}` }); expect(response.statusCode).toBe(400); expect(response.json().error).toEqual({ code: "VALID_001", details: { formErrors: [], fieldErrors: { [field]: ["invalid_calendar_date"] }, reasonCode: "dashboard.query_invalid" } }); } const reversed = await app.inject({ method: "GET", url: `/api/dashboard/${path}?dateFrom=2026-05-01&dateTo=2026-04-30` }); expect(reversed.statusCode).toBe(400); expect(reversed.json().error.details).toEqual({ dateTo: "must_be_after_dateFrom", reasonCode: "dashboard.date_range_invalid" }); for (const query of queries) expect(query).not.toHaveBeenCalled(); } finally { await app.close(); } }); it.each(paths)("%s preserves valid UTC bounds and omitted range", async (path) => { const { app, queries } = await buildApp(); try { const response = await app.inject({ method: "GET", url: `/api/dashboard/${path}?dateFrom=2024-02-29&dateTo=2026-04-30` }); expect(response.statusCode).toBe(200); const clauses = queries.flatMap((query) => query.mock.calls).map(([arg]) => arg.where.reimbursement_form ?? arg.where); expect(clauses.some((where) => where.AND.some((clause) => clause.reimbursement_date?.gte?.toISOString() === "2024-02-29T00:00:00.000Z" && clause.reimbursement_date?.lte?.toISOString() === "2026-04-30T23:59:59.999Z"))).toBe(true); for (const query of queries) query.mockClear(); expect((await app.inject({ method: "GET", url: `/api/dashboard/${path}` })).statusCode).toBe(200); const calls = queries.flatMap((query) => query.mock.calls); expect(calls.length).toBeGreaterThan(0); for (const [arg] of calls) expect((arg.where.reimbursement_form ?? arg.where).AND.some((clause) => clause.reimbursement_date)).toBe(false); } finally { await app.close(); } }); it.each(paths)("%s retains permission rejection before reads", async (path) => { const { app, queries } = await buildApp(false); try { expect((await app.inject({ method: "GET", url: `/api/dashboard/${path}` })).statusCode).toBe(403); for (const query of queries) expect(query).not.toHaveBeenCalled(); } finally { await app.close(); } }); });