1078 lines
33 KiB
TypeScript
1078 lines
33 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(),
|
|
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(),
|
|
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, isAudioFile } = require('@/lib/audio-processor')
|
|
const { canUploadMaterial } = require('@/lib/license')
|
|
const { validateTemplateId, getTemplateWithFields } = require('@/lib/template-validator')
|
|
|
|
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
|
|
}
|
|
|
|
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,
|
|
department: isAdmin ? '设计部' : '运营部',
|
|
managedDepartment: isAdmin ? '设计部' : null,
|
|
username: '上传者A',
|
|
}
|
|
})
|
|
prisma.department.findMany.mockResolvedValue([
|
|
{ name: '设计部' },
|
|
{ name: '设计部-建模组' },
|
|
{ name: '运营部' },
|
|
])
|
|
|
|
prisma.material.findUnique.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)
|
|
|
|
uploadToS3.mockResolvedValue('s3://bucket/buffer-upload')
|
|
uploadStreamToS3.mockResolvedValue('s3://bucket/stream-upload')
|
|
parseAudioMetadata.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).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('非音频原始素材上传应走流式上传,避免二次整文件 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('音频原始素材暂时保留 Buffer 上传以解析时长', async () => {
|
|
const audioFile = createMockFile('demo.mp3', 'audio/mpeg', 'audio-data')
|
|
parseAudioMetadata.mockResolvedValueOnce({
|
|
duration: 65,
|
|
durationFormatted: '01:05',
|
|
format: 'mp3',
|
|
})
|
|
|
|
const response = await uploadMaterial(
|
|
buildUploadRequest({
|
|
materialType: '音频',
|
|
materialKind: 'AUDIO',
|
|
categoryName: '音效',
|
|
sceneName: '提示音',
|
|
zipFile: audioFile,
|
|
templateFields: JSON.stringify({
|
|
productModel: '稳定材质',
|
|
customCode: 'CODE-AUDIO',
|
|
}),
|
|
})
|
|
)
|
|
|
|
expect(response.status).toBe(201)
|
|
expect(uploadStreamToS3).not.toHaveBeenCalled()
|
|
expect(uploadToS3).toHaveBeenCalledWith(
|
|
expect.any(Buffer),
|
|
expect.stringMatching(/^material-.+\.mp3$/),
|
|
'audio/mpeg'
|
|
)
|
|
expect(parseAudioMetadata).toHaveBeenCalledWith(expect.any(Buffer), 'audio/mpeg')
|
|
|
|
const createArgs = prisma.material.create.mock.calls[0][0]
|
|
expect(createArgs.data.audioDuration).toBe('01:05')
|
|
expect(createArgs.data.audioUrl).toBe('s3://bucket/buffer-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('更新时应保留旧 metadata 字段并覆盖本次提交值', async () => {
|
|
prisma.material.findUnique.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.findUnique.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.findUnique.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.findUnique.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.findUnique.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.findUnique.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.findUnique.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.findUnique).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('读取素材详情时应回传 metadata 中的模板字段', async () => {
|
|
prisma.material.findUnique.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')
|
|
})
|
|
})
|
|
|
|
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.findUnique.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.findUnique.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 更新入口拒绝普通管理员编辑管理范围外素材', async () => {
|
|
prisma.material.findUnique.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.findUnique.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.findUnique.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.findUnique.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.update).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
})
|