flnxi-Wu/app/api/materials/__tests__/template-fields-routes.test.ts
2026-05-28 15:11:36 +08:00

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()
})
})
})