mirror of
https://github.com/saltbo/zpan.git
synced 2026-09-24 23:22:31 +08:00
* feat(avatars): host avatars + team logos on Cloud via SDK 2.4.0; remove public-bucket mode Host user avatars and org logos on the ZPan Cloud avatar service (zpan-cloud-sdk ^2.4.0) instead of a public S3/R2 bucket, then remove the now-dead storages.mode / public-bucket concept entirely (#456 parts 2-3). - image-upload gateway: upload/delete via SDK uploadAvatar/deleteAvatar against a bound Cloud client; validate mime (AVATAR_CONTENT_TYPES) + size (MAX_AVATAR_BYTES) before the call; map cloud error codes to 400/403/413/500; unbound instance returns 503 cloud_required (delete is a best-effort no-op). - licensing-cloud: createAvatarUploadClient builds the client with a plain-object bearer header so both the image content-type and Authorization survive hono's per-request header merge (a Headers instance would be dropped). - drop storages.mode (migration via drizzle-kit), StorageRepo.select() no longer takes a mode, remove StorageMode / Storage.mode / mode schema+audit+UI+i18n and the PUBLIC_IMAGES bucket + PUBLIC_IMAGES_URL wiring. Agent-Profile: https://agent-kanban.dev/agents/f759c704c282d88a * ci(deploy): drop dead PUBLIC_IMAGES R2 provisioning from CF deploy The Cloud avatar migration removed the PUBLIC_IMAGES binding from wrangler.toml, so the deploy workflow's R2 public-images steps are dead and must go too — otherwise every CF deploy keeps re-provisioning a public-read zpan-public-images bucket (the footgun #456 eliminates) and sets an unused PUBLIC_IMAGES_URL secret. Removes the bucket-create, managed-public-URL, and secret steps (steps.r2 was only consumed by the secret step). Also drops a stale storage-modes line from the v2.0 roadmap. Agent-Profile: https://agent-kanban.dev/agents/f759c704c282d88a --------- Co-authored-by: Alex Chen <alex-chen@mails.agent-kanban.dev>
618 lines
22 KiB
TypeScript
618 lines
22 KiB
TypeScript
import type { Storage } from '@shared/types'
|
|
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
import { S3Service } from './s3.js'
|
|
|
|
const mockSend = vi.fn()
|
|
|
|
vi.mock('@aws-sdk/client-s3', () => {
|
|
class MockS3Client {
|
|
config: unknown
|
|
constructor(config: unknown) {
|
|
this.config = config
|
|
}
|
|
send = mockSend
|
|
}
|
|
class MockPutObjectCommand {
|
|
input: unknown
|
|
constructor(input: unknown) {
|
|
this.input = input
|
|
}
|
|
}
|
|
class MockCreateMultipartUploadCommand {
|
|
input: unknown
|
|
constructor(input: unknown) {
|
|
this.input = input
|
|
}
|
|
}
|
|
class MockUploadPartCommand {
|
|
input: unknown
|
|
constructor(input: unknown) {
|
|
this.input = input
|
|
}
|
|
}
|
|
class MockCompleteMultipartUploadCommand {
|
|
input: unknown
|
|
constructor(input: unknown) {
|
|
this.input = input
|
|
}
|
|
}
|
|
class MockAbortMultipartUploadCommand {
|
|
input: unknown
|
|
constructor(input: unknown) {
|
|
this.input = input
|
|
}
|
|
}
|
|
class MockGetObjectCommand {
|
|
input: unknown
|
|
constructor(input: unknown) {
|
|
this.input = input
|
|
}
|
|
}
|
|
class MockHeadObjectCommand {
|
|
input: unknown
|
|
constructor(input: unknown) {
|
|
this.input = input
|
|
}
|
|
}
|
|
class MockCopyObjectCommand {
|
|
input: unknown
|
|
constructor(input: unknown) {
|
|
this.input = input
|
|
}
|
|
}
|
|
class MockDeleteObjectCommand {
|
|
input: unknown
|
|
constructor(input: unknown) {
|
|
this.input = input
|
|
}
|
|
}
|
|
class MockDeleteObjectsCommand {
|
|
input: unknown
|
|
constructor(input: unknown) {
|
|
this.input = input
|
|
}
|
|
}
|
|
return {
|
|
S3Client: MockS3Client,
|
|
PutObjectCommand: MockPutObjectCommand,
|
|
CreateMultipartUploadCommand: MockCreateMultipartUploadCommand,
|
|
UploadPartCommand: MockUploadPartCommand,
|
|
CompleteMultipartUploadCommand: MockCompleteMultipartUploadCommand,
|
|
AbortMultipartUploadCommand: MockAbortMultipartUploadCommand,
|
|
GetObjectCommand: MockGetObjectCommand,
|
|
HeadObjectCommand: MockHeadObjectCommand,
|
|
CopyObjectCommand: MockCopyObjectCommand,
|
|
DeleteObjectCommand: MockDeleteObjectCommand,
|
|
DeleteObjectsCommand: MockDeleteObjectsCommand,
|
|
}
|
|
})
|
|
|
|
vi.mock('@aws-sdk/s3-request-presigner', () => ({
|
|
getSignedUrl: vi.fn().mockResolvedValue('https://signed-url.example.com'),
|
|
}))
|
|
|
|
function makeStorage(overrides: Partial<Storage> = {}): Storage {
|
|
return {
|
|
id: 's1',
|
|
title: 'Test',
|
|
bucket: 'my-bucket',
|
|
endpoint: 'https://s3.example.com',
|
|
region: 'us-east-1',
|
|
accessKey: 'AKID',
|
|
secretKey: 'SECRET',
|
|
filePath: '',
|
|
customHost: '',
|
|
capacity: 0,
|
|
egressCreditBillingEnabled: false,
|
|
egressCreditUnitBytes: 104857600,
|
|
egressCreditPerUnit: 1,
|
|
used: 0,
|
|
status: 'active',
|
|
createdAt: '2026-01-01',
|
|
updatedAt: '2026-01-01',
|
|
...overrides,
|
|
}
|
|
}
|
|
|
|
describe('S3Service', () => {
|
|
const service = new S3Service()
|
|
const storage = makeStorage()
|
|
|
|
beforeEach(() => {
|
|
mockSend.mockReset()
|
|
vi.clearAllMocks()
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
describe('createClient', () => {
|
|
it('creates a client with correct config', () => {
|
|
const client = service.createClient(storage)
|
|
expect(client.config).toMatchObject({
|
|
region: 'us-east-1',
|
|
endpoint: 'https://s3.example.com',
|
|
requestChecksumCalculation: 'WHEN_REQUIRED',
|
|
forcePathStyle: true,
|
|
})
|
|
})
|
|
})
|
|
|
|
describe('presignUpload', () => {
|
|
it('returns a signed URL', async () => {
|
|
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner')
|
|
const url = await service.presignUpload(storage, 'test.jpg', 'image/jpeg')
|
|
expect(url).toBe('https://signed-url.example.com')
|
|
expect(getSignedUrl).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({ input: { Bucket: 'my-bucket', Key: 'test.jpg', ContentType: 'image/jpeg' } }),
|
|
{ expiresIn: 3600 },
|
|
)
|
|
})
|
|
|
|
it('respects custom expiresIn', async () => {
|
|
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner')
|
|
await service.presignUpload(storage, 'test.jpg', 'image/jpeg', 600)
|
|
expect(getSignedUrl).toHaveBeenCalledWith(expect.anything(), expect.anything(), { expiresIn: 600 })
|
|
})
|
|
|
|
it('includes Content-Disposition metadata when filename is provided', async () => {
|
|
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner')
|
|
await service.presignUpload(storage, 'test.jpg', 'image/jpeg', 'my file.jpg')
|
|
expect(getSignedUrl).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
input: expect.objectContaining({
|
|
ContentDisposition: 'attachment; filename="my file.jpg"; filename*=UTF-8\'\'my%20file.jpg',
|
|
}),
|
|
}),
|
|
expect.anything(),
|
|
)
|
|
})
|
|
|
|
it('does not apply customHost to the signed URL to support PUT write uploads', async () => {
|
|
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner')
|
|
vi.mocked(getSignedUrl).mockResolvedValueOnce('https://s3.example.com/my-bucket/test.jpg?X-Amz-Signature=abc')
|
|
const s = makeStorage({ customHost: 'https://cdn.example.com' })
|
|
const url = await service.presignUpload(s, 'test.jpg', 'image/jpeg')
|
|
expect(url).toBe('https://s3.example.com/my-bucket/test.jpg?X-Amz-Signature=abc')
|
|
})
|
|
|
|
it('leaves URL unchanged when customHost is empty', async () => {
|
|
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner')
|
|
vi.mocked(getSignedUrl).mockResolvedValueOnce('https://s3.example.com/my-bucket/test.jpg?X-Amz-Signature=abc')
|
|
const url = await service.presignUpload(storage, 'test.jpg', 'image/jpeg')
|
|
expect(url).toBe('https://s3.example.com/my-bucket/test.jpg?X-Amz-Signature=abc')
|
|
})
|
|
})
|
|
|
|
describe('presignDownload', () => {
|
|
it('returns a signed URL with Content-Disposition', async () => {
|
|
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner')
|
|
const url = await service.presignDownload(storage, 'test.jpg', 'my photo.jpg')
|
|
expect(url).toBe('https://signed-url.example.com')
|
|
expect(getSignedUrl).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
input: expect.objectContaining({
|
|
ResponseContentDisposition: 'attachment; filename="my photo.jpg"; filename*=UTF-8\'\'my%20photo.jpg',
|
|
}),
|
|
}),
|
|
expect.anything(),
|
|
)
|
|
})
|
|
|
|
it('applies customHost to the signed URL and omits the bucket name from the path', async () => {
|
|
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner')
|
|
vi.mocked(getSignedUrl).mockResolvedValueOnce('https://s3.example.com/my-bucket/test.jpg?X-Amz-Signature=abc')
|
|
const s = makeStorage({ customHost: 'https://cdn.example.com' })
|
|
const url = await service.presignDownload(s, 'test.jpg', 'test.jpg')
|
|
expect(url).toBe('https://cdn.example.com/test.jpg?X-Amz-Signature=abc')
|
|
})
|
|
})
|
|
|
|
describe('presignInline', () => {
|
|
it('returns a signed URL with inline Content-Disposition and ResponseContentType', async () => {
|
|
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner')
|
|
const url = await service.presignInline(storage, 'images/photo.jpg', 'image/jpeg')
|
|
expect(url).toBe('https://signed-url.example.com')
|
|
expect(getSignedUrl).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
input: expect.objectContaining({
|
|
ResponseContentDisposition: 'inline',
|
|
ResponseContentType: 'image/jpeg',
|
|
}),
|
|
}),
|
|
expect.anything(),
|
|
)
|
|
})
|
|
|
|
it('respects custom expiresIn', async () => {
|
|
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner')
|
|
await service.presignInline(storage, 'img.png', 'image/png', 600)
|
|
expect(getSignedUrl).toHaveBeenCalledWith(expect.anything(), expect.anything(), { expiresIn: 600 })
|
|
})
|
|
|
|
it('applies customHost to the signed URL and omits the bucket name from the path', async () => {
|
|
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner')
|
|
vi.mocked(getSignedUrl).mockResolvedValueOnce(
|
|
'https://s3.example.com/my-bucket/images/photo.jpg?X-Amz-Signature=abc',
|
|
)
|
|
const s = makeStorage({ customHost: 'https://cdn.example.com' })
|
|
const url = await service.presignInline(s, 'images/photo.jpg', 'image/jpeg')
|
|
expect(url).toBe('https://cdn.example.com/images/photo.jpg?X-Amz-Signature=abc')
|
|
})
|
|
})
|
|
|
|
describe('multipart upload controls', () => {
|
|
it('creates multipart uploads through a presigned POST and parses upload id', async () => {
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(
|
|
new Response('<CreateMultipartUploadResult><UploadId>upload-1</UploadId></CreateMultipartUploadResult>'),
|
|
)
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
await expect(service.createMultipartUpload(storage, 'video.mp4', 'video/mp4')).resolves.toBe('upload-1')
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith('https://signed-url.example.com', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'video/mp4' },
|
|
})
|
|
expect(mockSend).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it('fails create multipart uploads when S3 omits the upload id', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response('<CreateMultipartUploadResult />')))
|
|
|
|
await expect(service.createMultipartUpload(storage, 'video.mp4', 'video/mp4')).rejects.toThrow(
|
|
'S3 multipart upload did not return an upload id',
|
|
)
|
|
})
|
|
|
|
it('completes multipart uploads through a presigned POST XML body', async () => {
|
|
const { getSignedUrl } = await import('@aws-sdk/s3-request-presigner')
|
|
const fetchMock = vi.fn().mockResolvedValueOnce(new Response('<CompleteMultipartUploadResult />'))
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
await expect(
|
|
service.completeMultipartUpload(storage, 'video.mp4', 'upload-1', [
|
|
{ partNumber: 2, etag: '"etag&2"' },
|
|
{ partNumber: 1, etag: '"etag-1"' },
|
|
]),
|
|
).resolves.toBeUndefined()
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith('https://signed-url.example.com', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'application/xml' },
|
|
body: '<CompleteMultipartUpload><Part><PartNumber>1</PartNumber><ETag>"etag-1"</ETag></Part><Part><PartNumber>2</PartNumber><ETag>"etag&2"</ETag></Part></CompleteMultipartUpload>',
|
|
})
|
|
expect(getSignedUrl).toHaveBeenCalledWith(
|
|
expect.anything(),
|
|
expect.objectContaining({
|
|
input: { Bucket: 'my-bucket', Key: 'video.mp4', UploadId: 'upload-1' },
|
|
}),
|
|
expect.anything(),
|
|
)
|
|
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)
|
|
|
|
await expect(service.abortMultipartUpload(storage, 'video.mp4', 'upload-1')).resolves.toBeUndefined()
|
|
|
|
expect(fetchMock).toHaveBeenCalledWith('https://signed-url.example.com', { method: 'DELETE' })
|
|
expect(mockSend).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('getPublicUrl', () => {
|
|
it('uses customHost when available', () => {
|
|
const s = makeStorage({ customHost: 'https://cdn.example.com' })
|
|
expect(service.getPublicUrl(s, 'a/b.jpg')).toBe('https://cdn.example.com/a/b.jpg')
|
|
})
|
|
|
|
it('strips trailing slash from customHost', () => {
|
|
const s = makeStorage({ customHost: 'https://cdn.example.com/' })
|
|
expect(service.getPublicUrl(s, 'a/b.jpg')).toBe('https://cdn.example.com/a/b.jpg')
|
|
})
|
|
|
|
it('falls back to endpoint/bucket when no customHost', () => {
|
|
expect(service.getPublicUrl(storage, 'a/b.jpg')).toBe('https://s3.example.com/my-bucket/a/b.jpg')
|
|
})
|
|
|
|
it('strips trailing slash from endpoint', () => {
|
|
const s = makeStorage({ endpoint: 'https://s3.example.com/' })
|
|
expect(service.getPublicUrl(s, 'a/b.jpg')).toBe('https://s3.example.com/my-bucket/a/b.jpg')
|
|
})
|
|
})
|
|
|
|
describe('headObject', () => {
|
|
it('returns size, contentType, and the quote-stripped etag', async () => {
|
|
mockSend.mockResolvedValueOnce({
|
|
ContentLength: 1024,
|
|
ContentType: 'image/png',
|
|
ETag: '"abc123"',
|
|
$metadata: {},
|
|
})
|
|
const result = await service.headObject(storage, 'test.png')
|
|
expect(result).toEqual({ size: 1024, contentType: 'image/png', etag: 'abc123' })
|
|
expect(mockSend).toHaveBeenCalledWith(
|
|
expect.objectContaining({ input: { Bucket: 'my-bucket', Key: 'test.png' } }),
|
|
)
|
|
})
|
|
|
|
it('defaults size to 0, contentType to application/octet-stream, and etag to empty', async () => {
|
|
mockSend.mockResolvedValueOnce({ $metadata: {} })
|
|
const result = await service.headObject(storage, 'test.bin')
|
|
expect(result).toEqual({ size: 0, contentType: 'application/octet-stream', etag: '' })
|
|
})
|
|
})
|
|
|
|
describe('getObjectBytes', () => {
|
|
it('returns bytes from fetched object bodies', async () => {
|
|
const bytes = new Uint8Array([1, 2, 3])
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(bytes)))
|
|
|
|
await expect(service.getObjectBytes(storage, 'test.bin')).resolves.toEqual(bytes)
|
|
expect(fetch).toHaveBeenCalledWith('https://signed-url.example.com', undefined)
|
|
})
|
|
|
|
it('sends Range when reading partial object bytes', async () => {
|
|
const bytes = new Uint8Array([4, 5, 6])
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(bytes, { status: 206 })))
|
|
|
|
await expect(service.getObjectBytes(storage, 'test.bin', 'bytes=0-2')).resolves.toEqual(bytes)
|
|
expect(fetch).toHaveBeenCalledWith('https://signed-url.example.com', {
|
|
headers: { Range: 'bytes=0-2' },
|
|
})
|
|
})
|
|
|
|
it('rejects failed object reads', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(null, { status: 404 })))
|
|
|
|
await expect(service.getObjectBytes(storage, 'missing.bin')).rejects.toThrow('S3 object read failed: 404')
|
|
})
|
|
})
|
|
|
|
describe('getObjectBody', () => {
|
|
it('returns fetched ReadableStream bodies without buffering', async () => {
|
|
const stream = new ReadableStream()
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(stream)))
|
|
|
|
await expect(service.getObjectBody(storage, 'test.bin')).resolves.toBe(stream)
|
|
})
|
|
})
|
|
|
|
describe('copyObject', () => {
|
|
it('sends CopyObjectCommand with correct CopySource', async () => {
|
|
mockSend.mockResolvedValueOnce({ $metadata: {} })
|
|
const dst = makeStorage({ bucket: 'dst-bucket' })
|
|
await service.copyObject(storage, 'src.jpg', dst, 'dst.jpg')
|
|
expect(mockSend).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
input: {
|
|
CopySource: 'my-bucket/src.jpg',
|
|
Bucket: 'dst-bucket',
|
|
Key: 'dst.jpg',
|
|
},
|
|
}),
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('deleteObject', () => {
|
|
it('sends DeleteObjectCommand', async () => {
|
|
mockSend.mockResolvedValueOnce({ $metadata: {} })
|
|
await service.deleteObject(storage, 'test.jpg')
|
|
expect(mockSend).toHaveBeenCalledWith(
|
|
expect.objectContaining({ input: { Bucket: 'my-bucket', Key: 'test.jpg' } }),
|
|
)
|
|
})
|
|
})
|
|
|
|
describe('deleteObjects', () => {
|
|
it('sends individual DeleteObjectCommand for each key', async () => {
|
|
mockSend.mockResolvedValue({ $metadata: {} })
|
|
await service.deleteObjects(storage, ['a.jpg', 'b.jpg'])
|
|
expect(mockSend).toHaveBeenCalledWith(expect.objectContaining({ input: { Bucket: 'my-bucket', Key: 'a.jpg' } }))
|
|
expect(mockSend).toHaveBeenCalledWith(expect.objectContaining({ input: { Bucket: 'my-bucket', Key: 'b.jpg' } }))
|
|
})
|
|
|
|
it('skips API call for empty keys array', async () => {
|
|
mockSend.mockClear()
|
|
await service.deleteObjects(storage, [])
|
|
expect(mockSend).not.toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe('putObject', () => {
|
|
it('sends PutObjectCommand with correct params for Uint8Array body', async () => {
|
|
mockSend.mockResolvedValueOnce({ $metadata: {} })
|
|
const body = new Uint8Array([1, 2, 3])
|
|
await expect(service.putObject(storage, 'images/test.png', body, 'image/png')).resolves.toBe(3)
|
|
expect(mockSend).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
input: {
|
|
Bucket: 'my-bucket',
|
|
Key: 'images/test.png',
|
|
Body: body,
|
|
ContentType: 'image/png',
|
|
ContentLength: 3,
|
|
},
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('uploads small fixed-length ReadableStream bodies directly', async () => {
|
|
mockSend.mockResolvedValueOnce({ $metadata: {} })
|
|
const body = bytesStream(new Uint8Array([1, 2, 3]))
|
|
|
|
await expect(service.putObject(storage, 'notes/test.txt', body, 'text/plain', 3)).resolves.toBe(3)
|
|
|
|
expect(mockSend).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
input: {
|
|
Bucket: 'my-bucket',
|
|
Key: 'notes/test.txt',
|
|
Body: new Uint8Array([1, 2, 3]),
|
|
ContentType: 'text/plain',
|
|
ContentLength: 3,
|
|
},
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('rejects small fixed-length streams that do not match Content-Length', async () => {
|
|
const body = bytesStream(new Uint8Array([1, 2, 3]))
|
|
|
|
await expect(service.putObject(storage, 'notes/test.txt', body, 'text/plain', 4)).rejects.toThrow(
|
|
'Request body length does not match Content-Length',
|
|
)
|
|
})
|
|
|
|
it('uploads large ReadableStream bodies through a presigned PUT without buffering', async () => {
|
|
mockSend.mockClear()
|
|
const fetchMock = vi.fn().mockResolvedValueOnce(new Response(null, { status: 200 }))
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
const body = new ReadableStream()
|
|
await expect(service.putObject(storage, 'videos/test.mp4', body, 'video/mp4', 1024 * 1024)).resolves.toBe(
|
|
1024 * 1024,
|
|
)
|
|
expect(fetchMock).toHaveBeenCalledWith('https://signed-url.example.com', {
|
|
method: 'PUT',
|
|
headers: {
|
|
'Content-Type': 'video/mp4',
|
|
'Content-Length': '1048576',
|
|
},
|
|
body,
|
|
})
|
|
expect(mockSend).not.toHaveBeenCalled()
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
it('fails when presigned stream upload is rejected', async () => {
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(new Response(null, { status: 403 })))
|
|
await expect(
|
|
service.putObject(storage, 'videos/test.mp4', new ReadableStream(), 'video/mp4', 1024 * 1024),
|
|
).rejects.toThrow('S3 stream upload failed: 403')
|
|
vi.unstubAllGlobals()
|
|
})
|
|
|
|
it('uploads ReadableStream bodies without content length through multipart upload', async () => {
|
|
mockSend.mockClear()
|
|
mockSend.mockResolvedValueOnce({ ETag: '"part-1"' })
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(
|
|
new Response('<CreateMultipartUploadResult><UploadId>upload-1</UploadId></CreateMultipartUploadResult>'),
|
|
)
|
|
.mockResolvedValueOnce(new Response('<CompleteMultipartUploadResult />'))
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
const body = bytesStream(new Uint8Array([1, 2, 3]))
|
|
|
|
await expect(service.putObject(storage, 'videos/test.mp4', body, 'video/mp4')).resolves.toBe(3)
|
|
|
|
expect(fetchMock).toHaveBeenNthCalledWith(1, 'https://signed-url.example.com', {
|
|
method: 'POST',
|
|
headers: { 'Content-Type': 'video/mp4' },
|
|
})
|
|
expect(mockSend).toHaveBeenCalledWith(
|
|
expect.objectContaining({
|
|
input: {
|
|
Bucket: 'my-bucket',
|
|
Key: 'videos/test.mp4',
|
|
UploadId: 'upload-1',
|
|
PartNumber: 1,
|
|
Body: new Uint8Array([1, 2, 3]),
|
|
ContentLength: 3,
|
|
},
|
|
}),
|
|
)
|
|
expect(fetchMock).toHaveBeenNthCalledWith(
|
|
2,
|
|
'https://signed-url.example.com',
|
|
expect.objectContaining({ method: 'POST' }),
|
|
)
|
|
})
|
|
|
|
it('writes empty unknown-length streams as empty objects', async () => {
|
|
mockSend.mockClear()
|
|
mockSend.mockResolvedValueOnce({})
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(
|
|
new Response('<CreateMultipartUploadResult><UploadId>upload-1</UploadId></CreateMultipartUploadResult>'),
|
|
)
|
|
.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
await expect(service.putObject(storage, 'empty.bin', bytesStream(), 'application/octet-stream')).resolves.toBe(0)
|
|
|
|
expect(fetchMock).toHaveBeenNthCalledWith(2, 'https://signed-url.example.com', { method: 'DELETE' })
|
|
expect(mockSend).toHaveBeenNthCalledWith(
|
|
1,
|
|
expect.objectContaining({
|
|
input: {
|
|
Bucket: 'my-bucket',
|
|
Key: 'empty.bin',
|
|
Body: new Uint8Array(),
|
|
ContentType: 'application/octet-stream',
|
|
ContentLength: 0,
|
|
},
|
|
}),
|
|
)
|
|
})
|
|
|
|
it('aborts multipart upload when a part upload fails', async () => {
|
|
mockSend.mockClear()
|
|
mockSend.mockRejectedValueOnce(new Error('part failed'))
|
|
const fetchMock = vi
|
|
.fn()
|
|
.mockResolvedValueOnce(
|
|
new Response('<CreateMultipartUploadResult><UploadId>upload-1</UploadId></CreateMultipartUploadResult>'),
|
|
)
|
|
.mockResolvedValueOnce(new Response(null, { status: 204 }))
|
|
vi.stubGlobal('fetch', fetchMock)
|
|
|
|
await expect(
|
|
service.putObject(storage, 'fail.mp4', bytesStream(new Uint8Array([1])), 'video/mp4'),
|
|
).rejects.toThrow('part failed')
|
|
|
|
expect(fetchMock).toHaveBeenLastCalledWith('https://signed-url.example.com', { method: 'DELETE' })
|
|
})
|
|
|
|
it('propagates S3 errors', async () => {
|
|
mockSend.mockRejectedValueOnce(new Error('S3 put failed'))
|
|
await expect(service.putObject(storage, 'fail.png', new Uint8Array(), 'image/png')).rejects.toThrow(
|
|
'S3 put failed',
|
|
)
|
|
})
|
|
})
|
|
})
|
|
|
|
function bytesStream(...chunks: Uint8Array[]): ReadableStream<Uint8Array> {
|
|
return new ReadableStream({
|
|
start(controller) {
|
|
for (const chunk of chunks) controller.enqueue(chunk)
|
|
controller.close()
|
|
},
|
|
})
|
|
}
|