汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、PWA/采购事务、错误原因与日期校验及ERP退役。 统一共享预算池锁序和锁后判断,恢复差旅原预算选择,补原因码固定夹具及反向完整性检查。 纳入配套迁移、测试、受管QA、分层验收合同、规约及任务记录;原始证据留在ignored artifacts。 关联任务:09-17-company-loan-principal-repayment、09-17-erp-exclusive-retirement、09-18-reimbursement-periodic-numbering、09-08-reimbursement-payment-closeout、09-18-local-concurrency-product-fixes、09-19-budget-pool-concurrency、09-05-error-explainability-payment-readiness。 预算补审由Claude定向验证2文件928项通过,原4446/28及r6结果仍仅对应各自历史输入;本轮未重跑测试。 完整新候选门禁、R1真实入口接入与12项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
157 行
14 KiB
JavaScript
157 行
14 KiB
JavaScript
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);
|
|
});
|