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:
Alex
2026-03-04 19:10:06 +00:00
committed by GitHub
parent bd65bccf52
commit fbe25725d3
26 changed files with 1682 additions and 1891 deletions
+3 -5
View File
@@ -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
```
+247 -103
View File
@@ -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):
-295
View File
@@ -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"]
+12 -39
View File
@@ -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)
-14
View File
@@ -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(
-41
View File
@@ -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))
+8 -149
View File
@@ -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
View File
@@ -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)
-8
View File
@@ -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."""