@@ -95,6 +95,7 @@ describe("R2Storage", () => {
95
95
created_at : "2024-01-01T00:00:00Z" ,
96
96
updated_at : "2024-01-01T00:00:00Z" ,
97
97
version : 1 ,
98
+ current_version : "2024-01-01T00:00:00Z" ,
98
99
total_size : 1000 ,
99
100
blob_count : 1 ,
100
101
encrypted_metadata : {
@@ -149,6 +150,7 @@ describe("R2Storage", () => {
149
150
created_at : "2024-01-01T00:00:00Z" ,
150
151
updated_at : "2024-01-01T00:00:00Z" ,
151
152
version : 1 ,
153
+ current_version : "2024-01-01T00:00:00Z" ,
152
154
total_size : 1000 ,
153
155
blob_count : 1 ,
154
156
encrypted_metadata : {
@@ -191,18 +193,24 @@ describe("R2Storage", () => {
191
193
} ) ;
192
194
193
195
describe ( "putBlob" , ( ) => {
194
- it ( "should store blob successfully" , async ( ) => {
196
+ it ( "should store blob successfully and return timestamp " , async ( ) => {
195
197
await storage . initialize ( ) ;
196
198
const data = new Uint8Array ( [ 1 , 2 , 3 , 4 ] ) ;
197
- await storage . putBlob ( "test-id" , data ) ;
198
-
199
- expect ( mockBucket . put ) . toHaveBeenCalledWith ( "blobs/test-id" , data , {
200
- httpMetadata : { contentType : "application/octet-stream" } ,
201
- customMetadata : {
202
- type : "blob" ,
203
- size : "4" ,
204
- } ,
205
- } ) ;
199
+ const timestamp = await storage . putBlob ( "test-id" , data ) ;
200
+
201
+ expect ( timestamp ) . toMatch ( / ^ \d { 4 } - \d { 2 } - \d { 2 } T \d { 2 } : \d { 2 } : \d { 2 } / ) ;
202
+ expect ( mockBucket . put ) . toHaveBeenCalledWith (
203
+ expect . stringMatching ( / ^ v e r s i o n s \/ t e s t - i d \/ .* \. b i n $ / ) ,
204
+ data ,
205
+ {
206
+ httpMetadata : { contentType : "application/octet-stream" } ,
207
+ customMetadata : {
208
+ type : "version" ,
209
+ size : "4" ,
210
+ timestamp : expect . any ( String ) ,
211
+ } ,
212
+ }
213
+ ) ;
206
214
} ) ;
207
215
208
216
it ( "should handle put errors" , async ( ) => {
@@ -216,39 +224,98 @@ describe("R2Storage", () => {
216
224
} ) ;
217
225
218
226
describe ( "getBlob" , ( ) => {
219
- it ( "should retrieve blob successfully" , async ( ) => {
227
+ it ( "should retrieve blob by timestamp successfully" , async ( ) => {
220
228
await storage . initialize ( ) ;
221
229
const mockData = new Uint8Array ( [ 1 , 2 , 3 , 4 ] ) ;
222
230
mockBucket . get . mockResolvedValue ( {
223
231
arrayBuffer : vi . fn ( ) . mockResolvedValue ( mockData . buffer ) ,
224
232
} ) ;
225
233
226
- const result = await storage . getBlob ( "test-id" ) ;
234
+ const timestamp = "2024-01-01T00:00:00Z" ;
235
+ const result = await storage . getBlob ( "test-id" , timestamp ) ;
227
236
expect ( result ) . toEqual ( mockData ) ;
228
- expect ( mockBucket . get ) . toHaveBeenCalledWith ( "blobs/test-id" ) ;
237
+ expect ( mockBucket . get ) . toHaveBeenCalledWith (
238
+ "versions/test-id/2024-01-01T00:00:00Z.bin"
239
+ ) ;
229
240
} ) ;
230
241
231
242
it ( "should return null if blob not found" , async ( ) => {
232
243
await storage . initialize ( ) ;
233
244
mockBucket . get . mockResolvedValue ( null ) ;
234
245
235
- const result = await storage . getBlob ( "test-id" ) ;
246
+ const result = await storage . getBlob ( "test-id" , "2024-01-01T00:00:00Z" ) ;
247
+ expect ( result ) . toBeNull ( ) ;
248
+ } ) ;
249
+ } ) ;
250
+
251
+ describe ( "getCurrentBlob" , ( ) => {
252
+ const mockMetadata : GistMetadata = {
253
+ id : "test-id" ,
254
+ created_at : "2024-01-01T00:00:00Z" ,
255
+ updated_at : "2024-01-01T00:00:00Z" ,
256
+ version : 1 ,
257
+ current_version : "2024-01-01T00:00:00Z" ,
258
+ total_size : 1000 ,
259
+ blob_count : 1 ,
260
+ encrypted_metadata : {
261
+ iv : "test-iv" ,
262
+ data : "test-data" ,
263
+ } ,
264
+ } ;
265
+
266
+ it ( "should retrieve current blob using metadata" , async ( ) => {
267
+ await storage . initialize ( ) ;
268
+ const mockData = new Uint8Array ( [ 1 , 2 , 3 , 4 ] ) ;
269
+
270
+ // Mock metadata get
271
+ mockBucket . get
272
+ . mockResolvedValueOnce ( {
273
+ text : vi . fn ( ) . mockResolvedValue ( JSON . stringify ( mockMetadata ) ) ,
274
+ } )
275
+ // Mock blob get
276
+ . mockResolvedValueOnce ( {
277
+ arrayBuffer : vi . fn ( ) . mockResolvedValue ( mockData . buffer ) ,
278
+ } ) ;
279
+
280
+ const result = await storage . getCurrentBlob ( "test-id" ) ;
281
+ expect ( result ) . toEqual ( mockData ) ;
282
+ } ) ;
283
+
284
+ it ( "should return null if metadata not found" , async ( ) => {
285
+ await storage . initialize ( ) ;
286
+ mockBucket . get . mockResolvedValue ( null ) ;
287
+
288
+ const result = await storage . getCurrentBlob ( "test-id" ) ;
236
289
expect ( result ) . toBeNull ( ) ;
237
290
} ) ;
238
291
} ) ;
239
292
240
293
describe ( "deleteGist" , ( ) => {
241
- it ( "should delete both metadata and blob " , async ( ) => {
294
+ it ( "should delete metadata and all versions " , async ( ) => {
242
295
await storage . initialize ( ) ;
296
+ mockBucket . list . mockResolvedValue ( {
297
+ objects : [
298
+ { key : "versions/test-id/2024-01-01T00:00:00Z.bin" } ,
299
+ { key : "versions/test-id/2024-01-02T00:00:00Z.bin" } ,
300
+ ] ,
301
+ truncated : false ,
302
+ } ) ;
303
+
243
304
await storage . deleteGist ( "test-id" ) ;
244
305
245
306
expect ( mockBucket . delete ) . toHaveBeenCalledWith ( "metadata/test-id.json" ) ;
246
- expect ( mockBucket . delete ) . toHaveBeenCalledWith ( "blobs/test-id" ) ;
247
- expect ( mockBucket . delete ) . toHaveBeenCalledTimes ( 2 ) ;
307
+ expect ( mockBucket . delete ) . toHaveBeenCalledWith (
308
+ "versions/test-id/2024-01-01T00:00:00Z.bin"
309
+ ) ;
310
+ expect ( mockBucket . delete ) . toHaveBeenCalledWith (
311
+ "versions/test-id/2024-01-02T00:00:00Z.bin"
312
+ ) ;
313
+ expect ( mockBucket . delete ) . toHaveBeenCalledTimes ( 3 ) ;
248
314
} ) ;
249
315
250
316
it ( "should handle delete errors" , async ( ) => {
251
317
await storage . initialize ( ) ;
318
+ mockBucket . list . mockResolvedValue ( { objects : [ ] , truncated : false } ) ;
252
319
mockBucket . delete . mockRejectedValue ( new Error ( "Delete failed" ) ) ;
253
320
254
321
await expect ( storage . deleteGist ( "test-id" ) ) . rejects . toThrow ( AppError ) ;
@@ -288,6 +355,7 @@ describe("R2Storage", () => {
288
355
created_at : "2024-01-01T00:00:00Z" ,
289
356
updated_at : "2024-01-01T00:00:00Z" ,
290
357
version : 1 ,
358
+ current_version : "2024-01-01T00:00:00Z" ,
291
359
total_size : 1000 ,
292
360
blob_count : 1 ,
293
361
encrypted_metadata : {
@@ -365,12 +433,88 @@ describe("R2Storage", () => {
365
433
expect ( stats . totalSize ) . toBe ( 300 ) ;
366
434
} ) ;
367
435
} ) ;
436
+
437
+ describe ( "listVersions" , ( ) => {
438
+ it ( "should list all versions for a gist" , async ( ) => {
439
+ await storage . initialize ( ) ;
440
+ mockBucket . list . mockResolvedValue ( {
441
+ objects : [
442
+ { key : "versions/test-id/2024-01-02T00:00:00Z.bin" , size : 200 } ,
443
+ { key : "versions/test-id/2024-01-01T00:00:00Z.bin" , size : 100 } ,
444
+ ] ,
445
+ truncated : false ,
446
+ } ) ;
447
+
448
+ const versions = await storage . listVersions ( "test-id" ) ;
449
+
450
+ expect ( versions ) . toHaveLength ( 2 ) ;
451
+ expect ( versions [ 0 ] ) . toEqual ( {
452
+ timestamp : "2024-01-02T00:00:00Z" ,
453
+ size : 200 ,
454
+ } ) ;
455
+ expect ( versions [ 1 ] ) . toEqual ( {
456
+ timestamp : "2024-01-01T00:00:00Z" ,
457
+ size : 100 ,
458
+ } ) ;
459
+ } ) ;
460
+
461
+ it ( "should handle list errors" , async ( ) => {
462
+ await storage . initialize ( ) ;
463
+ mockBucket . list . mockRejectedValue ( new Error ( "List failed" ) ) ;
464
+
465
+ await expect ( storage . listVersions ( "test-id" ) ) . rejects . toThrow ( AppError ) ;
466
+ } ) ;
467
+ } ) ;
468
+
469
+ describe ( "pruneVersions" , ( ) => {
470
+ it ( "should delete old versions beyond limit" , async ( ) => {
471
+ await storage . initialize ( ) ;
472
+ mockBucket . list . mockResolvedValue ( {
473
+ objects : [
474
+ { key : "versions/test-id/2024-01-05T00:00:00Z.bin" , size : 100 } ,
475
+ { key : "versions/test-id/2024-01-04T00:00:00Z.bin" , size : 100 } ,
476
+ { key : "versions/test-id/2024-01-03T00:00:00Z.bin" , size : 100 } ,
477
+ { key : "versions/test-id/2024-01-02T00:00:00Z.bin" , size : 100 } ,
478
+ { key : "versions/test-id/2024-01-01T00:00:00Z.bin" , size : 100 } ,
479
+ ] ,
480
+ truncated : false ,
481
+ } ) ;
482
+
483
+ const deleted = await storage . pruneVersions ( "test-id" , 3 ) ;
484
+
485
+ expect ( deleted ) . toBe ( 2 ) ;
486
+ expect ( mockBucket . delete ) . toHaveBeenCalledWith (
487
+ "versions/test-id/2024-01-02T00:00:00Z.bin"
488
+ ) ;
489
+ expect ( mockBucket . delete ) . toHaveBeenCalledWith (
490
+ "versions/test-id/2024-01-01T00:00:00Z.bin"
491
+ ) ;
492
+ } ) ;
493
+
494
+ it ( "should not delete if under limit" , async ( ) => {
495
+ await storage . initialize ( ) ;
496
+ mockBucket . list . mockResolvedValue ( {
497
+ objects : [
498
+ { key : "versions/test-id/2024-01-02T00:00:00Z.bin" , size : 100 } ,
499
+ { key : "versions/test-id/2024-01-01T00:00:00Z.bin" , size : 100 } ,
500
+ ] ,
501
+ truncated : false ,
502
+ } ) ;
503
+
504
+ const deleted = await storage . pruneVersions ( "test-id" , 50 ) ;
505
+
506
+ expect ( deleted ) . toBe ( 0 ) ;
507
+ expect ( mockBucket . delete ) . not . toHaveBeenCalled ( ) ;
508
+ } ) ;
509
+ } ) ;
368
510
} ) ;
369
511
370
512
describe ( "StorageKeys" , ( ) => {
371
513
it ( "should generate correct keys" , ( ) => {
372
514
expect ( StorageKeys . metadata ( "test-id" ) ) . toBe ( "metadata/test-id.json" ) ;
373
- expect ( StorageKeys . blob ( "test-id" ) ) . toBe ( "blobs/test-id" ) ;
515
+ expect ( StorageKeys . version ( "test-id" , "2024-01-01T00:00:00Z" ) ) . toBe (
516
+ "versions/test-id/2024-01-01T00:00:00Z.bin"
517
+ ) ;
374
518
expect ( StorageKeys . temp ( "test-id" ) ) . toBe ( "temp/test-id" ) ;
375
519
} ) ;
376
520
} ) ;
0 commit comments