mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-24 22:05:20 +01:00
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:
@@ -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']}",
|
||||
)
|
||||
@@ -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)
|
||||
@@ -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] = {}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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"):
|
||||
|
||||
Reference in New Issue
Block a user