387 lines
15 KiB
TypeScript
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',
|
|
}))
|
|
})
|
|
})
|