文件
hyb-oa/server/tests/unit/routes/attendanceCorrections.test.js

332 行
11 KiB
JavaScript

const Fastify = require("fastify");
const attendanceCorrectionsRoutes = require("../../../src/routes/attendanceCorrections");
const { resolveEntityPermission } = require("../../../src/constants/permissions");
const { AppError } = require("../../../src/utils/AppError");
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
const MANAGER_ID = "22222222-2222-4222-8222-222222222222";
const DEPARTMENT_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
function buildFlag(flagKey, overrideMode = "force_on") {
return {
id: `flag-${flagKey}`,
flag_key: flagKey,
override_mode: overrideMode,
rollout_type: "global",
department_ids: [],
note: "",
version: 1,
created_at: new Date("2026-05-01T00:00:00.000Z"),
updated_at: new Date("2026-05-01T00:00:00.000Z"),
};
}
function buildEmployee(overrides = {}) {
return {
id: EMPLOYEE_ID,
employee_no: "E001",
name: "员工一",
status: "active",
department_id: DEPARTMENT_ID,
department: { id: DEPARTMENT_ID, name: "研发部" },
direct_manager_id: MANAGER_ID,
...overrides,
};
}
function buildApp(options = {}) {
const app = Fastify();
const actor = options.actor ?? {
id: EMPLOYEE_ID,
department_id: DEPARTMENT_ID,
permissionCodes: ["attendance.create", "attendance.submit", "attendance.view_own"],
roleCodes: ["employee"],
};
const corrections = [];
const initialCorrections = options.corrections ?? [];
const workflowInstances = [];
const workflowTasks = [];
const workflowHistory = [];
const employees = options.employees ?? [
buildEmployee(),
buildEmployee({ id: MANAGER_ID, name: "经理", direct_manager_id: null }),
];
corrections.push(
...initialCorrections.map((item) => ({
created_at: new Date("2026-05-17T02:00:00.000Z"),
updated_at: new Date("2026-05-17T02:00:00.000Z"),
workflow_instances: [],
...item,
})),
);
const tx = {
employee: {
findUnique: vi.fn(async ({ where, select }) => {
const employee = employees.find((item) => item.id === where.id) ?? null;
if (!employee || !select) return employee;
return Object.fromEntries(Object.keys(select).map((key) => [key, employee[key]]));
}),
},
featureFlag: {
findUnique: vi.fn(async ({ where }) => buildFlag(where.flag_key, "force_on")),
},
workflowDefinition: {
findFirst: vi.fn(async () => ({
id: "33333333-3333-4333-8333-333333333333",
definition_json: {
steps: [
{
id: "attendance_correction_approval_manager",
name: "直属经理审批",
type: "approval",
assignee_rule: { type: "department_manager" },
},
],
},
})),
},
attendanceCorrection: {
create: vi.fn(async ({ data }) => {
const row = {
id: "44444444-4444-4444-8444-444444444444",
created_at: new Date("2026-05-17T02:00:00.000Z"),
updated_at: new Date("2026-05-17T02:00:00.000Z"),
owner: buildEmployee(),
workflow_instances: [],
...data,
};
corrections.push(row);
return { id: row.id };
}),
findUnique: vi.fn(async ({ where }) => {
const row = corrections.find((item) => item.id === where.id);
if (!row) return null;
const owner = employees.find((item) => item.id === row.owner_id) ?? buildEmployee({ id: row.owner_id });
return {
...row,
owner,
workflow_instances: workflowInstances.filter((item) => item.entity_id === row.id),
};
}),
findMany: vi.fn(async ({ where }) =>
corrections
.filter((item) => {
const owner = employees.find((employee) => employee.id === item.owner_id) ?? null;
if (where?.owner_id && item.owner_id !== where.owner_id) return false;
if (where?.OR?.length) {
const matched = where.OR.some((condition) => {
if (condition.owner_id && item.owner_id === condition.owner_id) return true;
if (condition.owner?.direct_manager_id && owner?.direct_manager_id === condition.owner.direct_manager_id) return true;
return false;
});
if (!matched) return false;
}
if (where?.status && item.status !== where.status) return false;
if (where?.kind && item.kind !== where.kind) return false;
return true;
})
.map((item) => ({
...item,
owner: employees.find((employee) => employee.id === item.owner_id) ?? buildEmployee({ id: item.owner_id }),
workflow_instances: [],
})),
),
count: vi.fn(async ({ where }) => tx.attendanceCorrection.findMany({ where }).then((items) => items.length)),
update: vi.fn(async ({ where, data }) => {
const index = corrections.findIndex((item) => item.id === where.id);
corrections[index] = { ...corrections[index], ...data, updated_at: new Date("2026-05-17T03:00:00.000Z") };
return corrections[index];
}),
},
workflowInstance: {
create: vi.fn(async ({ data }) => {
const row = {
id: "55555555-5555-4555-8555-555555555555",
current_status: data.current_status,
current_step_id: data.current_step_id,
created_at: new Date("2026-05-17T02:00:00.000Z"),
...data,
};
workflowInstances.push(row);
return { id: row.id };
}),
findFirst: vi.fn(async ({ where }) =>
workflowInstances.find(
(item) =>
item.entity_type === where.entity_type &&
item.entity_id === where.entity_id &&
item.current_status === where.current_status,
) ?? null,
),
update: vi.fn(async ({ where, data }) => {
const index = workflowInstances.findIndex((item) => item.id === where.id);
workflowInstances[index] = { ...workflowInstances[index], ...data };
return workflowInstances[index];
}),
},
workflowTask: {
create: vi.fn(async ({ data }) => {
const row = { id: "66666666-6666-4666-8666-666666666666", ...data };
workflowTasks.push(row);
return { id: row.id };
}),
updateMany: vi.fn(async () => ({ count: 1 })),
},
workflowHistory: {
create: vi.fn(async ({ data }) => {
workflowHistory.push(data);
return { id: `history-${workflowHistory.length}`, ...data };
}),
},
auditLog: {
create: vi.fn(async () => ({})),
},
};
const prisma = {
...tx,
$transaction: vi.fn(async (callback) => callback(tx)),
};
app.decorate("authenticate", async (request) => {
request.employee = actor;
});
app.decorate("assertEntityPermission", (employee, entityType, action) => {
const permission = resolveEntityPermission(entityType, action);
if (!permission || (!employee.permissionCodes.includes(permission) && !employee.permissionCodes.includes("*"))) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission } });
}
});
app.decorate("requireEntityPermission", (entityType, action) => async (request) => {
await app.authenticate(request);
app.assertEntityPermission(request.employee, entityType, action);
});
app.decorate("config", {
appTimezone: "Asia/Shanghai",
attendanceUnifiedEnabled: true,
});
app.decorate("prisma", prisma);
app.register(attendanceCorrectionsRoutes, { prefix: "/api" });
return { app, prisma, corrections, workflowInstances, workflowTasks, workflowHistory };
}
describe("attendance correction routes", () => {
let currentApp = null;
afterEach(async () => {
if (currentApp) {
await currentApp.close();
currentApp = null;
}
});
it("submits missed punch correction into OA workflow and writes audit", async () => {
const { app, prisma, workflowInstances, workflowTasks } = buildApp();
currentApp = app;
const response = await app.inject({
method: "POST",
url: "/api/attendance-corrections",
payload: {
kind: "missed_punch",
workDate: "2026-05-12",
missedPunchAt: "2026-05-12T09:00:00+08:00",
reason: "企微缺卡,提交 OA 解释",
},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.item).toMatchObject({
kind: "missed_punch",
status: "reviewing",
reason: "企微缺卡,提交 OA 解释",
});
expect(workflowInstances[0]).toMatchObject({
entity_type: "attendance_correction",
attendance_correction_id: "44444444-4444-4444-8444-444444444444",
current_step_id: "attendance_correction_approval_manager",
});
expect(workflowTasks[0]).toMatchObject({
assignee_id: MANAGER_ID,
step_id: "attendance_correction_approval_manager",
});
expect(prisma.auditLog.create).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: "ATTENDANCE_CORRECTION_SUBMITTED",
target_type: "attendance_correction",
}),
}),
);
});
it("rejects correction submission when unified attendance flag is disabled", async () => {
const { app, prisma } = buildApp();
currentApp = app;
prisma.featureFlag.findUnique.mockResolvedValueOnce(buildFlag("attendance_unified_v1", "force_off"));
const response = await app.inject({
method: "POST",
url: "/api/attendance-corrections",
payload: {
kind: "missed_punch",
workDate: "2026-05-12",
missedPunchAt: "2026-05-12T09:00:00+08:00",
reason: "企微缺卡,提交 OA 解释",
},
});
expect(response.statusCode).toBe(409);
});
it("limits manager correction list to own and direct reports", async () => {
const outsiderId = "77777777-7777-4777-8777-777777777777";
const otherManagerId = "88888888-8888-4888-8888-888888888888";
const { app } = buildApp({
actor: {
id: MANAGER_ID,
department_id: DEPARTMENT_ID,
permissionCodes: ["attendance.view_own", "attendance.view_all"],
roleCodes: ["manager"],
},
employees: [
buildEmployee(),
buildEmployee({ id: MANAGER_ID, name: "经理", direct_manager_id: null }),
buildEmployee({ id: outsiderId, name: "其他员工", direct_manager_id: otherManagerId }),
],
corrections: [
{
id: "44444444-4444-4444-8444-444444444444",
owner_id: EMPLOYEE_ID,
department_id: DEPARTMENT_ID,
kind: "missed_punch",
status: "reviewing",
work_date: new Date("2026-05-12T00:00:00.000Z"),
reason: "直属下属补卡",
},
{
id: "99999999-9999-4999-8999-999999999999",
owner_id: outsiderId,
department_id: DEPARTMENT_ID,
kind: "out",
status: "reviewing",
work_date: new Date("2026-05-12T00:00:00.000Z"),
reason: "非直属员工外出",
},
],
});
currentApp = app;
const response = await app.inject({
method: "GET",
url: "/api/attendance-corrections?month=2026-05",
});
expect(response.statusCode).toBe(200);
const items = response.json().data.items;
expect(items).toHaveLength(1);
expect(items[0]).toMatchObject({ reason: "直属下属补卡" });
});
});