Files
shelfmark/tests/core/test_socket_handlers.py
T
splitsec2 9ca1981d00 refactor: move the Socket.IO handlers into core/socket_handlers.py (#1638)
This one is small. The three Socket.IO handlers, connect, disconnect and
request_status, move from `main.py` to `core/socket_handlers.py`,
registered with `register_socket_handlers(socketio, ...)`. It is the
Socket.IO item in the plan from Discussion #1536.

They had no tests, so the first commit adds 9 that go through
Flask-SocketIO's test client: what a client gets on connect with no
auth, as an admin, as a user and as a visitor with no session, a failure
reading the status, the manual status request, and the room bookkeeping
on disconnect. They pass on current `main`. The second commit is the
move.

`resolve_status_scope` stays in `main.py` since the HTTP routes use it
too, so it is passed in. One test line changed, the patch of
`redact_status`, which now points at the new module. Log lines from the
handlers now name `shelfmark.core.socket_handlers`.

The URL map and the registered socket events are identical before and
after, and the full suite, ruff and basedpyright are clean. I also
started the app from the branch and the Socket.IO handshake answers.


<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

<!-- coderabbit-ai-generated:v1 -->

## Summary by CodeRabbit

* **Bug Fixes**
* Status requests now return a consistent empty update when access is
denied and an error message when status retrieval fails.
* Status visibility follows the requester’s access level, with non-admin
results redacted.
* **Refactor**
* Connection, disconnection, and status-request handling now follows
consistent room and connection-tracking behavior.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-11-01 18:13:11 -07:00

182 lines
8.3 KiB
Python

"""Characterization tests for the Socket.IO handlers, written before they moved out of main.py.
They pin what a client sees on connect, on a manual status request and on disconnect, and how
the room bookkeeping is called, for no auth, an admin, a plain user and an anonymous visitor.
"""
from __future__ import annotations
import importlib
from unittest.mock import patch
import pytest
@pytest.fixture(scope="module")
def main_module():
"""Import `shelfmark.main` with background startup disabled."""
with patch("shelfmark.download.orchestrator.start"):
import shelfmark.main as main
importlib.reload(main)
return main
@pytest.fixture
def client(main_module):
return main_module.app.test_client()
def _login(client, *, user_id="alice", db_user_id: int | None = 7, is_admin=False) -> None:
with client.session_transaction() as sess:
sess["user_id"] = user_id
sess["is_admin"] = is_admin
if db_user_id is not None:
sess["db_user_id"] = db_user_id
def _socket(main_module, client):
return main_module.socketio.test_client(main_module.app, flask_test_client=client)
def _events(socket) -> list[tuple[str, dict]]:
return [(item["name"], item["args"][0]) for item in socket.get_received()]
_STATUS = {"queued": {"task-1": {"title": "A Book", "source": "direct_download"}}}
class TestConnect:
def test_no_auth_joins_as_admin_and_gets_the_whole_status(self, main_module, client):
with patch.object(main_module, "get_auth_mode", return_value="none"):
with patch.object(main_module.backend, "queue_status", return_value=_STATUS) as status:
with patch.object(main_module.ws_manager, "join_user_room") as join:
with patch.object(main_module.ws_manager, "client_connected") as connected:
socket = _socket(main_module, client)
events = _events(socket)
socket.disconnect()
assert events == [("status_update", _STATUS)]
status.assert_called_once_with(user_id=None)
connected.assert_called_once_with()
assert join.call_args.kwargs == {"is_admin": True, "db_user_id": None}
def test_an_admin_gets_the_whole_status(self, main_module, client):
_login(client, user_id="root", db_user_id=None, is_admin=True)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend, "queue_status", return_value=_STATUS):
with patch.object(main_module.ws_manager, "join_user_room") as join:
socket = _socket(main_module, client)
events = _events(socket)
socket.disconnect()
assert events == [("status_update", _STATUS)]
assert join.call_args.kwargs == {"is_admin": True, "db_user_id": None}
def test_a_user_gets_their_own_status_with_release_details_hidden(self, main_module, client):
_login(client, user_id="alice", db_user_id=7)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend, "queue_status", return_value=_STATUS) as status:
with patch(
"shelfmark.core.socket_handlers.redact_status", return_value={"redacted": {}}
):
with patch.object(main_module.ws_manager, "join_user_room") as join:
socket = _socket(main_module, client)
events = _events(socket)
socket.disconnect()
assert events == [("status_update", {"redacted": {}})]
status.assert_called_once_with(user_id=7)
assert join.call_args.kwargs == {"is_admin": False, "db_user_id": 7}
def test_a_visitor_with_no_session_gets_an_empty_status(self, main_module, client):
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend, "queue_status") as status:
with patch.object(main_module.ws_manager, "join_user_room"):
socket = _socket(main_module, client)
events = _events(socket)
socket.disconnect()
assert events == [("status_update", {})]
status.assert_not_called()
def test_a_failure_reading_the_status_sends_nothing_and_keeps_the_connection(
self, main_module, client
):
_login(client, user_id="root", db_user_id=None, is_admin=True)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(
main_module.backend, "queue_status", side_effect=RuntimeError("broke")
):
with patch.object(main_module.ws_manager, "join_user_room"):
socket = _socket(main_module, client)
events = _events(socket)
connected = socket.is_connected()
socket.disconnect()
assert events == []
assert connected is True
class TestRequestStatus:
def test_a_manual_request_syncs_the_room_and_answers_with_the_status(self, main_module, client):
_login(client, user_id="root", db_user_id=None, is_admin=True)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend, "queue_status", return_value=_STATUS):
with patch.object(main_module.ws_manager, "join_user_room"):
socket = _socket(main_module, client)
socket.get_received()
with patch.object(main_module.ws_manager, "sync_user_room") as sync:
socket.emit("request_status")
events = _events(socket)
socket.disconnect()
assert events == [("status_update", _STATUS)]
assert sync.call_args.kwargs == {"is_admin": True, "db_user_id": None}
def test_a_visitor_with_no_session_gets_an_empty_status(self, main_module, client):
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.ws_manager, "join_user_room"):
socket = _socket(main_module, client)
socket.get_received()
with patch.object(main_module.ws_manager, "sync_user_room"):
socket.emit("request_status")
events = _events(socket)
socket.disconnect()
assert events == [("status_update", {})]
def test_a_failure_answers_with_an_error_event(self, main_module, client):
_login(client, user_id="root", db_user_id=None, is_admin=True)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend, "queue_status", return_value=_STATUS):
with patch.object(main_module.ws_manager, "join_user_room"):
socket = _socket(main_module, client)
socket.get_received()
with patch.object(
main_module.backend, "queue_status", side_effect=RuntimeError("broke")
):
with patch.object(main_module.ws_manager, "sync_user_room"):
socket.emit("request_status")
events = _events(socket)
socket.disconnect()
assert events == [("error", {"message": "Failed to get status"})]
class TestDisconnect:
def test_leaves_the_room_and_counts_the_disconnect(self, main_module, client):
with patch.object(main_module, "get_auth_mode", return_value="none"):
with patch.object(main_module.backend, "queue_status", return_value={}):
with patch.object(main_module.ws_manager, "join_user_room"):
socket = _socket(main_module, client)
with patch.object(main_module.ws_manager, "leave_user_room") as leave:
with patch.object(
main_module.ws_manager, "client_disconnected"
) as disconnected:
socket.disconnect()
leave.assert_called_once()
assert isinstance(leave.call_args.args[0], str) and leave.call_args.args[0]
disconnected.assert_called_once_with()