@@ -533,4 +533,73 @@ describe('SnapshotService', () => {
533533 expect ( databaseMock . db . select ) . not . toHaveBeenCalled ( )
534534 } )
535535 } )
536+
537+ describe ( 'cleanupOrphanedSnapshots' , ( ) => {
538+ function setupCleanupMocks ( selectBatches : Array < Array < { id : string } > > ) {
539+ const limitFn = vi . fn ( )
540+ for ( const batch of selectBatches ) limitFn . mockResolvedValueOnce ( batch )
541+ limitFn . mockResolvedValue ( [ ] )
542+ const whereSelect = vi . fn ( ) . mockReturnValue ( { limit : limitFn } )
543+ const fromFn = vi . fn ( ) . mockReturnValue ( { where : whereSelect } )
544+ databaseMock . db . select = vi . fn ( ) . mockReturnValue ( { from : fromFn } )
545+
546+ const returningFn = vi . fn ( ) . mockImplementation ( ( ) => Promise . resolve ( [ ] ) )
547+ const whereDelete = vi . fn ( ) . mockReturnValue ( { returning : returningFn } )
548+ let batchIdx = 0
549+ const deleteFn = vi . fn ( ) . mockImplementation ( ( ) => {
550+ const batch = selectBatches [ batchIdx ] ?? [ ]
551+ batchIdx ++
552+ returningFn . mockImplementationOnce ( ( ) => Promise . resolve ( batch . map ( ( r ) => ( { id : r . id } ) ) ) )
553+ return { where : whereDelete }
554+ } )
555+ databaseMock . db . delete = deleteFn
556+
557+ return { deleteFn }
558+ }
559+
560+ it ( 'returns 0 and skips delete when nothing is orphaned' , async ( ) => {
561+ const service = new SnapshotService ( )
562+ const { deleteFn } = setupCleanupMocks ( [ ] )
563+
564+ const count = await service . cleanupOrphanedSnapshots ( 7 )
565+
566+ expect ( count ) . toBe ( 0 )
567+ expect ( deleteFn ) . not . toHaveBeenCalled ( )
568+ } )
569+
570+ it ( 'stops after the first short batch' , async ( ) => {
571+ const service = new SnapshotService ( )
572+ const partial = Array . from ( { length : 3 } , ( _ , i ) => ( { id : `s${ i } ` } ) )
573+ const { deleteFn } = setupCleanupMocks ( [ partial ] )
574+
575+ const count = await service . cleanupOrphanedSnapshots ( 7 )
576+
577+ expect ( count ) . toBe ( 3 )
578+ expect ( deleteFn ) . toHaveBeenCalledTimes ( 1 )
579+ } )
580+
581+ it ( 'loops through multiple full batches until exhausted' , async ( ) => {
582+ const service = new SnapshotService ( )
583+ const fullBatch = Array . from ( { length : 1000 } , ( _ , i ) => ( { id : `s${ i } ` } ) )
584+ const tail = [ { id : 'tail-1' } ]
585+ const { deleteFn } = setupCleanupMocks ( [ fullBatch , fullBatch , tail ] )
586+
587+ const count = await service . cleanupOrphanedSnapshots ( 7 )
588+
589+ expect ( count ) . toBe ( 2001 )
590+ expect ( deleteFn ) . toHaveBeenCalledTimes ( 3 )
591+ } )
592+
593+ it ( 'caps at MAX_BATCHES (20 × 1000) even when more rows remain' , async ( ) => {
594+ const service = new SnapshotService ( )
595+ const fullBatch = Array . from ( { length : 1000 } , ( _ , i ) => ( { id : `s${ i } ` } ) )
596+ const batches = Array . from ( { length : 25 } , ( ) => fullBatch )
597+ const { deleteFn } = setupCleanupMocks ( batches )
598+
599+ const count = await service . cleanupOrphanedSnapshots ( 7 )
600+
601+ expect ( count ) . toBe ( 20_000 )
602+ expect ( deleteFn ) . toHaveBeenCalledTimes ( 20 )
603+ } )
604+ } )
536605} )
0 commit comments