汇总已落盘的借款本金与还款、兼容历史编号的周期化编号、付款责任与回单核验、报销进度和排序、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项公网义务仍开放。生产编号未启用、差旅仍冻结;不推送、不部署,不宣称发布资格。
263 行
17 KiB
JavaScript
263 行
17 KiB
JavaScript
const Fastify = require("fastify");
|
|
const budgetsRoutes = require("../../../src/routes/budgets");
|
|
const performanceRoutes = require("../../../src/routes/performance");
|
|
const { AppError } = require("../../../src/utils/AppError");
|
|
|
|
const ID = "11111111-1111-4111-8111-111111111111";
|
|
const OTHER_ID = "22222222-2222-4222-8222-222222222222";
|
|
const budgetInput = { code: "SYNTHETIC", name: "合成预算", periodStart: "2024-02-29", periodEnd: "2024-03-31", allocatedAmount: "100.00" };
|
|
const cycleInput = { name: "合成周期", cycleType: "monthly", startDate: "2024-02-29", endDate: "2024-03-31" };
|
|
const apps = [];
|
|
|
|
afterEach(async () => { await Promise.all(apps.splice(0).map((app) => app.close())); });
|
|
|
|
async function buildApp({ allowed = true, enabled = true } = {}) {
|
|
const app = Fastify();
|
|
apps.push(app);
|
|
const cycle = { id: ID, status: "draft", start_date: new Date("2024-02-29T00:00:00Z"), end_date: new Date("2024-03-31T00:00:00Z") };
|
|
const prisma = {
|
|
featureFlag: { findUnique: vi.fn().mockResolvedValue({ flag_key: "employee_lifecycle_v1", override_mode: enabled ? "force_on" : "force_off", rollout_type: "global", department_ids: [] }) },
|
|
department: { findUnique: vi.fn().mockResolvedValue({ id: ID, archived_at: null }) },
|
|
legalEntity: { findUnique: vi.fn().mockResolvedValue({ id: ID, archived_at: null, enabled: true }) },
|
|
costCenter: { findUnique: vi.fn().mockResolvedValue({ id: ID, legal_entity_id: ID, archived_at: null, enabled: true }) },
|
|
budgetPool: {
|
|
findMany: vi.fn().mockResolvedValue([]), count: vi.fn().mockResolvedValue(0),
|
|
findUnique: vi.fn().mockResolvedValue({ id: ID, ...budgetInput }),
|
|
create: vi.fn(async ({ data }) => ({ id: ID, ...data })),
|
|
update: vi.fn(async ({ data }) => ({ id: ID, ...data })),
|
|
},
|
|
performanceReviewCycle: {
|
|
findMany: vi.fn().mockResolvedValue([]), findUnique: vi.fn().mockResolvedValue(cycle),
|
|
findFirst: vi.fn().mockResolvedValue(null),
|
|
create: vi.fn(async ({ data }) => ({ ...cycle, ...data })),
|
|
update: vi.fn(async ({ data }) => ({ ...cycle, ...data })),
|
|
},
|
|
employee: { findUnique: vi.fn().mockResolvedValue({ id: ID, status: "active" }) },
|
|
performanceReview: {
|
|
findFirst: vi.fn().mockResolvedValue(null), findUnique: vi.fn().mockResolvedValue({ id: ID }),
|
|
create: vi.fn().mockResolvedValue({ id: ID }), update: vi.fn().mockResolvedValue({ id: ID }),
|
|
},
|
|
performanceReviewDimension: { deleteMany: vi.fn(), createMany: vi.fn() },
|
|
auditLog: { create: vi.fn().mockResolvedValue({}) },
|
|
$queryRaw: vi.fn().mockResolvedValue([{ id: ID, status: "active" }]),
|
|
};
|
|
prisma.$transaction = vi.fn(async (callback) => callback(prisma));
|
|
app.decorate("config", { employeeLifecycleEnabled: true, appTimezone: "Asia/Shanghai" });
|
|
app.decorate("prisma", prisma);
|
|
app.decorate("authenticate", async (request) => { request.employee = { id: OTHER_ID, department_id: null }; });
|
|
app.decorate("requirePermission", () => async (request) => {
|
|
await app.authenticate(request);
|
|
if (!allowed) throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
|
|
});
|
|
app.decorate("hasPermission", () => allowed);
|
|
app.setErrorHandler((error, request, reply) => reply.code(error.statusCode ?? 500).send({ error: { code: error.code ?? "SYS_001", details: error.details ?? null } }));
|
|
await app.register(budgetsRoutes, { prefix: "/api" });
|
|
await app.register(performanceRoutes, { prefix: "/api" });
|
|
return { app, prisma, cycle };
|
|
}
|
|
|
|
function expectNoWrites(prisma, { cycleUpdateAttempted = false } = {}) {
|
|
for (const fn of [prisma.budgetPool.create, prisma.budgetPool.update, prisma.performanceReviewCycle.create,
|
|
...(!cycleUpdateAttempted ? [prisma.performanceReviewCycle.update] : []), prisma.performanceReview.create,
|
|
prisma.performanceReview.update, prisma.performanceReviewDimension.deleteMany, prisma.performanceReviewDimension.createMany,
|
|
prisma.auditLog.create]) expect(fn).not.toHaveBeenCalled();
|
|
}
|
|
|
|
function expectFailure(response, status, reasonCode, details) {
|
|
expect(response.statusCode).toBe(status);
|
|
expect(response.json().error).toEqual({ code: "VALID_001", details: { ...details, reasonCode } });
|
|
}
|
|
|
|
describe("budget route error contracts", () => {
|
|
it.each([
|
|
["GET", "/budgets?page=0", undefined, "budget.query_invalid", "page"],
|
|
["POST", "/budgets", { ...budgetInput, allocatedAmount: "-1" }, "budget.create_input_invalid", "allocatedAmount"],
|
|
["PATCH", `/budgets/${ID}`, { warningThresholdPercent: 101 }, "budget.update_input_invalid", "warningThresholdPercent"],
|
|
])("%s %s rejects invalid input before budget access", async (method, path, payload, reasonCode, field) => {
|
|
const { app, prisma } = await buildApp();
|
|
const response = await app.inject({ method, url: `/api${path}`, ...(payload ? { payload } : {}) });
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json().error.code).toBe("VALID_001");
|
|
expect(response.json().error.details).toMatchObject({ reasonCode, formErrors: [], fieldErrors: { [field]: expect.any(Array) } });
|
|
expect(prisma.budgetPool.findUnique).not.toHaveBeenCalled();
|
|
expect(prisma.budgetPool.findMany).not.toHaveBeenCalled();
|
|
expect(prisma.budgetPool.count).not.toHaveBeenCalled();
|
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
|
expectNoWrites(prisma);
|
|
});
|
|
|
|
it.each([["PATCH", "/budgets/bad", { name: "合成" }], ["POST", "/budgets/bad/archive", undefined]])(
|
|
"%s %s rejects an invalid id", async (method, path, payload) => {
|
|
const { app, prisma } = await buildApp();
|
|
const response = await app.inject({ method, url: `/api${path}`, ...(payload ? { payload } : {}) });
|
|
expectFailure(response, 400, "budget.id_invalid", { id: "invalid_uuid" });
|
|
expect(prisma.budgetPool.findUnique).not.toHaveBeenCalled();
|
|
expectNoWrites(prisma);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
["department", null, { departmentId: ID }, 400, "budget.department_not_found", { departmentId: "not_found" }],
|
|
["department", { id: ID, archived_at: new Date("2024-01-01Z") }, { departmentId: ID }, 409, "budget.department_archived", { departmentId: "archived" }],
|
|
["legalEntity", null, { legalEntityId: ID, costCenterId: ID }, 400, "budget.legal_entity_not_found", { legalEntityId: "not_found" }],
|
|
["legalEntity", { id: ID, enabled: false }, { legalEntityId: ID, costCenterId: ID }, 409, "budget.legal_entity_unavailable", { legalEntityId: "disabled_or_archived" }],
|
|
["legalEntity", { id: ID, enabled: true, archived_at: new Date("2024-01-01Z") }, { legalEntityId: ID, costCenterId: ID }, 409, "budget.legal_entity_unavailable", { legalEntityId: "disabled_or_archived" }],
|
|
["costCenter", null, { legalEntityId: ID, costCenterId: ID }, 400, "budget.cost_center_not_found", { costCenterId: "not_found" }],
|
|
["costCenter", { id: ID, enabled: false }, { legalEntityId: ID, costCenterId: ID }, 409, "budget.cost_center_unavailable", { costCenterId: "disabled_or_archived" }],
|
|
["costCenter", { id: ID, enabled: true, archived_at: new Date("2024-01-01Z") }, { legalEntityId: ID, costCenterId: ID }, 409, "budget.cost_center_unavailable", { costCenterId: "disabled_or_archived" }],
|
|
["costCenter", { id: ID, enabled: true, legal_entity_id: OTHER_ID }, { legalEntityId: ID, costCenterId: ID }, 409, "budget.cost_center_company_mismatch", { costCenterId: "legalEntityId_mismatch" }],
|
|
])("preserves reference rejection %s %j", async (model, row, scope, status, reason, details) => {
|
|
const { app, prisma } = await buildApp();
|
|
prisma[model].findUnique.mockResolvedValue(row);
|
|
const response = await app.inject({ method: "POST", url: "/api/budgets", payload: { ...budgetInput, ...scope } });
|
|
expectFailure(response, status, reason, details);
|
|
if (model === "legalEntity") expect(prisma.costCenter.findUnique).not.toHaveBeenCalled();
|
|
expectNoWrites(prisma);
|
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it.each([
|
|
["PATCH", `/budgets/${ID}`, {}, "budget.update_empty", { body: "empty_patch" }],
|
|
["POST", "/budgets", { ...budgetInput, periodEnd: "2024-02-28" }, "budget.period_invalid", { periodEnd: "must_be_after_periodStart" }],
|
|
])("preserves %s %s business validation", async (method, path, payload, reason, details) => {
|
|
const { app, prisma } = await buildApp();
|
|
expectFailure(await app.inject({ method, url: `/api${path}`, payload }), 400, reason, details);
|
|
expect(prisma.budgetPool.findUnique).not.toHaveBeenCalled();
|
|
expect(prisma.department.findUnique).not.toHaveBeenCalled();
|
|
expectNoWrites(prisma);
|
|
});
|
|
|
|
it("explains the shared scope conflict without writes", async () => {
|
|
const { app, prisma } = await buildApp();
|
|
const response = await app.inject({ method: "POST", url: "/api/budgets", payload: { ...budgetInput, departmentId: ID, legalEntityId: ID, costCenterId: ID } });
|
|
expect(response.statusCode).toBe(409);
|
|
expect(response.json().error.details).toEqual({ scope: "mixed_dimensions_not_allowed", reasonCode: "budget.scope_mixed" });
|
|
expect(prisma.department.findUnique).not.toHaveBeenCalled();
|
|
expectNoWrites(prisma);
|
|
});
|
|
|
|
it("keeps a valid scoped budget, patch and archive working", async () => {
|
|
const { app, prisma } = await buildApp();
|
|
const created = await app.inject({ method: "POST", url: "/api/budgets", payload: { ...budgetInput, legalEntityId: ID, costCenterId: ID } });
|
|
expect(created.statusCode).toBe(200);
|
|
expect(prisma.budgetPool.create.mock.calls[0][0].data).toMatchObject({ legal_entity_id: ID, cost_center_id: ID, period_start: new Date("2024-02-29T00:00:00Z"), allocated_amount: "100.00" });
|
|
expect((await app.inject({ method: "PATCH", url: `/api/budgets/${ID}`, payload: { name: "合成修改", warningThresholdPercent: 90 } })).statusCode).toBe(200);
|
|
expect((await app.inject({ method: "POST", url: `/api/budgets/${ID}/archive` })).statusCode).toBe(200);
|
|
expect(prisma.auditLog.create).toHaveBeenCalledTimes(3);
|
|
});
|
|
});
|
|
|
|
describe("performance route error contracts", () => {
|
|
it.each([
|
|
["POST", "/performance/cycles", { ...cycleInput, name: "" }, "performance.cycle_create_input_invalid", "name"],
|
|
["PATCH", `/performance/cycles/${ID}`, { cycleType: "invalid" }, "performance.cycle_update_input_invalid", "cycleType"],
|
|
["PUT", `/performance/cycles/${ID}/reviews/${ID}`, { overallScore: 4.2 }, "performance.review_input_invalid", "overallScore"],
|
|
["PUT", `/performance/cycles/${ID}/reviews/${ID}`, { overallScore: 4, dimensions: [{ dimensionType: "teamwork", score: 4 }, { dimensionType: "teamwork", score: 5 }] }, "performance.review_input_invalid", "dimensions"],
|
|
])("%s %s rejects invalid operation input before business access", async (method, path, payload, reason, field) => {
|
|
const { app, prisma } = await buildApp();
|
|
const response = await app.inject({ method, url: `/api${path}`, payload });
|
|
expect(response.statusCode).toBe(400);
|
|
expect(response.json().error.code).toBe("VALID_001");
|
|
expect(response.json().error.details).toMatchObject({ reasonCode: reason, formErrors: [], fieldErrors: { [field]: expect.any(Array) } });
|
|
expect(prisma.performanceReviewCycle.findUnique).not.toHaveBeenCalled();
|
|
expect(prisma.employee.findUnique).not.toHaveBeenCalled();
|
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
|
expectNoWrites(prisma);
|
|
});
|
|
|
|
it.each([
|
|
["PATCH", "/performance/cycles/bad", { name: "合成" }],
|
|
["GET", "/performance/cycles/bad/reviews"],
|
|
["GET", "/performance/employees/bad/reviews"],
|
|
["PUT", `/performance/cycles/${ID}/reviews/bad`, { overallScore: 4 }],
|
|
])("%s %s rejects invalid identifiers", async (method, path, payload) => {
|
|
const { app, prisma } = await buildApp();
|
|
expectFailure(await app.inject({ method, url: `/api${path}`, ...(payload ? { payload } : {}) }), 400, "performance.id_invalid", { id: "invalid_uuid" });
|
|
expect(prisma.employee.findUnique).not.toHaveBeenCalled();
|
|
expect(prisma.performanceReviewCycle.findUnique).not.toHaveBeenCalled();
|
|
expectNoWrites(prisma);
|
|
});
|
|
|
|
it.each(["POST", "PATCH"])("%s retains the reversed date guard", async (method) => {
|
|
const { app, prisma } = await buildApp();
|
|
const response = await app.inject({ method, url: `/api/performance/cycles${method === "PATCH" ? `/${ID}` : ""}`, payload: method === "POST" ? { ...cycleInput, endDate: "2024-02-28" } : { endDate: "2024-02-28" } });
|
|
expectFailure(response, 400, "performance.cycle_date_range_invalid", { endDate: "must_be_after_or_equal_startDate" });
|
|
expectNoWrites(prisma);
|
|
});
|
|
|
|
it.each([
|
|
["completed", { name: "合成" }, "performance.completed_cycle_locked", "completed_cycle_locked"],
|
|
["active", { name: "合成" }, "performance.active_cycle_only_complete", "active_cycle_only_supports_complete"],
|
|
["draft", { status: "completed" }, "performance.draft_cannot_complete", "draft_cycle_cannot_complete_directly"],
|
|
])("retains %s cycle mutation guard", async (status, payload, reason, detail) => {
|
|
const { app, prisma, cycle } = await buildApp();
|
|
cycle.status = status;
|
|
expectFailure(await app.inject({ method: "PATCH", url: `/api/performance/cycles/${ID}`, payload }), 409, reason, { cycle: detail });
|
|
expectNoWrites(prisma);
|
|
});
|
|
|
|
it.each(["precheck", "unique_constraint"])("explains another active cycle at %s", async (stage) => {
|
|
const { app, prisma } = await buildApp();
|
|
if (stage === "precheck") prisma.performanceReviewCycle.findFirst.mockResolvedValue({ id: OTHER_ID });
|
|
else prisma.performanceReviewCycle.update.mockRejectedValue({ code: "P2002" });
|
|
const response = await app.inject({ method: "PATCH", url: `/api/performance/cycles/${ID}`, payload: { status: "active" } });
|
|
expectFailure(response, 409, "performance.active_cycle_exists", { cycle: "active_cycle_exists", ...(stage === "precheck" ? { activeCycleId: OTHER_ID } : {}) });
|
|
expectNoWrites(prisma, { cycleUpdateAttempted: stage === "unique_constraint" });
|
|
if (stage === "unique_constraint") expect(prisma.performanceReviewCycle.update).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it("preserves non-unique database failures without a misleading conflict reason", async () => {
|
|
const { app, prisma } = await buildApp();
|
|
prisma.performanceReviewCycle.update.mockRejectedValue(new Error("synthetic_database_failure"));
|
|
const response = await app.inject({ method: "PATCH", url: `/api/performance/cycles/${ID}`, payload: { status: "active" } });
|
|
expect(response.statusCode).toBe(500);
|
|
expect(response.json().error).toEqual({ code: "SYS_001", details: null });
|
|
expectNoWrites(prisma, { cycleUpdateAttempted: true });
|
|
});
|
|
|
|
it("rejects inactive employees before the review transaction", async () => {
|
|
const { app, prisma } = await buildApp();
|
|
prisma.employee.findUnique.mockResolvedValue({ id: ID, status: "inactive" });
|
|
expectFailure(await app.inject({ method: "PUT", url: `/api/performance/cycles/${ID}/reviews/${ID}`, payload: { overallScore: 4 } }), 409, "performance.employee_inactive", { employee: "inactive_employee_not_reviewable" });
|
|
expect(prisma.$transaction).not.toHaveBeenCalled();
|
|
expect(prisma.performanceReviewCycle.findUnique).not.toHaveBeenCalled();
|
|
expectNoWrites(prisma);
|
|
});
|
|
|
|
it("rechecks the locked cycle before any review or dimension writes", async () => {
|
|
const { app, prisma, cycle } = await buildApp();
|
|
cycle.status = "active";
|
|
prisma.$queryRaw.mockResolvedValue([{ id: ID, status: "completed" }]);
|
|
expectFailure(await app.inject({ method: "PUT", url: `/api/performance/cycles/${ID}/reviews/${ID}`, payload: { overallScore: 4 } }), 409, "performance.active_cycle_required", { cycle: "active_cycle_required" });
|
|
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
|
expect(prisma.$queryRaw).toHaveBeenCalledTimes(1);
|
|
expect(prisma.performanceReview.findFirst).not.toHaveBeenCalled();
|
|
expectNoWrites(prisma);
|
|
});
|
|
|
|
it("preserves the disabled feature rejection before cycle queries", async () => {
|
|
const { app, prisma } = await buildApp({ enabled: false });
|
|
expectFailure(await app.inject({ method: "GET", url: "/api/performance/cycles" }), 409, "performance.disabled", { featureFlag: "employee_lifecycle_v1_disabled", reason: "flag_force_off" });
|
|
expect(prisma.performanceReviewCycle.findMany).not.toHaveBeenCalled();
|
|
expectNoWrites(prisma);
|
|
});
|
|
|
|
it("still writes a valid admin review in the existing transaction", async () => {
|
|
const { app, prisma } = await buildApp();
|
|
const response = await app.inject({ method: "PUT", url: `/api/performance/cycles/${ID}/reviews/${ID}`, payload: { overallScore: 4.5, dimensions: [{ dimensionType: "teamwork", score: 4 }] } });
|
|
expect(response.statusCode).toBe(200);
|
|
expect(prisma.$transaction).toHaveBeenCalledTimes(1);
|
|
expect(prisma.performanceReview.create.mock.calls[0][0].data).toMatchObject({ overall_score: 4.5, cycle_id: ID, employee_id: ID });
|
|
expect(prisma.performanceReviewDimension.createMany).toHaveBeenCalledTimes(1);
|
|
expect(prisma.auditLog.create).toHaveBeenCalledTimes(1);
|
|
});
|
|
});
|
|
|
|
it.each(["/budgets", "/performance/cycles"])("keeps permission rejection before %s writes", async (path) => {
|
|
const { app, prisma } = await buildApp({ allowed: false });
|
|
const response = await app.inject({ method: "POST", url: `/api${path}`, payload: {} });
|
|
expect(response.statusCode).toBe(403);
|
|
expect(response.json().error.code).toBe("PERM_001");
|
|
expectNoWrites(prisma);
|
|
});
|