mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-11-14 09:18:25 +00:00
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 -->
182 lines
8.3 KiB
Python
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()
|