1. 排行榜悬浮窗关闭后小按钮消失问题 - 修复面板居中模式关闭时按钮位置错误 - 保存并恢复按钮的居中状态 2. 首页tag按钮按素材数量排序 - 添加按素材数量降序排序逻辑 3. 上传弹窗输入框浅色模式文字不可见 - 添加 text-slate-900 确保浅色模式下文字可见 - 添加 placeholder 样式确保占位符正确显示
472 lines
14 KiB
TypeScript
472 lines
14 KiB
TypeScript
/**
|
||
* 模板CRUD API单元测试
|
||
*
|
||
* 测试模板的创建、读取、更新、删除功能
|
||
* 包括版本控制、数据验证、错误处理
|
||
*/
|
||
|
||
import { NextRequest } from 'next/server'
|
||
|
||
// Mock dependencies
|
||
jest.mock('@/lib/auth', () => ({
|
||
authOptions: {},
|
||
}))
|
||
|
||
jest.mock('next-auth', () => ({
|
||
getServerSession: jest.fn(),
|
||
}))
|
||
|
||
jest.mock('@/lib/prisma', () => ({
|
||
prisma: {
|
||
uploadTemplate: {
|
||
findMany: jest.fn(),
|
||
findUnique: jest.fn(),
|
||
create: jest.fn(),
|
||
update: jest.fn(),
|
||
delete: jest.fn(),
|
||
},
|
||
material: {
|
||
findMany: jest.fn(),
|
||
},
|
||
$transaction: jest.fn(),
|
||
},
|
||
}))
|
||
|
||
jest.mock('@/lib/field-pool', () => ({
|
||
syncFieldPoolFromTemplates: jest.fn(),
|
||
}))
|
||
|
||
const { getServerSession } = require('next-auth')
|
||
const { prisma } = require('@/lib/prisma')
|
||
const { syncFieldPoolFromTemplates } = require('@/lib/field-pool')
|
||
|
||
// 导入API处理函数
|
||
import { GET as listTemplates, POST as createTemplate } from '../route'
|
||
import {
|
||
GET as getTemplate,
|
||
PUT as updateTemplate,
|
||
DELETE as deleteTemplate
|
||
} from '../[id]/route'
|
||
|
||
describe('模板CRUD API测试', () => {
|
||
beforeEach(() => {
|
||
jest.clearAllMocks()
|
||
// 默认模拟管理员会话
|
||
getServerSession.mockResolvedValue({
|
||
user: { id: 'admin-id', role: 'ADMIN' }
|
||
})
|
||
})
|
||
|
||
describe('GET /api/admin/upload-templates - 获取模板列表', () => {
|
||
test('应该返回模板列表(包含materialCount)', async () => {
|
||
const mockTemplates = [
|
||
{
|
||
id: 'template-1',
|
||
name: '测试模板1',
|
||
status: 'active',
|
||
version: 1,
|
||
_count: { materials: 5 },
|
||
},
|
||
{
|
||
id: 'template-2',
|
||
name: '测试模板2',
|
||
status: 'draft',
|
||
version: 2,
|
||
_count: { materials: 0 },
|
||
},
|
||
]
|
||
|
||
prisma.uploadTemplate.findMany.mockResolvedValue(mockTemplates)
|
||
|
||
const response = await listTemplates()
|
||
const data = await response.json()
|
||
|
||
expect(response.status).toBe(200)
|
||
expect(data).toHaveLength(2)
|
||
expect(data[0].materialCount).toBe(5)
|
||
expect(data[1].materialCount).toBe(0)
|
||
})
|
||
|
||
test('应该拒绝非管理员访问', async () => {
|
||
getServerSession.mockResolvedValue({
|
||
user: { id: 'user-id', role: 'USER' }
|
||
})
|
||
|
||
const response = await listTemplates()
|
||
const data = await response.json()
|
||
|
||
expect(response.status).toBe(403)
|
||
expect(data.errorCode).toBe('UNAUTHORIZED')
|
||
})
|
||
|
||
test('应该处理数据库错误', async () => {
|
||
prisma.uploadTemplate.findMany.mockRejectedValue(new Error('数据库错误'))
|
||
|
||
const response = await listTemplates()
|
||
const data = await response.json()
|
||
|
||
expect(response.status).toBe(500)
|
||
expect(data.errorCode).toBe('LIST_FAILED')
|
||
})
|
||
})
|
||
|
||
describe('POST /api/admin/upload-templates - 创建模板', () => {
|
||
test('应该成功创建模板', async () => {
|
||
const mockTemplate = {
|
||
id: 'new-template-id',
|
||
name: '新模板',
|
||
materialType: '3DMAX',
|
||
materialKind: 'IMAGE',
|
||
status: 'draft',
|
||
version: 1,
|
||
}
|
||
|
||
prisma.uploadTemplate.create.mockResolvedValue(mockTemplate)
|
||
|
||
const request = new NextRequest('http://localhost/api/admin/upload-templates', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
name: '新模板',
|
||
materialType: '3DMAX',
|
||
materialKind: 'IMAGE',
|
||
fields: [],
|
||
}),
|
||
})
|
||
|
||
const response = await createTemplate(request)
|
||
const data = await response.json()
|
||
|
||
expect(response.status).toBe(201)
|
||
expect(data.name).toBe('新模板')
|
||
expect(syncFieldPoolFromTemplates).toHaveBeenCalled()
|
||
})
|
||
|
||
test('应该验证必填字段 - name', async () => {
|
||
const request = new NextRequest('http://localhost/api/admin/upload-templates', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
materialType: '3DMAX',
|
||
materialKind: 'IMAGE',
|
||
}),
|
||
})
|
||
|
||
const response = await createTemplate(request)
|
||
const data = await response.json()
|
||
|
||
expect(response.status).toBe(400)
|
||
expect(data.errorCode).toBe('VALIDATION_ERROR')
|
||
expect(data.field).toBe('name')
|
||
})
|
||
|
||
test('应该验证必填字段 - materialType', async () => {
|
||
const request = new NextRequest('http://localhost/api/admin/upload-templates', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
name: '测试模板',
|
||
materialKind: 'IMAGE',
|
||
}),
|
||
})
|
||
|
||
const response = await createTemplate(request)
|
||
const data = await response.json()
|
||
|
||
expect(response.status).toBe(400)
|
||
expect(data.errorCode).toBe('VALIDATION_ERROR')
|
||
expect(data.field).toBe('materialType')
|
||
})
|
||
|
||
test('应该验证materialKind的有效值', async () => {
|
||
const request = new NextRequest('http://localhost/api/admin/upload-templates', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
name: '测试模板',
|
||
materialType: '3DMAX',
|
||
materialKind: 'INVALID',
|
||
}),
|
||
})
|
||
|
||
const response = await createTemplate(request)
|
||
const data = await response.json()
|
||
|
||
expect(response.status).toBe(400)
|
||
expect(data.errorCode).toBe('VALIDATION_ERROR')
|
||
expect(data.field).toBe('materialKind')
|
||
})
|
||
|
||
test('应该检查字段key的唯一性', async () => {
|
||
const request = new NextRequest('http://localhost/api/admin/upload-templates', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
name: '测试模板',
|
||
materialType: '3DMAX',
|
||
materialKind: 'IMAGE',
|
||
fields: [
|
||
{ key: 'field1', label: '字段1', type: 'text' },
|
||
{ key: 'field1', label: '字段2', type: 'text' }, // 重复的key
|
||
],
|
||
}),
|
||
})
|
||
|
||
const response = await createTemplate(request)
|
||
const data = await response.json()
|
||
|
||
expect(response.status).toBe(400)
|
||
expect(data.errorCode).toBe('VALIDATION_ERROR')
|
||
expect(data.details.duplicateKeys).toContain('field1')
|
||
})
|
||
|
||
test('应该自动转换optionsStatic数组为JSON字符串', async () => {
|
||
const mockTemplate = {
|
||
id: 'new-template-id',
|
||
name: '新模板',
|
||
version: 1,
|
||
}
|
||
|
||
prisma.uploadTemplate.create.mockResolvedValue(mockTemplate)
|
||
|
||
const request = new NextRequest('http://localhost/api/admin/upload-templates', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
name: '新模板',
|
||
materialType: '3DMAX',
|
||
materialKind: 'IMAGE',
|
||
fields: [
|
||
{
|
||
key: 'category',
|
||
label: '分类',
|
||
type: 'select',
|
||
optionsStatic: ['选项1', '选项2', '选项3'] // 数组格式
|
||
},
|
||
],
|
||
}),
|
||
})
|
||
|
||
await createTemplate(request)
|
||
|
||
expect(prisma.uploadTemplate.create).toHaveBeenCalled()
|
||
const createData = prisma.uploadTemplate.create.mock.calls[0][0].data
|
||
const field = createData.fields.createMany.data[0]
|
||
expect(typeof field.optionsStatic).toBe('string')
|
||
expect(JSON.parse(field.optionsStatic)).toEqual(['选项1', '选项2', '选项3'])
|
||
})
|
||
|
||
test('应该处理重复名称错误', async () => {
|
||
const prismaError = new Error('Unique constraint failed')
|
||
;(prismaError as any).code = 'P2002'
|
||
;(prismaError as any).meta = { target: ['name'] }
|
||
|
||
prisma.uploadTemplate.create.mockRejectedValue(prismaError)
|
||
|
||
const request = new NextRequest('http://localhost/api/admin/upload-templates', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
name: '重复模板',
|
||
materialType: '3DMAX',
|
||
materialKind: 'IMAGE',
|
||
}),
|
||
})
|
||
|
||
const response = await createTemplate(request)
|
||
const data = await response.json()
|
||
|
||
expect(response.status).toBe(400)
|
||
expect(data.errorCode).toBe('DUPLICATE_ENTRY')
|
||
})
|
||
})
|
||
|
||
describe('GET /api/admin/upload-templates/[id] - 获取单个模板', () => {
|
||
test('应该返回模板详情(包含统计信息)', async () => {
|
||
const mockTemplate = {
|
||
id: 'template-1',
|
||
name: '测试模板',
|
||
version: 1,
|
||
fields: [],
|
||
_count: { materials: 10 },
|
||
}
|
||
|
||
const mockMaterials = [
|
||
{ id: 'mat-1', productModel: '产品1', previewUrl: 'url1', createdAt: new Date() },
|
||
]
|
||
|
||
prisma.uploadTemplate.findUnique.mockResolvedValue(mockTemplate)
|
||
prisma.material.findMany.mockResolvedValue(mockMaterials)
|
||
|
||
const response = await getTemplate(
|
||
new NextRequest('http://localhost'),
|
||
{ params: { id: 'template-1' } }
|
||
)
|
||
const data = await response.json()
|
||
|
||
expect(response.status).toBe(200)
|
||
expect(data.materialCount).toBe(10)
|
||
expect(data.materials).toHaveLength(1)
|
||
})
|
||
|
||
test('应该返回404当模板不存在', async () => {
|
||
prisma.uploadTemplate.findUnique.mockResolvedValue(null)
|
||
|
||
const response = await getTemplate(
|
||
new NextRequest('http://localhost'),
|
||
{ params: { id: 'non-existent' } }
|
||
)
|
||
const data = await response.json()
|
||
|
||
expect(response.status).toBe(404)
|
||
expect(data.errorCode).toBe('NOT_FOUND')
|
||
})
|
||
})
|
||
|
||
describe('PUT /api/admin/upload-templates/[id] - 更新模板', () => {
|
||
test('应该成功更新模板并递增版本号', async () => {
|
||
const existingTemplate = {
|
||
id: 'template-1',
|
||
name: '旧名称',
|
||
version: 1,
|
||
updatedAt: new Date(),
|
||
}
|
||
|
||
const updatedTemplate = {
|
||
id: 'template-1',
|
||
name: '新名称',
|
||
version: 2,
|
||
}
|
||
|
||
prisma.uploadTemplate.findUnique.mockResolvedValue(existingTemplate)
|
||
prisma.$transaction.mockImplementation(async (callback: (tx: any) => Promise<any>) => {
|
||
return callback({
|
||
uploadTemplate: {
|
||
update: jest.fn().mockResolvedValue({ id: 'template-1', version: 2 }),
|
||
},
|
||
})
|
||
})
|
||
prisma.uploadTemplate.findUnique.mockResolvedValueOnce(existingTemplate)
|
||
.mockResolvedValueOnce(updatedTemplate)
|
||
|
||
const request = new NextRequest('http://localhost', {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
name: '新名称',
|
||
version: 1, // 客户端版本
|
||
}),
|
||
})
|
||
|
||
const response = await updateTemplate(request, { params: { id: 'template-1' } })
|
||
const data = await response.json()
|
||
|
||
expect(response.status).toBe(200)
|
||
expect(data.version).toBe(2)
|
||
})
|
||
|
||
test('应该检测版本冲突', async () => {
|
||
const existingTemplate = {
|
||
id: 'template-1',
|
||
name: '模板',
|
||
version: 3, // 服务器版本已更新
|
||
updatedAt: new Date(),
|
||
}
|
||
|
||
prisma.uploadTemplate.findUnique.mockResolvedValue(existingTemplate)
|
||
|
||
const request = new NextRequest('http://localhost', {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
name: '新名称',
|
||
version: 2, // 客户端版本过旧
|
||
}),
|
||
})
|
||
|
||
const response = await updateTemplate(request, { params: { id: 'template-1' } })
|
||
const data = await response.json()
|
||
|
||
expect(response.status).toBe(409)
|
||
expect(data.errorCode).toBe('VERSION_CONFLICT')
|
||
expect(data.details.clientVersion).toBe(2)
|
||
expect(data.details.serverVersion).toBe(3)
|
||
})
|
||
|
||
test('应该验证字段key唯一性', async () => {
|
||
const existingTemplate = {
|
||
id: 'template-1',
|
||
version: 1,
|
||
updatedAt: new Date(),
|
||
}
|
||
|
||
prisma.uploadTemplate.findUnique.mockResolvedValue(existingTemplate)
|
||
|
||
const request = new NextRequest('http://localhost', {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
version: 1,
|
||
fields: [
|
||
{ key: 'field1', label: '字段1', type: 'text' },
|
||
{ key: 'field1', label: '字段2', type: 'text' },
|
||
],
|
||
}),
|
||
})
|
||
|
||
const response = await updateTemplate(request, { params: { id: 'template-1' } })
|
||
const data = await response.json()
|
||
|
||
expect(response.status).toBe(400)
|
||
expect(data.errorCode).toBe('VALIDATION_ERROR')
|
||
})
|
||
})
|
||
|
||
describe('DELETE /api/admin/upload-templates/[id] - 删除模板', () => {
|
||
test('应该成功删除未使用的模板', async () => {
|
||
const mockTemplate = {
|
||
id: 'template-1',
|
||
name: '测试模板',
|
||
_count: { materials: 0 }, // 没有素材使用
|
||
}
|
||
|
||
prisma.uploadTemplate.findUnique.mockResolvedValue(mockTemplate)
|
||
prisma.uploadTemplate.delete.mockResolvedValue(mockTemplate)
|
||
|
||
const response = await deleteTemplate(
|
||
new NextRequest('http://localhost'),
|
||
{ params: { id: 'template-1' } }
|
||
)
|
||
const data = await response.json()
|
||
|
||
expect(response.status).toBe(200)
|
||
expect(data.success).toBe(true)
|
||
expect(syncFieldPoolFromTemplates).toHaveBeenCalled()
|
||
})
|
||
|
||
test('应该阻止删除正在使用的模板', async () => {
|
||
const mockTemplate = {
|
||
id: 'template-1',
|
||
name: '使用中的模板',
|
||
_count: { materials: 5 }, // 有5个素材使用
|
||
}
|
||
|
||
prisma.uploadTemplate.findUnique.mockResolvedValue(mockTemplate)
|
||
|
||
const response = await deleteTemplate(
|
||
new NextRequest('http://localhost'),
|
||
{ params: { id: 'template-1' } }
|
||
)
|
||
const data = await response.json()
|
||
|
||
expect(response.status).toBe(400)
|
||
expect(data.errorCode).toBe('TEMPLATE_IN_USE')
|
||
expect(data.details.materialCount).toBe(5)
|
||
expect(prisma.uploadTemplate.delete).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('应该返回404当模板不存在', async () => {
|
||
prisma.uploadTemplate.findUnique.mockResolvedValue(null)
|
||
|
||
const response = await deleteTemplate(
|
||
new NextRequest('http://localhost'),
|
||
{ params: { id: 'non-existent' } }
|
||
)
|
||
const data = await response.json()
|
||
|
||
expect(response.status).toBe(404)
|
||
expect(data.error).toContain('不存在')
|
||
})
|
||
})
|
||
})
|