@@ -95,6 +95,7 @@ describe("R2Storage", () => {
9595 created_at : "2024-01-01T00:00:00Z" ,
9696 updated_at : "2024-01-01T00:00:00Z" ,
9797 version : 1 ,
98+ current_version : "2024-01-01T00:00:00Z" ,
9899 total_size : 1000 ,
99100 blob_count : 1 ,
100101 encrypted_metadata : {
@@ -149,6 +150,7 @@ describe("R2Storage", () => {
149150 created_at : "2024-01-01T00:00:00Z" ,
150151 updated_at : "2024-01-01T00:00:00Z" ,
151152 version : 1 ,
153+ current_version : "2024-01-01T00:00:00Z" ,
152154 total_size : 1000 ,
153155 blob_count : 1 ,
154156 encrypted_metadata : {
@@ -191,18 +193,24 @@ describe("R2Storage", () => {
191193 } ) ;
192194
193195 describe ( "putBlob" , ( ) => {
194- it ( "should store blob successfully" , async ( ) => {
196+ it ( "should store blob successfully and return timestamp " , async ( ) => {
195197 await storage . initialize ( ) ;
196198 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+ ) ;
206214 } ) ;
207215
208216 it ( "should handle put errors" , async ( ) => {
@@ -216,39 +224,98 @@ describe("R2Storage", () => {
216224 } ) ;
217225
218226 describe ( "getBlob" , ( ) => {
219- it ( "should retrieve blob successfully" , async ( ) => {
227+ it ( "should retrieve blob by timestamp successfully" , async ( ) => {
220228 await storage . initialize ( ) ;
221229 const mockData = new Uint8Array ( [ 1 , 2 , 3 , 4 ] ) ;
222230 mockBucket . get . mockResolvedValue ( {
223231 arrayBuffer : vi . fn ( ) . mockResolvedValue ( mockData . buffer ) ,
224232 } ) ;
225233
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 ) ;
227236 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+ ) ;
229240 } ) ;
230241
231242 it ( "should return null if blob not found" , async ( ) => {
232243 await storage . initialize ( ) ;
233244 mockBucket . get . mockResolvedValue ( null ) ;
234245
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" ) ;
236289 expect ( result ) . toBeNull ( ) ;
237290 } ) ;
238291 } ) ;
239292
240293 describe ( "deleteGist" , ( ) => {
241- it ( "should delete both metadata and blob " , async ( ) => {
294+ it ( "should delete metadata and all versions " , async ( ) => {
242295 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+
243304 await storage . deleteGist ( "test-id" ) ;
244305
245306 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 ) ;
248314 } ) ;
249315
250316 it ( "should handle delete errors" , async ( ) => {
251317 await storage . initialize ( ) ;
318+ mockBucket . list . mockResolvedValue ( { objects : [ ] , truncated : false } ) ;
252319 mockBucket . delete . mockRejectedValue ( new Error ( "Delete failed" ) ) ;
253320
254321 await expect ( storage . deleteGist ( "test-id" ) ) . rejects . toThrow ( AppError ) ;
@@ -288,6 +355,7 @@ describe("R2Storage", () => {
288355 created_at : "2024-01-01T00:00:00Z" ,
289356 updated_at : "2024-01-01T00:00:00Z" ,
290357 version : 1 ,
358+ current_version : "2024-01-01T00:00:00Z" ,
291359 total_size : 1000 ,
292360 blob_count : 1 ,
293361 encrypted_metadata : {
@@ -365,12 +433,88 @@ describe("R2Storage", () => {
365433 expect ( stats . totalSize ) . toBe ( 300 ) ;
366434 } ) ;
367435 } ) ;
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+ } ) ;
368510} ) ;
369511
370512describe ( "StorageKeys" , ( ) => {
371513 it ( "should generate correct keys" , ( ) => {
372514 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+ ) ;
374518 expect ( StorageKeys . temp ( "test-id" ) ) . toBe ( "temp/test-id" ) ;
375519 } ) ;
376520} ) ;
0 commit comments