const Fastify = require("fastify"); const { AppError } = require("../../../src/utils/AppError"); const featureFlagState = vi.hoisted(() => ({ enabled: true })); vi.mock("../../../src/services/featureFlags", () => ({ FEATURE_FLAG_KEYS: { announcements: "announcements_v1" }, evaluateFeatureFlag: vi.fn(async () => ({ enabled: featureFlagState.enabled, reason: featureFlagState.enabled ? "mock_on" : "mock_off", })), })); const announcementsRoutes = require("../../../src/routes/announcements"); const ADMIN_ID = "00000000-0000-4000-8000-000000000001"; const EMPLOYEE_ID = "00000000-0000-4000-8000-000000000002"; const EMPLOYEE_2_ID = "00000000-0000-4000-8000-000000000003"; const INACTIVE_EMPLOYEE_ID = "00000000-0000-4000-8000-000000000004"; const POLICY_ID = "10000000-0000-4000-8000-000000000001"; const GENERAL_ID = "10000000-0000-4000-8000-000000000002"; const HIDDEN_ID = "10000000-0000-4000-8000-000000000003"; const TEST_FUTURE_OFFSET_MS = 24 * 60 * 60 * 1000; function buildFutureDate() { return new Date(Date.now() + TEST_FUTURE_OFFSET_MS); } function buildEmployee(overrides = {}) { return { id: EMPLOYEE_ID, employee_no: "E001", name: "员工一", status: "active", permissionCodes: ["expense.view_own"], ...overrides, }; } function buildAnnouncement(overrides = {}) { const now = new Date("2026-05-28T02:00:00.000Z"); return { id: POLICY_ID, title: "费用制度签收", summary: "请确认费用制度 V1。", link_url: null, starts_at: null, ends_at: null, enabled: true, priority: 10, category: "policy", requires_acknowledgement: true, acknowledgement_statement: "我已阅读并理解该制度。", acknowledgement_due_at: buildFutureDate(), created_at: now, created_by: ADMIN_ID, updated_at: now, updated_by: ADMIN_ID, ...overrides, }; } function cloneWithInclude(item, include, acknowledgements) { const row = { ...item }; if (include?.acknowledgements) { let rows = acknowledgements.filter((acknowledgement) => acknowledgement.announcement_id === item.id); if (include.acknowledgements.where?.employee_id) { rows = rows.filter((acknowledgement) => acknowledgement.employee_id === include.acknowledgements.where.employee_id); } row.acknowledgements = rows.map((acknowledgement) => ({ id: acknowledgement.id, employee_id: acknowledgement.employee_id, acknowledged_at: acknowledgement.acknowledged_at, })); } return row; } function matchAnnouncementWhere(item, where = {}) { if (where.enabled !== undefined && item.enabled !== where.enabled) { return false; } const startsAtUpperBound = where.OR?.find((clause) => clause.starts_at?.lte)?.starts_at.lte ?? new Date(); const endsAtLowerBound = where.AND?.[0]?.OR?.find((clause) => clause.ends_at?.gte)?.ends_at.gte ?? new Date(); if (where.OR && item.starts_at && item.starts_at > startsAtUpperBound) { return false; } if (where.AND && item.ends_at && item.ends_at < endsAtLowerBound) { return false; } return true; } function createPrismaState(options = {}) { const employees = options.employees ?? [ buildEmployee({ id: EMPLOYEE_ID, employee_no: "E001", name: "员工一" }), buildEmployee({ id: EMPLOYEE_2_ID, employee_no: "E002", name: "员工二" }), buildEmployee({ id: INACTIVE_EMPLOYEE_ID, employee_no: "E003", name: "离职员工", status: "archived" }), ]; const announcements = options.announcements ?? [ buildAnnouncement(), buildAnnouncement({ id: GENERAL_ID, title: "普通公告", summary: "无需签收。", category: "general", requires_acknowledgement: false, acknowledgement_statement: null, acknowledgement_due_at: null, priority: 20, }), buildAnnouncement({ id: HIDDEN_ID, title: "未来公告", starts_at: buildFutureDate(), }), ]; const acknowledgements = options.acknowledgements ?? []; const auditLogs = []; const portalAnnouncement = { findMany: vi.fn(async ({ where, include }) => announcements .filter((item) => matchAnnouncementWhere(item, where)) .sort((left, right) => left.priority - right.priority || right.created_at.getTime() - left.created_at.getTime()) .map((item) => cloneWithInclude(item, include, acknowledgements)), ), findUnique: vi.fn(async ({ where, include }) => { const item = announcements.find((announcement) => announcement.id === where.id); return item ? cloneWithInclude(item, include, acknowledgements) : null; }), create: vi.fn(async ({ data }) => { const created = buildAnnouncement({ ...data, id: `10000000-0000-4000-8000-${String(announcements.length + 10).padStart(12, "0")}`, link_url: data.link_url ?? null, starts_at: data.starts_at ?? null, ends_at: data.ends_at ?? null, created_at: new Date("2026-05-28T02:00:00.000Z"), updated_at: new Date("2026-05-28T02:00:00.000Z"), }); announcements.push(created); return created; }), update: vi.fn(async ({ where, data }) => { const index = announcements.findIndex((announcement) => announcement.id === where.id); if (index < 0) { throw new Error("not_found"); } announcements[index] = { ...announcements[index], ...data, updated_at: new Date("2026-05-28T03:00:00.000Z") }; return announcements[index]; }), }; const portalAnnouncementAcknowledgement = { count: vi.fn(async ({ where }) => acknowledgements.filter((acknowledgement) => acknowledgement.announcement_id === where.announcement_id).length, ), create: vi.fn(async ({ data }) => { const duplicated = acknowledgements.find( (acknowledgement) => acknowledgement.announcement_id === data.announcement_id && acknowledgement.employee_id === data.employee_id, ); if (duplicated) { const error = new Error("duplicate_acknowledgement"); error.code = "P2002"; throw error; } const created = { id: `20000000-0000-4000-8000-${String(acknowledgements.length + 1).padStart(12, "0")}`, acknowledged_at: new Date("2026-05-28T02:30:00.000Z"), created_at: new Date("2026-05-28T02:30:00.000Z"), ...data, }; acknowledgements.push(created); return created; }), findUnique: vi.fn(async ({ where }) => { const compound = where.announcement_id_employee_id; if (!compound) return null; return acknowledgements.find( (acknowledgement) => acknowledgement.announcement_id === compound.announcement_id && acknowledgement.employee_id === compound.employee_id, ) ?? null; }), }; const auditLog = { create: vi.fn(async ({ data }) => { auditLogs.push(data); return { id: `audit-${auditLogs.length}`, ...data }; }), }; const prisma = { portalAnnouncement, portalAnnouncementAcknowledgement, employee: { findMany: vi.fn(async ({ where }) => employees.filter((employee) => employee.status === where.status)), }, auditLog, featureFlag: { findUnique: vi.fn(async () => ({ id: "flag-announcements", flag_key: "announcements_v1", override_mode: "force_on", rollout_type: "global", department_ids: [], note: "", version: 1, created_at: new Date("2026-05-28T00:00:00.000Z"), created_by: null, updated_at: new Date("2026-05-28T00:00:00.000Z"), updated_by: null, })), }, $transaction: vi.fn(async (callback) => callback({ portalAnnouncementAcknowledgement, auditLog })), }; return { prisma, employees, announcements, acknowledgements, auditLogs }; } async function buildApp(options = {}) { const state = createPrismaState(options); const actor = options.actor ?? buildEmployee({ id: EMPLOYEE_ID, permissionCodes: ["expense.view_own"] }); const app = Fastify({ logger: false }); app.decorate("config", { announcementsEnabled: true }); app.decorate("prisma", state.prisma); app.decorate("hasPermission", (employee, permissionCode) => (employee?.permissionCodes ?? []).includes(permissionCode)); app.decorate("assertPermission", (employee, permissionCode) => { if (!app.hasPermission(employee, permissionCode)) { throw new AppError({ code: "PERM_001", message: "无权限", statusCode: 403, details: { permission: permissionCode }, }); } }); app.decorate("authenticate", async (request) => { request.employee = actor; }); app.decorate("requirePermission", (permissionCode) => async (request) => { await app.authenticate(request); app.assertPermission(request.employee, permissionCode); }); app.setErrorHandler((error, request, reply) => { if (error instanceof AppError) { return reply.status(error.statusCode).send({ requestId: request.id, error: { code: error.code, message: error.message, details: error.details }, }); } return reply.status(500).send({ requestId: request.id, error: { code: "SYS_001", message: error.message, details: {} } }); }); await app.register(announcementsRoutes, { prefix: "/api" }); return { app, state }; } beforeEach(() => { featureFlagState.enabled = true; }); describe("announcements policy acknowledgement routes", () => { it("rejects policy acknowledgement announcement without statement", async () => { const { app } = await buildApp({ actor: buildEmployee({ id: ADMIN_ID, permissionCodes: ["system.settings"] }), }); const response = await app.inject({ method: "POST", url: "/api/announcements", payload: { title: "制度公告", summary: "缺少签收声明。", category: "policy", }, }); expect(response.statusCode).toBe(400); expect(response.json().error.details.acknowledgementStatement).toBe("required_for_policy_acknowledgement"); await app.close(); }); it("returns only current employee acknowledgement status for normal list", async () => { const { app } = await buildApp(); const response = await app.inject({ method: "GET", url: "/api/announcements" }); expect(response.statusCode).toBe(200); const item = response.json().data.items.find((announcement) => announcement.id === POLICY_ID); expect(item.acknowledgement).toMatchObject({ status: "pending", acknowledgedAt: null, overdue: false }); expect(item.acknowledgementSummary).toBeUndefined(); expect(item.pendingEmployees).toBeUndefined(); await app.close(); }); it("creates acknowledgement once and writes audit log idempotently", async () => { const { app, state } = await buildApp(); const first = await app.inject({ method: "POST", url: `/api/announcements/${POLICY_ID}/acknowledge`, payload: { confirmed: true }, }); const second = await app.inject({ method: "POST", url: `/api/announcements/${POLICY_ID}/acknowledge`, payload: { confirmed: true }, }); expect(first.statusCode).toBe(201); expect(second.statusCode).toBe(200); expect(state.acknowledgements).toHaveLength(1); expect(state.acknowledgements[0]).toMatchObject({ announcement_id: POLICY_ID, employee_id: EMPLOYEE_ID, statement_snapshot: "我已阅读并理解该制度。", title_snapshot: "费用制度签收", }); expect(state.auditLogs).toHaveLength(1); expect(state.auditLogs[0]).toMatchObject({ action: "PORTAL_ANNOUNCEMENT_ACKNOWLEDGED", target_type: "portal_announcement_acknowledgement", }); await app.close(); }); it("rejects acknowledgement for general or invisible announcements", async () => { const { app } = await buildApp(); const general = await app.inject({ method: "POST", url: `/api/announcements/${GENERAL_ID}/acknowledge`, payload: { confirmed: true }, }); const hidden = await app.inject({ method: "POST", url: `/api/announcements/${HIDDEN_ID}/acknowledge`, payload: { confirmed: true }, }); expect(general.statusCode).toBe(409); expect(general.json().error.details.acknowledgement).toBe("not_required"); expect(hidden.statusCode).toBe(404); await app.close(); }); it("returns admin acknowledgement summary and minimal pending employees only", async () => { const { app } = await buildApp({ actor: buildEmployee({ id: ADMIN_ID, permissionCodes: ["system.settings"] }), acknowledgements: [ { id: "20000000-0000-4000-8000-000000000099", announcement_id: POLICY_ID, employee_id: EMPLOYEE_ID, actor_id: EMPLOYEE_ID, acknowledged_at: new Date("2026-05-28T01:00:00.000Z"), statement_snapshot: "我已阅读并理解该制度。", title_snapshot: "费用制度签收", summary_snapshot: "请确认费用制度 V1。", created_at: new Date("2026-05-28T01:00:00.000Z"), }, ], }); const response = await app.inject({ method: "GET", url: "/api/announcements?manage=1" }); expect(response.statusCode).toBe(200); const item = response.json().data.items.find((announcement) => announcement.id === POLICY_ID); expect(item.acknowledgementSummary).toMatchObject({ required: true, totalActiveEmployees: 2, acknowledgedCount: 1, pendingCount: 1, overdueCount: 0, }); expect(item.pendingEmployees).toEqual([ { id: EMPLOYEE_2_ID, employeeNo: "E002", name: "员工二", status: "pending" }, ]); expect(JSON.stringify(item.pendingEmployees)).not.toMatch(/phone|email|wecom/i); await app.close(); }); it("denies manage query for employee without system settings permission", async () => { const { app } = await buildApp(); const response = await app.inject({ method: "GET", url: "/api/announcements?manage=1" }); expect(response.statusCode).toBe(403); await app.close(); }); it("rejects protected policy content edits after acknowledgement exists", async () => { const { app } = await buildApp({ actor: buildEmployee({ id: ADMIN_ID, permissionCodes: ["system.settings"] }), acknowledgements: [ { id: "20000000-0000-4000-8000-000000000099", announcement_id: POLICY_ID, employee_id: EMPLOYEE_ID, actor_id: EMPLOYEE_ID, acknowledged_at: new Date("2026-05-28T01:00:00.000Z"), statement_snapshot: "我已阅读并理解该制度。", title_snapshot: "费用制度签收", summary_snapshot: "请确认费用制度 V1。", created_at: new Date("2026-05-28T01:00:00.000Z"), }, ], }); const response = await app.inject({ method: "PATCH", url: `/api/announcements/${POLICY_ID}`, payload: { title: "费用制度签收 V2" }, }); expect(response.statusCode).toBe(409); expect(response.json().error.details.acknowledgement).toBe("create_new_version_after_acknowledgement"); await app.close(); }); });