/** * 模板系统集成测试 * * 测试完整的模板创建、素材上传、字段池同步、分类管理流程 * * @module integration/template-workflow * @created 2025-12-13 */ import { validateTemplateId, isBuiltinTemplate, extractBuiltinTemplateKey, BUILTIN_TEMPLATE_KEYS, TemplateValidationError, } from '../../template-validator' import { normalizeFieldOptionValue, parseOptionsStatic, expandOptionValue, OptionCleaningIssue, } from '../../field-pool' // Mock prisma jest.mock('../../prisma', () => ({ prisma: { uploadTemplate: { findUnique: jest.fn(), findMany: jest.fn(), create: jest.fn(), update: jest.fn(), delete: jest.fn(), }, uploadField: { findMany: jest.fn(), create: jest.fn(), update: jest.fn(), deleteMany: jest.fn(), }, fieldDefinition: { findUnique: jest.fn(), findMany: jest.fn(), create: jest.fn(), update: jest.fn(), upsert: jest.fn(), deleteMany: jest.fn(), }, fieldOption: { findMany: jest.fn(), create: jest.fn(), update: jest.fn(), deleteMany: jest.fn(), }, material: { findUnique: jest.fn(), findMany: jest.fn(), create: jest.fn(), update: jest.fn(), count: jest.fn(), }, $transaction: jest.fn((callback) => callback({ fieldDefinition: { findUnique: jest.fn(), findMany: jest.fn().mockResolvedValue([]), upsert: jest.fn().mockResolvedValue({ id: 'field-1' }), deleteMany: jest.fn(), }, fieldOption: { findMany: jest.fn().mockResolvedValue([]), create: jest.fn(), update: jest.fn(), deleteMany: jest.fn(), }, })), }, })) const { prisma } = require('../../prisma') describe('集成测试: 模板创建流程', () => { beforeEach(() => { jest.clearAllMocks() }) describe('1. 模板创建和验证', () => { test('应该成功创建自定义模板', async () => { const mockTemplate = { id: 'custom-template-1', name: '测试模板', description: '测试描述', status: 'active', materialType: '3DMAX', materialKind: 'IMAGE', version: 1, fields: [], } prisma.uploadTemplate.create.mockResolvedValue(mockTemplate) prisma.uploadTemplate.findUnique.mockResolvedValue(mockTemplate) // 验证创建的模板 const validation = await validateTemplateId('custom-template-1') expect(validation.valid).toBe(true) expect(validation.isBuiltin).toBe(false) expect(validation.templateName).toBe('测试模板') }) test('应该验证内置模板', async () => { for (const key of BUILTIN_TEMPLATE_KEYS) { const templateId = `builtin-${key}` const validation = await validateTemplateId(templateId) expect(validation.valid).toBe(true) expect(validation.isBuiltin).toBe(true) expect(validation.templateKey).toBe(key) } }) test('应该拒绝无效的模板ID', async () => { prisma.uploadTemplate.findUnique.mockResolvedValue(null) const validation = await validateTemplateId('non-existent-template') expect(validation.valid).toBe(false) expect(validation.errorCode).toBe(TemplateValidationError.TEMPLATE_NOT_FOUND) }) test('应该检查模板状态', async () => { prisma.uploadTemplate.findUnique.mockResolvedValue({ id: 'draft-template', name: '草稿模板', status: 'draft', }) const validation = await validateTemplateId('draft-template', { checkStatus: true }) expect(validation.valid).toBe(false) expect(validation.errorCode).toBe(TemplateValidationError.TEMPLATE_NOT_ACTIVE) }) }) describe('2. 模板字段配置', () => { test('应该正确解析字段选项', () => { // JSON数组格式 const jsonOptions = parseOptionsStatic('["选项1", "选项2", "选项3"]') expect(jsonOptions).toEqual(['选项1', '选项2', '选项3']) // 换行格式 const newlineOptions = parseOptionsStatic('选项A\n选项B\n选项C') expect(newlineOptions).toEqual(['选项A', '选项B', '选项C']) }) test('应该清洗脏数据选项', () => { // 多余空白 expect(normalizeFieldOptionValue(' 选项 ')).toBe('选项') // 特殊引号 expect(normalizeFieldOptionValue('"选项"')).toBe('选项') // 嵌套数组 expect(normalizeFieldOptionValue('[[选项]]')).toBe('选项') }) test('应该去重选项', () => { const options = parseOptionsStatic('["选项1", "选项2", "选项1", "选项3", "选项2"]') expect(options).toEqual(['选项1', '选项2', '选项3']) }) test('应该展开嵌套数组选项', () => { const expanded = expandOptionValue('[选项1, 选项2, 选项3]') expect(expanded).toEqual(['选项1', '选项2', '选项3']) }) }) describe('3. 模板版本控制', () => { test('应该检测版本冲突', async () => { const mockTemplate = { id: 'versioned-template', name: '版本控制模板', status: 'active', version: 2, } prisma.uploadTemplate.findUnique.mockResolvedValue(mockTemplate) // 模拟客户端提交旧版本 const clientVersion = 1 const serverVersion = mockTemplate.version expect(clientVersion < serverVersion).toBe(true) }) test('应该在更新后递增版本号', async () => { const originalVersion = 1 const updatedTemplate = { id: 'versioned-template', name: '更新后的模板', status: 'active', version: originalVersion + 1, } prisma.uploadTemplate.update.mockResolvedValue(updatedTemplate) expect(updatedTemplate.version).toBe(2) }) }) }) describe('集成测试: 素材上传流程', () => { beforeEach(() => { jest.clearAllMocks() }) describe('1. 模板验证', () => { test('应该在上传前验证模板', async () => { // 有效模板 prisma.uploadTemplate.findUnique.mockResolvedValue({ id: 'valid-template', name: '有效模板', status: 'active', }) const validation = await validateTemplateId('valid-template', { checkStatus: true }) expect(validation.valid).toBe(true) }) test('应该拒绝使用非活跃模板上传', async () => { prisma.uploadTemplate.findUnique.mockResolvedValue({ id: 'archived-template', name: '已归档模板', status: 'archived', }) const validation = await validateTemplateId('archived-template', { checkStatus: true }) expect(validation.valid).toBe(false) }) test('应该允许不指定模板上传(向后兼容)', async () => { const validation = await validateTemplateId(null) expect(validation.valid).toBe(true) }) }) describe('2. 字段数据处理', () => { test('应该正确处理模板字段值', () => { const templateFields = { categoryName: '家具', sceneName: '客厅', brandName: '品牌A', } // 验证字段值不为空 expect(templateFields.categoryName).toBeTruthy() expect(templateFields.sceneName).toBeTruthy() expect(templateFields.brandName).toBeTruthy() }) test('应该处理空字段值', () => { const normalizeInput = (value: string | null) => { if (!value) return null const trimmed = value.trim() return trimmed ? trimmed : null } expect(normalizeInput(null)).toBeNull() expect(normalizeInput('')).toBeNull() expect(normalizeInput(' ')).toBeNull() expect(normalizeInput('有效值')).toBe('有效值') }) }) describe('3. 素材类型处理', () => { test('应该正确识别素材类型', () => { const materialKindMap: Record = { '3DMAX': 'IMAGE', 'PSD': 'IMAGE', '视频': 'VIDEO', '模特': 'IMAGE', '材质': 'IMAGE', 'AI': 'IMAGE', '其他': 'OTHER', } expect(materialKindMap['3DMAX']).toBe('IMAGE') expect(materialKindMap['视频']).toBe('VIDEO') expect(materialKindMap['其他']).toBe('OTHER') }) test('应该处理视频素材的转码状态', () => { const videoMaterial = { id: 'video-1', materialKind: 'VIDEO', videoProcessingStatus: 'pending', } expect(videoMaterial.videoProcessingStatus).toBe('pending') }) }) }) describe('集成测试: 字段池同步流程', () => { beforeEach(() => { jest.clearAllMocks() }) describe('1. 字段聚合', () => { test('应该从多个模板聚合相同字段', () => { // 模拟多个模板的相同字段 const template1Field = { key: 'categoryName', label: '分类', type: 'select', optionsStatic: '["家具", "灯具"]', templateId: 'template-1', } const template2Field = { key: 'categoryName', label: '分类', type: 'select', optionsStatic: '["家具", "装饰"]', templateId: 'template-2', } // 聚合选项 const options1 = parseOptionsStatic(template1Field.optionsStatic) const options2 = parseOptionsStatic(template2Field.optionsStatic) const mergedOptions = Array.from(new Set([...options1, ...options2])) expect(mergedOptions).toContain('家具') expect(mergedOptions).toContain('灯具') expect(mergedOptions).toContain('装饰') expect(mergedOptions.length).toBe(3) }) test('应该区分标准字段和自定义字段', () => { const standardFieldKeys = ['categoryName', 'sceneName', 'platformName', 'brandName'] const customFieldKey = 'customField1' expect(standardFieldKeys.includes('categoryName')).toBe(true) expect(standardFieldKeys.includes(customFieldKey)).toBe(false) }) }) describe('2. 选项清洗', () => { test('应该处理各种脏数据格式', () => { // 空值 expect(normalizeFieldOptionValue('')).toBe('') expect(normalizeFieldOptionValue(' ')).toBe('') // Unicode控制字符 expect(normalizeFieldOptionValue('选项\u0000')).toBe('选项') // 多余空白 expect(normalizeFieldOptionValue('选项 A')).toBe('选项 A') // 嵌套括号 expect(normalizeFieldOptionValue('[[[选项]]]')).toBe('选项') }) test('应该记录清洗问题', () => { const consoleSpy = jest.spyOn(console, 'warn').mockImplementation() normalizeFieldOptionValue(' 脏数据 ', { logIssues: true }) // 清洗后应该有日志 consoleSpy.mockRestore() }) test('应该处理JSON格式选项', () => { const jsonOptions = '["选项1", "选项2", "选项3"]' const parsed = parseOptionsStatic(jsonOptions) expect(parsed).toHaveLength(3) expect(parsed).toEqual(['选项1', '选项2', '选项3']) }) test('应该处理换行格式选项', () => { const newlineOptions = '选项A\n选项B\n选项C' const parsed = parseOptionsStatic(newlineOptions) expect(parsed).toHaveLength(3) expect(parsed).toEqual(['选项A', '选项B', '选项C']) }) }) describe('3. 同步机制', () => { test('应该支持增量同步', async () => { // 模拟增量同步结果 const syncResult = { success: true, duration: 100, fieldsAdded: 1, fieldsUpdated: 2, fieldsRemoved: 0, optionsAdded: 5, optionsRemoved: 1, errors: [], } expect(syncResult.success).toBe(true) expect(syncResult.fieldsAdded).toBeGreaterThanOrEqual(0) expect(syncResult.errors).toHaveLength(0) }) test('应该处理同步错误', () => { const syncResultWithError = { success: false, duration: 50, fieldsAdded: 0, fieldsUpdated: 0, fieldsRemoved: 0, optionsAdded: 0, optionsRemoved: 0, errors: ['数据库连接失败'], } expect(syncResultWithError.success).toBe(false) expect(syncResultWithError.errors.length).toBeGreaterThan(0) }) }) }) describe('集成测试: 分类管理流程', () => { beforeEach(() => { jest.clearAllMocks() }) describe('1. 字段启用/禁用', () => { test('应该切换字段的筛选启用状态', async () => { const mockField = { id: 'field-1', key: 'categoryName', label: '分类', isFilterEnabled: false, } prisma.fieldDefinition.findUnique.mockResolvedValue(mockField) prisma.fieldDefinition.update.mockResolvedValue({ ...mockField, isFilterEnabled: true, }) const updatedField = await prisma.fieldDefinition.update({ where: { id: mockField.id }, data: { isFilterEnabled: true }, }) expect(updatedField.isFilterEnabled).toBe(true) }) test('应该批量启用/禁用字段', async () => { const fieldKeys = ['categoryName', 'sceneName', 'brandName'] const enabled = true // 模拟批量更新 for (const key of fieldKeys) { prisma.fieldDefinition.update.mockResolvedValueOnce({ key, isFilterEnabled: enabled, }) } // 验证所有字段都被更新 expect(fieldKeys.length).toBe(3) }) }) describe('2. 字段排序', () => { test('应该更新字段的筛选顺序', () => { const updates = [ { fieldKey: 'categoryName', filterOrder: 0 }, { fieldKey: 'sceneName', filterOrder: 1 }, { fieldKey: 'brandName', filterOrder: 2 }, ] // 验证顺序数据结构正确 expect(updates[0].filterOrder).toBe(0) expect(updates[1].filterOrder).toBe(1) expect(updates[2].filterOrder).toBe(2) expect(updates.length).toBe(3) }) }) describe('3. 字段重命名', () => { test('应该更新字段的显示名称', async () => { // 重置mock prisma.fieldDefinition.update.mockReset() const mockField = { id: 'field-1', key: 'categoryName', label: '分类', filterDisplayName: '分类', } const updatedMockField = { ...mockField, filterDisplayName: '产品分类', } prisma.fieldDefinition.update.mockResolvedValue(updatedMockField) const updatedField = await prisma.fieldDefinition.update({ where: { id: mockField.id }, data: { filterDisplayName: '产品分类' }, }) expect(updatedField.filterDisplayName).toBe('产品分类') }) test('应该处理空的显示名称', async () => { // 重置mock prisma.fieldDefinition.update.mockReset() const mockField = { id: 'field-1', key: 'categoryName', label: '分类', filterDisplayName: null, } prisma.fieldDefinition.update.mockResolvedValue(mockField) const updatedField = await prisma.fieldDefinition.update({ where: { id: mockField.id }, data: { filterDisplayName: null }, }) expect(updatedField.filterDisplayName).toBeNull() }) }) describe('4. 选项管理', () => { test('应该添加新选项', async () => { const newOption = { id: 'option-new', fieldId: 'field-1', value: '新选项', label: '新选项', orderIndex: 0, } prisma.fieldOption.create.mockResolvedValue(newOption) const createdOption = await prisma.fieldOption.create({ data: newOption, }) expect(createdOption.value).toBe('新选项') }) test('应该删除选项', async () => { prisma.fieldOption.deleteMany.mockResolvedValue({ count: 1 }) const result = await prisma.fieldOption.deleteMany({ where: { id: 'option-to-delete' }, }) expect(result.count).toBe(1) }) test('应该重命名选项', async () => { const mockOption = { id: 'option-1', value: '旧名称', label: '旧名称', } prisma.fieldOption.update.mockResolvedValue({ ...mockOption, value: '新名称', label: '新名称', }) const updatedOption = await prisma.fieldOption.update({ where: { id: mockOption.id }, data: { value: '新名称', label: '新名称' }, }) expect(updatedOption.value).toBe('新名称') }) }) describe('5. 批量操作', () => { test('应该支持CSV格式导入', () => { const csvContent = `value,label 选项1,选项1标签 选项2,选项2标签 选项3,选项3标签` const lines = csvContent.split('\n') const headers = lines[0].split(',') const data = lines.slice(1).map(line => { const values = line.split(',') return { value: values[0], label: values[1], } }) expect(headers).toEqual(['value', 'label']) expect(data).toHaveLength(3) expect(data[0].value).toBe('选项1') }) test('应该支持JSON格式导入', () => { const jsonContent = JSON.stringify([ { value: '选项1', label: '选项1标签' }, { value: '选项2', label: '选项2标签' }, ]) const data = JSON.parse(jsonContent) expect(data).toHaveLength(2) expect(data[0].value).toBe('选项1') }) test('应该支持导出选项', () => { const options = [ { value: '选项1', label: '选项1标签' }, { value: '选项2', label: '选项2标签' }, ] // CSV导出 const csvHeader = 'value,label' const csvRows = options.map(opt => `${opt.value},${opt.label}`) const csvContent = [csvHeader, ...csvRows].join('\n') expect(csvContent).toContain('value,label') expect(csvContent).toContain('选项1,选项1标签') // JSON导出 const jsonContent = JSON.stringify(options, null, 2) expect(jsonContent).toContain('"value": "选项1"') }) }) }) describe('集成测试: 端到端流程', () => { beforeEach(() => { jest.clearAllMocks() }) describe('完整工作流', () => { test('应该完成模板创建到素材上传的完整流程', async () => { // 1. 创建模板 const mockTemplate = { id: 'e2e-template', name: '端到端测试模板', status: 'active', materialType: '3DMAX', materialKind: 'IMAGE', version: 1, fields: [ { key: 'categoryName', label: '分类', type: 'select', optionsStatic: '["家具"]' }, ], } prisma.uploadTemplate.create.mockResolvedValue(mockTemplate) prisma.uploadTemplate.findUnique.mockResolvedValue(mockTemplate) // 2. 验证模板 const validation = await validateTemplateId('e2e-template') expect(validation.valid).toBe(true) // 3. 模拟素材上传 const mockMaterial = { id: 'FLNXI202512130001', templateId: 'e2e-template', categoryName: '家具', materialType: '3DMAX', materialKind: 'IMAGE', } prisma.material.create.mockResolvedValue(mockMaterial) const createdMaterial = await prisma.material.create({ data: mockMaterial, }) expect(createdMaterial.templateId).toBe('e2e-template') expect(createdMaterial.categoryName).toBe('家具') }) test('应该完成字段池同步到分类管理的完整流程', async () => { // 1. 模拟模板字段 const templateFields = [ { key: 'categoryName', label: '分类', type: 'select', optionsStatic: '["家具", "灯具"]', addToCategory: true, templateId: 'template-1', }, ] prisma.uploadField.findMany.mockResolvedValue(templateFields) // 2. 解析选项 const options = parseOptionsStatic(templateFields[0].optionsStatic) expect(options).toEqual(['家具', '灯具']) // 3. 模拟字段池记录 const fieldDefinition = { id: 'field-def-1', key: 'categoryName', label: '分类', isFilterEnabled: true, filterOrder: 0, } prisma.fieldDefinition.upsert.mockResolvedValue(fieldDefinition) // 4. 验证字段池记录 expect(fieldDefinition.isFilterEnabled).toBe(true) }) }) describe('错误处理', () => { test('应该处理模板不存在的情况', async () => { prisma.uploadTemplate.findUnique.mockResolvedValue(null) const validation = await validateTemplateId('non-existent') expect(validation.valid).toBe(false) expect(validation.errorCode).toBe(TemplateValidationError.TEMPLATE_NOT_FOUND) }) test('应该处理数据库错误', async () => { prisma.uploadTemplate.findUnique.mockRejectedValue(new Error('数据库连接失败')) const validation = await validateTemplateId('any-template') expect(validation.valid).toBe(false) expect(validation.errorCode).toBe(TemplateValidationError.DATABASE_ERROR) }) test('应该处理无效的选项格式', () => { // 无效JSON const invalidJson = 'not-valid-json' const parsed = parseOptionsStatic(invalidJson) // 应该回退到换行解析 expect(parsed).toEqual(['not-valid-json']) }) }) })