const Fastify = require("fastify"); const { AppError } = require("../../../src/utils/AppError"); const attendance = require("../../../src/services/attendance"); const flags = require("../../../src/services/featureFlags"); const savedGate = flags.evaluateFeatureFlag; const gate = vi.fn(); flags.evaluateFeatureFlag = gate; const routePath = require.resolve("../../../src/routes/attendance"); const savedRoute = require.cache[routePath]; delete require.cache[routePath]; const routes = require(routePath); flags.evaluateFeatureFlag = savedGate; afterAll(() => { if (savedRoute) require.cache[routePath] = savedRoute; else delete require.cache[routePath]; }); const ID = "11111111-1111-4111-8111-111111111111"; const DATE = "2026-09-16"; const POLICY = { workStartTime: "09:00", workEndTime: "18:00", lunchStartTime: "12:00", lunchEndTime: "13:00", lateToleranceMinutes: 0 }; const apps = []; beforeEach(() => { gate.mockReset().mockResolvedValue({ enabled: true, reason: "synthetic_gate" }); vi.useFakeTimers({ toFake: ["Date"] }); vi.setSystemTime(new Date(`${DATE}T02:00:00Z`)); }); afterEach(async () => { vi.useRealTimers(); await Promise.all(apps.splice(0).map((app) => app.close())); }); async function build({ allowed = true } = {}) { const app = Fastify(); apps.push(app); const row = { id: "synthetic-record", employee_id: ID, work_date: new Date(`${DATE}T00:00:00Z`), clock_in_at: null, clock_out_at: null }; const prisma = { employee: { findUnique: vi.fn().mockResolvedValue({ id: ID, status: "active" }), findMany: vi.fn().mockResolvedValue([]) }, attendanceRecord: { findFirst: vi.fn().mockResolvedValue(null), findMany: vi.fn().mockResolvedValue([]), create: vi.fn(async ({ data }) => ({ ...row, ...data })), update: vi.fn(async ({ data }) => ({ ...row, ...data })) }, attendancePolicy: { findFirst: vi.fn().mockResolvedValue(null), create: vi.fn(async ({ data }) => ({ id: "synthetic-policy", ...data })), update: vi.fn(async ({ data }) => ({ id: "synthetic-policy", ...data })) }, leaveRequest: { findMany: vi.fn().mockResolvedValue([]) }, travelRequest: { findMany: vi.fn().mockResolvedValue([]) }, legalEntity: { findMany: vi.fn().mockResolvedValue([]) }, wecomCheckinSyncRun: { findMany: vi.fn().mockResolvedValue([]), count: vi.fn().mockResolvedValue(0), create: vi.fn(), update: vi.fn() }, auditLog: { create: vi.fn().mockResolvedValue({}) }, }; const auth = async (request) => { request.employee = { id: ID, permissionCodes: ["attendance.sync", "attendance.view_all"] }; if (!allowed) throw new AppError({ code: "PERM_001", statusCode: 403 }); }; app.decorate("prisma", prisma); app.decorate("config", { appTimezone: "Asia/Shanghai" }); app.decorate("authenticate", auth); app.decorate("requirePermission", () => auth); app.decorate("assertEntityPermission", () => {}); app.setErrorHandler((e, _, reply) => reply.status(e.statusCode || 500).send({ error: { code: e.code, details: e.details } })); await app.register(routes, { prefix: "/api" }); return { app, prisma, row }; } function rejected(r, reason, status = 400, details = {}) { expect(r.statusCode).toBe(status); expect(r.json().error).toMatchObject({ code: "VALID_001", details: { reasonCode: `attendance.${reason}`, ...details } }); } function noWrites(p) { for (const m of Object.values(p)) for (const op of ["create", "update"]) if (m[op]) expect(m[op]).not.toHaveBeenCalled(); } const inject = (app, method, path, payload) => app.inject({ method, url: `/api/attendance${path}`, payload }); describe("attendance error contracts with explicit synthetic gate (legacy remains hard off)", () => { it.each([ ["POST", "/clock-in", { unexpected: true }, "clock_input_invalid"], ["POST", "/clock-out", { note: "x".repeat(301) }, "clock_input_invalid"], ["GET", "/me/month?year=1999", undefined, "legacy_month_query_invalid"], ["GET", "/team?date=2026-02-29", undefined, "team_query_invalid"], ["PUT", "/policy", { ...POLICY, workStartTime: "25:00" }, "policy_input_invalid"], ["PUT", "/policy", { ...POLICY, lateToleranceMinutes: 601 }, "policy_input_invalid"], ["POST", "/admin/backfill", {}, "backfill_input_invalid"], ["POST", "/wecom/sync", { startDate: DATE, endDate: "2026-09-15" }, "sync_input_invalid"], ["POST", "/wecom/sync", { startDate: DATE, endDate: "2026-10-17" }, "sync_input_invalid"], ["POST", "/wecom/sync", { startDate: DATE, endDate: DATE, employeeIds: Array(51).fill(ID) }, "sync_input_invalid"], ["GET", "/wecom/sync-runs?page=0", undefined, "sync_runs_query_invalid"], ])("explains %s %s before writes", async (method, path, payload, reason) => { const { app, prisma } = await build(); const r = await inject(app, method, path, payload); rejected(r, reason); noWrites(prisma); expect(prisma.employee.findUnique).not.toHaveBeenCalled(); if (path === "/policy") expect(r.json().error.details.fieldErrors).not.toHaveProperty("reasonCode"); }); it.each([["/clock-in", "feature_disabled"], ["/wecom/sync", "unified_feature_disabled"]])("preserves disabled gate priority %s", async (path, reason) => { gate.mockResolvedValue({ enabled: false, reason: "synthetic_off" }); const { app, prisma } = await build(); rejected(await inject(app, "POST", path, {}), reason, 409, { reason: "synthetic_off" }); noWrites(prisma); expect(prisma.employee.findUnique).not.toHaveBeenCalled(); }); it.each(["/clock-in", "/clock-out"])("explains exempt day %s", async (path) => { const { app, prisma } = await build(); prisma.leaveRequest.findMany.mockResolvedValue([{ owner_id: ID, start_date: new Date(DATE), end_date: new Date(DATE) }]); rejected(await inject(app, "POST", path, {}), "exempt_day", 409, { exemptionReason: "leave" }); noWrites(prisma); }); it("explains missing clock-in", async () => { const { app, prisma } = await build(); rejected(await inject(app, "POST", "/clock-out", {}), "missing_clock_in", 400, { attendance: "missing_clock_in" }); noWrites(prisma); }); it.each([[{}, "missing_backfill_values"], [{ clockInAt: `${DATE}T09:00:00Z`, clockOutAt: `${DATE}T08:00:00Z` }, "clock_range_invalid"]])("rejects backfill %s", async (patch, reason) => { const { app, prisma } = await build(); rejected(await inject(app, "POST", "/admin/backfill", { employeeId: ID, workDate: DATE, ...patch }), reason); noWrites(prisma); }); it.each([[{ workEndTime: "08:00" }, "work_range_invalid"], [{ lunchEndTime: "11:00" }, "lunch_range_invalid"], [{ lunchStartTime: "08:00" }, "lunch_outside_work_range"], [{ lunchEndTime: "19:00" }, "lunch_outside_work_range"]])("explains policy relation %s", async (patch, reason) => { const { app, prisma } = await build(); rejected(await inject(app, "PUT", "/policy", { ...POLICY, ...patch }), reason); noWrites(prisma); }); it("retains first clock-in write and idempotent repeated clock-in", async () => { const { app, prisma, row } = await build(); expect((await inject(app, "POST", "/clock-in", {})).statusCode).toBe(200); expect(prisma.attendanceRecord.create).toHaveBeenCalledOnce(); prisma.attendanceRecord.create.mockClear(); prisma.attendanceRecord.findFirst.mockResolvedValue({ ...row, clock_in_at: new Date(`${DATE}T01:00:00Z`) }); expect((await inject(app, "POST", "/clock-in", {})).statusCode).toBe(200); noWrites(prisma); }); it.each([{ clockOutAt: `${DATE}T09:00:00Z` }, { clockInAt: `${DATE}T09:00:00Z`, clockOutAt: `${DATE}T09:00:00Z` }])("preserves legal backfill %s", async (patch) => { const { app, prisma } = await build(); const r = await inject(app, "POST", "/admin/backfill", { employeeId: ID, workDate: DATE, ...patch }); expect(r.statusCode).toBe(200); expect(prisma.attendanceRecord.create).toHaveBeenCalledOnce(); expect(prisma.auditLog.create).toHaveBeenCalledOnce(); }); it("retains policy create and permission priority", async () => { const { app, prisma } = await build(); expect((await inject(app, "PUT", "/policy", POLICY)).statusCode).toBe(200); expect(prisma.attendancePolicy.create).toHaveBeenCalledOnce(); gate.mockClear(); const denied = await build({ allowed: false }); expect((await inject(denied.app, "PUT", "/policy", {})).statusCode).toBe(403); expect(gate).not.toHaveBeenCalled(); noWrites(denied.prisma); }); }); describe("actual attendance service guards", () => { it.each(["9:00", "24:00", "12:60", "bad"])("rejects time %s", (value) => { expect(() => attendance.parseTimeToMinutes(value, "workStartTime")).toThrow(expect.objectContaining({ statusCode: 400, details: { workStartTime: "invalid_time", reasonCode: "attendance.policy_time_invalid" } })); }); it.each([-1, 601, 0.5, "bad"])("rejects tolerance %s before persistence", async (value) => { const { prisma } = await build(); await expect(attendance.upsertAttendancePolicy(prisma, ID, { ...POLICY, lateToleranceMinutes: value })).rejects.toMatchObject({ details: { reasonCode: "attendance.tolerance_invalid" } }); expect(prisma.attendancePolicy.findFirst).not.toHaveBeenCalled(); noWrites(prisma); }); it.each([[1999, 1, "year_invalid"], [2101, 1, "year_invalid"], [2026, 0, "month_invalid"], [2026, 13, "month_invalid"]])("rejects month range %s/%s", (year, month, reason) => { expect(() => attendance.resolveMonthInput({ year, month }, "Asia/Shanghai")).toThrow(expect.objectContaining({ details: expect.objectContaining({ reasonCode: `attendance.${reason}` }) })); }); it("keeps legal time and leap month", () => { expect(attendance.parseTimeToMinutes("23:59")).toBe(1439); expect(attendance.resolveMonthInput({ year: 2024, month: 2 }, "Asia/Shanghai").dateStrings).toHaveLength(29); }); });