mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 19:11:06 +01:00
Download history refactor (#700)
- Much simpler handling of downloads in the activity sidebar, and improved storage, persistence and UI behavior. - Replace `ActivityService` with direct storage on `DownloadHistoryService` and `download_requests` and removes the activity_log/activity_dismissals tables - Simplify no-auth mode by removing the fake user row pattern, handled internally - Add local download fallback so history entries can still serve files after tasks leave the queue - Downloads, requests and history are now entirely persistent between updates / restarts, and correctly tied to each user.
This commit is contained in:
+3
-5
@@ -254,10 +254,8 @@ docker ps | grep -E "qbittorrent|transmission|deluge|nzbget|sabnzbd"
|
||||
```
|
||||
|
||||
### Stale test data
|
||||
|
||||
Restart the container to reset the in-memory queue between test runs:
|
||||
```bash
|
||||
# Clear the queue between test runs
|
||||
docker exec test-cwabd python3 -c "
|
||||
from shelfmark.core.queue import book_queue
|
||||
book_queue.clear()
|
||||
"
|
||||
docker restart test-cwabd
|
||||
```
|
||||
|
||||
@@ -39,6 +39,41 @@ def _create_user(main_module, *, prefix: str, role: str = "user") -> dict:
|
||||
return main_module.user_db.create_user(username=username, role=role)
|
||||
|
||||
|
||||
def _record_terminal_download(
|
||||
main_module,
|
||||
*,
|
||||
task_id: str,
|
||||
user_id: int | None,
|
||||
username: str | None,
|
||||
title: str = "Recorded Download",
|
||||
author: str = "Recorded Author",
|
||||
source: str = "direct_download",
|
||||
source_display_name: str = "Direct Download",
|
||||
origin: str = "direct",
|
||||
final_status: str = "complete",
|
||||
request_id: int | None = None,
|
||||
status_message: str | None = None,
|
||||
) -> None:
|
||||
main_module.download_history_service.record_terminal(
|
||||
task_id=task_id,
|
||||
user_id=user_id,
|
||||
username=username,
|
||||
request_id=request_id,
|
||||
source=source,
|
||||
source_display_name=source_display_name,
|
||||
title=title,
|
||||
author=author,
|
||||
format="epub",
|
||||
size="1 MB",
|
||||
preview=None,
|
||||
content_type="ebook",
|
||||
origin=origin,
|
||||
final_status=final_status,
|
||||
status_message=status_message,
|
||||
download_path=None,
|
||||
)
|
||||
|
||||
|
||||
def _sample_status_payload() -> dict:
|
||||
return {
|
||||
"queued": {},
|
||||
@@ -87,14 +122,15 @@ class TestActivityRoutes:
|
||||
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(
|
||||
_record_terminal_download(
|
||||
main_module,
|
||||
task_id="test-task",
|
||||
user_id=user["id"],
|
||||
item_type="download",
|
||||
item_key="download:test-task",
|
||||
username=user["username"],
|
||||
title="Dismiss Me",
|
||||
origin="requested",
|
||||
final_status="complete",
|
||||
source_id="test-task",
|
||||
snapshot={"title": "Dismiss Me"},
|
||||
request_id=12,
|
||||
status_message="Complete",
|
||||
)
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
@@ -116,7 +152,8 @@ class TestActivityRoutes:
|
||||
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 history_response.json[0]["snapshot"]["kind"] == "download"
|
||||
assert history_response.json[0]["snapshot"]["download"]["title"] == "Dismiss Me"
|
||||
|
||||
assert clear_history_response.status_code == 200
|
||||
assert clear_history_response.json["status"] == "cleared"
|
||||
@@ -125,10 +162,62 @@ class TestActivityRoutes:
|
||||
assert history_after_clear.status_code == 200
|
||||
assert history_after_clear.json == []
|
||||
|
||||
def test_clear_history_deletes_dismissed_requests_from_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": "Dismissed Request",
|
||||
"author": "Request Author",
|
||||
"provider": "openlibrary",
|
||||
"provider_id": "dismissed-request",
|
||||
},
|
||||
status="rejected",
|
||||
)
|
||||
request_key = f"request:{request_row['id']}"
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
dismiss_response = client.post(
|
||||
"/api/activity/dismiss",
|
||||
json={"item_type": "request", "item_key": request_key},
|
||||
)
|
||||
history_before_clear = 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")
|
||||
with patch.object(main_module.backend, "queue_status", return_value=_sample_status_payload()):
|
||||
snapshot_after_clear = client.get("/api/activity/snapshot")
|
||||
|
||||
assert dismiss_response.status_code == 200
|
||||
assert history_before_clear.status_code == 200
|
||||
assert any(row["item_key"] == request_key for row in history_before_clear.json)
|
||||
|
||||
assert clear_history_response.status_code == 200
|
||||
assert clear_history_response.json["status"] == "cleared"
|
||||
|
||||
assert history_after_clear.status_code == 200
|
||||
assert history_after_clear.json == []
|
||||
|
||||
assert snapshot_after_clear.status_code == 200
|
||||
assert all(row["id"] != request_row["id"] for row in snapshot_after_clear.json["requests"])
|
||||
assert {"item_type": "request", "item_key": request_key} not in snapshot_after_clear.json["dismissed"]
|
||||
|
||||
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)
|
||||
|
||||
_record_terminal_download(
|
||||
main_module,
|
||||
task_id="admin-visible-task",
|
||||
user_id=admin["id"],
|
||||
username=admin["username"],
|
||||
title="Admin Visible",
|
||||
)
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
dismiss_response = client.post(
|
||||
"/api/activity/dismiss",
|
||||
@@ -144,6 +233,52 @@ class TestActivityRoutes:
|
||||
"item_key": "download:admin-visible-task",
|
||||
} in snapshot_response.json["dismissed"]
|
||||
|
||||
def test_localdownload_falls_back_to_download_history_file(self, main_module, client, tmp_path):
|
||||
user = _create_user(main_module, prefix="reader")
|
||||
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
|
||||
|
||||
task_id = "history-localdownload-task"
|
||||
file_path = tmp_path / "history-fallback.epub"
|
||||
file_bytes = b"history download payload"
|
||||
file_path.write_bytes(file_bytes)
|
||||
|
||||
_record_terminal_download(
|
||||
main_module,
|
||||
task_id=task_id,
|
||||
user_id=user["id"],
|
||||
username=user["username"],
|
||||
title="History Local Download",
|
||||
)
|
||||
|
||||
row = main_module.download_history_service.get_by_task_id(task_id)
|
||||
assert row is not None
|
||||
assert main_module.download_history_service is not None
|
||||
main_module.download_history_service.record_terminal(
|
||||
task_id=task_id,
|
||||
user_id=user["id"],
|
||||
username=user["username"],
|
||||
request_id=row.get("request_id"),
|
||||
source=row.get("source") or "direct_download",
|
||||
source_display_name=row.get("source_display_name"),
|
||||
title=row.get("title") or "History Local Download",
|
||||
author=row.get("author"),
|
||||
format=row.get("format"),
|
||||
size=row.get("size"),
|
||||
preview=row.get("preview"),
|
||||
content_type=row.get("content_type"),
|
||||
origin=row.get("origin") or "direct",
|
||||
final_status=row.get("final_status") or "complete",
|
||||
status_message=row.get("status_message"),
|
||||
download_path=str(file_path),
|
||||
)
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
response = client.get(f"/api/localdownload?id={task_id}")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.data == file_bytes
|
||||
assert "attachment" in response.headers.get("Content-Disposition", "").lower()
|
||||
|
||||
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)
|
||||
@@ -198,13 +333,19 @@ class TestActivityRoutes:
|
||||
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)
|
||||
_record_terminal_download(
|
||||
main_module,
|
||||
task_id="emit-task",
|
||||
user_id=user["id"],
|
||||
username=user["username"],
|
||||
)
|
||||
|
||||
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"},
|
||||
json={"item_type": "download", "item_key": "download:emit-task"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
@@ -215,7 +356,15 @@ class TestActivityRoutes:
|
||||
)
|
||||
|
||||
def test_no_auth_dismiss_many_and_history_use_shared_identity(self, main_module):
|
||||
item_key = f"download:no-auth-{uuid.uuid4().hex[:10]}"
|
||||
task_id = f"no-auth-{uuid.uuid4().hex[:10]}"
|
||||
item_key = f"download:{task_id}"
|
||||
_record_terminal_download(
|
||||
main_module,
|
||||
task_id=task_id,
|
||||
user_id=None,
|
||||
username=None,
|
||||
title="No Auth",
|
||||
)
|
||||
|
||||
client_one = main_module.app.test_client()
|
||||
client_two = main_module.app.test_client()
|
||||
@@ -247,7 +396,16 @@ class TestActivityRoutes:
|
||||
stale_db_user_id = 999999999
|
||||
_set_session(client, user_id="stale-session-user", db_user_id=stale_db_user_id, is_admin=False)
|
||||
|
||||
item_key = f"download:no-auth-stale-{uuid.uuid4().hex[:8]}"
|
||||
task_id = f"no-auth-stale-{uuid.uuid4().hex[:8]}"
|
||||
item_key = f"download:{task_id}"
|
||||
_record_terminal_download(
|
||||
main_module,
|
||||
task_id=task_id,
|
||||
user_id=None,
|
||||
username=None,
|
||||
title="No Auth Stale",
|
||||
)
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="none"):
|
||||
response = client.post(
|
||||
"/api/activity/dismiss-many",
|
||||
@@ -257,10 +415,8 @@ class TestActivityRoutes:
|
||||
assert response.status_code == 200
|
||||
assert response.json["status"] == "dismissed"
|
||||
|
||||
no_auth_user = main_module.user_db.get_user(username="__shelfmark_noauth_activity__")
|
||||
assert no_auth_user is not None
|
||||
dismissals = main_module.activity_service.get_dismissal_set(no_auth_user["id"])
|
||||
assert {"item_type": "download", "item_key": item_key} in dismissals
|
||||
dismissals = main_module.download_history_service.get_dismissed_keys(user_id=None)
|
||||
assert task_id in dismissals
|
||||
|
||||
def test_no_auth_dismiss_many_uses_shared_identity_even_with_valid_session_db_user(
|
||||
self,
|
||||
@@ -275,7 +431,15 @@ class TestActivityRoutes:
|
||||
is_admin=False,
|
||||
)
|
||||
|
||||
item_key = f"download:no-auth-valid-{uuid.uuid4().hex[:8]}"
|
||||
task_id = f"no-auth-valid-{uuid.uuid4().hex[:8]}"
|
||||
item_key = f"download:{task_id}"
|
||||
_record_terminal_download(
|
||||
main_module,
|
||||
task_id=task_id,
|
||||
user_id=None,
|
||||
username=None,
|
||||
title="No Auth Valid",
|
||||
)
|
||||
other_client = main_module.app.test_client()
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="none"):
|
||||
@@ -302,61 +466,18 @@ class TestActivityRoutes:
|
||||
assert response.status_code == 403
|
||||
assert response.json["code"] == "user_identity_unavailable"
|
||||
|
||||
def test_queue_clear_does_not_set_request_delivery_state_to_cleared(self, main_module, client):
|
||||
def test_snapshot_backfills_undismissed_terminal_download_from_download_history(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(
|
||||
_record_terminal_download(
|
||||
main_module,
|
||||
task_id="expired-task-1",
|
||||
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",
|
||||
},
|
||||
},
|
||||
username=user["username"],
|
||||
title="Expired Task",
|
||||
author="Expired Author",
|
||||
status_message="Finished",
|
||||
)
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
@@ -372,25 +493,16 @@ class TestActivityRoutes:
|
||||
request_owner = _create_user(main_module, prefix="reader")
|
||||
_set_session(client, user_id=admin["username"], db_user_id=admin["id"], is_admin=True)
|
||||
|
||||
main_module.activity_service.record_terminal_snapshot(
|
||||
_record_terminal_download(
|
||||
main_module,
|
||||
task_id="cross-user-expired-task",
|
||||
user_id=request_owner["id"],
|
||||
item_type="download",
|
||||
item_key="download:cross-user-expired-task",
|
||||
username=request_owner["username"],
|
||||
title="Cross User Task",
|
||||
author="Another User",
|
||||
origin="requested",
|
||||
final_status="complete",
|
||||
source_id="cross-user-expired-task",
|
||||
snapshot={
|
||||
"kind": "download",
|
||||
"download": {
|
||||
"id": "cross-user-expired-task",
|
||||
"title": "Cross User Task",
|
||||
"author": "Another User",
|
||||
"added_time": 123,
|
||||
"status_message": "Finished",
|
||||
"source": "direct_download",
|
||||
"user_id": request_owner["id"],
|
||||
},
|
||||
},
|
||||
request_id=123,
|
||||
status_message="Finished",
|
||||
)
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
@@ -405,6 +517,14 @@ class TestActivityRoutes:
|
||||
user = _create_user(main_module, prefix="reader")
|
||||
_set_session(client, user_id=user["username"], db_user_id=user["id"], is_admin=False)
|
||||
|
||||
_record_terminal_download(
|
||||
main_module,
|
||||
task_id="task-reused-1",
|
||||
user_id=user["id"],
|
||||
username=user["username"],
|
||||
title="Reused Task",
|
||||
)
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
dismiss_response = client.post(
|
||||
"/api/activity/dismiss",
|
||||
@@ -431,12 +551,20 @@ class TestActivityRoutes:
|
||||
"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"]) == []
|
||||
assert "task-reused-1" not in main_module.download_history_service.get_dismissed_keys(user_id=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")
|
||||
|
||||
_record_terminal_download(
|
||||
main_module,
|
||||
task_id="shared-task",
|
||||
user_id=user_one["id"],
|
||||
username=user_one["username"],
|
||||
title="Shared Task",
|
||||
)
|
||||
|
||||
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(
|
||||
@@ -457,12 +585,26 @@ class TestActivityRoutes:
|
||||
def test_admin_request_dismissal_is_shared_across_admin_users(self, main_module, client):
|
||||
admin_one = _create_user(main_module, prefix="admin-one", role="admin")
|
||||
admin_two = _create_user(main_module, prefix="admin-two", role="admin")
|
||||
request_owner = _create_user(main_module, prefix="request-owner")
|
||||
request_row = main_module.user_db.create_request(
|
||||
user_id=request_owner["id"],
|
||||
content_type="ebook",
|
||||
request_level="book",
|
||||
policy_mode="request_book",
|
||||
book_data={
|
||||
"title": "Dismiss Me Request",
|
||||
"author": "Request Author",
|
||||
"provider": "openlibrary",
|
||||
"provider_id": "dismiss-request-1",
|
||||
},
|
||||
status="rejected",
|
||||
)
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
_set_session(client, user_id=admin_one["username"], db_user_id=admin_one["id"], is_admin=True)
|
||||
dismiss_response = client.post(
|
||||
"/api/activity/dismiss",
|
||||
json={"item_type": "request", "item_key": "request:999999"},
|
||||
json={"item_type": "request", "item_key": f"request:{request_row['id']}"},
|
||||
)
|
||||
assert dismiss_response.status_code == 200
|
||||
|
||||
@@ -472,31 +614,25 @@ class TestActivityRoutes:
|
||||
history_response = client.get("/api/activity/history?limit=50&offset=0")
|
||||
|
||||
assert snapshot_response.status_code == 200
|
||||
assert {"item_type": "request", "item_key": "request:999999"} in snapshot_response.json["dismissed"]
|
||||
assert {"item_type": "request", "item_key": f"request:{request_row['id']}"} in snapshot_response.json["dismissed"]
|
||||
|
||||
assert history_response.status_code == 200
|
||||
assert any(row["item_key"] == "request:999999" for row in history_response.json)
|
||||
assert any(row["item_key"] == f"request:{request_row['id']}" for row in history_response.json)
|
||||
|
||||
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(
|
||||
task_id = f"history-task-{index}"
|
||||
_record_terminal_download(
|
||||
main_module,
|
||||
task_id=task_id,
|
||||
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,
|
||||
username=user["username"],
|
||||
title=f"History Task {index}",
|
||||
)
|
||||
main_module.download_history_service.dismiss(task_id=task_id, user_id=user["id"])
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
page_one = client.get("/api/activity/history?limit=2&offset=0")
|
||||
@@ -523,6 +659,12 @@ class TestActivityRoutes:
|
||||
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)
|
||||
_record_terminal_download(
|
||||
main_module,
|
||||
task_id="test-task-many",
|
||||
user_id=user["id"],
|
||||
username=user["username"],
|
||||
)
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
with patch.object(main_module.ws_manager, "is_enabled", return_value=True):
|
||||
@@ -546,11 +688,13 @@ class TestActivityRoutes:
|
||||
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(
|
||||
_record_terminal_download(
|
||||
main_module,
|
||||
task_id="history-clear-task",
|
||||
user_id=user["id"],
|
||||
item_type="download",
|
||||
item_key="download:history-clear-task",
|
||||
username=user["username"],
|
||||
)
|
||||
main_module.download_history_service.dismiss(task_id="history-clear-task", user_id=user["id"])
|
||||
|
||||
with patch.object(main_module, "get_auth_mode", return_value="builtin"):
|
||||
with patch.object(main_module.ws_manager, "is_enabled", return_value=True):
|
||||
|
||||
@@ -1,295 +0,0 @@
|
||||
"""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"],
|
||||
owner_user_id=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"},
|
||||
}
|
||||
|
||||
def test_get_undismissed_terminal_downloads_can_span_owners_for_admin_viewer(
|
||||
self,
|
||||
user_db,
|
||||
activity_service,
|
||||
):
|
||||
viewer = user_db.create_user(username="admin-viewer", role="admin")
|
||||
owner_one = user_db.create_user(username="owner-one")
|
||||
owner_two = user_db.create_user(username="owner-two")
|
||||
|
||||
activity_service.record_terminal_snapshot(
|
||||
user_id=owner_one["id"],
|
||||
item_type="download",
|
||||
item_key="download:owner-one-task",
|
||||
origin="direct",
|
||||
final_status="complete",
|
||||
source_id="owner-one-task",
|
||||
terminal_at="2026-01-01T10:00:00+00:00",
|
||||
snapshot={"kind": "download", "download": {"id": "owner-one-task"}},
|
||||
)
|
||||
activity_service.record_terminal_snapshot(
|
||||
user_id=owner_two["id"],
|
||||
item_type="download",
|
||||
item_key="download:owner-two-task",
|
||||
origin="direct",
|
||||
final_status="complete",
|
||||
source_id="owner-two-task",
|
||||
terminal_at="2026-01-01T11:00:00+00:00",
|
||||
snapshot={"kind": "download", "download": {"id": "owner-two-task"}},
|
||||
)
|
||||
activity_service.dismiss_item(
|
||||
user_id=viewer["id"],
|
||||
item_type="download",
|
||||
item_key="download:owner-two-task",
|
||||
)
|
||||
|
||||
all_owner_rows = activity_service.get_undismissed_terminal_downloads(
|
||||
viewer["id"],
|
||||
owner_user_id=None,
|
||||
)
|
||||
assert [row["item_key"] for row in all_owner_rows] == ["download:owner-one-task"]
|
||||
|
||||
owner_one_rows = activity_service.get_undismissed_terminal_downloads(
|
||||
viewer["id"],
|
||||
owner_user_id=owner_one["id"],
|
||||
)
|
||||
assert [row["item_key"] for row in owner_one_rows] == ["download:owner-one-task"]
|
||||
@@ -3,7 +3,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib
|
||||
import json
|
||||
import uuid
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -28,19 +27,19 @@ def _create_user(main_module, *, prefix: str) -> dict:
|
||||
return main_module.user_db.create_user(username=username, role="user")
|
||||
|
||||
|
||||
def _read_activity_log_row(main_module, snapshot_id: int):
|
||||
def _read_download_history_row(main_module, task_id: str):
|
||||
conn = main_module.user_db._connect()
|
||||
try:
|
||||
return conn.execute(
|
||||
"SELECT * FROM activity_log WHERE id = ?",
|
||||
(snapshot_id,),
|
||||
"SELECT * FROM download_history WHERE task_id = ?",
|
||||
(task_id,),
|
||||
).fetchone()
|
||||
finally:
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestTerminalSnapshotCapture:
|
||||
def test_complete_transition_records_direct_snapshot_and_survives_queue_clear(self, main_module):
|
||||
def test_complete_transition_records_direct_snapshot(self, main_module):
|
||||
user = _create_user(main_module, prefix="snap-direct")
|
||||
task_id = f"direct-{uuid.uuid4().hex[:8]}"
|
||||
task = DownloadTask(
|
||||
@@ -54,25 +53,15 @@ class TestTerminalSnapshotCapture:
|
||||
|
||||
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
|
||||
row = _read_download_history_row(main_module, task_id)
|
||||
assert row 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)
|
||||
row = _read_download_history_row(main_module, task_id)
|
||||
assert row is not None
|
||||
assert row["user_id"] == user["id"]
|
||||
assert row["item_key"] == item_key
|
||||
assert row["task_id"] == task_id
|
||||
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)
|
||||
|
||||
@@ -109,20 +98,11 @@ class TestTerminalSnapshotCapture:
|
||||
|
||||
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)
|
||||
row = _read_download_history_row(main_module, task_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"]
|
||||
assert row["task_id"] == task_id
|
||||
finally:
|
||||
main_module.backend.book_queue.cancel_download(task_id)
|
||||
|
||||
@@ -143,16 +123,9 @@ class TestTerminalSnapshotCapture:
|
||||
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)
|
||||
row = _read_download_history_row(main_module, task_id)
|
||||
assert row is not None
|
||||
snapshot = json.loads(row["snapshot_json"])
|
||||
assert snapshot["download"]["status_message"] == "Complete"
|
||||
assert row["status_message"] == "Complete"
|
||||
finally:
|
||||
main_module.backend.book_queue.cancel_download(task_id)
|
||||
|
||||
|
||||
@@ -106,20 +106,6 @@ class TestAdminUsersListEndpoint:
|
||||
users = resp.json
|
||||
assert "password_hash" not in users[0]
|
||||
|
||||
def test_list_users_hides_internal_no_auth_activity_user(self, admin_client, user_db):
|
||||
user_db.create_user(
|
||||
username="__shelfmark_noauth_activity__",
|
||||
display_name="No-auth Activity",
|
||||
role="admin",
|
||||
)
|
||||
user_db.create_user(username="alice", email="alice@example.com")
|
||||
|
||||
resp = admin_client.get("/api/admin/users")
|
||||
assert resp.status_code == 200
|
||||
usernames = [u["username"] for u in resp.json]
|
||||
assert "__shelfmark_noauth_activity__" not in usernames
|
||||
assert "alice" in usernames
|
||||
|
||||
def test_list_users_includes_auth_source_and_is_active(self, admin_client, user_db):
|
||||
user_db.create_user(username="local_user", auth_source="builtin")
|
||||
user_db.create_user(
|
||||
|
||||
@@ -117,47 +117,6 @@ class TestQueueFilterByUser:
|
||||
assert "book-1" not in all_tasks
|
||||
assert "book-2" in all_tasks
|
||||
|
||||
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_excludes_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 == 1
|
||||
|
||||
status = q.get_status()
|
||||
all_tasks = {}
|
||||
for tasks_by_status in status.values():
|
||||
all_tasks.update(tasks_by_status)
|
||||
|
||||
assert "legacy-book" in all_tasks
|
||||
assert "user-book" not in all_tasks
|
||||
|
||||
def test_enqueue_existing_deduplicates_queue_entries(self):
|
||||
q = BookQueue()
|
||||
q.add(self._make_task("book-1", user_id=1))
|
||||
|
||||
@@ -59,14 +59,6 @@ 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")
|
||||
@@ -221,17 +213,10 @@ 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"
|
||||
updated = main_module.user_db.get_request(request_id)
|
||||
assert updated is not None
|
||||
assert updated["user_id"] == user["id"]
|
||||
assert updated["status"] == "cancelled"
|
||||
|
||||
def test_create_request_emits_websocket_events(self, main_module, client):
|
||||
user = _create_user(main_module, prefix="reader")
|
||||
@@ -652,17 +637,10 @@ class TestRequestRoutes:
|
||||
assert reject_resp.json["status"] == "rejected"
|
||||
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"
|
||||
updated = main_module.user_db.get_request(request_id)
|
||||
assert updated is not None
|
||||
assert updated["user_id"] == user["id"]
|
||||
assert updated["status"] == "rejected"
|
||||
|
||||
def test_admin_reject_emits_update_to_user_and_admin_rooms(self, main_module, client):
|
||||
user = _create_user(main_module, prefix="reader")
|
||||
@@ -1986,122 +1964,3 @@ 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()
|
||||
|
||||
+366
-7
@@ -62,7 +62,15 @@ class TestUserDBInitialization:
|
||||
assert cursor.fetchone() is not None
|
||||
conn.close()
|
||||
|
||||
def test_initialize_creates_activity_tables(self, user_db, db_path):
|
||||
def test_initialize_creates_download_history_table(self, user_db, db_path):
|
||||
conn = sqlite3.connect(db_path)
|
||||
cursor = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='download_history'"
|
||||
)
|
||||
assert cursor.fetchone() is not None
|
||||
conn.close()
|
||||
|
||||
def test_initialize_does_not_create_legacy_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'"
|
||||
@@ -70,8 +78,8 @@ class TestUserDBInitialization:
|
||||
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
|
||||
assert activity_log is None
|
||||
assert dismissals is None
|
||||
conn.close()
|
||||
|
||||
def test_initialize_creates_download_requests_indexes(self, user_db, db_path):
|
||||
@@ -84,20 +92,30 @@ 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):
|
||||
def test_initialize_does_not_create_legacy_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
|
||||
assert "idx_activity_log_user_terminal" not in log_index_names
|
||||
assert "idx_activity_log_lookup" not 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
|
||||
assert "idx_activity_dismissals_user_dismissed_at" not in dismissal_index_names
|
||||
conn.close()
|
||||
|
||||
def test_initialize_creates_download_history_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='download_history'"
|
||||
).fetchall()
|
||||
index_names = {row[0] for row in rows}
|
||||
assert "idx_download_history_user_status" in index_names
|
||||
assert "idx_download_history_dismissed" in index_names
|
||||
conn.close()
|
||||
|
||||
def test_initialize_enables_wal_mode(self, user_db, db_path):
|
||||
@@ -226,6 +244,226 @@ class TestUserDBInitialization:
|
||||
assert "REQUESTS_ALLOW_NOTES" not in column_names
|
||||
conn.close()
|
||||
|
||||
def test_initialize_migrates_download_requests_dismissed_at_column(self, db_path):
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT,
|
||||
display_name TEXT,
|
||||
password_hash TEXT,
|
||||
oidc_subject TEXT UNIQUE,
|
||||
auth_source TEXT NOT NULL DEFAULT 'builtin',
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE user_settings (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
settings_json TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE download_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
delivery_state TEXT NOT NULL DEFAULT 'none',
|
||||
source_hint TEXT,
|
||||
content_type TEXT NOT NULL,
|
||||
request_level TEXT NOT NULL,
|
||||
policy_mode TEXT NOT NULL,
|
||||
book_data TEXT NOT NULL,
|
||||
release_data TEXT,
|
||||
note TEXT,
|
||||
admin_note TEXT,
|
||||
reviewed_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
reviewed_at TIMESTAMP,
|
||||
delivery_updated_at TIMESTAMP
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
db = UserDB(db_path)
|
||||
db.initialize()
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
columns = conn.execute("PRAGMA table_info(download_requests)").fetchall()
|
||||
column_names = {str(col["name"]) for col in columns}
|
||||
assert "dismissed_at" in column_names
|
||||
conn.close()
|
||||
|
||||
def test_initialize_migrates_existing_install_without_backfill(self, db_path):
|
||||
"""Upgrade path: preserve existing rows and add new schema without retroactive history backfill."""
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.executescript(
|
||||
"""
|
||||
CREATE TABLE users (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
username TEXT UNIQUE NOT NULL,
|
||||
email TEXT,
|
||||
display_name TEXT,
|
||||
password_hash TEXT,
|
||||
oidc_subject TEXT UNIQUE,
|
||||
auth_source TEXT NOT NULL DEFAULT 'builtin',
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE user_settings (
|
||||
user_id INTEGER PRIMARY KEY REFERENCES users(id) ON DELETE CASCADE,
|
||||
settings_json TEXT NOT NULL DEFAULT '{}'
|
||||
);
|
||||
|
||||
CREATE TABLE download_requests (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
status TEXT NOT NULL DEFAULT 'pending',
|
||||
delivery_state TEXT NOT NULL DEFAULT 'none',
|
||||
source_hint TEXT,
|
||||
content_type TEXT NOT NULL,
|
||||
request_level TEXT NOT NULL,
|
||||
policy_mode TEXT NOT NULL,
|
||||
book_data TEXT NOT NULL,
|
||||
release_data TEXT,
|
||||
note TEXT,
|
||||
admin_note TEXT,
|
||||
reviewed_by INTEGER REFERENCES users(id),
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
reviewed_at TIMESTAMP,
|
||||
delivery_updated_at TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE activity_log (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER REFERENCES users(id) ON DELETE CASCADE,
|
||||
item_type TEXT NOT NULL,
|
||||
item_key TEXT NOT NULL,
|
||||
request_id INTEGER,
|
||||
source_id TEXT,
|
||||
origin TEXT NOT NULL,
|
||||
final_status TEXT NOT NULL,
|
||||
snapshot_json TEXT NOT NULL,
|
||||
terminal_at TIMESTAMP NOT NULL,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE activity_dismissals (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
item_type TEXT NOT NULL,
|
||||
item_key TEXT NOT NULL,
|
||||
activity_log_id INTEGER REFERENCES activity_log(id) ON DELETE SET NULL,
|
||||
dismissed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
UNIQUE(user_id, item_type, item_key)
|
||||
);
|
||||
"""
|
||||
)
|
||||
conn.execute("INSERT INTO users (id, username, role) VALUES (?, ?, ?)", (1, "legacy-user", "user"))
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO download_requests (
|
||||
id,
|
||||
user_id,
|
||||
status,
|
||||
delivery_state,
|
||||
content_type,
|
||||
request_level,
|
||||
policy_mode,
|
||||
book_data
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(11, 1, "fulfilled", "complete", "ebook", "book", "request_book", '{"title":"Legacy Book"}'),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO activity_log (
|
||||
id,
|
||||
user_id,
|
||||
item_type,
|
||||
item_key,
|
||||
request_id,
|
||||
source_id,
|
||||
origin,
|
||||
final_status,
|
||||
snapshot_json,
|
||||
terminal_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
""",
|
||||
(
|
||||
21,
|
||||
1,
|
||||
"download",
|
||||
"download:legacy-task",
|
||||
11,
|
||||
"legacy-task",
|
||||
"request",
|
||||
"complete",
|
||||
'{"kind":"download","download":{"id":"legacy-task","title":"Legacy Book"}}',
|
||||
"2026-01-01T00:00:00+00:00",
|
||||
),
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO activity_dismissals (
|
||||
user_id,
|
||||
item_type,
|
||||
item_key,
|
||||
activity_log_id,
|
||||
dismissed_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
""",
|
||||
(1, "download", "download:legacy-task", 21, "2026-01-02T00:00:00+00:00"),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
|
||||
from shelfmark.core.user_db import UserDB
|
||||
|
||||
db = UserDB(db_path)
|
||||
db.initialize()
|
||||
|
||||
conn = sqlite3.connect(db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
|
||||
request_row = conn.execute(
|
||||
"SELECT id, user_id, status FROM download_requests WHERE id = 11"
|
||||
).fetchone()
|
||||
assert request_row is not None
|
||||
assert request_row["user_id"] == 1
|
||||
assert request_row["status"] == "fulfilled"
|
||||
|
||||
request_columns = conn.execute("PRAGMA table_info(download_requests)").fetchall()
|
||||
request_column_names = {str(col["name"]) for col in request_columns}
|
||||
assert "dismissed_at" in request_column_names
|
||||
|
||||
history_table = conn.execute(
|
||||
"SELECT name FROM sqlite_master WHERE type='table' AND name='download_history'"
|
||||
).fetchone()
|
||||
assert history_table is not None
|
||||
|
||||
# No retroactive copy from legacy activity tables in the no-backfill plan.
|
||||
history_count = conn.execute("SELECT COUNT(*) AS count FROM download_history").fetchone()["count"]
|
||||
assert history_count == 0
|
||||
|
||||
legacy_activity_rows = conn.execute("SELECT COUNT(*) AS count FROM activity_log").fetchone()["count"]
|
||||
legacy_dismissal_rows = conn.execute(
|
||||
"SELECT COUNT(*) AS count FROM activity_dismissals"
|
||||
).fetchone()["count"]
|
||||
assert legacy_activity_rows == 1
|
||||
assert legacy_dismissal_rows == 1
|
||||
conn.close()
|
||||
|
||||
|
||||
class TestUserCRUD:
|
||||
"""Tests for user create, read, update, delete operations."""
|
||||
@@ -738,3 +976,124 @@ class TestDownloadRequests:
|
||||
user_db.delete_user(user["id"])
|
||||
|
||||
assert user_db.get_request(created["id"]) is None
|
||||
|
||||
def test_list_dismissed_requests_scopes_by_user(self, user_db):
|
||||
alice = user_db.create_user(username="alice")
|
||||
bob = user_db.create_user(username="bob")
|
||||
first = user_db.create_request(
|
||||
user_id=alice["id"],
|
||||
content_type="ebook",
|
||||
request_level="book",
|
||||
policy_mode="request_book",
|
||||
book_data=self._book_data(),
|
||||
)
|
||||
second = user_db.create_request(
|
||||
user_id=bob["id"],
|
||||
content_type="ebook",
|
||||
request_level="book",
|
||||
policy_mode="request_book",
|
||||
book_data=self._book_data(),
|
||||
)
|
||||
third = user_db.create_request(
|
||||
user_id=alice["id"],
|
||||
content_type="ebook",
|
||||
request_level="book",
|
||||
policy_mode="request_book",
|
||||
book_data=self._book_data(),
|
||||
)
|
||||
|
||||
user_db.update_request(first["id"], dismissed_at="2026-01-01T10:00:00+00:00")
|
||||
user_db.update_request(second["id"], dismissed_at="2026-01-01T12:00:00+00:00")
|
||||
user_db.update_request(third["id"], dismissed_at="2026-01-01T11:00:00+00:00")
|
||||
|
||||
all_rows = user_db.list_dismissed_requests(user_id=None)
|
||||
assert [row["id"] for row in all_rows] == [second["id"], third["id"], first["id"]]
|
||||
|
||||
alice_rows = user_db.list_dismissed_requests(user_id=alice["id"])
|
||||
assert [row["id"] for row in alice_rows] == [third["id"], first["id"]]
|
||||
|
||||
bob_rows = user_db.list_dismissed_requests(user_id=bob["id"])
|
||||
assert [row["id"] for row in bob_rows] == [second["id"]]
|
||||
|
||||
def test_clear_request_dismissals_scopes_by_user(self, 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"],
|
||||
content_type="ebook",
|
||||
request_level="book",
|
||||
policy_mode="request_book",
|
||||
book_data=self._book_data(),
|
||||
)
|
||||
bob_request = user_db.create_request(
|
||||
user_id=bob["id"],
|
||||
content_type="ebook",
|
||||
request_level="book",
|
||||
policy_mode="request_book",
|
||||
book_data=self._book_data(),
|
||||
)
|
||||
|
||||
user_db.update_request(alice_request["id"], dismissed_at="2026-01-01T10:00:00+00:00")
|
||||
user_db.update_request(bob_request["id"], dismissed_at="2026-01-01T11:00:00+00:00")
|
||||
|
||||
cleared_alice = user_db.clear_request_dismissals(user_id=alice["id"])
|
||||
assert cleared_alice == 1
|
||||
assert user_db.get_request(alice_request["id"])["dismissed_at"] is None
|
||||
assert user_db.get_request(bob_request["id"])["dismissed_at"] is not None
|
||||
|
||||
cleared_all = user_db.clear_request_dismissals(user_id=None)
|
||||
assert cleared_all == 1
|
||||
assert user_db.get_request(bob_request["id"])["dismissed_at"] is None
|
||||
|
||||
def test_delete_dismissed_requests_scopes_by_user_and_only_deletes_terminal(self, user_db):
|
||||
alice = user_db.create_user(username="alice")
|
||||
bob = user_db.create_user(username="bob")
|
||||
alice_rejected = user_db.create_request(
|
||||
user_id=alice["id"],
|
||||
content_type="ebook",
|
||||
request_level="book",
|
||||
policy_mode="request_book",
|
||||
book_data=self._book_data(),
|
||||
status="rejected",
|
||||
)
|
||||
bob_fulfilled = user_db.create_request(
|
||||
user_id=bob["id"],
|
||||
content_type="ebook",
|
||||
request_level="book",
|
||||
policy_mode="request_book",
|
||||
book_data=self._book_data(),
|
||||
status="fulfilled",
|
||||
)
|
||||
alice_pending = user_db.create_request(
|
||||
user_id=alice["id"],
|
||||
content_type="ebook",
|
||||
request_level="book",
|
||||
policy_mode="request_book",
|
||||
book_data=self._book_data(),
|
||||
status="pending",
|
||||
)
|
||||
|
||||
user_db.update_request(alice_rejected["id"], dismissed_at="2026-01-01T10:00:00+00:00")
|
||||
user_db.update_request(bob_fulfilled["id"], dismissed_at="2026-01-01T11:00:00+00:00")
|
||||
user_db.update_request(alice_pending["id"], dismissed_at="2026-01-01T12:00:00+00:00")
|
||||
|
||||
deleted_alice = user_db.delete_dismissed_requests(user_id=alice["id"])
|
||||
assert deleted_alice == 1
|
||||
assert user_db.get_request(alice_rejected["id"]) is None
|
||||
assert user_db.get_request(alice_pending["id"]) is not None
|
||||
assert user_db.get_request(bob_fulfilled["id"]) is not None
|
||||
|
||||
deleted_all = user_db.delete_dismissed_requests(user_id=None)
|
||||
assert deleted_all == 1
|
||||
assert user_db.get_request(bob_fulfilled["id"]) is None
|
||||
assert user_db.get_request(alice_pending["id"]) is not None
|
||||
|
||||
def test_request_dismissal_helpers_validate_user_scope(self, user_db):
|
||||
with pytest.raises(ValueError, match="user_id must be a positive integer"):
|
||||
user_db.list_dismissed_requests(user_id=0)
|
||||
|
||||
with pytest.raises(ValueError, match="user_id must be a positive integer"):
|
||||
user_db.clear_request_dismissals(user_id=-1)
|
||||
|
||||
with pytest.raises(ValueError, match="user_id must be a positive integer"):
|
||||
user_db.delete_dismissed_requests(user_id=0)
|
||||
|
||||
@@ -207,14 +207,6 @@ class TestQueueEndpoint:
|
||||
else:
|
||||
assert isinstance(data, list)
|
||||
|
||||
def test_clear_queue(self, api_client: APIClient, download_tracker: DownloadTracker):
|
||||
"""Test clearing the queue."""
|
||||
resp = api_client.delete("/api/queue/clear")
|
||||
|
||||
# Should succeed (may be 200 or 204)
|
||||
assert resp.status_code in [200, 204]
|
||||
|
||||
|
||||
@pytest.mark.e2e
|
||||
class TestSettingsEndpoint:
|
||||
"""Tests for settings endpoints."""
|
||||
|
||||
Reference in New Issue
Block a user