Files
shelfmark/tests/core/test_user_db.py
T
splitsec2andCaliBrain 72b5d04939 perf: cut repeated work on the status and activity paths (#1545)
Two small cuts to work that repeats on every poll or request.

The delivery-state sync behind /api/status and the activity snapshot
listed every fulfilled request (SELECT * plus a JSON parse of book_data
and release_data per row) before it looked at the queue. That history is
never purged, so an idle instance paid for it on every poll. It now
returns straight away when no queue bucket holds a task, since nothing
could map to a request then. Results and emitted updates are unchanged.
The activity snapshot gains less from this, because its status merges
download history rows, so it is usually not empty.

In proxy auth mode the before-request hook looked the user up by name
and by session id on every API call, cover images included, each on a
new SQLite connection. UserDB.get_user_cached answers repeat lookups
from memory for up to five seconds, bounded at 256 entries. create_user,
update_user and delete_user drop the cache as soon as they commit, and a
lookup that overlaps a write is not stored, so a role change or deletion
made in the app applies on the next request. Changes made outside that
UserDB instance (another process, or the separate instances some helpers
open) show within the five seconds. Only the middleware uses the cached
path; every other get_user caller is unchanged.

Each change has a test that failed before it: one counts list_requests
calls for an empty queue, the others count get_user calls across
repeated lookups and middleware runs, check expiry with a patched clock,
and check that writes and caller mutations do not leave a stale row
behind. The full suite passes (4432 passed, 48 skipped, e2e excluded),
as do ruff and basedpyright on the changed files.

I also looked at throttling the full status_update broadcast when only
the progress message changes during torrent and usenet polling, and left
it out. Those handlers poll every 2 seconds and
DOWNLOAD_PROGRESS_UPDATE_INTERVAL defaults to 1 second, so a throttle on
that interval would skip nothing at the default setting. A longer window
would slow the speed and ETA text in the UI, and without a trailing
flush it would hide the last message of a quick burst, such as "Waiting
for bot response" arriving just after "Requesting file from bot", for
the whole wait that follows.

---------

Co-authored-by: CaliBrain <calibrain@l4n.xyz>
2026-10-22 20:27:07 -04:00

1127 lines
42 KiB
Python

"""
Tests for SQLite user database.
Tests CRUD operations on users and user_settings tables.
"""
import os
import sqlite3
import tempfile
import pytest
@pytest.fixture
def db_path():
"""Create a temporary database path."""
with tempfile.TemporaryDirectory() as tmpdir:
yield os.path.join(tmpdir, "shelfmark.db")
@pytest.fixture
def user_db(db_path):
"""Create a UserDB instance with a temporary database."""
from shelfmark.core.user_db import UserDB
db = UserDB(db_path)
db.initialize()
return db
class TestUserDBInitialization:
"""Tests for database creation and schema setup."""
def test_initialize_creates_database_file(self, db_path):
from shelfmark.core.user_db import UserDB
db = UserDB(db_path)
db.initialize()
assert os.path.exists(db_path)
def test_initialize_creates_users_table(self, user_db, db_path):
conn = sqlite3.connect(db_path)
cursor = conn.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='users'")
assert cursor.fetchone() is not None
conn.close()
def test_initialize_creates_user_settings_table(self, user_db, db_path):
conn = sqlite3.connect(db_path)
cursor = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='user_settings'"
)
assert cursor.fetchone() is not None
conn.close()
def test_initialize_creates_download_requests_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_requests'"
)
assert cursor.fetchone() is not None
conn.close()
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_creates_activity_view_state_table(self, user_db, db_path):
conn = sqlite3.connect(db_path)
cursor = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='activity_view_state'"
)
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'"
).fetchone()
dismissals = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='activity_dismissals'"
).fetchone()
assert activity_log is None
assert dismissals is None
conn.close()
def test_initialize_creates_download_requests_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_requests'"
).fetchall()
index_names = {row[0] for row in rows}
assert "idx_download_requests_user_status_created_at" in index_names
assert "idx_download_requests_status_created_at" in index_names
conn.close()
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" 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" 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_recent" in index_names
conn.close()
def test_initialize_creates_activity_view_state_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_view_state'"
).fetchall()
index_names = {row[0] for row in rows}
assert "idx_activity_view_state_history" in index_names
assert "idx_activity_view_state_hidden" in index_names
conn.close()
def test_initialize_enables_wal_mode(self, user_db, db_path):
conn = sqlite3.connect(db_path)
cursor = conn.execute("PRAGMA journal_mode")
mode = cursor.fetchone()[0]
assert mode == "wal"
conn.close()
def test_initialize_is_idempotent(self, db_path):
from shelfmark.core.user_db import UserDB
db = UserDB(db_path)
db.initialize()
db.initialize() # Should not raise
assert os.path.exists(db_path)
def test_initialize_migrates_auth_source_column_and_backfills(self, db_path):
"""Existing DBs without auth_source should be migrated in place."""
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,
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 '{}'
);
"""
)
conn.execute(
"INSERT INTO users (username, password_hash, oidc_subject, role) VALUES (?, ?, ?, ?)",
("local_admin", "hash", None, "admin"),
)
conn.execute(
"INSERT INTO users (username, oidc_subject, role) VALUES (?, ?, ?)",
("oidc_user", "sub-123", "user"),
)
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(users)").fetchall()
assert "auth_source" in {str(c["name"]) for c in columns}
rows = conn.execute("SELECT username, auth_source FROM users ORDER BY username").fetchall()
by_username = {r["username"]: r["auth_source"] for r in rows}
assert by_username["local_admin"] == "builtin"
assert by_username["oidc_user"] == "oidc"
conn.close()
def test_initialize_preserves_existing_users_and_user_settings_rows(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,
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 '{}'
);
"""
)
conn.execute(
"INSERT INTO users (id, username, email, role) VALUES (?, ?, ?, ?)",
(1, "legacy-user", "legacy@example.com", "user"),
)
conn.execute(
"INSERT INTO user_settings (user_id, settings_json) VALUES (?, ?)",
(1, '{"DESTINATION":"/books/legacy"}'),
)
conn.commit()
conn.close()
from shelfmark.core.user_db import UserDB
db = UserDB(db_path)
db.initialize()
db.initialize()
conn = sqlite3.connect(db_path)
user_row = conn.execute("SELECT username, email FROM users WHERE id = 1").fetchone()
settings_row = conn.execute(
"SELECT settings_json FROM user_settings WHERE user_id = 1"
).fetchone()
assert user_row == ("legacy-user", "legacy@example.com")
assert settings_row == ('{"DESTINATION":"/books/legacy"}',)
conn.close()
def test_initialize_does_not_add_policy_columns_to_users_table(self, user_db, db_path):
conn = sqlite3.connect(db_path)
conn.row_factory = sqlite3.Row
columns = conn.execute("PRAGMA table_info(users)").fetchall()
column_names = {str(col["name"]) for col in columns}
assert "REQUESTS_ENABLED" not in column_names
assert "REQUEST_POLICY_DEFAULT_EBOOK" not in column_names
assert "REQUEST_POLICY_DEFAULT_AUDIOBOOK" not in column_names
assert "REQUEST_POLICY_RULES" not in column_names
assert "MAX_PENDING_REQUESTS_PER_USER" not in column_names
assert "REQUESTS_ALLOW_NOTES" not in column_names
conn.close()
def test_initialize_does_not_add_dismissed_at_to_download_requests(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" not 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" not 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
view_state_table = conn.execute(
"SELECT name FROM sqlite_master WHERE type='table' AND name='activity_view_state'"
).fetchone()
assert view_state_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."""
def test_create_user(self, user_db):
user = user_db.create_user(
username="john",
email="john@example.com",
display_name="John Doe",
)
assert user["id"] is not None
assert user["username"] == "john"
assert user["email"] == "john@example.com"
assert user["display_name"] == "John Doe"
assert user["auth_source"] == "builtin"
assert user["role"] == "user"
def test_create_user_with_password(self, user_db):
user = user_db.create_user(
username="admin",
password_hash="hashed_pw",
role="admin",
)
assert user["role"] == "admin"
assert user["password_hash"] == "hashed_pw"
def test_create_user_with_oidc_subject(self, user_db):
user = user_db.create_user(
username="oidcuser",
oidc_subject="sub-12345",
email="oidc@example.com",
auth_source="oidc",
)
assert user["oidc_subject"] == "sub-12345"
assert user["auth_source"] == "oidc"
def test_create_user_with_invalid_auth_source_fails(self, user_db):
with pytest.raises(ValueError, match="Invalid auth_source"):
user_db.create_user(username="john", auth_source="not-real")
def test_create_duplicate_username_fails(self, user_db):
user_db.create_user(username="john")
with pytest.raises(ValueError, match="already exists"):
user_db.create_user(username="john")
def test_create_duplicate_oidc_subject_fails(self, user_db):
user_db.create_user(username="user1", oidc_subject="sub-123")
with pytest.raises(ValueError, match="already exists"):
user_db.create_user(username="user2", oidc_subject="sub-123")
def test_get_user_by_id(self, user_db):
created = user_db.create_user(username="john")
fetched = user_db.get_user(user_id=created["id"])
assert fetched["username"] == "john"
def test_get_user_by_username(self, user_db):
user_db.create_user(username="john", email="john@example.com")
fetched = user_db.get_user(username="john")
assert fetched["email"] == "john@example.com"
def test_get_user_by_oidc_subject(self, user_db):
user_db.create_user(username="john", oidc_subject="sub-123")
fetched = user_db.get_user(oidc_subject="sub-123")
assert fetched["username"] == "john"
def test_get_nonexistent_user_returns_none(self, user_db):
assert user_db.get_user(username="nobody") is None
def test_update_user(self, user_db):
user = user_db.create_user(username="john", role="user")
user_db.update_user(
user["id"],
username="jane",
role="admin",
email="new@example.com",
auth_source="proxy",
)
updated = user_db.get_user(user_id=user["id"])
assert updated["username"] == "jane"
assert updated["role"] == "admin"
assert updated["email"] == "new@example.com"
assert updated["auth_source"] == "proxy"
def test_update_user_rejects_invalid_auth_source(self, user_db):
user = user_db.create_user(username="john")
with pytest.raises(ValueError, match="Invalid auth_source"):
user_db.update_user(user["id"], auth_source="bad")
def test_update_nonexistent_user_raises(self, user_db):
with pytest.raises(ValueError, match="not found"):
user_db.update_user(9999, role="admin")
def test_delete_user(self, user_db):
user = user_db.create_user(username="john")
user_db.delete_user(user["id"])
assert user_db.get_user(user_id=user["id"]) is None
def test_delete_user_cascades_settings(self, user_db):
user = user_db.create_user(username="john")
user_db.set_user_settings(user["id"], {"booklore_library_id": 1})
user_db.delete_user(user["id"])
assert user_db.get_user_settings(user["id"]) == {}
def test_list_users(self, user_db):
user_db.create_user(username="alice")
user_db.create_user(username="bob")
user_db.create_user(username="charlie")
users = user_db.list_users()
assert len(users) == 3
usernames = [u["username"] for u in users]
assert "alice" in usernames
assert "bob" in usernames
assert "charlie" in usernames
class TestCachedUserLookup:
"""Tests for the short-lived lookup cache used by per-request auth."""
@pytest.fixture
def counted(self, user_db, monkeypatch):
calls = []
real_get_user = user_db.get_user
def counting_get_user(**kwargs):
calls.append(kwargs)
return real_get_user(**kwargs)
monkeypatch.setattr(user_db, "get_user", counting_get_user)
return calls
def test_repeat_lookups_are_answered_from_memory(self, user_db, counted):
user = user_db.create_user(username="john", role="admin")
for _ in range(3):
assert user_db.get_user_cached(username="john")["id"] == user["id"]
assert user_db.get_user_cached(user_id=user["id"])["username"] == "john"
assert user_db.get_user_cached(username="nobody") is None
assert user_db.get_user_cached(username="nobody") is None
assert len(counted) == 3
def test_entries_expire_after_the_ttl(self, user_db, counted, monkeypatch):
import shelfmark.core.user_db as user_db_module
user_db.create_user(username="john")
now = [1000.0]
monkeypatch.setattr(user_db_module.time, "monotonic", lambda: now[0])
user_db.get_user_cached(username="john")
now[0] += user_db_module.USER_CACHE_TTL_SECONDS - 0.1
user_db.get_user_cached(username="john")
assert len(counted) == 1
now[0] += 0.2
user_db.get_user_cached(username="john")
assert len(counted) == 2
def test_writes_through_the_db_drop_cached_entries(self, user_db):
user = user_db.create_user(username="john", role="admin")
assert user_db.get_user_cached(username="john")["role"] == "admin"
assert user_db.get_user_cached(username="jane") is None
user_db.update_user(user["id"], role="user")
assert user_db.get_user_cached(username="john")["role"] == "user"
user_db.create_user(username="jane")
assert user_db.get_user_cached(username="jane") is not None
user_db.delete_user(user["id"])
assert user_db.get_user_cached(username="john") is None
assert user_db.get_user_cached(user_id=user["id"]) is None
def test_callers_cannot_change_the_cached_row(self, user_db):
user_db.create_user(username="john", role="user")
user_db.get_user_cached(username="john")["role"] = "admin"
assert user_db.get_user_cached(username="john")["role"] == "user"
class TestUserSettings:
"""Tests for per-user settings."""
def test_set_and_get_user_settings(self, user_db):
user = user_db.create_user(username="john")
settings = {"booklore_library_id": 5, "booklore_path_id": 2}
user_db.set_user_settings(user["id"], settings)
fetched = user_db.get_user_settings(user["id"])
assert fetched["booklore_library_id"] == 5
assert fetched["booklore_path_id"] == 2
def test_get_settings_for_user_without_settings(self, user_db):
user = user_db.create_user(username="john")
assert user_db.get_user_settings(user["id"]) == {}
def test_update_user_settings_merges(self, user_db):
user = user_db.create_user(username="john")
user_db.set_user_settings(user["id"], {"key1": "val1"})
user_db.set_user_settings(user["id"], {"key2": "val2"})
settings = user_db.get_user_settings(user["id"])
assert settings["key1"] == "val1"
assert settings["key2"] == "val2"
def test_update_user_settings_overwrites_existing_key(self, user_db):
user = user_db.create_user(username="john")
user_db.set_user_settings(user["id"], {"key1": "old"})
user_db.set_user_settings(user["id"], {"key1": "new"})
settings = user_db.get_user_settings(user["id"])
assert settings["key1"] == "new"
class TestDownloadRequests:
"""Tests for download request storage and validation."""
@staticmethod
def _book_data():
return {
"title": "Test Book",
"author": "Test Author",
"content_type": "ebook",
"provider": "openlibrary",
"provider_id": "ol-1",
}
@staticmethod
def _release_data():
return {
"source": "direct_download",
"source_id": "release-1",
"title": "Release One",
}
def test_create_and_get_release_level_request(self, user_db):
user = user_db.create_user(username="alice")
created = user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data=self._book_data(),
release_data=self._release_data(),
note="please grab this release",
)
assert created["id"] is not None
assert created["status"] == "pending"
assert created["policy_mode"] == "request_release"
assert created["request_level"] == "release"
assert created["book_data"]["title"] == "Test Book"
assert created["release_data"]["source_id"] == "release-1"
fetched = user_db.get_request(created["id"])
assert fetched is not None
assert fetched["id"] == created["id"]
assert fetched["note"] == "please grab this release"
def test_create_request_rejects_invalid_status(self, user_db):
user = user_db.create_user(username="alice")
with pytest.raises(ValueError, match="Invalid request status"):
user_db.create_request(
user_id=user["id"],
status="queued",
content_type="ebook",
request_level="book",
policy_mode="request_book",
book_data=self._book_data(),
)
def test_create_request_rejects_invalid_policy_mode(self, user_db):
user = user_db.create_user(username="alice")
with pytest.raises(ValueError, match="Invalid policy_mode"):
user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="book",
policy_mode="allow",
book_data=self._book_data(),
)
def test_create_request_rejects_release_level_without_release_data(self, user_db):
user = user_db.create_user(username="alice")
with pytest.raises(
ValueError, match="request_level=release requires non-null release_data"
):
user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data=self._book_data(),
release_data=None,
)
def test_create_request_rejects_book_level_with_release_data(self, user_db):
user = user_db.create_user(username="alice")
with pytest.raises(ValueError, match="request_level=book requires null release_data"):
user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="book",
policy_mode="request_book",
book_data=self._book_data(),
release_data=self._release_data(),
)
def test_create_request_rejects_non_object_release_data(self, user_db):
user = user_db.create_user(username="alice")
with pytest.raises(TypeError, match="release_data must be an object when provided"):
user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data=self._book_data(),
release_data="not-an-object",
)
def test_list_requests_filters_by_user_and_status(self, user_db):
alice = user_db.create_user(username="alice")
bob = user_db.create_user(username="bob")
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(),
)
alice_fulfilled = user_db.create_request(
user_id=alice["id"],
content_type="ebook",
request_level="book",
policy_mode="request_book",
book_data=self._book_data(),
status="fulfilled",
)
bob_pending = user_db.create_request(
user_id=bob["id"],
content_type="ebook",
request_level="book",
policy_mode="request_book",
book_data=self._book_data(),
)
alice_only = user_db.list_requests(user_id=alice["id"])
assert {row["id"] for row in alice_only} == {alice_pending["id"], alice_fulfilled["id"]}
pending_only = user_db.list_requests(status="pending")
assert {row["id"] for row in pending_only} == {alice_pending["id"], bob_pending["id"]}
def test_update_request_allows_pending_to_terminal_transition(self, user_db):
user = user_db.create_user(username="alice")
created = user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="book",
policy_mode="request_book",
book_data=self._book_data(),
)
updated = user_db.update_request(
created["id"],
status="fulfilled",
admin_note="done",
)
assert updated["status"] == "fulfilled"
assert updated["admin_note"] == "done"
def test_update_request_expected_current_status_enforces_compare_and_swap(self, user_db):
user = user_db.create_user(username="alice")
created = user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="book",
policy_mode="request_book",
book_data=self._book_data(),
)
first = user_db.update_request(
created["id"],
expected_current_status="pending",
status="fulfilled",
)
assert first["status"] == "fulfilled"
with pytest.raises(ValueError, match="Request state changed before update"):
user_db.update_request(
created["id"],
expected_current_status="pending",
status="fulfilled",
)
def test_update_request_rejects_terminal_status_mutation(self, user_db):
user = user_db.create_user(username="alice")
created = user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="book",
policy_mode="request_book",
book_data=self._book_data(),
status="cancelled",
)
with pytest.raises(ValueError, match="Terminal request statuses are immutable"):
user_db.update_request(created["id"], status="fulfilled")
def test_update_request_validates_request_level_and_release_data(self, user_db):
user = user_db.create_user(username="alice")
created = user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="book",
policy_mode="request_book",
book_data=self._book_data(),
)
updated = user_db.update_request(
created["id"],
request_level="release",
release_data=self._release_data(),
policy_mode="request_release",
)
assert updated["request_level"] == "release"
assert updated["policy_mode"] == "request_release"
assert updated["release_data"]["source_id"] == "release-1"
def test_update_request_allows_fulfilled_book_level_to_store_release_data(self, user_db):
user = user_db.create_user(username="alice")
created = user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="book",
policy_mode="request_book",
book_data=self._book_data(),
)
updated = user_db.update_request(
created["id"],
status="fulfilled",
release_data=self._release_data(),
admin_note="Approved from browse mode",
)
assert updated["request_level"] == "book"
assert updated["status"] == "fulfilled"
assert updated["release_data"]["source_id"] == "release-1"
assert updated["admin_note"] == "Approved from browse mode"
def test_update_request_rejects_non_object_release_data(self, user_db):
user = user_db.create_user(username="alice")
created = user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data=self._book_data(),
release_data=self._release_data(),
)
with pytest.raises(TypeError, match="release_data must be an object when provided"):
user_db.update_request(created["id"], release_data="not-an-object")
def test_reopen_failed_request_resets_fulfilled_request_for_reapproval(self, user_db):
user = user_db.create_user(username="alice")
created = user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data=self._book_data(),
release_data=self._release_data(),
status="fulfilled",
delivery_state="queued",
reviewed_by=user["id"],
reviewed_at="2026-01-01T00:00:00+00:00",
delivery_updated_at="2026-01-01T00:00:01+00:00",
)
reopened = user_db.reopen_failed_request(
created["id"],
failure_reason=" Download timed out ",
)
assert reopened is not None
assert reopened["status"] == "pending"
assert reopened["delivery_state"] == "none"
assert reopened["delivery_updated_at"] is None
assert reopened["release_data"] is None
assert reopened["last_failure_reason"] == "Download timed out"
assert reopened["reviewed_by"] is None
assert reopened["reviewed_at"] is None
def test_reopen_failed_request_requires_reason_for_non_failure_states(self, user_db):
user = user_db.create_user(username="alice")
created = user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data=self._book_data(),
release_data=self._release_data(),
status="fulfilled",
delivery_state="queued",
)
reopened = user_db.reopen_failed_request(created["id"])
assert reopened is None
def test_reopen_failed_request_allows_failure_states_without_reason(self, user_db):
user = user_db.create_user(username="alice")
created = user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="release",
policy_mode="request_release",
book_data=self._book_data(),
release_data=self._release_data(),
status="fulfilled",
delivery_state="error",
)
reopened = user_db.reopen_failed_request(created["id"])
assert reopened is not None
assert reopened["status"] == "pending"
assert reopened["last_failure_reason"] is None
def test_count_pending_requests(self, user_db):
alice = user_db.create_user(username="alice")
bob = user_db.create_user(username="bob")
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.create_request(
user_id=alice["id"],
content_type="ebook",
request_level="book",
policy_mode="request_book",
book_data=self._book_data(),
status="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="pending",
)
assert user_db.count_pending_requests() == 2
assert user_db.count_user_pending_requests(alice["id"]) == 1
assert user_db.count_user_pending_requests(bob["id"]) == 1
def test_delete_user_cascades_download_requests(self, user_db):
user = user_db.create_user(username="alice")
created = user_db.create_request(
user_id=user["id"],
content_type="ebook",
request_level="book",
policy_mode="request_book",
book_data=self._book_data(),
)
user_db.delete_user(user["id"])
assert user_db.get_request(created["id"]) is None
def test_delete_user_cleans_up_activity_view_state(self, user_db, db_path):
from shelfmark.core.activity_view_state_service import ActivityViewStateService
activity_view_state_service = ActivityViewStateService(db_path)
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(),
status="rejected",
)
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(),
status="rejected",
)
activity_view_state_service.dismiss(
viewer_scope=f"user:{alice['id']}",
item_type="request",
item_key=f"request:{alice_request['id']}",
)
activity_view_state_service.dismiss(
viewer_scope="admin:shared",
item_type="request",
item_key=f"request:{alice_request['id']}",
)
activity_view_state_service.dismiss(
viewer_scope="admin:shared",
item_type="request",
item_key=f"request:{bob_request['id']}",
)
user_db.delete_user(alice["id"])
conn = sqlite3.connect(db_path)
rows = conn.execute(
"SELECT viewer_scope, item_key FROM activity_view_state ORDER BY viewer_scope, item_key"
).fetchall()
conn.close()
assert rows == [("admin:shared", f"request:{bob_request['id']}")]