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

677 行
19 KiB
JavaScript

const Fastify = require("fastify");
const attendanceRoutes = require("../../../src/routes/attendance");
const { AppError } = require("../../../src/utils/AppError");
const ACTOR_ID = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa";
const EMPLOYEE_ID = "11111111-1111-4111-8111-111111111111";
const OTHER_EMPLOYEE_ID = "22222222-2222-4222-8222-222222222222";
const DEPARTMENT_ID = "bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb";
const LEGAL_ENTITY_ID = "cccccccc-cccc-4ccc-8ccc-cccccccccccc";
function buildEmployeeRow(overrides = {}) {
return {
id: EMPLOYEE_ID,
employee_no: "E001",
name: "Alice",
title: "员工",
department_id: DEPARTMENT_ID,
legal_entity_id: LEGAL_ENTITY_ID,
status: "active",
department: {
id: DEPARTMENT_ID,
name: "行政部",
},
...overrides,
};
}
function buildAttendanceRecord(overrides = {}) {
return {
id: "attendance-1",
employee_id: EMPLOYEE_ID,
work_date: new Date("2026-04-09T00:00:00.000Z"),
clock_in_at: null,
clock_out_at: null,
work_minutes: null,
is_late: false,
late_minutes: 0,
is_early_leave: false,
early_minutes: 0,
note: null,
source: "web",
created_at: new Date("2026-04-09T01:00:00.000Z"),
updated_at: new Date("2026-04-09T01:00:00.000Z"),
updated_by: ACTOR_ID,
...overrides,
};
}
function buildPolicy(overrides = {}) {
return {
id: "policy-1",
work_start_time: "09:00",
work_end_time: "18:00",
lunch_start_time: "12:00",
lunch_end_time: "13:00",
late_tolerance_minutes: 0,
enabled: true,
created_at: new Date("2026-04-01T00:00:00.000Z"),
updated_at: new Date("2026-04-01T00:00:00.000Z"),
updated_by: null,
...overrides,
};
}
function buildLeaveRow(overrides = {}) {
return {
owner_id: EMPLOYEE_ID,
start_date: new Date("2026-04-09T00:00:00.000Z"),
end_date: new Date("2026-04-09T00:00:00.000Z"),
reason: "请假",
status: "approved",
...overrides,
};
}
function buildTravelRow(overrides = {}) {
return {
applicant_employee_id: EMPLOYEE_ID,
start_date: new Date("2026-04-09T00:00:00.000Z"),
end_date: new Date("2026-04-09T00:00:00.000Z"),
purpose: "出差",
status: "approved",
...overrides,
};
}
function buildLegalEntityRow(overrides = {}) {
return {
id: LEGAL_ENTITY_ID,
default_holiday_calendar: {
id: "calendar-1",
archived_at: null,
days: [],
},
...overrides,
};
}
function sameDate(left, right) {
return left.toISOString().slice(0, 10) === right.toISOString().slice(0, 10);
}
function projectEmployeeForFindUnique(employee, select) {
if (!employee) {
return null;
}
if (!select) {
return employee;
}
const projected = {};
for (const [key, value] of Object.entries(select)) {
if (!value) {
continue;
}
projected[key] = employee[key];
}
return projected;
}
async function buildApp(options = {}) {
const app = Fastify();
const actor = options.actor ?? {
id: EMPLOYEE_ID,
department_id: DEPARTMENT_ID,
permissionCodes: [],
};
const employees = options.employees ?? [buildEmployeeRow()];
const attendanceRecords = [...(options.attendanceRecords ?? [])];
const attendancePolicies = [...(options.attendancePolicies ?? [buildPolicy()])];
const leaveRows = options.leaveRows ?? [];
const travelRows = options.travelRows ?? [];
const legalEntities = options.legalEntities ?? [buildLegalEntityRow()];
const employeeFindUnique = vi.fn(async ({ where, select }) => {
const employee = employees.find((item) => item.id === where.id);
return projectEmployeeForFindUnique(employee, select);
});
const employeeFindMany = vi.fn(async ({ where, select }) => {
const rows = employees
.filter((item) => !where?.status || item.status === where.status)
.filter((item) => !where?.department_id || item.department_id === where.department_id)
.filter((item) => !where?.id || item.id === where.id)
.map((item) => {
if (!select) {
return item;
}
return {
id: item.id,
employee_no: item.employee_no,
name: item.name,
title: item.title,
department_id: item.department_id,
legal_entity_id: item.legal_entity_id,
department: item.department,
};
});
return rows;
});
const attendanceRecordFindFirst = vi.fn(async ({ where }) =>
attendanceRecords.find((row) => {
if (where?.employee_id && row.employee_id !== where.employee_id) return false;
if (where?.work_date instanceof Date) {
return sameDate(row.work_date, where.work_date);
}
return true;
}) ?? null,
);
const attendanceRecordFindMany = vi.fn(async ({ where }) =>
attendanceRecords.filter((row) => {
if (where?.employee_id?.in && !where.employee_id.in.includes(row.employee_id)) return false;
if (where?.employee_id && typeof where.employee_id === "string" && row.employee_id !== where.employee_id) return false;
if (where?.work_date instanceof Date) {
return sameDate(row.work_date, where.work_date);
}
if (where?.work_date?.gte && row.work_date < where.work_date.gte) return false;
if (where?.work_date?.lte && row.work_date > where.work_date.lte) return false;
return true;
}),
);
const attendanceRecordCreate = vi.fn(async ({ data }) => {
const created = buildAttendanceRecord({
id: `attendance-${attendanceRecords.length + 1}`,
employee_id: data.employee_id,
work_date: data.work_date,
clock_in_at: data.clock_in_at ?? null,
clock_out_at: data.clock_out_at ?? null,
work_minutes: data.work_minutes ?? null,
is_late: data.is_late ?? false,
late_minutes: data.late_minutes ?? 0,
is_early_leave: data.is_early_leave ?? false,
early_minutes: data.early_minutes ?? 0,
note: data.note ?? null,
source: data.source ?? "web",
updated_by: data.updated_by ?? null,
updated_at: new Date(),
});
attendanceRecords.push(created);
return created;
});
const attendanceRecordUpdate = vi.fn(async ({ where, data }) => {
const index = attendanceRecords.findIndex((row) => row.id === where.id);
if (index < 0) {
throw new Error("record_not_found");
}
attendanceRecords[index] = {
...attendanceRecords[index],
...data,
updated_at: new Date(),
};
return attendanceRecords[index];
});
const attendancePolicyFindFirst = vi.fn(async () => attendancePolicies[0] ?? null);
const attendancePolicyUpdate = vi.fn(async ({ where, data }) => {
const index = attendancePolicies.findIndex((item) => item.id === where.id);
if (index < 0) {
throw new Error("policy_not_found");
}
attendancePolicies[index] = {
...attendancePolicies[index],
...data,
updated_at: new Date(),
};
return attendancePolicies[index];
});
const attendancePolicyCreate = vi.fn(async ({ data }) => {
const created = buildPolicy({
id: `policy-${attendancePolicies.length + 1}`,
...data,
created_at: new Date(),
updated_at: new Date(),
});
attendancePolicies.push(created);
return created;
});
const leaveFindMany = vi.fn(async ({ where }) =>
leaveRows.filter((row) => {
if (where?.owner_id?.in && !where.owner_id.in.includes(row.owner_id)) return false;
if (where?.status && row.status !== where.status) return false;
if (where?.start_date?.lte && row.start_date > where.start_date.lte) return false;
if (where?.end_date?.gte && row.end_date < where.end_date.gte) return false;
return true;
}),
);
const travelFindMany = vi.fn(async ({ where }) =>
travelRows.filter((row) => {
if (where?.applicant_employee_id?.in && !where.applicant_employee_id.in.includes(row.applicant_employee_id)) return false;
if (where?.status && row.status !== where.status) return false;
if (where?.start_date?.lte && row.start_date > where.start_date.lte) return false;
if (where?.end_date?.gte && row.end_date < where.end_date.gte) return false;
return true;
}),
);
const legalEntityFindMany = vi.fn(async ({ where }) =>
legalEntities.filter((item) => where?.id?.in?.includes(item.id)),
);
const auditCreate = vi.fn().mockResolvedValue({});
app.decorate("authenticate", async (request) => {
request.employee = actor;
});
app.decorate("requirePermission", (permissionCode) => async (request) => {
request.employee = actor;
if (!(actor.permissionCodes ?? []).includes(permissionCode)) {
throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 });
}
});
app.decorate("config", {
appTimezone: "Asia/Shanghai",
attendanceEnabled: true,
leaveHolidayCalendarEnabled: false,
});
app.decorate("prisma", {
employee: {
findUnique: employeeFindUnique,
findMany: employeeFindMany,
},
attendanceRecord: {
findFirst: attendanceRecordFindFirst,
findMany: attendanceRecordFindMany,
create: attendanceRecordCreate,
update: attendanceRecordUpdate,
},
attendancePolicy: {
findFirst: attendancePolicyFindFirst,
update: attendancePolicyUpdate,
create: attendancePolicyCreate,
},
leaveRequest: {
findMany: leaveFindMany,
},
travelRequest: {
findMany: travelFindMany,
},
legalEntity: {
findMany: legalEntityFindMany,
},
auditLog: {
create: auditCreate,
},
featureFlag: {
findUnique: vi.fn().mockResolvedValue({
id: "flag-attendance",
flag_key: "attendance_v1",
override_mode: "force_on",
rollout_type: "global",
department_ids: [],
note: "",
version: 1,
created_at: new Date("2026-04-01T00:00:00.000Z"),
created_by: "system",
updated_at: new Date("2026-04-01T00:00:00.000Z"),
updated_by: "system",
}),
},
});
await app.register(attendanceRoutes, { prefix: "/api" });
return {
app,
auditCreate,
attendancePolicies,
attendanceRecords,
employeeFindMany,
attendanceRecordCreate,
attendanceRecordUpdate,
};
}
function countWeekdays(year, month) {
let count = 0;
const date = new Date(Date.UTC(year, month - 1, 1));
while (date.getUTCMonth() === month - 1) {
const day = date.getUTCDay();
if (day !== 0 && day !== 6) {
count += 1;
}
date.setUTCDate(date.getUTCDate() + 1);
}
return count;
}
describe("attendance routes", () => {
let currentApp = null;
beforeEach(() => {
vi.useFakeTimers({ toFake: ["Date"] });
});
afterEach(async () => {
vi.useRealTimers();
if (currentApp) {
await currentApp.close();
currentApp = null;
}
});
it("keeps clock-in idempotent and does not overwrite the original timestamp", async () => {
vi.setSystemTime(new Date("2026-04-09T03:00:00.000Z"));
const originalClockIn = new Date("2026-04-09T01:02:00.000Z");
const { app, attendanceRecordCreate, attendanceRecordUpdate } = await buildApp({
attendanceRecords: [
buildAttendanceRecord({
clock_in_at: originalClockIn,
updated_at: originalClockIn,
}),
],
});
currentApp = app;
const response = await app.inject({
method: "POST",
url: "/api/attendance/clock-in",
payload: {},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.item.clockInAt).toBe(originalClockIn.toISOString());
expect(attendanceRecordCreate).not.toHaveBeenCalled();
expect(attendanceRecordUpdate).not.toHaveBeenCalled();
});
it("calculates late minutes with tolerance on clock-in", async () => {
vi.setSystemTime(new Date("2026-04-09T01:20:00.000Z"));
const { app } = await buildApp({
attendancePolicies: [buildPolicy({ late_tolerance_minutes: 15 })],
attendanceRecords: [],
});
currentApp = app;
const response = await app.inject({
method: "POST",
url: "/api/attendance/clock-in",
payload: {},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.item).toMatchObject({
isLate: true,
lateMinutes: 5,
});
});
it("allows repeated clock-out and recalculates work minutes", async () => {
vi.setSystemTime(new Date("2026-04-09T10:00:00.000Z"));
const { app, attendanceRecords } = await buildApp({
attendanceRecords: [
buildAttendanceRecord({
clock_in_at: new Date("2026-04-09T01:00:00.000Z"),
clock_out_at: new Date("2026-04-09T09:00:00.000Z"),
work_minutes: 420,
}),
],
});
currentApp = app;
const response = await app.inject({
method: "POST",
url: "/api/attendance/clock-out",
payload: {},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.item.clockOutAt).toBe("2026-04-09T10:00:00.000Z");
expect(response.json().data.item.workMinutes).toBe(480);
expect(attendanceRecords[0].clock_out_at.toISOString()).toBe("2026-04-09T10:00:00.000Z");
});
it("calculates work minutes with lunch deduction and early leave on clock-out", async () => {
vi.setSystemTime(new Date("2026-04-09T08:30:00.000Z"));
const { app } = await buildApp({
attendanceRecords: [
buildAttendanceRecord({
clock_in_at: new Date("2026-04-09T01:00:00.000Z"),
}),
],
});
currentApp = app;
const response = await app.inject({
method: "POST",
url: "/api/attendance/clock-out",
payload: {},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.item).toMatchObject({
workMinutes: 390,
isEarlyLeave: true,
earlyMinutes: 90,
});
});
it("marks approved leave day as no-need-clock-in and rejects self clock-in", async () => {
vi.setSystemTime(new Date("2026-04-09T01:00:00.000Z"));
const { app } = await buildApp({
attendanceRecords: [],
leaveRows: [buildLeaveRow()],
});
currentApp = app;
const todayResponse = await app.inject({
method: "GET",
url: "/api/attendance/me/today",
});
expect(todayResponse.statusCode).toBe(200);
expect(todayResponse.json().data).toMatchObject({
workDate: "2026-04-09",
needClockIn: false,
exemptionReason: "leave",
item: null,
});
const clockInResponse = await app.inject({
method: "POST",
url: "/api/attendance/clock-in",
payload: {},
});
expect(clockInResponse.statusCode).toBe(409);
});
it("treats configured legal holidays as no-need-clock-in even when leave holiday flag is off", async () => {
vi.setSystemTime(new Date("2026-05-01T01:00:00.000Z"));
const { app } = await buildApp({
legalEntities: [
buildLegalEntityRow({
default_holiday_calendar: {
id: "calendar-1",
archived_at: null,
days: [{ date: new Date("2026-05-01T00:00:00.000Z"), day_type: "holiday" }],
},
}),
],
});
currentApp = app;
const response = await app.inject({
method: "GET",
url: "/api/attendance/me/today",
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toMatchObject({
workDate: "2026-05-01",
needClockIn: false,
exemptionReason: "holiday",
item: null,
});
});
it("treats adjusted workdays as working days even on weekends", async () => {
vi.setSystemTime(new Date("2026-01-04T01:00:00.000Z"));
const { app } = await buildApp({
legalEntities: [
buildLegalEntityRow({
default_holiday_calendar: {
id: "calendar-1",
archived_at: null,
days: [{ date: new Date("2026-01-04T00:00:00.000Z"), day_type: "adjusted_workday" }],
},
}),
],
});
currentApp = app;
const response = await app.inject({
method: "GET",
url: "/api/attendance/me/today",
});
expect(response.statusCode).toBe(200);
expect(response.json().data).toMatchObject({
workDate: "2026-01-04",
needClockIn: true,
exemptionReason: null,
item: null,
});
});
it("builds month summary without counting approved leave as missed", async () => {
vi.setSystemTime(new Date("2026-04-15T01:00:00.000Z"));
const { app } = await buildApp({
attendanceRecords: [
buildAttendanceRecord({
work_date: new Date("2026-04-01T00:00:00.000Z"),
clock_in_at: new Date("2026-04-01T01:00:00.000Z"),
clock_out_at: new Date("2026-04-01T10:00:00.000Z"),
work_minutes: 480,
}),
],
leaveRows: [
buildLeaveRow({
start_date: new Date("2026-04-02T00:00:00.000Z"),
end_date: new Date("2026-04-02T00:00:00.000Z"),
}),
],
});
currentApp = app;
const response = await app.inject({
method: "GET",
url: "/api/attendance/me/month?year=2026&month=4",
});
expect(response.statusCode).toBe(200);
const payload = response.json().data;
const leaveItem = payload.items.find((item) => item.workDate === "2026-04-02");
expect(leaveItem).toMatchObject({
attendanceStatus: "leave",
needClockIn: false,
});
expect(payload.summary).toMatchObject({
totalWorkDays: countWeekdays(2026, 4) - 1,
clockedDays: 1,
lateDays: 0,
earlyLeaveDays: 0,
missedDays: countWeekdays(2026, 4) - 2,
totalWorkMinutes: 480,
});
});
it("rejects team attendance query without system.users permission", async () => {
vi.setSystemTime(new Date("2026-04-09T01:00:00.000Z"));
const { app, employeeFindMany } = await buildApp({
actor: {
id: ACTOR_ID,
department_id: DEPARTMENT_ID,
permissionCodes: [],
},
});
currentApp = app;
const response = await app.inject({
method: "GET",
url: "/api/attendance/team",
});
expect(response.statusCode).toBe(403);
expect(employeeFindMany).not.toHaveBeenCalled();
});
it("allows system.users to backfill attendance and writes audit log", async () => {
vi.setSystemTime(new Date("2026-04-09T09:30:00.000Z"));
const { app, auditCreate, attendanceRecords } = await buildApp({
actor: {
id: ACTOR_ID,
department_id: DEPARTMENT_ID,
permissionCodes: ["system.users"],
},
employees: [
buildEmployeeRow(),
buildEmployeeRow({
id: OTHER_EMPLOYEE_ID,
employee_no: "E002",
name: "Bob",
}),
],
});
currentApp = app;
const response = await app.inject({
method: "POST",
url: "/api/attendance/admin/backfill",
payload: {
employeeId: OTHER_EMPLOYEE_ID,
workDate: "2026-04-09",
clockInAt: "2026-04-09T01:00:00.000Z",
clockOutAt: "2026-04-09T10:00:00.000Z",
note: "补卡原因",
},
});
expect(response.statusCode).toBe(200);
expect(response.json().data.item).toMatchObject({
employeeId: OTHER_EMPLOYEE_ID,
source: "admin",
workMinutes: 480,
});
expect(attendanceRecords).toHaveLength(1);
expect(auditCreate).toHaveBeenCalledWith(
expect.objectContaining({
data: expect.objectContaining({
action: "ATTENDANCE_ADMIN_BACKFILLED",
extra: expect.objectContaining({
employeeId: OTHER_EMPLOYEE_ID,
workDate: "2026-04-09",
}),
}),
}),
);
});
});