420 lines
12 KiB
TypeScript
420 lines
12 KiB
TypeScript
import { chunkUpload, getAllPendingUploads } from '@/lib/chunk-upload'
|
|
import { getOptimalConcurrency } from '@/lib/chunk-upload-config'
|
|
|
|
jest.mock('@/lib/chunk-upload-config', () => ({
|
|
getOptimalChunkSize: jest.fn().mockResolvedValue(1024 * 1024),
|
|
getOptimalConcurrency: jest.fn().mockResolvedValue(1),
|
|
getChunkTimeout: jest.fn(() => 30_000),
|
|
}))
|
|
|
|
type MockRequest<T = unknown> = {
|
|
result?: T
|
|
error?: unknown
|
|
onsuccess?: ((event?: { target: { result: T } }) => void) | null
|
|
onerror?: ((event?: { target: { error: unknown } }) => void) | null
|
|
onupgradeneeded?: ((event?: { target: { result: unknown } }) => void) | null
|
|
}
|
|
|
|
function createAsyncRequest<T>(executor: () => T): MockRequest<T> {
|
|
const request: MockRequest<T> = {}
|
|
|
|
setTimeout(() => {
|
|
try {
|
|
request.result = executor()
|
|
request.onsuccess?.({ target: { result: request.result as T } })
|
|
} catch (error) {
|
|
request.error = error
|
|
request.onerror?.({ target: { error } })
|
|
}
|
|
}, 0)
|
|
|
|
return request
|
|
}
|
|
|
|
function createIndexedDbMock(): IDBFactory {
|
|
const records = new Map<string, unknown>()
|
|
let initialized = false
|
|
|
|
const objectStore = {
|
|
put: (value: { fileId: string }) => createAsyncRequest(() => {
|
|
records.set(value.fileId, value)
|
|
return value.fileId
|
|
}),
|
|
get: (key: string) => createAsyncRequest(() => records.get(key)),
|
|
delete: (key: string) => createAsyncRequest(() => {
|
|
records.delete(key)
|
|
return undefined
|
|
}),
|
|
count: () => createAsyncRequest(() => records.size),
|
|
getAll: () => createAsyncRequest(() => Array.from(records.values())),
|
|
clear: () => createAsyncRequest(() => {
|
|
records.clear()
|
|
return undefined
|
|
}),
|
|
}
|
|
|
|
const db = {
|
|
objectStoreNames: {
|
|
contains: () => initialized,
|
|
},
|
|
createObjectStore: () => {
|
|
initialized = true
|
|
return objectStore
|
|
},
|
|
transaction: () => ({
|
|
objectStore: () => objectStore,
|
|
}),
|
|
}
|
|
|
|
return {
|
|
open: jest.fn(() => {
|
|
const request: MockRequest<typeof db> = {}
|
|
|
|
setTimeout(() => {
|
|
request.result = db
|
|
|
|
if (!initialized) {
|
|
request.onupgradeneeded?.({ target: { result: db } })
|
|
initialized = true
|
|
}
|
|
|
|
request.onsuccess?.({ target: { result: db } })
|
|
}, 0)
|
|
|
|
return request as unknown as IDBOpenDBRequest
|
|
}),
|
|
} as unknown as IDBFactory
|
|
}
|
|
|
|
class MockXMLHttpRequest {
|
|
static instances: MockXMLHttpRequest[] = []
|
|
static defaultStatus = 413
|
|
|
|
timeout = 0
|
|
status = MockXMLHttpRequest.defaultStatus
|
|
responseText = ''
|
|
upload = {
|
|
onloadstart: null as (() => void) | null,
|
|
onprogress: null as ((event: ProgressEvent) => void) | null,
|
|
}
|
|
onload: (() => void) | null = null
|
|
onerror: (() => void) | null = null
|
|
onabort: (() => void) | null = null
|
|
ontimeout: (() => void) | null = null
|
|
headers: Record<string, string> = {}
|
|
method = ''
|
|
url = ''
|
|
|
|
constructor() {
|
|
MockXMLHttpRequest.instances.push(this)
|
|
}
|
|
|
|
open(method: string, url: string) {
|
|
this.method = method
|
|
this.url = url
|
|
}
|
|
|
|
setRequestHeader(name: string, value: string) {
|
|
this.headers[name] = value
|
|
}
|
|
|
|
send() {
|
|
setTimeout(() => {
|
|
this.upload.onloadstart?.()
|
|
this.onload?.()
|
|
}, 0)
|
|
}
|
|
}
|
|
|
|
describe('chunkUpload', () => {
|
|
beforeEach(() => {
|
|
jest.clearAllMocks()
|
|
MockXMLHttpRequest.instances = []
|
|
MockXMLHttpRequest.defaultStatus = 413
|
|
|
|
Object.defineProperty(global, 'indexedDB', {
|
|
configurable: true,
|
|
writable: true,
|
|
value: createIndexedDbMock(),
|
|
})
|
|
|
|
Object.defineProperty(global, 'fetch', {
|
|
configurable: true,
|
|
writable: true,
|
|
value: jest.fn(),
|
|
})
|
|
|
|
Object.defineProperty(global, 'XMLHttpRequest', {
|
|
configurable: true,
|
|
writable: true,
|
|
value: MockXMLHttpRequest,
|
|
})
|
|
})
|
|
|
|
test('空文件会在初始化前被直接拦截', async () => {
|
|
const emptyFile = new File([], 'empty.psd', { type: 'application/octet-stream' })
|
|
|
|
await expect(chunkUpload(emptyFile, { materialType: '任务-详情' })).rejects.toThrow('为空')
|
|
expect(global.fetch).not.toHaveBeenCalled()
|
|
await expect(getAllPendingUploads()).resolves.toEqual([])
|
|
})
|
|
|
|
test('upload/init 返回 400 时不再无限重试', async () => {
|
|
;(global.fetch as jest.Mock).mockResolvedValue({
|
|
ok: false,
|
|
status: 400,
|
|
json: async () => ({ error: '上传文件不能为空' }),
|
|
})
|
|
|
|
const file = new File(['hello'], 'source.psb', { type: 'application/octet-stream' })
|
|
|
|
await expect(
|
|
chunkUpload(
|
|
file,
|
|
{ materialType: '任务-详情' },
|
|
{ maxRetries: 5, retryDelay: 0 },
|
|
),
|
|
).rejects.toThrow('上传文件不能为空')
|
|
|
|
expect(global.fetch).toHaveBeenCalledTimes(1)
|
|
await expect(getAllPendingUploads()).resolves.toEqual([])
|
|
})
|
|
|
|
test('upload/chunk 返回 413 时直接失败并清理会话', async () => {
|
|
;(global.fetch as jest.Mock)
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ uploadId: 'upload-too-large' }),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ message: '清理成功' }),
|
|
})
|
|
|
|
const file = new File(['hello'], 'source.psb', { type: 'application/octet-stream' })
|
|
|
|
await expect(
|
|
chunkUpload(
|
|
file,
|
|
{ materialType: '任务-详情' },
|
|
{ maxRetries: 5, retryDelay: 0 },
|
|
),
|
|
).rejects.toThrow('上传分片超过入口服务器限制')
|
|
|
|
expect(MockXMLHttpRequest.instances).toHaveLength(1)
|
|
expect(global.fetch).toHaveBeenCalledTimes(2)
|
|
expect(global.fetch).toHaveBeenNthCalledWith(
|
|
2,
|
|
'/api/materials/upload/cleanup',
|
|
expect.objectContaining({
|
|
method: 'POST',
|
|
body: JSON.stringify({ uploadId: 'upload-too-large' }),
|
|
}),
|
|
)
|
|
await expect(getAllPendingUploads()).resolves.toEqual([])
|
|
})
|
|
|
|
test('显式指定分片并发时不再读取网络自动并发配置', async () => {
|
|
;(global.fetch as jest.Mock)
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ uploadId: 'upload-explicit-concurrency' }),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ message: '清理成功' }),
|
|
})
|
|
|
|
const file = new File(['hello'], 'source.psb', { type: 'application/octet-stream' })
|
|
|
|
await expect(
|
|
chunkUpload(
|
|
file,
|
|
{ materialType: '任务-详情' },
|
|
{ maxRetries: 1, retryDelay: 0, chunkConcurrency: 2 },
|
|
),
|
|
).rejects.toThrow('上传分片超过入口服务器限制')
|
|
|
|
expect(getOptimalConcurrency).not.toHaveBeenCalled()
|
|
expect(MockXMLHttpRequest.instances).toHaveLength(1)
|
|
})
|
|
|
|
test('断点续传沿用服务端既有分片布局,不受公网内网切换影响', async () => {
|
|
;(global.fetch as jest.Mock)
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ uploadId: 'upload-resume-layout' }),
|
|
})
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ message: '清理成功' }),
|
|
})
|
|
|
|
const file = new File(['hello'], 'source.psb', { type: 'application/octet-stream' })
|
|
|
|
MockXMLHttpRequest.defaultStatus = 500
|
|
await expect(chunkUpload(
|
|
file,
|
|
{ materialType: '任务-详情' },
|
|
{ chunkSize: 2, chunkConcurrency: 1, maxRetries: 1, retryDelay: 0 },
|
|
)).rejects.toThrow('失败')
|
|
|
|
await expect(getAllPendingUploads()).resolves.toEqual([
|
|
expect.objectContaining({
|
|
uploadId: 'upload-resume-layout',
|
|
chunkSize: 2,
|
|
totalChunks: 3,
|
|
}),
|
|
])
|
|
|
|
MockXMLHttpRequest.defaultStatus = 413
|
|
await expect(chunkUpload(
|
|
file,
|
|
{ materialType: '任务-详情' },
|
|
// 模拟网络探测从公网2字节布局切到内网4字节布局。
|
|
{ chunkSize: 4, chunkConcurrency: 1, maxRetries: 1, retryDelay: 0 },
|
|
)).rejects.toThrow('上传分片超过入口服务器限制')
|
|
|
|
const resumedRequest = MockXMLHttpRequest.instances[1]
|
|
expect(resumedRequest.headers['X-Chunk-Size']).toBe('2')
|
|
expect(resumedRequest.headers['X-Chunk-Index']).toBe('0')
|
|
expect(global.fetch).toHaveBeenCalledTimes(2)
|
|
})
|
|
|
|
test('特殊批次跳过 complete 时保留断点,建档确认后才删除', async () => {
|
|
;(global.fetch as jest.Mock).mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ uploadId: 'upload-retained-special-batch' }),
|
|
})
|
|
MockXMLHttpRequest.defaultStatus = 200
|
|
|
|
const file = new File(['hello'], 'brand-vi.pdf', { type: 'application/pdf' })
|
|
const result = await chunkUpload(
|
|
file,
|
|
{ materialType: '品牌VI灵感' },
|
|
{
|
|
chunkSize: 5,
|
|
chunkConcurrency: 1,
|
|
skipComplete: true,
|
|
retainStateOnSkipComplete: true,
|
|
},
|
|
)
|
|
|
|
await expect(getAllPendingUploads()).resolves.toEqual([
|
|
expect.objectContaining({
|
|
uploadId: 'upload-retained-special-batch',
|
|
uploadedChunks: new Set([0]),
|
|
}),
|
|
])
|
|
|
|
await result.confirmMaterialCreated()
|
|
await expect(getAllPendingUploads()).resolves.toEqual([])
|
|
})
|
|
|
|
test('断点的上传用途不匹配时清理旧会话并重新初始化', async () => {
|
|
let initCount = 0
|
|
;(global.fetch as jest.Mock).mockImplementation((input: RequestInfo | URL) => {
|
|
if (String(input) === '/api/materials/upload/init') {
|
|
initCount += 1
|
|
return Promise.resolve({
|
|
ok: true,
|
|
json: async () => ({ uploadId: initCount === 1 ? 'upload-old-purpose' : 'upload-brand-purpose' }),
|
|
})
|
|
}
|
|
return Promise.resolve({ ok: true, json: async () => ({ message: '清理成功' }) })
|
|
})
|
|
|
|
const file = new File(['hello'], 'brand-guide.pdf', {
|
|
type: 'application/pdf',
|
|
lastModified: 123456,
|
|
})
|
|
|
|
MockXMLHttpRequest.defaultStatus = 500
|
|
await expect(chunkUpload(
|
|
file,
|
|
{ materialType: '任务-详情' },
|
|
{ chunkSize: 5, chunkConcurrency: 1, maxRetries: 1, retryDelay: 0 },
|
|
)).rejects.toThrow('上传块 0 失败')
|
|
|
|
MockXMLHttpRequest.defaultStatus = 413
|
|
await expect(chunkUpload(
|
|
file,
|
|
{ uploadPurpose: 'brand-knowledge' },
|
|
{ chunkSize: 5, chunkConcurrency: 1, maxRetries: 1, retryDelay: 0 },
|
|
)).rejects.toThrow('上传分片超过入口服务器限制')
|
|
|
|
const initCalls = (global.fetch as jest.Mock).mock.calls.filter(([url]) => (
|
|
String(url) === '/api/materials/upload/init'
|
|
))
|
|
expect(initCalls).toHaveLength(2)
|
|
expect(JSON.parse(initCalls[1][1].body)).toMatchObject({ uploadPurpose: 'brand-knowledge' })
|
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
'/api/materials/upload/cleanup',
|
|
expect.objectContaining({ body: JSON.stringify({ uploadId: 'upload-old-purpose' }) }),
|
|
)
|
|
})
|
|
|
|
test('同一文件并发选择两次时拒绝第二条,避免共用 uploadId 覆盖分片', async () => {
|
|
;(global.fetch as jest.Mock).mockResolvedValue({
|
|
ok: false,
|
|
status: 400,
|
|
json: async () => ({ error: '测试结束首条上传' }),
|
|
})
|
|
|
|
const file = new File(['hello'], 'duplicate.psb', {
|
|
type: 'application/octet-stream',
|
|
lastModified: 123456,
|
|
})
|
|
|
|
const firstUpload = chunkUpload(
|
|
file,
|
|
{ materialType: '任务-详情' },
|
|
{ maxRetries: 1, retryDelay: 0 },
|
|
)
|
|
|
|
await expect(chunkUpload(
|
|
file,
|
|
{ materialType: '任务-详情' },
|
|
{ maxRetries: 1, retryDelay: 0 },
|
|
)).rejects.toThrow('正在上传,请勿在同一批次重复选择')
|
|
|
|
await expect(firstUpload).rejects.toThrow('测试结束首条上传')
|
|
expect(global.fetch).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
test('普通 complete 响应丢失时保留断点且不调用 destructive cleanup', async () => {
|
|
;(global.fetch as jest.Mock)
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
json: async () => ({ uploadId: 'upload-response-lost' }),
|
|
})
|
|
.mockRejectedValueOnce(new TypeError('network connection lost'))
|
|
MockXMLHttpRequest.defaultStatus = 200
|
|
|
|
const file = new File(['hello'], 'source.psb', { type: 'application/octet-stream' })
|
|
|
|
await expect(chunkUpload(
|
|
file,
|
|
{ materialType: '3D场景' },
|
|
{
|
|
chunkSize: 5,
|
|
chunkConcurrency: 1,
|
|
maxRetries: 1,
|
|
retryDelay: 0,
|
|
},
|
|
)).rejects.toThrow('已保留上传断点')
|
|
|
|
await expect(getAllPendingUploads()).resolves.toEqual([
|
|
expect.objectContaining({
|
|
uploadId: 'upload-response-lost',
|
|
uploadedChunks: new Set([0]),
|
|
}),
|
|
])
|
|
expect(global.fetch).toHaveBeenCalledTimes(2)
|
|
expect(global.fetch).not.toHaveBeenCalledWith(
|
|
'/api/materials/upload/cleanup',
|
|
expect.anything(),
|
|
)
|
|
})
|
|
})
|