flnxi-Wu/lib/__tests__/integration/template-workflow.integration.test.ts
WuYongJie 63ec6b7dfb fix: 修复排行榜按钮消失、tag排序和输入框浅色模式问题
1. 排行榜悬浮窗关闭后小按钮消失问题
   - 修复面板居中模式关闭时按钮位置错误
   - 保存并恢复按钮的居中状态

2. 首页tag按钮按素材数量排序
   - 添加按素材数量降序排序逻辑

3. 上传弹窗输入框浅色模式文字不可见
   - 添加 text-slate-900 确保浅色模式下文字可见
   - 添加 placeholder 样式确保占位符正确显示
2025-12-14 21:41:29 +08:00

747 lines
21 KiB
TypeScript

/**
* 模板系统集成测试
*
* 测试完整的模板创建、素材上传、字段池同步、分类管理流程
*
* @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<string, string> = {
'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'])
})
})
})