"""Unit tests for Socket.IO event handlers in the chat feature. These tests verify the SocketIOService event handling for: - Client connection/disconnection lifecycle - Subscription management - Event emission to subscribers - Error handling and cleanup """ from threading import Thread from unittest.mock import MagicMock, patch import pytest from src.local_deep_research.web.services.socket_service import SocketIOService @pytest.fixture(autouse=True) def _reset_socketio_singleton(): """Reset ``SocketIOService._instance`` around every test. Each test wired this inline at top + bottom of the method body with no ``try/finally`` — so an assertion failure mid-test would skip the trailing cleanup and contaminate the next test with a stale singleton. An autouse fixture guarantees the reset runs in both teardown and setup regardless of test outcome. """ SocketIOService._instance = None yield SocketIOService._instance = None class TestSocketIOHandlers: """Tests for SocketIOService event handlers.""" def test_on_connect_logs_client_sid(self, app): """Test that client connection is logged with SID.""" from src.local_deep_research.web.services.socket_service import ( SocketIOService, ) with patch( "src.local_deep_research.web.services.socket_service.SocketIO" ) as mock_socketio_class: mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio with patch( "src.local_deep_research.web.services.socket_service.logger" ) as mock_logger: service = SocketIOService(app=app) # Capture the on_connect handler connect_handler = None for call in mock_socketio.on.call_args_list: if call[0][0] == "connect": connect_handler = ( call[0][1] if len(call[0]) > 1 else None ) break # If using decorator syntax, find it differently if connect_handler is None: # The handlers are registered via decorators, so we need to # invoke the internal method directly. The handler reads # flask.session, so a request context is required. mock_request = MagicMock() mock_request.sid = "test-client-123" with app.test_request_context(): service._SocketIOService__handle_connect(mock_request) # Verify logging was called assert mock_logger.info.called def test_on_disconnect_cleans_up_subscriptions(self): """Test that client disconnection cleans up subscriptions.""" from src.local_deep_research.web.services.socket_service import ( SocketIOService, ) mock_app = MagicMock() mock_app.config = {"SECRET_KEY": "test-secret"} with patch( "src.local_deep_research.web.services.socket_service.SocketIO" ) as mock_socketio_class: mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio service = SocketIOService(app=mock_app) # Manually add a subscription to test cleanup mock_request = MagicMock() mock_request.sid = "client-to-disconnect" # Add subscription via the internal dict (research_id -> set of sids) service._SocketIOService__socket_subscriptions["research-1"] = { mock_request.sid } # Call disconnect handler service._SocketIOService__handle_disconnect( mock_request, "transport close" ) # Verify subscription was cleaned up (research_id entry removed # because its only subscriber disconnected) assert ( "research-1" not in service._SocketIOService__socket_subscriptions ) def test_on_subscribe_adds_to_subscription_set(self): """Test that subscribe event adds client to subscription set.""" from src.local_deep_research.web.services.socket_service import ( SocketIOService, ) mock_app = MagicMock() mock_app.config = {"SECRET_KEY": "test-secret"} with ( patch( "src.local_deep_research.web.services.socket_service.SocketIO" ) as mock_socketio_class, patch( "src.local_deep_research.web.services.socket_service.session", {"username": "alice"}, ), patch.object( SocketIOService, "_user_owns_research", return_value=True ), ): mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio service = SocketIOService(app=mock_app) mock_request = MagicMock() mock_request.sid = "subscribing-client" data = {"research_id": "research-abc-123"} # Call subscribe handler service._SocketIOService__handle_subscribe(data, mock_request) # Verify subscription was added subs = service._SocketIOService__socket_subscriptions assert "research-abc-123" in subs assert "subscribing-client" in subs["research-abc-123"] def test_on_subscribe_sends_current_status_if_available(self): """Test that subscribe sends current status when research is active.""" from src.local_deep_research.web.services.socket_service import ( SocketIOService, ) mock_app = MagicMock() mock_app.config = {"SECRET_KEY": "test-secret"} with ( patch( "src.local_deep_research.web.services.socket_service.SocketIO" ) as mock_socketio_class, patch( "src.local_deep_research.web.services.socket_service.session", {"username": "alice"}, ), patch.object( SocketIOService, "_user_owns_research", return_value=True ), ): mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio with patch( "src.local_deep_research.web.services.socket_service.get_active_research_snapshot" ) as mock_snapshot: mock_snapshot.return_value = { "progress": 50, "log": [{"message": "Processing...", "time": "2024-01-01"}], } service = SocketIOService(app=mock_app) mock_request = MagicMock() mock_request.sid = "subscriber-client" data = {"research_id": "active-research-1"} # Call subscribe handler service._SocketIOService__handle_subscribe(data, mock_request) # Verify emit was called with current status mock_socketio.emit.assert_called() def test_emit_to_subscribers_broadcasts_to_room(self): """Test that emit_to_subscribers broadcasts to all subscribers.""" from src.local_deep_research.web.services.socket_service import ( SocketIOService, ) mock_app = MagicMock() mock_app.config = {"SECRET_KEY": "test-secret"} with patch( "src.local_deep_research.web.services.socket_service.SocketIO" ) as mock_socketio_class: mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio service = SocketIOService(app=mock_app) # Add some subscribers service._SocketIOService__socket_subscriptions["research-123"] = { "client-1", "client-2", } # Emit to subscribers result = service.emit_to_subscribers( "research_progress", "research-123", {"progress": 75, "message": "Almost done"}, ) # Verify result assert result is True # Verify emit was called assert mock_socketio.emit.called def test_emit_socket_event_handles_exceptions(self): """Test that emit_socket_event handles exceptions gracefully.""" from src.local_deep_research.web.services.socket_service import ( SocketIOService, ) mock_app = MagicMock() mock_app.config = {"SECRET_KEY": "test-secret"} with patch( "src.local_deep_research.web.services.socket_service.SocketIO" ) as mock_socketio_class: mock_socketio = MagicMock() mock_socketio.emit.side_effect = Exception("Network error") mock_socketio_class.return_value = mock_socketio service = SocketIOService(app=mock_app) # Emit should return False on error, not raise result = service.emit_socket_event("test_event", {"data": "test"}) assert result is False def test_concurrent_subscribe_unsubscribe_thread_safety(self): """Test that concurrent subscribe/unsubscribe operations are thread-safe.""" from src.local_deep_research.web.services.socket_service import ( SocketIOService, ) mock_app = MagicMock() mock_app.config = {"SECRET_KEY": "test-secret"} with ( patch( "src.local_deep_research.web.services.socket_service.SocketIO" ) as mock_socketio_class, patch( "src.local_deep_research.web.services.socket_service.session", {"username": "alice"}, ), patch.object( SocketIOService, "_user_owns_research", return_value=True ), ): mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio service = SocketIOService(app=mock_app) errors = [] research_id = "concurrent-research" def subscribe_client(client_id): try: mock_request = MagicMock() mock_request.sid = f"client-{client_id}" service._SocketIOService__handle_subscribe( {"research_id": research_id}, mock_request ) except Exception as e: errors.append(str(e)) def disconnect_client(client_id): try: mock_request = MagicMock() mock_request.sid = f"client-{client_id}" service._SocketIOService__handle_disconnect( mock_request, "close" ) except Exception as e: errors.append(str(e)) # Run concurrent operations threads = [] for i in range(10): t1 = Thread(target=subscribe_client, args=(i,)) t2 = Thread(target=disconnect_client, args=(i,)) threads.extend([t1, t2]) for t in threads: t.start() for t in threads: t.join() # Should complete without errors assert len(errors) == 0, f"Concurrent operations failed: {errors}" def test_subscription_cleanup_on_error(self): """Test that subscriptions are cleaned up even if emit fails.""" from src.local_deep_research.web.services.socket_service import ( SocketIOService, ) mock_app = MagicMock() mock_app.config = {"SECRET_KEY": "test-secret"} with ( patch( "src.local_deep_research.web.services.socket_service.SocketIO" ) as mock_socketio_class, patch( "src.local_deep_research.web.services.socket_service.session", {"username": "alice"}, ), patch.object( SocketIOService, "_user_owns_research", return_value=True ), ): mock_socketio = MagicMock() # Make emit fail for specific clients mock_socketio.emit.side_effect = Exception("Emit failed") mock_socketio_class.return_value = mock_socketio service = SocketIOService(app=mock_app) # Add a subscription mock_request = MagicMock() mock_request.sid = "failing-client" service._SocketIOService__handle_subscribe( {"research_id": "error-research"}, mock_request ) # Subscription should still be added despite emit error # (emit error is for sending current status, not for adding to set) subs = service._SocketIOService__socket_subscriptions assert "error-research" in subs assert "failing-client" in subs["error-research"] # Now disconnect - should clean up even with errors with patch( "src.local_deep_research.web.services.socket_service.logger" ): service._SocketIOService__handle_disconnect( mock_request, "close" ) # __handle_disconnect is keyed by research_id -> set of sids: it # discards the disconnecting sid from every set and drops any key # whose set is now empty. "failing-client" was the sole subscriber # to "error-research", so the entire key is removed. assert "failing-client" not in subs.get("error-research", set()) assert "error-research" not in subs