mirror of
https://github.com/calibrain/shelfmark.git
synced 2026-09-27 22:05:56 +01:00
Closes #1018. Worth correcting the issue first: the search tab is not hardcoded. `useContentTypePreferences` has persisted the user's choice to localStorage since #564, so a browser that has picked a tab already keeps it. What is missing is the other half the issue asks for, a default for a browser that has stored nothing, and a per-user override. `DEFAULT_CONTENT_TYPE` is a user overridable select next to `BOOK_LANGUAGE`, so it follows the same path: global value in Settings, per-user value in Search Preferences, resolved with `user_id` in `/api/config`. The frontend uses it only when this browser has no stored choice, which is captured before the existing effect writes one, so nothing changes for anyone who has already picked a tab. The resolution is a pure function in `utils/contentTypePreference.ts` rather than logic inside the hook, since vitest here has no jsdom and the existing tests cover resolvers like `resolveDefaultLanguageCodes` the same way. One small move in `App.tsx`: the `config` state was declared below the hook that now reads it, so it moved above it. ## Verification - `tests/core/test_config_api.py`: the payload carries `default_content_type` and reads it with the user's id. - `tests/core/test_admin_users_api.py`: the key appears in the per-user search preferences list. - `src/frontend/src/tests/contentTypePreference.test.ts`: a stored tab wins, combined mode survives, the server default applies when nothing is stored, and an unrecognised value falls back to ebook. - Python suite (3163) and frontend suite (201) green, plus ruff, ruff format, basedpyright, vulture, tsc, oxlint, oxfmt and the production build.
183 lines
7.2 KiB
Python
183 lines
7.2 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"],
|
|
"DEFAULT_CONTENT_TYPE": "audiobook",
|
|
"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["default_content_type"] == "audiobook"
|
|
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
|
|
assert ("DEFAULT_CONTENT_TYPE", 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}"
|