mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-01 05:44:38 +08:00
fix(storage): surface multipart complete failures
This commit is contained in:
@@ -423,6 +423,48 @@ describe('Download tasks API integration', () => {
|
||||
})
|
||||
})
|
||||
|
||||
it('returns storage failure details when multipart upload completion fails', async () => {
|
||||
vi.mocked(S3Service.prototype.completeMultipartUpload).mockRejectedValueOnce(new Error('InvalidPart: part missing'))
|
||||
const { app, db } = await createTestApp()
|
||||
await insertStorage(db)
|
||||
const headers = {
|
||||
...(await authedHeaders(app, 'multipart-complete-user@example.com')),
|
||||
'Content-Type': 'application/json',
|
||||
}
|
||||
|
||||
const createObjectRes = await app.request('/api/objects', {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
name: 'fixture.bin',
|
||||
type: 'application/octet-stream',
|
||||
size: 6 * 1024 * 1024 * 1024,
|
||||
parent: '',
|
||||
}),
|
||||
})
|
||||
expect(createObjectRes.status).toBe(201)
|
||||
const object = (await createObjectRes.json()) as { id: string }
|
||||
|
||||
const sessionRes = await app.request(`/api/objects/${object.id}/uploads`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({ partSize: 64 * 1024 * 1024 }),
|
||||
})
|
||||
expect(sessionRes.status).toBe(201)
|
||||
const session = (await sessionRes.json()) as { id: string }
|
||||
|
||||
const completeRes = await app.request(`/api/objects/${object.id}/uploads/${session.id}`, {
|
||||
method: 'PATCH',
|
||||
headers,
|
||||
body: JSON.stringify({ action: 'complete', parts: [{ partNumber: 1, etag: '"etag-1"' }] }),
|
||||
})
|
||||
|
||||
expect(completeRes.status).toBe(502)
|
||||
await expect(completeRes.json()).resolves.toEqual({
|
||||
error: 'Storage multipart upload complete failed: InvalidPart: part missing',
|
||||
})
|
||||
})
|
||||
|
||||
it('submits user task actions through downloader polling state', async () => {
|
||||
const { app, db } = await createTestApp({ DOWNLOAD_TOKEN_SECRET: 'test-download-token-secret' })
|
||||
await insertStorage(db)
|
||||
|
||||
@@ -114,13 +114,27 @@ export async function patchObjectUploadSession(
|
||||
if (row.status !== 'active') throw new ObjectUploadSessionError('invalid_state')
|
||||
const now = new Date()
|
||||
if (params.input.action === 'complete') {
|
||||
await s3.completeMultipartUpload(params.storage, row.storageKey, row.uploadId, params.input.parts)
|
||||
try {
|
||||
await s3.completeMultipartUpload(params.storage, row.storageKey, row.uploadId, params.input.parts)
|
||||
} catch (error) {
|
||||
throw new ObjectUploadSessionError(
|
||||
'storage_failure',
|
||||
`Storage multipart upload complete failed: ${(error as Error).message}`,
|
||||
)
|
||||
}
|
||||
await db
|
||||
.update(objectUploadSessions)
|
||||
.set({ status: 'completed', updatedAt: now })
|
||||
.where(eq(objectUploadSessions.id, row.id))
|
||||
} else {
|
||||
await s3.abortMultipartUpload(params.storage, row.storageKey, row.uploadId)
|
||||
try {
|
||||
await s3.abortMultipartUpload(params.storage, row.storageKey, row.uploadId)
|
||||
} catch (error) {
|
||||
throw new ObjectUploadSessionError(
|
||||
'storage_failure',
|
||||
`Storage multipart upload abort failed: ${(error as Error).message}`,
|
||||
)
|
||||
}
|
||||
await db
|
||||
.update(objectUploadSessions)
|
||||
.set({ status: 'aborted', updatedAt: now })
|
||||
|
||||
@@ -289,6 +289,21 @@ describe('S3Service', () => {
|
||||
expect(mockSend).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('rejects embedded S3 complete errors returned with HTTP 200', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(
|
||||
new Response('<Error><Code>InvalidPart</Code><Message>part does not exist</Message></Error>'),
|
||||
),
|
||||
)
|
||||
|
||||
await expect(
|
||||
service.completeMultipartUpload(storage, 'video.mp4', 'upload-1', [{ partNumber: 1, etag: '"etag-1"' }]),
|
||||
).rejects.toThrow('InvalidPart')
|
||||
})
|
||||
|
||||
it('aborts multipart uploads through a presigned DELETE', async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValueOnce(new Response(null, { status: 204 }))
|
||||
vi.stubGlobal('fetch', fetchMock)
|
||||
|
||||
@@ -116,8 +116,11 @@ export class S3Service {
|
||||
headers: { 'Content-Type': 'application/xml' },
|
||||
body: multipartCompleteXml(sortedParts),
|
||||
})
|
||||
const body = await response.text()
|
||||
if (!response.ok) {
|
||||
const body = await response.text()
|
||||
throw new Error(`S3 multipart upload complete failed: ${response.status}: ${body.trim()}`)
|
||||
}
|
||||
if (xmlTag(body, 'Error') !== null || xmlTag(body, 'Code') !== null) {
|
||||
throw new Error(`S3 multipart upload complete failed: ${response.status}: ${body.trim()}`)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user