diff --git a/api/nestquest_core.py b/api/nestquest_core.py index d67596a..5cfb60d 100644 --- a/api/nestquest_core.py +++ b/api/nestquest_core.py @@ -129,6 +129,17 @@ core_events = importlib.import_module(f"{_CORE_MODULE_NAME}.events") #: the snapshot builder's ``settings`` parameter takes. core_settings = importlib.import_module(f"{_CORE_MODULE_NAME}.settings") +#: The durable settings store (``core.settings_store.load_settings`` / +#: ``update_settings``) the admin plane's settings routes read and +#: write — the API's OWN settings source: a validated JSON document in +#: the existing ``nestquest_meta_state`` table, resolved through the +#: same core settings validators (the integration's HA config-entry +#: options remain a temporary separate source until Feature 18 wires +#: the client to the API). +core_settings_store = importlib.import_module( + f"{_CORE_MODULE_NAME}.settings_store" +) + #: The children business layer (``core.children.create_child`` et al.) #: the admin plane's children routes call. core_children = importlib.import_module(f"{_CORE_MODULE_NAME}.children") @@ -206,4 +217,5 @@ __all__ = [ "core_recurrence", "core_snapshot", "core_settings", + "core_settings_store", ] diff --git a/api/routes_admin.py b/api/routes_admin.py index 4ff4553..6c8ff9a 100644 --- a/api/routes_admin.py +++ b/api/routes_admin.py @@ -116,6 +116,29 @@ owns every rule so the two routes cannot drift: a ``Content-Disposition`` attachment filename and the documented, stable header row (Admin spec §5 event-row fields). +The settings routes (task 2b3de7e5) follow the same pattern over +:mod:`nestquest_core.settings_store` — the API's OWN durable settings +store. The API now OWNS settings in the database (a validated JSON +document in the existing ``nestquest_meta_state`` key/value table); the +integration's Home-Assistant config-entry options remain a temporary, +SEPARATE source until Feature 18 wires the client to the API — the two +are deliberately not unified here: + +- ``GET /settings`` returns the current effective settings: all ten + documented fields (the rolling horizon, the day rollover time, the + notify target, the three notification times and the four enable + toggles) — the defaults on a fresh database. +- ``PATCH /settings`` updates ONLY the supplied fields: the body is a + JSON object of settings field names to new values, passed STRAIGHT + to :func:`nestquest_core.settings_store.update_settings` — the + unknown-field rejection, the per-field validation (the core settings + validators: a positive int, strict ``HH:MM`` strings, real booleans) + and the validate-everything-before-write atomicity all live in the + core, so a rejected update never changes the stored settings. A + JSON ``null`` value resets its field to the default (the settings + module's uniform None-as-absent rule). The response is the updated + effective settings, the same shape ``GET`` returns. + Error mapping (every children, quest-definition and presence route, deliberately narrow): @@ -174,6 +197,13 @@ deliberately narrow): invalid, the same failure class Pydantic reports. Only ``ValueError`` is caught, so an unexpected failure re-raises rather than becoming a 4xx. +- The SETTINGS routes have no path ids either, so they have no 404 + case: a body that is not a JSON object is FastAPI's 422 before any + handler code runs, and every core ``ValueError`` — an unknown field + or an invalid value, each naming the offending field, raised BEFORE + any write so the stored settings are never half-changed — is 422 + (:func:`_raise_settings_error`). Only ``ValueError`` is caught, so + an unexpected failure re-raises rather than becoming a 4xx. PATCH edit semantics: the body model's fields are optional and only SUPPLIED fields are forwarded (``model_dump(exclude_unset=True)``), so @@ -205,6 +235,8 @@ from api.nestquest_core import ( core_presence_management, core_quest_definitions, core_recurrence, + core_settings, + core_settings_store, ) #: All admin-plane routes share this router; the ONE admin dependency @@ -601,6 +633,27 @@ class AdminHistoryResponse(BaseModel): rows: list[AdminHistoryRow] +class AdminSettingsResponse(BaseModel): + """The effective settings as the admin plane serializes them. + + Mirrors the core :class:`~nestquest_core.settings.NestQuestSettings` + — all TEN documented fields, resolved (every absent stored field + already fell back to its default in the core). The same shape both + the GET and the PATCH route answer with. + """ + + horizon_days: int + day_rollover_time: str + notify_target: str + morning_summary_time: str + afternoon_reminder_time: str + end_of_day_report_time: str + morning_summary_enabled: bool + afternoon_reminder_enabled: bool + end_of_day_report_enabled: bool + celebration_enabled: bool + + #: 404 detail for a child id the core layer reports as non-existent. _CHILD_NOT_FOUND_DETAIL = "Child not found" @@ -736,6 +789,20 @@ def _raise_history_error(error: ValueError) -> NoReturn: raise HTTPException(status_code=422, detail=str(error)) from error +def _raise_settings_error(error: ValueError) -> NoReturn: + """Map a core settings-store ``ValueError`` onto 422 and raise it. + + The settings routes have no path ids either, so they have no 404 + case: every ``ValueError`` that survives to here is a rejected + change — an unknown field or an invalid value, each raised by the + core naming the offending field BEFORE any write, so the stored + settings are never half-changed — and is 422 with the core's + message as the detail. Only ``ValueError`` is caught, so an + unexpected failure re-raises rather than becoming a 4xx. + """ + raise HTTPException(status_code=422, detail=str(error)) from error + + def _local_now() -> datetime.datetime: """Return the API host's local time as ONE timezone-aware clock read. @@ -1401,6 +1468,29 @@ def _history_row_response(row: core_history.HistoryRow) -> AdminHistoryRow: ) +def _settings_response( + settings: core_settings.NestQuestSettings, +) -> AdminSettingsResponse: + """Serialize one core NestQuestSettings into the documented payload. + + Explicit field-by-field (not ``dataclasses.asdict``) so the payload + stays pinned to the ten documented fields even if the core + dataclass later grows another one. + """ + return AdminSettingsResponse( + horizon_days=settings.horizon_days, + day_rollover_time=settings.day_rollover_time, + notify_target=settings.notify_target, + morning_summary_time=settings.morning_summary_time, + afternoon_reminder_time=settings.afternoon_reminder_time, + end_of_day_report_time=settings.end_of_day_report_time, + morning_summary_enabled=settings.morning_summary_enabled, + afternoon_reminder_enabled=settings.afternoon_reminder_enabled, + end_of_day_report_enabled=settings.end_of_day_report_enabled, + celebration_enabled=settings.celebration_enabled, + ) + + @router.get( "/history", summary="Query the completion history for one filter and range", @@ -1489,4 +1579,60 @@ async def admin_history_csv( ) +@router.get( + "/settings", + summary="Read the effective settings", + response_model=AdminSettingsResponse, +) +async def admin_get_settings(request: Request) -> AdminSettingsResponse: + """Return the current effective settings (all ten documented fields). + + Requires a valid admin JWT (the router's shared + :func:`~api.auth.require_admin` dependency — the ONE check; this + handler performs NO auth of its own). Thin adapter: ONE + :func:`nestquest_core.settings_store.load_settings` call — the API + owns settings in the database (the integration's HA options remain + a temporary separate source until Feature 18 wires the client to + the API), and the stored-document decode, the per-field validation + and the defaults-for-absent-fields fallback all live in the core. + A corrupt stored document never fails the read: the core falls + back to the defaults (per field where possible). + """ + state: DatabaseState = request.app.state.db + settings = await core_settings_store.load_settings(state.database) + return _settings_response(settings) + + +@router.patch( + "/settings", + summary="Update the supplied settings fields", + response_model=AdminSettingsResponse, +) +async def admin_update_settings( + changes: dict[str, object], request: Request +) -> AdminSettingsResponse: + """Update the supplied settings fields and return the new state. + + Requires a valid admin JWT (the router's shared + :func:`~api.auth.require_admin` dependency — the ONE check; this + handler performs NO auth of its own). Thin adapter: the body — a + JSON object of settings field names to new values — goes STRAIGHT + to :func:`nestquest_core.settings_store.update_settings`, which + rejects an unknown field, resolves each supplied field through the + core settings validators (a JSON ``null`` resets its field to the + default) and merges onto the current effective settings only after + every value has validated — so a rejected update never changes the + stored settings. Every core ``ValueError`` is 422 + (:func:`_raise_settings_error`); anything else re-raises. + """ + state: DatabaseState = request.app.state.db + try: + settings = await core_settings_store.update_settings( + state.database, changes + ) + except ValueError as error: + _raise_settings_error(error) + return _settings_response(settings) + + __all__ = ["router"] diff --git a/custom_components/nestquest/core/settings_store.py b/custom_components/nestquest/core/settings_store.py new file mode 100644 index 0000000..9f9c91f --- /dev/null +++ b/custom_components/nestquest/core/settings_store.py @@ -0,0 +1,210 @@ +"""Durable settings store for the NestQuest API service. + +The API service has NO Home Assistant config entry, so it cannot build +:class:`~.settings.NestQuestSettings` from ``entry.options`` the way the +integration does. This module gives the API its own durable settings +source: a JSON document kept in the EXISTING ``nestquest_meta_state`` +key/value table (:class:`~.dao_meta.MetaStateDao`) under the well-known +key :data:`SETTINGS_META_KEY` — no schema change, no new table — +validated through the SAME field table the settings object is built +from (:data:`~.settings._FIELDS`, the table driving +``from_options``/``from_options_resilient``). No validation rule is +re-implemented here. + +OWNERSHIP: the API now owns settings in the database. The +integration's Home-Assistant config-entry options remain a temporary, +SEPARATE source until Feature 18 converts the integration into a thin +API client; the two are deliberately NOT unified here, and neither +side reads the other's values. + +STORED SERIALIZATION — the document at ``SETTINGS_META_KEY`` is a JSON +object holding exactly the TEN settings fields, keyed by field name +(which equals the ``CONF_*`` option key for every field), complete +after the first update: + +- ``horizon_days`` — int >= 1, +- ``day_rollover_time``, ``morning_summary_time``, + ``afternoon_reminder_time``, ``end_of_day_report_time`` — strict + 24-hour ``HH:MM`` strings, +- ``notify_target`` — a string (a padded value is stripped by the + core's normalizer), +- ``morning_summary_enabled``, ``afternoon_reminder_enabled``, + ``end_of_day_report_enabled``, ``celebration_enabled`` — real + booleans. + +Only values whose core validators ACCEPTED are ever written, so a +stored document always round-trips through the core's option builders. +An absent key (a fresh database) resolves to the all-defaults +settings, and a corrupt document degrades to defaults or per-field +defaults rather than crashing the service (see :func:`load_settings`). +""" +from __future__ import annotations + +import asyncio +import json +from collections.abc import Mapping +from typing import Any + +from .const import LOGGER +from .dao_meta import MetaStateDao +from .db import NestQuestDatabase +from .settings import NestQuestSettings, _FIELDS + +#: The well-known ``nestquest_meta_state`` key holding the settings +#: document. One key, one JSON object — the same table the missed +#: sweep's watermark already uses, so no migration is needed. +SETTINGS_META_KEY = "settings" + +#: The per-field resolution table keyed by field name: each row is the +#: ``(default, validator)`` pair the settings module's ``_FIELDS`` table +#: carries for that field. ``update_settings`` resolves each PROVIDED +#: field through its row's validator — the SAME core validators +#: ``from_options`` applies — and never re-implements one here. +#: (Field names equal the ``CONF_*`` option keys, so one dict serves +#: both the API's field names and the stored document's keys.) +_RESOLUTIONS: dict[str, tuple[Any, Any]] = { + key: (default, validator) for _field, key, default, validator in _FIELDS +} + +#: The known field names, in the settings module's field order — the +#: allowed keys of an update body and the stored document's key set. +_FIELD_NAMES: tuple[str, ...] = tuple( + key for _field, key, _default, _validator in _FIELDS +) + +#: One asyncio.Lock per (connection wrapper, running loop), the missed +#: sweep's pattern: the whole stored-document read → merge → write span +#: is one critical section, so two concurrent updates queue instead of +#: one losing the other's change — the loser re-reads the document +#: INSIDE the lock and merges onto the winner's settings. +_SETTINGS_LOCKS: dict[tuple[int, int], asyncio.Lock] = {} + + +def _settings_lock(database: NestQuestDatabase) -> asyncio.Lock: + """Return the settings lock bound to ``database``'s running loop.""" + loop = asyncio.get_running_loop() + key = (id(database), id(loop)) + lock = _SETTINGS_LOCKS.get(key) + if lock is None: + lock = asyncio.Lock() + _SETTINGS_LOCKS[key] = lock + return lock + + +async def _load_document(database: NestQuestDatabase) -> dict[str, Any]: + """Read and decode the stored settings document (empty when unusable). + + An absent key — a fresh database that never updated settings — and + an unparseable value (invalid JSON, or JSON that is not an object) + return an empty mapping, which the option builders resolve to the + all-defaults settings; the corrupt cases log a warning first. A + parseable object is returned as stored (its per-field validity is + the builder's concern, not this reader's). + """ + raw = await MetaStateDao(database).get(SETTINGS_META_KEY) + if raw is None: + return {} + try: + document = json.loads(raw) + except ValueError: + LOGGER.warning( + "NestQuest stored settings (meta_state key %r) are not valid " + "JSON; falling back to the default settings", + SETTINGS_META_KEY, + ) + return {} + if not isinstance(document, dict): + LOGGER.warning( + "NestQuest stored settings (meta_state key %r) are not a JSON " + "object; falling back to the default settings", + SETTINGS_META_KEY, + ) + return {} + return document + + +def _named(field: str, error: ValueError) -> str: + """Return a field-named message for a rejected validator value. + + The horizon, time and notify-target validators already name their + field in the message; the boolean validator's message is generic + (``expected a boolean, got ...``), so it gets the field prefixed. + Every rejected update's error therefore names its field either way. + """ + message = str(error) + if message.startswith(f"{field} "): + return message + return f"{field}: {message}" + + +async def load_settings(database: NestQuestDatabase) -> NestQuestSettings: + """Return the current effective settings stored for ``database``. + + Reads :data:`SETTINGS_META_KEY` and resolves the stored document + through the core settings validators, with each absent field + falling back to its default. An absent key and an unparseable + document fall back to the all-defaults settings (via + :func:`_load_document`), and a parseable document with a single + invalid FIELD falls back PER FIELD — through + :meth:`NestQuestSettings.from_options_resilient`, the core's own + resilient builder — so a corrupt sibling never resets the stored + values around it. This never raises: the service must keep + serving reads even over a corrupt store. + """ + document = await _load_document(database) + return NestQuestSettings.from_options_resilient(document) + + +async def update_settings( + database: NestQuestDatabase, changes: Mapping[str, Any] +) -> NestQuestSettings: + """Validate ``changes`` and persist them as the effective settings. + + ``changes`` maps settings field names to new values. EVERY name is + checked against the known field table BEFORE anything is validated + or written: an unknown name raises a ``ValueError`` naming it (and + the known names), leaving the stored settings untouched. Each + PROVIDED field is then resolved from_options-style — its + :data:`~.settings._FIELDS` validator applied, with a JSON ``null`` + mapping to the field's default (the settings module's uniform + None-as-absent rule) — and an invalid value raises a ``ValueError`` + naming the field, also BEFORE anything is written, so a rejected + update can never leave a half-applied document behind. + + The validated changes are merged onto the current effective + settings and the merged (complete, ten-field) document is persisted + in one atomic upsert; the updated settings are returned. The whole + read → merge → write span runs inside the per-database settings + lock, so concurrent updates queue instead of interleaving. + """ + if not isinstance(changes, Mapping): + raise ValueError( + "settings changes must be a JSON object mapping field names " + "to values" + ) + unknown = sorted( + str(name) for name in changes if name not in _RESOLUTIONS + ) + if unknown: + listed = ", ".join(repr(name) for name in unknown) + known = ", ".join(_FIELD_NAMES) + raise ValueError( + f"unknown settings field(s): {listed}; known fields: {known}" + ) + async with _settings_lock(database): + current = await load_settings(database) + document: dict[str, Any] = { + field: getattr(current, field) for field in _FIELD_NAMES + } + for name, raw_value in changes.items(): + default, validator = _RESOLUTIONS[name] + try: + document[name] = validator( + default if raw_value is None else raw_value + ) + except ValueError as error: + raise ValueError(_named(name, error)) from error + await MetaStateDao(database).set( + SETTINGS_META_KEY, json.dumps(document, sort_keys=True) + ) + return NestQuestSettings(**document) diff --git a/tests/test_api_admin_settings.py b/tests/test_api_admin_settings.py new file mode 100644 index 0000000..94604a1 --- /dev/null +++ b/tests/test_api_admin_settings.py @@ -0,0 +1,398 @@ +"""Admin-plane settings route tests (task 2b3de7e5). + +These tests exercise the admin plane's settings read and update routes +against the ASGI app with httpx, reusing the shared local-RSA-key/ +stubbed-JWKS harness (:mod:`tests.admin_jwt_harness`) so an admin JWT +genuinely authenticates — signature, issuer, audience, expiry and the +nestquest-admins group — with NO network access. The stored settings +document is read back through the SAME core copy the app uses +(``nestquest_core.*``; see the two-copy caveat in api/nestquest_core.py) +via its :class:`nestquest_core.dao_meta.MetaStateDao`, so persistence is +proven against the DATABASE the routes wrote, not a parallel copy. + +Proven per the done-condition, everything read back through the API: + +- ``GET /api/v1/admin/settings`` returns the current effective settings + — the ten documented fields at their defaults on a fresh database. +- ``PATCH /api/v1/admin/settings`` PERSISTS the supplied fields and the + NEXT read reflects them; a later partial update merges onto the + current effective settings (omitted fields keep their stored value), + and the document is stored under the well-known ``nestquest_meta_state`` + key. +- An invalid value (a non-positive or boolean horizon, a non-strict + time, a non-bool toggle, a non-string target) is rejected with 422 + naming the field, and the stored settings are UNCHANGED — the next + read still shows the previous value. An unknown field is rejected + the same way, also when mixed with a valid field (validate + everything before writing). +- A corrupt stored document (invalid JSON, a non-object, an invalid + field value) does not crash the service: the read falls back to the + defaults, per field where possible. +- Authentication runs through the router's ONE ``require_admin`` + dependency: a non-admin JWT is 403 and an absent credential is 401 + on both routes — and no refused patch writes anything. +""" +from __future__ import annotations + +import importlib +import json +from types import SimpleNamespace + +import pytest + +from tests.admin_jwt_harness import ( + ADMIN_KEY, + KID, + AdminRunner, + jwks_for, + make_token, +) + +# The meta-state DAO through the SAME core copy the app uses +# (nestquest_core.*, NOT the conftest-stubbed custom_components copy) — +# used for the tests' database assertions only; the routes reach it +# through api.nestquest_core. +_dao_meta = importlib.import_module("nestquest_core.dao_meta") + +#: The well-known ``nestquest_meta_state`` key the API stores its +#: settings under — asserted literally so the contract is pinned here, +#: independent of the core constant. +SETTINGS_META_KEY = "settings" + +#: The all-defaults effective settings a fresh database reports — the +#: documented ten fields, asserted literally so the payload contract is +#: pinned here, independent of the core constants. +DEFAULT_SETTINGS = { + "horizon_days": 14, + "day_rollover_time": "00:00", + "notify_target": "", + "morning_summary_time": "08:00", + "afternoon_reminder_time": "15:00", + "end_of_day_report_time": "20:00", + "morning_summary_enabled": True, + "afternoon_reminder_enabled": True, + "end_of_day_report_enabled": True, + "celebration_enabled": True, +} + + +def _admin_headers() -> dict[str, str]: + """The Authorization header of a valid nestquest-admins JWT.""" + return {"Authorization": f"Bearer {make_token()}"} + + +@pytest.fixture +async def settings_client(temp_db_path: str) -> SimpleNamespace: + """An admin-plane app client plus its live database. + + The same runner the children tests use (stubbed JWKS, no network); + the fixture also exposes the app's ONE database connection so tests + can read (or corrupt) the stored settings document itself. + """ + runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) + async with runner as client: + yield SimpleNamespace( + client=client, + database=runner.app.state.db.database, + ) + + +# --- read: the effective settings, defaults on a fresh database -------- + + +async def test_get_settings_returns_defaults_on_a_fresh_database( + settings_client: SimpleNamespace, +) -> None: + """GET returns the ten documented fields at their defaults.""" + response = await settings_client.client.get( + "/api/v1/admin/settings", headers=_admin_headers() + ) + assert response.status_code == 200 + assert response.json() == DEFAULT_SETTINGS + + +# --- update: persists, and the NEXT read reflects it -------------------- + + +async def test_update_persists_and_the_next_read_reflects_it( + settings_client: SimpleNamespace, +) -> None: + """PATCH persists the supplied fields; GET reads them back.""" + client = settings_client.client + changes = { + "horizon_days": 21, + "day_rollover_time": "03:30", + "notify_target": "notify.mobile_app_dads_phone", + "celebration_enabled": False, + } + patched = await client.patch( + "/api/v1/admin/settings", json=changes, headers=_admin_headers() + ) + assert patched.status_code == 200 + expected = {**DEFAULT_SETTINGS, **changes} + assert patched.json() == expected + + # The NEXT read reflects the persisted settings... + reread = await client.get( + "/api/v1/admin/settings", headers=_admin_headers() + ) + assert reread.status_code == 200 + assert reread.json() == expected + + # ...and the DATABASE holds the merged document: the complete ten + # fields, keyed by field name, under the well-known meta_state key. + raw = await _dao_meta.MetaStateDao(settings_client.database).get( + SETTINGS_META_KEY + ) + assert raw is not None + assert json.loads(raw) == expected + + +async def test_partial_update_merges_onto_the_current_settings( + settings_client: SimpleNamespace, +) -> None: + """A later PATCH changes only its own fields; the rest survive.""" + client = settings_client.client + first = await client.patch( + "/api/v1/admin/settings", + json={"horizon_days": 7, "notify_target": " notify.x "}, + headers=_admin_headers(), + ) + assert first.status_code == 200 + # The notify target went through the core's own normalizer: stripped. + assert first.json()["horizon_days"] == 7 + assert first.json()["notify_target"] == "notify.x" + + second = await client.patch( + "/api/v1/admin/settings", + json={"morning_summary_time": "07:45"}, + headers=_admin_headers(), + ) + assert second.status_code == 200 + assert second.json() == { + **DEFAULT_SETTINGS, + "horizon_days": 7, + "notify_target": "notify.x", + "morning_summary_time": "07:45", + } + + reread = await client.get( + "/api/v1/admin/settings", headers=_admin_headers() + ) + assert reread.json() == second.json() + + +async def test_empty_update_is_a_noop( + settings_client: SimpleNamespace, +) -> None: + """PATCHing no fields leaves the effective settings as they are.""" + client = settings_client.client + response = await client.patch( + "/api/v1/admin/settings", json={}, headers=_admin_headers() + ) + assert response.status_code == 200 + assert response.json() == DEFAULT_SETTINGS + + +async def test_null_resets_a_field_to_its_default( + settings_client: SimpleNamespace, +) -> None: + """A JSON ``null`` maps to the field's default (None-as-absent).""" + client = settings_client.client + set_horizon = await client.patch( + "/api/v1/admin/settings", + json={"horizon_days": 7}, + headers=_admin_headers(), + ) + assert set_horizon.status_code == 200 + assert set_horizon.json()["horizon_days"] == 7 + + reset = await client.patch( + "/api/v1/admin/settings", + json={"horizon_days": None}, + headers=_admin_headers(), + ) + assert reset.status_code == 200 + assert reset.json()["horizon_days"] == 14 + + reread = await client.get( + "/api/v1/admin/settings", headers=_admin_headers() + ) + assert reread.json()["horizon_days"] == 14 + + +# --- validation: rejected values never change the stored settings ------- + + +@pytest.mark.parametrize( + ("changes", "field"), + [ + ({"horizon_days": 0}, "horizon_days"), + ({"horizon_days": -3}, "horizon_days"), + # A bool is rejected — the core's guardrail survives the route. + ({"horizon_days": True}, "horizon_days"), + ({"horizon_days": "soon"}, "horizon_days"), + ({"day_rollover_time": "25:00"}, "day_rollover_time"), + ({"day_rollover_time": "7:30"}, "day_rollover_time"), + ({"morning_summary_enabled": "yes"}, "morning_summary_enabled"), + ({"celebration_enabled": 1}, "celebration_enabled"), + ({"notify_target": 5}, "notify_target"), + ], +) +async def test_invalid_value_is_rejected_and_stored_settings_unchanged( + settings_client: SimpleNamespace, changes: dict[str, object], field: str +) -> None: + """A rejected value is 422 naming the field; the next read is same.""" + client = settings_client.client + seeded = await client.patch( + "/api/v1/admin/settings", + json={ + "horizon_days": 21, + "day_rollover_time": "01:30", + "morning_summary_enabled": False, + }, + headers=_admin_headers(), + ) + assert seeded.status_code == 200 + before = seeded.json() + + response = await client.patch( + "/api/v1/admin/settings", json=changes, headers=_admin_headers() + ) + assert response.status_code == 422, changes + assert field in str(response.json()["detail"]), changes + + after = await client.get( + "/api/v1/admin/settings", headers=_admin_headers() + ) + assert after.status_code == 200 + assert after.json() == before + + +async def test_unknown_field_is_rejected_and_stored_settings_unchanged( + settings_client: SimpleNamespace, +) -> None: + """An unknown field is 422 naming it — alone or beside a valid one.""" + client = settings_client.client + unknown = await client.patch( + "/api/v1/admin/settings", + json={"bogus_setting": 1}, + headers=_admin_headers(), + ) + assert unknown.status_code == 422 + assert "bogus_setting" in str(unknown.json()["detail"]) + + mixed = await client.patch( + "/api/v1/admin/settings", + json={"horizon_days": 7, "bogus_setting": 1}, + headers=_admin_headers(), + ) + assert mixed.status_code == 422 + assert "bogus_setting" in str(mixed.json()["detail"]) + + # Neither rejection wrote anything, not even the valid sibling. + after = await client.get( + "/api/v1/admin/settings", headers=_admin_headers() + ) + assert after.json() == DEFAULT_SETTINGS + + +async def test_non_object_body_is_422( + settings_client: SimpleNamespace, +) -> None: + """A body that is not a JSON object is FastAPI's 422 (the shape).""" + client = settings_client.client + for body in ([1, 2], "nope", 5): + response = await client.patch( + "/api/v1/admin/settings", json=body, headers=_admin_headers() + ) + assert response.status_code == 422, body + # A bare JSON null is likewise not an object. + response = await client.patch( + "/api/v1/admin/settings", + content=b"null", + headers={**_admin_headers(), "content-type": "application/json"}, + ) + assert response.status_code == 422 + + after = await client.get( + "/api/v1/admin/settings", headers=_admin_headers() + ) + assert after.json() == DEFAULT_SETTINGS + + +# --- resilience: a corrupt stored document does not crash the service --- + + +async def test_corrupt_stored_document_falls_back_to_defaults( + settings_client: SimpleNamespace, +) -> None: + """GET degrades gracefully over an unusable settings document.""" + client = settings_client.client + dao = _dao_meta.MetaStateDao(settings_client.database) + + # Not JSON at all: all defaults, no crash. + await dao.set(SETTINGS_META_KEY, "{not-json") + response = await client.get( + "/api/v1/admin/settings", headers=_admin_headers() + ) + assert response.status_code == 200 + assert response.json() == DEFAULT_SETTINGS + + # JSON but not an object: all defaults, no crash. + await dao.set(SETTINGS_META_KEY, json.dumps([1, 2])) + response = await client.get( + "/api/v1/admin/settings", headers=_admin_headers() + ) + assert response.status_code == 200 + assert response.json() == DEFAULT_SETTINGS + + # An object with one invalid field: PER-FIELD fallback — the valid + # sibling survives, the invalid one falls back to its default. + await dao.set( + SETTINGS_META_KEY, + json.dumps({"horizon_days": 7, "day_rollover_time": "soon"}), + ) + response = await client.get( + "/api/v1/admin/settings", headers=_admin_headers() + ) + assert response.status_code == 200 + assert response.json() == {**DEFAULT_SETTINGS, "horizon_days": 7} + + +# --- authentication: the ONE require_admin dependency guards both routes + + +async def test_settings_routes_refuse_non_admin_and_absent_credentials( + settings_client: SimpleNamespace, +) -> None: + """A non-admin JWT is 403, an absent credential is 401; none write.""" + client = settings_client.client + non_admin = make_token(groups=("some-other-group",)) + forbidden_get = await client.get( + "/api/v1/admin/settings", + headers={"Authorization": f"Bearer {non_admin}"}, + ) + assert forbidden_get.status_code == 403 + assert forbidden_get.json()["detail"] == ( + "Admin access requires membership in the nestquest-admins group" + ) + forbidden_patch = await client.patch( + "/api/v1/admin/settings", + json={"horizon_days": 7}, + headers={"Authorization": f"Bearer {non_admin}"}, + ) + assert forbidden_patch.status_code == 403 + + absent_get = await client.get("/api/v1/admin/settings") + assert absent_get.status_code == 401 + absent_patch = await client.patch( + "/api/v1/admin/settings", json={"horizon_days": 7} + ) + assert absent_patch.status_code == 401 + + # No refused request changed anything: the settings are untouched. + after = await client.get( + "/api/v1/admin/settings", headers=_admin_headers() + ) + assert after.json() == DEFAULT_SETTINGS