Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
315 lines
10 KiB
TypeScript
315 lines
10 KiB
TypeScript
/** @jest-environment jsdom */
|
|
|
|
import { chunkUpload } from '@/lib/chunk-upload'
|
|
|
|
import {
|
|
completeBatchUpload,
|
|
uploadBatch3D,
|
|
uploadBatchBrandVI,
|
|
uploadBatchMusic,
|
|
} from './special-batch-uploaders'
|
|
|
|
jest.mock('@/lib/chunk-upload', () => ({
|
|
chunkUpload: jest.fn(),
|
|
}))
|
|
|
|
type FileItem = { file: File; productModel: string }
|
|
type BatchResult = { productModel: string; status: string }
|
|
type RunBatch = (files: FileItem[]) => Promise<BatchResult[]>
|
|
|
|
function flushMicrotasks(count = 10) {
|
|
return (async () => {
|
|
for (let index = 0; index < count; index += 1) {
|
|
await Promise.resolve()
|
|
}
|
|
})()
|
|
}
|
|
|
|
async function assertBoundedFilePool(
|
|
runBatch: RunBatch,
|
|
expectedStatus = 'processing',
|
|
expectedConcurrency = 3,
|
|
) {
|
|
const files = Array.from({ length: 10 }, (_, index) => ({
|
|
file: new File([`file-${index}`], `file-${index}.bin`, { type: 'application/octet-stream' }),
|
|
productModel: `SPU-${index}`,
|
|
}))
|
|
|
|
let chunkCallCount = 0
|
|
;(chunkUpload as jest.Mock).mockImplementation(async () => ({
|
|
uploadId: `upload-${chunkCallCount++}`,
|
|
confirmMaterialCreated: jest.fn().mockResolvedValue(undefined),
|
|
}))
|
|
|
|
let activeCompleteRequests = 0
|
|
let maxActiveCompleteRequests = 0
|
|
let completeCallCount = 0
|
|
const materialCreateUploadIds: string[] = []
|
|
const pendingCompleteResponses: Array<{
|
|
uploadId: string
|
|
resolve: (response: unknown) => void
|
|
}> = []
|
|
|
|
const fetchMock = jest.fn((url: string, init?: RequestInit) => {
|
|
if (url === '/api/materials/upload/complete') {
|
|
const body = JSON.parse(String(init?.body)) as { uploadId: string }
|
|
completeCallCount += 1
|
|
activeCompleteRequests += 1
|
|
maxActiveCompleteRequests = Math.max(maxActiveCompleteRequests, activeCompleteRequests)
|
|
|
|
return new Promise((resolve) => {
|
|
pendingCompleteResponses.push({
|
|
uploadId: body.uploadId,
|
|
resolve: (response) => {
|
|
activeCompleteRequests -= 1
|
|
resolve(response)
|
|
},
|
|
})
|
|
})
|
|
}
|
|
|
|
if (url === '/api/materials/upload') {
|
|
materialCreateUploadIds.push(String((init?.body as FormData).get('uploadId')))
|
|
return Promise.resolve({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ material: { id: 'material-id' } }),
|
|
})
|
|
}
|
|
|
|
return Promise.reject(new Error(`unexpected URL: ${url}`))
|
|
})
|
|
global.fetch = fetchMock as unknown as typeof fetch
|
|
|
|
const batchPromise = runBatch(files)
|
|
await flushMicrotasks(20)
|
|
|
|
expect(chunkUpload).toHaveBeenCalledTimes(expectedConcurrency)
|
|
expect(completeCallCount).toBe(expectedConcurrency)
|
|
expect(maxActiveCompleteRequests).toBeLessThanOrEqual(expectedConcurrency)
|
|
|
|
let safety = 0
|
|
while ((completeCallCount < files.length || pendingCompleteResponses.length > 0) && safety < 100) {
|
|
const pending = pendingCompleteResponses.shift()
|
|
if (pending) {
|
|
pending.resolve({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
existingFilePath: `uploads/${pending.uploadId}.bin`,
|
|
fileName: `${pending.uploadId}.bin`,
|
|
}),
|
|
})
|
|
}
|
|
await flushMicrotasks(10)
|
|
safety += 1
|
|
}
|
|
|
|
expect(completeCallCount).toBe(files.length)
|
|
const results = await batchPromise
|
|
expect(results).toHaveLength(files.length)
|
|
expect(results.map((result) => result.productModel)).toEqual(files.map((file) => file.productModel))
|
|
expect(results.every((result) => result.status === expectedStatus)).toBe(true)
|
|
expect(activeCompleteRequests).toBe(0)
|
|
expect(maxActiveCompleteRequests).toBe(expectedConcurrency)
|
|
expect(materialCreateUploadIds).toEqual(
|
|
Array.from({ length: files.length }, (_, index) => `upload-${index}`),
|
|
)
|
|
}
|
|
|
|
describe('特殊批量上传的文件级并发池', () => {
|
|
const originalFetch = global.fetch
|
|
let consoleLog: jest.SpyInstance
|
|
let consoleWarn: jest.SpyInstance
|
|
let consoleError: jest.SpyInstance
|
|
|
|
beforeEach(() => {
|
|
jest.clearAllMocks()
|
|
consoleLog = jest.spyOn(console, 'log').mockImplementation(() => undefined)
|
|
consoleWarn = jest.spyOn(console, 'warn').mockImplementation(() => undefined)
|
|
consoleError = jest.spyOn(console, 'error').mockImplementation(() => undefined)
|
|
})
|
|
|
|
afterEach(() => {
|
|
global.fetch = originalFetch
|
|
consoleLog.mockRestore()
|
|
consoleWarn.mockRestore()
|
|
consoleError.mockRestore()
|
|
})
|
|
|
|
test('音乐 10 个文件最多同时处理 3 个 complete 请求', async () => {
|
|
await assertBoundedFilePool((files) => uploadBatchMusic(files, '', '', jest.fn()))
|
|
})
|
|
|
|
test('品牌 VI 10 个文件严格逐个处理,避免 PDF 解码与服务端排队叠加', async () => {
|
|
await assertBoundedFilePool(
|
|
(files) => uploadBatchBrandVI(files, '', jest.fn()),
|
|
'processing',
|
|
1,
|
|
)
|
|
})
|
|
|
|
test('3D 场景 10 个文件也最多同时处理 3 个 complete 请求', async () => {
|
|
await assertBoundedFilePool(
|
|
(files) => uploadBatch3D(files, '', '', '', '', jest.fn()),
|
|
'success',
|
|
)
|
|
})
|
|
|
|
test('complete 遇到 503 时用同一 uploadId 重试并恢复', async () => {
|
|
const completeBodies: Array<{ uploadId: string; originalFileName: string }> = []
|
|
const fetchMock = jest.fn((url: string, init?: RequestInit) => {
|
|
if (url === '/api/materials/upload/complete') {
|
|
completeBodies.push(JSON.parse(String(init?.body)))
|
|
if (completeBodies.length === 1) {
|
|
return Promise.resolve({
|
|
ok: false,
|
|
status: 503,
|
|
headers: { get: () => '0' },
|
|
text: async () => 'temporarily busy',
|
|
})
|
|
}
|
|
|
|
return Promise.resolve({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
existingFilePath: 'uploads/recovered.mp3',
|
|
fileName: 'recovered.mp3',
|
|
}),
|
|
})
|
|
}
|
|
|
|
return Promise.resolve({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({ material: { id: 'material-recovered' } }),
|
|
})
|
|
})
|
|
global.fetch = fetchMock as unknown as typeof fetch
|
|
|
|
const result = await completeBatchUpload('upload-same-id', {
|
|
materialType: '音乐',
|
|
productModel: 'SPU-RECOVER',
|
|
fileName: 'song.mp3',
|
|
})
|
|
|
|
expect(result.material.id).toBe('material-recovered')
|
|
expect(completeBodies).toHaveLength(2)
|
|
expect(completeBodies[0].uploadId).toBe('upload-same-id')
|
|
expect(completeBodies[1].uploadId).toBe('upload-same-id')
|
|
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
|
'/api/materials/upload/complete',
|
|
'/api/materials/upload/complete',
|
|
'/api/materials/upload',
|
|
])
|
|
})
|
|
|
|
test('complete 遇到磁盘空间不足 507 时不重试放大压力', async () => {
|
|
const fetchMock = jest.fn().mockResolvedValue({
|
|
ok: false,
|
|
status: 507,
|
|
headers: { get: () => null },
|
|
text: async () => 'disk full',
|
|
})
|
|
global.fetch = fetchMock as unknown as typeof fetch
|
|
|
|
await expect(
|
|
completeBatchUpload('upload-storage-low', {
|
|
materialType: '音乐',
|
|
productModel: 'SPU-LOW',
|
|
fileName: 'song.mp3',
|
|
}),
|
|
).rejects.toThrow('分片合并失败: 507')
|
|
|
|
expect(fetchMock).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
test('第二阶段响应丢失时只查 owner-scoped 状态,不盲目再次 POST', async () => {
|
|
const fetchMock = jest.fn((url: string, init?: RequestInit) => {
|
|
if (url === '/api/materials/upload/complete') {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
existingFilePath: 'uploads/temp/upload-response-lost/merged-song.mp3',
|
|
fileName: 'song.mp3',
|
|
}),
|
|
})
|
|
}
|
|
|
|
if (url === '/api/materials/upload' && init?.method === 'POST') {
|
|
const formData = init.body as FormData
|
|
expect(formData.get('uploadId')).toBe('upload-response-lost')
|
|
return Promise.reject(new TypeError('connection closed after request'))
|
|
}
|
|
|
|
if (url === '/api/materials/upload?uploadId=upload-response-lost') {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
status: 'completed',
|
|
material: { id: 'material-already-created' },
|
|
}),
|
|
})
|
|
}
|
|
|
|
return Promise.reject(new Error(`unexpected URL: ${url}`))
|
|
})
|
|
global.fetch = fetchMock as unknown as typeof fetch
|
|
|
|
const result = await completeBatchUpload('upload-response-lost', {
|
|
materialType: '音乐',
|
|
productModel: 'SPU-LOST',
|
|
fileName: 'song.mp3',
|
|
})
|
|
|
|
expect(result.material.id).toBe('material-already-created')
|
|
expect(fetchMock.mock.calls.filter(([url, init]) => (
|
|
url === '/api/materials/upload' && (init as RequestInit | undefined)?.method === 'POST'
|
|
))).toHaveLength(1)
|
|
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
|
'/api/materials/upload/complete',
|
|
'/api/materials/upload',
|
|
'/api/materials/upload?uploadId=upload-response-lost',
|
|
])
|
|
})
|
|
|
|
test('临时目录已删的断点重放通过状态确认,不再创建第二份素材', async () => {
|
|
const fetchMock = jest.fn((url: string) => {
|
|
if (url === '/api/materials/upload/complete') {
|
|
return Promise.resolve({
|
|
ok: false,
|
|
status: 404,
|
|
text: async () => 'session missing',
|
|
})
|
|
}
|
|
if (url === '/api/materials/upload?uploadId=upload-temp-deleted') {
|
|
return Promise.resolve({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
status: 'completed',
|
|
material: { id: 'material-existing' },
|
|
}),
|
|
})
|
|
}
|
|
return Promise.reject(new Error(`unexpected URL: ${url}`))
|
|
})
|
|
global.fetch = fetchMock as unknown as typeof fetch
|
|
|
|
const result = await completeBatchUpload('upload-temp-deleted', {
|
|
materialType: '品牌VI灵感',
|
|
productModel: 'SPU-EXISTING',
|
|
fileName: 'brand.pdf',
|
|
})
|
|
|
|
expect(result.material.id).toBe('material-existing')
|
|
expect(fetchMock.mock.calls.map(([url]) => url)).toEqual([
|
|
'/api/materials/upload/complete',
|
|
'/api/materials/upload?uploadId=upload-temp-deleted',
|
|
])
|
|
})
|
|
})
|