gongxiangp2/lib/__tests__/material-update.test.ts

1259 lines
38 KiB
TypeScript

import { prisma } from '@/lib/prisma'
import { createMaterialLog } from '@/lib/material-log'
import { compressImage, sanitizeFileName } from '@/lib/image-processor'
import { uploadToS3 } from '@/lib/s3'
import { deleteFileIfUnreferenced } from '@/lib/file-cleanup'
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(),
findUnique: jest.fn(),
update: jest.fn(),
},
user: {
findUnique: jest.fn(),
findFirst: jest.fn(),
},
},
}))
jest.mock('@/lib/material-log', () => ({
createMaterialLog: jest.fn(),
}))
jest.mock('@/lib/s3', () => ({
uploadToS3: jest.fn(),
}))
jest.mock('@/lib/file-cleanup', () => ({
deleteFileIfUnreferenced: 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),
}))
jest.mock('@/lib/material-authorization', () => ({
canManageMaterial: jest.fn().mockResolvedValue(true),
}))
describe('material-update shared rules', () => {
const reportMetadataInput = {
reportKind: '成品',
certificateTestedOn: '2027-09-23',
certificateNumber: 'CMA-2026-001',
certificateExpiresOn: '2028-09-23',
reportUsageRules: '完整的报告使用规则说明',
}
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')
;(deleteFileIfUnreferenced as jest.Mock).mockResolvedValue({ success: true })
})
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级板材检测报告',
...reportMetadataInput,
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级板材检测报告',
reportKind: '成品',
certificateTestedOn: new Date('2027-09-23T00:00:00.000Z'),
certificateNumber: 'CMA-2026-001',
certificateExpiresOn: new Date('2028-09-23T00:00:00.000Z'),
reportUsageRules: '完整的报告使用规则说明',
categoryName: '通用',
sceneName: '通用',
materialType: '证书-检测报告',
}),
})
})
test('清空到期时间后保存,服务端根据检测日期计算一年', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
...baseMaterial,
materialType: '证书-检测报告',
certificateExpiresOn: new Date('2030-01-01T00:00:00Z'),
})
;(prisma.material.update as jest.Mock).mockImplementation(async ({ data }) => ({ ...baseMaterial, ...data }))
const result = await updateMaterialWithSharedRules({
materialId: 'material-1', operatorUserId: 'user-1', operatorRole: 'USER',
input: {
...reportMetadataInput,
certificateTestedOn: '2024-02-29',
certificateExpiresOn: '',
productModel: 'SPU-001', productCategoryName: '板材',
materialType: '证书-检测报告', authorName: '测试作者',
},
})
expect('error' in result).toBe(false)
expect(prisma.material.update).toHaveBeenCalledWith({ where: { id: 'material-1' }, data: expect.objectContaining({
certificateTestedOn: new Date('2024-02-29T00:00:00Z'),
certificateExpiresOn: new Date('2025-02-28T00:00:00Z'),
}) })
})
test('旧编辑调用未提交报告名称时应保留证书原值', async () => {
const existingCertificate = {
...baseMaterial,
materialType: '证书-检测报告',
categoryName: '通用',
sceneName: '通用',
productCategoryName: '板材',
productModel: '',
reportTitle: '原检测报告',
reportKind: '成品',
certificateTestedOn: new Date('2027-09-23T00:00:00.000Z'),
certificateNumber: 'CMA-2026-001',
certificateExpiresOn: new Date('2028-09-23T00:00:00.000Z'),
reportUsageRules: '完整的报告使用规则说明',
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', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
...baseMaterial,
materialType: '证书-检测报告',
reportKind: '成品',
reportTitle: '原报告',
})
;(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: 'OLD-SPU',
reportTitle: '板材检测报告',
...reportMetadataInput,
reportKind: '原材料',
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({ productModel: '', reportKind: '原材料' }),
})
})
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: '',
...reportMetadataInput,
categoryName: '',
sceneName: '',
platformName: '',
productCategoryName: '板材',
materialType: '证书-检测报告',
renderer: '',
authorName: '测试作者',
userId: 'user-1',
},
})
expect(result).toEqual({
error: 'SPU 和 报告名称/检测对象 至少填写一个',
status: 400,
})
expect(prisma.material.update).not.toHaveBeenCalled()
})
test('历史到期日不得被当作检测日期,缺少检测日期时拒绝保存', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
...baseMaterial,
materialType: '证书-检测报告',
reportTitle: '历史报告',
reportKind: '成品',
certificateExpiresOn: new Date('2027-09-23T00:00:00.000Z'),
certificateTestedOn: null,
certificateNumber: 'CMA-2026-001',
certificateUsageInstructions: '使用前核对送检主体',
reportUsageRules: '完整的报告使用规则说明',
})
const result = await updateMaterialWithSharedRules({
materialId: 'material-1',
operatorUserId: 'user-1',
operatorRole: 'USER',
input: {
productModel: 'OLD-SPU',
reportTitle: '历史报告',
productCategoryName: '板材',
materialType: '证书-检测报告',
authorName: '测试作者',
},
})
expect(result).toEqual({ error: '请选择有效的证书检测日期', 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 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: '',
},
})
expect(prisma.material.update).toHaveBeenLastCalledWith({
where: { id: 'material-1' },
data: expect.objectContaining({
taskDetailSourceType: null,
}),
})
})
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('3D场景可以写入钉钉审批编号', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(baseMaterial)
;(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: 'SPU-001',
categoryName: '店铺A',
sceneName: '场景A',
materialType: '3D场景',
renderer: 'CR',
authorName: '作者',
dingtalkApprovalNo: '202508211445000157693',
},
})
expect('error' in result).toBe(false)
expect(prisma.material.update).toHaveBeenCalledWith({
where: { id: 'material-1' },
data: expect.objectContaining({
materialType: '3D场景',
dingtalkApprovalNo: '202508211445000157693',
}),
})
})
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+-[0-9a-f-]{36}-cover\.png$/),
'image/webp',
)
expect(uploadToS3).not.toHaveBeenCalled()
expect(deleteFileIfUnreferenced).toHaveBeenCalledWith(
'https://old-preview.example/cover.webp',
{ excludeMaterialIds: ['material-1'] },
)
})
test('不同素材同毫秒上传同名预览也必须使用不同对象键', async () => {
jest.spyOn(Date, 'now').mockReturnValue(1_700_000_000_000)
;(prisma.material.findFirst as jest.Mock)
.mockResolvedValueOnce(baseMaterial)
.mockResolvedValueOnce({ ...baseMaterial, id: 'material-2' })
;(prisma.material.update as jest.Mock).mockImplementation(async ({ where, data }) => ({
...baseMaterial,
id: where.id,
...data,
}))
;(uploadPublicDisplayToS3 as jest.Mock)
.mockResolvedValueOnce('https://new-preview.example/one.webp')
.mockResolvedValueOnce('https://new-preview.example/two.webp')
const input = {
productModel: 'SPU-001',
categoryName: '新店铺',
sceneName: '新场景',
platformName: 'Amazon',
productCategoryName: '',
materialType: '3D场景',
renderer: 'CR',
authorName: '新作者',
userId: 'user-1',
}
const makePreview = () => ({
name: 'cover.png',
size: 7,
arrayBuffer: jest.fn().mockResolvedValue(Buffer.from('preview')),
} as any)
await updateMaterialWithSharedRules({
materialId: 'material-1', operatorUserId: 'user-1', input, previewImage: makePreview(),
})
await updateMaterialWithSharedRules({
materialId: 'material-2', operatorUserId: 'user-1', input, previewImage: makePreview(),
})
const firstKey = (uploadPublicDisplayToS3 as jest.Mock).mock.calls[0][1]
const secondKey = (uploadPublicDisplayToS3 as jest.Mock).mock.calls[1][1]
expect(firstKey).not.toBe(secondKey)
expect(firstKey).toMatch(/^materials\/preview-1700000000000-[0-9a-f-]{36}-cover\.png$/)
expect(secondKey).toMatch(/^materials\/preview-1700000000000-[0-9a-f-]{36}-cover\.png$/)
})
test('数据库明确未提交新预览时只清本次新对象并保留旧图', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(baseMaterial)
;(prisma.material.update as jest.Mock).mockRejectedValue(new Error('database unavailable'))
;(prisma.material.findUnique as jest.Mock).mockResolvedValue(baseMaterial)
const previewImage = {
name: 'cover.png',
size: 7,
arrayBuffer: jest.fn().mockResolvedValue(Buffer.from('preview')),
} as any
await expect(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,
})).rejects.toThrow('database unavailable')
expect(deleteFileIfUnreferenced).toHaveBeenCalledWith(
'https://new-preview.example/cover.webp',
{ excludeMaterialIds: ['material-1'] },
)
expect(deleteFileIfUnreferenced).not.toHaveBeenCalledWith(
'https://old-preview.example/cover.webp',
expect.anything(),
)
})
test('数据库提交确认丢失但回读已是新预览时不得删除新对象', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue(baseMaterial)
;(prisma.material.update as jest.Mock).mockRejectedValue(new Error('commit acknowledgement lost'))
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
...baseMaterial,
previewUrl: 'https://new-preview.example/cover.webp',
productModel: 'SPU-001',
})
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: {
name: 'cover.png',
size: 7,
arrayBuffer: jest.fn().mockResolvedValue(Buffer.from('preview')),
} as any,
})
expect('error' in result).toBe(false)
expect(deleteFileIfUnreferenced).not.toHaveBeenCalledWith(
'https://new-preview.example/cover.webp',
expect.anything(),
)
expect(deleteFileIfUnreferenced).toHaveBeenCalledWith(
'https://old-preview.example/cover.webp',
{ excludeMaterialIds: ['material-1'] },
)
})
})