431 lines
19 KiB
TypeScript
431 lines
19 KiB
TypeScript
/** @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',
|
||
}))
|
||
})
|
||
})
|