const Fastify = require("fastify"); const holidayRoutes = require("../../../src/routes/masterDataHolidayCalendars"); const { AppError } = require("../../../src/utils/AppError"); const CALENDAR = "11111111-1111-4111-8111-111111111111"; const COMPANY = "22222222-2222-4222-8222-222222222222"; const URL = `/api/master-data/holiday-calendars/${CALENDAR}/days`; const day = (date) => ({ date, dayType: "holiday", name: "合成节假日" }); async function buildApp({ allowed = true, archived = false } = {}) { const app = Fastify(); const record = { id: CALENDAR, code: "SYNTHETIC", name: "合成日历", timezone: "Asia/Shanghai", enabled: true, archived_at: archived ? new Date("2026-01-01T00:00:00Z") : null }; const company = { id: COMPANY, code: "SYNTHETIC", archived_at: null, default_holiday_calendar_id: null }; const prisma = { holidayCalendar: { findUnique: vi.fn(async () => record), findMany: vi.fn(async () => [record]), create: vi.fn(async ({ data }) => ({ id: CALENDAR, ...data, legal_entity: data.legal_entity_id ? { ...company } : null })), update: vi.fn(async ({ data }) => ({ ...record, ...data })) }, legalEntity: { findUnique: vi.fn(async () => company), findFirst: vi.fn().mockResolvedValue(null), update: vi.fn().mockResolvedValue({}) }, holidayCalendarDay: { deleteMany: vi.fn(), createMany: vi.fn(), findMany: vi.fn().mockResolvedValue([]) }, auditLog: { create: vi.fn() }, }; prisma.$transaction = vi.fn(async (callback) => callback(prisma)); app.decorate("prisma", prisma); app.decorate("authenticate", async (request) => { request.employee = { id: "synthetic-admin" }; }); app.decorate("hasPermission", () => allowed); app.decorate("requirePermission", () => async (request) => { request.employee = { id: "synthetic-admin" }; if (!allowed) 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(holidayRoutes, { prefix: "/api" }); return { app, prisma, record, company }; } describe("holiday calendar batch date validation", () => { it.each(["2026-02-29", "2026-02-30", "2026-04-31", "2026-13-01", "", null, undefined])("rejects a batch containing %s before deleting any existing days", async (date) => { const { app, prisma } = await buildApp(); try { const response = await app.inject({ method: "PUT", url: URL, payload: { items: [day("2026-01-01"), day(date)] } }); expect(response.statusCode).toBe(400); expect(response.json().error.details.fieldErrors.items.length).toBeGreaterThan(0); expect(response.json().error.details.reasonCode).toBe("holiday_calendar.days_input_invalid"); expect(prisma.$transaction).not.toHaveBeenCalled(); expect(prisma.holidayCalendar.findUnique).not.toHaveBeenCalled(); expect(prisma.holidayCalendarDay.deleteMany).not.toHaveBeenCalled(); expect(prisma.holidayCalendarDay.createMany).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); } finally { await app.close(); } }); it("keeps leap-day/month-end replacement inside the existing transaction", async () => { const { app, prisma } = await buildApp(); try { const response = await app.inject({ method: "PUT", url: URL, payload: { items: [day("2024-02-29"), day("2024-04-30")] } }); expect(response.statusCode).toBe(200); expect(response.json().data).toEqual({ holidayCalendarId: CALENDAR, replacedCount: 2 }); expect(prisma.$transaction).toHaveBeenCalledTimes(1); expect(prisma.holidayCalendarDay.createMany.mock.calls[0][0].data.map((row) => row.date.toISOString())).toEqual(["2024-02-29T00:00:00.000Z", "2024-04-30T00:00:00.000Z"]); expect(prisma.holidayCalendarDay.deleteMany.mock.invocationCallOrder[0]).toBeLessThan(prisma.holidayCalendarDay.createMany.mock.invocationCallOrder[0]); expect(prisma.holidayCalendarDay.createMany.mock.invocationCallOrder[0]).toBeLessThan(prisma.auditLog.create.mock.invocationCallOrder[0]); } finally { await app.close(); } }); it("preserves duplicate-date conflict before the transaction", async () => { const { app, prisma } = await buildApp(); try { const response = await app.inject({ method: "PUT", url: URL, payload: { items: [day("2024-02-29"), day("2024-02-29")] } }); expect(response.statusCode).toBe(409); expect(response.json().error.details).toEqual({ date: "duplicate", reasonCode: "holiday_calendar.date_duplicate" }); expect(prisma.$transaction).not.toHaveBeenCalled(); } finally { await app.close(); } }); it.each([{ allowed: false }, { archived: true }])("keeps unavailable calendar guard %j without deleting days", async (options) => { const { app, prisma } = await buildApp(options); try { const response = await app.inject({ method: "PUT", url: URL, payload: { items: [day("2024-02-29")] } }); expect(response.statusCode).toBe(options.archived ? 409 : 403); if (options.archived) expect(response.json().error.details).toEqual({ status: "archived", reasonCode: "holiday_calendar.archived_locked" }); if (!options.allowed && !options.archived) expect(prisma.$transaction).not.toHaveBeenCalled(); expect(prisma.holidayCalendarDay.deleteMany).not.toHaveBeenCalled(); expect(prisma.holidayCalendarDay.createMany).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); } finally { await app.close(); } }); it("keeps empty replacement supported", async () => { const { app, prisma } = await buildApp(); try { const response = await app.inject({ method: "PUT", url: URL, payload: { items: [] } }); expect(response.statusCode).toBe(200); expect(response.json().data.replacedCount).toBe(0); expect(prisma.holidayCalendarDay.deleteMany).toHaveBeenCalledTimes(1); expect(prisma.holidayCalendarDay.createMany).not.toHaveBeenCalled(); expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); } finally { await app.close(); } }); }); const managedApps = []; afterEach(async () => { await Promise.all(managedApps.splice(0).map((app) => app.close())); }); async function managedApp(options) { const context = await buildApp(options); managedApps.push(context.app); return context; } const BASE = "/api/master-data/holiday-calendars"; function noWrites(prisma) { for (const method of [prisma.holidayCalendar.create, prisma.holidayCalendar.update, prisma.holidayCalendarDay.deleteMany, prisma.holidayCalendarDay.createMany, prisma.legalEntity.update, prisma.auditLog.create]) expect(method).not.toHaveBeenCalled(); } function expectError(response, status, reasonCode, details) { expect(response.statusCode).toBe(status); expect(response.json().error).toEqual({ code: "VALID_001", details: { ...details, reasonCode } }); } it.each([ ["GET", "?includeArchived=bad", undefined, "query_invalid"], ["POST", "", {}, "create_input_invalid"], ["PATCH", `/${CALENDAR}`, { name: "" }, "update_input_invalid"], ["PUT", `/${CALENDAR}/days`, { items: Array.from({ length: 501 }, () => day("2024-01-01")) }, "days_input_invalid"], ])("explains %s invalid calendar input", async (method, suffix, payload, reason) => { const { app, prisma } = await managedApp(); const response = await app.inject({ method, url: BASE + suffix, ...(payload ? { payload } : {}) }); expect(response.statusCode).toBe(400); expect(response.json().error.details).toMatchObject({ reasonCode: `holiday_calendar.${reason}`, formErrors: [], fieldErrors: expect.any(Object) }); noWrites(prisma); expect(prisma.$transaction).not.toHaveBeenCalled(); expect(prisma.holidayCalendar.findMany).not.toHaveBeenCalled(); expect(prisma.holidayCalendar.findUnique).not.toHaveBeenCalled(); }); it.each([["PATCH", "", { name: "合成" }], ["GET", "/days"], ["PUT", "/days", { items: [] }], ["POST", "/archive"]])("explains invalid calendar id on %s", async (method, suffix, payload) => { const { app, prisma } = await managedApp(); expectError(await app.inject({ method, url: `${BASE}/bad${suffix}`, ...(payload ? { payload } : {}) }), 400, "holiday_calendar.id_invalid", { id: "invalid_uuid" }); noWrites(prisma); expect(prisma.$transaction).not.toHaveBeenCalled(); expect(prisma.holidayCalendar.findUnique).not.toHaveBeenCalled(); }); it("explains empty patch", async () => { const { app, prisma } = await managedApp(); expectError(await app.inject({ method: "PATCH", url: `${BASE}/${CALENDAR}`, payload: {} }), 400, "holiday_calendar.update_empty", { body: "empty" }); noWrites(prisma); }); it("keeps archived calendar metadata locked", async () => { const { app, prisma } = await managedApp({ archived: true }); expectError(await app.inject({ method: "PATCH", url: `${BASE}/${CALENDAR}`, payload: { name: "合成" } }), 409, "holiday_calendar.archived_locked", { status: "archived" }); noWrites(prisma); }); it.each(["POST", "PATCH"].flatMap((method) => ["not_found", "archived"].map((state) => [method, state])))("explains %s company %s before writes", async (method, state) => { const { app, prisma, company } = await managedApp(); if (state === "not_found") prisma.legalEntity.findUnique.mockResolvedValue(null); else company.archived_at = new Date("2024-01-01T00:00:00Z"); expectError(await app.inject({ method, url: BASE + (method === "PATCH" ? `/${CALENDAR}` : ""), payload: { code: "TEST", name: "合成", legalEntityId: COMPANY } }), state === "not_found" ? 400 : 409, `legal_entity.reference_${state}`, { legalEntityId: state }); noWrites(prisma); }); it.each(["POST", "PATCH"])("explains %s code conflicts with no default reassignment or audit", async (method) => { const { app, prisma } = await managedApp(); prisma.holidayCalendar[method === "POST" ? "create" : "update"].mockRejectedValue({ code: "P2002" }); expectError(await app.inject({ method, url: BASE + (method === "PATCH" ? `/${CALENDAR}` : ""), payload: { code: "TEST", name: "合成", legalEntityId: COMPANY } }), 409, "holiday_calendar.code_duplicate", { code: "duplicate" }); expect(prisma.legalEntity.update).not.toHaveBeenCalled(); expect(prisma.auditLog.create).not.toHaveBeenCalled(); }); it("keeps default-calendar binding details and blocks archive", async () => { const { app, prisma } = await managedApp(); prisma.legalEntity.findFirst.mockResolvedValue({ id: COMPANY, code: "SYNTHETIC" }); expectError(await app.inject({ method: "POST", url: `${BASE}/${CALENDAR}/archive` }), 409, "holiday_calendar.bound_as_default", { holidayCalendar: "bound_as_default", legalEntityCode: "SYNTHETIC" }); noWrites(prisma); expect(prisma.legalEntity.findFirst).toHaveBeenCalledWith(expect.objectContaining({ where: { default_holiday_calendar_id: CALENDAR, archived_at: null } })); }); it.each([false, true])("preserves creation default assignment, existing default=%s", async (hasDefault) => { const { app, prisma, company } = await managedApp(); if (hasDefault) company.default_holiday_calendar_id = COMPANY; const response = await app.inject({ method: "POST", url: BASE, payload: { code: " test ", name: " 合成 ", legalEntityId: COMPANY } }); expect(response.statusCode).toBe(200); expect(prisma.holidayCalendar.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ code: "TEST", name: "合成", timezone: "Asia/Shanghai", enabled: true }) })); expect(prisma.legalEntity.update).toHaveBeenCalledTimes(hasDefault ? 0 : 1); expect(response.json().data.holidayCalendar.isDefaultForLegalEntity).toBe(!hasDefault); expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); }); it("preserves nullable company patch without changing default assignment", async () => { const { app, prisma } = await managedApp(); const response = await app.inject({ method: "PATCH", url: `${BASE}/${CALENDAR}`, payload: { legalEntityId: null, name: " 合成 " } }); expect(response.statusCode).toBe(200); expect(prisma.holidayCalendar.update).toHaveBeenCalledWith(expect.objectContaining({ data: { name: "合成", legal_entity_id: null, updated_by: "synthetic-admin" } })); expect(prisma.legalEntity.findUnique).not.toHaveBeenCalled(); expect(prisma.legalEntity.update).not.toHaveBeenCalled(); expect(prisma.auditLog.create).toHaveBeenCalledTimes(1); }); it.each([false, true])("preserves archive success and idempotence, archived=%s", async (archived) => { const { app, prisma } = await managedApp({ archived }); const response = await app.inject({ method: "POST", url: `${BASE}/${CALENDAR}/archive` }); expect(response.statusCode).toBe(200); expect(response.json().data.outcome).toBe(archived ? "already_archived" : "archived"); expect(prisma.holidayCalendar.update).toHaveBeenCalledTimes(archived ? 0 : 1); expect(prisma.auditLog.create).toHaveBeenCalledTimes(archived ? 0 : 1); }); it.each([["PATCH", "", { name: "合成" }], ["GET", "/days"], ["PUT", "/days", { items: [] }], ["POST", "/archive"]])("preserves %s missing calendar 404", async (method, suffix, payload) => { const { app, prisma } = await managedApp(); prisma.holidayCalendar.findUnique.mockResolvedValue(null); const response = await app.inject({ method, url: `${BASE}/${CALENDAR}${suffix}`, ...(payload ? { payload } : {}) }); expect(response.statusCode).toBe(404); expect(response.json().error.code).toBe("SYS_404"); noWrites(prisma); expect(prisma.holidayCalendarDay.findMany).not.toHaveBeenCalled(); }); it("preserves calendar/day list success and filters", async () => { const { app, prisma } = await managedApp(); expect((await app.inject({ method: "GET", url: `${BASE}?enabled=1&legalEntityId=${COMPANY}` })).statusCode).toBe(200); expect(prisma.holidayCalendar.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { enabled: true, legal_entity_id: COMPANY, archived_at: null } })); expect((await app.inject({ method: "GET", url: URL })).statusCode).toBe(200); noWrites(prisma); });