Requests: Various fixes and improvements (#617)

- Refactored activity backend for full user-level management, using the
db file
- Revamped the activity sidebar UX and categorisation
- Added download history and user filtering
- Added User Preferences modal, giving limited configuration for
non-admins - replaces the "restrict settings" config option.
- Many many bug fixes
- Many many new tests
This commit is contained in:
Alex
2026-02-14 18:24:28 +00:00
committed by GitHub
parent 68608b6162
commit b7bee132a1
42 changed files with 5118 additions and 467 deletions
+420
View File
@@ -0,0 +1,420 @@
"""API tests for activity snapshot/dismiss/history routes."""
from __future__ import annotations
import importlib
import uuid
from unittest.mock import ANY, 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 _set_session(client, *, user_id: str, db_user_id: int | None, is_admin: bool) -> 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
elif "db_user_id" in sess:
del sess["db_user_id"]
def _create_user(main_module, *, prefix: str, role: str = "user") -> dict:
username = f"{prefix}-{uuid.uuid4().hex[:8]}"
return main_module.user_db.create_user(username=username, role=role)
def _sample_status_payload() -> dict:
return {
"queued": {},
"resolving": {},
"locating": {},
"downloading": {},
"complete": {},
"available": {},
"done": {},
"error": {},
"cancelled": {},
}
class TestActivityRoutes:
def test_snapshot_returns_status_requests_and_dismissed(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
main_module.user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="book",
policy_mode="request_book",
book_data={
"title": "Snapshot Book",
"author": "Snapshot Author",
"provider": "openlibrary",
"provider_id": "snap-1",
},
status="pending",
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()):
response = client.get("/api/activity/snapshot")
assert response.status_code == 200
assert "status" in response.json
assert "requests" in response.json
assert "dismissed" in response.json
assert response.json["dismissed"] == []
assert any(item["user_id"] == user["id"] for item in response.json["requests"])
def test_dismiss_and_history_flow(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
main_module.activity_service.record_terminal_snapshot(
user_id=user["id"],
item_type="download",
item_key="download:test-task",
origin="requested",
final_status="complete",
source_id="test-task",
snapshot={"title": "Dismiss Me"},
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
dismiss_response = client.post(
"/api/activity/dismiss",
json={"item_type": "download", "item_key": "download:test-task"},
)
snapshot_response = client.get("/api/activity/snapshot")
history_response = client.get("/api/activity/history?limit=10&offset=0")
clear_history_response = client.delete("/api/activity/history")
history_after_clear = client.get("/api/activity/history?limit=10&offset=0")
assert dismiss_response.status_code == 200
assert dismiss_response.json["status"] == "dismissed"
assert snapshot_response.status_code == 200
assert {"item_type": "download", "item_key": "download:test-task"} in snapshot_response.json["dismissed"]
assert history_response.status_code == 200
assert len(history_response.json) == 1
assert history_response.json[0]["item_key"] == "download:test-task"
assert history_response.json[0]["snapshot"] == {"title": "Dismiss Me"}
assert clear_history_response.status_code == 200
assert clear_history_response.json["status"] == "cleared"
assert clear_history_response.json["deleted_count"] == 1
assert history_after_clear.status_code == 200
assert history_after_clear.json == []
def test_admin_snapshot_includes_admin_viewer_dismissals(self, main_module, client):
admin = _create_user(main_module, prefix="admin", role="admin")
_set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
dismiss_response = client.post(
"/api/activity/dismiss",
json={"item_type": "download", "item_key": "download:admin-visible-task"},
)
with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()):
snapshot_response = client.get("/api/activity/snapshot")
assert dismiss_response.status_code == 200
assert snapshot_response.status_code == 200
assert {
"item_type": "download",
"item_key": "download:admin-visible-task",
} in snapshot_response.json["dismissed"]
def test_dismiss_legacy_fulfilled_request_creates_minimal_history_snapshot(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
request_row = main_module.user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="book",
policy_mode="request_book",
book_data={
"title": "Legacy Fulfilled Request",
"author": "Legacy Author",
"provider": "openlibrary",
"provider_id": "legacy-fulfilled-1",
},
status="fulfilled",
delivery_state="unknown",
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
dismiss_response = client.post(
"/api/activity/dismiss",
json={"item_type": "request", "item_key": f"request:{request_row['id']}"},
)
history_response = client.get("/api/activity/history?limit=10&offset=0")
assert dismiss_response.status_code == 200
assert history_response.status_code == 200
assert len(history_response.json) == 1
history_entry = history_response.json[0]
assert history_entry["item_type"] == "request"
assert history_entry["item_key"] == f"request:{request_row['id']}"
assert history_entry["final_status"] == "complete"
assert history_entry["snapshot"]["kind"] == "request"
assert history_entry["snapshot"]["request"]["id"] == request_row["id"]
assert history_entry["snapshot"]["request"]["book_data"]["title"] == "Legacy Fulfilled Request"
def test_dismiss_requires_db_identity(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=None, is_admin=False)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
response = client.post(
"/api/activity/dismiss",
json={"item_type": "download", "item_key": "download:test-task"},
)
assert response.status_code == 403
assert response.json["code"] == "user_identity_unavailable"
def test_dismiss_emits_activity_update_to_user_room(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.ws_manager, "is_enabled", return_value=True):
with patch.object(main_module.ws_manager.socketio, "emit") as mock_emit:
response = client.post(
"/api/activity/dismiss",
json={"item_type": "download", "item_key": "download:test-task"},
)
assert response.status_code == 200
mock_emit.assert_called_once_with(
"activity_update",
ANY,
to=f"user_{user['id']}",
)
def test_queue_clear_does_not_set_request_delivery_state_to_cleared(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
request_row = main_module.user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data={
"title": "Queue Clear Book",
"author": "Queue Clear Author",
"provider": "openlibrary",
"provider_id": "clear-1",
},
release_data={
"source": "prowlarr",
"source_id": "clear-task-1",
"title": "Queue Clear Book.epub",
},
status="fulfilled",
delivery_state="complete",
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend, "clear_completed", return_value=1):
response = client.delete("/api/queue/clear")
assert response.status_code == 200
updated_request = main_module.user_db.get_request(request_row["id"])
assert updated_request is not None
assert updated_request["delivery_state"] == "complete"
def test_snapshot_backfills_undismissed_terminal_download_from_activity_log(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
main_module.activity_service.record_terminal_snapshot(
user_id=user["id"],
item_type="download",
item_key="download:expired-task-1",
origin="direct",
final_status="complete",
source_id="expired-task-1",
snapshot={
"kind": "download",
"download": {
"id": "expired-task-1",
"title": "Expired Task",
"author": "Expired Author",
"added_time": 123,
"status_message": "Finished",
"source": "direct_download",
},
},
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()):
response = client.get("/api/activity/snapshot")
assert response.status_code == 200
assert "expired-task-1" in response.json["status"]["complete"]
assert response.json["status"]["complete"]["expired-task-1"]["id"] == "expired-task-1"
def test_snapshot_clears_stale_download_dismissal_when_same_task_is_active(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
dismiss_response = client.post(
"/api/activity/dismiss",
json={"item_type": "download", "item_key": "download:task-reused-1"},
)
assert dismiss_response.status_code == 200
active_status = _sample_status_payload()
active_status["downloading"] = {
"task-reused-1": {
"id": "task-reused-1",
"title": "Reused Task",
"author": "Author",
"source": "direct_download",
"added_time": 1,
}
}
with patch.object(main_module.backend, "queue_status", return_value=active_status):
snapshot_response = client.get("/api/activity/snapshot")
assert snapshot_response.status_code == 200
assert {
"item_type": "download",
"item_key": "download:task-reused-1",
} not in snapshot_response.json["dismissed"]
assert main_module.activity_service.get_dismissal_set(user["id"]) == []
def test_dismiss_state_is_isolated_per_user(self, main_module, client):
user_one = _create_user(main_module, prefix="reader-one")
user_two = _create_user(main_module, prefix="reader-two")
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
_set_session(client, user_id=user_one["username"], db_user_id=user_one["id"], is_admin=False)
dismiss_response = client.post(
"/api/activity/dismiss",
json={"item_type": "download", "item_key": "download:shared-task"},
)
assert dismiss_response.status_code == 200
snapshot_one = client.get("/api/activity/snapshot")
assert snapshot_one.status_code == 200
assert {"item_type": "download", "item_key": "download:shared-task"} in snapshot_one.json["dismissed"]
_set_session(client, user_id=user_two["username"], db_user_id=user_two["id"], is_admin=False)
snapshot_two = client.get("/api/activity/snapshot")
assert snapshot_two.status_code == 200
assert {"item_type": "download", "item_key": "download:shared-task"} not in snapshot_two.json["dismissed"]
def test_history_paging_is_stable_and_non_overlapping(self, main_module, client):
user = _create_user(main_module, prefix="history-user")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
for index in range(5):
item_key = f"download:history-task-{index}"
main_module.activity_service.record_terminal_snapshot(
user_id=user["id"],
item_type="download",
item_key=item_key,
origin="direct",
final_status="complete",
source_id=f"history-task-{index}",
snapshot={"kind": "download", "download": {"id": f"history-task-{index}"}},
)
main_module.activity_service.dismiss_item(
user_id=user["id"],
item_type="download",
item_key=item_key,
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
page_one = client.get("/api/activity/history?limit=2&offset=0")
page_two = client.get("/api/activity/history?limit=2&offset=2")
page_three = client.get("/api/activity/history?limit=2&offset=4")
full = client.get("/api/activity/history?limit=10&offset=0")
assert page_one.status_code == 200
assert page_two.status_code == 200
assert page_three.status_code == 200
assert full.status_code == 200
page_one_ids = [row["id"] for row in page_one.json]
page_two_ids = [row["id"] for row in page_two.json]
page_three_ids = [row["id"] for row in page_three.json]
combined_ids = page_one_ids + page_two_ids + page_three_ids
full_ids = [row["id"] for row in full.json]
assert len(set(page_one_ids).intersection(page_two_ids)) == 0
assert len(set(page_one_ids).intersection(page_three_ids)) == 0
assert len(set(page_two_ids).intersection(page_three_ids)) == 0
assert combined_ids == full_ids[: len(combined_ids)]
def test_dismiss_many_emits_activity_update_only_to_acting_user_room(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.ws_manager, "is_enabled", return_value=True):
with patch.object(main_module.ws_manager.socketio, "emit") as mock_emit:
response = client.post(
"/api/activity/dismiss-many",
json={
"items": [
{"item_type": "download", "item_key": "download:test-task-many"},
]
},
)
assert response.status_code == 200
mock_emit.assert_called_once_with(
"activity_update",
ANY,
to=f"user_{user['id']}",
)
def test_clear_history_emits_activity_update_only_to_acting_user_room(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
main_module.activity_service.dismiss_item(
user_id=user["id"],
item_type="download",
item_key="download:history-clear-task",
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.ws_manager, "is_enabled", return_value=True):
with patch.object(main_module.ws_manager.socketio, "emit") as mock_emit:
response = client.delete("/api/activity/history")
assert response.status_code == 200
mock_emit.assert_called_once_with(
"activity_update",
ANY,
to=f"user_{user['id']}",
)
+245
View File
@@ -0,0 +1,245 @@
"""Tests for activity service persistence helpers."""
from __future__ import annotations
import os
import tempfile
import pytest
from shelfmark.core.activity_service import (
ActivityService,
build_download_item_key,
build_item_key,
build_request_item_key,
)
from shelfmark.core.user_db import UserDB
@pytest.fixture
def db_path():
with tempfile.TemporaryDirectory() as tmpdir:
yield os.path.join(tmpdir, "users.db")
@pytest.fixture
def user_db(db_path):
db = UserDB(db_path)
db.initialize()
return db
@pytest.fixture
def activity_service(db_path):
return ActivityService(db_path)
class TestItemKeys:
def test_build_request_item_key(self):
assert build_request_item_key(42) == "request:42"
assert build_item_key("request", 7) == "request:7"
def test_build_download_item_key(self):
assert build_download_item_key("abc123") == "download:abc123"
assert build_item_key("download", "xyz") == "download:xyz"
def test_build_item_key_validation(self):
with pytest.raises(ValueError):
build_item_key("bad", "x")
with pytest.raises(ValueError):
build_item_key("request", "nope")
with pytest.raises(ValueError):
build_item_key("download", "")
class TestActivityService:
def test_record_snapshot_and_dismiss_and_history(self, user_db, activity_service):
user = user_db.create_user(username="activity-user")
snapshot = activity_service.record_terminal_snapshot(
user_id=user["id"],
item_type="download",
item_key="download:task-1",
origin="requested",
final_status="complete",
request_id=12,
source_id="task-1",
snapshot={"title": "My Book", "status": "complete"},
)
assert snapshot["item_type"] == "download"
assert snapshot["item_key"] == "download:task-1"
assert snapshot["origin"] == "requested"
assert snapshot["final_status"] == "complete"
dismissal = activity_service.dismiss_item(
user_id=user["id"],
item_type="download",
item_key="download:task-1",
)
assert dismissal["item_type"] == "download"
assert dismissal["item_key"] == "download:task-1"
assert dismissal["activity_log_id"] == snapshot["id"]
dismissed_set = activity_service.get_dismissal_set(user["id"])
assert dismissed_set == [{"item_type": "download", "item_key": "download:task-1"}]
history = activity_service.get_history(user["id"], limit=10, offset=0)
assert len(history) == 1
assert history[0]["item_type"] == "download"
assert history[0]["item_key"] == "download:task-1"
assert history[0]["origin"] == "requested"
assert history[0]["final_status"] == "complete"
assert history[0]["snapshot"] == {"title": "My Book", "status": "complete"}
def test_history_hydrates_legacy_request_dismissals_without_snapshot(self, user_db, activity_service):
user = user_db.create_user(username="legacy-reader")
request_row = user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="book",
policy_mode="request_book",
book_data={
"title": "Legacy Request",
"author": "Legacy Author",
"provider": "openlibrary",
"provider_id": "legacy-hydrate-1",
},
status="fulfilled",
delivery_state="unknown",
)
activity_service.dismiss_item(
user_id=user["id"],
item_type="request",
item_key=f"request:{request_row['id']}",
)
history = activity_service.get_history(user["id"], limit=10, offset=0)
assert len(history) == 1
assert history[0]["item_type"] == "request"
assert history[0]["item_key"] == f"request:{request_row['id']}"
assert history[0]["origin"] == "request"
assert history[0]["final_status"] == "complete"
assert history[0]["snapshot"] == {
"kind": "request",
"request": {
"id": request_row["id"],
"user_id": user["id"],
"status": "fulfilled",
"delivery_state": "unknown",
"request_level": "book",
"book_data": {
"title": "Legacy Request",
"author": "Legacy Author",
"provider": "openlibrary",
"provider_id": "legacy-hydrate-1",
},
"release_data": {},
"note": None,
"admin_note": None,
"created_at": request_row["created_at"],
"updated_at": request_row["created_at"],
},
}
def test_dismiss_many_and_clear_history(self, user_db, activity_service):
alice = user_db.create_user(username="alice")
bob = user_db.create_user(username="bob")
activity_service.record_terminal_snapshot(
user_id=alice["id"],
item_type="request",
item_key="request:10",
origin="request",
final_status="rejected",
request_id=10,
snapshot={"title": "Rejected Book"},
)
activity_service.record_terminal_snapshot(
user_id=alice["id"],
item_type="download",
item_key="download:task-2",
origin="direct",
final_status="error",
source_id="task-2",
snapshot={"title": "Failed Download"},
)
dismissed_count = activity_service.dismiss_many(
user_id=alice["id"],
items=[
{"item_type": "request", "item_key": "request:10"},
{"item_type": "download", "item_key": "download:task-2"},
],
)
assert dismissed_count == 2
# Bob has independent dismiss state.
activity_service.dismiss_item(
user_id=bob["id"],
item_type="request",
item_key="request:10",
)
alice_history = activity_service.get_history(alice["id"])
bob_history = activity_service.get_history(bob["id"])
assert len(alice_history) == 2
assert len(bob_history) == 1
cleared = activity_service.clear_history(alice["id"])
assert cleared == 2
assert activity_service.get_history(alice["id"]) == []
assert len(activity_service.get_history(bob["id"])) == 1
def test_get_undismissed_terminal_downloads_returns_latest_per_item_and_excludes_dismissed(
self,
user_db,
activity_service,
):
user = user_db.create_user(username="snapshot-user")
activity_service.record_terminal_snapshot(
user_id=user["id"],
item_type="download",
item_key="download:task-1",
origin="direct",
final_status="error",
source_id="task-1",
terminal_at="2026-01-01T10:00:00+00:00",
snapshot={"kind": "download", "download": {"id": "task-1", "status_message": "failed"}},
)
activity_service.record_terminal_snapshot(
user_id=user["id"],
item_type="download",
item_key="download:task-1",
origin="direct",
final_status="complete",
source_id="task-1",
terminal_at="2026-01-01T11:00:00+00:00",
snapshot={"kind": "download", "download": {"id": "task-1", "status_message": "done"}},
)
activity_service.record_terminal_snapshot(
user_id=user["id"],
item_type="download",
item_key="download:task-2",
origin="direct",
final_status="cancelled",
source_id="task-2",
terminal_at="2026-01-01T09:00:00+00:00",
snapshot={"kind": "download", "download": {"id": "task-2", "status_message": "stopped"}},
)
activity_service.dismiss_item(
user_id=user["id"],
item_type="download",
item_key="download:task-2",
)
rows = activity_service.get_undismissed_terminal_downloads(user["id"])
assert len(rows) == 1
assert rows[0]["item_key"] == "download:task-1"
assert rows[0]["final_status"] == "complete"
assert rows[0]["snapshot"] == {
"kind": "download",
"download": {"id": "task-1", "status_message": "done"},
}
@@ -0,0 +1,156 @@
"""Tests for terminal activity snapshot capture from queue transitions."""
from __future__ import annotations
import importlib
import json
import uuid
from unittest.mock import patch
import pytest
from shelfmark.core.models import DownloadTask, QueueStatus
@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
def _create_user(main_module, *, prefix: str) -> dict:
username = f"{prefix}-{uuid.uuid4().hex[:8]}"
return main_module.user_db.create_user(username=username, role="user")
def _read_activity_log_row(main_module, snapshot_id: int):
conn = main_module.user_db._connect()
try:
return conn.execute(
"SELECT * FROM activity_log WHERE id = ?",
(snapshot_id,),
).fetchone()
finally:
conn.close()
class TestTerminalSnapshotCapture:
def test_complete_transition_records_direct_snapshot_and_survives_queue_clear(self, main_module):
user = _create_user(main_module, prefix="snap-direct")
task_id = f"direct-{uuid.uuid4().hex[:8]}"
task = DownloadTask(
task_id=task_id,
source="direct_download",
title="Direct Snapshot",
user_id=user["id"],
username=user["username"],
)
assert main_module.backend.book_queue.add(task) is True
try:
main_module.backend.book_queue.update_status(task_id, QueueStatus.COMPLETE)
item_key = f"download:{task_id}"
snapshot_id = main_module.activity_service.get_latest_activity_log_id(
item_type="download",
item_key=item_key,
)
assert snapshot_id is not None
removed = main_module.backend.book_queue.clear_completed(user_id=user["id"])
assert removed >= 1
row = _read_activity_log_row(main_module, snapshot_id)
assert row is not None
assert row["user_id"] == user["id"]
assert row["item_key"] == item_key
assert row["origin"] == "direct"
assert row["final_status"] == "complete"
snapshot = json.loads(row["snapshot_json"])
assert snapshot["kind"] == "download"
assert snapshot["download"]["id"] == task_id
finally:
main_module.backend.book_queue.cancel_download(task_id)
def test_complete_transition_records_requested_origin_for_graduated_request(self, main_module):
user = _create_user(main_module, prefix="snap-requested")
task_id = f"requested-{uuid.uuid4().hex[:8]}"
request_row = main_module.user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data={
"title": "Requested Snapshot",
"author": "Snapshot Author",
"provider": "openlibrary",
"provider_id": "snapshot-req",
},
release_data={
"source": "prowlarr",
"source_id": task_id,
"title": "Requested Snapshot.epub",
},
status="fulfilled",
delivery_state="queued",
)
task = DownloadTask(
task_id=task_id,
source="prowlarr",
title="Requested Snapshot",
user_id=user["id"],
username=user["username"],
)
assert main_module.backend.book_queue.add(task) is True
try:
main_module.backend.book_queue.update_status(task_id, QueueStatus.COMPLETE)
snapshot_id = main_module.activity_service.get_latest_activity_log_id(
item_type="download",
item_key=f"download:{task_id}",
)
assert snapshot_id is not None
row = _read_activity_log_row(main_module, snapshot_id)
assert row is not None
assert row["origin"] == "requested"
assert row["request_id"] == request_row["id"]
assert row["source_id"] == task_id
snapshot = json.loads(row["snapshot_json"])
assert snapshot["download"]["id"] == task_id
assert snapshot["request"]["id"] == request_row["id"]
finally:
main_module.backend.book_queue.cancel_download(task_id)
def test_complete_transition_snapshot_uses_latest_terminal_status_message(self, main_module):
user = _create_user(main_module, prefix="snap-message")
task_id = f"message-{uuid.uuid4().hex[:8]}"
task = DownloadTask(
task_id=task_id,
source="direct_download",
title="Message Snapshot",
user_id=user["id"],
username=user["username"],
)
assert main_module.backend.book_queue.add(task) is True
try:
# Simulate a stale in-progress message that used to leak into history snapshots.
main_module.backend.book_queue.update_status_message(task_id, "Moving file")
main_module.backend.update_download_status(task_id, "complete", "Complete")
snapshot_id = main_module.activity_service.get_latest_activity_log_id(
item_type="download",
item_key=f"download:{task_id}",
)
assert snapshot_id is not None
row = _read_activity_log_row(main_module, snapshot_id)
assert row is not None
snapshot = json.loads(row["snapshot_json"])
assert snapshot["download"]["status_message"] == "Complete"
finally:
main_module.backend.book_queue.cancel_download(task_id)
+150
View File
@@ -8,10 +8,13 @@ existing contracts.
from __future__ import annotations
import importlib
import uuid
from unittest.mock import patch
import pytest
from shelfmark.core.models import DownloadTask
@pytest.fixture(scope="module")
def main_module():
@@ -42,6 +45,11 @@ def _set_authenticated_session(
sess["db_user_id"] = db_user_id
def _create_user(main_module, *, prefix: str, role: str = "user") -> dict:
username = f"{prefix}-{uuid.uuid4().hex[:8]}"
return main_module.user_db.create_user(username=username, role=role)
class TestDownloadEndpointGuardrails:
def test_missing_book_id_returns_400_and_does_not_queue(self, main_module, client):
with patch.object(main_module, "get_auth_mode", return_value="none"):
@@ -211,6 +219,148 @@ class TestReleaseDownloadEndpointGuardrails:
mock_queue_release.assert_not_called()
class TestCancelDownloadEndpointGuardrails:
def test_owner_can_cancel_direct_download(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_authenticated_session(
client,
user_id=user["username"],
db_user_id=user["id"],
is_admin=False,
)
task = DownloadTask(
task_id="direct-task-1",
source="direct_download",
title="Direct Task",
user_id=user["id"],
username=user["username"],
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend.book_queue, "get_task", return_value=task):
with patch.object(main_module.backend, "cancel_download", return_value=True) as mock_cancel:
resp = client.delete("/api/download/direct-task-1/cancel")
assert resp.status_code == 200
assert resp.get_json() == {"status": "cancelled", "book_id": "direct-task-1"}
mock_cancel.assert_called_once_with("direct-task-1")
def test_non_owner_cannot_cancel_download(self, main_module, client):
owner = _create_user(main_module, prefix="owner")
actor = _create_user(main_module, prefix="actor")
_set_authenticated_session(
client,
user_id=actor["username"],
db_user_id=actor["id"],
is_admin=False,
)
task = DownloadTask(
task_id="owned-task-1",
source="direct_download",
title="Owned Task",
user_id=owner["id"],
username=owner["username"],
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend.book_queue, "get_task", return_value=task):
with patch.object(main_module.backend, "cancel_download", return_value=True) as mock_cancel:
resp = client.delete("/api/download/owned-task-1/cancel")
assert resp.status_code == 403
assert resp.get_json()["code"] == "download_not_owned"
mock_cancel.assert_not_called()
def test_owner_cannot_cancel_graduated_request_download(self, main_module, client):
user = _create_user(main_module, prefix="requester")
_set_authenticated_session(
client,
user_id=user["username"],
db_user_id=user["id"],
is_admin=False,
)
main_module.user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data={
"title": "Requested Book",
"author": "Request Author",
"provider": "openlibrary",
"provider_id": "req-guard-1",
},
release_data={
"source": "prowlarr",
"source_id": "requested-task-1",
"title": "Requested Book.epub",
},
status="fulfilled",
delivery_state="queued",
)
task = DownloadTask(
task_id="requested-task-1",
source="prowlarr",
title="Requested Book",
user_id=user["id"],
username=user["username"],
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend.book_queue, "get_task", return_value=task):
with patch.object(main_module.backend, "cancel_download", return_value=True) as mock_cancel:
resp = client.delete("/api/download/requested-task-1/cancel")
assert resp.status_code == 403
assert resp.get_json()["code"] == "requested_download_cancel_forbidden"
mock_cancel.assert_not_called()
def test_admin_can_cancel_graduated_request_download(self, main_module, client):
admin = _create_user(main_module, prefix="admin", role="admin")
requester = _create_user(main_module, prefix="requester")
_set_authenticated_session(
client,
user_id=admin["username"],
db_user_id=admin["id"],
is_admin=True,
)
main_module.user_db.create_request(
user_id=requester["id"],
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data={
"title": "Admin Requested Book",
"author": "Admin Request Author",
"provider": "openlibrary",
"provider_id": "req-guard-2",
},
release_data={
"source": "prowlarr",
"source_id": "requested-task-2",
"title": "Admin Requested Book.epub",
},
status="fulfilled",
delivery_state="queued",
)
task = DownloadTask(
task_id="requested-task-2",
source="prowlarr",
title="Admin Requested Book",
user_id=requester["id"],
username=requester["username"],
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend.book_queue, "get_task", return_value=task):
with patch.object(main_module.backend, "cancel_download", return_value=True) as mock_cancel:
resp = client.delete("/api/download/requested-task-2/cancel")
assert resp.status_code == 200
assert resp.get_json() == {"status": "cancelled", "book_id": "requested-task-2"}
mock_cancel.assert_called_once_with("requested-task-2")
class TestStatusEndpointGuardrails:
def test_no_auth_allows_without_session_and_returns_status(self, main_module, client):
observed: dict[str, object] = {}
+6 -6
View File
@@ -70,9 +70,9 @@ class TestSettingsRestrictionPolicy:
def test_default_is_admin_restricted(self):
assert should_restrict_settings_to_admin({}) is True
def test_respects_global_users_toggle(self):
def test_restriction_is_always_enabled(self):
assert should_restrict_settings_to_admin({"RESTRICT_SETTINGS_TO_ADMIN": True}) is True
assert should_restrict_settings_to_admin({"RESTRICT_SETTINGS_TO_ADMIN": False}) is False
assert should_restrict_settings_to_admin({"RESTRICT_SETTINGS_TO_ADMIN": False}) is True
def test_extracts_settings_tab_from_path(self):
assert get_settings_tab_from_path("/api/settings/security") == "security"
@@ -84,11 +84,11 @@ class TestSettingsRestrictionPolicy:
assert requires_admin_for_settings_access("/api/settings/security", users_config) is True
assert requires_admin_for_settings_access("/api/settings/users", users_config) is True
def test_other_tabs_follow_global_toggle(self):
def test_other_tabs_also_require_admin(self):
assert requires_admin_for_settings_access(
"/api/settings/general",
{"RESTRICT_SETTINGS_TO_ADMIN": False},
) is False
) is True
assert requires_admin_for_settings_access(
"/api/settings/general",
{"RESTRICT_SETTINGS_TO_ADMIN": True},
@@ -112,13 +112,13 @@ class TestAuthCheckAdminStatus:
)
assert result is False
def test_authenticated_user_when_not_restricted(self):
def test_authenticated_non_admin_user_is_not_admin(self):
result = get_auth_check_admin_status(
"proxy",
{"RESTRICT_SETTINGS_TO_ADMIN": False},
{"user_id": "user", "is_admin": False},
)
assert result is True
assert result is False
def test_unauthenticated_is_never_admin(self):
result = get_auth_check_admin_status(
+34 -1
View File
@@ -5,7 +5,7 @@ Tests that DownloadTask has a user_id field and that the queue
can be filtered by user.
"""
from shelfmark.core.models import DownloadTask
from shelfmark.core.models import DownloadTask, QueueStatus
from shelfmark.core.queue import BookQueue
@@ -116,6 +116,39 @@ class TestQueueFilterByUser:
# User 1 sees their own + legacy (no user_id)
assert len(all_tasks) == 2
def test_clear_completed_for_user_only_removes_user_terminal_tasks(self):
q = BookQueue()
q.add(self._make_task("book-1", user_id=1))
q.add(self._make_task("book-2", user_id=2))
q.add(self._make_task("book-3", user_id=1))
q.update_status("book-1", QueueStatus.COMPLETE)
q.update_status("book-2", QueueStatus.ERROR)
q.update_status("book-3", QueueStatus.QUEUED)
removed = q.clear_completed(user_id=1)
assert removed == 1
status = q.get_status()
all_tasks = {}
for tasks_by_status in status.values():
all_tasks.update(tasks_by_status)
assert "book-1" not in all_tasks
assert "book-2" in all_tasks
assert "book-3" in all_tasks
def test_clear_completed_for_user_includes_legacy_tasks(self):
q = BookQueue()
q.add(self._make_task("legacy-book", user_id=None))
q.add(self._make_task("user-book", user_id=1))
q.update_status("legacy-book", QueueStatus.COMPLETE)
q.update_status("user-book", QueueStatus.COMPLETE)
removed = q.clear_completed(user_id=1)
assert removed == 2
# ---------------------------------------------------------------------------
# Per-user destination override in get_final_destination
+150
View File
@@ -58,6 +58,14 @@ def _policy(
}
def _read_activity_log_row(main_module, snapshot_id: int):
conn = main_module.user_db._connect()
try:
return conn.execute("SELECT * FROM activity_log WHERE id = ?", (snapshot_id,)).fetchone()
finally:
conn.close()
class TestDownloadPolicyGuards:
def test_download_endpoint_blocks_before_queue_when_policy_requires_request(self, main_module, client):
user = _create_user(main_module, prefix="reader")
@@ -194,6 +202,17 @@ class TestRequestRoutes:
assert cancel_resp.status_code == 200
assert cancel_resp.json["status"] == "cancelled"
snapshot_id = main_module.activity_service.get_latest_activity_log_id(
item_type="request",
item_key=f"request:{request_id}",
)
assert snapshot_id is not None
log_row = _read_activity_log_row(main_module, snapshot_id)
assert log_row is not None
assert log_row["user_id"] == user["id"]
assert log_row["final_status"] == "cancelled"
assert log_row["origin"] == "request"
def test_create_request_emits_websocket_events(self, main_module, client):
user = _create_user(main_module, prefix="reader")
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
@@ -498,6 +517,17 @@ class TestRequestRoutes:
assert reject_again_resp.status_code == 409
assert reject_again_resp.json["code"] == "stale_transition"
snapshot_id = main_module.activity_service.get_latest_activity_log_id(
item_type="request",
item_key=f"request:{request_id}",
)
assert snapshot_id is not None
log_row = _read_activity_log_row(main_module, snapshot_id)
assert log_row is not None
assert log_row["user_id"] == user["id"]
assert log_row["final_status"] == "rejected"
assert log_row["origin"] == "request"
def test_admin_reject_emits_update_to_user_and_admin_rooms(self, main_module, client):
user = _create_user(main_module, prefix="reader")
admin = _create_user(main_module, prefix="admin", role="admin")
@@ -1638,3 +1668,123 @@ class TestDownloadPolicyGuardsExtended:
})
assert resp.status_code == 200
def test_clear_queue_does_not_mutate_fulfilled_request_delivery_state(main_module, client):
user = _create_user(main_module, prefix="reader")
admin = _create_user(main_module, prefix="admin", role="admin")
_set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True)
created = main_module.user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data={
"title": "Clear Delivery State",
"author": "QA",
"content_type": "ebook",
"provider": "openlibrary",
"provider_id": "ol-clear-delivery",
},
release_data={
"source": "prowlarr",
"source_id": "clear-delivery-source-id",
"title": "Clear Delivery State.epub",
},
status="fulfilled",
delivery_state="complete",
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.ws_manager, "is_enabled", return_value=False):
with patch.object(main_module.ws_manager, "broadcast_status_update"):
with patch.object(main_module.backend, "queue_status", return_value={}) as mock_queue_status:
with patch.object(main_module.backend, "clear_completed", return_value=1) as mock_clear_completed:
resp = client.delete("/api/queue/clear")
assert resp.status_code == 200
assert resp.json["status"] == "cleared"
assert resp.json["removed_count"] == 1
assert mock_queue_status.call_args_list[0].kwargs == {}
mock_clear_completed.assert_called_once_with(user_id=None)
refreshed = main_module.user_db.get_request(created["id"])
assert refreshed["delivery_state"] == "complete"
def test_non_admin_clear_queue_is_scoped_without_mutating_request_delivery_state(main_module, client):
alice = _create_user(main_module, prefix="alice")
bob = _create_user(main_module, prefix="bob")
_set_session(client, user_id=alice["username"], db_user_id=alice["id"], is_admin=False)
alice_request = main_module.user_db.create_request(
user_id=alice["id"],
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data={
"title": "Alice Clear Scope",
"author": "QA",
"content_type": "ebook",
"provider": "openlibrary",
"provider_id": "ol-alice-scope",
},
release_data={
"source": "prowlarr",
"source_id": "shared-clear-scope-source-id",
"title": "Alice Scope.epub",
},
status="fulfilled",
delivery_state="complete",
)
bob_request = main_module.user_db.create_request(
user_id=bob["id"],
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data={
"title": "Bob Clear Scope",
"author": "QA",
"content_type": "ebook",
"provider": "openlibrary",
"provider_id": "ol-bob-scope",
},
release_data={
"source": "prowlarr",
"source_id": "shared-clear-scope-source-id",
"title": "Bob Scope.epub",
},
status="fulfilled",
delivery_state="complete",
)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.ws_manager, "is_enabled", return_value=False):
with patch.object(main_module.ws_manager, "broadcast_status_update"):
with patch.object(main_module.backend, "queue_status", return_value={}) as mock_queue_status:
with patch.object(main_module.backend, "clear_completed", return_value=1) as mock_clear_completed:
resp = client.delete("/api/queue/clear")
assert resp.status_code == 200
assert resp.json["status"] == "cleared"
assert resp.json["removed_count"] == 1
assert mock_queue_status.call_args_list[0].kwargs == {}
mock_clear_completed.assert_called_once_with(user_id=alice["id"])
refreshed_alice = main_module.user_db.get_request(alice_request["id"])
refreshed_bob = main_module.user_db.get_request(bob_request["id"])
assert refreshed_alice["delivery_state"] == "complete"
assert refreshed_bob["delivery_state"] == "complete"
def test_non_admin_clear_queue_without_db_user_id_returns_403(main_module, client):
_set_session(client, user_id="reader-no-db", db_user_id=None, is_admin=False)
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch.object(main_module.backend, "clear_completed") as mock_clear_completed:
resp = client.delete("/api/queue/clear")
assert resp.status_code == 403
assert resp.json["code"] == "user_identity_unavailable"
mock_clear_completed.assert_not_called()
+60
View File
@@ -14,9 +14,11 @@ from shelfmark.core.requests_service import (
create_request,
fulfil_request,
normalize_policy_mode,
normalize_delivery_state,
normalize_request_level,
normalize_request_status,
reject_request,
sync_delivery_states_from_queue_status,
validate_request_level_payload,
validate_status_transition,
)
@@ -61,6 +63,16 @@ def test_normalize_request_status_rejects_unknown_values():
normalize_request_status("queued")
def test_normalize_delivery_state_accepts_known_values():
assert normalize_delivery_state("none") == "none"
assert normalize_delivery_state(" QUEUED ") == "queued"
def test_normalize_delivery_state_rejects_unknown_values():
with pytest.raises(ValueError, match="Invalid delivery_state"):
normalize_delivery_state("pending")
def test_normalize_policy_mode_accepts_strings_and_enum():
assert normalize_policy_mode("download") == "download"
assert normalize_policy_mode("REQUEST_BOOK") == "request_book"
@@ -370,6 +382,8 @@ def test_fulfil_request_queues_as_requesting_user(user_db):
)
assert fulfilled["status"] == "fulfilled"
assert fulfilled["delivery_state"] == "queued"
assert fulfilled["delivery_updated_at"] is not None
assert fulfilled["reviewed_by"] == admin["id"]
assert captured["priority"] == 0
assert captured["user_id"] == alice["id"]
@@ -411,6 +425,8 @@ def test_fulfil_book_level_request_stores_selected_release_data(user_db):
)
assert fulfilled["status"] == "fulfilled"
assert fulfilled["delivery_state"] == "queued"
assert fulfilled["delivery_updated_at"] is not None
assert fulfilled["request_level"] == "book"
assert fulfilled["release_data"]["source_id"] == "admin-picked-book-release"
assert captured["release_data"]["source_id"] == "admin-picked-book-release"
@@ -418,6 +434,50 @@ def test_fulfil_book_level_request_stores_selected_release_data(user_db):
assert captured["username"] == "alice"
def test_sync_delivery_states_from_queue_status_updates_matching_fulfilled_requests(user_db):
alice = user_db.create_user(username="alice")
bob = user_db.create_user(username="bob")
alice_request = user_db.create_request(
user_id=alice["id"],
source_hint="prowlarr",
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data=_book_data(),
release_data={"source": "prowlarr", "source_id": "alice-rel", "title": "Alice Release"},
status="fulfilled",
delivery_state="queued",
)
bob_request = user_db.create_request(
user_id=bob["id"],
source_hint="prowlarr",
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data=_book_data(),
release_data={"source": "prowlarr", "source_id": "bob-rel", "title": "Bob Release"},
status="fulfilled",
delivery_state="queued",
)
updated = sync_delivery_states_from_queue_status(
user_db,
queue_status={
"downloading": {"alice-rel": {"id": "alice-rel"}},
"complete": {"bob-rel": {"id": "bob-rel"}},
},
user_id=alice["id"],
)
assert [row["id"] for row in updated] == [alice_request["id"]]
refreshed_alice = user_db.get_request(alice_request["id"])
refreshed_bob = user_db.get_request(bob_request["id"])
assert refreshed_alice["delivery_state"] == "downloading"
assert refreshed_alice["delivery_updated_at"] is not None
assert refreshed_bob["delivery_state"] == "queued"
# ---------------------------------------------------------------------------
# book_data validation
# ---------------------------------------------------------------------------
+28
View File
@@ -62,6 +62,18 @@ class TestUserDBInitialization:
assert cursor.fetchone() is not None
conn.close()
def test_initialize_creates_activity_tables(self, user_db, db_path):
conn = sqlite3.connect(db_path)
activity_log = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='activity_log'"
).fetchone()
dismissals = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='activity_dismissals'"
).fetchone()
assert activity_log is not None
assert dismissals is not None
conn.close()
def test_initialize_creates_download_requests_indexes(self, user_db, db_path):
conn = sqlite3.connect(db_path)
rows = conn.execute(
@@ -72,6 +84,22 @@ class TestUserDBInitialization:
assert "idx_download_requests_status_created_at" in index_names
conn.close()
def test_initialize_creates_activity_indexes(self, user_db, db_path):
conn = sqlite3.connect(db_path)
rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='activity_log'"
).fetchall()
log_index_names = {row[0] for row in rows}
assert "idx_activity_log_user_terminal" in log_index_names
assert "idx_activity_log_lookup" in log_index_names
rows = conn.execute(
"SELECT name FROM sqlite_master WHERE type='index' AND tbl_name='activity_dismissals'"
).fetchall()
dismissal_index_names = {row[0] for row in rows}
assert "idx_activity_dismissals_user_dismissed_at" in dismissal_index_names
conn.close()
def test_initialize_enables_wal_mode(self, user_db, db_path):
conn = sqlite3.connect(db_path)
cursor = conn.execute("PRAGMA journal_mode")
+5 -3
View File
@@ -191,7 +191,7 @@ class TestLoginRequiredDecorator:
assert resp[0]["success"] is True
def test_settings_access_not_restricted_when_global_toggle_off(self, main_module, view):
def test_settings_access_requires_admin_even_when_legacy_toggle_off(self, main_module, view):
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
with patch(
"shelfmark.core.settings_registry.load_config_file",
@@ -201,9 +201,11 @@ class TestLoginRequiredDecorator:
main_module.session["user_id"] = "user"
main_module.session["is_admin"] = False
decorated = main_module.login_required(view)
resp = decorated()
resp = _as_response(decorated())
data = resp.get_json()
assert resp[0]["success"] is True
assert resp.status_code == 403
assert "Admin access required" in (data.get("error") or "")
def test_security_tab_always_blocks_non_admin_even_when_toggle_off(self, main_module, view):
with patch.object(main_module, "get_auth_mode", return_value="builtin"):