11'use strict'
22
3- import { S3Client } from '@aws-sdk/client-s3'
3+ import { HeadObjectCommand , PutObjectCommand , S3Client } from '@aws-sdk/client-s3'
4+ import { Upload } from '@aws-sdk/lib-storage'
45import { Readable } from 'stream'
5- import { S3Backend } from '../storage/backend/s3/adapter'
6+ import { getConfig } from '../config'
7+ import { ErrorCode , isStorageError } from '../internal/errors'
8+ import { MAX_PUT_OBJECT_SIZE , S3Backend } from '../storage/backend/s3/adapter'
69
710jest . mock ( '@aws-sdk/client-s3' , ( ) => {
811 const originalModule = jest . requireActual ( '@aws-sdk/client-s3' )
@@ -14,17 +17,76 @@ jest.mock('@aws-sdk/client-s3', () => {
1417 }
1518} )
1619
20+ jest . mock ( '@aws-sdk/lib-storage' , ( ) => {
21+ const originalModule = jest . requireActual ( '@aws-sdk/lib-storage' )
22+ return {
23+ ...originalModule ,
24+ Upload : jest . fn ( ) ,
25+ }
26+ } )
27+
28+ type MockUploadInstance = {
29+ options : any
30+ abort : jest . Mock
31+ done : jest . Mock < Promise < any > , [ ] >
32+ on : jest . Mock
33+ off : jest . Mock
34+ emit : ( event : string , payload : unknown ) => void
35+ }
36+
1737describe ( 'S3Backend' , ( ) => {
1838 let mockSend : jest . Mock
39+ let mockUploadDone : jest . Mock < Promise < any > , [ MockUploadInstance ] >
40+ let uploadInstances : MockUploadInstance [ ]
1941
2042 beforeEach ( ( ) => {
2143 jest . clearAllMocks ( )
2244 mockSend = jest . fn ( )
45+ mockUploadDone = jest . fn ( ) . mockResolvedValue ( {
46+ ETag : '"multipart-etag"' ,
47+ $metadata : {
48+ httpStatusCode : 200 ,
49+ } ,
50+ } )
51+ uploadInstances = [ ]
52+
2353 ; ( S3Client as jest . Mock ) . mockImplementation ( ( ) => ( {
2454 send : mockSend ,
2555 } ) )
56+
57+ ; ( Upload as unknown as jest . Mock ) . mockImplementation ( ( options ) => {
58+ const handlers = new Map < string , Set < ( payload : unknown ) => void > > ( )
59+ const instance = { } as MockUploadInstance
60+
61+ instance . options = options
62+ instance . abort = jest . fn ( )
63+ instance . done = jest . fn ( ( ) => mockUploadDone ( instance ) )
64+ instance . on = jest . fn ( ( event : string , handler : ( payload : unknown ) => void ) => {
65+ const eventHandlers = handlers . get ( event ) ?? new Set ( )
66+ eventHandlers . add ( handler )
67+ handlers . set ( event , eventHandlers )
68+ return instance
69+ } )
70+ instance . off = jest . fn ( ( event : string , handler : ( payload : unknown ) => void ) => {
71+ handlers . get ( event ) ?. delete ( handler )
72+ return instance
73+ } )
74+ instance . emit = ( event : string , payload : unknown ) => {
75+ handlers . get ( event ) ?. forEach ( ( handler ) => handler ( payload ) )
76+ }
77+
78+ uploadInstances . push ( instance )
79+ return instance
80+ } )
2681 } )
2782
83+ function createBackend ( ) {
84+ return new S3Backend ( {
85+ region : 'us-east-1' ,
86+ endpoint : 'http://localhost:9000' ,
87+ } )
88+ }
89+
2890 describe ( 'getObject' , ( ) => {
2991 test ( 'should return correct default MIME type when S3 returns no ContentType' , async ( ) => {
3092 mockSend . mockResolvedValue ( {
@@ -38,10 +100,7 @@ describe('S3Backend', () => {
38100 } ,
39101 } )
40102
41- const backend = new S3Backend ( {
42- region : 'us-east-1' ,
43- endpoint : 'http://localhost:9000' ,
44- } )
103+ const backend = createBackend ( )
45104
46105 const result = await backend . getObject ( 'test-bucket' , 'test-key' , undefined )
47106
@@ -64,14 +123,151 @@ describe('S3Backend', () => {
64123 } ,
65124 } )
66125
67- const backend = new S3Backend ( {
68- region : 'us-east-1' ,
69- endpoint : 'http://localhost:9000' ,
70- } )
126+ const backend = createBackend ( )
71127
72128 const result = await backend . getObject ( 'test-bucket' , 'test-key' , undefined )
73129
74130 expect ( result . metadata . mimetype ) . toBe ( 'image/png' )
75131 } )
76132 } )
133+
134+ describe ( 'uploadObject' , ( ) => {
135+ test ( 'uses PutObject for known-size uploads within the single-request limit' , async ( ) => {
136+ mockSend . mockResolvedValue ( {
137+ ETag : '"put-etag"' ,
138+ $metadata : {
139+ httpStatusCode : 200 ,
140+ } ,
141+ } )
142+
143+ const backend = createBackend ( )
144+ const result = await backend . uploadObject (
145+ 'test-bucket' ,
146+ 'test-key' ,
147+ undefined ,
148+ Readable . from ( [ 'hello' ] ) ,
149+ 'text/plain' ,
150+ 'max-age=60' ,
151+ undefined ,
152+ 5
153+ )
154+
155+ expect ( mockSend ) . toHaveBeenCalledTimes ( 1 )
156+ expect ( mockSend . mock . calls [ 0 ] [ 0 ] ) . toBeInstanceOf ( PutObjectCommand )
157+ expect ( mockSend . mock . calls [ 0 ] [ 0 ] . input ) . toMatchObject ( {
158+ Bucket : 'test-bucket' ,
159+ Key : 'test-key' ,
160+ ContentType : 'text/plain' ,
161+ CacheControl : 'max-age=60' ,
162+ ContentLength : 5 ,
163+ } )
164+ expect ( Upload ) . not . toHaveBeenCalled ( )
165+ expect ( result ) . toMatchObject ( {
166+ httpStatusCode : 200 ,
167+ cacheControl : 'max-age=60' ,
168+ eTag : '"put-etag"' ,
169+ mimetype : 'text/plain' ,
170+ contentLength : 5 ,
171+ size : 5 ,
172+ } )
173+ } )
174+
175+ test ( 'falls back to multipart upload when content length exceeds the single-request limit' , async ( ) => {
176+ const overLimit = MAX_PUT_OBJECT_SIZE + 1
177+ const lastModified = new Date ( '2024-01-01T00:00:00.000Z' )
178+
179+ mockUploadDone . mockImplementationOnce ( async ( instance ) => {
180+ instance . emit ( 'httpUploadProgress' , { loaded : 1 } )
181+ return {
182+ ETag : '"multipart-etag"' ,
183+ $metadata : {
184+ httpStatusCode : 200 ,
185+ } ,
186+ }
187+ } )
188+ mockSend . mockResolvedValueOnce ( {
189+ CacheControl : 'max-age=60' ,
190+ ContentType : 'text/plain' ,
191+ ContentLength : overLimit ,
192+ ETag : '"head-etag"' ,
193+ LastModified : lastModified ,
194+ $metadata : {
195+ httpStatusCode : 200 ,
196+ } ,
197+ } )
198+
199+ const backend = createBackend ( )
200+ const result = await backend . uploadObject (
201+ 'test-bucket' ,
202+ 'test-key' ,
203+ undefined ,
204+ Readable . from ( [ 'hello' ] ) ,
205+ 'text/plain' ,
206+ 'max-age=60' ,
207+ undefined ,
208+ overLimit
209+ )
210+
211+ expect ( Upload ) . toHaveBeenCalledTimes ( 1 )
212+ expect ( uploadInstances [ 0 ] . options . queueSize ) . toBe ( getConfig ( ) . storageS3UploadQueueSize )
213+ expect ( mockSend ) . toHaveBeenCalledTimes ( 1 )
214+ expect ( mockSend . mock . calls [ 0 ] [ 0 ] ) . toBeInstanceOf ( HeadObjectCommand )
215+ expect ( result ) . toMatchObject ( {
216+ httpStatusCode : 200 ,
217+ cacheControl : 'max-age=60' ,
218+ eTag : '"head-etag"' ,
219+ mimetype : 'text/plain' ,
220+ contentLength : overLimit ,
221+ size : overLimit ,
222+ lastModified,
223+ } )
224+ } )
225+
226+ test ( 'uses multipart upload when content length is unknown' , async ( ) => {
227+ const backend = createBackend ( )
228+ const result = await backend . uploadObject (
229+ 'test-bucket' ,
230+ 'test-key' ,
231+ undefined ,
232+ Readable . from ( [ 'hello' ] ) ,
233+ 'text/plain' ,
234+ 'max-age=60'
235+ )
236+
237+ expect ( Upload ) . toHaveBeenCalledTimes ( 1 )
238+ expect ( uploadInstances [ 0 ] . options . queueSize ) . toBe ( getConfig ( ) . storageS3UploadQueueSize )
239+ expect ( mockSend ) . not . toHaveBeenCalled ( )
240+ expect ( result ) . toMatchObject ( {
241+ httpStatusCode : 200 ,
242+ cacheControl : 'max-age=60' ,
243+ eTag : '"multipart-etag"' ,
244+ mimetype : 'text/plain' ,
245+ contentLength : 0 ,
246+ size : 0 ,
247+ } )
248+ } )
249+
250+ test ( 'maps PutObject abort errors to AbortedTerminate' , async ( ) => {
251+ mockSend . mockRejectedValueOnce ( Object . assign ( new Error ( 'aborted' ) , { name : 'AbortError' } ) )
252+
253+ const backend = createBackend ( )
254+
255+ try {
256+ await backend . uploadObject (
257+ 'test-bucket' ,
258+ 'test-key' ,
259+ undefined ,
260+ Readable . from ( [ 'hello' ] ) ,
261+ 'text/plain' ,
262+ 'max-age=60' ,
263+ undefined ,
264+ 5
265+ )
266+ throw new Error ( 'Expected uploadObject to throw' )
267+ } catch ( error ) {
268+ expect ( isStorageError ( ErrorCode . AbortedTerminate , error ) ) . toBe ( true )
269+ expect ( ( error as Error ) . message ) . toBe ( 'Upload was aborted' )
270+ }
271+ } )
272+ } )
77273} )
0 commit comments