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.setErrorHandler((error, request, reply) => reply.status(error.statusCode || 500).send({ error: { code: error.code, details: error.details ?? {} } })); 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(); }); }); describe("presence calendar boundaries", () => { const routes = [ ["GET", "/presence/me", "date"], ["GET", "/presence/team", "date"], ["GET", "/presence/summary", "date"], ["GET", "/presence/me/history", "dateFrom"], ["GET", "/presence/me/history", "dateTo"], ["GET", "/presence/team/history", "dateFrom"], ["GET", "/presence/team/history", "dateTo"], ["PUT", "/presence/me", "date"], ["PUT", `/presence/team/${EMPLOYEE_ID}`, "date"], ]; it.each(routes)("rejects invalid calendar %s %s %s before reads/writes", async (method, url, field) => { const { app, auditCreate, employeePresenceUpsert } = await buildApp(); try { const input = { [field]: "2026-02-30" }; const response = await app.inject({ method, url: `/api${url}` + (method === "GET" ? `?${new URLSearchParams(input)}` : ""), ...(method === "PUT" ? { payload: { ...input, status: "office" } } : {}) }); expect(response.statusCode).toBe(400); const reason = method === "PUT" ? (url === "/presence/me" ? "write_input_invalid" : "override_input_invalid") : url.includes("team/history") ? "team_history_query_invalid" : url.includes("history") ? "history_query_invalid" : "query_invalid"; expect(response.json().error.details.reasonCode).toBe(`presence.${reason}`); expect(app.prisma.employeePresence.findMany).not.toHaveBeenCalled(); expect(employeePresenceUpsert).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled(); } finally { await app.close(); } }); it.each(["2024-02-29", "2026-04-30"])("accepts %s in self and admin writes", async (date) => { const { app, employeePresenceUpsert } = await buildApp(); try { for (const url of ["/api/presence/me", `/api/presence/team/${EMPLOYEE_ID}`]) { const response = await app.inject({ method: "PUT", url, payload: { date, status: "office" } }); expect(response.statusCode).toBe(200); expect(employeePresenceUpsert.mock.calls.at(-1)[0].create.date).toEqual(new Date(`${date}T00:00:00.000Z`)); } } finally { await app.close(); } }); it("keeps omitted date supported and null rejected for self writes", async () => { const { app, employeePresenceUpsert } = await buildApp(); try { const omitted = await app.inject({ method: "PUT", url: "/api/presence/me", payload: { status: "office" } }); expect(omitted.statusCode).toBe(200); employeePresenceUpsert.mockClear(); const nullDate = await app.inject({ method: "PUT", url: "/api/presence/me", payload: { date: null, status: "office" } }); expect(nullDate.statusCode).toBe(400); expect(employeePresenceUpsert).not.toHaveBeenCalled(); } finally { await app.close(); } }); }); describe("presence specific error contracts", () => { it("rejects invalid override id before employee reads", async () => { const { app, auditCreate, employeePresenceUpsert } = await buildApp(); try { const r = await app.inject({ method: "PUT", url: "/api/presence/team/bad", payload: { date: "2026-03-27", status: "office" } }); expect(r.statusCode).toBe(400); expect(r.json().error.details.reasonCode).toBe("presence.employee_id_invalid"); expect(app.prisma.employee.findUnique).not.toHaveBeenCalled(); expect(employeePresenceUpsert).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled(); } finally { await app.close(); } }); it("retains feature rejection before queries", async () => { const { app, auditCreate, employeePresenceUpsert } = await buildApp(); app.config.presenceBoardEnabled = false; try { const r = await app.inject({ method: "GET", url: "/api/presence/me?date=bad" }); expect(r.statusCode).toBe(409); expect(r.json().error.details).toMatchObject({ featureFlag: "presence_board_v1_disabled", reasonCode: "presence.feature_disabled" }); expect(app.prisma.employeePresence.findMany).not.toHaveBeenCalled(); expect(employeePresenceUpsert).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled(); } finally { await app.close(); } }); it("keeps approved leave readonly for self writes", async () => { const { app, auditCreate, employeePresenceUpsert } = await buildApp({ actor: { id: EMPLOYEE_ID, permissionCodes: [] }, leaveRows: [buildLeaveRow()] }); try { const r = await app.inject({ method: "PUT", url: "/api/presence/me", payload: { date: "2026-03-27", status: "office" } }); expect(r.statusCode).toBe(409); expect(r.json().error.details).toEqual({ date: "covered_by_approved_leave", reasonCode: "presence.approved_leave_readonly" }); expect(employeePresenceUpsert).not.toHaveBeenCalled(); expect(auditCreate).not.toHaveBeenCalled(); } finally { await app.close(); } }); });