455 lines
14 KiB
TypeScript
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',
|
|
}),
|
|
}),
|
|
)
|
|
})
|
|
})
|