7a0da7932b
OSV-Scanner (Scheduled) / scan-scheduled (push) Failing after 0s
Create Release / test-gate (push) Has been cancelled
Create Release / release-gate (push) Has been cancelled
Create Release / ci-gate (push) Has been cancelled
Create Release / version-check (push) Has been cancelled
Create Release / e2e-test-gate (push) Has been cancelled
Create Release / responsive-test-gate (push) Has been cancelled
Create Release / compat-test-gate (push) Has been cancelled
Create Release / compose-integration-gate (push) Has been cancelled
Create Release / vulture-gate (push) Has been cancelled
Create Release / build (push) Has been cancelled
Create Release / provenance (push) Has been cancelled
Create Release / prerelease-docker (push) Has been cancelled
Create Release / publish-docker (push) Has been cancelled
Create Release / create-release (push) Has been cancelled
Create Release / cleanup-changelog (push) Has been cancelled
Create Release / trigger-pypi (push) Has been cancelled
Create Release / monitor-pypi (push) Has been cancelled
Create Release / Clean up orphan prerelease tags and signatures (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-form] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-metrics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [research-workflow] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-core] (push) Has been cancelled
CodeQL Advanced / Analyze (javascript-typescript) (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [history-news] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [library] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [link-analytics] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-core] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [chat-lifecycle] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [error-benchmark] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [settings-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) (push) Has been cancelled
Docker Tests (Consolidated) / Accessibility Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Unit Tests (push) Has been cancelled
Docker Tests (Consolidated) / LLM Example Tests (push) Has been cancelled
Docker Tests (Consolidated) / Production Image Smoke Test (push) Has been cancelled
Docker Tests (Consolidated) / Infrastructure Tests (push) Has been cancelled
OSSF Scorecard / OSSF Security Scorecard Analysis (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [mobile] (push) Has been cancelled
Backwards Compatibility / Verify Encryption Constants (push) Has been cancelled
Backwards Compatibility / PyPI Version Compatibility (push) Has been cancelled
Backwards Compatibility / Database Migration Tests (push) Has been cancelled
CodeQL Advanced / Analyze (python) (push) Has been cancelled
Docker Tests (Consolidated) / detect-changes (push) Has been cancelled
Docker Tests (Consolidated) / Build Test Image (push) Has been cancelled
Docker Tests (Consolidated) / All Pytest Tests + Coverage (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [accessibility] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [api-crud] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-login] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-pages] (push) Has been cancelled
Docker Tests (Consolidated) / UI Tests (Puppeteer) [auth-register] (push) Has been cancelled
931 lines
31 KiB
Python
931 lines
31 KiB
Python
"""
|
|
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
|