@@ -5,24 +5,28 @@ import { dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
55import { beforeEach , describe , expect , it , vi } from 'vitest'
66
77const {
8+ mockAllocateUniqueWorkspaceFileName,
89 mockCheckStorageQuotaForBillingContext,
910 mockDecompress,
1011 mockFetchBuffer,
1112 mockFindFolder,
1213 mockFindUpload,
1314 mockGetBoundWorkspaceFileSecretProvenance,
15+ mockGetWorkspaceFile,
1416 mockHasCloudStorage,
1517 mockHeadObject,
1618 mockIncrementStorageUsageForBillingContextInTx,
1719 mockMaybeNotifyStorageLimitForBillingContext,
1820 mockResolveStorageBillingContext,
1921} = vi . hoisted ( ( ) => ( {
22+ mockAllocateUniqueWorkspaceFileName : vi . fn ( ) ,
2023 mockCheckStorageQuotaForBillingContext : vi . fn ( ) ,
2124 mockDecompress : vi . fn ( ) ,
2225 mockFetchBuffer : vi . fn ( ) ,
2326 mockFindFolder : vi . fn ( ) ,
2427 mockFindUpload : vi . fn ( ) ,
2528 mockGetBoundWorkspaceFileSecretProvenance : vi . fn ( ) ,
29+ mockGetWorkspaceFile : vi . fn ( ) ,
2630 mockHasCloudStorage : vi . fn ( ) ,
2731 mockHeadObject : vi . fn ( ) ,
2832 mockIncrementStorageUsageForBillingContextInTx : vi . fn ( ) ,
@@ -43,7 +47,9 @@ vi.mock('@/lib/uploads', () => ({
4347} ) )
4448
4549vi . mock ( '@/lib/uploads/contexts/workspace/workspace-file-manager' , ( ) => ( {
50+ allocateUniqueWorkspaceFileName : mockAllocateUniqueWorkspaceFileName ,
4651 fetchWorkspaceFileBuffer : mockFetchBuffer ,
52+ getWorkspaceFile : mockGetWorkspaceFile ,
4753} ) )
4854
4955vi . mock ( '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' , ( ) => ( {
@@ -82,7 +88,9 @@ vi.mock('@/lib/billing/storage', () => ({
8288} ) )
8389
8490vi . mock ( '@/lib/copilot/vfs/path-utils' , ( ) => ( {
85- canonicalWorkspaceFilePath : vi . fn ( ( ) => 'files/report.txt' ) ,
91+ canonicalWorkspaceFilePath : vi . fn (
92+ ( { name } : { name : string } ) => `files/${ encodeURIComponent ( name ) } `
93+ ) ,
8694 encodeVfsPathSegments : ( segments : string [ ] ) =>
8795 segments . map ( ( s ) => encodeURIComponent ( s ) ) . join ( '/' ) ,
8896} ) )
@@ -243,6 +251,8 @@ describe('executeMaterializeFile - save storage transition', () => {
243251 vi . clearAllMocks ( )
244252 resetDbChainMock ( )
245253 mockFindUpload . mockResolvedValue ( mothershipRow )
254+ mockAllocateUniqueWorkspaceFileName . mockResolvedValue ( 'report.txt' )
255+ mockGetWorkspaceFile . mockResolvedValue ( { id : 'file-1' , name : 'report.txt' } )
246256 mockHeadObject . mockResolvedValue ( { size : 250 , contentType : 'text/plain' } )
247257 mockHasCloudStorage . mockReturnValue ( true )
248258 mockResolveStorageBillingContext . mockResolvedValue ( STORAGE_CONTEXT )
@@ -284,6 +294,11 @@ describe('executeMaterializeFile - save storage transition', () => {
284294 expect ( result . success ) . toBe ( true )
285295 expect ( mockHeadObject ) . toHaveBeenCalledWith ( 'mothership/file-1' , 'mothership' )
286296 expect ( mockCheckStorageQuotaForBillingContext ) . toHaveBeenCalledWith ( STORAGE_CONTEXT , 250 )
297+ expect ( mockAllocateUniqueWorkspaceFileName ) . toHaveBeenCalledWith (
298+ context . workspaceId ,
299+ 'report.txt' ,
300+ null
301+ )
287302 expect ( dbChainMockFns . set ) . toHaveBeenCalledWith (
288303 expect . objectContaining ( { context : 'workspace' , chatId : null , size : 250 } )
289304 )
@@ -293,15 +308,167 @@ describe('executeMaterializeFile - save storage transition', () => {
293308 )
294309 } )
295310
311+ it ( 'materializes with an available root-level copy name' , async ( ) => {
312+ mockFindUpload . mockResolvedValueOnce ( {
313+ ...mothershipRow ,
314+ originalName : 'image.png' ,
315+ displayName : 'image.png' ,
316+ } )
317+ mockAllocateUniqueWorkspaceFileName . mockResolvedValueOnce ( 'image (1).png' )
318+ dbChainMockFns . returning . mockResolvedValueOnce ( [
319+ { id : 'file-1' , originalName : 'image (1).png' } ,
320+ ] )
321+
322+ const result = await executeMaterializeFile (
323+ { fileNames : [ 'image.png' ] , operation : 'save' } ,
324+ context
325+ )
326+
327+ expect ( result . success ) . toBe ( true )
328+ expect ( mockAllocateUniqueWorkspaceFileName ) . toHaveBeenCalledWith (
329+ context . workspaceId ,
330+ 'image.png' ,
331+ null
332+ )
333+ expect ( dbChainMockFns . set ) . toHaveBeenCalledWith (
334+ expect . objectContaining ( {
335+ context : 'workspace' ,
336+ originalName : 'image (1).png' ,
337+ displayName : 'image (1).png' ,
338+ } )
339+ )
340+ expect ( result . output ) . toEqual ( { succeeded : [ 'image (1).png' ] , failed : [ ] } )
341+ expect ( result . resources ) . toEqual ( [ { type : 'file' , id : 'file-1' , title : 'image (1).png' } ] )
342+ } )
343+
344+ it ( 'reallocates and retries when a concurrent root-level write claims the name' , async ( ) => {
345+ const nameCollision = Object . assign ( new Error ( 'duplicate workspace file name' ) , {
346+ code : '23505' ,
347+ constraint_name : 'workspace_files_workspace_folder_name_active_unique' ,
348+ } )
349+ mockFindUpload . mockResolvedValueOnce ( {
350+ ...mothershipRow ,
351+ originalName : 'image.png' ,
352+ displayName : 'image.png' ,
353+ } )
354+ mockAllocateUniqueWorkspaceFileName
355+ . mockResolvedValueOnce ( 'image (1).png' )
356+ . mockResolvedValueOnce ( 'image (2).png' )
357+ dbChainMockFns . returning
358+ . mockRejectedValueOnce ( nameCollision )
359+ . mockResolvedValueOnce ( [ { id : 'file-1' , originalName : 'image (2).png' } ] )
360+
361+ const result = await executeMaterializeFile (
362+ { fileNames : [ 'image.png' ] , operation : 'save' } ,
363+ context
364+ )
365+
366+ expect ( result . success ) . toBe ( true )
367+ expect ( mockAllocateUniqueWorkspaceFileName ) . toHaveBeenCalledTimes ( 2 )
368+ expect ( mockAllocateUniqueWorkspaceFileName ) . toHaveBeenNthCalledWith (
369+ 1 ,
370+ context . workspaceId ,
371+ 'image.png' ,
372+ null
373+ )
374+ expect ( mockAllocateUniqueWorkspaceFileName ) . toHaveBeenNthCalledWith (
375+ 2 ,
376+ context . workspaceId ,
377+ 'image.png' ,
378+ null
379+ )
380+ expect ( dbChainMockFns . transaction ) . toHaveBeenCalledTimes ( 2 )
381+ expect ( dbChainMockFns . set ) . toHaveBeenNthCalledWith (
382+ 1 ,
383+ expect . objectContaining ( { originalName : 'image (1).png' } )
384+ )
385+ expect ( dbChainMockFns . set ) . toHaveBeenNthCalledWith (
386+ 2 ,
387+ expect . objectContaining ( { originalName : 'image (2).png' } )
388+ )
389+ expect ( mockIncrementStorageUsageForBillingContextInTx ) . toHaveBeenCalledTimes ( 1 )
390+ expect ( result . output ) . toEqual ( { succeeded : [ 'image (2).png' ] , failed : [ ] } )
391+ expect ( result . resources ) . toEqual ( [ { type : 'file' , id : 'file-1' , title : 'image (2).png' } ] )
392+ } )
393+
394+ it ( 'stops after the bounded number of root-level name collisions' , async ( ) => {
395+ const nameCollision = Object . assign ( new Error ( 'duplicate workspace file name' ) , {
396+ code : '23505' ,
397+ constraint_name : 'workspace_files_workspace_folder_name_active_unique' ,
398+ } )
399+ dbChainMockFns . returning . mockRejectedValue ( nameCollision )
400+
401+ const result = await executeMaterializeFile (
402+ { fileNames : [ 'report.txt' ] , operation : 'save' } ,
403+ context
404+ )
405+
406+ expect ( result . success ) . toBe ( false )
407+ expect ( mockAllocateUniqueWorkspaceFileName ) . toHaveBeenCalledTimes ( 8 )
408+ expect ( dbChainMockFns . transaction ) . toHaveBeenCalledTimes ( 8 )
409+ expect ( mockIncrementStorageUsageForBillingContextInTx ) . not . toHaveBeenCalled ( )
410+ expect ( mockMaybeNotifyStorageLimitForBillingContext ) . not . toHaveBeenCalled ( )
411+ } )
412+
413+ it ( 'does not retry unique violations from a different constraint' , async ( ) => {
414+ const keyCollision = Object . assign ( new Error ( 'duplicate workspace file key' ) , {
415+ code : '23505' ,
416+ constraint_name : 'workspace_files_key_active_unique' ,
417+ } )
418+ dbChainMockFns . returning . mockRejectedValueOnce ( keyCollision )
419+
420+ const result = await executeMaterializeFile (
421+ { fileNames : [ 'report.txt' ] , operation : 'save' } ,
422+ context
423+ )
424+
425+ expect ( result . success ) . toBe ( false )
426+ expect ( mockAllocateUniqueWorkspaceFileName ) . toHaveBeenCalledTimes ( 1 )
427+ expect ( dbChainMockFns . transaction ) . toHaveBeenCalledTimes ( 1 )
428+ expect ( mockIncrementStorageUsageForBillingContextInTx ) . not . toHaveBeenCalled ( )
429+ } )
430+
296431 it ( 'treats a lost conditional transition as a replay no-op' , async ( ) => {
297432 dbChainMockFns . returning . mockResolvedValueOnce ( [ ] )
433+ mockGetWorkspaceFile . mockResolvedValueOnce ( { id : 'file-1' , name : 'report (1).txt' } )
298434
299435 const result = await executeMaterializeFile (
300436 { fileNames : [ 'report.txt' ] , operation : 'save' } ,
301437 context
302438 )
303439
304440 expect ( result . success ) . toBe ( true )
441+ expect ( mockGetWorkspaceFile ) . toHaveBeenCalledWith ( context . workspaceId , 'file-1' , {
442+ throwOnError : true ,
443+ } )
444+ expect ( result . output ) . toEqual ( { succeeded : [ 'report (1).txt' ] , failed : [ ] } )
445+ expect ( result . resources ) . toEqual ( [ { type : 'file' , id : 'file-1' , title : 'report (1).txt' } ] )
446+ expect ( mockIncrementStorageUsageForBillingContextInTx ) . not . toHaveBeenCalled ( )
447+ expect ( mockMaybeNotifyStorageLimitForBillingContext ) . not . toHaveBeenCalled ( )
448+ } )
449+
450+ it ( 'fails a replay when the materialized workspace file no longer exists' , async ( ) => {
451+ dbChainMockFns . returning . mockResolvedValueOnce ( [ ] )
452+ mockGetWorkspaceFile . mockResolvedValueOnce ( null )
453+
454+ const result = await executeMaterializeFile (
455+ { fileNames : [ 'report.txt' ] , operation : 'save' } ,
456+ context
457+ )
458+
459+ expect ( result . success ) . toBe ( false )
460+ expect ( result . output ) . toEqual ( {
461+ succeeded : [ ] ,
462+ failed : [
463+ {
464+ fileName : 'report.txt' ,
465+ error : 'Upload no longer available: "report.txt".' ,
466+ } ,
467+ ] ,
468+ } )
469+ expect ( mockGetWorkspaceFile ) . toHaveBeenCalledWith ( context . workspaceId , 'file-1' , {
470+ throwOnError : true ,
471+ } )
305472 expect ( mockIncrementStorageUsageForBillingContextInTx ) . not . toHaveBeenCalled ( )
306473 expect ( mockMaybeNotifyStorageLimitForBillingContext ) . not . toHaveBeenCalled ( )
307474 } )
0 commit comments