1. 排行榜悬浮窗关闭后小按钮消失问题 - 修复面板居中模式关闭时按钮位置错误 - 保存并恢复按钮的居中状态 2. 首页tag按钮按素材数量排序 - 添加按素材数量降序排序逻辑 3. 上传弹窗输入框浅色模式文字不可见 - 添加 text-slate-900 确保浅色模式下文字可见 - 添加 placeholder 样式确保占位符正确显示
523 lines
17 KiB
TypeScript
523 lines
17 KiB
TypeScript
/**
|
||
* 系统数据完整性模块测试
|
||
* 测试 lib/system-integrity.ts 的核心功能
|
||
*/
|
||
|
||
import {
|
||
BUILTIN_TEMPLATE_KEYS,
|
||
BUILTIN_TEMPLATE_CONFIG,
|
||
type BuiltinTemplateKey,
|
||
checkSystemIntegrity,
|
||
repairSystemIntegrity,
|
||
ensureBuiltinTemplates,
|
||
} from '../system-integrity'
|
||
import { prisma } from '@/lib/prisma'
|
||
|
||
// Mock prisma
|
||
jest.mock('@/lib/prisma', () => ({
|
||
prisma: {
|
||
uploadTemplate: {
|
||
findMany: jest.fn(),
|
||
findUnique: jest.fn(),
|
||
create: jest.fn(),
|
||
count: jest.fn(),
|
||
},
|
||
uploadField: {
|
||
create: jest.fn(),
|
||
},
|
||
material: {
|
||
findMany: jest.fn(),
|
||
count: jest.fn(),
|
||
updateMany: jest.fn(),
|
||
},
|
||
$transaction: jest.fn(),
|
||
},
|
||
}))
|
||
|
||
const mockPrisma = prisma as jest.Mocked<typeof prisma>
|
||
|
||
describe('系统数据完整性模块', () => {
|
||
describe('BUILTIN_TEMPLATE_KEYS', () => {
|
||
it('应该包含6个内置模板key', () => {
|
||
expect(BUILTIN_TEMPLATE_KEYS).toHaveLength(6)
|
||
})
|
||
|
||
it('应该包含所有预期的模板key', () => {
|
||
const expectedKeys = [
|
||
'3dmax-material',
|
||
'psd-material',
|
||
'audio-material',
|
||
'video-material',
|
||
'model-material',
|
||
'other-material',
|
||
]
|
||
expect([...BUILTIN_TEMPLATE_KEYS]).toEqual(expectedKeys)
|
||
})
|
||
|
||
it('所有key应该是字符串类型', () => {
|
||
for (const key of BUILTIN_TEMPLATE_KEYS) {
|
||
expect(typeof key).toBe('string')
|
||
}
|
||
})
|
||
})
|
||
|
||
describe('BUILTIN_TEMPLATE_CONFIG', () => {
|
||
it('每个模板key都应该有对应的配置', () => {
|
||
for (const key of BUILTIN_TEMPLATE_KEYS) {
|
||
expect(BUILTIN_TEMPLATE_CONFIG[key]).toBeDefined()
|
||
}
|
||
})
|
||
|
||
it('每个模板配置应该包含必要字段', () => {
|
||
for (const key of BUILTIN_TEMPLATE_KEYS) {
|
||
const config = BUILTIN_TEMPLATE_CONFIG[key]
|
||
expect(config.name).toBeTruthy()
|
||
expect(typeof config.name).toBe('string')
|
||
expect(config.description).toBeTruthy()
|
||
expect(typeof config.description).toBe('string')
|
||
expect(config.materialType).toBeTruthy()
|
||
expect(typeof config.materialType).toBe('string')
|
||
expect(config.materialKind).toBeTruthy()
|
||
expect(Array.isArray(config.fields)).toBe(true)
|
||
expect(config.fields.length).toBeGreaterThan(0)
|
||
}
|
||
})
|
||
|
||
it('materialKind 应该是有效值 (IMAGE/VIDEO/AUDIO/OTHER)', () => {
|
||
const validKinds = ['IMAGE', 'VIDEO', 'AUDIO', 'OTHER']
|
||
for (const key of BUILTIN_TEMPLATE_KEYS) {
|
||
const config = BUILTIN_TEMPLATE_CONFIG[key]
|
||
expect(validKinds).toContain(config.materialKind)
|
||
}
|
||
})
|
||
|
||
it('3DMAX模板应该包含renderer字段', () => {
|
||
const config = BUILTIN_TEMPLATE_CONFIG['3dmax-material']
|
||
const fieldKeys = config.fields.map(f => f.key)
|
||
expect(fieldKeys).toContain('renderer')
|
||
})
|
||
|
||
it('视频模板的materialKind应该是VIDEO', () => {
|
||
const config = BUILTIN_TEMPLATE_CONFIG['video-material']
|
||
expect(config.materialKind).toBe('VIDEO')
|
||
})
|
||
|
||
it('其他模板的materialKind应该是OTHER', () => {
|
||
const config = BUILTIN_TEMPLATE_CONFIG['other-material']
|
||
expect(config.materialKind).toBe('OTHER')
|
||
})
|
||
|
||
it('所有IMAGE类型模板应该正确标记', () => {
|
||
const imageTemplates: BuiltinTemplateKey[] = [
|
||
'3dmax-material',
|
||
'psd-material',
|
||
'model-material',
|
||
]
|
||
for (const key of imageTemplates) {
|
||
expect(BUILTIN_TEMPLATE_CONFIG[key].materialKind).toBe('IMAGE')
|
||
}
|
||
})
|
||
|
||
it('音频模板的materialKind应该是AUDIO', () => {
|
||
const config = BUILTIN_TEMPLATE_CONFIG['audio-material']
|
||
expect(config.materialKind).toBe('AUDIO')
|
||
})
|
||
})
|
||
|
||
describe('字段配置验证', () => {
|
||
it('每个字段应该有完整的配置', () => {
|
||
for (const key of BUILTIN_TEMPLATE_KEYS) {
|
||
const config = BUILTIN_TEMPLATE_CONFIG[key]
|
||
for (const field of config.fields) {
|
||
expect(field.key).toBeTruthy()
|
||
expect(typeof field.key).toBe('string')
|
||
expect(field.label).toBeTruthy()
|
||
expect(typeof field.label).toBe('string')
|
||
expect(field.type).toBeTruthy()
|
||
expect(typeof field.required).toBe('boolean')
|
||
expect(field.layoutWidth).toBeTruthy()
|
||
expect(typeof field.visibleOnHomeFilter).toBe('boolean')
|
||
expect(typeof field.isSearchable).toBe('boolean')
|
||
}
|
||
}
|
||
})
|
||
|
||
it('字段类型应该是有效值', () => {
|
||
const validTypes = ['text', 'select', 'number', 'textarea', 'date', 'checkbox']
|
||
for (const key of BUILTIN_TEMPLATE_KEYS) {
|
||
const config = BUILTIN_TEMPLATE_CONFIG[key]
|
||
for (const field of config.fields) {
|
||
expect(validTypes).toContain(field.type)
|
||
}
|
||
}
|
||
})
|
||
|
||
it('所有模板都应该包含productModel字段', () => {
|
||
for (const key of BUILTIN_TEMPLATE_KEYS) {
|
||
const config = BUILTIN_TEMPLATE_CONFIG[key]
|
||
const fieldKeys = config.fields.map(f => f.key)
|
||
expect(fieldKeys).toContain('productModel')
|
||
}
|
||
})
|
||
|
||
it('所有模板都应该包含分类字段(categoryName或audioCategory)', () => {
|
||
for (const key of BUILTIN_TEMPLATE_KEYS) {
|
||
const config = BUILTIN_TEMPLATE_CONFIG[key]
|
||
const fieldKeys = config.fields.map(f => f.key)
|
||
// 音频模板使用 audioCategory,其他模板使用 categoryName
|
||
const hasCategoryField = fieldKeys.includes('categoryName') || fieldKeys.includes('audioCategory')
|
||
expect(hasCategoryField).toBe(true)
|
||
}
|
||
})
|
||
|
||
it('所有模板都应该包含场景字段(sceneName或audioScene)', () => {
|
||
for (const key of BUILTIN_TEMPLATE_KEYS) {
|
||
const config = BUILTIN_TEMPLATE_CONFIG[key]
|
||
const fieldKeys = config.fields.map(f => f.key)
|
||
// 音频模板使用 audioScene,其他模板使用 sceneName
|
||
const hasSceneField = fieldKeys.includes('sceneName') || fieldKeys.includes('audioScene')
|
||
expect(hasSceneField).toBe(true)
|
||
}
|
||
})
|
||
|
||
it('分类和场景字段应该标记addToCategory', () => {
|
||
for (const key of BUILTIN_TEMPLATE_KEYS) {
|
||
const config = BUILTIN_TEMPLATE_CONFIG[key]
|
||
// 查找分类字段(categoryName 或 audioCategory)
|
||
const categoryField = config.fields.find(f => f.key === 'categoryName' || f.key === 'audioCategory')
|
||
// 查找场景字段(sceneName 或 audioScene)
|
||
const sceneField = config.fields.find(f => f.key === 'sceneName' || f.key === 'audioScene')
|
||
|
||
expect(categoryField?.addToCategory).toBe(true)
|
||
expect(sceneField?.addToCategory).toBe(true)
|
||
}
|
||
})
|
||
|
||
it('select类型字段应该标记addToCategory', () => {
|
||
for (const key of BUILTIN_TEMPLATE_KEYS) {
|
||
const config = BUILTIN_TEMPLATE_CONFIG[key]
|
||
const selectFields = config.fields.filter(f => f.type === 'select')
|
||
|
||
for (const field of selectFields) {
|
||
expect(field.addToCategory).toBe(true)
|
||
}
|
||
}
|
||
})
|
||
})
|
||
|
||
describe('模板ID生成规则', () => {
|
||
it('内置模板ID应该以builtin-开头', () => {
|
||
for (const key of BUILTIN_TEMPLATE_KEYS) {
|
||
const templateId = `builtin-${key}`
|
||
expect(templateId).toMatch(/^builtin-/)
|
||
}
|
||
})
|
||
|
||
it('生成的模板ID应该唯一', () => {
|
||
const ids = BUILTIN_TEMPLATE_KEYS.map(key => `builtin-${key}`)
|
||
const uniqueIds = new Set(ids)
|
||
expect(uniqueIds.size).toBe(ids.length)
|
||
})
|
||
|
||
it('模板ID格式应该符合规范', () => {
|
||
for (const key of BUILTIN_TEMPLATE_KEYS) {
|
||
const templateId = `builtin-${key}`
|
||
// 只包含字母、数字和连字符
|
||
expect(templateId).toMatch(/^[a-z0-9-]+$/)
|
||
}
|
||
})
|
||
})
|
||
|
||
describe('模板配置一致性', () => {
|
||
it('所有模板的materialType应该唯一', () => {
|
||
const materialTypes = BUILTIN_TEMPLATE_KEYS.map(
|
||
key => BUILTIN_TEMPLATE_CONFIG[key].materialType
|
||
)
|
||
const uniqueTypes = new Set(materialTypes)
|
||
expect(uniqueTypes.size).toBe(materialTypes.length)
|
||
})
|
||
|
||
it('模板名称应该唯一', () => {
|
||
const names = BUILTIN_TEMPLATE_KEYS.map(
|
||
key => BUILTIN_TEMPLATE_CONFIG[key].name
|
||
)
|
||
const uniqueNames = new Set(names)
|
||
expect(uniqueNames.size).toBe(names.length)
|
||
})
|
||
|
||
it('每个模板的字段key应该唯一', () => {
|
||
for (const key of BUILTIN_TEMPLATE_KEYS) {
|
||
const config = BUILTIN_TEMPLATE_CONFIG[key]
|
||
const fieldKeys = config.fields.map(f => f.key)
|
||
const uniqueKeys = new Set(fieldKeys)
|
||
expect(uniqueKeys.size).toBe(fieldKeys.length)
|
||
}
|
||
})
|
||
})
|
||
})
|
||
|
||
|
||
describe('checkSystemIntegrity', () => {
|
||
beforeEach(() => {
|
||
jest.clearAllMocks()
|
||
})
|
||
|
||
it('当所有内置模板存在且无孤儿素材时应返回healthy=true', async () => {
|
||
// Mock: 所有内置模板都存在
|
||
const expectedIds = BUILTIN_TEMPLATE_KEYS.map(key => `builtin-${key}`)
|
||
;(mockPrisma.uploadTemplate.findMany as jest.Mock)
|
||
.mockResolvedValueOnce(expectedIds.map(id => ({ id }))) // 内置模板检查
|
||
.mockResolvedValueOnce([]) // 引用模板检查
|
||
|
||
// Mock: 没有素材引用模板
|
||
;(mockPrisma.material.findMany as jest.Mock).mockResolvedValue([])
|
||
|
||
const result = await checkSystemIntegrity()
|
||
|
||
expect(result.healthy).toBe(true)
|
||
expect(result.checks.builtinTemplates.healthy).toBe(true)
|
||
expect(result.checks.builtinTemplates.missing).toHaveLength(0)
|
||
expect(result.checks.orphanedMaterials.healthy).toBe(true)
|
||
expect(result.recommendations).toHaveLength(0)
|
||
})
|
||
|
||
it('当缺少内置模板时应返回healthy=false', async () => {
|
||
// Mock: 只有部分模板存在
|
||
;(mockPrisma.uploadTemplate.findMany as jest.Mock)
|
||
.mockResolvedValueOnce([
|
||
{ id: 'builtin-3dmax-material' },
|
||
{ id: 'builtin-psd-material' },
|
||
])
|
||
.mockResolvedValueOnce([])
|
||
|
||
;(mockPrisma.material.findMany as jest.Mock).mockResolvedValue([])
|
||
|
||
const result = await checkSystemIntegrity()
|
||
|
||
expect(result.healthy).toBe(false)
|
||
expect(result.checks.builtinTemplates.healthy).toBe(false)
|
||
expect(result.checks.builtinTemplates.existing).toBe(2)
|
||
expect(result.checks.builtinTemplates.missing.length).toBe(4)
|
||
expect(result.recommendations.length).toBeGreaterThan(0)
|
||
})
|
||
|
||
it('当存在孤儿素材时应返回healthy=false', async () => {
|
||
// Mock: 所有内置模板存在
|
||
const expectedIds = BUILTIN_TEMPLATE_KEYS.map(key => `builtin-${key}`)
|
||
;(mockPrisma.uploadTemplate.findMany as jest.Mock)
|
||
.mockResolvedValueOnce(expectedIds.map(id => ({ id })))
|
||
.mockResolvedValueOnce([]) // 引用的模板不存在
|
||
|
||
// Mock: 有素材引用不存在的模板
|
||
;(mockPrisma.material.findMany as jest.Mock).mockResolvedValue([
|
||
{ templateId: 'deleted-template-1' },
|
||
{ templateId: 'deleted-template-2' },
|
||
])
|
||
|
||
;(mockPrisma.material.count as jest.Mock).mockResolvedValue(5)
|
||
|
||
const result = await checkSystemIntegrity()
|
||
|
||
expect(result.healthy).toBe(false)
|
||
expect(result.checks.orphanedMaterials.healthy).toBe(false)
|
||
expect(result.checks.orphanedMaterials.count).toBe(5)
|
||
expect(result.checks.orphanedMaterials.templateIds).toContain('deleted-template-1')
|
||
})
|
||
|
||
it('应该返回正确的时间戳格式', async () => {
|
||
;(mockPrisma.uploadTemplate.findMany as jest.Mock).mockResolvedValue([])
|
||
;(mockPrisma.material.findMany as jest.Mock).mockResolvedValue([])
|
||
|
||
const result = await checkSystemIntegrity()
|
||
|
||
expect(result.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/)
|
||
})
|
||
})
|
||
|
||
describe('repairSystemIntegrity', () => {
|
||
beforeEach(() => {
|
||
jest.clearAllMocks()
|
||
})
|
||
|
||
it('应该创建缺失的内置模板', async () => {
|
||
// Mock transaction 执行创建逻辑
|
||
;(mockPrisma.$transaction as jest.Mock).mockImplementation(async (callback) => {
|
||
const tx = {
|
||
uploadTemplate: {
|
||
findUnique: jest.fn().mockResolvedValue(null), // 模板不存在
|
||
create: jest.fn().mockResolvedValue({ id: 'test' }),
|
||
},
|
||
uploadField: {
|
||
create: jest.fn().mockResolvedValue({}),
|
||
},
|
||
}
|
||
return callback(tx)
|
||
})
|
||
|
||
// Mock 孤儿素材检查
|
||
;(mockPrisma.material.findMany as jest.Mock).mockResolvedValue([])
|
||
;(mockPrisma.uploadTemplate.findMany as jest.Mock).mockResolvedValue([])
|
||
|
||
const result = await repairSystemIntegrity({
|
||
createMissingBuiltinTemplates: true,
|
||
fixOrphanedMaterials: false,
|
||
})
|
||
|
||
expect(result.success).toBe(true)
|
||
expect(result.repairs.templatesCreated.length).toBe(6)
|
||
expect(result.errors).toHaveLength(0)
|
||
})
|
||
|
||
it('当模板已存在时应跳过创建', async () => {
|
||
;(mockPrisma.$transaction as jest.Mock).mockImplementation(async (callback) => {
|
||
const tx = {
|
||
uploadTemplate: {
|
||
findUnique: jest.fn().mockResolvedValue({ id: 'existing' }), // 模板已存在
|
||
create: jest.fn(),
|
||
},
|
||
uploadField: {
|
||
create: jest.fn(),
|
||
},
|
||
}
|
||
return callback(tx)
|
||
})
|
||
|
||
;(mockPrisma.material.findMany as jest.Mock).mockResolvedValue([])
|
||
;(mockPrisma.uploadTemplate.findMany as jest.Mock).mockResolvedValue([])
|
||
|
||
const result = await repairSystemIntegrity({
|
||
createMissingBuiltinTemplates: true,
|
||
fixOrphanedMaterials: false,
|
||
})
|
||
|
||
expect(result.success).toBe(true)
|
||
expect(result.repairs.templatesCreated).toHaveLength(0)
|
||
})
|
||
|
||
it('应该修复孤儿素材', async () => {
|
||
;(mockPrisma.$transaction as jest.Mock).mockImplementation(async (callback) => {
|
||
const tx = {
|
||
uploadTemplate: {
|
||
findUnique: jest.fn().mockResolvedValue({ id: 'existing' }),
|
||
create: jest.fn(),
|
||
},
|
||
uploadField: {
|
||
create: jest.fn(),
|
||
},
|
||
}
|
||
return callback(tx)
|
||
})
|
||
|
||
// Mock: 有素材引用不存在的自定义模板
|
||
;(mockPrisma.material.findMany as jest.Mock).mockResolvedValue([
|
||
{ templateId: 'custom-deleted-template' },
|
||
])
|
||
;(mockPrisma.uploadTemplate.findMany as jest.Mock).mockResolvedValue([])
|
||
;(mockPrisma.material.updateMany as jest.Mock).mockResolvedValue({ count: 3 })
|
||
|
||
const result = await repairSystemIntegrity({
|
||
createMissingBuiltinTemplates: false,
|
||
fixOrphanedMaterials: true,
|
||
})
|
||
|
||
expect(result.success).toBe(true)
|
||
expect(result.repairs.orphanedMaterialsFixed).toBe(3)
|
||
})
|
||
|
||
it('创建模板失败时应记录错误', async () => {
|
||
;(mockPrisma.$transaction as jest.Mock).mockRejectedValue(new Error('Database error'))
|
||
;(mockPrisma.material.findMany as jest.Mock).mockResolvedValue([])
|
||
;(mockPrisma.uploadTemplate.findMany as jest.Mock).mockResolvedValue([])
|
||
|
||
const result = await repairSystemIntegrity({
|
||
createMissingBuiltinTemplates: true,
|
||
fixOrphanedMaterials: false,
|
||
})
|
||
|
||
expect(result.success).toBe(false)
|
||
expect(result.errors.length).toBeGreaterThan(0)
|
||
expect(result.repairs.templatesFailed.length).toBe(6)
|
||
})
|
||
})
|
||
|
||
describe('ensureBuiltinTemplates', () => {
|
||
beforeEach(() => {
|
||
jest.clearAllMocks()
|
||
})
|
||
|
||
it('当所有模板已存在时应返回空created数组', async () => {
|
||
const expectedIds = BUILTIN_TEMPLATE_KEYS.map(key => `builtin-${key}`)
|
||
;(mockPrisma.uploadTemplate.findMany as jest.Mock).mockResolvedValue(
|
||
expectedIds.map(id => ({ id }))
|
||
)
|
||
|
||
const result = await ensureBuiltinTemplates()
|
||
|
||
expect(result.checked).toBe(true)
|
||
expect(result.created).toHaveLength(0)
|
||
expect(result.errors).toHaveLength(0)
|
||
})
|
||
|
||
it('应该只创建缺失的模板', async () => {
|
||
// Mock: 只有2个模板存在
|
||
;(mockPrisma.uploadTemplate.findMany as jest.Mock).mockResolvedValue([
|
||
{ id: 'builtin-3dmax-material' },
|
||
{ id: 'builtin-psd-material' },
|
||
])
|
||
|
||
;(mockPrisma.$transaction as jest.Mock).mockImplementation(async (callback) => {
|
||
const tx = {
|
||
uploadTemplate: {
|
||
findUnique: jest.fn().mockResolvedValue(null),
|
||
create: jest.fn().mockResolvedValue({}),
|
||
},
|
||
uploadField: {
|
||
create: jest.fn().mockResolvedValue({}),
|
||
},
|
||
}
|
||
await callback(tx)
|
||
})
|
||
|
||
const result = await ensureBuiltinTemplates()
|
||
|
||
expect(result.checked).toBe(true)
|
||
expect(result.created.length).toBe(4) // 6 - 2 = 4
|
||
})
|
||
|
||
it('并发创建时应忽略唯一约束冲突', async () => {
|
||
;(mockPrisma.uploadTemplate.findMany as jest.Mock).mockResolvedValue([])
|
||
;(mockPrisma.$transaction as jest.Mock).mockRejectedValue(
|
||
new Error('Unique constraint failed')
|
||
)
|
||
|
||
const result = await ensureBuiltinTemplates()
|
||
|
||
expect(result.checked).toBe(true)
|
||
expect(result.errors).toHaveLength(0) // 唯一约束错误被忽略
|
||
})
|
||
|
||
it('其他错误应该被记录', async () => {
|
||
;(mockPrisma.uploadTemplate.findMany as jest.Mock).mockResolvedValue([])
|
||
;(mockPrisma.$transaction as jest.Mock).mockRejectedValue(
|
||
new Error('Connection timeout')
|
||
)
|
||
|
||
const result = await ensureBuiltinTemplates()
|
||
|
||
expect(result.checked).toBe(true)
|
||
expect(result.errors.length).toBeGreaterThan(0)
|
||
expect(result.errors[0]).toContain('Connection timeout')
|
||
})
|
||
|
||
it('初始查询失败时应返回checked=false', async () => {
|
||
;(mockPrisma.uploadTemplate.findMany as jest.Mock).mockRejectedValue(
|
||
new Error('Database unavailable')
|
||
)
|
||
|
||
const result = await ensureBuiltinTemplates()
|
||
|
||
expect(result.checked).toBe(false)
|
||
expect(result.errors).toContain('Database unavailable')
|
||
})
|
||
})
|