@@ -13,6 +13,7 @@ import {
1313 type JoinFileDocError ,
1414 type JoinFileDocPayload ,
1515 type JoinFileDocSuccess ,
16+ type LeaveFileDocPayload ,
1617} from '@sim/realtime-protocol/file-doc'
1718import { createDeferred } from '@sim/testing/helpers/deferred'
1819import { getErrorMessage } from '@sim/utils/errors'
@@ -296,6 +297,97 @@ describe('Project documents across the Socket.IO and internal HTTP boundary', ()
296297 } )
297298 }
298299
300+ for ( const action of [ 'join' , 'leave' ] as const ) {
301+ check (
302+ action === 'join'
303+ ? 'a join for another owner supersedes the pending admission for the same file'
304+ : 'a leave for another owner preserves the pending admission for the same file' ,
305+ async ( ) => {
306+ const actor = `owner-intent-${ generateId ( ) } `
307+ actors . set ( actor , 'read' )
308+ const gate = {
309+ requests : 0 ,
310+ entered : createDeferred < void > ( ) ,
311+ release : createDeferred < void > ( ) ,
312+ status : 200 ,
313+ }
314+ accessGates . set ( actor , gate )
315+ const socket = connect ( socketUrl , { transports : [ 'websocket' ] , auth : { actor } } )
316+ clients . push ( socket )
317+ await new Promise < void > ( ( resolve ) => socket . once ( 'connect' , resolve ) )
318+ const server = io . sockets . sockets . get ( socket . id ?? '' )
319+ if ( ! server ) throw new Error ( 'Pending socket missing' )
320+ const originalJoin = server . listeners ( FILE_DOC_EVENTS . JOIN ) [ 0 ]
321+ const originalLeave = server . listeners ( FILE_DOC_EVENTS . LEAVE ) [ 0 ]
322+ if ( ! originalJoin || ! originalLeave ) throw new Error ( 'Document lifecycle handlers missing' )
323+ const provider = new Y . Doc ( )
324+ const nextProvider = new Y . Doc ( )
325+ const done = createDeferred < void > ( )
326+ const actionDone = createDeferred < void > ( )
327+ const observeJoin = async ( payload : JoinFileDocPayload ) => {
328+ try {
329+ await originalJoin . call ( server , payload )
330+ } finally {
331+ if ( payload . clientId === provider . clientID ) done . resolve ( )
332+ else actionDone . resolve ( )
333+ }
334+ }
335+ const observeLeave = async ( payload : LeaveFileDocPayload ) => {
336+ try {
337+ await originalLeave . call ( server , payload )
338+ } finally {
339+ actionDone . resolve ( )
340+ }
341+ }
342+ server . off ( FILE_DOC_EVENTS . JOIN , originalJoin )
343+ server . on ( FILE_DOC_EVENTS . JOIN , observeJoin )
344+ server . off ( FILE_DOC_EVENTS . LEAVE , originalLeave )
345+ server . on ( FILE_DOC_EVENTS . LEAVE , observeLeave )
346+ const admissions : JoinFileDocSuccess [ ] = [ ]
347+ const errors : JoinFileDocError [ ] = [ ]
348+ socket . on ( FILE_DOC_EVENTS . JOIN_SUCCESS , ( payload ) => admissions . push ( payload ) )
349+ socket . on ( FILE_DOC_EVENTS . JOIN_ERROR , ( payload ) => errors . push ( payload ) )
350+ try {
351+ socket . emit ( FILE_DOC_EVENTS . JOIN , {
352+ fileId,
353+ projectId,
354+ clientId : provider . clientID ,
355+ schemaVersion : FILE_DOC_SCHEMA_VERSION ,
356+ } )
357+ await gate . entered . promise
358+ const otherOwner = { fileId, projectId : generateId ( ) }
359+ if ( action === 'join' )
360+ socket . emit ( FILE_DOC_EVENTS . JOIN , {
361+ ...otherOwner ,
362+ clientId : nextProvider . clientID ,
363+ schemaVersion : FILE_DOC_SCHEMA_VERSION ,
364+ } )
365+ else socket . emit ( FILE_DOC_EVENTS . LEAVE , otherOwner )
366+ await actionDone . promise
367+ gate . release . resolve ( )
368+ await done . promise
369+ const drained = new Promise < void > ( ( resolve ) => socket . once ( 'fixture-drained' , resolve ) )
370+ server . emit ( 'fixture-drained' )
371+ await drained
372+ const shouldAdmit = action === 'leave'
373+ expect ( server . rooms . has ( `project-file-doc:${ projectId } /${ fileId } ` ) ) . toBe ( shouldAdmit )
374+ expect ( admissions ) . toHaveLength ( shouldAdmit ? 1 : 0 )
375+ expect ( errors . map ( ( error ) => error . code ) ) . toEqual ( shouldAdmit ? [ ] : [ 'NOT_FOUND' ] )
376+ } finally {
377+ gate . release . resolve ( )
378+ accessGates . delete ( actor )
379+ server . off ( FILE_DOC_EVENTS . JOIN , observeJoin )
380+ server . on ( FILE_DOC_EVENTS . JOIN , originalJoin )
381+ server . off ( FILE_DOC_EVENTS . LEAVE , observeLeave )
382+ server . on ( FILE_DOC_EVENTS . LEAVE , originalLeave )
383+ socket . disconnect ( )
384+ provider . destroy ( )
385+ nextProvider . destroy ( )
386+ }
387+ }
388+ )
389+ }
390+
299391 for ( const rejectLeave of [ false , true ] ) {
300392 check (
301393 `a stale rejoin preserves the admitted document when delayed cleanup ${ rejectLeave ? 'rejects' : 'resolves' } ` ,
0 commit comments