汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、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项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
203 行
15 KiB
JavaScript
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(); }
|
|
});
|
|
});
|