@@ -16,18 +16,22 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
1616 * - `exactName: true` throws `FileConflictError` on a duplicate leaf name, while
1717 * `exactName: false` auto-suffixes, mirroring `uploadWorkspaceFile`.
1818 */
19- const { store, mockUpload, mockDelete, mockEnsureFolder } = vi . hoisted ( ( ) => ( {
19+ const { store, mockUpload, mockDelete, mockEnsureFolder, mockDeleteFolder } = vi . hoisted ( ( ) => ( {
2020 store : {
2121 folderIdByPath : new Map < string , string > ( ) ,
2222 fileKeys : new Set < string > ( ) ,
23+ /** Paths passed to the folder-delete operation, in call order. */
24+ deletedFolderPaths : [ ] as string [ ] ,
2325 sequence : 0 ,
2426 } ,
2527 mockUpload : vi . fn ( ) ,
2628 mockDelete : vi . fn ( ) ,
2729 mockEnsureFolder : vi . fn ( ) ,
30+ mockDeleteFolder : vi . fn ( ) ,
2831} ) )
2932vi . mock ( '@/lib/workspace-files/application/workspace-file-folders' , ( ) => ( {
3033 ensureWorkspaceFileFolderPathOperation : { execute : mockEnsureFolder } ,
34+ deleteWorkspaceFileFolderOperation : { execute : mockDeleteFolder } ,
3135} ) )
3236vi . mock ( '@/lib/workspace-files/application/create-workspace-file' , ( ) => ( {
3337 createWorkspaceFileFromBuffer : {
@@ -109,6 +113,21 @@ function allocateUniqueName(folderKey: string, name: string): string {
109113 }
110114}
111115
116+ /** Reverse lookup of the fake folder store: id -> path, or `undefined` if gone. */
117+ function folderPathById ( folderId : string | undefined ) : string | undefined {
118+ for ( const [ path , id ] of store . folderIdByPath ) {
119+ if ( id === folderId ) return path
120+ }
121+ return undefined
122+ }
123+
124+ /** Pre-seeds a folder chain that existed before extraction ran. */
125+ function seedExistingFolders ( ...paths : string [ ] [ ] ) : void {
126+ for ( const segments of paths ) {
127+ store . folderIdByPath . set ( buildFolderPath ( segments ) , `preexisting_${ ++ store . sequence } ` )
128+ }
129+ }
130+
112131/** Pre-seeds an already-existing workspace file so a later leaf name collides. */
113132function seedExistingFile ( folderId : string | null , name : string ) : void {
114133 store . fileKeys . add ( `${ folderId ?? '' } |${ name } ` )
@@ -118,11 +137,15 @@ beforeEach(() => {
118137 vi . clearAllMocks ( )
119138 store . folderIdByPath . clear ( )
120139 store . fileKeys . clear ( )
140+ store . deletedFolderPaths . length = 0
121141 store . sequence = 0
122142
123143 mockEnsureFolder . mockImplementation ( async ( { input } : { input : { pathSegments : string [ ] } } ) => {
124144 let folderId : string | null = null
125145 const walked : string [ ] = [ ]
146+ // Only the segments this call actually inserts are reported as created; a
147+ // segment resolved from the store was reused and must never be rolled back.
148+ const createdFolderIds : string [ ] = [ ]
126149 for ( const segment of input . pathSegments ) {
127150 walked . push ( segment )
128151 const path = buildFolderPath ( walked )
@@ -133,10 +156,28 @@ beforeEach(() => {
133156 }
134157 folderId = `folder_${ ++ store . sequence } `
135158 store . folderIdByPath . set ( path , folderId )
159+ createdFolderIds . push ( folderId )
136160 }
137- return { folderId }
161+ return { folderId, createdFolderIds }
138162 } )
139163
164+ mockDeleteFolder . mockImplementation (
165+ async ( { input } : { input : { folderId ?: string ; recursive ?: boolean } } ) => {
166+ const path = folderPathById ( input . folderId )
167+ // Mirrors `deleteWorkspaceFileFolderOperation`, which raises `not_found` when
168+ // nothing was archived — deleting a parent before its children would make the
169+ // child's own delete hit this.
170+ if ( ! path ) throw new Error ( 'Folder not found' )
171+ store . deletedFolderPaths . push ( path )
172+ for ( const [ candidate ] of store . folderIdByPath ) {
173+ if ( candidate === path || candidate . startsWith ( `${ path } /` ) ) {
174+ store . folderIdByPath . delete ( candidate )
175+ }
176+ }
177+ return { deletedItems : { files : 0 , folders : 1 } }
178+ }
179+ )
180+
140181 mockDelete . mockResolvedValue ( undefined )
141182 mockUpload . mockImplementation (
142183 async ( {
@@ -443,6 +484,84 @@ describe('decompressArchiveBufferToWorkspaceFiles', () => {
443484 )
444485 } )
445486
487+ it ( 'rolls back the folders it created when an upload fails mid-extraction' , async ( ) => {
488+ // `materialize_file` refuses to re-extract into a root folder that still has any
489+ // child, so a folder left behind by a failed run turns every retry into
490+ // "already extracted" until a human deletes the tree by hand.
491+ const buffer = await buildZip ( { 'a/one.txt' : 'first' , 'b/two.txt' : 'second' } )
492+ mockUpload
493+ . mockResolvedValueOnce ( {
494+ file : { id : 'f_one' , name : 'one.txt' , url : '/one' , key : 'k/one' , size : 5 } ,
495+ } )
496+ . mockRejectedValueOnce ( new Error ( 'storage quota exceeded' ) )
497+
498+ await expect (
499+ decompressArchiveBufferToWorkspaceFiles ( buffer , {
500+ workspaceId : 'ws' ,
501+ principal : TEST_PRINCIPAL ,
502+ rootFolderSegments : [ 'bundle' ] ,
503+ } )
504+ ) . rejects . toThrow ( 'storage quota exceeded' )
505+
506+ expect ( [ ...store . folderIdByPath . keys ( ) ] ) . toEqual ( [ ] )
507+ expect ( mockDeleteFolder ) . toHaveBeenCalledWith (
508+ expect . objectContaining ( {
509+ input : expect . objectContaining ( { workspaceId : 'ws' , recursive : true } ) ,
510+ } )
511+ )
512+ } )
513+
514+ it ( 'leaves a folder that already existed before the call untouched on rollback' , async ( ) => {
515+ // Extracting into an existing path is normal — a sibling entry, or an earlier
516+ // successful extraction. Deleting a reused folder would destroy unrelated data.
517+ seedExistingFolders ( [ 'bundle' ] , [ 'bundle' , 'keep' ] )
518+ const preexistingIds = [ ...store . folderIdByPath . values ( ) ]
519+ const buffer = await buildZip ( { 'keep/kept.txt' : 'a' , 'fresh/new.txt' : 'b' } )
520+ mockUpload
521+ . mockResolvedValueOnce ( {
522+ file : { id : 'f_kept' , name : 'kept.txt' , url : '/kept' , key : 'k/kept' , size : 1 } ,
523+ } )
524+ . mockRejectedValueOnce ( new Error ( 'storage quota exceeded' ) )
525+
526+ await expect (
527+ decompressArchiveBufferToWorkspaceFiles ( buffer , {
528+ workspaceId : 'ws' ,
529+ principal : TEST_PRINCIPAL ,
530+ rootFolderSegments : [ 'bundle' ] ,
531+ } )
532+ ) . rejects . toThrow ( 'storage quota exceeded' )
533+
534+ expect ( [ ...store . folderIdByPath . keys ( ) ] . sort ( ) ) . toEqual ( [ '/bundle' , '/bundle/keep' ] )
535+ expect ( store . deletedFolderPaths ) . toEqual ( [ '/bundle/fresh' ] )
536+ const deletedIds = mockDeleteFolder . mock . calls . map ( ( [ args ] ) => args . input . folderId )
537+ for ( const preexistingId of preexistingIds ) {
538+ expect ( deletedIds ) . not . toContain ( preexistingId )
539+ }
540+ } )
541+
542+ it ( 'deletes rolled-back folders deepest-first' , async ( ) => {
543+ // A parent removed before its children would make the children's own deletes
544+ // fail (nothing left to archive), so the unwind walks creation order backwards.
545+ const buffer = await buildZip ( { 'x/y/z/leaf.txt' : 'a' } )
546+ mockUpload . mockRejectedValueOnce ( new Error ( 'storage quota exceeded' ) )
547+
548+ await expect (
549+ decompressArchiveBufferToWorkspaceFiles ( buffer , {
550+ workspaceId : 'ws' ,
551+ principal : TEST_PRINCIPAL ,
552+ rootFolderSegments : [ 'bundle' ] ,
553+ } )
554+ ) . rejects . toThrow ( 'storage quota exceeded' )
555+
556+ expect ( store . deletedFolderPaths ) . toEqual ( [
557+ '/bundle/x/y/z' ,
558+ '/bundle/x/y' ,
559+ '/bundle/x' ,
560+ '/bundle' ,
561+ ] )
562+ expect ( [ ...store . folderIdByPath . keys ( ) ] ) . toEqual ( [ ] )
563+ } )
564+
446565 it ( 'does not count noise entries toward the extraction cap when they are being skipped' , async ( ) => {
447566 // macOS Finder zips carry a __MACOSX/._* shadow per file, doubling the raw
448567 // entry count. 501 files + 501 shadows = 1002 raw entries — over the
0 commit comments