gongxiangp2/components/upload/special-batch-uploaders.pool.test.ts
chenpei 7326e273e1 fix: harden media upload and release safety
Protect upload cleanup and atomic completion paths, add durable media worker leases and brand asset validation, and introduce verified migration/bootstrap/deployment gates.
2026-08-12 18:04:08 +08:00

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',
])
})
})