@@ -132,7 +132,7 @@ describe('get', () => {
132132 siteID,
133133 } )
134134
135- expect ( async ( ) => await blobs . get ( key ) ) . rejects . toThrowError (
135+ await expect ( async ( ) => await blobs . get ( key ) ) . rejects . toThrowError (
136136 `Netlify Blobs has generated an internal error (401 status code, ID: ${ mockRequestID } )` ,
137137 )
138138 expect ( mockStore . fulfilled ) . toBeTruthy ( )
@@ -212,6 +212,126 @@ describe('get', () => {
212212
213213 expect ( mockStore . fulfilled ) . toBeTruthy ( )
214214 } )
215+
216+ describe ( 'Conditional writes' , ( ) => {
217+ test ( 'Returns `modified: false` when `onlyIfNew` is true and key exists' , async ( ) => {
218+ const mockStore = new MockFetch ( )
219+ . put ( {
220+ headers : { authorization : `Bearer ${ apiToken } ` } ,
221+ response : new Response ( JSON . stringify ( { url : signedURL } ) ) ,
222+ url : `https://api.netlify.com/api/v1/blobs/${ siteID } /site:production/${ key } ` ,
223+ } )
224+ . put ( {
225+ headers : { 'if-none-match' : '*' } ,
226+ response : new Response ( null , { status : 412 } ) ,
227+ url : signedURL ,
228+ } )
229+ . inject ( )
230+
231+ const blobs = getStore ( {
232+ name : 'production' ,
233+ token : apiToken ,
234+ siteID,
235+ } )
236+
237+ const result = await blobs . set ( key , value , {
238+ onlyIfNew : true ,
239+ } )
240+
241+ expect ( result . modified ) . toBe ( false )
242+ expect ( result . etag ) . toBeUndefined ( )
243+ expect ( mockStore . fulfilled ) . toBeTruthy ( )
244+ } )
245+
246+ test ( 'Returns `modified: true` when `onlyIfNew` is true and key does not exist' , async ( ) => {
247+ const mockStore = new MockFetch ( )
248+ . put ( {
249+ headers : { authorization : `Bearer ${ apiToken } ` } ,
250+ response : new Response ( JSON . stringify ( { url : signedURL } ) ) ,
251+ url : `https://api.netlify.com/api/v1/blobs/${ siteID } /site:production/${ key } ` ,
252+ } )
253+ . put ( {
254+ headers : { 'if-none-match' : '*' } ,
255+ response : new Response ( null , { status : 201 , headers : { etag : '"123"' } } ) ,
256+ url : signedURL ,
257+ } )
258+ . inject ( )
259+
260+ const blobs = getStore ( {
261+ name : 'production' ,
262+ token : apiToken ,
263+ siteID,
264+ } )
265+
266+ const result = await blobs . set ( key , value , {
267+ onlyIfNew : true ,
268+ } )
269+
270+ expect ( result . modified ) . toBe ( true )
271+ expect ( result . etag ) . toBe ( '"123"' )
272+ expect ( mockStore . fulfilled ) . toBeTruthy ( )
273+ } )
274+
275+ test ( 'Returns `modified: false` when `onlyIfMatch` does not match' , async ( ) => {
276+ const etag = 'etag-123'
277+ const mockStore = new MockFetch ( )
278+ . put ( {
279+ headers : { authorization : `Bearer ${ apiToken } ` } ,
280+ response : new Response ( JSON . stringify ( { url : signedURL } ) ) ,
281+ url : `https://api.netlify.com/api/v1/blobs/${ siteID } /site:production/${ key } ` ,
282+ } )
283+ . put ( {
284+ headers : { 'if-match' : etag } ,
285+ response : new Response ( null , { status : 412 } ) ,
286+ url : signedURL ,
287+ } )
288+ . inject ( )
289+
290+ const blobs = getStore ( {
291+ name : 'production' ,
292+ token : apiToken ,
293+ siteID,
294+ } )
295+
296+ const result = await blobs . set ( key , value , {
297+ onlyIfMatch : etag ,
298+ } )
299+
300+ expect ( result . modified ) . toBe ( false )
301+ expect ( result . etag ) . toBeUndefined ( )
302+ expect ( mockStore . fulfilled ) . toBeTruthy ( )
303+ } )
304+
305+ test ( 'Returns `modified: true` when `onlyIfMatch` matches' , async ( ) => {
306+ const etag = 'etag-123'
307+ const mockStore = new MockFetch ( )
308+ . put ( {
309+ headers : { authorization : `Bearer ${ apiToken } ` } ,
310+ response : new Response ( JSON . stringify ( { url : signedURL } ) ) ,
311+ url : `https://api.netlify.com/api/v1/blobs/${ siteID } /site:production/${ key } ` ,
312+ } )
313+ . put ( {
314+ headers : { 'if-match' : etag } ,
315+ response : new Response ( null , { status : 200 , headers : { etag : '"123"' } } ) ,
316+ url : signedURL ,
317+ } )
318+ . inject ( )
319+
320+ const blobs = getStore ( {
321+ name : 'production' ,
322+ token : apiToken ,
323+ siteID,
324+ } )
325+
326+ const result = await blobs . set ( key , value , {
327+ onlyIfMatch : etag ,
328+ } )
329+
330+ expect ( result . modified ) . toBe ( true )
331+ expect ( result . etag ) . toBe ( '"123"' )
332+ expect ( mockStore . fulfilled ) . toBeTruthy ( )
333+ } )
334+ } )
215335 } )
216336
217337 describe ( 'With edge credentials' , ( ) => {
@@ -289,6 +409,159 @@ describe('get', () => {
289409 expect ( mockStore . fulfilled ) . toBeTruthy ( )
290410 } )
291411
412+ describe ( 'Conditional writes' , ( ) => {
413+ test ( 'Returns `modified: false` when `onlyIfNew` is true and key exists' , async ( ) => {
414+ const mockStore = new MockFetch ( )
415+ . put ( {
416+ headers : { authorization : `Bearer ${ edgeToken } ` , 'if-none-match' : '*' } ,
417+ response : new Response ( null , { status : 412 } ) ,
418+ url : `${ edgeURL } /${ siteID } /site:production/${ key } ` ,
419+ } )
420+ . inject ( )
421+
422+ const blobs = getStore ( {
423+ edgeURL,
424+ name : 'production' ,
425+ token : edgeToken ,
426+ siteID,
427+ } )
428+
429+ const result = await blobs . set ( key , value , {
430+ onlyIfNew : true ,
431+ } )
432+
433+ expect ( result . modified ) . toBe ( false )
434+ expect ( mockStore . fulfilled ) . toBeTruthy ( )
435+ } )
436+
437+ test ( 'Returns `modified: true` when `onlyIfNew` is true and key does not exist' , async ( ) => {
438+ const mockStore = new MockFetch ( )
439+ . put ( {
440+ headers : { authorization : `Bearer ${ edgeToken } ` , 'if-none-match' : '*' } ,
441+ response : new Response ( null , { status : 201 , headers : { etag : '"123"' } } ) ,
442+ url : `${ edgeURL } /${ siteID } /site:production/${ key } ` ,
443+ } )
444+ . inject ( )
445+
446+ const blobs = getStore ( {
447+ edgeURL,
448+ name : 'production' ,
449+ token : edgeToken ,
450+ siteID,
451+ } )
452+
453+ const result = await blobs . set ( key , value , {
454+ onlyIfNew : true ,
455+ } )
456+
457+ expect ( result . modified ) . toBe ( true )
458+ expect ( result . etag ) . toBe ( '"123"' )
459+ expect ( mockStore . fulfilled ) . toBeTruthy ( )
460+ } )
461+
462+ test ( 'Returns `modified: false` when `onlyIfMatch` does not match' , async ( ) => {
463+ const etag = 'etag-123'
464+ const mockStore = new MockFetch ( )
465+ . put ( {
466+ headers : { authorization : `Bearer ${ edgeToken } ` , 'if-match' : etag } ,
467+ response : new Response ( null , { status : 412 } ) ,
468+ url : `${ edgeURL } /${ siteID } /site:production/${ key } ` ,
469+ } )
470+ . inject ( )
471+
472+ const blobs = getStore ( {
473+ edgeURL,
474+ name : 'production' ,
475+ token : edgeToken ,
476+ siteID,
477+ } )
478+
479+ const result = await blobs . set ( key , value , {
480+ onlyIfMatch : etag ,
481+ } )
482+
483+ expect ( result . modified ) . toBe ( false )
484+ expect ( mockStore . fulfilled ) . toBeTruthy ( )
485+ } )
486+
487+ test ( 'Returns `modified: true` when `onlyIfMatch` matches' , async ( ) => {
488+ const etag = 'etag-123'
489+ const mockStore = new MockFetch ( )
490+ . put ( {
491+ headers : { authorization : `Bearer ${ edgeToken } ` , 'if-match' : etag } ,
492+ response : new Response ( null , { status : 200 , headers : { etag : '"123"' } } ) ,
493+ url : `${ edgeURL } /${ siteID } /site:production/${ key } ` ,
494+ } )
495+ . inject ( )
496+
497+ const blobs = getStore ( {
498+ edgeURL,
499+ name : 'production' ,
500+ token : edgeToken ,
501+ siteID,
502+ } )
503+
504+ const result = await blobs . set ( key , value , {
505+ onlyIfMatch : etag ,
506+ } )
507+
508+ expect ( result . modified ) . toBe ( true )
509+ expect ( result . etag ) . toBe ( '"123"' )
510+ expect ( mockStore . fulfilled ) . toBeTruthy ( )
511+ } )
512+
513+ test ( 'Throws an error when both `onlyIfNew` and `onlyIfMatch` are provided' , async ( ) => {
514+ const blobs = getStore ( {
515+ name : 'production' ,
516+ token : apiToken ,
517+ siteID,
518+ } )
519+
520+ await expect (
521+ blobs . set ( key , value , {
522+ onlyIfNew : true ,
523+
524+ // @ts -expect-error Testing runtime validation
525+ onlyIfMatch : '"123"' ,
526+ } ) ,
527+ ) . rejects . toThrow (
528+ `The 'onlyIfMatch' and 'onlyIfNew' options are mutually exclusive. Using 'onlyIfMatch' will make the write succeed only if there is an entry for the key with the given content, while 'onlyIfNew' will make the write succeed only if there is no entry for the key.` ,
529+ )
530+ } )
531+
532+ test ( 'Throws an error when `onlyIfMatch` is not a string' , async ( ) => {
533+ const blobs = getStore ( {
534+ name : 'production' ,
535+ token : apiToken ,
536+ siteID,
537+ } )
538+
539+ await expect (
540+ blobs . set ( key , value , {
541+ // @ts -expect-error Testing runtime validation
542+ onlyIfMatch : 123 ,
543+ } ) ,
544+ ) . rejects . toThrow ( `The 'onlyIfMatch' property expects a string representing an ETag.` )
545+ } )
546+
547+ test ( 'Throws an error when `onlyIfNew` is not a boolean' , async ( ) => {
548+ const blobs = getStore ( {
549+ name : 'production' ,
550+ token : apiToken ,
551+ siteID,
552+ } )
553+
554+ await expect (
555+ blobs . set ( key , value , {
556+ // @ts -expect-error Testing runtime validation
557+ onlyIfNew : 'yes' ,
558+ } ) ,
559+ ) . rejects . toThrow (
560+ `The 'onlyIfNew' property expects a boolean indicating whether the write should fail if an entry for the key already exists.` ,
561+ )
562+ } )
563+ } )
564+
292565 describe ( 'Loads credentials from the environment' , ( ) => {
293566 test ( 'From the `NETLIFY_BLOBS_CONTEXT` environment variable' , async ( ) => {
294567 const tokens = [ 'some-token-1' , 'another-token-2' ]
@@ -804,7 +1077,7 @@ describe('set', () => {
8041077 siteID,
8051078 } )
8061079
807- expect ( async ( ) => await blobs . set ( key , 'value' ) ) . rejects . toThrowError (
1080+ await expect ( async ( ) => await blobs . set ( key , 'value' ) ) . rejects . toThrowError (
8081081 `Netlify Blobs has generated an internal error (401 status code)` ,
8091082 )
8101083 expect ( mockStore . fulfilled ) . toBeTruthy ( )
@@ -819,11 +1092,11 @@ describe('set', () => {
8191092 siteID,
8201093 } )
8211094
822- expect ( async ( ) => await blobs . set ( '' , 'value' ) ) . rejects . toThrowError ( 'Blob key must not be empty.' )
823- expect ( async ( ) => await blobs . set ( '/key' , 'value' ) ) . rejects . toThrowError (
1095+ await expect ( async ( ) => await blobs . set ( '' , 'value' ) ) . rejects . toThrowError ( 'Blob key must not be empty.' )
1096+ await expect ( async ( ) => await blobs . set ( '/key' , 'value' ) ) . rejects . toThrowError (
8241097 'Blob key must not start with forward slash (/).' ,
8251098 )
826- expect ( async ( ) => await blobs . set ( 'a' . repeat ( 801 ) , 'value' ) ) . rejects . toThrowError (
1099+ await expect ( async ( ) => await blobs . set ( 'a' . repeat ( 801 ) , 'value' ) ) . rejects . toThrowError (
8271100 'Blob key must be a sequence of Unicode characters whose UTF-8 encoding is at most 600 bytes long.' ,
8281101 )
8291102 } )
@@ -1076,7 +1349,7 @@ describe('setJSON', () => {
10761349 siteID,
10771350 } )
10781351
1079- expect ( async ( ) => await blobs . setJSON ( key , { value } , { metadata } ) ) . rejects . toThrowError (
1352+ await expect ( async ( ) => await blobs . setJSON ( key , { value } , { metadata } ) ) . rejects . toThrowError (
10801353 'Metadata object exceeds the maximum size' ,
10811354 )
10821355 } )
0 commit comments