gongxiangp2/lib/__tests__/material-edit-routes.test.ts

455 lines
14 KiB
TypeScript

/** @jest-environment node */
import { NextRequest } from 'next/server'
import { GET as getMaterialById, PUT as putMaterialById } from '@/app/api/materials/[id]/route'
import { PUT as putMaterialUpdate } from '@/app/api/materials/update/route'
import { getServerSession } from 'next-auth'
import { updateMaterialWithSharedRules } from '@/lib/material-update'
import { prisma } from '@/lib/prisma'
import {
canUserDownloadMaterial,
canUserDownloadSource,
canUserViewMaterial,
isSuperAdmin,
isSystemAdmin,
} from '@/lib/department-permission'
import { deriveTaskDetailIsFullSet } from '@/lib/task-detail-full-set'
jest.mock('next-auth', () => ({
getServerSession: jest.fn(),
}))
jest.mock('@/lib/auth', () => ({
authOptions: {},
}))
jest.mock('@/lib/prisma', () => ({
prisma: {
material: {
findFirst: jest.fn(),
},
user: {
findUnique: jest.fn(),
},
},
}))
jest.mock('@/lib/department-permission', () => ({
canUserDownloadMaterial: jest.fn(),
canUserDownloadSource: jest.fn(),
canUserViewMaterial: jest.fn(),
isSuperAdmin: jest.fn(),
isSystemAdmin: jest.fn(),
}))
jest.mock('@/lib/task-detail-full-set', () => ({
deriveTaskDetailIsFullSet: jest.fn(),
}))
jest.mock('@/lib/material-update', () => ({
updateMaterialWithSharedRules: jest.fn(),
}))
describe('material edit routes', () => {
const originalFile = globalThis.File
beforeEach(() => {
jest.clearAllMocks()
;(getServerSession as jest.Mock).mockResolvedValue({
user: {
id: 'user-1',
role: 'USER',
department: '测试部门',
managedDepartment: null,
},
})
;(isSuperAdmin as jest.Mock).mockResolvedValue(false)
;(isSystemAdmin as jest.Mock).mockReturnValue(false)
;(canUserViewMaterial as jest.Mock).mockResolvedValue(true)
;(canUserDownloadMaterial as jest.Mock).mockResolvedValue(true)
;(canUserDownloadSource as jest.Mock).mockResolvedValue(true)
;(prisma.user.findUnique as jest.Mock).mockResolvedValue({ canDownload: true })
;(deriveTaskDetailIsFullSet as jest.Mock).mockReturnValue(false)
;(updateMaterialWithSharedRules as jest.Mock).mockResolvedValue({
material: {
id: 'material-1',
productModel: 'SPU-001',
categoryName: '店铺A',
sceneName: '场景A',
platformName: 'Amazon',
productCategoryName: '椅子',
materialType: '标准模型',
renderer: 'CR',
authorName: '测试作者',
taskNo: null,
taskDetailSourceType: null,
taskDetailRequesterUserId: null,
taskDetailRequesterName: null,
taskStatus: null,
taskFetchedAt: null,
isFullSet: false,
zipFileSize: 0,
},
})
Object.defineProperty(globalThis, 'File', {
configurable: true,
writable: true,
value: undefined,
})
})
afterAll(() => {
Object.defineProperty(globalThis, 'File', {
configurable: true,
writable: true,
value: originalFile,
})
})
function createFormData() {
const formData = new FormData()
formData.append('productModel', 'SPU-001')
formData.append('categoryName', '店铺A')
formData.append('sceneName', '场景A')
formData.append('platformName', 'Amazon')
formData.append('productCategoryName', '椅子')
formData.append('materialType', '标准模型')
formData.append('renderer', 'CR')
formData.append('authorName', '测试作者')
formData.append('userId', 'user-1')
formData.append('taskNo', '')
formData.append('taskDetailSourceType', 'AI')
formData.append('taskDetailRequesterUserId', 'requester-1')
return formData
}
function createPutRequest(url: string, formData: FormData) {
return new NextRequest(url, {
method: 'PUT',
headers: {
'content-length': '1024',
},
body: formData,
})
}
test('PUT /api/materials/[id] 在没有全局 File 时仍可正常处理表单更新', async () => {
const request = createPutRequest(
'http://localhost/api/materials/material-1',
createFormData(),
)
const response = await putMaterialById(request, {
params: { id: 'material-1' },
})
const body = await response.json()
expect(response.status).toBe(200)
expect(body.success).toBe(true)
expect(updateMaterialWithSharedRules).toHaveBeenCalledWith(
expect.objectContaining({
materialId: 'material-1',
previewImage: null,
}),
)
})
test('PUT /api/materials/update 在没有全局 File 时仍可正常处理表单更新', async () => {
const formData = createFormData()
formData.append('id', 'material-1')
const request = createPutRequest('http://localhost/api/materials/update', formData)
const response = await putMaterialUpdate(request)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.message).toBe('更新成功')
expect(updateMaterialWithSharedRules).toHaveBeenCalledWith(
expect.objectContaining({
materialId: 'material-1',
previewImage: null,
}),
)
})
test.each([
{
name: '/api/materials/[id]',
put: async (formData: FormData) =>
putMaterialById(
createPutRequest('http://localhost/api/materials/material-1', formData),
{ params: { id: 'material-1' } },
),
},
{
name: '/api/materials/update',
put: async (formData: FormData) => {
formData.append('id', 'material-1')
return putMaterialUpdate(
createPutRequest('http://localhost/api/materials/update', formData),
)
},
},
])('$name 不应把未提交的可选字段合成为空值', async ({ put }) => {
const formData = new FormData()
formData.append('productModel', 'SPU-001')
formData.append('categoryName', '店铺A')
formData.append('sceneName', '场景A')
formData.append('materialType', '3D场景')
formData.append('renderer', 'CR')
formData.append('authorName', '测试作者')
const response = await put(formData)
expect(response.status).toBe(200)
const call = (updateMaterialWithSharedRules as jest.Mock).mock.calls[0][0]
expect(call.input).not.toHaveProperty('reportTitle')
expect(call.input).not.toHaveProperty('userId')
expect(call.input).not.toHaveProperty('taskNo')
expect(call.input).not.toHaveProperty('dingtalkApprovalNo')
expect(call.input).not.toHaveProperty('taskDetailSourceType')
expect(call.input).not.toHaveProperty('taskDetailRequesterUserId')
expect(call.input).not.toHaveProperty('isFullSet')
})
test.each(['share=1', 'shareId=anything'])(
'GET /api/materials/[id] 不能通过 %s 绕过登录校验',
async (shareParam) => {
;(getServerSession as jest.Mock).mockResolvedValue(null)
const response = await getMaterialById(
new NextRequest(`http://localhost/api/materials/material-1?${shareParam}`),
{ params: { id: 'material-1' } },
)
const body = await response.json()
expect(response.status).toBe(401)
expect(body.error).toBe('请先登录')
expect(prisma.material.findFirst).not.toHaveBeenCalled()
},
)
test('GET /api/materials/[id] 应将 assets 中的 BigInt fileSize 序列化为字符串', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
id: 'material-1',
userId: 'user-1',
productModel: 'SPU-001',
categoryName: '店铺A',
sceneName: '场景A',
platformName: 'Amazon',
productCategoryName: '椅子',
materialType: '任务-AI原图',
isAiGenerated: false,
renderer: 'CR',
authorName: '测试作者',
previewUrl: 'https://s3.jiansun.vip/preview.webp',
zipFileName: 'material.zip',
zipFilePath: 'uploads/brand-vi/original.pdf',
zipFileSize: BigInt(123),
downloadCount: 1,
likeCount: 2,
displayImages: [],
createdAt: new Date('2026-04-03T00:00:00.000Z'),
videoSourceUrl: null,
videoPreview540Url: null,
videoPreviewClipUrl: null,
videoCoverUrl: null,
videoDuration: null,
videoProcessingStatus: null,
taskNo: 'TASK202603261129596600-01-01',
taskStatus: 'fetched',
taskFetchedAt: new Date('2026-04-03T00:00:01.000Z'),
isFullSet: false,
taskDetailSourceType: '渲染',
taskDetailRequesterUserId: 'requester-1',
taskDetailRequesterName: '运营甲',
user: {
id: 'user-1',
username: '测试用户',
realName: '测试用户',
},
assets: [
{
id: 'asset-1',
assetType: 'zip',
fileUrl: 'uploads/material.zip',
fileName: 'material.zip',
fileSize: BigInt(456),
sortOrder: 0,
},
],
})
const request = new NextRequest('http://localhost/api/materials/material-1', {
method: 'GET',
})
const response = await getMaterialById(request, {
params: { id: 'material-1' },
})
const body = await response.json()
expect(response.status).toBe(200)
expect(body.zipFileSize).toBe('123')
expect(body.assets[0].fileSize).toBe('456')
expect(body.taskDetailSourceType).toBe('渲染')
expect(body.taskDetailRequesterUserId).toBe('requester-1')
expect(body.taskDetailRequesterName).toBe('运营甲')
expect(body.sourceFileUrl).toBe('/api/materials/download?id=material-1')
expect(body.canEdit).toBe(true)
expect(canUserDownloadSource).toHaveBeenCalledWith(
'测试部门',
'USER',
null,
'任务-AI原图',
'user-1',
)
})
test('视频等待期内,素材所有者仍可读取元数据编辑信息,但不能取得视频预览地址', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
id: 'material-1',
userId: 'user-1',
productModel: 'VIDEO-001',
reportTitle: null,
categoryName: '店铺A',
sceneName: '场景A',
platformName: '',
productCategoryName: '',
materialType: '淘系-主图视频',
isAiGenerated: false,
taskDetailSourceType: null,
taskDetailRequesterUserId: null,
taskDetailRequesterName: null,
renderer: '',
authorName: '测试作者',
previewUrl: 'https://s3.jiansun.vip/video-cover.webp',
zipFileName: 'video.mp4',
zipFilePath: 'uploads/video.mp4',
zipFileSize: BigInt(123),
downloadCount: 0,
likeCount: 0,
displayImages: [],
createdAt: new Date(),
videoSourceUrl: 'https://s3.jiansun.vip/video.mp4',
videoPreview540Url: 'https://s3.jiansun.vip/video-540.mp4',
videoPreviewClipUrl: 'https://s3.jiansun.vip/video-clip.mp4',
videoCoverUrl: 'https://s3.jiansun.vip/video-cover.webp',
videoDuration: 20,
videoProcessingStatus: 'completed',
taskNo: null,
dingtalkApprovalNo: null,
taskStatus: null,
taskFetchedAt: null,
isFullSet: false,
user: {
id: 'user-1',
username: '测试用户',
realName: '测试用户',
},
assets: [],
})
const response = await getMaterialById(
new NextRequest('http://localhost/api/materials/material-1?intent=edit'),
{ params: { id: 'material-1' } },
)
const body = await response.json()
expect(response.status).toBe(200)
expect(body.canEdit).toBe(true)
expect(body.productModel).toBe('VIDEO-001')
expect(body.previewUrl).toBeNull()
expect(body).not.toHaveProperty('videoSourceUrl')
expect(body).not.toHaveProperty('videoPreview540Url')
expect(body).not.toHaveProperty('sourceFileUrl')
expect(canUserViewMaterial).not.toHaveBeenCalled()
const detailResponse = await getMaterialById(
new NextRequest('http://localhost/api/materials/material-1'),
{ params: { id: 'material-1' } },
)
const detailBody = await detailResponse.json()
expect(detailResponse.status).toBe(403)
expect(detailBody.code).toBe('VIDEO_WAIT_PERIOD')
})
test('非素材所有者不能通过编辑读取意图绕过权限', async () => {
;(prisma.material.findFirst as jest.Mock).mockResolvedValue({
id: 'material-1',
userId: 'other-user',
materialType: '3D场景',
createdAt: new Date('2026-01-01T00:00:00.000Z'),
user: {
id: 'other-user',
username: '其他用户',
realName: '其他用户',
},
assets: [],
})
const response = await getMaterialById(
new NextRequest('http://localhost/api/materials/material-1?intent=edit'),
{ params: { id: 'material-1' } },
)
const body = await response.json()
expect(response.status).toBe(403)
expect(body.error).toBe('您没有权限编辑此素材')
expect(canUserViewMaterial).not.toHaveBeenCalled()
})
test('PUT 路由应透传任务详情出图方式字段', async () => {
const request = createPutRequest(
'http://localhost/api/materials/material-1',
createFormData(),
)
await putMaterialById(request, {
params: { id: 'material-1' },
})
expect(updateMaterialWithSharedRules).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({
taskDetailSourceType: 'AI',
}),
}),
)
})
test.each([
['PUT /api/materials/[id]', false],
['PUT /api/materials/update', true],
])('%s 应透传需求运营人员字段', async (_label, legacyRoute) => {
const formData = createFormData()
if (legacyRoute) {
formData.append('id', 'material-1')
}
const request = createPutRequest(
legacyRoute
? 'http://localhost/api/materials/update'
: 'http://localhost/api/materials/material-1',
formData,
)
if (legacyRoute) {
await putMaterialUpdate(request)
} else {
await putMaterialById(request, {
params: { id: 'material-1' },
})
}
expect(updateMaterialWithSharedRules).toHaveBeenCalledWith(
expect.objectContaining({
input: expect.objectContaining({
taskDetailRequesterUserId: 'requester-1',
}),
}),
)
})
})