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
973 lines
33 KiB
Python
973 lines
33 KiB
Python
"""
|
|
Comprehensive tests for SocketIOService.
|
|
Tests singleton pattern, event emission, subscriptions, and error handling.
|
|
"""
|
|
|
|
import pytest
|
|
from unittest.mock import Mock, MagicMock, patch
|
|
|
|
|
|
class TestSocketIOServiceSingleton:
|
|
"""Tests for SocketIOService singleton pattern.
|
|
|
|
Note: Singleton reset is handled by the global reset_all_singletons fixture
|
|
in tests/conftest.py.
|
|
"""
|
|
|
|
def test_requires_app_on_first_creation(self):
|
|
"""Test that Flask app is required on first creation."""
|
|
from local_deep_research.web.services.socket_service import (
|
|
SocketIOService,
|
|
)
|
|
|
|
with pytest.raises(ValueError) as exc_info:
|
|
SocketIOService()
|
|
|
|
assert "Flask app must be specified" in str(exc_info.value)
|
|
|
|
def test_creates_instance_with_app(self, mock_flask_app):
|
|
"""Test that instance is created with Flask app."""
|
|
from local_deep_research.web.services.socket_service import (
|
|
SocketIOService,
|
|
)
|
|
|
|
with patch(
|
|
"local_deep_research.web.services.socket_service.SocketIO"
|
|
) as mock_socketio:
|
|
service = SocketIOService(app=mock_flask_app)
|
|
|
|
assert service is not None
|
|
mock_socketio.assert_called_once()
|
|
|
|
def test_returns_same_instance_on_second_call(self, mock_flask_app):
|
|
"""Test that same instance is returned on subsequent calls."""
|
|
from local_deep_research.web.services.socket_service import (
|
|
SocketIOService,
|
|
)
|
|
|
|
with patch("local_deep_research.web.services.socket_service.SocketIO"):
|
|
service1 = SocketIOService(app=mock_flask_app)
|
|
service2 = SocketIOService()
|
|
|
|
assert service1 is service2
|
|
|
|
def test_ignores_app_on_second_call(self, mock_flask_app):
|
|
"""Test that app parameter is ignored after singleton creation."""
|
|
from local_deep_research.web.services.socket_service import (
|
|
SocketIOService,
|
|
)
|
|
|
|
with patch("local_deep_research.web.services.socket_service.SocketIO"):
|
|
service1 = SocketIOService(app=mock_flask_app)
|
|
other_app = Mock()
|
|
service2 = SocketIOService(app=other_app)
|
|
|
|
assert service1 is service2
|
|
|
|
|
|
class TestSocketIOServiceInit:
|
|
"""Tests for SocketIOService initialization.
|
|
|
|
Note: Singleton reset is handled by the global reset_all_singletons fixture
|
|
in tests/conftest.py.
|
|
"""
|
|
|
|
def test_initializes_socketio_with_correct_params(self, mock_flask_app):
|
|
"""Test that SocketIO is initialized with correct parameters."""
|
|
from local_deep_research.web.services.socket_service import (
|
|
SocketIOService,
|
|
)
|
|
|
|
with patch(
|
|
"local_deep_research.web.services.socket_service.SocketIO"
|
|
) as mock_socketio:
|
|
SocketIOService(app=mock_flask_app)
|
|
|
|
mock_socketio.assert_called_once_with(
|
|
mock_flask_app,
|
|
cors_allowed_origins=None,
|
|
async_mode="threading",
|
|
path="/socket.io",
|
|
logger=False,
|
|
engineio_logger=False,
|
|
ping_timeout=20,
|
|
ping_interval=5,
|
|
)
|
|
|
|
def test_registers_connect_handler(self, mock_flask_app):
|
|
"""Test that connect handler is registered."""
|
|
from local_deep_research.web.services.socket_service import (
|
|
SocketIOService,
|
|
)
|
|
|
|
mock_socketio = MagicMock()
|
|
|
|
with patch(
|
|
"local_deep_research.web.services.socket_service.SocketIO",
|
|
return_value=mock_socketio,
|
|
):
|
|
SocketIOService(app=mock_flask_app)
|
|
|
|
# Check that on("connect") was called
|
|
mock_socketio.on.assert_any_call("connect")
|
|
|
|
def test_registers_disconnect_handler(self, mock_flask_app):
|
|
"""Test that disconnect handler is registered."""
|
|
from local_deep_research.web.services.socket_service import (
|
|
SocketIOService,
|
|
)
|
|
|
|
mock_socketio = MagicMock()
|
|
|
|
with patch(
|
|
"local_deep_research.web.services.socket_service.SocketIO",
|
|
return_value=mock_socketio,
|
|
):
|
|
SocketIOService(app=mock_flask_app)
|
|
|
|
mock_socketio.on.assert_any_call("disconnect")
|
|
|
|
def test_registers_subscribe_handler(self, mock_flask_app):
|
|
"""Test that subscribe_to_research handler is registered."""
|
|
from local_deep_research.web.services.socket_service import (
|
|
SocketIOService,
|
|
)
|
|
|
|
mock_socketio = MagicMock()
|
|
|
|
with patch(
|
|
"local_deep_research.web.services.socket_service.SocketIO",
|
|
return_value=mock_socketio,
|
|
):
|
|
SocketIOService(app=mock_flask_app)
|
|
|
|
mock_socketio.on.assert_any_call("subscribe_to_research")
|
|
|
|
def test_registers_join_alias_handler(self, mock_flask_app):
|
|
"""Test that the 'join' alias handler is registered.
|
|
|
|
The JS client emits 'join' on subscribe; without this alias, the
|
|
catch-up snapshot in __handle_subscribe never fires for fresh page
|
|
loads and per-client targeting falls through to broadcast.
|
|
"""
|
|
from local_deep_research.web.services.socket_service import (
|
|
SocketIOService,
|
|
)
|
|
|
|
mock_socketio = MagicMock()
|
|
|
|
with patch(
|
|
"local_deep_research.web.services.socket_service.SocketIO",
|
|
return_value=mock_socketio,
|
|
):
|
|
SocketIOService(app=mock_flask_app)
|
|
|
|
mock_socketio.on.assert_any_call("join")
|
|
|
|
def test_registers_leave_alias_handler(self, mock_flask_app):
|
|
"""Test that the 'leave' alias handler is registered."""
|
|
from local_deep_research.web.services.socket_service import (
|
|
SocketIOService,
|
|
)
|
|
|
|
mock_socketio = MagicMock()
|
|
|
|
with patch(
|
|
"local_deep_research.web.services.socket_service.SocketIO",
|
|
return_value=mock_socketio,
|
|
):
|
|
SocketIOService(app=mock_flask_app)
|
|
|
|
mock_socketio.on.assert_any_call("leave")
|
|
|
|
def test_registers_unsubscribe_handler(self, mock_flask_app):
|
|
"""Test that unsubscribe_from_research handler is registered."""
|
|
from local_deep_research.web.services.socket_service import (
|
|
SocketIOService,
|
|
)
|
|
|
|
mock_socketio = MagicMock()
|
|
|
|
with patch(
|
|
"local_deep_research.web.services.socket_service.SocketIO",
|
|
return_value=mock_socketio,
|
|
):
|
|
SocketIOService(app=mock_flask_app)
|
|
|
|
mock_socketio.on.assert_any_call("unsubscribe_from_research")
|
|
|
|
|
|
class TestSocketIOServiceEmitSocketEvent:
|
|
"""Tests for emit_socket_event method.
|
|
|
|
Note: Singleton reset is handled by the global reset_all_singletons fixture
|
|
in tests/conftest.py.
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def service(self, mock_flask_app):
|
|
"""Create SocketIOService instance with mocked SocketIO."""
|
|
from local_deep_research.web.services.socket_service import (
|
|
SocketIOService,
|
|
)
|
|
|
|
mock_socketio = MagicMock()
|
|
|
|
with patch(
|
|
"local_deep_research.web.services.socket_service.SocketIO",
|
|
return_value=mock_socketio,
|
|
):
|
|
service = SocketIOService(app=mock_flask_app)
|
|
service._SocketIOService__socketio = mock_socketio
|
|
return service, mock_socketio
|
|
|
|
def test_emits_to_all_when_no_room(self, service):
|
|
"""Test emitting to all clients when no room specified."""
|
|
svc, mock_socketio = service
|
|
|
|
result = svc.emit_socket_event("test_event", {"key": "value"})
|
|
|
|
assert result is True
|
|
mock_socketio.emit.assert_called_once_with(
|
|
"test_event", {"key": "value"}
|
|
)
|
|
|
|
def test_emits_to_specific_room(self, service):
|
|
"""Test emitting to specific room."""
|
|
svc, mock_socketio = service
|
|
|
|
result = svc.emit_socket_event(
|
|
"test_event", {"key": "value"}, room="room-123"
|
|
)
|
|
|
|
assert result is True
|
|
mock_socketio.emit.assert_called_once_with(
|
|
"test_event", {"key": "value"}, room="room-123"
|
|
)
|
|
|
|
def test_returns_false_on_exception(self, service):
|
|
"""Test that False is returned on emission error."""
|
|
svc, mock_socketio = service
|
|
mock_socketio.emit.side_effect = Exception("Emission failed")
|
|
|
|
result = svc.emit_socket_event("test_event", {"key": "value"})
|
|
|
|
assert result is False
|
|
|
|
def test_logs_exception_on_error(self, service):
|
|
"""Test that exception is logged on error."""
|
|
svc, mock_socketio = service
|
|
mock_socketio.emit.side_effect = Exception("Emission failed")
|
|
|
|
with patch(
|
|
"local_deep_research.web.services.socket_service.logger"
|
|
) as mock_logger:
|
|
svc.emit_socket_event("test_event", {"key": "value"})
|
|
|
|
mock_logger.exception.assert_called_once()
|
|
|
|
|
|
class TestSocketIOServiceEmitToSubscribers:
|
|
"""Tests for emit_to_subscribers method.
|
|
|
|
Note: Singleton reset is handled by the global reset_all_singletons fixture
|
|
in tests/conftest.py.
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def service(self, mock_flask_app):
|
|
"""Create SocketIOService instance with mocked SocketIO."""
|
|
from local_deep_research.web.services.socket_service import (
|
|
SocketIOService,
|
|
)
|
|
|
|
mock_socketio = MagicMock()
|
|
|
|
with patch(
|
|
"local_deep_research.web.services.socket_service.SocketIO",
|
|
return_value=mock_socketio,
|
|
):
|
|
service = SocketIOService(app=mock_flask_app)
|
|
service._SocketIOService__socketio = mock_socketio
|
|
return service, mock_socketio
|
|
|
|
def test_emits_formatted_event(self, service, sample_research_id):
|
|
"""Test that event is emitted with formatted name to subscribers."""
|
|
svc, mock_socketio = service
|
|
# Targeted delivery is the only path emit_to_subscribers takes;
|
|
# without a registered subscriber the event is dropped (the
|
|
# no-subscribers test pins that contract separately).
|
|
svc._SocketIOService__socket_subscriptions = {
|
|
sample_research_id: {"sid-1"}
|
|
}
|
|
|
|
svc.emit_to_subscribers(
|
|
"research_update", sample_research_id, {"data": "test"}
|
|
)
|
|
|
|
# Should emit with formatted event name, scoped to the room.
|
|
mock_socketio.emit.assert_any_call(
|
|
f"research_update_{sample_research_id}",
|
|
{"data": "test"},
|
|
room="sid-1",
|
|
)
|
|
|
|
def test_emits_to_individual_subscribers(self, service, sample_research_id):
|
|
"""Test that event is emitted to each subscriber."""
|
|
svc, mock_socketio = service
|
|
|
|
# Add subscribers
|
|
svc._SocketIOService__socket_subscriptions = {
|
|
sample_research_id: {"sid-1", "sid-2"}
|
|
}
|
|
|
|
svc.emit_to_subscribers(
|
|
"research_update", sample_research_id, {"data": "test"}
|
|
)
|
|
|
|
# Should emit to each subscriber
|
|
calls = mock_socketio.emit.call_args_list
|
|
room_calls = [c for c in calls if "room" in c.kwargs]
|
|
assert len(room_calls) == 2
|
|
|
|
def test_returns_true_on_success(self, service, sample_research_id):
|
|
"""Test that True is returned on success."""
|
|
svc, mock_socketio = service
|
|
|
|
result = svc.emit_to_subscribers(
|
|
"research_update", sample_research_id, {"data": "test"}
|
|
)
|
|
|
|
assert result is True
|
|
|
|
def test_returns_false_on_exception(self, service, sample_research_id):
|
|
"""Test that False is returned on error.
|
|
|
|
Per-subscriber emit failures are now caught inside the inner
|
|
loop (so other subscribers still receive the event), so the
|
|
method returns False only when something outside the loop
|
|
raises — simulated here by raising during ``__lock`` acquisition.
|
|
"""
|
|
svc, mock_socketio = service
|
|
# Make the lock context manager raise to force the outer
|
|
# except path. The lock is a private attribute on the service.
|
|
broken_lock = MagicMock()
|
|
broken_lock.__enter__ = MagicMock(side_effect=Exception("Failed"))
|
|
broken_lock.__exit__ = MagicMock(return_value=False)
|
|
svc._SocketIOService__lock = broken_lock
|
|
|
|
result = svc.emit_to_subscribers(
|
|
"research_update", sample_research_id, {"data": "test"}
|
|
)
|
|
|
|
assert result is False
|
|
|
|
def test_disables_logging_when_requested(self, service, sample_research_id):
|
|
"""Test that logging can be disabled."""
|
|
svc, mock_socketio = service
|
|
|
|
svc.emit_to_subscribers(
|
|
"research_update",
|
|
sample_research_id,
|
|
{"data": "test"},
|
|
enable_logging=False,
|
|
)
|
|
|
|
# After call, logging should be re-enabled
|
|
assert svc._SocketIOService__logging_enabled is True
|
|
|
|
def test_re_enables_logging_after_exception(
|
|
self, service, sample_research_id
|
|
):
|
|
"""Test that logging is re-enabled even after exception."""
|
|
svc, mock_socketio = service
|
|
mock_socketio.emit.side_effect = Exception("Failed")
|
|
|
|
svc.emit_to_subscribers(
|
|
"research_update",
|
|
sample_research_id,
|
|
{"data": "test"},
|
|
enable_logging=False,
|
|
)
|
|
|
|
# Logging should be re-enabled after exception
|
|
assert svc._SocketIOService__logging_enabled is True
|
|
|
|
def test_handles_no_subscribers(self, service, sample_research_id):
|
|
"""No-subscribers: drop the event rather than broadcasting.
|
|
|
|
A prior implementation broadcast room-less to all clients in
|
|
this case, which leaked research payloads across users. Early
|
|
events are recovered by the catch-up snapshot in
|
|
``__handle_subscribe``; no cross-user broadcast is needed.
|
|
"""
|
|
svc, mock_socketio = service
|
|
|
|
result = svc.emit_to_subscribers(
|
|
"research_update", sample_research_id, {"data": "test"}
|
|
)
|
|
|
|
assert result is True
|
|
# MUST NOT emit when there are no targeted subscribers — the
|
|
# absence of a room= argument would broadcast to every client.
|
|
mock_socketio.emit.assert_not_called()
|
|
|
|
def test_handles_subscriber_emit_error(self, service, sample_research_id):
|
|
"""Test that individual subscriber errors don't stop other emissions."""
|
|
svc, mock_socketio = service
|
|
|
|
# Add subscribers
|
|
svc._SocketIOService__socket_subscriptions = {
|
|
sample_research_id: {"sid-1", "sid-2"}
|
|
}
|
|
|
|
# First call succeeds, second fails
|
|
call_count = [0]
|
|
|
|
def emit_side_effect(*args, **kwargs):
|
|
call_count[0] += 1
|
|
if call_count[0] == 2: # Fail on second subscriber
|
|
raise Exception("Subscriber error")
|
|
|
|
mock_socketio.emit.side_effect = emit_side_effect
|
|
|
|
result = svc.emit_to_subscribers(
|
|
"research_update", sample_research_id, {"data": "test"}
|
|
)
|
|
|
|
# Should still return True (overall success)
|
|
assert result is True
|
|
|
|
|
|
class TestSocketIOServiceSubscriptionManagement:
|
|
"""Tests for subscription management.
|
|
|
|
Note: Singleton reset is handled by the global reset_all_singletons fixture
|
|
in tests/conftest.py.
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def service_with_mocks(self, mock_flask_app):
|
|
"""Create service with accessible internal state."""
|
|
from local_deep_research.web.services.socket_service import (
|
|
SocketIOService,
|
|
)
|
|
|
|
mock_socketio = MagicMock()
|
|
|
|
with patch(
|
|
"local_deep_research.web.services.socket_service.SocketIO",
|
|
return_value=mock_socketio,
|
|
):
|
|
service = SocketIOService(app=mock_flask_app)
|
|
service._SocketIOService__socketio = mock_socketio
|
|
return service, mock_socketio
|
|
|
|
def test_subscribe_adds_client(
|
|
self, service_with_mocks, mock_request, sample_research_id
|
|
):
|
|
"""Test that subscribing adds client to subscription set."""
|
|
svc, _ = service_with_mocks
|
|
|
|
data = {"research_id": sample_research_id}
|
|
|
|
svc._SocketIOService__handle_subscribe(data, mock_request)
|
|
|
|
subscriptions = svc._SocketIOService__socket_subscriptions
|
|
assert sample_research_id in subscriptions
|
|
assert mock_request.sid in subscriptions[sample_research_id]
|
|
|
|
def test_subscribe_creates_set_for_new_research(
|
|
self, service_with_mocks, mock_request
|
|
):
|
|
"""Test that subscribing creates new set for new research."""
|
|
svc, _ = service_with_mocks
|
|
|
|
data = {"research_id": "new-research-id"}
|
|
|
|
svc._SocketIOService__handle_subscribe(data, mock_request)
|
|
|
|
subscriptions = svc._SocketIOService__socket_subscriptions
|
|
assert "new-research-id" in subscriptions
|
|
assert isinstance(subscriptions["new-research-id"], set)
|
|
|
|
def test_subscribe_ignores_empty_research_id(
|
|
self, service_with_mocks, mock_request
|
|
):
|
|
"""Test that empty research_id is ignored."""
|
|
svc, _ = service_with_mocks
|
|
|
|
data = {"research_id": ""}
|
|
|
|
svc._SocketIOService__handle_subscribe(data, mock_request)
|
|
|
|
subscriptions = svc._SocketIOService__socket_subscriptions
|
|
assert "" not in subscriptions
|
|
|
|
def test_subscribe_ignores_missing_research_id(
|
|
self, service_with_mocks, mock_request
|
|
):
|
|
"""Test that missing research_id is ignored."""
|
|
svc, _ = service_with_mocks
|
|
|
|
data = {}
|
|
|
|
svc._SocketIOService__handle_subscribe(data, mock_request)
|
|
|
|
subscriptions = svc._SocketIOService__socket_subscriptions
|
|
assert len(subscriptions) == 0
|
|
|
|
def test_disconnect_removes_subscription(
|
|
self, service_with_mocks, mock_request
|
|
):
|
|
"""Test that disconnecting 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.
|
|
"""
|
|
svc, _ = service_with_mocks
|
|
|
|
# Set up correct schema: research_id → {sids}
|
|
svc._SocketIOService__socket_subscriptions = {
|
|
"research-1": {mock_request.sid, "other-sid"},
|
|
"research-2": {mock_request.sid},
|
|
}
|
|
|
|
svc._SocketIOService__handle_disconnect(
|
|
mock_request, "client disconnect"
|
|
)
|
|
|
|
subscriptions = svc._SocketIOService__socket_subscriptions
|
|
# sid should be removed from research-1, leaving only other-sid
|
|
assert mock_request.sid not in subscriptions.get("research-1", set())
|
|
assert "other-sid" in subscriptions["research-1"]
|
|
# research-2 had only this sid, so the key should be cleaned up
|
|
assert "research-2" not in subscriptions
|
|
|
|
def test_disconnect_handles_no_subscription(
|
|
self, service_with_mocks, mock_request
|
|
):
|
|
"""Test that disconnect handles non-subscribed client."""
|
|
svc, _ = service_with_mocks
|
|
|
|
# No subscriptions exist
|
|
svc._SocketIOService__socket_subscriptions = {}
|
|
|
|
# Should not raise
|
|
svc._SocketIOService__handle_disconnect(
|
|
mock_request, "client disconnect"
|
|
)
|
|
|
|
def test_disconnect_leaves_other_clients_intact(
|
|
self, service_with_mocks, mock_request
|
|
):
|
|
"""Test that disconnect only removes the disconnecting client."""
|
|
svc, _ = service_with_mocks
|
|
|
|
svc._SocketIOService__socket_subscriptions = {
|
|
"research-1": {mock_request.sid, "sid-A", "sid-B"},
|
|
}
|
|
|
|
svc._SocketIOService__handle_disconnect(
|
|
mock_request, "client disconnect"
|
|
)
|
|
|
|
subscriptions = svc._SocketIOService__socket_subscriptions
|
|
assert subscriptions["research-1"] == {"sid-A", "sid-B"}
|
|
|
|
def test_disconnect_noop_when_sid_not_subscribed(
|
|
self, service_with_mocks, mock_request
|
|
):
|
|
"""Test that disconnect is a no-op when sid has no subscriptions."""
|
|
svc, _ = service_with_mocks
|
|
|
|
svc._SocketIOService__socket_subscriptions = {
|
|
"research-1": {"other-sid-1", "other-sid-2"},
|
|
}
|
|
|
|
svc._SocketIOService__handle_disconnect(
|
|
mock_request, "client disconnect"
|
|
)
|
|
|
|
# Nothing should change
|
|
subscriptions = svc._SocketIOService__socket_subscriptions
|
|
assert subscriptions["research-1"] == {"other-sid-1", "other-sid-2"}
|
|
|
|
def test_subscribe_then_disconnect_round_trip(
|
|
self, service_with_mocks, mock_request
|
|
):
|
|
"""Test full subscribe → disconnect cycle uses consistent schema."""
|
|
svc, _ = service_with_mocks
|
|
|
|
# Subscribe to two research IDs
|
|
svc._SocketIOService__handle_subscribe(
|
|
{"research_id": "r1"}, mock_request
|
|
)
|
|
svc._SocketIOService__handle_subscribe(
|
|
{"research_id": "r2"}, mock_request
|
|
)
|
|
|
|
subscriptions = svc._SocketIOService__socket_subscriptions
|
|
assert mock_request.sid in subscriptions["r1"]
|
|
assert mock_request.sid in subscriptions["r2"]
|
|
|
|
# Disconnect should clean up both
|
|
svc._SocketIOService__handle_disconnect(
|
|
mock_request, "client disconnect"
|
|
)
|
|
|
|
assert mock_request.sid not in subscriptions.get("r1", set())
|
|
assert mock_request.sid not in subscriptions.get("r2", set())
|
|
|
|
def test_unsubscribe_discards_sid(
|
|
self, service_with_mocks, mock_request, sample_research_id
|
|
):
|
|
"""Unsubscribe handler removes the sid from the subscription set."""
|
|
svc, _ = service_with_mocks
|
|
svc._SocketIOService__socket_subscriptions = {
|
|
sample_research_id: {mock_request.sid, "other-sid"},
|
|
}
|
|
|
|
svc._SocketIOService__handle_unsubscribe(
|
|
{"research_id": sample_research_id}, mock_request
|
|
)
|
|
|
|
subscriptions = svc._SocketIOService__socket_subscriptions
|
|
assert mock_request.sid not in subscriptions[sample_research_id]
|
|
# Other clients are untouched
|
|
assert "other-sid" in subscriptions[sample_research_id]
|
|
|
|
def test_unsubscribe_ignores_missing_research_id(
|
|
self, service_with_mocks, mock_request
|
|
):
|
|
"""Unsubscribe with missing research_id is a no-op."""
|
|
svc, _ = service_with_mocks
|
|
svc._SocketIOService__socket_subscriptions = {
|
|
"research-1": {"sid-A"},
|
|
}
|
|
|
|
# Should not raise
|
|
svc._SocketIOService__handle_unsubscribe({}, mock_request)
|
|
svc._SocketIOService__handle_unsubscribe(None, mock_request)
|
|
|
|
# Existing subscriptions are unchanged
|
|
subscriptions = svc._SocketIOService__socket_subscriptions
|
|
assert subscriptions["research-1"] == {"sid-A"}
|
|
|
|
def test_unsubscribe_handles_unknown_research_id(
|
|
self, service_with_mocks, mock_request
|
|
):
|
|
"""Unsubscribe for a research_id with no subscribers is a no-op."""
|
|
svc, _ = service_with_mocks
|
|
svc._SocketIOService__socket_subscriptions = {}
|
|
|
|
# Should not raise
|
|
svc._SocketIOService__handle_unsubscribe(
|
|
{"research_id": "never-subscribed"}, mock_request
|
|
)
|
|
|
|
def test_unsubscribe_prunes_empty_subscription_set(
|
|
self, service_with_mocks, mock_request, sample_research_id
|
|
):
|
|
"""Removing the last sid for a research_id deletes the dict entry.
|
|
|
|
Otherwise __socket_subscriptions accumulates stale keys for every
|
|
research_id seen over a long-running server, even after all clients
|
|
have left.
|
|
"""
|
|
svc, _ = service_with_mocks
|
|
svc._SocketIOService__socket_subscriptions = {
|
|
sample_research_id: {mock_request.sid},
|
|
}
|
|
|
|
svc._SocketIOService__handle_unsubscribe(
|
|
{"research_id": sample_research_id}, mock_request
|
|
)
|
|
|
|
assert (
|
|
sample_research_id not in svc._SocketIOService__socket_subscriptions
|
|
)
|
|
|
|
def test_unsubscribe_keeps_set_when_other_clients_remain(
|
|
self, service_with_mocks, mock_request, sample_research_id
|
|
):
|
|
"""Unsubscribe must not delete the set while other sids are present."""
|
|
svc, _ = service_with_mocks
|
|
svc._SocketIOService__socket_subscriptions = {
|
|
sample_research_id: {mock_request.sid, "other-sid"},
|
|
}
|
|
|
|
svc._SocketIOService__handle_unsubscribe(
|
|
{"research_id": sample_research_id}, mock_request
|
|
)
|
|
|
|
assert svc._SocketIOService__socket_subscriptions[
|
|
sample_research_id
|
|
] == {"other-sid"}
|
|
|
|
|
|
class TestSocketIOServiceErrorHandling:
|
|
"""Tests for error handling methods.
|
|
|
|
Note: Singleton reset is handled by the global reset_all_singletons fixture
|
|
in tests/conftest.py.
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def service(self, mock_flask_app):
|
|
"""Create SocketIOService instance."""
|
|
from local_deep_research.web.services.socket_service import (
|
|
SocketIOService,
|
|
)
|
|
|
|
mock_socketio = MagicMock()
|
|
|
|
with patch(
|
|
"local_deep_research.web.services.socket_service.SocketIO",
|
|
return_value=mock_socketio,
|
|
):
|
|
service = SocketIOService(app=mock_flask_app)
|
|
return service
|
|
|
|
def test_socket_error_returns_false(self, service):
|
|
"""Test that socket error handler returns False."""
|
|
result = service._SocketIOService__handle_socket_error(
|
|
Exception("Test error")
|
|
)
|
|
|
|
assert result is False
|
|
|
|
def test_default_error_returns_false(self, service):
|
|
"""Test that default error handler returns False."""
|
|
result = service._SocketIOService__handle_default_error(
|
|
Exception("Test error")
|
|
)
|
|
|
|
assert result is False
|
|
|
|
def test_connect_handler_rejects_unauthenticated(
|
|
self, service, mock_request
|
|
):
|
|
"""Connect handler rejects when no session username."""
|
|
with patch(
|
|
"local_deep_research.web.services.socket_service.session", {}
|
|
):
|
|
assert (
|
|
service._SocketIOService__handle_connect(mock_request) is False
|
|
)
|
|
|
|
def test_connect_handler_rejects_no_db_session_and_no_password(
|
|
self, service, mock_request
|
|
):
|
|
"""Connect handler rejects when user has no active DB session and no stored password."""
|
|
with (
|
|
patch(
|
|
"local_deep_research.web.services.socket_service.session",
|
|
{"username": "alice", "session_id": "sess-1"},
|
|
),
|
|
patch(
|
|
"local_deep_research.web.services.socket_service.db_manager"
|
|
) as mock_db,
|
|
patch(
|
|
"local_deep_research.web.services.socket_service.session_password_store"
|
|
) as mock_store,
|
|
):
|
|
mock_db.is_user_connected.return_value = False
|
|
mock_store.get_session_password.return_value = None
|
|
assert (
|
|
service._SocketIOService__handle_connect(mock_request) is False
|
|
)
|
|
mock_db.open_user_database.assert_not_called()
|
|
|
|
def test_connect_handler_lazy_opens_when_password_available(
|
|
self, service, mock_request
|
|
):
|
|
"""Connect handler lazy-opens the DB when the engine isn't open but a password is stored."""
|
|
with (
|
|
patch(
|
|
"local_deep_research.web.services.socket_service.session",
|
|
{"username": "alice", "session_id": "sess-1"},
|
|
),
|
|
patch(
|
|
"local_deep_research.web.services.socket_service.db_manager"
|
|
) as mock_db,
|
|
patch(
|
|
"local_deep_research.web.services.socket_service.session_password_store"
|
|
) as mock_store,
|
|
patch("local_deep_research.web.services.socket_service.join_room"),
|
|
):
|
|
mock_db.is_user_connected.return_value = False
|
|
mock_store.get_session_password.return_value = "pw"
|
|
assert (
|
|
service._SocketIOService__handle_connect(mock_request) is True
|
|
)
|
|
mock_db.open_user_database.assert_called_once_with("alice", "pw")
|
|
|
|
def test_connect_handler_accepts_authenticated(self, service, mock_request):
|
|
"""Connect handler accepts authenticated users with active DB session."""
|
|
with (
|
|
patch(
|
|
"local_deep_research.web.services.socket_service.session",
|
|
{"username": "alice"},
|
|
),
|
|
patch(
|
|
"local_deep_research.web.services.socket_service.db_manager"
|
|
) as mock_db,
|
|
patch(
|
|
"local_deep_research.web.services.socket_service.logger"
|
|
) as mock_logger,
|
|
patch("local_deep_research.web.services.socket_service.join_room"),
|
|
):
|
|
mock_db.is_user_connected.return_value = True
|
|
assert (
|
|
service._SocketIOService__handle_connect(mock_request) is True
|
|
)
|
|
mock_logger.info.assert_called()
|
|
call_args = mock_logger.info.call_args[0][0]
|
|
assert mock_request.sid in call_args
|
|
|
|
|
|
class TestSocketIOServiceRun:
|
|
"""Tests for run method.
|
|
|
|
Note: Singleton reset is handled by the global reset_all_singletons fixture
|
|
in tests/conftest.py.
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def service(self, mock_flask_app):
|
|
"""Create SocketIOService instance."""
|
|
from local_deep_research.web.services.socket_service import (
|
|
SocketIOService,
|
|
)
|
|
|
|
mock_socketio = MagicMock()
|
|
|
|
with patch(
|
|
"local_deep_research.web.services.socket_service.SocketIO",
|
|
return_value=mock_socketio,
|
|
):
|
|
service = SocketIOService(app=mock_flask_app)
|
|
service._SocketIOService__socketio = mock_socketio
|
|
return service, mock_socketio, mock_flask_app
|
|
|
|
def test_run_calls_socketio_run(self, service):
|
|
"""Test that run method calls socketio.run."""
|
|
svc, mock_socketio, mock_app = service
|
|
|
|
svc.run("0.0.0.0", 5000)
|
|
|
|
mock_socketio.run.assert_called_once()
|
|
|
|
def test_run_passes_correct_params(self, service):
|
|
"""Test that run passes correct parameters."""
|
|
svc, mock_socketio, mock_app = service
|
|
|
|
svc.run("localhost", 8080, debug=True)
|
|
|
|
mock_socketio.run.assert_called_once_with(
|
|
mock_app,
|
|
debug=True,
|
|
host="localhost",
|
|
port=8080,
|
|
allow_unsafe_werkzeug=True,
|
|
use_reloader=False,
|
|
)
|
|
|
|
def test_run_defaults_debug_to_false(self, service):
|
|
"""Test that debug defaults to False."""
|
|
svc, mock_socketio, mock_app = service
|
|
|
|
svc.run("0.0.0.0", 5000)
|
|
|
|
call_kwargs = mock_socketio.run.call_args[1]
|
|
assert call_kwargs["debug"] is False
|
|
|
|
|
|
class TestSocketIOServiceLogging:
|
|
"""Tests for logging behavior.
|
|
|
|
Note: Singleton reset is handled by the global reset_all_singletons fixture
|
|
in tests/conftest.py.
|
|
"""
|
|
|
|
@pytest.fixture
|
|
def service(self, mock_flask_app):
|
|
"""Create SocketIOService instance."""
|
|
from local_deep_research.web.services.socket_service import (
|
|
SocketIOService,
|
|
)
|
|
|
|
mock_socketio = MagicMock()
|
|
|
|
with patch(
|
|
"local_deep_research.web.services.socket_service.SocketIO",
|
|
return_value=mock_socketio,
|
|
):
|
|
service = SocketIOService(app=mock_flask_app)
|
|
return service
|
|
|
|
def test_logging_enabled_by_default(self, service):
|
|
"""Test that logging is enabled by default."""
|
|
assert service._SocketIOService__logging_enabled is True
|
|
|
|
def test_log_info_logs_when_enabled(self, service):
|
|
"""Test that __log_info logs when enabled."""
|
|
with patch(
|
|
"local_deep_research.web.services.socket_service.logger"
|
|
) as mock_logger:
|
|
service._SocketIOService__log_info("Test message")
|
|
|
|
mock_logger.info.assert_called_once_with("Test message")
|
|
|
|
def test_log_info_skips_when_disabled(self, service):
|
|
"""Test that __log_info skips when disabled."""
|
|
service._SocketIOService__logging_enabled = False
|
|
|
|
with patch(
|
|
"local_deep_research.web.services.socket_service.logger"
|
|
) as mock_logger:
|
|
service._SocketIOService__log_info("Test message")
|
|
|
|
mock_logger.info.assert_not_called()
|
|
|
|
def test_log_error_logs_when_enabled(self, service):
|
|
"""Test that __log_error logs when enabled."""
|
|
with patch(
|
|
"local_deep_research.web.services.socket_service.logger"
|
|
) as mock_logger:
|
|
service._SocketIOService__log_error("Error message")
|
|
|
|
mock_logger.error.assert_called_once_with("Error message")
|
|
|
|
def test_log_error_skips_when_disabled(self, service):
|
|
"""Test that __log_error skips when disabled."""
|
|
service._SocketIOService__logging_enabled = False
|
|
|
|
with patch(
|
|
"local_deep_research.web.services.socket_service.logger"
|
|
) as mock_logger:
|
|
service._SocketIOService__log_error("Error message")
|
|
|
|
mock_logger.error.assert_not_called()
|
|
|
|
def test_log_exception_logs_when_enabled(self, service):
|
|
"""Test that __log_exception logs when enabled."""
|
|
with patch(
|
|
"local_deep_research.web.services.socket_service.logger"
|
|
) as mock_logger:
|
|
service._SocketIOService__log_exception("Exception message")
|
|
|
|
mock_logger.exception.assert_called_once_with("Exception message")
|
|
|
|
def test_log_exception_skips_when_disabled(self, service):
|
|
"""Test that __log_exception skips when disabled."""
|
|
service._SocketIOService__logging_enabled = False
|
|
|
|
with patch(
|
|
"local_deep_research.web.services.socket_service.logger"
|
|
) as mock_logger:
|
|
service._SocketIOService__log_exception("Exception message")
|
|
|
|
mock_logger.exception.assert_not_called()
|