2026-05-28 15:50:54 +08:00

1158 lines
36 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 模板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('不存在')
})
})
})