@@ -5,22 +5,26 @@ 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,
14+ mockGetWorkspaceFile,
1315 mockHasCloudStorage,
1416 mockHeadObject,
1517 mockIncrementStorageUsageForBillingContextInTx,
1618 mockMaybeNotifyStorageLimitForBillingContext,
1719 mockResolveStorageBillingContext,
1820} = vi . hoisted ( ( ) => ( {
21+ mockAllocateUniqueWorkspaceFileName : vi . fn ( ) ,
1922 mockCheckStorageQuotaForBillingContext : vi . fn ( ) ,
2023 mockDecompress : vi . fn ( ) ,
2124 mockFetchBuffer : vi . fn ( ) ,
2225 mockFindFolder : vi . fn ( ) ,
2326 mockFindUpload : vi . fn ( ) ,
27+ mockGetWorkspaceFile : vi . fn ( ) ,
2428 mockHasCloudStorage : vi . fn ( ) ,
2529 mockHeadObject : vi . fn ( ) ,
2630 mockIncrementStorageUsageForBillingContextInTx : vi . fn ( ) ,
@@ -41,7 +45,9 @@ vi.mock('@/lib/uploads', () => ({
4145} ) )
4246
4347vi . mock ( '@/lib/uploads/contexts/workspace/workspace-file-manager' , ( ) => ( {
48+ allocateUniqueWorkspaceFileName : mockAllocateUniqueWorkspaceFileName ,
4449 fetchWorkspaceFileBuffer : mockFetchBuffer ,
50+ getWorkspaceFile : mockGetWorkspaceFile ,
4551} ) )
4652
4753vi . mock ( '@/lib/uploads/contexts/workspace/workspace-file-folder-manager' , ( ) => ( {
@@ -76,7 +82,9 @@ vi.mock('@/lib/billing/storage', () => ({
7682} ) )
7783
7884vi . mock ( '@/lib/copilot/vfs/path-utils' , ( ) => ( {
79- canonicalWorkspaceFilePath : vi . fn ( ( ) => 'files/report.txt' ) ,
85+ canonicalWorkspaceFilePath : vi . fn (
86+ ( { name } : { name : string } ) => `files/${ encodeURIComponent ( name ) } `
87+ ) ,
8088 encodeVfsPathSegments : ( segments : string [ ] ) =>
8189 segments . map ( ( s ) => encodeURIComponent ( s ) ) . join ( '/' ) ,
8290} ) )
@@ -237,6 +245,8 @@ describe('executeMaterializeFile - save storage transition', () => {
237245 vi . clearAllMocks ( )
238246 resetDbChainMock ( )
239247 mockFindUpload . mockResolvedValue ( mothershipRow )
248+ mockAllocateUniqueWorkspaceFileName . mockResolvedValue ( 'report.txt' )
249+ mockGetWorkspaceFile . mockResolvedValue ( { id : 'file-1' , name : 'report.txt' } )
240250 mockHeadObject . mockResolvedValue ( { size : 250 , contentType : 'text/plain' } )
241251 mockHasCloudStorage . mockReturnValue ( true )
242252 mockResolveStorageBillingContext . mockResolvedValue ( STORAGE_CONTEXT )
@@ -278,6 +288,11 @@ describe('executeMaterializeFile - save storage transition', () => {
278288 expect ( result . success ) . toBe ( true )
279289 expect ( mockHeadObject ) . toHaveBeenCalledWith ( 'mothership/file-1' , 'mothership' )
280290 expect ( mockCheckStorageQuotaForBillingContext ) . toHaveBeenCalledWith ( STORAGE_CONTEXT , 250 )
291+ expect ( mockAllocateUniqueWorkspaceFileName ) . toHaveBeenCalledWith (
292+ context . workspaceId ,
293+ 'report.txt' ,
294+ null
295+ )
281296 expect ( dbChainMockFns . set ) . toHaveBeenCalledWith (
282297 expect . objectContaining ( { context : 'workspace' , chatId : null , size : 250 } )
283298 )
@@ -287,15 +302,160 @@ describe('executeMaterializeFile - save storage transition', () => {
287302 )
288303 } )
289304
305+ it ( 'materializes with an available root-level copy name' , async ( ) => {
306+ mockFindUpload . mockResolvedValueOnce ( {
307+ ...mothershipRow ,
308+ originalName : 'image.png' ,
309+ displayName : 'image.png' ,
310+ } )
311+ mockAllocateUniqueWorkspaceFileName . mockResolvedValueOnce ( 'image (1).png' )
312+ dbChainMockFns . returning . mockResolvedValueOnce ( [
313+ { id : 'file-1' , originalName : 'image (1).png' } ,
314+ ] )
315+
316+ const result = await executeMaterializeFile (
317+ { fileNames : [ 'image.png' ] , operation : 'save' } ,
318+ context
319+ )
320+
321+ expect ( result . success ) . toBe ( true )
322+ expect ( mockAllocateUniqueWorkspaceFileName ) . toHaveBeenCalledWith (
323+ context . workspaceId ,
324+ 'image.png' ,
325+ null
326+ )
327+ expect ( dbChainMockFns . set ) . toHaveBeenCalledWith (
328+ expect . objectContaining ( { context : 'workspace' , originalName : 'image (1).png' } )
329+ )
330+ expect ( result . resources ) . toEqual ( [ { type : 'file' , id : 'file-1' , title : 'image (1).png' } ] )
331+ } )
332+
333+ it ( 'reallocates and retries when a concurrent root-level write claims the name' , async ( ) => {
334+ const nameCollision = Object . assign ( new Error ( 'duplicate workspace file name' ) , {
335+ code : '23505' ,
336+ constraint_name : 'workspace_files_workspace_folder_name_active_unique' ,
337+ } )
338+ mockFindUpload . mockResolvedValueOnce ( {
339+ ...mothershipRow ,
340+ originalName : 'image.png' ,
341+ displayName : 'image.png' ,
342+ } )
343+ mockAllocateUniqueWorkspaceFileName
344+ . mockResolvedValueOnce ( 'image (1).png' )
345+ . mockResolvedValueOnce ( 'image (2).png' )
346+ dbChainMockFns . returning
347+ . mockRejectedValueOnce ( nameCollision )
348+ . mockResolvedValueOnce ( [ { id : 'file-1' , originalName : 'image (2).png' } ] )
349+
350+ const result = await executeMaterializeFile (
351+ { fileNames : [ 'image.png' ] , operation : 'save' } ,
352+ context
353+ )
354+
355+ expect ( result . success ) . toBe ( true )
356+ expect ( mockAllocateUniqueWorkspaceFileName ) . toHaveBeenCalledTimes ( 2 )
357+ expect ( mockAllocateUniqueWorkspaceFileName ) . toHaveBeenNthCalledWith (
358+ 1 ,
359+ context . workspaceId ,
360+ 'image.png' ,
361+ null
362+ )
363+ expect ( mockAllocateUniqueWorkspaceFileName ) . toHaveBeenNthCalledWith (
364+ 2 ,
365+ context . workspaceId ,
366+ 'image.png' ,
367+ null
368+ )
369+ expect ( dbChainMockFns . transaction ) . toHaveBeenCalledTimes ( 2 )
370+ expect ( dbChainMockFns . set ) . toHaveBeenNthCalledWith (
371+ 1 ,
372+ expect . objectContaining ( { originalName : 'image (1).png' } )
373+ )
374+ expect ( dbChainMockFns . set ) . toHaveBeenNthCalledWith (
375+ 2 ,
376+ expect . objectContaining ( { originalName : 'image (2).png' } )
377+ )
378+ expect ( mockIncrementStorageUsageForBillingContextInTx ) . toHaveBeenCalledTimes ( 1 )
379+ expect ( result . resources ) . toEqual ( [ { type : 'file' , id : 'file-1' , title : 'image (2).png' } ] )
380+ } )
381+
382+ it ( 'stops after the bounded number of root-level name collisions' , async ( ) => {
383+ const nameCollision = Object . assign ( new Error ( 'duplicate workspace file name' ) , {
384+ code : '23505' ,
385+ constraint_name : 'workspace_files_workspace_folder_name_active_unique' ,
386+ } )
387+ dbChainMockFns . returning . mockRejectedValue ( nameCollision )
388+
389+ const result = await executeMaterializeFile (
390+ { fileNames : [ 'report.txt' ] , operation : 'save' } ,
391+ context
392+ )
393+
394+ expect ( result . success ) . toBe ( false )
395+ expect ( mockAllocateUniqueWorkspaceFileName ) . toHaveBeenCalledTimes ( 8 )
396+ expect ( dbChainMockFns . transaction ) . toHaveBeenCalledTimes ( 8 )
397+ expect ( mockIncrementStorageUsageForBillingContextInTx ) . not . toHaveBeenCalled ( )
398+ expect ( mockMaybeNotifyStorageLimitForBillingContext ) . not . toHaveBeenCalled ( )
399+ } )
400+
401+ it ( 'does not retry unique violations from a different constraint' , async ( ) => {
402+ const keyCollision = Object . assign ( new Error ( 'duplicate workspace file key' ) , {
403+ code : '23505' ,
404+ constraint_name : 'workspace_files_key_active_unique' ,
405+ } )
406+ dbChainMockFns . returning . mockRejectedValueOnce ( keyCollision )
407+
408+ const result = await executeMaterializeFile (
409+ { fileNames : [ 'report.txt' ] , operation : 'save' } ,
410+ context
411+ )
412+
413+ expect ( result . success ) . toBe ( false )
414+ expect ( mockAllocateUniqueWorkspaceFileName ) . toHaveBeenCalledTimes ( 1 )
415+ expect ( dbChainMockFns . transaction ) . toHaveBeenCalledTimes ( 1 )
416+ expect ( mockIncrementStorageUsageForBillingContextInTx ) . not . toHaveBeenCalled ( )
417+ } )
418+
290419 it ( 'treats a lost conditional transition as a replay no-op' , async ( ) => {
291420 dbChainMockFns . returning . mockResolvedValueOnce ( [ ] )
421+ mockGetWorkspaceFile . mockResolvedValueOnce ( { id : 'file-1' , name : 'report (1).txt' } )
292422
293423 const result = await executeMaterializeFile (
294424 { fileNames : [ 'report.txt' ] , operation : 'save' } ,
295425 context
296426 )
297427
298428 expect ( result . success ) . toBe ( true )
429+ expect ( mockGetWorkspaceFile ) . toHaveBeenCalledWith ( context . workspaceId , 'file-1' , {
430+ throwOnError : true ,
431+ } )
432+ expect ( result . resources ) . toEqual ( [ { type : 'file' , id : 'file-1' , title : 'report (1).txt' } ] )
433+ expect ( mockIncrementStorageUsageForBillingContextInTx ) . not . toHaveBeenCalled ( )
434+ expect ( mockMaybeNotifyStorageLimitForBillingContext ) . not . toHaveBeenCalled ( )
435+ } )
436+
437+ it ( 'fails a replay when the materialized workspace file no longer exists' , async ( ) => {
438+ dbChainMockFns . returning . mockResolvedValueOnce ( [ ] )
439+ mockGetWorkspaceFile . mockResolvedValueOnce ( null )
440+
441+ const result = await executeMaterializeFile (
442+ { fileNames : [ 'report.txt' ] , operation : 'save' } ,
443+ context
444+ )
445+
446+ expect ( result . success ) . toBe ( false )
447+ expect ( result . output ) . toEqual ( {
448+ succeeded : [ ] ,
449+ failed : [
450+ {
451+ fileName : 'report.txt' ,
452+ error : 'Upload no longer available: "report.txt".' ,
453+ } ,
454+ ] ,
455+ } )
456+ expect ( mockGetWorkspaceFile ) . toHaveBeenCalledWith ( context . workspaceId , 'file-1' , {
457+ throwOnError : true ,
458+ } )
299459 expect ( mockIncrementStorageUsageForBillingContextInTx ) . not . toHaveBeenCalled ( )
300460 expect ( mockMaybeNotifyStorageLimitForBillingContext ) . not . toHaveBeenCalled ( )
301461 } )
0 commit comments