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
378 lines
14 KiB
Python
378 lines
14 KiB
Python
"""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
|