Files
shelfmark/tests/core/test_config_user_overrides.py
T
463ef49ac3 feat(search): let each user pick their own default book languages (#1255)
## 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>
2026-08-24 17:57:21 -04:00

168 lines
6.1 KiB
Python

"""Tests for user override precedence and effective user-preference payloads."""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import patch
from shelfmark.core.config import config
def _download_field(key: str):
import shelfmark.config.settings # noqa: F401
from shelfmark.core import settings_registry
return settings_registry.get_settings_field_map()[key][0]
def test_get_prefers_env_over_user_override(monkeypatch):
field = _download_field("DESTINATION")
monkeypatch.setattr(config, "_ensure_loaded", lambda: None)
monkeypatch.setattr(config, "_cache", {"DESTINATION": "/env/books"})
monkeypatch.setattr(config, "_field_map", {"DESTINATION": (field, "downloads")})
monkeypatch.setattr(config, "_get_user_override", lambda user_id, key: "/user/books")
monkeypatch.setattr(
"shelfmark.core.config._get_registry",
lambda: SimpleNamespace(is_value_from_env=lambda field: True),
)
assert config.get("DESTINATION", "/default", user_id=10) == "/env/books"
def test_get_uses_user_override_when_not_from_env(monkeypatch):
field = _download_field("DESTINATION")
monkeypatch.setattr(config, "_ensure_loaded", lambda: None)
monkeypatch.setattr(config, "_cache", {"DESTINATION": "/global/books"})
monkeypatch.setattr(config, "_field_map", {"DESTINATION": (field, "downloads")})
monkeypatch.setattr(config, "_get_user_override", lambda user_id, key: "/user/books")
monkeypatch.setattr(
"shelfmark.core.config._get_registry",
lambda: SimpleNamespace(is_value_from_env=lambda field: False),
)
assert config.get("DESTINATION", "/default", user_id=10) == "/user/books"
def test_get_ignores_user_override_for_non_overridable_field(monkeypatch):
field = _download_field("FILE_ORGANIZATION")
monkeypatch.setattr(config, "_ensure_loaded", lambda: None)
monkeypatch.setattr(config, "_cache", {"FILE_ORGANIZATION": "rename"})
monkeypatch.setattr(
config,
"_field_map",
{"FILE_ORGANIZATION": (field, "downloads")},
)
monkeypatch.setattr(config, "_get_user_override", lambda user_id, key: "organize")
monkeypatch.setattr(
"shelfmark.core.config._get_registry",
lambda: SimpleNamespace(is_value_from_env=lambda field: False),
)
assert config.get("FILE_ORGANIZATION", "rename", user_id=10) == "rename"
def test_get_keeps_empty_string_override(monkeypatch):
field = _download_field("DESTINATION_AUDIOBOOK")
monkeypatch.setattr(config, "_ensure_loaded", lambda: None)
monkeypatch.setattr(config, "_cache", {"DESTINATION_AUDIOBOOK": "/global/audiobooks"})
monkeypatch.setattr(
config,
"_field_map",
{"DESTINATION_AUDIOBOOK": (field, "downloads")},
)
monkeypatch.setattr(config, "_get_user_override", lambda user_id, key: "")
monkeypatch.setattr(
"shelfmark.core.config._get_registry",
lambda: SimpleNamespace(is_value_from_env=lambda field: False),
)
assert config.get("DESTINATION_AUDIOBOOK", "/default", user_id=10) == ""
def test_build_user_preferences_payload_reports_effective_sources(monkeypatch):
import shelfmark.config.settings # noqa: F401
from shelfmark.core import settings_registry
from shelfmark.core.user_settings_overrides import build_user_preferences_payload
user_db = SimpleNamespace(
get_user_settings=lambda user_id: {
"DESTINATION": "/user/books",
}
)
def fake_get(key, default=None, user_id=None):
values = {
"DESTINATION": "/global/books",
"BOOKS_OUTPUT_MODE": "folder",
"EMAIL_RECIPIENT": "global@example.com",
}
return values.get(key, default)
with (
patch(
"shelfmark.core.user_settings_overrides.get_settings_registry",
return_value=settings_registry,
),
patch(
"shelfmark.core.user_settings_overrides.load_config_file",
return_value={
"DESTINATION": "/global/books",
"BOOKS_OUTPUT_MODE": "folder",
"EMAIL_RECIPIENT": "global@example.com",
},
),
patch.object(config, "get", side_effect=fake_get),
patch.object(
settings_registry,
"is_value_from_env",
side_effect=lambda field: field.key == "BOOKS_OUTPUT_MODE",
),
):
payload = build_user_preferences_payload(user_db, 7, "downloads")
assert payload["tab"] == "downloads"
assert payload["userOverrides"] == {"DESTINATION": "/user/books"}
assert payload["globalValues"]["DESTINATION"] == "/global/books"
assert payload["effective"]["DESTINATION"] == {
"value": "/user/books",
"source": "user_override",
}
assert payload["effective"]["BOOKS_OUTPUT_MODE"] == {
"value": "folder",
"source": "env_var",
}
assert payload["effective"]["EMAIL_RECIPIENT"] == {
"value": "global@example.com",
"source": "global_config",
}
fields_by_key = {field["key"]: field for field in payload["fields"]}
assert fields_by_key["DESTINATION"]["fromEnv"] is False
assert fields_by_key["BOOKS_OUTPUT_MODE"]["fromEnv"] is True
def test_build_user_preferences_payload_carries_the_language_default():
"""BOOK_LANGUAGE rides along with the other search preferences on its tab."""
import shelfmark.config.settings # noqa: F401
from shelfmark.core.user_settings_overrides import build_user_preferences_payload
user_db = SimpleNamespace(get_user_settings=lambda user_id: {"BOOK_LANGUAGE": ["de", "en"]})
payload = build_user_preferences_payload(user_db, 7, "search_mode")
assert payload["tab"] == "search_mode"
assert {"SEARCH_MODE", "BOOK_LANGUAGE"}.issubset(payload["keys"])
assert payload["userOverrides"] == {"BOOK_LANGUAGE": ["de", "en"]}
assert payload["effective"]["BOOK_LANGUAGE"] == {
"value": ["de", "en"],
"source": "user_override",
}
fields_by_key = {field["key"]: field for field in payload["fields"]}
assert fields_by_key["BOOK_LANGUAGE"]["type"] == "MultiSelectField"
assert fields_by_key["BOOK_LANGUAGE"]["options"]