flnxi-Wu/app/api/materials/__tests__/template-fields-routes.test.ts
2026-06-01 11:40:02 +08:00

1395 lines
43 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.

import { NextRequest } from 'next/server'
import { parseMetadata } from '@/lib/field-data-storage'
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
user: {
findUnique: jest.fn(),
},
department: {
findMany: jest.fn(),
},
material: {
findUnique: jest.fn(),
findFirst: jest.fn(),
create: jest.fn(),
update: jest.fn(),
},
},
}))
jest.mock('@/lib/material-log', () => ({
createMaterialLog: jest.fn(),
}))
jest.mock('@/lib/s3', () => ({
uploadToS3: jest.fn(),
uploadStreamToS3: jest.fn(),
deleteFromS3: jest.fn(),
isS3Url: jest.fn(() => true),
}))
jest.mock('@/lib/image-processor', () => ({
processImage: jest.fn(),
getProcessedFileName: jest.fn((name: string) => name),
sliceImage: jest.fn(),
}))
jest.mock('@/lib/audio-processor', () => ({
parseAudioMetadata: jest.fn(),
parseAudioMetadataFromFile: jest.fn(),
isAudioFile: jest.fn(() => false),
}))
jest.mock('@/lib/license', () => ({
canUploadMaterial: jest.fn(),
}))
jest.mock('@/lib/template-validator', () => ({
validateTemplateId: jest.fn(),
getTemplateWithFields: jest.fn(),
}))
const { getServerSession } = require('next-auth')
const { prisma } = require('@/lib/prisma')
const { createMaterialLog } = require('@/lib/material-log')
const { uploadToS3, uploadStreamToS3 } = require('@/lib/s3')
const { parseAudioMetadata, parseAudioMetadataFromFile, isAudioFile } = require('@/lib/audio-processor')
const { canUploadMaterial } = require('@/lib/license')
const { validateTemplateId, getTemplateWithFields } = require('@/lib/template-validator')
const { processImage, getProcessedFileName, sliceImage } = require('@/lib/image-processor')
import { POST as uploadMaterial } from '../upload/route'
import { PUT as updateMaterial } from '../update/route'
import { GET as getMaterial, PUT as updateMaterialById } from '../[id]/route'
type MockFormDataValue = FormDataEntryValue | null | undefined
function createTemplateField(key: string, required = false) {
return {
id: `field-${key}`,
key,
label: key === 'productModel' ? '产品型号' : '自定义编码',
type: 'text',
required,
}
}
function createConditionalTemplateDefinition() {
return {
id: 'template-1',
name: '条件模板',
fields: [
createTemplateField('productModel', true),
{
...createTemplateField('conditionalCode', true),
label: '条件编码',
conditions: JSON.stringify({
logic: 'AND',
conditions: [{ fieldKey: 'productModel', operator: 'equals', value: '需要编码' }],
}),
},
],
}
}
function createUploadTemplateDefinition() {
return {
id: 'template-1',
name: '测试模板',
fields: [
createTemplateField('productModel', true),
createTemplateField('customCode', true),
],
}
}
function createMockRequest(
values: Record<string, MockFormDataValue>,
lists: Record<string, unknown[]> = {}
) {
const formData = {
get: (key: string) => values[key] ?? null,
getAll: (key: string) => lists[key] ?? [],
}
return {
formData: async () => formData,
} as any
}
async function createMultipartUploadRequest(
fields: Record<string, string>,
files: Record<string, File | File[]>
) {
const boundary = `----flnxi-test-${Date.now()}`
const chunks: Buffer[] = []
const append = (value: string | Buffer) => {
chunks.push(typeof value === 'string' ? Buffer.from(value) : value)
}
Object.entries(fields).forEach(([key, value]) => {
append(`--${boundary}\r\n`)
append(`Content-Disposition: form-data; name="${key}"\r\n\r\n`)
append(`${value}\r\n`)
})
for (const [key, fileOrFiles] of Object.entries(files)) {
const fileList = Array.isArray(fileOrFiles) ? fileOrFiles : [fileOrFiles]
for (const file of fileList) {
append(`--${boundary}\r\n`)
append(`Content-Disposition: form-data; name="${key}"; filename="${file.name}"\r\n`)
append(`Content-Type: ${file.type || 'application/octet-stream'}\r\n\r\n`)
append(Buffer.from(await file.arrayBuffer()))
append('\r\n')
}
}
append(`--${boundary}--\r\n`)
const body = Buffer.concat(chunks)
return new NextRequest('http://localhost/api/materials/upload', {
method: 'POST',
headers: {
'content-type': `multipart/form-data; boundary=${boundary}`,
'content-length': String(body.length),
},
body,
})
}
function buildUploadRequest(overrides: Partial<Record<string, MockFormDataValue>> = {}) {
return createMockRequest({
productModel: '稳定材质',
materialType: '材质',
templateId: 'template-1',
...overrides,
})
}
function buildUpdateRequest(overrides: Partial<Record<string, MockFormDataValue>> = {}) {
return createMockRequest({
id: 'material-1',
productModel: '更新后的产品',
categoryName: '店铺A',
sceneName: '场景A',
materialType: '3DMAX',
renderer: 'CR',
authorName: '管理员',
...overrides,
})
}
function createMockFile(name: string, contentType: string, content = 'file-content'): File {
return new File([Buffer.from(content)], name, { type: contentType })
}
describe('素材模板字段链路测试', () => {
beforeEach(() => {
jest.resetAllMocks()
getServerSession.mockResolvedValue({
user: { id: 'admin-1', role: 'ADMIN' },
})
canUploadMaterial.mockResolvedValue({
allowed: true,
})
prisma.user.findUnique.mockImplementation(async ({ where }: any = {}) => {
const id = where?.id || 'admin-1'
const isAdmin = id === 'admin-1'
return {
id,
role: isAdmin ? 'ADMIN' : 'USER',
isSuperAdmin: false,
isActive: true,
canDownload: true,
department: isAdmin ? '设计部' : '运营部',
managedDepartment: isAdmin ? '设计部' : null,
username: '上传者A',
}
})
prisma.department.findMany.mockResolvedValue([
{ name: '设计部' },
{ name: '设计部-建模组' },
{ name: '运营部' },
])
prisma.material.findUnique.mockResolvedValue(null)
prisma.material.findFirst.mockResolvedValue(null)
prisma.material.create.mockImplementation(async ({ data }: any) => ({
id: data.id,
...data,
zipFileSize: data.zipFileSize ?? BigInt(0),
user: {
username: '上传者A',
},
}))
prisma.material.update.mockImplementation(async ({ where, data }: any) => ({
id: where.id,
...data,
zipFileSize: BigInt(8),
user: {
username: '管理员',
},
}))
createMaterialLog.mockResolvedValue(undefined)
getProcessedFileName.mockImplementation((name: string) => name)
uploadToS3.mockResolvedValue('s3://bucket/buffer-upload')
uploadStreamToS3.mockResolvedValue('s3://bucket/stream-upload')
parseAudioMetadata.mockResolvedValue(null)
parseAudioMetadataFromFile.mockResolvedValue(null)
isAudioFile.mockImplementation((mimeType: string) => mimeType.startsWith('audio/'))
validateTemplateId.mockResolvedValue({
valid: true,
isBuiltin: false,
templateName: '测试模板',
templateStatus: 'active',
})
getTemplateWithFields.mockResolvedValue(createUploadTemplateDefinition())
})
describe('POST /api/materials/upload', () => {
test('templateFields 非法 JSON 时应直接拒绝', async () => {
const response = await uploadMaterial(
buildUploadRequest({
templateFields: '{"broken"',
})
)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.errorCode).toBe('INVALID_TEMPLATE_FIELDS')
expect(getTemplateWithFields).not.toHaveBeenCalled()
expect(prisma.material.create).not.toHaveBeenCalled()
})
test('缺失模板必填字段时应拒绝上传', async () => {
const response = await uploadMaterial(buildUploadRequest())
const data = await response.json()
expect(response.status).toBe(400)
expect(data.errorCode).toBe('TEMPLATE_REQUIRED_FIELDS_MISSING')
expect(prisma.material.create).not.toHaveBeenCalled()
expect(validateTemplateId).toHaveBeenCalledWith(
'template-1',
expect.objectContaining({
checkStatus: true,
logPerformance: true,
})
)
expect(uploadToS3).not.toHaveBeenCalled()
expect(uploadStreamToS3).not.toHaveBeenCalled()
})
test('上传成功时应写入 templateId 和自定义字段 metadata', async () => {
const response = await uploadMaterial(
buildUploadRequest({
templateFields: JSON.stringify({
productModel: '稳定材质',
customCode: 'CODE-001',
}),
})
)
const data = await response.json()
expect(response.status).toBe(201)
expect(validateTemplateId).toHaveBeenCalledWith(
'template-1',
expect.objectContaining({
checkStatus: true,
logPerformance: true,
})
)
const createArgs = prisma.material.create.mock.calls[0][0]
const storedMetadata = parseMetadata(createArgs.data.metadata)
expect(createArgs.data.templateId).toBe('template-1')
expect(storedMetadata.templateId).toBe('template-1')
expect(storedMetadata.templateFields).toEqual({
customCode: 'CODE-001',
})
expect(data.material.templateId).toBe('template-1')
expect(data.material.zipFileSize).toBe('0')
})
test('模板不可用时应在上传文件前拒绝', async () => {
const zipFile = createMockFile('inactive-template.zip', 'application/zip', 'zip-data')
validateTemplateId.mockResolvedValueOnce({
valid: false,
error: '模板已停用',
errorCode: 'TEMPLATE_INACTIVE',
templateStatus: 'inactive',
})
const response = await uploadMaterial(
buildUploadRequest({
zipFile,
templateFields: JSON.stringify({
productModel: '稳定材质',
customCode: 'CODE-INACTIVE',
}),
})
)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('模板已停用')
expect(data.errorCode).toBe('TEMPLATE_INACTIVE')
expect(getTemplateWithFields).not.toHaveBeenCalled()
expect(uploadToS3).not.toHaveBeenCalled()
expect(uploadStreamToS3).not.toHaveBeenCalled()
expect(prisma.material.create).not.toHaveBeenCalled()
})
test('预览图处理失败时不向前端暴露底层错误细节', async () => {
processImage.mockRejectedValueOnce(new Error('sharp failed at /private/storage/source.png'))
const response = await uploadMaterial(
buildUploadRequest({
materialType: '3DMAX',
categoryName: '空间',
sceneName: '客厅',
previewImage: createMockFile('preview.png', 'image/png', 'preview-data'),
zipFile: createMockFile('model.zip', 'application/zip', 'zip-data'),
templateFields: JSON.stringify({
productModel: '稳定材质',
customCode: 'CODE-PREVIEW',
}),
})
)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.error).toBe('预览图处理失败,请检查图片格式后重试')
expect(data.error).not.toContain('sharp')
expect(data.details).toBeUndefined()
expect(data.errorType).toBeUndefined()
expect(data.errorStack).toBeUndefined()
expect(prisma.material.create).not.toHaveBeenCalled()
})
test('PSD展示图处理失败时不向前端暴露底层错误细节', async () => {
sliceImage.mockRejectedValueOnce(new Error('sharp failed at /private/storage/display.png'))
const response = await uploadMaterial(
buildUploadRequest({
materialType: 'PSD',
categoryName: '空间',
sceneName: '客厅',
displayImage: createMockFile('display.png', 'image/png', 'display-data'),
zipFile: createMockFile('source.psd', 'application/octet-stream', 'psd-data'),
templateFields: JSON.stringify({
productModel: '稳定材质',
customCode: 'CODE-PSD',
}),
})
)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.error).toBe('PSD展示图片处理失败,请检查图片格式后重试')
expect(data.error).not.toContain('sharp')
expect(data.details).toBeUndefined()
expect(data.errorType).toBeUndefined()
expect(data.errorStack).toBeUndefined()
expect(prisma.material.create).not.toHaveBeenCalled()
})
test('原始文件保存失败时不向前端暴露底层错误细节', async () => {
processImage.mockResolvedValueOnce(Buffer.from('processed-preview'))
uploadStreamToS3.mockRejectedValueOnce(new Error('s3 secret path /private/bucket/source.zip'))
const response = await uploadMaterial(
buildUploadRequest({
materialType: '3DMAX',
categoryName: '空间',
sceneName: '客厅',
previewImage: createMockFile('preview.png', 'image/png', 'preview-data'),
zipFile: createMockFile('model.zip', 'application/zip', 'zip-data'),
templateFields: JSON.stringify({
productModel: '稳定材质',
customCode: 'CODE-FILE',
}),
})
)
const data = await response.json()
expect(response.status).toBe(500)
expect(data.error).toBe('文件保存失败,请检查存储配置后重试')
expect(data.error).not.toContain('private')
expect(data.details).toBeUndefined()
expect(data.errorType).toBeUndefined()
expect(data.errorStack).toBeUndefined()
expect(prisma.material.create).not.toHaveBeenCalled()
})
test('非音频原始素材上传应走流式上传,避免二次整文件 Buffer', async () => {
const zipFile = createMockFile('model.zip', 'application/zip', 'zip-data')
const response = await uploadMaterial(
buildUploadRequest({
zipFile,
templateFields: JSON.stringify({
productModel: '稳定材质',
customCode: 'CODE-STREAM',
}),
})
)
const data = await response.json()
expect(response.status).toBe(201)
expect(uploadStreamToS3).toHaveBeenCalledWith(
expect.any(Object),
expect.stringMatching(/^material-.+\.zip$/),
'application/zip',
zipFile.size
)
expect(uploadToS3).not.toHaveBeenCalled()
const createArgs = prisma.material.create.mock.calls[0][0]
expect(createArgs.data.zipFileSize).toBe(BigInt(zipFile.size))
expect(createArgs.data.zipFilePath).toBe('s3://bucket/stream-upload')
expect(data.material.zipFileSize).toBe(String(zipFile.size))
})
test('音频原始素材应走临时文件流式上传并从文件解析时长', async () => {
const audioFile = createMockFile('demo.mp3', 'audio/mpeg', 'audio-data')
parseAudioMetadataFromFile.mockResolvedValueOnce({
duration: 65,
durationFormatted: '01:05',
format: 'mp3',
})
const response = await uploadMaterial(
await createMultipartUploadRequest(
{
productModel: '稳定材质',
materialType: '音频',
materialKind: 'AUDIO',
categoryName: '音效',
sceneName: '提示音',
templateId: 'template-1',
templateFields: JSON.stringify({
productModel: '稳定材质',
customCode: 'CODE-AUDIO',
}),
},
{
zipFile: audioFile,
}
)
)
expect(response.status).toBe(201)
expect(uploadStreamToS3).toHaveBeenCalledWith(
expect.any(Object),
expect.stringMatching(/^material-.+\.mp3$/),
'audio/mpeg',
audioFile.size
)
expect(uploadToS3).not.toHaveBeenCalled()
expect(parseAudioMetadataFromFile).toHaveBeenCalledWith(expect.stringContaining('flnxi-upload-'))
expect(parseAudioMetadata).not.toHaveBeenCalled()
const createArgs = prisma.material.create.mock.calls[0][0]
expect(createArgs.data.audioDuration).toBe('01:05')
expect(createArgs.data.audioUrl).toBe('s3://bucket/stream-upload')
expect(createArgs.data.zipFileSize).toBe(BigInt(audioFile.size))
})
test('上传时条件不满足的必填字段缺失应允许通过且不写入隐藏字段', async () => {
getTemplateWithFields.mockResolvedValueOnce(createConditionalTemplateDefinition())
const response = await uploadMaterial(
buildUploadRequest({
templateFields: JSON.stringify({
productModel: '稳定材质',
conditionalCode: 'HIDDEN-CODE',
}),
})
)
const data = await response.json()
expect(response.status).toBe(201)
const createArgs = prisma.material.create.mock.calls[0][0]
const storedMetadata = parseMetadata(createArgs.data.metadata)
expect(storedMetadata.templateFields.conditionalCode).toBeUndefined()
expect(data.material.templateId).toBe('template-1')
})
test('上传时条件满足的必填字段缺失应拒绝', async () => {
getTemplateWithFields.mockResolvedValueOnce(createConditionalTemplateDefinition())
const response = await uploadMaterial(
buildUploadRequest({
productModel: '需要编码',
templateFields: JSON.stringify({
productModel: '需要编码',
}),
})
)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.errorCode).toBe('TEMPLATE_REQUIRED_FIELDS_MISSING')
expect(prisma.material.create).not.toHaveBeenCalled()
})
test('自定义字段数据过大时应拒绝上传,不能截坏 metadata JSON', async () => {
const response = await uploadMaterial(
buildUploadRequest({
templateFields: JSON.stringify({
productModel: '稳定材质',
customCode: 'x'.repeat(70000),
}),
})
)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.errorCode).toBe('METADATA_TOO_LARGE')
expect(data.error).toContain('自定义字段数据过大')
expect(prisma.material.create).not.toHaveBeenCalled()
})
})
describe('PUT /api/materials/update', () => {
test('templateFields 非法 JSON 时应直接拒绝更新', async () => {
const response = await updateMaterial(
buildUpdateRequest({
templateFields: '{"broken"',
})
)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.error).toBe('模板字段格式无效')
expect(prisma.material.update).not.toHaveBeenCalled()
})
test('软删除素材不能通过旧表单更新入口继续编辑', async () => {
prisma.material.findFirst.mockResolvedValueOnce(null)
const response = await updateMaterial(
buildUpdateRequest({
id: 'deleted-material',
templateFields: JSON.stringify({
productModel: '更新后的产品',
customCode: 'NOPE',
}),
})
)
const data = await response.json()
expect(response.status).toBe(404)
expect(data.error).toBe('素材不存在')
expect(prisma.material.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
id: 'deleted-material',
deletedAt: null,
},
})
)
expect(prisma.material.update).not.toHaveBeenCalled()
})
test('更新时应保留旧 metadata 字段并覆盖本次提交值', async () => {
prisma.material.findFirst.mockResolvedValueOnce({
id: 'material-1',
userId: 'owner-1',
user: {
id: 'owner-1',
department: '设计部-建模组',
},
previewUrl: 'https://example.com/preview.webp',
productModel: '旧产品',
brandName: null,
seriesName: null,
skuCode: null,
materialTexture: null,
categoryName: '旧店铺',
sceneName: '旧场景',
materialType: '3DMAX',
authorName: '旧作者',
renderer: 'CR',
renderTime: null,
imageResolution: null,
colorMode: null,
audioDuration: null,
audioCategory: null,
audioScene: null,
metadata: JSON.stringify({
templateId: 'template-1',
templateFields: {
legacyKey: 'legacy-value',
customCode: 'OLD-CODE',
},
}),
templateId: 'template-1',
})
const response = await updateMaterial(
buildUpdateRequest({
templateFields: JSON.stringify({
productModel: '更新后的产品',
customCode: 'NEW-CODE',
}),
})
)
const data = await response.json()
expect(response.status).toBe(200)
const updateArgs = prisma.material.update.mock.calls[0][0]
const storedMetadata = parseMetadata(updateArgs.data.metadata)
expect(updateArgs.data.templateId).toBe('template-1')
expect(storedMetadata.templateId).toBe('template-1')
expect(storedMetadata.templateFields).toEqual({
legacyKey: 'legacy-value',
customCode: 'NEW-CODE',
})
expect(data.material.zipFileSize).toBe('8')
expect(createMaterialLog).toHaveBeenCalled()
})
test('上传者本人可以通过表单编辑自己的素材', async () => {
getServerSession.mockResolvedValueOnce({
user: { id: 'owner-1', role: 'USER' },
})
prisma.material.findFirst.mockResolvedValueOnce({
id: 'material-1',
userId: 'owner-1',
previewUrl: 'https://example.com/preview.webp',
productModel: '旧产品',
brandName: null,
seriesName: null,
skuCode: null,
materialTexture: null,
categoryName: '旧店铺',
sceneName: '旧场景',
materialType: '3DMAX',
authorName: '旧作者',
renderer: 'CR',
renderTime: null,
imageResolution: null,
colorMode: null,
audioDuration: null,
audioCategory: null,
audioScene: null,
metadata: JSON.stringify({
templateId: 'template-1',
templateFields: {
customCode: 'OLD-CODE',
},
}),
templateId: 'template-1',
})
const response = await updateMaterial(
buildUpdateRequest({
templateFields: JSON.stringify({
productModel: '更新后的产品',
customCode: 'OWNER-CODE',
}),
})
)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.material.id).toBe('material-1')
expect(prisma.material.update).toHaveBeenCalled()
expect(createMaterialLog).toHaveBeenCalledWith(
expect.objectContaining({
materialId: 'material-1',
userId: 'owner-1',
action: 'UPDATE',
})
)
})
test('管理员可以通过表单编辑管理部门范围内的素材', async () => {
prisma.material.findFirst.mockResolvedValueOnce({
id: 'material-1',
userId: 'owner-1',
user: {
id: 'owner-1',
department: '设计部-建模组',
},
previewUrl: 'https://example.com/preview.webp',
productModel: '旧产品',
brandName: null,
seriesName: null,
skuCode: null,
materialTexture: null,
categoryName: '旧店铺',
sceneName: '旧场景',
materialType: '3DMAX',
authorName: '旧作者',
renderer: 'CR',
renderTime: null,
imageResolution: null,
colorMode: null,
audioDuration: null,
audioCategory: null,
audioScene: null,
metadata: JSON.stringify({
templateId: 'template-1',
templateFields: {
customCode: 'OLD-CODE',
},
}),
templateId: 'template-1',
})
const response = await updateMaterial(
buildUpdateRequest({
templateFields: JSON.stringify({
productModel: '更新后的产品',
customCode: 'ADMIN-CODE',
}),
})
)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.material.id).toBe('material-1')
expect(prisma.material.update).toHaveBeenCalled()
expect(createMaterialLog).toHaveBeenCalledWith(
expect.objectContaining({
materialId: 'material-1',
userId: 'admin-1',
action: 'UPDATE',
})
)
})
test('非上传者普通用户不能通过表单编辑别人的素材', async () => {
getServerSession.mockResolvedValueOnce({
user: { id: 'user-2', role: 'USER' },
})
prisma.material.findFirst.mockResolvedValueOnce({
id: 'material-1',
userId: 'owner-1',
previewUrl: 'https://example.com/preview.webp',
productModel: '旧产品',
brandName: null,
seriesName: null,
skuCode: null,
materialTexture: null,
categoryName: '旧店铺',
sceneName: '旧场景',
materialType: '3DMAX',
authorName: '旧作者',
renderer: 'CR',
renderTime: null,
imageResolution: null,
colorMode: null,
audioDuration: null,
audioCategory: null,
audioScene: null,
metadata: null,
templateId: 'template-1',
})
const response = await updateMaterial(
buildUpdateRequest({
templateFields: JSON.stringify({
productModel: '更新后的产品',
customCode: 'NOPE',
}),
})
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('您没有权限编辑此素材')
expect(getTemplateWithFields).not.toHaveBeenCalled()
expect(prisma.material.update).not.toHaveBeenCalled()
})
test('编辑时缺失模板必填字段应拒绝更新', async () => {
prisma.material.findFirst.mockResolvedValueOnce({
id: 'material-1',
userId: 'owner-1',
user: {
id: 'owner-1',
department: '设计部-建模组',
},
previewUrl: 'https://example.com/preview.webp',
productModel: '旧产品',
brandName: null,
seriesName: null,
skuCode: null,
materialTexture: null,
categoryName: '旧店铺',
sceneName: '旧场景',
materialType: '3DMAX',
authorName: '旧作者',
renderer: 'CR',
renderTime: null,
imageResolution: null,
colorMode: null,
audioDuration: null,
audioCategory: null,
audioScene: null,
metadata: JSON.stringify({
templateId: 'template-1',
templateFields: {
legacyKey: 'legacy-value',
},
}),
templateId: 'template-1',
})
const response = await updateMaterial(
buildUpdateRequest({
templateFields: JSON.stringify({
productModel: '更新后的产品',
}),
})
)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.errorCode).toBe('TEMPLATE_REQUIRED_FIELDS_MISSING')
expect(prisma.material.update).not.toHaveBeenCalled()
})
test('编辑时条件不满足的必填字段缺失应允许通过且保留历史隐藏字段', async () => {
getTemplateWithFields.mockResolvedValueOnce(createConditionalTemplateDefinition())
prisma.material.findFirst.mockResolvedValueOnce({
id: 'material-1',
userId: 'owner-1',
user: {
id: 'owner-1',
department: '设计部-建模组',
},
previewUrl: 'https://example.com/preview.webp',
productModel: '旧产品',
brandName: null,
seriesName: null,
skuCode: null,
materialTexture: null,
categoryName: '旧店铺',
sceneName: '旧场景',
materialType: '3DMAX',
authorName: '旧作者',
renderer: 'CR',
renderTime: null,
imageResolution: null,
colorMode: null,
audioDuration: null,
audioCategory: null,
audioScene: null,
metadata: JSON.stringify({
templateId: 'template-1',
templateFields: {
conditionalCode: 'OLD-HIDDEN-CODE',
},
}),
templateId: 'template-1',
})
const response = await updateMaterial(
buildUpdateRequest({
productModel: '普通产品',
templateFields: JSON.stringify({
productModel: '普通产品',
}),
})
)
expect(response.status).toBe(200)
const updateArgs = prisma.material.update.mock.calls[0][0]
const storedMetadata = parseMetadata(updateArgs.data.metadata)
expect(storedMetadata.templateFields.conditionalCode).toBe('OLD-HIDDEN-CODE')
})
test('编辑时自定义字段数据过大应拒绝更新,不能截坏 metadata JSON', async () => {
prisma.material.findFirst.mockResolvedValueOnce({
id: 'material-1',
userId: 'owner-1',
user: {
id: 'owner-1',
department: '设计部-建模组',
},
previewUrl: 'https://example.com/preview.webp',
productModel: '旧产品',
brandName: null,
seriesName: null,
skuCode: null,
materialTexture: null,
categoryName: '旧店铺',
sceneName: '旧场景',
materialType: '3DMAX',
authorName: '旧作者',
renderer: 'CR',
renderTime: null,
imageResolution: null,
colorMode: null,
audioDuration: null,
audioCategory: null,
audioScene: null,
metadata: JSON.stringify({
templateId: 'template-1',
templateFields: {},
}),
templateId: 'template-1',
})
const response = await updateMaterial(
buildUpdateRequest({
templateFields: JSON.stringify({
productModel: '更新后的产品',
customCode: '测'.repeat(40000),
}),
})
)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.errorCode).toBe('METADATA_TOO_LARGE')
expect(prisma.material.update).not.toHaveBeenCalled()
})
})
describe('GET /api/materials/[id]', () => {
test('未登录时应拒绝读取素材详情', async () => {
getServerSession.mockResolvedValueOnce(null)
const response = await getMaterial(new NextRequest('http://localhost'), {
params: { id: 'material-1' },
})
const data = await response.json()
expect(response.status).toBe(401)
expect(data.error).toBe('请先登录')
expect(prisma.material.findFirst).not.toHaveBeenCalled()
})
test('读取素材详情时应回传 metadata 中的模板字段', async () => {
prisma.material.findFirst.mockResolvedValueOnce({
id: 'material-1',
userId: 'admin-1',
productModel: '已保存产品',
materialKind: 'IMAGE',
brandName: null,
seriesName: null,
skuCode: null,
materialTexture: null,
categoryName: '店铺A',
sceneName: '场景A',
platformName: '',
materialType: '3DMAX',
renderer: 'CR',
renderTime: null,
imageResolution: null,
colorMode: null,
authorName: '管理员',
previewUrl: 'https://example.com/preview.webp',
videoCoverUrl: null,
videoPreview540Url: null,
videoPreviewClipUrl: null,
videoProcessingStatus: null,
videoDuration: null,
zipFileName: '',
zipFileSize: BigInt(16),
downloadCount: 0,
likeCount: 0,
displayImages: '[]',
metadata: JSON.stringify({
templateId: 'template-1',
templateFields: {
customCode: 'READ-CODE',
legacyKey: 'legacy-value',
},
}),
createdAt: new Date('2026-03-13T10:00:00.000Z'),
templateId: 'template-1',
audioDuration: null,
audioCategory: null,
audioScene: null,
audioUrl: null,
user: {
id: 'admin-1',
username: '管理员',
avatar: null,
},
})
const response = await getMaterial(new NextRequest('http://localhost'), {
params: { id: 'material-1' },
})
const data = await response.json()
expect(response.status).toBe(200)
expect(data.templateId).toBe('template-1')
expect(data.templateFields).toEqual({
customCode: 'READ-CODE',
legacyKey: 'legacy-value',
})
expect(data.zipFileSize).toBe('16')
expect(prisma.material.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
id: 'material-1',
deletedAt: null,
},
})
)
})
test('禁用用户即使带着旧会话也不能读取素材详情', async () => {
prisma.user.findUnique.mockResolvedValueOnce({
id: 'disabled-user',
role: 'USER',
isSuperAdmin: false,
isActive: false,
canDownload: true,
department: '设计部',
managedDepartment: null,
})
const response = await getMaterial(new NextRequest('http://localhost'), {
params: { id: 'material-1' },
})
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toContain('账号不可用')
expect(prisma.material.findFirst).not.toHaveBeenCalled()
})
test('软删除素材详情不应通过旧 ID 入口返回', async () => {
prisma.material.findFirst.mockResolvedValueOnce(null)
const response = await getMaterial(new NextRequest('http://localhost'), {
params: { id: 'deleted-material' },
})
const data = await response.json()
expect(response.status).toBe(404)
expect(data.error).toBe('素材不存在')
expect(prisma.material.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
id: 'deleted-material',
deletedAt: null,
},
})
)
})
})
describe('PUT /api/materials/[id]', () => {
const existingMaterialForJsonUpdate = {
id: 'material-1',
userId: 'admin-1',
productModel: '旧产品',
brandName: null,
seriesName: null,
skuCode: null,
materialTexture: null,
categoryName: '旧店铺',
sceneName: '旧场景',
platformName: '',
materialType: '3DMAX',
materialKind: 'IMAGE',
renderer: 'CR',
renderTime: null,
imageResolution: null,
colorMode: null,
authorName: '旧作者',
previewUrl: 'https://example.com/preview.webp',
metadata: JSON.stringify({
templateId: 'template-1',
templateFields: {
legacyKey: 'legacy-value',
},
}),
templateId: 'template-1',
updatedAt: new Date('2026-03-13T10:00:00.000Z'),
}
function buildJsonUpdateRequest(body: Record<string, unknown>) {
return new NextRequest('http://localhost/api/materials/material-1', {
method: 'PUT',
body: JSON.stringify({
productModel: '更新后的产品',
categoryName: '店铺A',
sceneName: '场景A',
materialType: '3DMAX',
renderer: 'CR',
authorName: '管理员',
...body,
}),
headers: {
'Content-Type': 'application/json',
},
})
}
test('旧 JSON 更新入口允许上传者本人编辑自己的素材', async () => {
getServerSession.mockResolvedValueOnce({
user: { id: 'owner-1', role: 'USER' },
})
prisma.material.findFirst.mockResolvedValueOnce({
...existingMaterialForJsonUpdate,
userId: 'owner-1',
})
const response = await updateMaterialById(
buildJsonUpdateRequest({
templateFields: {
productModel: '更新后的产品',
customCode: 'OWNER-CODE',
},
}),
{ params: { id: 'material-1' } }
)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.material.id).toBe('material-1')
expect(prisma.material.update).toHaveBeenCalled()
expect(createMaterialLog).toHaveBeenCalledWith(
expect.objectContaining({
materialId: 'material-1',
userId: 'owner-1',
action: 'UPDATE',
})
)
})
test('旧 JSON 更新入口允许管理员编辑管理部门范围内的素材', async () => {
prisma.material.findFirst.mockResolvedValueOnce({
...existingMaterialForJsonUpdate,
userId: 'owner-1',
user: {
id: 'owner-1',
department: '设计部-建模组',
},
})
const response = await updateMaterialById(
buildJsonUpdateRequest({
templateFields: {
productModel: '更新后的产品',
customCode: 'ADMIN-CODE',
},
}),
{ params: { id: 'material-1' } }
)
const data = await response.json()
expect(response.status).toBe(200)
expect(data.material.id).toBe('material-1')
expect(prisma.material.update).toHaveBeenCalled()
expect(createMaterialLog).toHaveBeenCalledWith(
expect.objectContaining({
materialId: 'material-1',
userId: 'admin-1',
action: 'UPDATE',
})
)
})
test('旧 JSON 更新入口拒绝没有管理部门的普通 ADMIN 编辑别人素材', async () => {
getServerSession.mockResolvedValueOnce({
user: { id: 'admin-no-scope', role: 'ADMIN' },
})
prisma.user.findUnique.mockResolvedValueOnce({
id: 'admin-no-scope',
role: 'ADMIN',
isSuperAdmin: false,
isActive: true,
canDownload: true,
department: '设计部',
managedDepartment: null,
})
prisma.material.findFirst.mockResolvedValueOnce({
...existingMaterialForJsonUpdate,
userId: 'owner-1',
user: {
id: 'owner-1',
department: '设计部',
},
})
const response = await updateMaterialById(
buildJsonUpdateRequest({
templateFields: {
productModel: '更新后的产品',
customCode: 'ADMIN-NO-SCOPE',
},
}),
{ params: { id: 'material-1' } }
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('您没有权限编辑此素材')
expect(prisma.material.update).not.toHaveBeenCalled()
})
test('旧 JSON 更新入口拒绝普通管理员编辑管理范围外素材', async () => {
prisma.material.findFirst.mockResolvedValueOnce({
...existingMaterialForJsonUpdate,
userId: 'owner-1',
user: {
id: 'owner-1',
department: '运营部',
},
})
const response = await updateMaterialById(
buildJsonUpdateRequest({
templateFields: {
productModel: '更新后的产品',
customCode: 'ADMIN-NOPE',
},
}),
{ params: { id: 'material-1' } }
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('您没有权限编辑此素材')
expect(prisma.material.update).not.toHaveBeenCalled()
expect(createMaterialLog).not.toHaveBeenCalled()
})
test('旧 JSON 更新入口拒绝非上传者普通用户编辑别人的素材', async () => {
getServerSession.mockResolvedValueOnce({
user: { id: 'user-2', role: 'USER' },
})
prisma.material.findFirst.mockResolvedValueOnce({
...existingMaterialForJsonUpdate,
userId: 'owner-1',
})
const response = await updateMaterialById(
buildJsonUpdateRequest({
templateFields: {
productModel: '更新后的产品',
customCode: 'NOPE',
},
}),
{ params: { id: 'material-1' } }
)
const data = await response.json()
expect(response.status).toBe(403)
expect(data.error).toBe('您没有权限编辑此素材')
expect(getTemplateWithFields).not.toHaveBeenCalled()
expect(prisma.material.update).not.toHaveBeenCalled()
})
test('旧 JSON 更新入口也应执行条件必填校验,不能绕过模板规则', async () => {
getTemplateWithFields.mockResolvedValueOnce(createConditionalTemplateDefinition())
prisma.material.findFirst.mockResolvedValueOnce(existingMaterialForJsonUpdate)
const response = await updateMaterialById(
buildJsonUpdateRequest({
templateFields: {
productModel: '需要编码',
},
}),
{ params: { id: 'material-1' } }
)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.errorCode).toBe('TEMPLATE_REQUIRED_FIELDS_MISSING')
expect(prisma.material.update).not.toHaveBeenCalled()
})
test('旧 JSON 更新入口应过滤条件不满足的隐藏字段并保留历史字段', async () => {
getTemplateWithFields.mockResolvedValueOnce(createConditionalTemplateDefinition())
prisma.material.findFirst.mockResolvedValueOnce(existingMaterialForJsonUpdate)
const response = await updateMaterialById(
buildJsonUpdateRequest({
productModel: '普通产品',
templateFields: {
productModel: '普通产品',
conditionalCode: 'SHOULD-NOT-SAVE',
},
}),
{ params: { id: 'material-1' } }
)
expect(response.status).toBe(200)
const updateArgs = prisma.material.update.mock.calls[0][0]
const storedMetadata = parseMetadata(updateArgs.data.metadata)
expect(updateArgs.data.templateId).toBe('template-1')
expect(storedMetadata.templateFields.conditionalCode).toBeUndefined()
expect(storedMetadata.templateFields.legacyKey).toBe('legacy-value')
})
test('旧 JSON 更新入口应拒绝非对象 templateFields', async () => {
const response = await updateMaterialById(
buildJsonUpdateRequest({
templateFields: ['bad'],
}),
{ params: { id: 'material-1' } }
)
const data = await response.json()
expect(response.status).toBe(400)
expect(data.errorCode).toBe('INVALID_TEMPLATE_FIELDS')
expect(prisma.material.findUnique).not.toHaveBeenCalled()
expect(prisma.material.findFirst).not.toHaveBeenCalled()
expect(prisma.material.update).not.toHaveBeenCalled()
})
test('旧 JSON 更新入口不应修改回收站里的素材', async () => {
prisma.material.findFirst.mockResolvedValueOnce(null)
const response = await updateMaterialById(
buildJsonUpdateRequest({
templateFields: {
productModel: '更新后的产品',
customCode: 'DELETED-NOPE',
},
}),
{ params: { id: 'deleted-material' } }
)
const data = await response.json()
expect(response.status).toBe(404)
expect(data.error).toBe('素材不存在')
expect(prisma.material.findFirst).toHaveBeenCalledWith(
expect.objectContaining({
where: {
id: 'deleted-material',
deletedAt: null,
},
})
)
expect(prisma.material.update).not.toHaveBeenCalled()
})
})
})