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

431 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/** @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',
}))
})
})