@@ -39,11 +39,15 @@ function makeManager(sockets: FakeSocket[], presence: Partial<UserPresence>[] =
3939 io : { sockets : { sockets : socketMap } } ,
4040 isReady : ( ) => true ,
4141 getWorkflowUsers : vi . fn ( ) . mockResolvedValue ( presence ) ,
42- removeUserFromRoom : vi . fn ( ) . mockResolvedValue ( null ) ,
42+ getWorkflowIdForSocket : vi . fn ( ) . mockResolvedValue ( null ) ,
43+ removeUserFromRoom : vi
44+ . fn ( )
45+ . mockImplementation ( async ( _socketId : string , workflowId ?: string ) => workflowId ?? null ) ,
4346 broadcastPresenceUpdate : vi . fn ( ) . mockResolvedValue ( undefined ) ,
4447 }
4548 return manager as unknown as IRoomManager & {
4649 getWorkflowUsers : ReturnType < typeof vi . fn >
50+ getWorkflowIdForSocket : ReturnType < typeof vi . fn >
4751 removeUserFromRoom : ReturnType < typeof vi . fn >
4852 broadcastPresenceUpdate : ReturnType < typeof vi . fn >
4953 }
@@ -185,6 +189,47 @@ describe('access-revalidation sweep', () => {
185189 expect ( manager . broadcastPresenceUpdate ) . toHaveBeenCalledWith ( 'wf-1' )
186190 } )
187191
192+ it ( 'defers cleanup when the manager swallows a removal failure into null' , async ( ) => {
193+ const socket = makeSocket ( 'sock-1' , 'user-1' , 'wf-1' )
194+ const manager = makeManager ( [ socket ] , [ { socketId : 'sock-1' , role : 'read' } ] )
195+ // Live mapping but the removal reports nothing removed — the Redis manager
196+ // swallows transport errors into null, so this is the only failure signal.
197+ manager . getWorkflowIdForSocket . mockResolvedValue ( 'wf-1' )
198+ manager . removeUserFromRoom . mockResolvedValueOnce ( null )
199+ mockResolveRole . mockResolvedValue ( null )
200+
201+ const sweep = startAccessRevalidationSweep ( manager )
202+ await sweep . runOnce ( )
203+
204+ expect ( socket . leave ) . toHaveBeenCalledWith ( 'wf-1' )
205+ expect ( manager . broadcastPresenceUpdate ) . not . toHaveBeenCalled ( )
206+
207+ // Next pass: the socket left the room, and the removal now succeeds.
208+ socket . rooms = new Set ( [ 'sock-1' ] )
209+ await sweep . runOnce ( )
210+ sweep . stop ( )
211+
212+ expect ( manager . removeUserFromRoom ) . toHaveBeenCalledTimes ( 2 )
213+ expect ( manager . broadcastPresenceUpdate ) . toHaveBeenCalledWith ( 'wf-1' )
214+ } )
215+
216+ it ( 'skips removal when the socket has since moved to a different workflow' , async ( ) => {
217+ const socket = makeSocket ( 'sock-1' , 'user-1' , 'wf-1' )
218+ const manager = makeManager ( [ socket ] , [ { socketId : 'sock-1' , role : 'read' } ] )
219+ // Between the membership snapshot and cleanup, the socket switched to a
220+ // workflow it can still access — removal must not touch its new presence.
221+ manager . getWorkflowIdForSocket . mockResolvedValue ( 'wf-2' )
222+ mockResolveRole . mockResolvedValue ( null )
223+
224+ const sweep = startAccessRevalidationSweep ( manager )
225+ await sweep . runOnce ( )
226+ sweep . stop ( )
227+
228+ expect ( socket . leave ) . toHaveBeenCalledWith ( 'wf-1' )
229+ expect ( manager . removeUserFromRoom ) . not . toHaveBeenCalled ( )
230+ expect ( manager . broadcastPresenceUpdate ) . not . toHaveBeenCalled ( )
231+ } )
232+
188233 it ( 'drops a deferred cleanup when the socket legitimately re-joined the room' , async ( ) => {
189234 const socket = makeSocket ( 'sock-1' , 'user-1' , 'wf-1' )
190235 const manager = makeManager ( [ socket ] , [ { socketId : 'sock-1' , role : 'read' } ] )
0 commit comments