@@ -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,167 @@ 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 ( {
329+ context : 'workspace' ,
330+ originalName : 'image (1).png' ,
331+ displayName : 'image (1).png' ,
332+ } )
333+ )
334+ expect ( result . output ) . toEqual ( { succeeded : [ 'image (1).png' ] , failed : [ ] } )
335+ expect ( result . resources ) . toEqual ( [ { type : 'file' , id : 'file-1' , title : 'image (1).png' } ] )
336+ } )
337+
338+ it ( 'reallocates and retries when a concurrent root-level write claims the name' , async ( ) => {
339+ const nameCollision = Object . assign ( new Error ( 'duplicate workspace file name' ) , {
340+ code : '23505' ,
341+ constraint_name : 'workspace_files_workspace_folder_name_active_unique' ,
342+ } )
343+ mockFindUpload . mockResolvedValueOnce ( {
344+ ...mothershipRow ,
345+ originalName : 'image.png' ,
346+ displayName : 'image.png' ,
347+ } )
348+ mockAllocateUniqueWorkspaceFileName
349+ . mockResolvedValueOnce ( 'image (1).png' )
350+ . mockResolvedValueOnce ( 'image (2).png' )
351+ dbChainMockFns . returning
352+ . mockRejectedValueOnce ( nameCollision )
353+ . mockResolvedValueOnce ( [ { id : 'file-1' , originalName : 'image (2).png' } ] )
354+
355+ const result = await executeMaterializeFile (
356+ { fileNames : [ 'image.png' ] , operation : 'save' } ,
357+ context
358+ )
359+
360+ expect ( result . success ) . toBe ( true )
361+ expect ( mockAllocateUniqueWorkspaceFileName ) . toHaveBeenCalledTimes ( 2 )
362+ expect ( mockAllocateUniqueWorkspaceFileName ) . toHaveBeenNthCalledWith (
363+ 1 ,
364+ context . workspaceId ,
365+ 'image.png' ,
366+ null
367+ )
368+ expect ( mockAllocateUniqueWorkspaceFileName ) . toHaveBeenNthCalledWith (
369+ 2 ,
370+ context . workspaceId ,
371+ 'image.png' ,
372+ null
373+ )
374+ expect ( dbChainMockFns . transaction ) . toHaveBeenCalledTimes ( 2 )
375+ expect ( dbChainMockFns . set ) . toHaveBeenNthCalledWith (
376+ 1 ,
377+ expect . objectContaining ( { originalName : 'image (1).png' } )
378+ )
379+ expect ( dbChainMockFns . set ) . toHaveBeenNthCalledWith (
380+ 2 ,
381+ expect . objectContaining ( { originalName : 'image (2).png' } )
382+ )
383+ expect ( mockIncrementStorageUsageForBillingContextInTx ) . toHaveBeenCalledTimes ( 1 )
384+ expect ( result . output ) . toEqual ( { succeeded : [ 'image (2).png' ] , failed : [ ] } )
385+ expect ( result . resources ) . toEqual ( [ { type : 'file' , id : 'file-1' , title : 'image (2).png' } ] )
386+ } )
387+
388+ it ( 'stops after the bounded number of root-level name collisions' , async ( ) => {
389+ const nameCollision = Object . assign ( new Error ( 'duplicate workspace file name' ) , {
390+ code : '23505' ,
391+ constraint_name : 'workspace_files_workspace_folder_name_active_unique' ,
392+ } )
393+ dbChainMockFns . returning . mockRejectedValue ( nameCollision )
394+
395+ const result = await executeMaterializeFile (
396+ { fileNames : [ 'report.txt' ] , operation : 'save' } ,
397+ context
398+ )
399+
400+ expect ( result . success ) . toBe ( false )
401+ expect ( mockAllocateUniqueWorkspaceFileName ) . toHaveBeenCalledTimes ( 8 )
402+ expect ( dbChainMockFns . transaction ) . toHaveBeenCalledTimes ( 8 )
403+ expect ( mockIncrementStorageUsageForBillingContextInTx ) . not . toHaveBeenCalled ( )
404+ expect ( mockMaybeNotifyStorageLimitForBillingContext ) . not . toHaveBeenCalled ( )
405+ } )
406+
407+ it ( 'does not retry unique violations from a different constraint' , async ( ) => {
408+ const keyCollision = Object . assign ( new Error ( 'duplicate workspace file key' ) , {
409+ code : '23505' ,
410+ constraint_name : 'workspace_files_key_active_unique' ,
411+ } )
412+ dbChainMockFns . returning . mockRejectedValueOnce ( keyCollision )
413+
414+ const result = await executeMaterializeFile (
415+ { fileNames : [ 'report.txt' ] , operation : 'save' } ,
416+ context
417+ )
418+
419+ expect ( result . success ) . toBe ( false )
420+ expect ( mockAllocateUniqueWorkspaceFileName ) . toHaveBeenCalledTimes ( 1 )
421+ expect ( dbChainMockFns . transaction ) . toHaveBeenCalledTimes ( 1 )
422+ expect ( mockIncrementStorageUsageForBillingContextInTx ) . not . toHaveBeenCalled ( )
423+ } )
424+
290425 it ( 'treats a lost conditional transition as a replay no-op' , async ( ) => {
291426 dbChainMockFns . returning . mockResolvedValueOnce ( [ ] )
427+ mockGetWorkspaceFile . mockResolvedValueOnce ( { id : 'file-1' , name : 'report (1).txt' } )
292428
293429 const result = await executeMaterializeFile (
294430 { fileNames : [ 'report.txt' ] , operation : 'save' } ,
295431 context
296432 )
297433
298434 expect ( result . success ) . toBe ( true )
435+ expect ( mockGetWorkspaceFile ) . toHaveBeenCalledWith ( context . workspaceId , 'file-1' , {
436+ throwOnError : true ,
437+ } )
438+ expect ( result . output ) . toEqual ( { succeeded : [ 'report (1).txt' ] , failed : [ ] } )
439+ expect ( result . resources ) . toEqual ( [ { type : 'file' , id : 'file-1' , title : 'report (1).txt' } ] )
440+ expect ( mockIncrementStorageUsageForBillingContextInTx ) . not . toHaveBeenCalled ( )
441+ expect ( mockMaybeNotifyStorageLimitForBillingContext ) . not . toHaveBeenCalled ( )
442+ } )
443+
444+ it ( 'fails a replay when the materialized workspace file no longer exists' , async ( ) => {
445+ dbChainMockFns . returning . mockResolvedValueOnce ( [ ] )
446+ mockGetWorkspaceFile . mockResolvedValueOnce ( null )
447+
448+ const result = await executeMaterializeFile (
449+ { fileNames : [ 'report.txt' ] , operation : 'save' } ,
450+ context
451+ )
452+
453+ expect ( result . success ) . toBe ( false )
454+ expect ( result . output ) . toEqual ( {
455+ succeeded : [ ] ,
456+ failed : [
457+ {
458+ fileName : 'report.txt' ,
459+ error : 'Upload no longer available: "report.txt".' ,
460+ } ,
461+ ] ,
462+ } )
463+ expect ( mockGetWorkspaceFile ) . toHaveBeenCalledWith ( context . workspaceId , 'file-1' , {
464+ throwOnError : true ,
465+ } )
299466 expect ( mockIncrementStorageUsageForBillingContextInTx ) . not . toHaveBeenCalled ( )
300467 expect ( mockMaybeNotifyStorageLimitForBillingContext ) . not . toHaveBeenCalled ( )
301468 } )
0 commit comments