499 lines
16 KiB
TypeScript
499 lines
16 KiB
TypeScript
/** @jest-environment node */
|
|
|
|
import { getServerSession } from 'next-auth'
|
|
import { prisma } from '@/lib/prisma'
|
|
import { POST } from '@/app/api/materials/upload/product-package/complete/route'
|
|
import {
|
|
completeProductPackageUpload,
|
|
} from '@/lib/product-package-completion'
|
|
import {
|
|
createTaskDetailUploadJob,
|
|
getLatestTaskDetailUploadJobStatus,
|
|
TaskDetailUploadQueueCapacityError,
|
|
} from '@/lib/task-detail-upload-jobs'
|
|
import { TASK_DETAIL_UPLOAD_LIMITS } from '@/lib/task-detail-upload-policy'
|
|
|
|
jest.mock('next-auth', () => ({ getServerSession: jest.fn() }))
|
|
jest.mock('@/lib/auth', () => ({ authOptions: {} }))
|
|
jest.mock('@/lib/prisma', () => ({
|
|
prisma: {
|
|
material: {
|
|
findUnique: jest.fn(),
|
|
},
|
|
},
|
|
}))
|
|
jest.mock('@/lib/product-package-completion', () => {
|
|
const { TASK_DETAIL_UPLOAD_LIMITS } = jest.requireActual('@/lib/task-detail-upload-policy')
|
|
const normalizeImageGroup = (group: unknown, label: string) => {
|
|
if (!Array.isArray(group)) return []
|
|
if (group.length > TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup) {
|
|
throw new Error(`${label}最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup} 张,请分批提交`)
|
|
}
|
|
return group.map((item) => {
|
|
if (!item || typeof item !== 'object') {
|
|
throw new Error('资产参数无效')
|
|
}
|
|
if ('fileUrl' in item || 'localFilePath' in item) {
|
|
throw new Error('公开上传不允许 fileUrl 或 localFilePath')
|
|
}
|
|
return item
|
|
})
|
|
}
|
|
const normalizeSourceGroup = (group: unknown) => {
|
|
if (!Array.isArray(group)) return []
|
|
if (group.length > TASK_DETAIL_UPLOAD_LIMITS.maxPsdFileCount) {
|
|
throw new Error(`PSD 源文件最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxPsdFileCount} 个,请分批提交`)
|
|
}
|
|
return group.map((item) => {
|
|
if (!item || typeof item !== 'object') {
|
|
throw new Error('资产参数无效')
|
|
}
|
|
if ('fileUrl' in item || 'localFilePath' in item) {
|
|
throw new Error('公开上传不允许 fileUrl 或 localFilePath')
|
|
}
|
|
return item
|
|
})
|
|
}
|
|
return {
|
|
parsePublicProductPackageAssets: jest.fn((value: unknown) => {
|
|
if (!value || typeof value !== 'object') {
|
|
return {}
|
|
}
|
|
const assets = value as Record<string, unknown>
|
|
return {
|
|
main: normalizeImageGroup(assets.main, '主图'),
|
|
sku: normalizeImageGroup(assets.sku, 'SKU 图'),
|
|
detail: normalizeImageGroup(assets.detail, '详情图'),
|
|
psd: normalizeSourceGroup(assets.psd),
|
|
}
|
|
}),
|
|
completeProductPackageUpload: jest.fn(),
|
|
}
|
|
})
|
|
jest.mock('@/lib/task-detail-upload-jobs', () => ({
|
|
createTaskDetailUploadJob: jest.fn(),
|
|
getLatestTaskDetailUploadJobStatus: jest.fn().mockResolvedValue(null),
|
|
TaskDetailUploadQueueCapacityError: class TaskDetailUploadQueueCapacityError extends Error {
|
|
scope: 'user' | 'global'
|
|
statusCode: 429 | 503
|
|
|
|
constructor(scope: 'user' | 'global') {
|
|
super(scope === 'user' ? '您的等待任务已满' : '全局等待队列已满')
|
|
this.scope = scope
|
|
this.statusCode = scope === 'user' ? 429 : 503
|
|
}
|
|
},
|
|
}))
|
|
describe('product-package complete public route security', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks()
|
|
;(getServerSession as jest.Mock).mockResolvedValue({ user: { id: 'user-1' } })
|
|
;(getLatestTaskDetailUploadJobStatus as jest.Mock).mockResolvedValue(null)
|
|
;(completeProductPackageUpload as jest.Mock).mockResolvedValue({
|
|
materialId: 'material-1',
|
|
taskNo: null,
|
|
isFullSet: false,
|
|
})
|
|
;(createTaskDetailUploadJob as jest.Mock).mockResolvedValue({
|
|
jobId: 'job-1',
|
|
materialId: 'material-1',
|
|
status: 'queued',
|
|
stage: 'queued',
|
|
progress: 0,
|
|
})
|
|
})
|
|
|
|
test.each([
|
|
['fileUrl', { fileUrl: 'http://127.0.0.1:3000/internal' }],
|
|
['localFilePath', { localFilePath: 'C:\\Windows\\win.ini' }],
|
|
])('公开 complete 路由拒绝 %s 来源', async (_source, sourceInput) => {
|
|
const response = await POST({
|
|
json: jest.fn().mockResolvedValue({
|
|
materialId: 'material-1',
|
|
assets: {
|
|
main: [{ ...sourceInput, fileName: 'main.png', fileSize: 10 }],
|
|
},
|
|
}),
|
|
} as any)
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(prisma.material.findUnique).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('非任务详情素材不能使用专用完成接口', async () => {
|
|
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
|
|
id: 'material-1',
|
|
userId: 'user-1',
|
|
materialType: '3D场景',
|
|
taskDetailRequesterUserId: 'requester-1',
|
|
})
|
|
|
|
const response = await POST({
|
|
json: jest.fn().mockResolvedValue({
|
|
materialId: 'material-1',
|
|
assets: { main: [] },
|
|
}),
|
|
} as any)
|
|
const body = await response.json()
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(body.error).toContain('不是任务-详情')
|
|
expect(completeProductPackageUpload).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('缺少需求运营的新流程素材不能完成上传', async () => {
|
|
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
|
|
id: 'material-1',
|
|
userId: 'user-1',
|
|
materialType: '任务-详情',
|
|
taskDetailRequesterUserId: null,
|
|
})
|
|
|
|
const response = await POST({
|
|
json: jest.fn().mockResolvedValue({
|
|
materialId: 'material-1',
|
|
assets: { main: [] },
|
|
}),
|
|
} as any)
|
|
const body = await response.json()
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(body.error).toContain('缺少需求运营人员')
|
|
expect(completeProductPackageUpload).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('缺少出图方式的新流程素材不能完成上传', async () => {
|
|
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
|
|
id: 'material-1',
|
|
userId: 'user-1',
|
|
materialType: '任务-详情',
|
|
taskDetailRequesterUserId: 'requester-1',
|
|
taskDetailSourceType: null,
|
|
})
|
|
|
|
const response = await POST({
|
|
json: jest.fn().mockResolvedValue({
|
|
materialId: 'material-1',
|
|
assets: { main: [] },
|
|
}),
|
|
} as any)
|
|
const body = await response.json()
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(body.error).toContain('出图方式')
|
|
expect(completeProductPackageUpload).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('已有非法新品私密状态的素材不能继续完成上传', async () => {
|
|
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
|
|
id: 'material-1',
|
|
userId: 'user-1',
|
|
materialType: '任务-详情',
|
|
taskDetailRequesterUserId: 'requester-1',
|
|
taskDetailSourceType: '实拍',
|
|
taskDetailDemandType: '新品',
|
|
taskDetailPrivate: true,
|
|
zipFilePath: '',
|
|
_count: { assets: 0 },
|
|
})
|
|
|
|
const response = await POST({
|
|
json: jest.fn().mockResolvedValue({
|
|
materialId: 'material-1',
|
|
assets: {
|
|
main: [{
|
|
uploadId: 'upload-main',
|
|
fileName: 'main.png',
|
|
fileSize: 10,
|
|
previewAssets: [{
|
|
uploadId: 'upload-preview',
|
|
fileName: 'main-preview.webp',
|
|
fileSize: 5,
|
|
width: 800,
|
|
height: 800,
|
|
kind: 'preview',
|
|
}],
|
|
}],
|
|
},
|
|
}),
|
|
} as any)
|
|
const body = await response.json()
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(body.error).toBe('仅优化需求支持私密上传')
|
|
expect(createTaskDetailUploadJob).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('合法任务详情上传会快速入队并返回 jobId', async () => {
|
|
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
|
|
id: 'material-1',
|
|
userId: 'user-1',
|
|
materialType: '任务-详情',
|
|
taskDetailRequesterUserId: 'requester-1',
|
|
taskDetailSourceType: '实拍',
|
|
zipFilePath: '',
|
|
})
|
|
|
|
const response = await POST({
|
|
json: jest.fn().mockResolvedValue({
|
|
materialId: 'material-1',
|
|
assets: {
|
|
main: [{
|
|
uploadId: 'upload-main',
|
|
fileName: 'main.png',
|
|
fileSize: 10,
|
|
previewAssets: [{
|
|
uploadId: 'upload-preview',
|
|
fileName: 'main-preview.webp',
|
|
fileSize: 5,
|
|
width: 800,
|
|
height: 800,
|
|
kind: 'preview',
|
|
}],
|
|
}],
|
|
},
|
|
}),
|
|
} as any)
|
|
const body = await response.json()
|
|
|
|
expect(response.status).toBe(202)
|
|
expect(body.data.jobId).toBe('job-1')
|
|
expect(createTaskDetailUploadJob).toHaveBeenCalledWith(expect.objectContaining({
|
|
materialId: 'material-1',
|
|
operatorUserId: 'user-1',
|
|
}))
|
|
expect(completeProductPackageUpload).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('客户端预览失败时仍可带原图入队,由后台生成预览', async () => {
|
|
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
|
|
id: 'material-1',
|
|
userId: 'user-1',
|
|
materialType: '任务-详情',
|
|
taskDetailRequesterUserId: 'requester-1',
|
|
taskDetailSourceType: '实拍',
|
|
zipFilePath: '',
|
|
_count: { assets: 0 },
|
|
})
|
|
|
|
const response = await POST({
|
|
json: jest.fn().mockResolvedValue({
|
|
materialId: 'material-1',
|
|
allowServerPreviewFallback: true,
|
|
assets: {
|
|
main: [{
|
|
uploadId: 'upload-main',
|
|
fileName: 'main.jpg',
|
|
fileSize: 10,
|
|
}],
|
|
},
|
|
}),
|
|
} as any)
|
|
const body = await response.json()
|
|
|
|
expect(response.status).toBe(202)
|
|
expect(body.data.jobId).toBe('job-1')
|
|
expect(createTaskDetailUploadJob).toHaveBeenCalledWith(expect.objectContaining({
|
|
materialId: 'material-1',
|
|
assets: expect.objectContaining({
|
|
main: [expect.objectContaining({ uploadId: 'upload-main' })],
|
|
}),
|
|
}))
|
|
})
|
|
|
|
test('缺少客户端预览且未声明兜底时仍拒绝完成请求', async () => {
|
|
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
|
|
id: 'material-1',
|
|
userId: 'user-1',
|
|
materialType: '任务-详情',
|
|
taskDetailRequesterUserId: 'requester-1',
|
|
taskDetailSourceType: '实拍',
|
|
zipFilePath: '',
|
|
_count: { assets: 0 },
|
|
})
|
|
|
|
const response = await POST({
|
|
json: jest.fn().mockResolvedValue({
|
|
materialId: 'material-1',
|
|
assets: {
|
|
main: [{ uploadId: 'upload-main', fileName: 'main.jpg', fileSize: 10 }],
|
|
},
|
|
}),
|
|
} as any)
|
|
const body = await response.json()
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(body.error).toContain('客户端预览图缺失')
|
|
expect(createTaskDetailUploadJob).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('已有部分持久化结果的素材不能重复入队', async () => {
|
|
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
|
|
id: 'material-1',
|
|
userId: 'user-1',
|
|
materialType: '任务-详情',
|
|
taskDetailRequesterUserId: 'requester-1',
|
|
taskDetailSourceType: '实拍',
|
|
zipFilePath: 'materials/product-package/material-1/all.zip',
|
|
})
|
|
|
|
const response = await POST({
|
|
json: jest.fn().mockResolvedValue({
|
|
materialId: 'material-1',
|
|
assets: {
|
|
main: [{
|
|
uploadId: 'upload-main',
|
|
fileName: 'main.png',
|
|
fileSize: 10,
|
|
previewAssets: [{
|
|
uploadId: 'upload-preview',
|
|
fileName: 'main.webp',
|
|
fileSize: 5,
|
|
width: 800,
|
|
height: 800,
|
|
kind: 'preview',
|
|
}],
|
|
}],
|
|
},
|
|
}),
|
|
} as any)
|
|
|
|
expect(response.status).toBe(409)
|
|
expect(createTaskDetailUploadJob).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('重复提交同一素材时返回已有后台任务,避免丢失 202 响应后重复上传', async () => {
|
|
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
|
|
id: 'material-1',
|
|
userId: 'user-1',
|
|
materialType: '任务-详情',
|
|
taskDetailRequesterUserId: 'requester-1',
|
|
taskDetailSourceType: '实拍',
|
|
zipFilePath: '',
|
|
_count: { assets: 0 },
|
|
})
|
|
;(getLatestTaskDetailUploadJobStatus as jest.Mock).mockResolvedValue({
|
|
jobId: 'job-existing',
|
|
materialId: 'material-1',
|
|
status: 'processing',
|
|
stage: 'zips',
|
|
progress: 80,
|
|
})
|
|
|
|
const response = await POST({
|
|
json: jest.fn().mockResolvedValue({
|
|
materialId: 'material-1',
|
|
assets: { main: [] },
|
|
}),
|
|
} as any)
|
|
const body = await response.json()
|
|
|
|
expect(response.status).toBe(202)
|
|
expect(body.data.jobId).toBe('job-existing')
|
|
expect(createTaskDetailUploadJob).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('首次 202 丢失且后台已完成时,即使素材已有 ZIP 也返回原任务', async () => {
|
|
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
|
|
id: 'material-1',
|
|
userId: 'user-1',
|
|
materialType: '任务-详情',
|
|
taskDetailRequesterUserId: 'requester-1',
|
|
taskDetailSourceType: '实拍',
|
|
zipFilePath: 'materials/product-package/material-1/all.zip',
|
|
_count: { assets: 5 },
|
|
})
|
|
;(getLatestTaskDetailUploadJobStatus as jest.Mock).mockResolvedValue({
|
|
jobId: 'job-completed',
|
|
materialId: 'material-1',
|
|
status: 'completed',
|
|
stage: 'completed',
|
|
progress: 100,
|
|
})
|
|
|
|
const response = await POST({
|
|
json: jest.fn().mockResolvedValue({ materialId: 'material-1', assets: {} }),
|
|
} as any)
|
|
const body = await response.json()
|
|
|
|
expect(response.status).toBe(202)
|
|
expect(body.data).toEqual(expect.objectContaining({
|
|
jobId: 'job-completed',
|
|
status: 'completed',
|
|
progress: 100,
|
|
}))
|
|
expect(createTaskDetailUploadJob).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('直接 HTTP 提交超过单组图片上限时在查询素材前拒绝', async () => {
|
|
const response = await POST({
|
|
json: jest.fn().mockResolvedValue({
|
|
materialId: 'material-1',
|
|
allowServerPreviewFallback: true,
|
|
assets: {
|
|
main: Array.from({ length: TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup + 1 }, (_, index) => ({
|
|
uploadId: `upload-main-${index}`,
|
|
fileName: `${index}.jpg`,
|
|
fileSize: 1,
|
|
})),
|
|
},
|
|
}),
|
|
} as any)
|
|
const body = await response.json()
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(body.error).toContain(`主图最多上传 ${TASK_DETAIL_UPLOAD_LIMITS.maxImagesPerGroup} 张`)
|
|
expect(prisma.material.findUnique).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('直接 HTTP 用大量小 PSD 绕过图片计数时拒绝', async () => {
|
|
const response = await POST({
|
|
json: jest.fn().mockResolvedValue({
|
|
materialId: 'material-1',
|
|
assets: {
|
|
psd: Array.from({ length: 21 }, (_, index) => ({
|
|
uploadId: `upload-psd-${index}`,
|
|
fileName: `${index}.psd`,
|
|
fileSize: 1,
|
|
})),
|
|
},
|
|
}),
|
|
} as any)
|
|
const body = await response.json()
|
|
|
|
expect(response.status).toBe(400)
|
|
expect(body.error).toContain('PSD 源文件最多上传 20 个')
|
|
expect(prisma.material.findUnique).not.toHaveBeenCalled()
|
|
})
|
|
|
|
test('用户等待队列已满时返回 429 和重试提示', async () => {
|
|
;(prisma.material.findUnique as jest.Mock).mockResolvedValue({
|
|
id: 'material-1',
|
|
userId: 'user-1',
|
|
materialType: '任务-详情',
|
|
taskDetailRequesterUserId: 'requester-1',
|
|
taskDetailSourceType: '实拍',
|
|
zipFilePath: '',
|
|
_count: { assets: 0 },
|
|
})
|
|
;(createTaskDetailUploadJob as jest.Mock).mockRejectedValue(
|
|
new TaskDetailUploadQueueCapacityError('user'),
|
|
)
|
|
|
|
const response = await POST({
|
|
json: jest.fn().mockResolvedValue({
|
|
materialId: 'material-1',
|
|
allowServerPreviewFallback: true,
|
|
assets: {
|
|
main: [{ uploadId: 'upload-main', fileName: 'main.jpg', fileSize: 10 }],
|
|
},
|
|
}),
|
|
} as any)
|
|
const body = await response.json()
|
|
|
|
expect(response.status).toBe(429)
|
|
expect(response.headers.get('retry-after')).toBe('60')
|
|
expect(body.code).toBe('UPLOAD_QUEUE_FULL')
|
|
})
|
|
})
|