1395 lines
43 KiB
TypeScript
1395 lines
43 KiB
TypeScript
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()
|
||
})
|
||
})
|
||
})
|