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), })) 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') ;(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级板材检测报告', 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 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'] }, ) }) })