1001 lines
29 KiB
TypeScript
1001 lines
29 KiB
TypeScript
import { prisma } from '@/lib/prisma'
|
||
import { createMaterialLog } from '@/lib/material-log'
|
||
import { compressImage, sanitizeFileName } from '@/lib/image-processor'
|
||
import { deleteFromS3, uploadToS3 } from '@/lib/s3'
|
||
import { uploadPublicDisplayToS3 } from '@/lib/public-display'
|
||
import { isSuperAdmin, isSystemAdmin } from '@/lib/department-permission'
|
||
import { queueTaskDetailFullSetNotification } from '@/lib/external-auto-listing'
|
||
import { updateMaterialWithSharedRules } from '@/lib/material-update'
|
||
|
||
jest.mock('@/lib/prisma', () => ({
|
||
prisma: {
|
||
material: {
|
||
findFirst: jest.fn(),
|
||
findMany: jest.fn(),
|
||
update: jest.fn(),
|
||
},
|
||
user: {
|
||
findUnique: jest.fn(),
|
||
findFirst: jest.fn(),
|
||
},
|
||
},
|
||
}))
|
||
|
||
jest.mock('@/lib/material-log', () => ({
|
||
createMaterialLog: jest.fn(),
|
||
}))
|
||
|
||
jest.mock('@/lib/s3', () => ({
|
||
deleteFromS3: jest.fn(),
|
||
uploadToS3: jest.fn(),
|
||
}))
|
||
|
||
jest.mock('@/lib/image-processor', () => ({
|
||
compressImage: jest.fn(),
|
||
sanitizeFileName: jest.fn(),
|
||
}))
|
||
|
||
jest.mock('@/lib/public-display', () => ({
|
||
buildMaterialPublicDisplayPath: jest.fn((fileName: string) => `materials/${fileName}`),
|
||
uploadPublicDisplayToS3: jest.fn(),
|
||
}))
|
||
|
||
jest.mock('@/lib/department-permission', () => ({
|
||
isSuperAdmin: jest.fn(),
|
||
isSystemAdmin: jest.fn(),
|
||
}))
|
||
|
||
jest.mock('@/lib/external-auto-listing', () => ({
|
||
queueTaskDetailFullSetNotification: jest.fn().mockResolvedValue(undefined),
|
||
}))
|
||
|
||
describe('material-update shared rules', () => {
|
||
const baseMaterial = {
|
||
id: 'material-1',
|
||
userId: 'user-1',
|
||
previewUrl: 'https://old-preview.example/cover.webp',
|
||
productModel: 'OLD-SPU',
|
||
categoryName: '旧店铺',
|
||
sceneName: '旧场景',
|
||
platformName: '旧平台',
|
||
productCategoryName: '旧品类',
|
||
materialType: '3D场景',
|
||
renderer: 'CR',
|
||
authorName: '旧作者',
|
||
taskNo: 'TASK202601141713525624',
|
||
dingtalkApprovalNo: null,
|
||
taskDetailRequesterUserId: null,
|
||
taskDetailRequesterName: null,
|
||
isFullSet: true,
|
||
assets: [],
|
||
deletedAt: null,
|
||
}
|
||
|
||
beforeEach(() => {
|
||
jest.clearAllMocks()
|
||
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
|
||
;(isSystemAdmin as jest.Mock).mockReturnValue(false)
|
||
;(sanitizeFileName as jest.Mock).mockImplementation((name: string) => name)
|
||
;(compressImage as jest.Mock).mockResolvedValue(Buffer.from('compressed'))
|
||
;(uploadPublicDisplayToS3 as jest.Mock).mockResolvedValue('https://new-preview.example/cover.webp')
|
||
;(uploadToS3 as jest.Mock).mockResolvedValue('https://new-preview.example/cover.webp')
|
||
;(deleteFromS3 as jest.Mock).mockResolvedValue(undefined)
|
||
})
|
||
|
||
test('切换到标准模型时应自动回写通用店铺和场景', async () => {
|
||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(baseMaterial)
|
||
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({
|
||
...baseMaterial,
|
||
...data,
|
||
}))
|
||
|
||
await updateMaterialWithSharedRules({
|
||
materialId: 'material-1',
|
||
operatorUserId: 'user-1',
|
||
operatorRole: 'USER',
|
||
input: {
|
||
productModel: 'SPU-001',
|
||
categoryName: '',
|
||
sceneName: '',
|
||
platformName: 'Amazon',
|
||
productCategoryName: '椅子',
|
||
materialType: '标准模型',
|
||
renderer: '',
|
||
authorName: '新作者',
|
||
userId: 'user-1',
|
||
},
|
||
})
|
||
|
||
expect(prisma.material.update).toHaveBeenCalledWith({
|
||
where: { id: 'material-1' },
|
||
data: expect.objectContaining({
|
||
productModel: 'SPU-001',
|
||
categoryName: '通用',
|
||
sceneName: '通用',
|
||
platformName: 'Amazon',
|
||
productCategoryName: '椅子',
|
||
materialType: '标准模型',
|
||
}),
|
||
})
|
||
expect(createMaterialLog).toHaveBeenCalled()
|
||
})
|
||
|
||
test('音乐类型编辑时不应强制要求店铺和场景,并应自动回写通用', async () => {
|
||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
|
||
...baseMaterial,
|
||
materialType: '音乐',
|
||
categoryName: '',
|
||
sceneName: '',
|
||
productCategoryName: '',
|
||
renderer: '',
|
||
})
|
||
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({
|
||
...baseMaterial,
|
||
...data,
|
||
}))
|
||
|
||
const result = await updateMaterialWithSharedRules({
|
||
materialId: 'material-1',
|
||
operatorUserId: 'user-1',
|
||
operatorRole: 'USER',
|
||
input: {
|
||
productModel: 'MUSIC-001',
|
||
categoryName: '',
|
||
sceneName: '',
|
||
platformName: 'Spotify',
|
||
productCategoryName: '',
|
||
materialType: '音乐',
|
||
renderer: '',
|
||
authorName: '音乐作者',
|
||
userId: 'user-1',
|
||
},
|
||
})
|
||
|
||
expect('error' in result).toBe(false)
|
||
expect(prisma.material.update).toHaveBeenCalledWith({
|
||
where: { id: 'material-1' },
|
||
data: expect.objectContaining({
|
||
categoryName: '通用',
|
||
sceneName: '通用',
|
||
materialType: '音乐',
|
||
platformName: 'Spotify',
|
||
}),
|
||
})
|
||
})
|
||
|
||
test('标准模型缺少品类时应返回 400', async () => {
|
||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(baseMaterial)
|
||
|
||
const result = await updateMaterialWithSharedRules({
|
||
materialId: 'material-1',
|
||
operatorUserId: 'user-1',
|
||
operatorRole: 'USER',
|
||
input: {
|
||
productModel: 'SPU-001',
|
||
categoryName: '',
|
||
sceneName: '',
|
||
platformName: '',
|
||
productCategoryName: '',
|
||
materialType: '标准模型',
|
||
renderer: '',
|
||
authorName: '测试作者',
|
||
userId: 'user-1',
|
||
},
|
||
})
|
||
|
||
expect(result).toEqual({
|
||
error: '标准模型类型必须选择品类',
|
||
status: 400,
|
||
})
|
||
expect(prisma.material.update).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('证书-检测报告允许只填写报告名称 / 检测对象', async () => {
|
||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
|
||
...baseMaterial,
|
||
materialType: '证书-检测报告',
|
||
categoryName: '通用',
|
||
sceneName: '通用',
|
||
productCategoryName: '板材',
|
||
productModel: '',
|
||
reportTitle: null,
|
||
renderer: '',
|
||
})
|
||
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({
|
||
...baseMaterial,
|
||
...data,
|
||
}))
|
||
|
||
const result = await updateMaterialWithSharedRules({
|
||
materialId: 'material-1',
|
||
operatorUserId: 'user-1',
|
||
operatorRole: 'USER',
|
||
input: {
|
||
productModel: '',
|
||
reportTitle: 'E0级板材检测报告',
|
||
categoryName: '',
|
||
sceneName: '',
|
||
platformName: '',
|
||
productCategoryName: '板材',
|
||
materialType: '证书-检测报告',
|
||
renderer: '',
|
||
authorName: '测试作者',
|
||
userId: 'user-1',
|
||
},
|
||
})
|
||
|
||
expect('error' in result).toBe(false)
|
||
expect(prisma.material.update).toHaveBeenCalledWith({
|
||
where: { id: 'material-1' },
|
||
data: expect.objectContaining({
|
||
productModel: '',
|
||
reportTitle: 'E0级板材检测报告',
|
||
categoryName: '通用',
|
||
sceneName: '通用',
|
||
materialType: '证书-检测报告',
|
||
}),
|
||
})
|
||
})
|
||
|
||
test('旧编辑调用未提交报告名称时应保留证书原值', async () => {
|
||
const existingCertificate = {
|
||
...baseMaterial,
|
||
materialType: '证书-检测报告',
|
||
categoryName: '通用',
|
||
sceneName: '通用',
|
||
productCategoryName: '板材',
|
||
productModel: '',
|
||
reportTitle: '原检测报告',
|
||
renderer: '',
|
||
}
|
||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(existingCertificate)
|
||
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({
|
||
...existingCertificate,
|
||
...data,
|
||
}))
|
||
|
||
const result = await updateMaterialWithSharedRules({
|
||
materialId: 'material-1',
|
||
operatorUserId: 'user-1',
|
||
operatorRole: 'USER',
|
||
input: {
|
||
productModel: '',
|
||
categoryName: '',
|
||
sceneName: '',
|
||
platformName: '',
|
||
productCategoryName: '板材',
|
||
materialType: '证书-检测报告',
|
||
renderer: '',
|
||
authorName: '测试作者',
|
||
},
|
||
})
|
||
|
||
expect('error' in result).toBe(false)
|
||
expect(prisma.material.update).toHaveBeenCalledWith({
|
||
where: { id: 'material-1' },
|
||
data: expect.objectContaining({
|
||
reportTitle: '原检测报告',
|
||
}),
|
||
})
|
||
})
|
||
|
||
test('证书-检测报告同时缺少 SPU 和报告名称时应返回 400', async () => {
|
||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
|
||
...baseMaterial,
|
||
materialType: '证书-检测报告',
|
||
categoryName: '通用',
|
||
sceneName: '通用',
|
||
productCategoryName: '板材',
|
||
productModel: '',
|
||
reportTitle: null,
|
||
renderer: '',
|
||
})
|
||
|
||
const result = await updateMaterialWithSharedRules({
|
||
materialId: 'material-1',
|
||
operatorUserId: 'user-1',
|
||
operatorRole: 'USER',
|
||
input: {
|
||
productModel: '',
|
||
reportTitle: '',
|
||
categoryName: '',
|
||
sceneName: '',
|
||
platformName: '',
|
||
productCategoryName: '板材',
|
||
materialType: '证书-检测报告',
|
||
renderer: '',
|
||
authorName: '测试作者',
|
||
userId: 'user-1',
|
||
},
|
||
})
|
||
|
||
expect(result).toEqual({
|
||
error: 'SPU 和 报告名称/检测对象 至少填写一个',
|
||
status: 400,
|
||
})
|
||
expect(prisma.material.update).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('非任务详情类型应强制将 isFullSet 保存为 false', async () => {
|
||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(baseMaterial)
|
||
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({
|
||
...baseMaterial,
|
||
...data,
|
||
}))
|
||
|
||
await updateMaterialWithSharedRules({
|
||
materialId: 'material-1',
|
||
operatorUserId: 'user-1',
|
||
operatorRole: 'USER',
|
||
input: {
|
||
productModel: 'MUSIC-001',
|
||
categoryName: '',
|
||
sceneName: '',
|
||
platformName: '',
|
||
productCategoryName: '',
|
||
materialType: '音乐',
|
||
renderer: '',
|
||
authorName: '音乐作者',
|
||
userId: 'user-1',
|
||
isFullSet: true,
|
||
},
|
||
})
|
||
|
||
expect(prisma.material.update).toHaveBeenCalledWith({
|
||
where: { id: 'material-1' },
|
||
data: expect.objectContaining({
|
||
categoryName: '通用',
|
||
sceneName: '通用',
|
||
isFullSet: false,
|
||
}),
|
||
})
|
||
})
|
||
|
||
test('任务-页面编辑时应保留任务号能力并要求店铺和场景', async () => {
|
||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
|
||
...baseMaterial,
|
||
materialType: '任务-页面',
|
||
categoryName: '旧店铺',
|
||
sceneName: '旧场景',
|
||
taskNo: 'TASK202601141713525624-01',
|
||
})
|
||
;(prisma.material.findFirst as jest.Mock)
|
||
.mockResolvedValueOnce({
|
||
...baseMaterial,
|
||
materialType: '任务-页面',
|
||
categoryName: '旧店铺',
|
||
sceneName: '旧场景',
|
||
taskNo: 'TASK202601141713525624-01',
|
||
})
|
||
.mockResolvedValueOnce(null)
|
||
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({
|
||
...baseMaterial,
|
||
...data,
|
||
}))
|
||
|
||
const result = await updateMaterialWithSharedRules({
|
||
materialId: 'material-1',
|
||
operatorUserId: 'user-1',
|
||
operatorRole: 'USER',
|
||
input: {
|
||
productModel: 'TASK-SPU-001',
|
||
categoryName: '新店铺',
|
||
sceneName: '新场景',
|
||
platformName: '',
|
||
productCategoryName: '',
|
||
materialType: '任务-页面',
|
||
renderer: '',
|
||
authorName: '任务作者',
|
||
userId: 'user-1',
|
||
taskNo: 'task202601141713525624-02',
|
||
},
|
||
})
|
||
|
||
expect('error' in result).toBe(false)
|
||
expect(prisma.material.update).toHaveBeenCalledWith({
|
||
where: { id: 'material-1' },
|
||
data: expect.objectContaining({
|
||
categoryName: '新店铺',
|
||
sceneName: '新场景',
|
||
taskNo: 'TASK202601141713525624-02',
|
||
materialType: '任务-页面',
|
||
}),
|
||
})
|
||
})
|
||
|
||
test('任务类素材任务号重复时应返回 409', async () => {
|
||
;(prisma.material.findFirst as jest.Mock)
|
||
.mockResolvedValueOnce({
|
||
...baseMaterial,
|
||
materialType: '任务-页面',
|
||
categoryName: '店铺A',
|
||
sceneName: '场景A',
|
||
taskNo: null,
|
||
})
|
||
.mockResolvedValueOnce({ id: 'material-2' })
|
||
|
||
const result = await updateMaterialWithSharedRules({
|
||
materialId: 'material-1',
|
||
operatorUserId: 'user-1',
|
||
operatorRole: 'USER',
|
||
input: {
|
||
productModel: 'TASK-SPU-001',
|
||
categoryName: '店铺A',
|
||
sceneName: '场景A',
|
||
platformName: '',
|
||
productCategoryName: '',
|
||
materialType: '任务-页面',
|
||
renderer: '',
|
||
authorName: '任务作者',
|
||
userId: 'user-1',
|
||
taskNo: 'TASK202601141713525624-01',
|
||
},
|
||
})
|
||
|
||
expect(result).toEqual({
|
||
error: '任务号已存在',
|
||
status: 409,
|
||
})
|
||
expect(prisma.material.update).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('任务-详情编辑时应支持保存,但不允许清空出图方式', async () => {
|
||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
|
||
...baseMaterial,
|
||
materialType: '任务-详情',
|
||
taskDetailSourceType: '实拍',
|
||
categoryName: '店铺A',
|
||
sceneName: '场景A',
|
||
})
|
||
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({
|
||
...baseMaterial,
|
||
...data,
|
||
}))
|
||
|
||
await updateMaterialWithSharedRules({
|
||
materialId: 'material-1',
|
||
operatorUserId: 'user-1',
|
||
operatorRole: 'USER',
|
||
input: {
|
||
productModel: 'TASK-SPU-001',
|
||
categoryName: '店铺A',
|
||
sceneName: '场景A',
|
||
platformName: '',
|
||
productCategoryName: '',
|
||
materialType: '任务-详情',
|
||
renderer: '',
|
||
authorName: '任务作者',
|
||
userId: 'user-1',
|
||
taskDetailSourceType: 'AI',
|
||
},
|
||
})
|
||
|
||
expect(prisma.material.update).toHaveBeenCalledWith({
|
||
where: { id: 'material-1' },
|
||
data: expect.objectContaining({
|
||
taskDetailSourceType: 'AI',
|
||
}),
|
||
})
|
||
|
||
await expect(updateMaterialWithSharedRules({
|
||
materialId: 'material-1',
|
||
operatorUserId: 'user-1',
|
||
operatorRole: 'USER',
|
||
input: {
|
||
productModel: 'TASK-SPU-001',
|
||
categoryName: '店铺A',
|
||
sceneName: '场景A',
|
||
platformName: '',
|
||
productCategoryName: '',
|
||
materialType: '任务-详情',
|
||
renderer: '',
|
||
authorName: '任务作者',
|
||
userId: 'user-1',
|
||
taskDetailSourceType: '',
|
||
},
|
||
})).resolves.toEqual({
|
||
error: '任务-详情必须选择出图方式(实拍、渲染或 AI)',
|
||
status: 400,
|
||
})
|
||
|
||
expect(prisma.material.update).toHaveBeenCalledTimes(1)
|
||
})
|
||
|
||
test('旧编辑调用未提交出图方式时应保留任务详情原值', async () => {
|
||
const existingTaskDetail = {
|
||
...baseMaterial,
|
||
materialType: '任务-详情',
|
||
taskDetailSourceType: '实拍',
|
||
categoryName: '店铺A',
|
||
sceneName: '场景A',
|
||
}
|
||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(existingTaskDetail)
|
||
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({
|
||
...existingTaskDetail,
|
||
...data,
|
||
}))
|
||
|
||
await updateMaterialWithSharedRules({
|
||
materialId: 'material-1',
|
||
operatorUserId: 'user-1',
|
||
operatorRole: 'USER',
|
||
input: {
|
||
productModel: 'TASK-SPU-001',
|
||
categoryName: '店铺A',
|
||
sceneName: '场景A',
|
||
platformName: '',
|
||
productCategoryName: '',
|
||
materialType: '任务-详情',
|
||
renderer: '',
|
||
authorName: '任务作者',
|
||
},
|
||
})
|
||
|
||
expect(prisma.material.update).toHaveBeenCalledWith({
|
||
where: { id: 'material-1' },
|
||
data: expect.objectContaining({
|
||
taskDetailSourceType: '实拍',
|
||
}),
|
||
})
|
||
})
|
||
|
||
test('历史任务详情没有需求运营时,编辑其他字段仍可继续留空', async () => {
|
||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
|
||
...baseMaterial,
|
||
materialType: '任务-详情',
|
||
categoryName: '店铺A',
|
||
sceneName: '场景A',
|
||
taskDetailRequesterUserId: null,
|
||
taskDetailRequesterName: null,
|
||
})
|
||
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({
|
||
...baseMaterial,
|
||
...data,
|
||
}))
|
||
|
||
const result = await updateMaterialWithSharedRules({
|
||
materialId: 'material-1',
|
||
operatorUserId: 'user-1',
|
||
operatorRole: 'USER',
|
||
input: {
|
||
productModel: 'TASK-SPU-001',
|
||
categoryName: '店铺A',
|
||
sceneName: '场景A',
|
||
materialType: '任务-详情',
|
||
renderer: '',
|
||
authorName: '任务作者',
|
||
userId: 'user-1',
|
||
taskDetailRequesterUserId: '',
|
||
},
|
||
})
|
||
|
||
expect('error' in result).toBe(false)
|
||
expect(prisma.material.update).toHaveBeenCalledWith({
|
||
where: { id: 'material-1' },
|
||
data: expect.objectContaining({
|
||
taskDetailRequesterUserId: null,
|
||
taskDetailRequesterName: null,
|
||
}),
|
||
})
|
||
})
|
||
|
||
test('其他素材切换为任务详情时必须选择需求运营', async () => {
|
||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(baseMaterial)
|
||
|
||
const result = await updateMaterialWithSharedRules({
|
||
materialId: 'material-1',
|
||
operatorUserId: 'user-1',
|
||
operatorRole: 'USER',
|
||
input: {
|
||
productModel: 'TASK-SPU-001',
|
||
categoryName: '店铺A',
|
||
sceneName: '场景A',
|
||
materialType: '任务-详情',
|
||
renderer: '',
|
||
authorName: '任务作者',
|
||
userId: 'user-1',
|
||
taskDetailRequesterUserId: '',
|
||
},
|
||
})
|
||
|
||
expect(result).toEqual({
|
||
error: '请选择需求运营人员',
|
||
status: 400,
|
||
})
|
||
expect(prisma.material.update).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('已有需求运营的任务详情不能清空该人员', async () => {
|
||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
|
||
...baseMaterial,
|
||
materialType: '任务-详情',
|
||
categoryName: '店铺A',
|
||
sceneName: '场景A',
|
||
taskDetailRequesterUserId: 'requester-old',
|
||
taskDetailRequesterName: '运营旧',
|
||
})
|
||
|
||
const result = await updateMaterialWithSharedRules({
|
||
materialId: 'material-1',
|
||
operatorUserId: 'user-1',
|
||
operatorRole: 'USER',
|
||
input: {
|
||
productModel: 'TASK-SPU-001',
|
||
categoryName: '店铺A',
|
||
sceneName: '场景A',
|
||
materialType: '任务-详情',
|
||
renderer: '',
|
||
authorName: '任务作者',
|
||
userId: 'user-1',
|
||
taskDetailRequesterUserId: '',
|
||
},
|
||
})
|
||
|
||
expect(result).toEqual({
|
||
error: '需求运营人员不能清空,只能更换',
|
||
status: 400,
|
||
})
|
||
expect(prisma.material.update).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('更换需求运营时应校验有效人员并由服务端保存姓名快照', async () => {
|
||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
|
||
...baseMaterial,
|
||
materialType: '任务-详情',
|
||
categoryName: '店铺A',
|
||
sceneName: '场景A',
|
||
taskDetailRequesterUserId: 'requester-old',
|
||
taskDetailRequesterName: '运营旧',
|
||
})
|
||
;(prisma.user.findFirst as jest.Mock).mockResolvedValue({
|
||
id: 'requester-new',
|
||
username: 'new-operator',
|
||
realName: '运营新',
|
||
})
|
||
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({
|
||
...baseMaterial,
|
||
...data,
|
||
}))
|
||
|
||
const result = await updateMaterialWithSharedRules({
|
||
materialId: 'material-1',
|
||
operatorUserId: 'user-1',
|
||
operatorRole: 'USER',
|
||
input: {
|
||
productModel: 'TASK-SPU-001',
|
||
categoryName: '店铺A',
|
||
sceneName: '场景A',
|
||
materialType: '任务-详情',
|
||
renderer: '',
|
||
authorName: '任务作者',
|
||
userId: 'user-1',
|
||
taskDetailRequesterUserId: 'requester-new',
|
||
},
|
||
})
|
||
|
||
expect('error' in result).toBe(false)
|
||
expect(prisma.user.findFirst).toHaveBeenCalledWith({
|
||
where: {
|
||
id: 'requester-new',
|
||
isActive: true,
|
||
status: 'active',
|
||
},
|
||
select: {
|
||
id: true,
|
||
username: true,
|
||
realName: true,
|
||
},
|
||
})
|
||
expect(prisma.material.update).toHaveBeenCalledWith({
|
||
where: { id: 'material-1' },
|
||
data: expect.objectContaining({
|
||
taskDetailRequesterUserId: 'requester-new',
|
||
taskDetailRequesterName: '运营新',
|
||
}),
|
||
})
|
||
})
|
||
|
||
test('任务详情切换为其他素材类型时应清空需求运营字段', async () => {
|
||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
|
||
...baseMaterial,
|
||
materialType: '任务-详情',
|
||
taskDetailRequesterUserId: 'requester-old',
|
||
taskDetailRequesterName: '运营旧',
|
||
})
|
||
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({
|
||
...baseMaterial,
|
||
...data,
|
||
}))
|
||
|
||
await updateMaterialWithSharedRules({
|
||
materialId: 'material-1',
|
||
operatorUserId: 'user-1',
|
||
operatorRole: 'USER',
|
||
input: {
|
||
productModel: 'MUSIC-001',
|
||
categoryName: '',
|
||
sceneName: '',
|
||
materialType: '音乐',
|
||
renderer: '',
|
||
authorName: '任务作者',
|
||
userId: 'user-1',
|
||
taskDetailRequesterUserId: 'requester-old',
|
||
},
|
||
})
|
||
|
||
expect(prisma.material.update).toHaveBeenCalledWith({
|
||
where: { id: 'material-1' },
|
||
data: expect.objectContaining({
|
||
taskDetailRequesterUserId: null,
|
||
taskDetailRequesterName: null,
|
||
}),
|
||
})
|
||
})
|
||
|
||
test('任务-其他编辑时不应强制要求店铺和场景,并应自动回写通用', async () => {
|
||
;(prisma.material.findFirst as jest.Mock)
|
||
.mockResolvedValueOnce({
|
||
...baseMaterial,
|
||
materialType: '任务-其他',
|
||
categoryName: '通用',
|
||
sceneName: '通用',
|
||
taskNo: 'TASK202603310001',
|
||
productModel: 'TASK202603310001',
|
||
})
|
||
.mockResolvedValueOnce(null)
|
||
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({
|
||
...baseMaterial,
|
||
...data,
|
||
}))
|
||
|
||
const result = await updateMaterialWithSharedRules({
|
||
materialId: 'material-1',
|
||
operatorUserId: 'user-1',
|
||
operatorRole: 'USER',
|
||
input: {
|
||
productModel: 'TASK202603310001',
|
||
categoryName: '',
|
||
sceneName: '',
|
||
platformName: '',
|
||
productCategoryName: '',
|
||
materialType: '任务-其他',
|
||
renderer: '',
|
||
authorName: '任务作者',
|
||
userId: 'user-1',
|
||
taskNo: 'task202603310001',
|
||
dingtalkApprovalNo: '202508211445000157693',
|
||
},
|
||
})
|
||
|
||
expect('error' in result).toBe(false)
|
||
expect(prisma.material.update).toHaveBeenCalledWith({
|
||
where: { id: 'material-1' },
|
||
data: expect.objectContaining({
|
||
productModel: 'TASK202603310001',
|
||
categoryName: '通用',
|
||
sceneName: '通用',
|
||
taskNo: 'TASK202603310001',
|
||
dingtalkApprovalNo: '202508211445000157693',
|
||
materialType: '任务-其他',
|
||
}),
|
||
})
|
||
})
|
||
|
||
test('任务素材编辑时应拒绝无效的钉钉审批编号', async () => {
|
||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
|
||
...baseMaterial,
|
||
materialType: '任务-页面',
|
||
})
|
||
|
||
const result = await updateMaterialWithSharedRules({
|
||
materialId: 'material-1',
|
||
operatorUserId: 'user-1',
|
||
operatorRole: 'USER',
|
||
input: {
|
||
productModel: 'SPU-001',
|
||
categoryName: '店铺A',
|
||
sceneName: '场景A',
|
||
materialType: '任务-页面',
|
||
renderer: '',
|
||
authorName: '任务作者',
|
||
dingtalkApprovalNo: '20250230123',
|
||
},
|
||
})
|
||
|
||
expect(result).toEqual({
|
||
error: '钉钉审批编号前8位不是有效日期',
|
||
status: 400,
|
||
})
|
||
expect(prisma.material.update).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('非任务素材不能写入钉钉审批编号', async () => {
|
||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(baseMaterial)
|
||
|
||
const result = await updateMaterialWithSharedRules({
|
||
materialId: 'material-1',
|
||
operatorUserId: 'user-1',
|
||
operatorRole: 'USER',
|
||
input: {
|
||
productModel: 'SPU-001',
|
||
categoryName: '店铺A',
|
||
sceneName: '场景A',
|
||
materialType: '3D场景',
|
||
renderer: 'CR',
|
||
authorName: '作者',
|
||
dingtalkApprovalNo: '202508211445000157693',
|
||
},
|
||
})
|
||
|
||
expect(result).toEqual({
|
||
error: '钉钉审批编号仅适用于“任务-”素材',
|
||
status: 400,
|
||
})
|
||
expect(prisma.material.update).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('任务-详情编辑时应按已有资产自动回写 isFullSet', async () => {
|
||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
|
||
...baseMaterial,
|
||
materialType: '任务-详情',
|
||
isFullSet: false,
|
||
categoryName: '店铺A',
|
||
sceneName: '场景A',
|
||
assets: [
|
||
{ assetType: 'main-preview' },
|
||
{ assetType: 'sku-preview' },
|
||
{ assetType: 'detail-preview' },
|
||
],
|
||
})
|
||
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({
|
||
...baseMaterial,
|
||
...data,
|
||
}))
|
||
|
||
await updateMaterialWithSharedRules({
|
||
materialId: 'material-1',
|
||
operatorUserId: 'user-1',
|
||
operatorRole: 'USER',
|
||
input: {
|
||
productModel: 'SPU-001',
|
||
categoryName: '店铺A',
|
||
sceneName: '场景A',
|
||
platformName: '',
|
||
productCategoryName: '',
|
||
materialType: '任务-详情',
|
||
renderer: '',
|
||
authorName: '任务作者',
|
||
userId: 'user-1',
|
||
} as any,
|
||
})
|
||
|
||
expect(prisma.material.update).toHaveBeenCalledWith({
|
||
where: { id: 'material-1' },
|
||
data: expect.objectContaining({
|
||
isFullSet: true,
|
||
taskNo: 'TASK202601141713525624',
|
||
}),
|
||
})
|
||
expect(queueTaskDetailFullSetNotification).toHaveBeenCalledWith('material-1')
|
||
})
|
||
|
||
test('已是全套的任务-详情再次编辑时不应重复触发自动上架通知', async () => {
|
||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
|
||
...baseMaterial,
|
||
materialType: '任务-详情',
|
||
isFullSet: true,
|
||
categoryName: '店铺A',
|
||
sceneName: '场景A',
|
||
assets: [
|
||
{ assetType: 'main-preview' },
|
||
{ assetType: 'sku-preview' },
|
||
{ assetType: 'detail-preview' },
|
||
],
|
||
})
|
||
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({
|
||
...baseMaterial,
|
||
...data,
|
||
isFullSet: true,
|
||
}))
|
||
|
||
await updateMaterialWithSharedRules({
|
||
materialId: 'material-1',
|
||
operatorUserId: 'user-1',
|
||
operatorRole: 'USER',
|
||
input: {
|
||
productModel: 'SPU-001',
|
||
categoryName: '店铺A',
|
||
sceneName: '场景A',
|
||
platformName: '',
|
||
productCategoryName: '',
|
||
materialType: '任务-详情',
|
||
renderer: '',
|
||
authorName: '任务作者',
|
||
userId: 'user-1',
|
||
} as any,
|
||
})
|
||
|
||
expect(queueTaskDetailFullSetNotification).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('任务类素材不允许手工替换预览图', async () => {
|
||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
|
||
...baseMaterial,
|
||
materialType: '任务-详情',
|
||
categoryName: '店铺A',
|
||
sceneName: '场景A',
|
||
isFullSet: true,
|
||
assets: [
|
||
{ assetType: 'main-preview' },
|
||
{ assetType: 'sku-preview' },
|
||
{ assetType: 'detail-preview' },
|
||
],
|
||
})
|
||
|
||
const result = await updateMaterialWithSharedRules({
|
||
materialId: 'material-1',
|
||
operatorUserId: 'user-1',
|
||
operatorRole: 'USER',
|
||
input: {
|
||
productModel: 'SPU-001',
|
||
categoryName: '店铺A',
|
||
sceneName: '场景A',
|
||
platformName: '',
|
||
productCategoryName: '',
|
||
materialType: '任务-详情',
|
||
renderer: '',
|
||
authorName: '任务作者',
|
||
userId: 'user-1',
|
||
isFullSet: false,
|
||
},
|
||
previewImage: new File(['preview'], 'preview.png', { type: 'image/png' }),
|
||
})
|
||
|
||
expect(result).toEqual({
|
||
error: '当前素材类型不支持手工替换预览图',
|
||
status: 400,
|
||
})
|
||
expect(prisma.material.update).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('可手工替换的预览图应写入 public-display materials 前缀', async () => {
|
||
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(baseMaterial)
|
||
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({
|
||
...baseMaterial,
|
||
...data,
|
||
}))
|
||
|
||
const previewImage = {
|
||
name: 'cover.png',
|
||
size: 7,
|
||
arrayBuffer: jest.fn().mockResolvedValue(Buffer.from('preview')),
|
||
} as any
|
||
const result = await updateMaterialWithSharedRules({
|
||
materialId: 'material-1',
|
||
operatorUserId: 'user-1',
|
||
operatorRole: 'USER',
|
||
input: {
|
||
productModel: 'SPU-001',
|
||
categoryName: '新店铺',
|
||
sceneName: '新场景',
|
||
platformName: 'Amazon',
|
||
productCategoryName: '',
|
||
materialType: '3D场景',
|
||
renderer: 'CR',
|
||
authorName: '新作者',
|
||
userId: 'user-1',
|
||
},
|
||
previewImage,
|
||
})
|
||
|
||
expect('error' in result).toBe(false)
|
||
expect(uploadPublicDisplayToS3).toHaveBeenCalledWith(
|
||
Buffer.from('compressed'),
|
||
expect.stringMatching(/^materials\/preview-\d+-cover\.png$/),
|
||
'image/webp',
|
||
)
|
||
expect(uploadToS3).not.toHaveBeenCalled()
|
||
expect(deleteFromS3).toHaveBeenCalledWith('https://old-preview.example/cover.webp')
|
||
})
|
||
})
|