/** @jest-environment node */ import { EXTERNAL_MATERIAL_IMPORT_STATUS, buildProductPackageImportAssets, createExternalMaterialImportJob, getExternalMaterialImportJobStatus, getExternalMaterialImportSchema, processExternalMaterialImportJob, } from '@/lib/external-material-import' import { prisma } from '@/lib/prisma' import { completeProductPackageUpload } from '@/lib/product-package-completion' jest.mock('@/lib/prisma', () => ({ prisma: { $executeRawUnsafe: jest.fn(), $transaction: jest.fn(), category: { findMany: jest.fn(), }, scene: { findMany: jest.fn(), }, platform: { findMany: jest.fn(), }, productCategory: { findMany: jest.fn(), }, user: { findUnique: jest.fn(), }, material: { create: jest.fn(), }, externalMaterialImportJob: { findFirst: jest.fn(), findUnique: jest.fn(), update: jest.fn(), }, }, })) jest.mock('@/lib/product-package-completion', () => ({ completeProductPackageUpload: jest.fn(), })) describe('external material import', () => { const tx = { $executeRaw: jest.fn(), $queryRaw: jest.fn(), user: { findFirst: jest.fn(), findUnique: jest.fn(), }, material: { findFirst: jest.fn(), create: jest.fn(), }, externalMaterialImportJob: { findUnique: jest.fn(), findFirst: jest.fn(), create: jest.fn(), update: jest.fn(), }, } const validBody = { clientRequestId: 'req-001', materialType: '任务-详情', requester: { dingtalkUserId: 'ding-user-1', }, formData: { taskNo: 'TASK202603240001', productModel: 'SPU-001', categoryName: '旗舰店', sceneName: '客厅', platformName: '天猫', productCategoryName: '沙发', }, sections: [ { key: 'detail', images: [ { url: 'https://img.example/detail-2.jpg', fileName: 'detail-2.jpg', fileSize: 22, sortOrder: 20, }, { url: 'https://img.example/detail-1.jpg', fileName: 'detail-1.jpg', fileSize: 11, sortOrder: 10, }, ], }, { key: 'main', images: [ { url: 'https://img.example/main-1.jpg', fileName: 'main-1.jpg', fileSize: 33, sortOrder: 1, }, ], }, ], } beforeEach(() => { jest.clearAllMocks() ;(prisma.$transaction as jest.Mock).mockImplementation(async (callback: any) => callback(tx)) ;(prisma.$executeRawUnsafe as jest.Mock).mockResolvedValue(undefined) tx.$executeRaw.mockResolvedValue(1) tx.$queryRaw.mockResolvedValue(undefined) tx.user.findFirst.mockResolvedValue({ id: 'user-1', username: 'zhangsan', realName: '张三', dingtalkUserId: 'ding-user-1', }) tx.user.findUnique.mockResolvedValue({ id: 'user-1', username: 'zhangsan', realName: '张三', }) tx.material.findFirst.mockResolvedValue(null) tx.material.create.mockResolvedValue({ id: 'material-1', userId: 'user-1', }) tx.externalMaterialImportJob.findUnique.mockResolvedValue(null) tx.externalMaterialImportJob.findFirst.mockResolvedValue(null) tx.externalMaterialImportJob.create.mockResolvedValue({ id: 'job-1', apiKeyId: 'api-1', clientRequestId: 'req-001', materialType: '任务-详情', taskNo: 'TASK202603240001', status: EXTERNAL_MATERIAL_IMPORT_STATUS.PROCESSING, resultMaterialId: null, errorCode: null, errorMessage: null, createdAt: new Date('2026-03-24T09:00:00.000Z'), updatedAt: new Date('2026-03-24T09:00:00.000Z'), completedAt: null, }) tx.externalMaterialImportJob.update.mockResolvedValue(undefined) ;(prisma.category.findMany as jest.Mock).mockResolvedValue([ { id: 'category-1', name: '旗舰店' }, ]) ;(prisma.scene.findMany as jest.Mock).mockResolvedValue([ { id: 'scene-1', name: '客厅' }, ]) ;(prisma.platform.findMany as jest.Mock).mockResolvedValue([ { id: 'platform-1', name: '天猫' }, ]) ;(prisma.productCategory.findMany as jest.Mock).mockResolvedValue([ { id: 'pc-1', name: '沙发' }, ]) ;(prisma.externalMaterialImportJob.findFirst as jest.Mock).mockResolvedValue({ id: 'job-1', apiKeyId: 'api-1', clientRequestId: 'req-001', materialType: '任务-详情', taskNo: 'TASK202603240001', status: EXTERNAL_MATERIAL_IMPORT_STATUS.SUCCEEDED, resultMaterialId: 'material-1', errorCode: null, errorMessage: null, createdAt: new Date('2026-03-24T09:00:00.000Z'), updatedAt: new Date('2026-03-24T09:02:00.000Z'), completedAt: new Date('2026-03-24T09:02:00.000Z'), }) ;(prisma.externalMaterialImportJob.findUnique as jest.Mock).mockResolvedValue({ id: 'job-1', apiKeyId: 'api-1', clientRequestId: 'req-001', materialType: '任务-详情', taskNo: 'TASK202603240001', status: EXTERNAL_MATERIAL_IMPORT_STATUS.PROCESSING, resolvedUserId: 'user-1', requestPayload: JSON.stringify(validBody), resultMaterialId: null, errorCode: null, errorMessage: null, completedAt: null, }) ;(prisma.externalMaterialImportJob.update as jest.Mock).mockResolvedValue(undefined) ;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ id: 'user-1', username: 'zhangsan', realName: '张三', }) ;(prisma.material.create as jest.Mock).mockResolvedValue({ id: 'material-1', userId: 'user-1', }) ;(completeProductPackageUpload as jest.Mock).mockResolvedValue(undefined) }) test('schema 应按当前项目真实规则返回任务-详情字段和下拉项', async () => { const schema = await getExternalMaterialImportSchema('任务-详情') expect(schema).toEqual({ materialType: '任务-详情', open: true, fields: [ { name: 'taskNo', label: '任务号', control: 'text', required: true, }, { name: 'productModel', label: 'SPU', control: 'text', required: true, }, { name: 'categoryName', label: '店铺', control: 'select', required: true, options: [{ label: '旗舰店', value: '旗舰店' }], }, { name: 'sceneName', label: '场景', control: 'select', required: true, options: [{ label: '客厅', value: '客厅' }], }, { name: 'platformName', label: '平台', control: 'select', required: false, options: [{ label: '天猫', value: '天猫' }], }, { name: 'productCategoryName', label: '品类', control: 'select', required: false, options: [{ label: '沙发', value: '沙发' }], }, ], imageGroups: { allowedKeys: ['main', 'sku', 'detail'], minTotalImages: 1, }, }) }) test('创建导入任务时,钉钉身份映射失败应立即拒绝且不创建任务', async () => { tx.user.findFirst.mockResolvedValueOnce(null) await expect( createExternalMaterialImportJob({ apiKeyId: 'api-1', body: validBody, }), ).rejects.toMatchObject({ status: 400, code: 'USER_NOT_FOUND', }) expect(tx.$executeRaw).toHaveBeenCalled() expect(tx.externalMaterialImportJob.create).not.toHaveBeenCalled() }) test('相同 clientRequestId 重复提交时应直接返回已有任务', async () => { tx.externalMaterialImportJob.findUnique.mockResolvedValueOnce({ id: 'job-existing', apiKeyId: 'api-1', clientRequestId: 'req-001', materialType: '任务-详情', taskNo: 'TASK202603240001', status: EXTERNAL_MATERIAL_IMPORT_STATUS.PROCESSING, resultMaterialId: null, errorCode: null, errorMessage: null, createdAt: new Date('2026-03-24T09:00:00.000Z'), updatedAt: new Date('2026-03-24T09:00:00.000Z'), completedAt: null, }) const result = await createExternalMaterialImportJob({ apiKeyId: 'api-1', body: validBody, }) expect(result.created).toBe(false) expect(result.job.jobId).toBe('job-existing') expect(tx.externalMaterialImportJob.create).not.toHaveBeenCalled() }) test('图片分组应允许非全套提交,但详情图顺序必须按显式顺序排序', () => { const assets = buildProductPackageImportAssets(validBody.sections) expect(assets).toEqual({ main: [ { fileUrl: 'https://img.example/main-1.jpg', fileName: 'main-1.jpg', fileSize: 33, sortOrder: 1, }, ], sku: [], detail: [ { fileUrl: 'https://img.example/detail-1.jpg', fileName: 'detail-1.jpg', fileSize: 11, sortOrder: 10, }, { fileUrl: 'https://img.example/detail-2.jpg', fileName: 'detail-2.jpg', fileSize: 22, sortOrder: 20, }, ], }) }) test('状态查询应返回成功态和结果素材编号', async () => { const status = await getExternalMaterialImportJobStatus({ apiKeyId: 'api-1', jobId: 'job-1', }) expect(status).toEqual({ jobId: 'job-1', materialType: '任务-详情', taskNo: 'TASK202603240001', status: EXTERNAL_MATERIAL_IMPORT_STATUS.SUCCEEDED, success: true, resultMaterialId: 'material-1', errorCode: null, errorMessage: null, completedAt: '2026-03-24T09:02:00.000Z', }) }) test('处理导入任务时应复用任务-详情完成入库能力并保留详情顺序', async () => { await processExternalMaterialImportJob('job-1') expect(prisma.material.create).toHaveBeenCalledWith({ data: expect.objectContaining({ productModel: 'SPU-001', categoryName: '旗舰店', sceneName: '客厅', platformName: '天猫', productCategoryName: '沙发', materialType: '任务-详情', userId: 'user-1', isAiGenerated: true, taskDetailRequesterUserId: 'user-1', taskDetailRequesterName: '张三', }), }) expect(completeProductPackageUpload).toHaveBeenCalledWith({ materialId: 'material-1', taskNo: 'TASK202603240001', operatorUserId: 'user-1', assets: { main: [ { fileUrl: 'https://img.example/main-1.jpg', fileName: 'main-1.jpg', fileSize: 33, sortOrder: 1, }, ], sku: [], detail: [ { fileUrl: 'https://img.example/detail-1.jpg', fileName: 'detail-1.jpg', fileSize: 11, sortOrder: 10, }, { fileUrl: 'https://img.example/detail-2.jpg', fileName: 'detail-2.jpg', fileSize: 22, sortOrder: 20, }, ], }, }) expect(prisma.externalMaterialImportJob.update).toHaveBeenCalledWith({ where: { id: 'job-1' }, data: expect.objectContaining({ status: EXTERNAL_MATERIAL_IMPORT_STATUS.SUCCEEDED, resultMaterialId: 'material-1', }), }) }) })