677 行
19 KiB
JavaScript
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",
|
|
}),
|
|
}),
|
|
}),
|
|
);
|
|
|
|
});
|
|
});
|