Files
wehub-resource-sync 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
chore: import upstream snapshot with attribution
2026-07-13 13:08:55 +08:00

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