const Fastify = require("fastify"); const presenceRoutes = require("../../../src/routes/presence"); 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"; function buildEmployeeRow(overrides = {}) { return { id: EMPLOYEE_ID, employee_no: "E001", name: "Alice", title: "员工", employment_status: "active", department_id: DEPARTMENT_ID, status: "active", department: { id: DEPARTMENT_ID, name: "行政部", }, ...overrides, }; } function buildPresenceRow(overrides = {}) { return { id: "presence-1", employee_id: EMPLOYEE_ID, date: new Date("2026-03-27T00:00:00.000Z"), status: "office", time_range: "full_day", note: null, source: "manual", updated_at: new Date("2026-03-27T08:00:00.000Z"), updated_by: ACTOR_ID, ...overrides, }; } function buildLeaveRow(overrides = {}) { return { id: "leave-1", owner_id: EMPLOYEE_ID, start_date: new Date("2026-03-27T00:00:00.000Z"), end_date: new Date("2026-03-27T00:00:00.000Z"), start_session: "am", end_session: "pm", reason: "年假", status: "approved", ...overrides, }; } function sameDate(left, right) { return left.toISOString().slice(0, 10) === right.toISOString().slice(0, 10); } async function buildApp(options = {}) { const app = Fastify(); const actor = options.actor ?? { id: ACTOR_ID, department_id: DEPARTMENT_ID, permissionCodes: ["system.users"], }; const employees = options.employees ?? [buildEmployeeRow()]; const presenceRows = [...(options.presenceRows ?? [])]; const leaveRows = options.leaveRows ?? []; const travelRows = options.travelRows ?? []; const employeeFindMany = vi.fn(async ({ where, select }) => { if (select?.id === true && Object.keys(select).length === 1) { return employees .filter((item) => item.status === (where?.status ?? item.status)) .map((item) => ({ id: item.id })); } return employees .filter((item) => item.status === (where?.status ?? item.status)) .filter((item) => !where?.department_id || item.department_id === where.department_id) .filter((item) => !where?.id || item.id === where.id) .map((item) => ({ id: item.id, employee_no: item.employee_no, name: item.name, title: item.title, employment_status: item.employment_status, department: item.department, })); }); const employeeFindUnique = vi.fn(async ({ where }) => { const employee = employees.find((item) => item.id === where.id); if (!employee) { return null; } return { id: employee.id, status: employee.status, }; }); const employeePresenceFindMany = vi.fn(async ({ where }) => presenceRows.filter((row) => { if (!where?.employee_id?.in?.includes(row.employee_id)) return false; if (where?.date instanceof Date) { return row.date.getTime() === where.date.getTime(); } if (where?.date?.gte && row.date < where.date.gte) return false; if (where?.date?.lte && row.date > where.date.lte) return false; return true; }), ); const employeePresenceUpsert = vi.fn(async ({ where, update, create }) => { const rowIndex = presenceRows.findIndex( (row) => row.employee_id === where.employee_id_date.employee_id && sameDate(row.date, where.employee_id_date.date), ); const nextUpdatedAt = new Date("2026-03-28T10:30:00.000Z"); if (rowIndex >= 0) { presenceRows[rowIndex] = { ...presenceRows[rowIndex], status: update.status, time_range: update.time_range, note: update.note, source: update.source, updated_by: update.updated_by, updated_at: nextUpdatedAt, }; return presenceRows[rowIndex]; } const created = { id: `presence-${presenceRows.length + 1}`, employee_id: create.employee_id, date: create.date, status: create.status, time_range: create.time_range, note: create.note, source: create.source, updated_by: create.updated_by, updated_at: nextUpdatedAt, }; presenceRows.push(created); return created; }); const leaveFindMany = vi.fn(async ({ where }) => leaveRows.filter((row) => { if (!where?.owner_id?.in?.includes(row.owner_id)) return false; if (row.status && 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?.includes(row.applicant_employee_id)) return false; if (row.status && 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 auditCreate = vi.fn().mockResolvedValue({}); app.decorate("authenticate", async (request) => { request.employee = actor; }); app.decorate("requirePermission", (permissionCode) => async (request) => { request.employee = actor; const permissionCodes = actor.permissionCodes ?? []; if (!permissionCodes.includes(permissionCode)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403 }); } }); app.decorate("config", { appTimezone: "Asia/Shanghai", presenceBoardEnabled: true, }); app.decorate("prisma", { employee: { findMany: employeeFindMany, findUnique: employeeFindUnique, }, employeePresence: { findMany: employeePresenceFindMany, upsert: employeePresenceUpsert, }, leaveRequest: { findMany: leaveFindMany, }, travelRequest: { findMany: travelFindMany, }, auditLog: { create: auditCreate, }, featureFlag: { findUnique: vi.fn().mockResolvedValue({ id: "flag-presence", flag_key: "presence_board_v1", override_mode: "force_on", rollout_type: "global", department_ids: [], note: "", version: 1, created_at: new Date("2026-03-01T00:00:00.000Z"), created_by: "system", updated_at: new Date("2026-03-01T00:00:00.000Z"), updated_by: "system", }), }, }); await app.register(presenceRoutes, { prefix: "/api" }); return { app, auditCreate, employeePresenceUpsert, presenceRows, }; } describe("presence admin override routes", () => { it("allows system.users to override leave-covered dates and keeps admin_override as final state", async () => { const { app, auditCreate, employeePresenceUpsert } = await buildApp({ leaveRows: [buildLeaveRow()], }); const updateResponse = await app.inject({ method: "PUT", url: `/api/presence/team/${EMPLOYEE_ID}`, payload: { date: "2026-03-27", status: "office", timeRange: "morning", note: "上午补录在岗", }, }); expect(updateResponse.statusCode).toBe(200); expect(employeePresenceUpsert).toHaveBeenCalledWith( expect.objectContaining({ update: expect.objectContaining({ source: "admin_override", status: "office", time_range: "morning", }), }), ); expect(auditCreate).toHaveBeenCalledWith( expect.objectContaining({ data: expect.objectContaining({ action: "PRESENCE_ADMIN_CORRECTED", extra: expect.objectContaining({ employeeId: EMPLOYEE_ID, date: "2026-03-27", before: expect.objectContaining({ status: "leave", source: "leave_sync", }), after: expect.objectContaining({ status: "office", source: "admin_override", }), }), }), }), ); const teamResponse = await app.inject({ method: "GET", url: "/api/presence/team?date=2026-03-27", }); expect(teamResponse.statusCode).toBe(200); expect(teamResponse.json().data.items).toEqual([ { employee: expect.objectContaining({ id: EMPLOYEE_ID, employeeNo: "E001", }), presence: expect.objectContaining({ status: "office", source: "admin_override", timeRange: "morning", }), }, ]); const historyResponse = await app.inject({ method: "GET", url: `/api/presence/team/history?dateFrom=2026-03-27&dateTo=2026-03-27&employeeId=${EMPLOYEE_ID}`, }); expect(historyResponse.statusCode).toBe(200); expect(historyResponse.json().data.items).toEqual([ { date: "2026-03-27", employee: expect.objectContaining({ id: EMPLOYEE_ID, employeeNo: "E001", }), presence: expect.objectContaining({ status: "office", source: "admin_override", }), }, ]); const summaryResponse = await app.inject({ method: "GET", url: "/api/presence/summary?date=2026-03-27", }); expect(summaryResponse.statusCode).toBe(200); expect(summaryResponse.json().data.summary).toEqual({ office: 1, remote: 0, out: 0, travel: 0, leave: 0, unspecified: 0, total: 1, }); await app.close(); }); it("updates existing manual rows to admin_override", async () => { const { app } = await buildApp({ presenceRows: [ buildPresenceRow({ status: "remote", note: "原始记录", source: "manual", }), ], }); const response = await app.inject({ method: "PUT", url: `/api/presence/team/${EMPLOYEE_ID}`, payload: { date: "2026-03-27", status: "out", note: "管理员修正", }, }); expect(response.statusCode).toBe(200); expect(response.json().data.item).toMatchObject({ status: "out", source: "admin_override", note: "管理员修正", }); await app.close(); }); it("rejects non system.users access", async () => { const { app } = await buildApp({ actor: { id: ACTOR_ID, department_id: DEPARTMENT_ID, permissionCodes: ["report.dashboard.view"], }, employees: [ buildEmployeeRow(), buildEmployeeRow({ id: OTHER_EMPLOYEE_ID, employee_no: "E002", }), ], }); const response = await app.inject({ method: "PUT", url: `/api/presence/team/${OTHER_EMPLOYEE_ID}`, payload: { date: "2026-03-27", status: "office", }, }); expect(response.statusCode).toBe(403); await app.close(); }); });