文件
hyb-oa/server/tests/unit/routes/employees.calendar.test.js
T
wangsli 1f8d5b658a 完善借款、编号、付款与预算并发及验收(09-06-release-gate-integrity)
汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、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项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
2026-09-19 13:11:04 +08:00

203 行
15 KiB
JavaScript

const Fastify = require("fastify");
const employeesRoutes = require("../../../src/routes/employees");
const { AppError } = require("../../../src/utils/AppError");
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
const RECORD_ID = "22222222-2222-4222-8222-222222222222";
const baseUrl = `/api/employees/${EMPLOYEE_ID}`;
const contracts = { contractNo: "SYNTHETIC", contractType: "labor", startDate: "2024-02-29", endDate: "2026-04-30" };
const trainings = { name: "合成培训", trainingType: "internal", status: "planned", startDate: "2024-02-29", endDate: "2026-04-30" };
const certificates = { name: "合成证书", issuer: "合成机构", obtainedDate: "2024-02-29", expiryDate: "2026-04-30" };
const archiveCases = [
["contracts", contracts, "employeeContract", ["startDate", "endDate", "terminatedDate"]],
["trainings", trainings, "employeeTraining", ["startDate", "endDate"]],
["certificates", certificates, "employeeCertificate", ["obtainedDate", "expiryDate"]],
];
const lifecyclePaths = ["onboarding/start", "regularize", "transfer", "offboarding/start"];
const invalidCases = [
...["hireDate", "probationEndDate", "lastWorkingDate"].map((field) => ["PATCH", "profile", {}, field]),
...lifecyclePaths.map((route) => ["POST", route, {}, "effectiveDate"]),
["POST", "onboarding/start", {}, "probationEndDate"],
["POST", "offboarding/start", {}, "lastWorkingDate"],
...archiveCases.flatMap(([route, payload, , fields]) => fields.flatMap((field) => [
["POST", route, payload, field], ["PATCH", `${route}/${RECORD_ID}`, {}, field],
])),
];
async function buildApp({ allowed = true, self = false } = {}) {
const app = Fastify();
const employee = { id: EMPLOYEE_ID, roles: [], department_id: null, status: "active", employment_status: "active", hire_date: new Date("2020-01-01T00:00:00Z") };
const prisma = { featureFlag: { findUnique: vi.fn().mockResolvedValue({ override_mode: "force_on", rollout_type: "global", department_ids: [] }) } };
for (const model of ["employee", "employeeContract", "employeeTraining", "employeeCertificate", "employeeLifecycleEvent", "employeeChecklistTask", "employeeRole", "auditLog", "wecomDirectorySyncRun", "assetCard", "workflowTask"]) {
const row = model === "employee" ? employee : { id: RECORD_ID, employee_id: EMPLOYEE_ID, start_date: new Date("2024-02-29T00:00:00Z"), end_date: new Date("2026-04-30T00:00:00Z"), obtained_date: new Date("2024-02-29T00:00:00Z"), expiry_date: new Date("2026-04-30T00:00:00Z") };
prisma[model] = {
findUnique: vi.fn().mockResolvedValue(row), findFirst: vi.fn().mockResolvedValue(row),
findMany: vi.fn().mockResolvedValue([]), count: vi.fn().mockResolvedValue(0),
create: vi.fn(async ({ data }) => ({ ...row, ...data })),
update: vi.fn(async ({ data }) => Object.assign(row, Object.fromEntries(Object.entries(data).filter(([, value]) => value !== undefined)))),
createMany: vi.fn().mockResolvedValue({ count: 1 }), deleteMany: vi.fn().mockResolvedValue({ count: 0 }),
};
}
prisma.$transaction = vi.fn(async (fn) => fn(prisma));
const authenticate = async (request) => { request.employee = { id: self ? EMPLOYEE_ID : "synthetic-actor", permissionCodes: allowed ? ["system.users", "employee.contract.manage"] : [] }; };
app.decorate("config", { employeeLifecycleEnabled: true, appTimezone: "Asia/Shanghai", wecomDirectorySyncEnabled: false });
app.decorate("prisma", prisma);
app.decorate("authenticate", authenticate);
app.decorate("hasPermission", (actor, permission) => actor.permissionCodes.includes(permission));
app.decorate("requirePermission", (permission) => async (request) => {
await authenticate(request);
if (!app.hasPermission(request.employee, permission)) 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(employeesRoutes, { prefix: "/api" });
return { app, prisma };
}
function expectNoWrites(prisma) {
expect(prisma.$transaction).not.toHaveBeenCalled();
for (const model of Object.values(prisma)) for (const operation of ["create", "update", "createMany", "deleteMany"]) if (model[operation]) expect(model[operation]).not.toHaveBeenCalled();
}
describe("employee calendar inputs before side effects", () => {
it.each(invalidCases)("%s %s rejects invalid %s %s", async (method, route, payload, field) => {
const { app, prisma } = await buildApp();
try {
for (const date of ["2026-02-29", "2026-04-31"]) {
const response = await app.inject({ method, url: `${baseUrl}/${route}`, payload: { ...payload, [field]: date } });
expect(response.statusCode).toBe(400);
expect(response.json().error.code).toBe("VALID_001");
const kind = route.split("/")[0];
const reason = { profile: "profile_input_invalid", onboarding: "onboarding_input_invalid", regularize: "regularize_input_invalid", transfer: "transfer_input_invalid", offboarding: "offboarding_input_invalid" }[kind]
?? `${{ contracts: "contract", trainings: "training", certificates: "certificate" }[kind]}_${method === "POST" ? "create" : "update"}_input_invalid`;
expect(response.json().error.details.reasonCode).toBe(`employee.${reason}`);
expect(response.json().error.details.fieldErrors[field]).toContain("invalid_calendar_date");
expectNoWrites(prisma);
expect(prisma.employee.findUnique).not.toHaveBeenCalled();
}
} finally { await app.close(); }
});
it.each(lifecyclePaths.flatMap((route) => ["2024-02-29", "2026-04-30", undefined].map((date) => [route, date])))(
"%s preserves effective date %s and its original lifecycle event", async (route, effectiveDate) => {
const { app, prisma } = await buildApp();
try {
const before = Date.now();
const response = await app.inject({ method: "POST", url: `${baseUrl}/${route}`, payload: effectiveDate ? { effectiveDate } : {} });
expect(response.statusCode).toBe(200);
expect(prisma.employeeLifecycleEvent.create).toHaveBeenCalledTimes(1);
const data = prisma.employeeLifecycleEvent.create.mock.calls[0][0].data;
expect(data.event_type).toBe({ "onboarding/start": "onboard", regularize: "regularize", transfer: "transfer", "offboarding/start": "offboard" }[route]);
if (effectiveDate) expect(data.effective_date.toISOString()).toBe(`${effectiveDate}T00:00:00.000Z`);
else { expect(data.effective_date.getTime()).toBeGreaterThanOrEqual(before); expect(data.effective_date.getTime()).toBeLessThanOrEqual(Date.now()); }
expect(prisma.wecomDirectorySyncRun.create).not.toHaveBeenCalled();
} finally { await app.close(); }
},
);
it.each(archiveCases)("%s accepts valid boundaries and nullable patches", async (route, payload, model) => {
const { app, prisma } = await buildApp();
try {
expect((await app.inject({ method: "POST", url: `${baseUrl}/${route}`, payload })).statusCode).toBe(200);
const data = prisma[model].create.mock.calls[0][0].data;
expect((data.start_date ?? data.obtained_date).toISOString()).toBe("2024-02-29T00:00:00.000Z");
expect((data.end_date ?? data.expiry_date).toISOString()).toBe("2026-04-30T00:00:00.000Z");
const field = route === "contracts" ? "terminatedDate" : route === "trainings" ? "endDate" : "expiryDate";
expect((await app.inject({ method: "PATCH", url: `${baseUrl}/${route}/${RECORD_ID}`, payload: { [field]: null } })).statusCode).toBe(200);
const updated = prisma[model].update.mock.calls[0][0].data;
expect(updated[route === "contracts" ? "terminated_date" : route === "trainings" ? "end_date" : "expiry_date"]).toBeNull();
expect(updated).not.toHaveProperty(route === "certificates" ? "obtained_date" : "start_date");
} finally { await app.close(); }
});
it.each(["hireDate", "probationEndDate", "lastWorkingDate"])("profile preserves %s valid/null/omitted", async (field) => {
const { app, prisma } = await buildApp();
const column = { hireDate: "hire_date", probationEndDate: "probation_end_date", lastWorkingDate: "last_working_date" }[field];
try {
for (const date of ["2024-02-29", "2026-04-30", null]) {
expect((await app.inject({ method: "PATCH", url: `${baseUrl}/profile`, payload: { [field]: date } })).statusCode).toBe(200);
const data = prisma.employee.update.mock.calls.at(-1)[0].data;
expect(data[column]).toEqual(date ? new Date(`${date}T00:00:00Z`) : null);
}
expect((await app.inject({ method: "PATCH", url: `${baseUrl}/profile`, payload: { notes: "synthetic" } })).statusCode).toBe(200);
expect(prisma.employee.update.mock.calls.at(-1)[0].data).not.toHaveProperty(column);
} finally { await app.close(); }
});
it.each(lifecyclePaths)("%s preserves unauthorized refusal", async (route) => {
const { app, prisma } = await buildApp({ allowed: false });
try {
expect((await app.inject({ method: "POST", url: `${baseUrl}/${route}`, payload: { effectiveDate: "2024-02-29" } })).statusCode).toBe(403);
expectNoWrites(prisma);
} finally { await app.close(); }
});
});
describe("employee archive and lifecycle error reasons", () => {
it.each([
["contracts", { ...contracts, endDate: "2024-02-28" }, "employee.contract_date_range_invalid", { endDate: "must_be_after_or_equal_startDate" }],
["contracts", { ...contracts, terminatedDate: "2024-02-28" }, "employee.contract_termination_date_invalid", { terminatedDate: "must_be_after_or_equal_startDate" }],
["trainings", { ...trainings, endDate: "2024-02-28" }, "employee.training_date_range_invalid", { endDate: "must_be_after_or_equal_startDate" }],
["certificates", { ...certificates, expiryDate: "2024-02-28" }, "employee.certificate_date_range_invalid", { expiryDate: "must_be_after_or_equal_obtainedDate" }],
])("retains the date range guard for %s", async (route, payload, reasonCode, details) => {
const { app, prisma } = await buildApp();
try {
const response = await app.inject({ method: "POST", url: `${baseUrl}/${route}`, payload });
expect(response.statusCode).toBe(400); expect(response.json().error.details).toEqual({ ...details, reasonCode }); expectNoWrites(prisma);
} finally { await app.close(); }
});
it.each(["POST", "PATCH"])("explains duplicate contract numbers on %s", async (method) => {
const { app, prisma } = await buildApp();
try {
const write = method === "POST" ? prisma.employeeContract.create : prisma.employeeContract.update;
write.mockRejectedValue({ code: "P2002" });
const response = await app.inject({ method, url: `${baseUrl}/contracts${method === "PATCH" ? `/${RECORD_ID}` : ""}`, payload: contracts });
expect(response.statusCode).toBe(400); expect(response.json().error.details).toEqual({ contractNo: "duplicate", reasonCode: "employee.contract_number_duplicate" });
expect(write).toHaveBeenCalledTimes(1); expect(prisma.auditLog.create).not.toHaveBeenCalled();
} finally { await app.close(); }
});
it("rejects self-only forbidden fields before persistence", async () => {
const { app, prisma } = await buildApp({ allowed: false, self: true });
try {
const response = await app.inject({ method: "PATCH", url: `${baseUrl}/profile`, payload: { hireDate: "2024-02-29" } });
expect(response.statusCode).toBe(403); expect(response.json().error.details).toEqual({ body: "self_profile_fields_forbidden:hireDate", reasonCode: "employee.self_profile_fields_forbidden" }); expectNoWrites(prisma);
} finally { await app.close(); }
});
it("preserves invalid id and lifecycle disabled guards", async () => {
const { app, prisma } = await buildApp();
try {
const response = await app.inject({ method: "PATCH", url: "/api/employees/bad/profile", payload: { notes: "合成" } });
expect(response.statusCode).toBe(400); expect(response.json().error.details).toEqual({ id: "invalid_uuid", reasonCode: "employee.id_invalid" });
prisma.featureFlag.findUnique.mockResolvedValue({ override_mode: "force_off", rollout_type: "global", department_ids: [] });
const disabled = await app.inject({ method: "PATCH", url: `${baseUrl}/profile`, payload: { notes: "合成" } });
expect(disabled.statusCode).toBe(409); expect(disabled.json().error.details).toEqual({ featureFlag: "employee_lifecycle_v1_disabled", reason: "flag_force_off", reasonCode: "employee.lifecycle_disabled" }); expectNoWrites(prisma);
} finally { await app.close(); }
});
it.each([
["POST", "/employees/wecom-hr-master-sync/runs", { mode: "invalid" }, "employee.hr_sync_input_invalid"],
["GET", "/employees/wecom-hr-master-sync/runs?pageSize=0", undefined, "employee.hr_sync_query_invalid"],
["POST", `/employees/${EMPLOYEE_ID}/wecom-directory-sync/retry`, { operation: "invalid" }, "employee.directory_retry_input_invalid"],
])("validates %s %s without any external request", async (method, path, payload, reasonCode) => {
const { app, prisma } = await buildApp();
const originalFetch = global.fetch; const noFetch = vi.fn(() => { throw new Error("external_request_forbidden"); }); global.fetch = noFetch;
try {
app.config.wecomHrMasterSyncEnabled = true;
for (const model of ["wecomHrMasterSyncRun", "wecomHrMasterSyncItem", "wecomIdentityBinding", "integrationBinding"]) prisma[model] = { findMany: vi.fn(), create: vi.fn() };
const response = await app.inject({ method, url: `/api${path}`, ...(payload ? { payload } : {}) });
expect(response.statusCode).toBe(400); expect(response.json().error.details).toMatchObject({ reasonCode, fieldErrors: expect.any(Object) }); expectNoWrites(prisma); expect(noFetch).not.toHaveBeenCalled();
} finally { global.fetch = originalFetch; await app.close(); }
});
it.each([
["POST", "/employees/wecom-hr-master-sync/runs", false, "employee.hr_sync_disabled", { featureFlag: "wecom_hr_master_sync_v1_disabled", reason: "env_hard_off" }],
["POST", "/employees/wecom-hr-master-sync/runs", true, "employee.hr_sync_unavailable", { featureFlag: "wecom_hr_master_sync_v1_unavailable" }],
["GET", `/employees/${EMPLOYEE_ID}/wecom-directory-sync`, false, "employee.directory_sync_unavailable", { featureFlag: "hr_wecom_directory_sync_v1_unavailable" }],
["POST", `/employees/${EMPLOYEE_ID}/wecom-directory-sync/retry`, false, "employee.directory_sync_unavailable", { featureFlag: "hr_wecom_directory_sync_v1_unavailable" }],
])("retains sync capability guards for %s %s", async (method, path, hrEnabled, reasonCode, details) => {
const { app, prisma } = await buildApp();
try {
app.config.wecomHrMasterSyncEnabled = hrEnabled;
const response = await app.inject({ method, url: `/api${path}`, ...(method === "POST" ? { payload: {} } : {}) });
expect(response.statusCode).toBe(409); expect(response.json().error.details).toEqual({ ...details, reasonCode }); expectNoWrites(prisma);
} finally { await app.close(); }
});
});