""" Tests for the SocketIOService class. Tests cover: - Singleton pattern - Event emission - Subscriber management - Error handling """ from unittest.mock import MagicMock, patch class MockFlaskApp: """Mock Flask application for testing.""" def __init__(self): self.config = {} self.debug = False class MockSocketIO: """Mock SocketIO for testing.""" def __init__(self, app=None, **kwargs): self.app = app self.kwargs = kwargs self.emitted_events = [] self._handlers = {} def emit(self, event, data, room=None): self.emitted_events.append({"event": event, "data": data, "room": room}) def on(self, event): def decorator(f): self._handlers[event] = f return f return decorator @property def on_error(self): def decorator(f): self._handlers["error"] = f return f return decorator @property def on_error_default(self): def decorator(f): self._handlers["error_default"] = f return f return decorator def run(self, app, **kwargs): pass class TestSocketIOServiceSingleton: """Tests for SocketIOService singleton pattern.""" def test_singleton_requires_app_first_time(self): """SocketIOService requires Flask app on first instantiation.""" # Reset singleton for test from local_deep_research.web.services.socket_service import ( SocketIOService, ) # Store and reset singleton original_instance = SocketIOService._instance SocketIOService._instance = None try: # Should raise ValueError when no app provided try: SocketIOService() assert False, "Should have raised ValueError" except ValueError as e: assert "Flask app must be specified" in str(e) finally: # Restore original singleton SocketIOService._instance = original_instance class TestSocketIOServiceEmit: """Tests for SocketIOService emit methods.""" @patch("local_deep_research.web.services.socket_service.SocketIO") def test_emit_socket_event_broadcast(self, mock_socketio_class): """emit_socket_event broadcasts to all clients when no room specified.""" from local_deep_research.web.services.socket_service import ( SocketIOService, ) # Reset and create fresh singleton original_instance = SocketIOService._instance SocketIOService._instance = None mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio try: app = MockFlaskApp() service = SocketIOService(app=app) result = service.emit_socket_event("test_event", {"data": "value"}) assert result is True mock_socketio.emit.assert_called_with( "test_event", {"data": "value"} ) finally: SocketIOService._instance = original_instance @patch("local_deep_research.web.services.socket_service.SocketIO") def test_emit_socket_event_to_room(self, mock_socketio_class): """emit_socket_event sends to specific room when specified.""" from local_deep_research.web.services.socket_service import ( SocketIOService, ) original_instance = SocketIOService._instance SocketIOService._instance = None mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio try: app = MockFlaskApp() service = SocketIOService(app=app) result = service.emit_socket_event( "test_event", {"data": "value"}, room="room123" ) assert result is True mock_socketio.emit.assert_called_with( "test_event", {"data": "value"}, room="room123" ) finally: SocketIOService._instance = original_instance @patch("local_deep_research.web.services.socket_service.SocketIO") def test_emit_socket_event_handles_error(self, mock_socketio_class): """emit_socket_event returns False on error.""" from local_deep_research.web.services.socket_service import ( SocketIOService, ) original_instance = SocketIOService._instance SocketIOService._instance = None mock_socketio = MagicMock() mock_socketio.emit.side_effect = Exception("Connection error") mock_socketio_class.return_value = mock_socketio try: app = MockFlaskApp() service = SocketIOService(app=app) result = service.emit_socket_event("test_event", {"data": "value"}) assert result is False finally: SocketIOService._instance = original_instance class TestSocketIOServiceSubscribers: """Tests for SocketIOService subscriber management.""" @patch("local_deep_research.web.services.socket_service.SocketIO") def test_emit_to_subscribers_success(self, mock_socketio_class): """emit_to_subscribers emits per-sid to the research channel. emit only fires when at least one subscriber sid is registered; we pre-populate one and verify the per-sid room-targeted emit. """ from local_deep_research.web.services.socket_service import ( SocketIOService, ) original_instance = SocketIOService._instance SocketIOService._instance = None mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio try: app = MockFlaskApp() service = SocketIOService(app=app) # Pre-register a subscriber sid so emit has somewhere to go. with service._SocketIOService__lock: service._SocketIOService__socket_subscriptions[ "research_123" ] = {"sid-1"} result = service.emit_to_subscribers( "progress", "research_123", {"percent": 50} ) assert result is True # Should emit to the formatted channel, scoped to the sid. mock_socketio.emit.assert_called_with( "progress_research_123", {"percent": 50}, room="sid-1" ) finally: SocketIOService._instance = original_instance @patch("local_deep_research.web.services.socket_service.SocketIO") def test_emit_to_subscribers_swallows_inner_error( self, mock_socketio_class ): """A per-subscriber emit failure is logged but does not fail the whole call — the outer ``return True`` path still wins so other subscribers in the same set can still receive the event. """ from local_deep_research.web.services.socket_service import ( SocketIOService, ) original_instance = SocketIOService._instance SocketIOService._instance = None mock_socketio = MagicMock() mock_socketio.emit.side_effect = Exception("Emit failed") mock_socketio_class.return_value = mock_socketio try: app = MockFlaskApp() service = SocketIOService(app=app) with service._SocketIOService__lock: service._SocketIOService__socket_subscriptions[ "research_123" ] = {"sid-1"} result = service.emit_to_subscribers( "progress", "research_123", {"percent": 50} ) # Per-sid failures are caught and logged; outer call still # succeeds so the loop can keep delivering to other sids. assert result is True mock_socketio.emit.assert_called_with( "progress_research_123", {"percent": 50}, room="sid-1" ) finally: SocketIOService._instance = original_instance class TestSocketIOServiceRun: """Tests for SocketIOService run method.""" @patch("local_deep_research.web.services.socket_service.SocketIO") def test_run_starts_server(self, mock_socketio_class): """run method starts the SocketIO server.""" from local_deep_research.web.services.socket_service import ( SocketIOService, ) original_instance = SocketIOService._instance SocketIOService._instance = None mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio try: app = MockFlaskApp() service = SocketIOService(app=app) # Mock run to not block mock_socketio.run = MagicMock() service.run(host="0.0.0.0", port=5000, debug=False) mock_socketio.run.assert_called_once() call_kwargs = mock_socketio.run.call_args assert call_kwargs[1]["host"] == "0.0.0.0" assert call_kwargs[1]["port"] == 5000 assert call_kwargs[1]["debug"] is False finally: SocketIOService._instance = original_instance class TestSocketIOServiceInit: """Tests for SocketIOService initialization.""" @patch("local_deep_research.web.services.socket_service.SocketIO") def test_service_initializes_with_app(self, mock_socketio_class): """Service initializes properly with Flask app.""" from local_deep_research.web.services.socket_service import ( SocketIOService, ) original_instance = SocketIOService._instance SocketIOService._instance = None mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio try: app = MockFlaskApp() service = SocketIOService(app=app) # Service should be initialized assert service is not None # SocketIO should have been created mock_socketio_class.assert_called_once() finally: SocketIOService._instance = original_instance class TestSocketIOServiceMultipleEmits: """Tests for multiple emit scenarios.""" @patch("local_deep_research.web.services.socket_service.SocketIO") def test_multiple_events_same_room(self, mock_socketio_class): """Multiple events can be emitted to the same room.""" from local_deep_research.web.services.socket_service import ( SocketIOService, ) original_instance = SocketIOService._instance SocketIOService._instance = None mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio try: app = MockFlaskApp() service = SocketIOService(app=app) service.emit_socket_event("event1", {"data": 1}, room="room1") service.emit_socket_event("event2", {"data": 2}, room="room1") assert mock_socketio.emit.call_count == 2 finally: SocketIOService._instance = original_instance @patch("local_deep_research.web.services.socket_service.SocketIO") def test_emit_with_namespace(self, mock_socketio_class): """Events can be emitted to specific namespaces.""" from local_deep_research.web.services.socket_service import ( SocketIOService, ) original_instance = SocketIOService._instance SocketIOService._instance = None mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio try: app = MockFlaskApp() service = SocketIOService(app=app) # Test emit with namespace if supported result = service.emit_socket_event( "test_event", {"data": "value"}, ) assert result is True finally: SocketIOService._instance = original_instance class TestHandleSubscribe: """Tests for subscribe event handling.""" @patch("local_deep_research.web.services.socket_service.SocketIO") def test_subscribe_adds_to_room(self, mock_socketio_class): """Subscribe event should add client to research room.""" from local_deep_research.web.services.socket_service import ( SocketIOService, ) original_instance = SocketIOService._instance SocketIOService._instance = None mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio try: app = MockFlaskApp() service = SocketIOService(app=app) # Service should register subscribe handler assert service is not None finally: SocketIOService._instance = original_instance @patch("local_deep_research.web.services.socket_service.SocketIO") def test_subscribe_with_research_id(self, mock_socketio_class): """Subscribe with valid research_id.""" from local_deep_research.web.services.socket_service import ( SocketIOService, ) original_instance = SocketIOService._instance SocketIOService._instance = None mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio try: app = MockFlaskApp() service = SocketIOService(app=app) # Service should be ready to handle subscriptions # The service initializes internal handlers assert service is not None finally: SocketIOService._instance = original_instance @patch("local_deep_research.web.services.socket_service.SocketIO") def test_subscribe_without_research_id_is_handled( self, mock_socketio_class ): """Subscribe without research_id should be handled gracefully.""" from local_deep_research.web.services.socket_service import ( SocketIOService, ) original_instance = SocketIOService._instance SocketIOService._instance = None mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio try: app = MockFlaskApp() service = SocketIOService(app=app) # Service should be able to handle missing research_id assert service is not None finally: SocketIOService._instance = original_instance class TestEmitToSubscribersAdvanced: """Advanced tests for emit_to_subscribers method.""" @patch("local_deep_research.web.services.socket_service.SocketIO") def test_emit_progress_update(self, mock_socketio_class): """Emit progress update to a registered subscriber. emit only fires per-sid, so we register a fake subscriber before calling emit_to_subscribers. """ from local_deep_research.web.services.socket_service import ( SocketIOService, ) original_instance = SocketIOService._instance SocketIOService._instance = None mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio try: app = MockFlaskApp() service = SocketIOService(app=app) with service._SocketIOService__lock: service._SocketIOService__socket_subscriptions[ "research_abc123" ] = {"sid-x"} result = service.emit_to_subscribers( "progress", "research_abc123", {"current": 50, "total": 100, "message": "Processing..."}, ) assert result is True mock_socketio.emit.assert_called_once() finally: SocketIOService._instance = original_instance @patch("local_deep_research.web.services.socket_service.SocketIO") def test_emit_status_update(self, mock_socketio_class): """Emit status update to subscribers.""" from local_deep_research.web.services.socket_service import ( SocketIOService, ) original_instance = SocketIOService._instance SocketIOService._instance = None mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio try: app = MockFlaskApp() service = SocketIOService(app=app) result = service.emit_to_subscribers( "status", "research_xyz789", {"status": "completed", "result_url": "/results/xyz789"}, ) assert result is True finally: SocketIOService._instance = original_instance @patch("local_deep_research.web.services.socket_service.SocketIO") def test_emit_error_to_subscribers(self, mock_socketio_class): """Emit error to subscribers.""" from local_deep_research.web.services.socket_service import ( SocketIOService, ) original_instance = SocketIOService._instance SocketIOService._instance = None mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio try: app = MockFlaskApp() service = SocketIOService(app=app) result = service.emit_to_subscribers( "error", "research_failed123", {"error": "Research failed", "code": 500}, ) assert result is True finally: SocketIOService._instance = original_instance class TestSocketIOServiceSingletonBehavior: """Tests for SocketIOService singleton behavior.""" @patch("local_deep_research.web.services.socket_service.SocketIO") def test_singleton_returns_same_instance(self, mock_socketio_class): """Second call returns same instance.""" from local_deep_research.web.services.socket_service import ( SocketIOService, ) original_instance = SocketIOService._instance SocketIOService._instance = None mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio try: app = MockFlaskApp() service1 = SocketIOService(app=app) service2 = SocketIOService() assert service1 is service2 finally: SocketIOService._instance = original_instance @patch("local_deep_research.web.services.socket_service.SocketIO") def test_singleton_ignores_second_app(self, mock_socketio_class): """Second app parameter is ignored.""" from local_deep_research.web.services.socket_service import ( SocketIOService, ) original_instance = SocketIOService._instance SocketIOService._instance = None mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio try: app1 = MockFlaskApp() app2 = MockFlaskApp() service1 = SocketIOService(app=app1) service2 = SocketIOService(app=app2) # Should be same instance, app2 ignored assert service1 is service2 finally: SocketIOService._instance = original_instance class TestSocketServiceDisconnectCleanup: """Tests for thread cleanup on socket disconnect. These tests verify that __handle_disconnect properly calls cleanup_current_thread() to prevent file descriptor leaks from unclosed SQLAlchemy sessions in socket handler threads. """ @patch("local_deep_research.web.services.socket_service.SocketIO") def test_disconnect_handler_exists(self, mock_socketio_class): """Test that the disconnect handler is registered.""" from local_deep_research.web.services.socket_service import ( SocketIOService, ) original_instance = SocketIOService._instance SocketIOService._instance = None mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio try: app = MockFlaskApp() service = SocketIOService(app=app) # The service should register handlers via @socketio.on decorators # We verify the service initializes without error assert service is not None finally: SocketIOService._instance = original_instance @patch("local_deep_research.web.services.socket_service.SocketIO") def test_disconnect_removes_subscriptions(self, mock_socketio_class): """Test that disconnect removes client from all research subscriptions. Schema: __socket_subscriptions is research_id → set of sids. Disconnect must discard the sid from every research_id's set. """ from local_deep_research.web.services.socket_service import ( SocketIOService, ) original_instance = SocketIOService._instance SocketIOService._instance = None mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio try: app = MockFlaskApp() service = SocketIOService(app=app) # Access the private subscriptions dict subscriptions = service._SocketIOService__socket_subscriptions # Set up correct schema: research_id → {sids} test_sid = "test_client_123" subscriptions["research_1"] = {test_sid, "other_client"} subscriptions["research_2"] = {test_sid} # Create a mock request mock_request = MagicMock() mock_request.sid = test_sid # Call the disconnect handler directly service._SocketIOService__handle_disconnect( mock_request, "test reason" ) # sid should be removed from research_1, leaving other_client assert test_sid not in subscriptions.get("research_1", set()) assert "other_client" in subscriptions["research_1"] # research_2 had only this sid, so the key should be cleaned up assert "research_2" not in subscriptions finally: SocketIOService._instance = original_instance @patch("local_deep_research.web.services.socket_service.SocketIO") def test_disconnect_leaves_other_clients_intact(self, mock_socketio_class): """Test that disconnect only removes the disconnecting client.""" from local_deep_research.web.services.socket_service import ( SocketIOService, ) original_instance = SocketIOService._instance SocketIOService._instance = None mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio try: app = MockFlaskApp() service = SocketIOService(app=app) subscriptions = service._SocketIOService__socket_subscriptions subscriptions["research_1"] = {"client_A", "client_B", "client_C"} mock_request = MagicMock() mock_request.sid = "client_B" service._SocketIOService__handle_disconnect( mock_request, "test reason" ) assert subscriptions["research_1"] == {"client_A", "client_C"} finally: SocketIOService._instance = original_instance @patch("local_deep_research.web.services.socket_service.SocketIO") def test_disconnect_noop_when_sid_not_subscribed(self, mock_socketio_class): """Test that disconnect is a no-op when sid has no subscriptions.""" from local_deep_research.web.services.socket_service import ( SocketIOService, ) original_instance = SocketIOService._instance SocketIOService._instance = None mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio try: app = MockFlaskApp() service = SocketIOService(app=app) subscriptions = service._SocketIOService__socket_subscriptions subscriptions["research_1"] = {"other_client_1", "other_client_2"} mock_request = MagicMock() mock_request.sid = "unknown_client" service._SocketIOService__handle_disconnect( mock_request, "test reason" ) # Nothing should change assert subscriptions["research_1"] == { "other_client_1", "other_client_2", } finally: SocketIOService._instance = original_instance @patch("local_deep_research.web.services.socket_service.SocketIO") def test_subscribe_then_disconnect_round_trip(self, mock_socketio_class): """Test full subscribe → disconnect cycle uses consistent schema.""" from local_deep_research.web.services.socket_service import ( SocketIOService, ) original_instance = SocketIOService._instance SocketIOService._instance = None mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio try: app = MockFlaskApp() service = SocketIOService(app=app) mock_request = MagicMock() mock_request.sid = "round_trip_client" # Subscribe to two research IDs service._SocketIOService__handle_subscribe( {"research_id": "r1"}, mock_request ) service._SocketIOService__handle_subscribe( {"research_id": "r2"}, mock_request ) subscriptions = service._SocketIOService__socket_subscriptions assert "round_trip_client" in subscriptions["r1"] assert "round_trip_client" in subscriptions["r2"] # Disconnect should clean up both service._SocketIOService__handle_disconnect( mock_request, "test reason" ) assert "round_trip_client" not in subscriptions.get("r1", set()) assert "round_trip_client" not in subscriptions.get("r2", set()) finally: SocketIOService._instance = original_instance @patch("local_deep_research.web.services.socket_service.SocketIO") def test_disconnect_calls_cleanup_current_thread(self, mock_socketio_class): """Test that disconnect handler calls cleanup_current_thread().""" import inspect from local_deep_research.web.services.socket_service import ( SocketIOService, ) original_instance = SocketIOService._instance SocketIOService._instance = None mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio try: app = MockFlaskApp() service = SocketIOService(app=app) # Verify by source code inspection that cleanup_current_thread # is called in the disconnect handler func_source = inspect.getsource( service._SocketIOService__handle_disconnect ) # Check that the cleanup function is imported and called assert "cleanup_current_thread" in func_source, ( "Disconnect handler should call cleanup_current_thread()" ) assert ( "from ...database.thread_local_session import" in func_source ), "Disconnect handler should import from thread_local_session" finally: SocketIOService._instance = original_instance @patch("local_deep_research.web.services.socket_service.SocketIO") def test_disconnect_handles_cleanup_import_error_gracefully( self, mock_socketio_class ): """Test that disconnect handles ImportError gracefully.""" from local_deep_research.web.services.socket_service import ( SocketIOService, ) original_instance = SocketIOService._instance SocketIOService._instance = None mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio try: app = MockFlaskApp() service = SocketIOService(app=app) mock_request = MagicMock() mock_request.sid = "test_client_import_error" # The handler should handle ImportError gracefully (pass) # This is already handled in the source code with: # except ImportError: pass # Call should not raise even if import fails # (the actual import might succeed or fail depending on environment) try: service._SocketIOService__handle_disconnect( mock_request, "import error test" ) except ImportError: # Should not propagate assert False, "ImportError should be caught internally" # Handler completed without propagating ImportError assert True finally: SocketIOService._instance = original_instance @patch("local_deep_research.web.services.socket_service.SocketIO") def test_disconnect_handles_cleanup_exception_gracefully( self, mock_socketio_class ): """Test that disconnect handles cleanup exceptions gracefully.""" from local_deep_research.web.services.socket_service import ( SocketIOService, ) original_instance = SocketIOService._instance SocketIOService._instance = None mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio try: app = MockFlaskApp() service = SocketIOService(app=app) mock_request = MagicMock() mock_request.sid = "test_client_cleanup_error" # The handler has a try/except block that catches Exception # and logs it rather than propagating # Call should not raise try: service._SocketIOService__handle_disconnect( mock_request, "cleanup error test" ) except Exception as e: # Only outer exceptions should propagate, not cleanup errors # The source has: except Exception: self.__log_exception(...) if "Error cleaning up thread session" in str(e): assert False, ( "Cleanup exception should be caught internally" ) # Handler completed assert True finally: SocketIOService._instance = original_instance @patch("local_deep_research.web.services.socket_service.SocketIO") def test_disconnect_logs_client_info(self, mock_socketio_class): """Test that disconnect logs client disconnect information.""" from local_deep_research.web.services.socket_service import ( SocketIOService, ) original_instance = SocketIOService._instance SocketIOService._instance = None mock_socketio = MagicMock() mock_socketio_class.return_value = mock_socketio try: app = MockFlaskApp() service = SocketIOService(app=app) mock_request = MagicMock() mock_request.sid = "logged_client_123" # Mock the logging method with patch.object( service, "_SocketIOService__log_info" ) as mock_log_info: service._SocketIOService__handle_disconnect( mock_request, "client initiated" ) # Should log disconnect info assert mock_log_info.called # Check that the client ID appears in at least one log call log_messages = [ str(call) for call in mock_log_info.call_args_list ] assert any( "logged_client_123" in msg for msg in log_messages ), "Client ID should be logged on disconnect" finally: SocketIOService._instance = original_instance