gongxiangp2/lib/__tests__/task-detail-change-approval.test.ts

387 lines
15 KiB
TypeScript

/** @jest-environment node */
import { prisma } from '@/lib/prisma'
import { getEmployeeByUserId } from '@/lib/employee-directory'
import { canManageMaterial } from '@/lib/material-authorization'
import { batchEditTaskDetailImages } 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() },
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() }))
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)
;(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('跨主图和详情图的变更在暂存前直接拒绝', 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('超过 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',
}))
})
})