/** @jest-environment node */ import { prisma } from '@/lib/prisma' import { getEmployeeByUserId } from '@/lib/employee-directory' import { canManageMaterial } from '@/lib/material-authorization' import { batchEditTaskDetailImages, TaskDetailAssetEditError } from '@/lib/task-detail-asset-edit' import { cleanupTaskDetailChangeStaging, resolveTaskDetailStagedFile, withTaskDetailStagedUploads, } from '@/lib/task-detail-change-staging' import { queueTaskDetailChangeNotification } from '@/lib/task-detail-change-notifications' import { decideTaskDetailChange, expireDueTaskDetailChanges, recoverMissingTaskDetailChangeRequestNotifications, recoverStaleApplyingTaskDetailChanges, submitTaskDetailChange, } from '@/lib/task-detail-change-approval' jest.mock('@/lib/prisma', () => ({ prisma: { material: { findFirst: jest.fn(), findUnique: jest.fn() }, performanceSurveyEmployeeProfile: { findUnique: jest.fn(), findMany: jest.fn() }, performanceSurveyEmployeeDepartmentMembership: { findMany: jest.fn() }, taskDetailChangeRequest: { create: jest.fn(), findUnique: jest.fn(), findMany: jest.fn(), update: jest.fn(), updateMany: jest.fn(), }, }, })) jest.mock('@/lib/employee-directory', () => ({ BINDING_STATUS: { ACTIVE: 'ACTIVE' }, getEmployeeByUserId: jest.fn(), })) jest.mock('@/lib/material-authorization', () => ({ canManageMaterial: jest.fn() })) jest.mock('@/lib/user-permission-inherit', () => ({ canUserEditTaskDetailAssets: jest.fn() })) jest.mock('@/lib/department-permission', () => ({ isSystemAdmin: jest.fn((role: string) => role === 'ADMIN' || role === 'SYSTEM_ADMIN'), })) jest.mock('@/lib/material-type-mapper', () => ({ isProductPackageType: jest.fn(() => true) })) jest.mock('@/lib/public-app-url', () => ({ getPublicAppOrigin: () => 'https://sc.example.com' })) jest.mock('@/lib/safe-redirect', () => ({ buildDingtalkEntryPath: (path: string) => path })) jest.mock('@/lib/task-detail-asset-edit', () => ({ batchEditTaskDetailImages: jest.fn(), TaskDetailAssetEditError: class extends Error { constructor(message: string, public statusCode = 400) { super(message) } }, })) jest.mock('@/lib/task-detail-change-notifications', () => ({ queueTaskDetailChangeNotification: jest.fn(), })) jest.mock('@/lib/task-detail-change-staging', () => ({ cleanupTaskDetailChangeStaging: jest.fn(), resolveTaskDetailStagedFile: jest.fn(), withTaskDetailStagedUploads: jest.fn(), })) const material = { id: 'material-1', userId: 'owner-1', materialType: '任务-详情', taskStatus: 'completed', taskNo: 'TASK-001', productModel: 'SPU-001', updatedAt: new Date('2026-08-28T00:00:00.000Z'), assets: [ { id: 'asset-1', assetType: 'main-preview' }, { id: 'detail-1', assetType: 'detail-preview' }, ], } function employee(levelType: 'M' | 'P', dingtalkUserId = 'employee-ding') { return { employeeProfileId: `employee-${levelType}`, userId: 'user-1', displayName: levelType === 'M' ? '管理者' : '申请人', realName: null, flowerName: null, jobNumber: 'JS001', dingtalkUserId, departmentName: '品牌中心', departmentPath: '公司/品牌中心', positionTitle: '设计师', positionLevel: `${levelType}1`, levelType, isLeadership: levelType === 'M', managerName: '直属主管', managerDingtalkUserId: 'manager-ding', employmentStatus: 'active', } } describe('任务详情图片变更主管审批', () => { beforeEach(() => { jest.clearAllMocks() ;(prisma.material.findFirst as jest.Mock).mockResolvedValue(material) ;(prisma.performanceSurveyEmployeeProfile.findMany as jest.Mock).mockResolvedValue([ { id: 'manager-employee', displayName: '直属主管', dingtalkUserId: 'manager-ding', employmentState: 'ACTIVE' }, ]) ;(prisma.performanceSurveyEmployeeDepartmentMembership.findMany as jest.Mock).mockResolvedValue([{ sourceDeptId: 'department-1' }]) ;(canManageMaterial as jest.Mock).mockResolvedValue(true) ;(batchEditTaskDetailImages as jest.Mock).mockResolvedValue({ success: true }) ;(queueTaskDetailChangeNotification as jest.Mock).mockResolvedValue('notification-1') ;(cleanupTaskDetailChangeStaging as jest.Mock).mockResolvedValue(undefined) ;(resolveTaskDetailStagedFile as jest.Mock).mockReturnValue({ trusted: true, filePath: '/uploads/staged/new.png', fileName: 'new.png', fileSize: 10, }) }) test('M 级人员有权限时直接应用,不创建审批单', async () => { ;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('M')) const result = await submitTaskDetailChange('material-1', { id: 'user-1', role: 'USER', }, [{ kind: 'delete', assetId: 'asset-1' }]) expect(result.mode).toBe('applied') expect(batchEditTaskDetailImages).toHaveBeenCalledWith(expect.objectContaining({ materialId: 'material-1', operations: [{ kind: 'delete', assetId: 'asset-1' }], })) expect(prisma.taskDetailChangeRequest.create).not.toHaveBeenCalled() }) test('P 级人员把替换文件转为持久暂存并创建 24 小时审批单', async () => { ;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('P')) ;(prisma.performanceSurveyEmployeeProfile.findUnique as jest.Mock).mockResolvedValue({ id: 'manager-employee', displayName: '直属主管', realName: null, employmentState: 'ACTIVE', dingtalkUserId: 'manager-ding', jobNumber: 'M001', departmentPath: '公司/品牌中心', positionLevel: 'M1', userBindings: [{ userId: 'manager-user' }], }) ;(withTaskDetailStagedUploads as jest.Mock).mockImplementation(async ({ commit }) => commit(new Map([ ['upload-1', { relativePath: 'task-detail-change-requests/request/new.png', fileName: 'new.png', fileSize: 10 }], ]))) ;(prisma.taskDetailChangeRequest.create as jest.Mock).mockImplementation(async ({ data }) => ({ ...data, material, submittedAt: new Date(), })) const result = await submitTaskDetailChange('material-1', { id: 'user-1', role: 'USER', }, [{ kind: 'replace', assetId: 'asset-1', uploadId: 'upload-1' }]) expect(result.mode).toBe('pending_approval') expect(batchEditTaskDetailImages).not.toHaveBeenCalled() expect(withTaskDetailStagedUploads).toHaveBeenCalledWith(expect.objectContaining({ ownerUserId: 'user-1', uploadIds: ['upload-1'], })) expect(prisma.taskDetailChangeRequest.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'pending_manager_approval', managerDingtalkUserId: 'manager-ding', operations: [expect.objectContaining({ kind: 'replace', assetId: 'asset-1', stagedFile: expect.objectContaining({ fileName: 'new.png' }), })], }), })) }) test('P 级负责人仍需直属主管审批,不能因 isLeadership 绕过', async () => { ;(getEmployeeByUserId as jest.Mock).mockResolvedValue({ ...employee('P'), isLeadership: true, }) ;(prisma.performanceSurveyEmployeeProfile.findUnique as jest.Mock).mockResolvedValue({ id: 'manager-employee', displayName: '直属主管', realName: null, employmentState: 'ACTIVE', dingtalkUserId: 'manager-ding', jobNumber: 'M001', departmentPath: '公司/品牌中心', positionLevel: 'M1', userBindings: [{ userId: 'manager-user' }], }) ;(withTaskDetailStagedUploads as jest.Mock).mockImplementation(async ({ commit }) => commit(new Map())) ;(prisma.taskDetailChangeRequest.create as jest.Mock).mockImplementation(async ({ data }) => ({ ...data, material, submittedAt: new Date(), })) const result = await submitTaskDetailChange('material-1', { id: 'user-1', role: 'USER', }, [{ kind: 'delete', assetId: 'asset-1' }]) expect(result.mode).toBe('pending_approval') expect(batchEditTaskDetailImages).not.toHaveBeenCalled() expect(prisma.taskDetailChangeRequest.create).toHaveBeenCalled() }) test('主管唯一值缺失时按同一主部门唯一M级人员进入主管确认,不提前修改图片', async () => { ;(getEmployeeByUserId as jest.Mock).mockResolvedValue({ ...employee('P'), managerDingtalkUserId: null, managerJobNumber: null, managerName: null }) ;(prisma.performanceSurveyEmployeeProfile.findUnique as jest.Mock).mockResolvedValue({ id: 'manager-employee', displayName: '部门主管', employmentState: 'ACTIVE', dingtalkUserId: 'manager-ding', userBindings: [{ userId: 'manager-user' }], }) ;(withTaskDetailStagedUploads as jest.Mock).mockImplementation(async ({ commit }) => commit(new Map())) ;(prisma.taskDetailChangeRequest.create as jest.Mock).mockImplementation(async ({ data }) => ({ ...data, material, submittedAt: new Date() })) const result = await submitTaskDetailChange('material-1', { id: 'user-1', role: 'USER' }, [{ kind: 'delete', assetId: 'asset-1' }]) expect(result.mode).toBe('pending_approval') expect(prisma.performanceSurveyEmployeeDepartmentMembership.findMany).toHaveBeenCalledWith(expect.objectContaining({ where: { employeeProfileId: 'employee-P', isPrimary: true }, })) expect(prisma.taskDetailChangeRequest.create).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ managerDingtalkUserId: 'manager-ding', managerUserId: 'manager-user' }) })) expect(batchEditTaskDetailImages).not.toHaveBeenCalled() }) test('跨主图和详情图的变更在暂存前直接拒绝', async () => { ;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('P')) await expect(submitTaskDetailChange('material-1', { id: 'user-1', role: 'USER', }, [ { kind: 'delete', assetId: 'asset-1' }, { kind: 'delete', assetId: 'detail-1' }, ])).rejects.toThrow('一次只能提交同一图片分组') expect(withTaskDetailStagedUploads).not.toHaveBeenCalled() expect(prisma.taskDetailChangeRequest.create).not.toHaveBeenCalled() }) test('直属主管批准后才应用暂存操作', async () => { const pending = { id: 'request-1', materialId: 'material-1', requesterUserId: 'requester-user', requesterName: '申请人', managerDingtalkUserId: 'manager-ding', status: 'pending_manager_approval', expiresAt: new Date(Date.now() + 60_000), baseMaterialUpdatedAt: material.updatedAt, operations: [{ kind: 'replace', assetId: 'asset-1', stagedFile: { relativePath: 'task-detail-change-requests/request-1/new.png', fileName: 'new.png', fileSize: 10 }, }], material, requesterEmployee: { dingtalkUserId: 'requester-ding' }, } ;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('M', 'manager-ding')) ;(prisma.taskDetailChangeRequest.findUnique as jest.Mock) .mockResolvedValueOnce(pending) .mockResolvedValueOnce({ ...pending, status: 'applied' }) ;(prisma.taskDetailChangeRequest.updateMany as jest.Mock).mockResolvedValue({ count: 1 }) ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ updatedAt: material.updatedAt }) ;(prisma.taskDetailChangeRequest.update as jest.Mock).mockResolvedValue({}) const result = await decideTaskDetailChange('request-1', { id: 'manager-user', role: 'USER', }, 'approve') expect(result.status).toBe('applied') expect(batchEditTaskDetailImages).toHaveBeenCalledWith(expect.objectContaining({ operatorUserId: 'requester-user', expectedMaterialUpdatedAt: material.updatedAt, approvalRequestId: 'request-1', operations: [expect.objectContaining({ kind: 'replace', stagedFile: expect.objectContaining({ trusted: true, fileName: 'new.png' }), })], })) expect(cleanupTaskDetailChangeStaging).toHaveBeenCalledWith('request-1') }) test('系统管理员可以审计查看但不能代替直属主管批准', async () => { const pending = { id: 'request-manager-only', materialId: 'material-1', requesterUserId: 'requester-user', managerDingtalkUserId: 'manager-ding', status: 'pending_manager_approval', expiresAt: new Date(Date.now() + 60_000), material, requesterEmployee: { dingtalkUserId: 'requester-ding' }, } ;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('M', 'other-manager-ding')) ;(prisma.taskDetailChangeRequest.findUnique as jest.Mock).mockResolvedValue(pending) await expect(decideTaskDetailChange('request-manager-only', { id: 'admin-user', role: 'ADMIN', }, 'approve')).rejects.toThrow('只有申请人的直属主管可以确认') expect(prisma.taskDetailChangeRequest.updateMany).not.toHaveBeenCalled() expect(batchEditTaskDetailImages).not.toHaveBeenCalled() }) test('批准时正式素材已变化会停止应用、清理暂存并通知申请人', async () => { const pending = { id: 'request-stale', materialId: 'material-1', requesterUserId: 'requester-user', requesterName: '申请人', managerDingtalkUserId: 'manager-ding', status: 'pending_manager_approval', expiresAt: new Date(Date.now() + 60_000), baseMaterialUpdatedAt: material.updatedAt, operations: [{ kind: 'delete', assetId: 'asset-1' }], material, requesterEmployee: { dingtalkUserId: 'requester-ding' }, } ;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('M', 'manager-ding')) ;(prisma.taskDetailChangeRequest.findUnique as jest.Mock) .mockResolvedValueOnce(pending) .mockResolvedValueOnce({ ...pending, status: 'apply_failed' }) ;(prisma.taskDetailChangeRequest.updateMany as jest.Mock).mockResolvedValue({ count: 1 }) ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ updatedAt: new Date(material.updatedAt.getTime() + 1000), }) ;(prisma.taskDetailChangeRequest.update as jest.Mock).mockResolvedValue({}) await expect(decideTaskDetailChange('request-stale', { id: 'manager-user', role: 'USER', }, 'approve')).rejects.toThrow('正式素材在审批期间已更新') expect(batchEditTaskDetailImages).not.toHaveBeenCalled() expect(prisma.taskDetailChangeRequest.update).toHaveBeenCalledWith(expect.objectContaining({ data: expect.objectContaining({ status: 'apply_failed' }), })) expect(cleanupTaskDetailChangeStaging).toHaveBeenCalledWith('request-stale') expect(queueTaskDetailChangeNotification).toHaveBeenCalledWith(expect.objectContaining({ eventType: 'task_detail_change.apply_failed', })) }) test('应用业务错误保存失败原因并透传状态码,不伪装成成功', async () => { const pending = { id: 'request-failed', materialId: 'material-1', requesterUserId: 'requester-user', requesterName: '申请人', managerDingtalkUserId: 'manager-ding', status: 'pending_manager_approval', expiresAt: new Date(Date.now() + 60_000), baseMaterialUpdatedAt: material.updatedAt, operations: [{ kind: 'delete', assetId: 'asset-1' }], material, requesterEmployee: { dingtalkUserId: 'requester-ding' }, } ;(getEmployeeByUserId as jest.Mock).mockResolvedValue(employee('M', 'manager-ding')) ;(prisma.taskDetailChangeRequest.findUnique as jest.Mock).mockResolvedValueOnce(pending).mockResolvedValueOnce({ ...pending, status: 'apply_failed' }) ;(prisma.taskDetailChangeRequest.updateMany as jest.Mock).mockResolvedValue({ count: 1 }) ;(prisma.material.findUnique as jest.Mock).mockResolvedValue({ updatedAt: material.updatedAt }) ;(batchEditTaskDetailImages as jest.Mock).mockRejectedValueOnce(new TaskDetailAssetEditError('插图位置已失效', 409)) await expect(decideTaskDetailChange('request-failed', { id: 'manager-user', role: 'USER' }, 'approve')).rejects.toMatchObject({ message: '插图位置已失效', statusCode: 409 }) expect(prisma.taskDetailChangeRequest.update).toHaveBeenCalledWith({ where: { id: 'request-failed' }, data: { status: 'apply_failed', applyError: '插图位置已失效' } }) expect(queueTaskDetailChangeNotification).toHaveBeenCalledWith(expect.objectContaining({ eventType: 'task_detail_change.apply_failed' })) }) test('超过 24 小时未处理的申请自动否决并清理暂存', async () => { ;(prisma.taskDetailChangeRequest.findMany as jest.Mock).mockResolvedValue([{ id: 'request-expired' }]) ;(prisma.taskDetailChangeRequest.updateMany as jest.Mock).mockResolvedValue({ count: 1 }) ;(prisma.taskDetailChangeRequest.findUnique as jest.Mock).mockResolvedValue({ id: 'request-expired', status: 'expired', requesterUserId: 'requester-user', requesterName: '申请人', requesterEmployee: { dingtalkUserId: 'requester-ding' }, material, }) await expect(expireDueTaskDetailChanges()).resolves.toBe(1) expect(cleanupTaskDetailChangeStaging).toHaveBeenCalledWith('request-expired') expect(queueTaskDetailChangeNotification).toHaveBeenCalledWith(expect.objectContaining({ eventType: 'task_detail_change.expired', })) }) test('审批单存在但主管通知 outbox 缺失时会补偿排队', async () => { ;(prisma.taskDetailChangeRequest.findMany as jest.Mock).mockResolvedValue([{ id: 'request-missing-notice', managerUserId: 'manager-user', managerDingtalkUserId: 'manager-ding', managerName: '直属主管', requesterName: '申请人', material, operations: [{ kind: 'delete', assetId: 'asset-1' }], expiresAt: new Date(Date.now() + 60_000), }]) await expect(recoverMissingTaskDetailChangeRequestNotifications()).resolves.toBe(1) expect(queueTaskDetailChangeNotification).toHaveBeenCalledWith(expect.objectContaining({ eventType: 'task_detail_change.requested', businessKey: 'task-detail-change:request-missing-notice:requested', })) }) test('陈旧 applying 会转失败、清理暂存并通知申请人', async () => { const now = new Date('2026-08-28T12:00:00.000Z') ;(prisma.taskDetailChangeRequest.findMany as jest.Mock).mockResolvedValue([{ id: 'request-stale-applying' }]) ;(prisma.taskDetailChangeRequest.updateMany as jest.Mock).mockResolvedValue({ count: 1 }) ;(prisma.taskDetailChangeRequest.findUnique as jest.Mock).mockResolvedValue({ id: 'request-stale-applying', status: 'apply_failed', requesterUserId: 'requester-user', requesterName: '申请人', requesterEmployee: { dingtalkUserId: 'requester-ding' }, material, }) await expect(recoverStaleApplyingTaskDetailChanges(now)).resolves.toBe(1) expect(prisma.taskDetailChangeRequest.updateMany).toHaveBeenCalledWith(expect.objectContaining({ where: expect.objectContaining({ id: 'request-stale-applying', status: 'applying' }), data: expect.objectContaining({ status: 'apply_failed' }), })) expect(cleanupTaskDetailChangeStaging).toHaveBeenCalledWith('request-stale-applying') expect(queueTaskDetailChangeNotification).toHaveBeenCalledWith(expect.objectContaining({ eventType: 'task_detail_change.apply_failed', })) }) })