mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-27 19:30:18 +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>
180 lines
7.0 KiB
Python
180 lines
7.0 KiB
Python
"""API tests for the frontend config endpoint."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import importlib
|
|
from pathlib import Path
|
|
from unittest.mock import patch
|
|
|
|
import pytest
|
|
|
|
|
|
@pytest.fixture(scope="module")
|
|
def main_module():
|
|
"""Import `shelfmark.main` with background startup disabled."""
|
|
with patch("shelfmark.download.orchestrator.start"):
|
|
import shelfmark.main as main
|
|
|
|
importlib.reload(main)
|
|
return main
|
|
|
|
|
|
@pytest.fixture
|
|
def client(main_module):
|
|
return main_module.app.test_client()
|
|
|
|
|
|
def _set_session(client, *, user_id: str, db_user_id: int, is_admin: bool) -> None:
|
|
with client.session_transaction() as sess:
|
|
sess["user_id"] = user_id
|
|
sess["db_user_id"] = db_user_id
|
|
sess["is_admin"] = is_admin
|
|
|
|
|
|
def test_config_endpoint_uses_user_scope_and_runtime_flags(main_module, client):
|
|
_set_session(client, user_id="reader-1", db_user_id=42, is_admin=False)
|
|
|
|
calls: list[tuple[str, int | None]] = []
|
|
|
|
def fake_get(key, default=None, user_id=None):
|
|
calls.append((key, user_id))
|
|
values = {
|
|
"SHOW_RELEASE_SOURCE_LINKS": False,
|
|
"SHOW_COMBINED_SELECTOR": False,
|
|
"SEARCH_MODE": "universal",
|
|
"SEARCH_PAGE_TITLE": "Custom Shelfmark",
|
|
"METADATA_PROVIDER": "openlibrary",
|
|
"METADATA_PROVIDER_AUDIOBOOK": "",
|
|
"DEFAULT_RELEASE_SOURCE": "prowlarr",
|
|
"DEFAULT_RELEASE_SOURCE_AUDIOBOOK": "audiobookbay",
|
|
"DOWNLOAD_TO_BROWSER_CONTENT_TYPES": ["book", "audiobook"],
|
|
"BOOK_LANGUAGE": ["de", "en"],
|
|
"AUTO_OPEN_DOWNLOADS_SIDEBAR": False,
|
|
"HARDCOVER_AUTO_REMOVE_ON_DOWNLOAD": True,
|
|
"AA_DEFAULT_SORT": "newest",
|
|
}
|
|
return values.get(key, default)
|
|
|
|
with (
|
|
patch.object(main_module.app_config, "get", side_effect=fake_get),
|
|
patch("shelfmark.config.env._is_config_dir_writable", return_value=True),
|
|
patch("shelfmark.core.onboarding.is_onboarding_complete", return_value=True),
|
|
patch("shelfmark.metadata_providers.get_provider_sort_options", return_value=["sort-a"]),
|
|
patch("shelfmark.metadata_providers.get_provider_search_fields", return_value=["field-a"]),
|
|
patch("shelfmark.metadata_providers.get_provider_default_sort", return_value="relevance"),
|
|
):
|
|
resp = client.get("/api/config")
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert data["show_release_source_links"] is False
|
|
assert data["show_combined_selector"] is False
|
|
assert data["search_mode"] == "universal"
|
|
assert data["search_page_title"] == "Custom Shelfmark"
|
|
assert data["metadata_sort_options"] == ["sort-a"]
|
|
assert data["metadata_search_fields"] == ["field-a"]
|
|
assert data["default_release_source"] == "prowlarr"
|
|
assert data["default_release_source_audiobook"] == "audiobookbay"
|
|
assert data["download_to_browser_content_types"] == ["book", "audiobook"]
|
|
assert data["default_language"] == ["de", "en"]
|
|
assert data["settings_enabled"] is True
|
|
assert data["metadata_default_sort"] == "relevance"
|
|
|
|
assert ("SHOW_RELEASE_SOURCE_LINKS", None) in calls
|
|
assert ("SHOW_COMBINED_SELECTOR", 42) in calls
|
|
assert ("DOWNLOAD_TO_BROWSER_CONTENT_TYPES", 42) in calls
|
|
assert ("BOOK_LANGUAGE", 42) in calls
|
|
|
|
|
|
def test_config_endpoint_falls_back_to_audiobook_metadata_provider(main_module, client):
|
|
_set_session(client, user_id="reader-2", db_user_id=77, is_admin=False)
|
|
|
|
provider_calls: list[str] = []
|
|
|
|
def fake_get(key, default=None, user_id=None):
|
|
values = {
|
|
"METADATA_PROVIDER": "",
|
|
"METADATA_PROVIDER_AUDIOBOOK": "audiobook-search",
|
|
"SHOW_RELEASE_SOURCE_LINKS": True,
|
|
}
|
|
return values.get(key, default)
|
|
|
|
def sort_options(provider: str):
|
|
provider_calls.append(provider)
|
|
return [f"{provider}-sort"]
|
|
|
|
def search_fields(provider: str):
|
|
provider_calls.append(provider)
|
|
return [f"{provider}-field"]
|
|
|
|
def default_sort(provider: str):
|
|
provider_calls.append(provider)
|
|
return f"{provider}-default"
|
|
|
|
with (
|
|
patch.object(main_module.app_config, "get", side_effect=fake_get),
|
|
patch("shelfmark.config.env._is_config_dir_writable", return_value=True),
|
|
patch("shelfmark.core.onboarding.is_onboarding_complete", return_value=True),
|
|
patch("shelfmark.metadata_providers.get_provider_sort_options", side_effect=sort_options),
|
|
patch("shelfmark.metadata_providers.get_provider_search_fields", side_effect=search_fields),
|
|
patch("shelfmark.metadata_providers.get_provider_default_sort", side_effect=default_sort),
|
|
):
|
|
resp = client.get("/api/config")
|
|
|
|
assert resp.status_code == 200
|
|
data = resp.get_json()
|
|
assert data["metadata_sort_options"] == ["audiobook-search-sort"]
|
|
assert data["metadata_search_fields"] == ["audiobook-search-field"]
|
|
assert data["metadata_default_sort"] == "audiobook-search-default"
|
|
assert provider_calls == ["audiobook-search", "audiobook-search", "audiobook-search"]
|
|
|
|
|
|
def test_frontend_dist_resolves_from_repo_root(main_module):
|
|
expected_project_root = Path(main_module.__file__).resolve().parent.parent
|
|
|
|
assert main_module.PROJECT_ROOT == expected_project_root
|
|
assert main_module.FRONTEND_DIST == expected_project_root / "frontend-dist"
|
|
|
|
|
|
def test_config_endpoint_serves_languages_without_resolution_aliases(main_module, client):
|
|
"""book_languages is a client contract, not a dump of the language data file.
|
|
|
|
data/book-languages.json also carries the aliases used to resolve a source's
|
|
spelling of a language to a code. Those are server-side only: the frontend
|
|
Language type is {code, language}, and shipping the aliases inflated every
|
|
config response by around 40%.
|
|
"""
|
|
_set_session(client, user_id="reader-1", db_user_id=1, is_admin=False)
|
|
|
|
with (
|
|
patch("shelfmark.config.env._is_config_dir_writable", return_value=True),
|
|
patch("shelfmark.core.onboarding.is_onboarding_complete", return_value=True),
|
|
):
|
|
resp = client.get("/api/config")
|
|
|
|
assert resp.status_code == 200
|
|
languages = resp.get_json()["book_languages"]
|
|
|
|
assert languages, "no languages served"
|
|
offending = [entry for entry in languages if set(entry) != {"code", "language"}]
|
|
assert offending == [], f"unexpected keys leaked to clients: {offending[:3]}"
|
|
|
|
|
|
def test_language_data_file_is_only_read_by_the_shared_module(main_module):
|
|
"""Reading data/book-languages.json anywhere else reintroduces the drift the
|
|
shared module exists to prevent, and bypasses the alias handling."""
|
|
del main_module
|
|
|
|
repo_root = Path(__file__).resolve().parents[2]
|
|
allowed = {Path("shelfmark/core/languages.py")}
|
|
|
|
offenders = []
|
|
for path in (repo_root / "shelfmark").rglob("*.py"):
|
|
relative = path.relative_to(repo_root)
|
|
if relative in allowed:
|
|
continue
|
|
if "book-languages" in path.read_text(encoding="utf-8"):
|
|
offenders.append(str(relative))
|
|
|
|
assert offenders == [], f"should use shelfmark.core.languages instead: {offenders}"
|