mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-10-05 18:31:05 +01:00
## Why `BOOK_LANGUAGE` is a per-reader property, not a per-instance one. On a shared install one household member searches in German while another wants English and German — today whoever changes the setting changes it for everyone, and the only escape is re-picking languages in the filter on every single search. The per-user override machinery already carries `SEARCH_MODE`, the metadata providers and the default release sources, so the language default mostly had to opt into it. ## What changed **The field.** `BOOK_LANGUAGE` becomes `user_overridable` and moves from the **General** tab to **Search Mode**, next to the other user-overridable search defaults (per [review](https://github.com/calibrain/shelfmark/pull/1255#issuecomment-5391189094) — the first version had the Search section span two tabs, this one doesn't). Admins set it per user in the user editor, users set it in **My Account → Search Preferences**, and the Search Mode tab carries the usual "N users override this" summary. **No migration for the move.** `general` and `search_mode` both persist into `settings.json`, and a field's value is resolved through `load_config_file(tab)` for the tab it's declared on — so an install that already stores `BOOK_LANGUAGE` keeps its value. Checked against a `settings.json` written while the field still lived on General: the stored value resolves unchanged, a fresh install still gets `["en"]`, and `BOOK_LANGUAGE` in the environment still overrides both. **The two places the default is read.** - `/api/config` seeds the frontend's language filter, so it now resolves `BOOK_LANGUAGE` for the session user. - `build_release_search_plan` falls back to the default whenever a request carries no language filter — which is exactly what the filter's "Default" option sends. It takes an optional `user_id`, passed by `/api/releases` from the session and by the Prowlarr retry path from `task.user_id`, so a retry re-searches in the languages of whoever queued the download. **Validation.** Overrides go through `normalize_language()`, so `"German"`, `"ger"` and `"de"` all store as `de`, and an unknown language is rejected with a message naming it instead of being silently searched for. An empty list stays an empty list (a deliberate "no default filter"), `null` clears the override as everywhere else, and ENV still wins: with `BOOK_LANGUAGE` set in the environment the field reports `fromEnv` and overrides are ignored. **Scope.** Only the language default becomes overridable. The two format lists left behind under "Default Search Filters" stay admin-only — they describe what the library and its post-processing accept, not what a reader wants to read. There's a test pinning that. ## Verification - 2681 unit tests pass (2670 before, 11 added) - `ruff check`, `ruff format`, `basedpyright` over backend and tests, and `vulture` all clean; frontend lint, format, typecheck and 126 unit tests clean - `docs/environment-variables.md` regenerated via `scripts/generate_env_docs.py` (the `BOOK_LANGUAGE` row follows the field into the Search Mode section) - Manually against a two-user instance with builtin auth (first round, before the tab move): with user A on German and user B on English+German, `/api/config` returns each reader their own `default_language` and an unfiltered `/api/releases` plans the matching languages; an admin can set and read the same override for another user; clearing it falls back to the global value; a stray `"klingon"` is rejected; and `BOOK_LANGUAGE` in the environment overrides both users with the field marked `fromEnv` - After the tab move I re-ran the suites above plus the stored-value/fresh-install/ENV check described under "No migration for the move"; the behaviour it exercises is what the move could have broken Co-authored-by: Claude Opus 5 <noreply@anthropic.com> Co-authored-by: CaliBrain <calibrain@l4n.xyz>
245 lines
9.1 KiB
Python
245 lines
9.1 KiB
Python
"""Tests for self-service account edit context and update endpoints."""
|
|
|
|
import os
|
|
import tempfile
|
|
from typing import Any
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
from flask import Flask
|
|
|
|
from shelfmark.core.self_user_routes import register_self_user_routes
|
|
from shelfmark.core.user_db import UserDB
|
|
|
|
|
|
@pytest.fixture
|
|
def db_path():
|
|
with tempfile.TemporaryDirectory() as tmpdir:
|
|
yield os.path.join(tmpdir, "shelfmark.db")
|
|
|
|
|
|
@pytest.fixture
|
|
def user_db(db_path):
|
|
db = UserDB(db_path)
|
|
db.initialize()
|
|
return db
|
|
|
|
|
|
@pytest.fixture
|
|
def app(user_db):
|
|
test_app = Flask(__name__)
|
|
test_app.config["SECRET_KEY"] = "test-secret"
|
|
test_app.config["TESTING"] = True
|
|
|
|
register_self_user_routes(test_app, user_db)
|
|
return test_app
|
|
|
|
|
|
def _authed_client_for_user(app: Flask, user: dict) -> Any:
|
|
client = app.test_client()
|
|
with client.session_transaction() as sess:
|
|
sess["user_id"] = user["username"]
|
|
sess["db_user_id"] = user["id"]
|
|
sess["is_admin"] = False
|
|
return client
|
|
|
|
|
|
def _visible_sections_config_get(
|
|
visible_sections: object,
|
|
):
|
|
def _get(key: str, default: object = None, user_id: int | None = None) -> object:
|
|
del user_id
|
|
if key == "VISIBLE_SELF_SETTINGS_SECTIONS":
|
|
return visible_sections
|
|
return default
|
|
|
|
return _get
|
|
|
|
|
|
def test_users_me_edit_context_respects_visible_sections(app, user_db, monkeypatch):
|
|
user = user_db.create_user(username="alice")
|
|
user_db.set_user_settings(user["id"], {"DESTINATION": "/books/alice"})
|
|
client = _authed_client_for_user(app, user)
|
|
monkeypatch.delenv("INGEST_DIR", raising=False)
|
|
|
|
with patch("shelfmark.core.self_user_routes.load_active_auth_mode", return_value="builtin"):
|
|
with patch(
|
|
"shelfmark.core.self_user_routes.app_config.get",
|
|
side_effect=_visible_sections_config_get(["delivery"]),
|
|
):
|
|
resp = client.get("/api/users/me/edit-context")
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json["visibleUserSettingsSections"] == ["delivery"]
|
|
assert resp.json["deliveryPreferences"]["tab"] == "downloads"
|
|
assert resp.json["deliveryPreferences"]["effective"]["DESTINATION"]["value"] == "/books/alice"
|
|
assert resp.json["deliveryPreferences"]["effective"]["DESTINATION"]["source"] == "user_override"
|
|
assert "DESTINATION" in resp.json["userOverridableKeys"]
|
|
assert resp.json["notificationPreferences"] is None
|
|
|
|
|
|
def test_users_me_edit_context_includes_search_preferences_when_visible(app, user_db):
|
|
user = user_db.create_user(username="alice")
|
|
user_db.set_user_settings(
|
|
user["id"],
|
|
{
|
|
"SEARCH_MODE": "universal",
|
|
"METADATA_PROVIDER": "openlibrary",
|
|
"BOOK_LANGUAGE": ["de", "en"],
|
|
},
|
|
)
|
|
client = _authed_client_for_user(app, user)
|
|
|
|
with patch("shelfmark.core.self_user_routes.load_active_auth_mode", return_value="builtin"):
|
|
with patch(
|
|
"shelfmark.core.self_user_routes.app_config.get",
|
|
side_effect=_visible_sections_config_get(["delivery", "search"]),
|
|
):
|
|
resp = client.get("/api/users/me/edit-context")
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json["visibleUserSettingsSections"] == ["delivery", "search"]
|
|
assert resp.json["deliveryPreferences"]["tab"] == "downloads"
|
|
assert resp.json["searchPreferences"]["tab"] == "search_mode"
|
|
assert resp.json["searchPreferences"]["effective"]["SEARCH_MODE"]["value"] == "universal"
|
|
assert resp.json["searchPreferences"]["effective"]["SEARCH_MODE"]["source"] == "user_override"
|
|
assert resp.json["searchPreferences"]["effective"]["BOOK_LANGUAGE"]["value"] == ["de", "en"]
|
|
assert resp.json["searchPreferences"]["effective"]["BOOK_LANGUAGE"]["source"] == (
|
|
"user_override"
|
|
)
|
|
assert "SEARCH_MODE" in resp.json["userOverridableKeys"]
|
|
assert "METADATA_PROVIDER" in resp.json["userOverridableKeys"]
|
|
assert "BOOK_LANGUAGE" in resp.json["userOverridableKeys"]
|
|
assert resp.json["notificationPreferences"] is None
|
|
assert resp.json["userOverridableKeys"] == sorted(resp.json["userOverridableKeys"])
|
|
|
|
|
|
def test_users_me_edit_context_falls_back_to_default_sections_for_invalid_config(app, user_db):
|
|
user = user_db.create_user(username="alice")
|
|
client = _authed_client_for_user(app, user)
|
|
|
|
with patch("shelfmark.core.self_user_routes.load_active_auth_mode", return_value="builtin"):
|
|
with patch(
|
|
"shelfmark.core.self_user_routes.app_config.get",
|
|
side_effect=_visible_sections_config_get("bogus"),
|
|
):
|
|
resp = client.get("/api/users/me/edit-context")
|
|
|
|
assert resp.status_code == 200
|
|
assert resp.json["visibleUserSettingsSections"] == ["delivery", "search", "notifications"]
|
|
assert resp.json["deliveryPreferences"] is not None
|
|
assert resp.json["searchPreferences"] is not None
|
|
assert resp.json["notificationPreferences"] is not None
|
|
|
|
|
|
def test_users_me_update_rejects_hidden_section_settings(app, user_db):
|
|
user = user_db.create_user(username="alice")
|
|
client = _authed_client_for_user(app, user)
|
|
|
|
with patch("shelfmark.core.self_user_routes.load_active_auth_mode", return_value="builtin"):
|
|
with patch(
|
|
"shelfmark.core.self_user_routes.app_config.get",
|
|
side_effect=_visible_sections_config_get(["delivery"]),
|
|
):
|
|
resp = client.put(
|
|
"/api/users/me",
|
|
json={
|
|
"settings": {
|
|
"USER_NOTIFICATION_ROUTES": [
|
|
{"event": "all", "url": "ntfys://ntfy.sh/alice"}
|
|
],
|
|
}
|
|
},
|
|
)
|
|
|
|
assert resp.status_code == 400
|
|
assert resp.json["error"] == "Some settings are admin-only"
|
|
assert "Setting not user-overridable: USER_NOTIFICATION_ROUTES" in resp.json["details"]
|
|
|
|
|
|
def test_users_me_update_accepts_visible_section_settings(app, user_db):
|
|
user = user_db.create_user(username="alice")
|
|
client = _authed_client_for_user(app, user)
|
|
|
|
with patch("shelfmark.core.self_user_routes.load_active_auth_mode", return_value="builtin"):
|
|
with patch(
|
|
"shelfmark.core.self_user_routes.app_config.get",
|
|
side_effect=_visible_sections_config_get(["delivery"]),
|
|
):
|
|
resp = client.put(
|
|
"/api/users/me",
|
|
json={"settings": {"DESTINATION": "/books/alice"}},
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
assert user_db.get_user_settings(user["id"]).get("DESTINATION") == "/books/alice"
|
|
assert resp.json["settings"]["DESTINATION"] == "/books/alice"
|
|
|
|
|
|
def test_users_me_update_accepts_book_language_when_search_section_visible(app, user_db):
|
|
user = user_db.create_user(username="alice")
|
|
client = _authed_client_for_user(app, user)
|
|
|
|
with patch("shelfmark.core.self_user_routes.load_active_auth_mode", return_value="builtin"):
|
|
with patch(
|
|
"shelfmark.core.self_user_routes.app_config.get",
|
|
side_effect=_visible_sections_config_get(["search"]),
|
|
):
|
|
resp = client.put(
|
|
"/api/users/me",
|
|
json={"settings": {"BOOK_LANGUAGE": ["German", "en"]}},
|
|
)
|
|
|
|
assert resp.status_code == 200
|
|
assert user_db.get_user_settings(user["id"])["BOOK_LANGUAGE"] == ["de", "en"]
|
|
|
|
|
|
def test_users_me_update_rejects_book_language_when_search_section_hidden(app, user_db):
|
|
user = user_db.create_user(username="alice")
|
|
client = _authed_client_for_user(app, user)
|
|
|
|
with patch("shelfmark.core.self_user_routes.load_active_auth_mode", return_value="builtin"):
|
|
with patch(
|
|
"shelfmark.core.self_user_routes.app_config.get",
|
|
side_effect=_visible_sections_config_get(["delivery"]),
|
|
):
|
|
resp = client.put(
|
|
"/api/users/me",
|
|
json={"settings": {"BOOK_LANGUAGE": ["de"]}},
|
|
)
|
|
|
|
assert resp.status_code == 400
|
|
assert resp.json["error"] == "Some settings are admin-only"
|
|
assert user_db.get_user_settings(user["id"]) == {}
|
|
|
|
|
|
def test_users_me_update_rejects_non_object_settings_payload(app, user_db):
|
|
user = user_db.create_user(username="alice")
|
|
client = _authed_client_for_user(app, user)
|
|
|
|
with patch("shelfmark.core.self_user_routes.load_active_auth_mode", return_value="builtin"):
|
|
with patch(
|
|
"shelfmark.core.self_user_routes.app_config.get",
|
|
side_effect=_visible_sections_config_get(["delivery"]),
|
|
):
|
|
resp = client.put("/api/users/me", json={"settings": ["DESTINATION"]})
|
|
|
|
assert resp.status_code == 400
|
|
assert resp.json["error"] == "Settings must be an object"
|
|
|
|
|
|
def test_users_me_update_rejects_oidc_email_change(app, user_db):
|
|
user = user_db.create_user(
|
|
username="oidc-user",
|
|
oidc_subject="oidc-sub-123",
|
|
auth_source="oidc",
|
|
)
|
|
client = _authed_client_for_user(app, user)
|
|
|
|
with patch("shelfmark.core.self_user_routes.load_active_auth_mode", return_value="builtin"):
|
|
resp = client.put("/api/users/me", json={"email": "new@example.com"})
|
|
|
|
assert resp.status_code == 400
|
|
assert resp.json["error"] == "Cannot change email for OIDC users"
|
|
assert user_db.get_user(user_id=user["id"])["email"] is None
|