1158 lines
36 KiB
TypeScript
1158 lines
36 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(),
|
||
}))
|
||
|
||
jest.mock('@/lib/license', () => ({
|
||
canUseCustomTemplates: jest.fn(),
|
||
}))
|
||
|
||
jest.mock('@/lib/admin-permissions', () => ({
|
||
isCurrentSuperAdmin: jest.fn(),
|
||
}))
|
||
|
||
const { getServerSession } = require('next-auth')
|
||
const { prisma } = require('@/lib/prisma')
|
||
const { syncFieldPoolFromTemplates } = require('@/lib/field-pool')
|
||
const { canUseCustomTemplates } = require('@/lib/license')
|
||
const { isCurrentSuperAdmin } = require('@/lib/admin-permissions')
|
||
|
||
function makeTemplateTransactionMock(overrides: any = {}) {
|
||
return {
|
||
uploadTemplate: {
|
||
...prisma.uploadTemplate,
|
||
...(overrides.uploadTemplate || {}),
|
||
},
|
||
uploadField: {
|
||
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||
update: jest.fn(),
|
||
create: jest.fn(),
|
||
...(overrides.uploadField || {}),
|
||
},
|
||
...(overrides.rest || {}),
|
||
}
|
||
}
|
||
|
||
// 导入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.resetAllMocks()
|
||
// 默认模拟管理员会话
|
||
getServerSession.mockResolvedValue({
|
||
user: { id: 'admin-id', role: 'ADMIN' }
|
||
})
|
||
canUseCustomTemplates.mockResolvedValue(true)
|
||
isCurrentSuperAdmin.mockResolvedValue(true)
|
||
prisma.$transaction.mockImplementation(async (callback: (tx: any) => Promise<any>) =>
|
||
callback(makeTemplateTransactionMock())
|
||
)
|
||
})
|
||
|
||
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 () => {
|
||
isCurrentSuperAdmin.mockResolvedValue(false)
|
||
|
||
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(403)
|
||
expect(data.error).toContain('最高权限')
|
||
expect(canUseCustomTemplates).not.toHaveBeenCalled()
|
||
expect(prisma.uploadTemplate.create).not.toHaveBeenCalled()
|
||
expect(syncFieldPoolFromTemplates).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('许可证关闭时应拒绝创建模板', async () => {
|
||
canUseCustomTemplates.mockResolvedValue(false)
|
||
|
||
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(403)
|
||
expect(data.errorCode).toBe('LICENSE_REQUIRED')
|
||
expect(prisma.uploadTemplate.create).not.toHaveBeenCalled()
|
||
})
|
||
|
||
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('创建模板时字段池同步应复用同一个事务', async () => {
|
||
const tx = makeTemplateTransactionMock({
|
||
uploadTemplate: {
|
||
create: jest.fn().mockResolvedValue({
|
||
id: 'new-template-id',
|
||
name: '新模板',
|
||
materialType: '3DMAX',
|
||
materialKind: 'IMAGE',
|
||
status: 'draft',
|
||
version: 1,
|
||
}),
|
||
},
|
||
})
|
||
prisma.$transaction.mockImplementationOnce(async (callback: (tx: any) => Promise<any>) =>
|
||
callback(tx)
|
||
)
|
||
|
||
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)
|
||
|
||
expect(response.status).toBe(201)
|
||
expect(tx.uploadTemplate.create).toHaveBeenCalled()
|
||
expect(syncFieldPoolFromTemplates).toHaveBeenCalledWith(tx)
|
||
})
|
||
|
||
test('应该允许创建音频模板', async () => {
|
||
const mockTemplate = {
|
||
id: 'audio-template-id',
|
||
name: '音频模板',
|
||
materialType: '音频',
|
||
materialKind: 'AUDIO',
|
||
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: '音频',
|
||
materialKind: 'AUDIO',
|
||
fields: [],
|
||
}),
|
||
})
|
||
|
||
const response = await createTemplate(request)
|
||
const data = await response.json()
|
||
|
||
expect(response.status).toBe(201)
|
||
expect(data.materialKind).toBe('AUDIO')
|
||
})
|
||
|
||
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('应该验证模板状态', async () => {
|
||
const request = new NextRequest('http://localhost/api/admin/upload-templates', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
name: '测试模板',
|
||
status: 'published',
|
||
materialType: '3DMAX',
|
||
materialKind: 'IMAGE',
|
||
fields: [],
|
||
}),
|
||
})
|
||
|
||
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('status')
|
||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||
})
|
||
|
||
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('应该拒绝必填单选字段缺少选项', async () => {
|
||
const request = new NextRequest('http://localhost/api/admin/upload-templates', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
name: '测试模板',
|
||
materialType: '3DMAX',
|
||
materialKind: 'IMAGE',
|
||
fields: [
|
||
{ key: 'usage', label: '用途', type: 'radio', required: true, optionsStatic: [] },
|
||
],
|
||
}),
|
||
})
|
||
|
||
const response = await createTemplate(request)
|
||
const data = await response.json()
|
||
|
||
expect(response.status).toBe(400)
|
||
expect(data.errorCode).toBe('VALIDATION_ERROR')
|
||
expect(data.details.fieldKey).toBe('usage')
|
||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('应该拒绝引用不存在字段的条件配置', async () => {
|
||
const request = new NextRequest('http://localhost/api/admin/upload-templates', {
|
||
method: 'POST',
|
||
body: JSON.stringify({
|
||
name: '测试模板',
|
||
materialType: '3DMAX',
|
||
materialKind: 'IMAGE',
|
||
fields: [
|
||
{ key: 'productModel', label: '型号', type: 'text' },
|
||
{
|
||
key: 'skuCode',
|
||
label: 'SKU',
|
||
type: 'text',
|
||
conditions: {
|
||
logic: 'AND',
|
||
conditions: [{ fieldKey: 'missingField', operator: 'notEmpty' }],
|
||
},
|
||
},
|
||
],
|
||
}),
|
||
})
|
||
|
||
const response = await createTemplate(request)
|
||
const data = await response.json()
|
||
|
||
expect(response.status).toBe(400)
|
||
expect(data.errorCode).toBe('VALIDATION_ERROR')
|
||
expect(data.details.dependencyKey).toBe('missingField')
|
||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||
})
|
||
|
||
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 () => {
|
||
isCurrentSuperAdmin.mockResolvedValue(false)
|
||
|
||
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(403)
|
||
expect(data.error).toContain('最高权限')
|
||
expect(canUseCustomTemplates).not.toHaveBeenCalled()
|
||
expect(prisma.uploadTemplate.findUnique).not.toHaveBeenCalled()
|
||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('许可证关闭时应拒绝更新模板', async () => {
|
||
canUseCustomTemplates.mockResolvedValue(false)
|
||
|
||
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(403)
|
||
expect(data.errorCode).toBe('LICENSE_REQUIRED')
|
||
expect(prisma.uploadTemplate.findUnique).not.toHaveBeenCalled()
|
||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('应该成功更新模板并递增版本号', async () => {
|
||
const existingTemplate = {
|
||
id: 'template-1',
|
||
name: '旧名称',
|
||
version: 1,
|
||
materialType: '3DMAX',
|
||
materialKind: 'IMAGE',
|
||
updatedAt: new Date(),
|
||
fields: [],
|
||
_count: { materials: 0 },
|
||
}
|
||
|
||
const updatedTemplate = {
|
||
id: 'template-1',
|
||
name: '新名称',
|
||
version: 2,
|
||
}
|
||
|
||
prisma.uploadTemplate.findUnique.mockResolvedValue(existingTemplate)
|
||
prisma.$transaction.mockImplementation(async (callback: (tx: any) => Promise<any>) => {
|
||
return callback(makeTemplateTransactionMock({
|
||
uploadTemplate: {
|
||
update: jest.fn().mockResolvedValue({ id: 'template-1', version: 2 }),
|
||
findUnique: jest.fn().mockResolvedValue(updatedTemplate),
|
||
},
|
||
}))
|
||
})
|
||
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: 1,
|
||
materialType: '3DMAX',
|
||
materialKind: 'IMAGE',
|
||
updatedAt: new Date(),
|
||
fields: [],
|
||
_count: { materials: 0 },
|
||
}
|
||
const tx = makeTemplateTransactionMock({
|
||
uploadTemplate: {
|
||
update: jest.fn().mockResolvedValue({ id: 'template-1', version: 2 }),
|
||
findUnique: jest.fn().mockResolvedValue({
|
||
id: 'template-1',
|
||
name: '新名称',
|
||
version: 2,
|
||
}),
|
||
},
|
||
})
|
||
|
||
prisma.uploadTemplate.findUnique.mockResolvedValue(existingTemplate)
|
||
prisma.$transaction.mockImplementationOnce(async (callback: (tx: any) => Promise<any>) =>
|
||
callback(tx)
|
||
)
|
||
|
||
const request = new NextRequest('http://localhost', {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
name: '新名称',
|
||
version: 1,
|
||
}),
|
||
})
|
||
|
||
const response = await updateTemplate(request, { params: { id: 'template-1' } })
|
||
|
||
expect(response.status).toBe(200)
|
||
expect(tx.uploadTemplate.update).toHaveBeenCalled()
|
||
expect(syncFieldPoolFromTemplates).toHaveBeenCalledWith(tx)
|
||
})
|
||
|
||
test('应该允许将模板更新为音频大类', async () => {
|
||
prisma.uploadTemplate.findUnique.mockResolvedValueOnce({
|
||
id: 'template-1',
|
||
version: 1,
|
||
name: '测试模板',
|
||
materialType: '音频',
|
||
materialKind: 'AUDIO',
|
||
updatedAt: new Date('2026-03-13T00:00:00.000Z'),
|
||
fields: [],
|
||
_count: { materials: 0 },
|
||
})
|
||
|
||
prisma.$transaction.mockImplementationOnce(async (callback: any) =>
|
||
callback(makeTemplateTransactionMock({
|
||
uploadTemplate: {
|
||
update: jest.fn().mockResolvedValue({ id: 'template-1', version: 2 }),
|
||
findUnique: jest.fn().mockResolvedValue({
|
||
id: 'template-1',
|
||
name: '音频模板',
|
||
materialType: '音频',
|
||
materialKind: 'AUDIO',
|
||
status: 'active',
|
||
version: 2,
|
||
fieldGroups: null,
|
||
displayConfig: null,
|
||
createdAt: new Date('2026-03-13T00:00:00.000Z'),
|
||
updatedAt: new Date('2026-03-13T00:00:01.000Z'),
|
||
fields: [],
|
||
}),
|
||
},
|
||
uploadField: {
|
||
deleteMany: jest.fn().mockResolvedValue({ count: 0 }),
|
||
update: jest.fn(),
|
||
create: jest.fn(),
|
||
},
|
||
}))
|
||
)
|
||
|
||
prisma.uploadTemplate.findUnique.mockResolvedValueOnce({
|
||
id: 'template-1',
|
||
name: '音频模板',
|
||
materialType: '音频',
|
||
materialKind: 'AUDIO',
|
||
status: 'active',
|
||
version: 2,
|
||
fieldGroups: null,
|
||
displayConfig: null,
|
||
createdAt: new Date('2026-03-13T00:00:00.000Z'),
|
||
updatedAt: new Date('2026-03-13T00:00:01.000Z'),
|
||
fields: [],
|
||
})
|
||
|
||
const request = new NextRequest('http://localhost/api/admin/upload-templates/template-1', {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
name: '音频模板',
|
||
materialType: '音频',
|
||
materialKind: 'AUDIO',
|
||
version: 1,
|
||
fields: [],
|
||
}),
|
||
})
|
||
|
||
const response = await updateTemplate(request, { params: { id: 'template-1' } })
|
||
const data = await response.json()
|
||
|
||
expect(response.status).toBe(200)
|
||
expect(data.materialKind).toBe('AUDIO')
|
||
})
|
||
|
||
test('应该检测版本冲突', async () => {
|
||
const existingTemplate = {
|
||
id: 'template-1',
|
||
name: '模板',
|
||
version: 3, // 服务器版本已更新
|
||
materialType: '3DMAX',
|
||
materialKind: 'IMAGE',
|
||
updatedAt: new Date(),
|
||
fields: [],
|
||
_count: { materials: 0 },
|
||
}
|
||
|
||
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,
|
||
name: '模板',
|
||
materialType: '3DMAX',
|
||
materialKind: 'IMAGE',
|
||
updatedAt: new Date(),
|
||
fields: [],
|
||
_count: { materials: 0 },
|
||
}
|
||
|
||
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')
|
||
})
|
||
|
||
test('应该拒绝循环依赖的字段条件', async () => {
|
||
const existingTemplate = {
|
||
id: 'template-1',
|
||
version: 1,
|
||
name: '模板',
|
||
materialType: '3DMAX',
|
||
materialKind: 'IMAGE',
|
||
updatedAt: new Date(),
|
||
fields: [],
|
||
_count: { materials: 0 },
|
||
}
|
||
|
||
prisma.uploadTemplate.findUnique.mockResolvedValue(existingTemplate)
|
||
|
||
const request = new NextRequest('http://localhost', {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
version: 1,
|
||
fields: [
|
||
{
|
||
key: 'fieldA',
|
||
label: '字段A',
|
||
type: 'text',
|
||
conditions: {
|
||
logic: 'AND',
|
||
conditions: [{ fieldKey: 'fieldB', operator: 'notEmpty' }],
|
||
},
|
||
},
|
||
{
|
||
key: 'fieldB',
|
||
label: '字段B',
|
||
type: 'text',
|
||
conditions: {
|
||
logic: 'AND',
|
||
conditions: [{ fieldKey: 'fieldA', operator: 'notEmpty' }],
|
||
},
|
||
},
|
||
],
|
||
}),
|
||
})
|
||
|
||
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')
|
||
expect(data.details.circularFields.length).toBeGreaterThan(0)
|
||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('已被素材使用的模板不应允许删除字段', async () => {
|
||
prisma.uploadTemplate.findUnique.mockResolvedValue({
|
||
id: 'template-1',
|
||
version: 1,
|
||
name: '使用中的模板',
|
||
materialType: '3DMAX',
|
||
materialKind: 'IMAGE',
|
||
updatedAt: new Date('2026-03-13T00:00:00.000Z'),
|
||
fields: [
|
||
{ id: 'field-1', key: 'productModel', type: 'text', label: 'SPU型号' },
|
||
{ id: 'field-2', key: 'customField', type: 'text', label: '自定义字段' },
|
||
],
|
||
_count: { materials: 3 },
|
||
})
|
||
|
||
const request = new NextRequest('http://localhost', {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
version: 1,
|
||
fields: [
|
||
{ id: 'field-1', key: 'productModel', label: 'SPU型号', type: 'text' },
|
||
],
|
||
}),
|
||
})
|
||
|
||
const response = await updateTemplate(request, { params: { id: 'template-1' } })
|
||
const data = await response.json()
|
||
|
||
expect(response.status).toBe(409)
|
||
expect(data.errorCode).toBe('TEMPLATE_STRUCTURE_LOCKED')
|
||
expect(data.details.materialCount).toBe(3)
|
||
expect(data.details.dangerousChanges[0].type).toBe('fieldDeleted')
|
||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('已被素材使用的模板不应允许修改字段 key', async () => {
|
||
prisma.uploadTemplate.findUnique.mockResolvedValue({
|
||
id: 'template-1',
|
||
version: 1,
|
||
name: '使用中的模板',
|
||
materialType: '3DMAX',
|
||
materialKind: 'IMAGE',
|
||
updatedAt: new Date('2026-03-13T00:00:00.000Z'),
|
||
fields: [
|
||
{ id: 'field-1', key: 'customField', type: 'text', label: '自定义字段' },
|
||
],
|
||
_count: { materials: 2 },
|
||
})
|
||
|
||
const request = new NextRequest('http://localhost', {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
version: 1,
|
||
fields: [
|
||
{ id: 'field-1', key: 'renamedField', label: '自定义字段', type: 'text' },
|
||
],
|
||
}),
|
||
})
|
||
|
||
const response = await updateTemplate(request, { params: { id: 'template-1' } })
|
||
const data = await response.json()
|
||
|
||
expect(response.status).toBe(409)
|
||
expect(data.errorCode).toBe('TEMPLATE_STRUCTURE_LOCKED')
|
||
expect(data.details.dangerousChanges[0].type).toBe('fieldKeyChanged')
|
||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('已被素材使用的模板不应允许取消字段加入分类', async () => {
|
||
prisma.uploadTemplate.findUnique.mockResolvedValue({
|
||
id: 'template-1',
|
||
version: 1,
|
||
name: '使用中的模板',
|
||
materialType: '3DMAX',
|
||
materialKind: 'IMAGE',
|
||
updatedAt: new Date('2026-03-13T00:00:00.000Z'),
|
||
fields: [
|
||
{ id: 'field-1', key: 'brand', type: 'select', label: '品牌', addToCategory: true },
|
||
],
|
||
_count: { materials: 2 },
|
||
})
|
||
|
||
const request = new NextRequest('http://localhost', {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
version: 1,
|
||
fields: [
|
||
{ id: 'field-1', key: 'brand', label: '品牌', type: 'select', addToCategory: false },
|
||
],
|
||
}),
|
||
})
|
||
|
||
const response = await updateTemplate(request, { params: { id: 'template-1' } })
|
||
const data = await response.json()
|
||
|
||
expect(response.status).toBe(409)
|
||
expect(data.errorCode).toBe('TEMPLATE_STRUCTURE_LOCKED')
|
||
expect(data.details.dangerousChanges[0].type).toBe('fieldCategoryDisabled')
|
||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('已被素材使用的模板不应允许关闭字段搜索', async () => {
|
||
prisma.uploadTemplate.findUnique.mockResolvedValue({
|
||
id: 'template-1',
|
||
version: 1,
|
||
name: '使用中的模板',
|
||
materialType: '3DMAX',
|
||
materialKind: 'IMAGE',
|
||
updatedAt: new Date('2026-03-13T00:00:00.000Z'),
|
||
fields: [
|
||
{ id: 'field-1', key: 'seriesName', type: 'text', label: '系列', addToCategory: false, isSearchable: true, isReadOnly: false },
|
||
],
|
||
_count: { materials: 2 },
|
||
})
|
||
|
||
const request = new NextRequest('http://localhost', {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
version: 1,
|
||
fields: [
|
||
{ id: 'field-1', key: 'seriesName', label: '系列', type: 'text', isSearchable: false, isReadOnly: false },
|
||
],
|
||
}),
|
||
})
|
||
|
||
const response = await updateTemplate(request, { params: { id: 'template-1' } })
|
||
const data = await response.json()
|
||
|
||
expect(response.status).toBe(409)
|
||
expect(data.errorCode).toBe('TEMPLATE_STRUCTURE_LOCKED')
|
||
expect(data.details.dangerousChanges[0].type).toBe('fieldSearchDisabled')
|
||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('已被素材使用的模板不应允许把可填写字段改成只读', async () => {
|
||
prisma.uploadTemplate.findUnique.mockResolvedValue({
|
||
id: 'template-1',
|
||
version: 1,
|
||
name: '使用中的模板',
|
||
materialType: '3DMAX',
|
||
materialKind: 'IMAGE',
|
||
updatedAt: new Date('2026-03-13T00:00:00.000Z'),
|
||
fields: [
|
||
{ id: 'field-1', key: 'productModel', type: 'text', label: '型号', addToCategory: false, isSearchable: false, isReadOnly: false },
|
||
],
|
||
_count: { materials: 2 },
|
||
})
|
||
|
||
const request = new NextRequest('http://localhost', {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
version: 1,
|
||
fields: [
|
||
{ id: 'field-1', key: 'productModel', label: '型号', type: 'text', isSearchable: false, isReadOnly: true },
|
||
],
|
||
}),
|
||
})
|
||
|
||
const response = await updateTemplate(request, { params: { id: 'template-1' } })
|
||
const data = await response.json()
|
||
|
||
expect(response.status).toBe(409)
|
||
expect(data.errorCode).toBe('TEMPLATE_STRUCTURE_LOCKED')
|
||
expect(data.details.dangerousChanges[0].type).toBe('fieldMadeReadOnly')
|
||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('已被素材使用的模板不应允许修改素材大类', async () => {
|
||
prisma.uploadTemplate.findUnique.mockResolvedValue({
|
||
id: 'template-1',
|
||
version: 1,
|
||
name: '音频模板',
|
||
materialType: '音频',
|
||
materialKind: 'AUDIO',
|
||
updatedAt: new Date('2026-03-13T00:00:00.000Z'),
|
||
fields: [
|
||
{ id: 'field-1', key: 'productModel', type: 'text', label: '音频名称' },
|
||
],
|
||
_count: { materials: 4 },
|
||
})
|
||
|
||
const request = new NextRequest('http://localhost', {
|
||
method: 'PUT',
|
||
body: JSON.stringify({
|
||
version: 1,
|
||
materialKind: 'IMAGE',
|
||
fields: [
|
||
{ id: 'field-1', key: 'productModel', label: '音频名称', type: 'text' },
|
||
],
|
||
}),
|
||
})
|
||
|
||
const response = await updateTemplate(request, { params: { id: 'template-1' } })
|
||
const data = await response.json()
|
||
|
||
expect(response.status).toBe(409)
|
||
expect(data.errorCode).toBe('TEMPLATE_STRUCTURE_LOCKED')
|
||
expect(data.details.dangerousChanges[0].type).toBe('materialKindChanged')
|
||
expect(prisma.$transaction).not.toHaveBeenCalled()
|
||
})
|
||
})
|
||
|
||
describe('DELETE /api/admin/upload-templates/[id] - 删除模板', () => {
|
||
test('普通管理员不能删除模板', async () => {
|
||
isCurrentSuperAdmin.mockResolvedValue(false)
|
||
|
||
const response = await deleteTemplate(
|
||
new NextRequest('http://localhost'),
|
||
{ params: { id: 'template-1' } }
|
||
)
|
||
const data = await response.json()
|
||
|
||
expect(response.status).toBe(403)
|
||
expect(data.error).toContain('最高权限')
|
||
expect(canUseCustomTemplates).not.toHaveBeenCalled()
|
||
expect(prisma.uploadTemplate.findUnique).not.toHaveBeenCalled()
|
||
expect(prisma.uploadTemplate.delete).not.toHaveBeenCalled()
|
||
})
|
||
|
||
test('许可证关闭时应拒绝删除模板', async () => {
|
||
canUseCustomTemplates.mockResolvedValue(false)
|
||
|
||
const response = await deleteTemplate(
|
||
new NextRequest('http://localhost'),
|
||
{ params: { id: 'template-1' } }
|
||
)
|
||
const data = await response.json()
|
||
|
||
expect(response.status).toBe(403)
|
||
expect(data.errorCode).toBe('LICENSE_REQUIRED')
|
||
expect(prisma.uploadTemplate.findUnique).not.toHaveBeenCalled()
|
||
expect(prisma.uploadTemplate.delete).not.toHaveBeenCalled()
|
||
})
|
||
|
||
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: 0 },
|
||
}
|
||
const tx = makeTemplateTransactionMock({
|
||
uploadTemplate: {
|
||
delete: jest.fn().mockResolvedValue(mockTemplate),
|
||
},
|
||
})
|
||
|
||
prisma.uploadTemplate.findUnique.mockResolvedValue(mockTemplate)
|
||
prisma.$transaction.mockImplementationOnce(async (callback: (tx: any) => Promise<any>) =>
|
||
callback(tx)
|
||
)
|
||
|
||
const response = await deleteTemplate(
|
||
new NextRequest('http://localhost'),
|
||
{ params: { id: 'template-1' } }
|
||
)
|
||
|
||
expect(response.status).toBe(200)
|
||
expect(tx.uploadTemplate.delete).toHaveBeenCalledWith({ where: { id: 'template-1' } })
|
||
expect(syncFieldPoolFromTemplates).toHaveBeenCalledWith(tx)
|
||
})
|
||
|
||
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('不存在')
|
||
})
|
||
})
|
||
})
|