332 行
11 KiB
JavaScript
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: "直属下属补卡" });
|
|
});
|
|
});
|