From 7fec3c76ef9dc706dc000c38d08e83802976f2bb Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Mon, 21 Sep 2026 16:13:41 -0400 Subject: [PATCH 01/15] FastAPI skeleton: health, OpenAPI, core wiring (task 042dfc5c) FastAPI skeleton: health, OpenAPI, core wiring (task 042dfc5c) --- api/__init__.py | 19 +++ api/app.py | 99 ++++++++++++ api/config.py | 68 ++++++++ api/database.py | 82 ++++++++++ api/nestquest_core.py | 110 +++++++++++++ pyproject.toml | 9 ++ tests/test_api_health.py | 324 +++++++++++++++++++++++++++++++++++++++ uv.lock | 272 ++++++++++++++++++++++++++++++++ 8 files changed, 983 insertions(+) create mode 100644 api/__init__.py create mode 100644 api/app.py create mode 100644 api/config.py create mode 100644 api/database.py create mode 100644 api/nestquest_core.py create mode 100644 tests/test_api_health.py diff --git a/api/__init__.py b/api/__init__.py new file mode 100644 index 0000000..0e62779 --- /dev/null +++ b/api/__init__.py @@ -0,0 +1,19 @@ +"""NestQuest API service. + +A standalone FastAPI deployable that owns the NestQuest database and +exposes it over HTTP to the Home Assistant integration (panel plane) and +the admin PWA (admin plane). See Feature 16 (task cd03735b) in the +project tracker for the full contract. + +This package imports the NestQuest domain ``core`` WITHOUT executing +``custom_components/nestquest/__init__.py`` — that parent package's +``__init__.py`` imports ``homeassistant``, which is absent from the +API's runtime. The coupling is encapsulated in +:mod:`api.nestquest_core`, which loads ``core`` by file location with +:mod:`importlib.util` and registers it under the distinct top-level name +``nestquest_core`` (no ``sys.path`` mutation, no generic top-level +names published). It is a TRANSITIONAL coupling that Feature 18 will +revisit: once the integration becomes a thin API client and ``core`` +moves out of the integration bundle, this helper goes away. +""" +from __future__ import annotations diff --git a/api/app.py b/api/app.py new file mode 100644 index 0000000..bb35c1c --- /dev/null +++ b/api/app.py @@ -0,0 +1,99 @@ +"""The NestQuest FastAPI application. + +This module builds the ASGI app for the NestQuest API service (Feature +16). The skeleton here wires the bundled ``core`` (Feature 15) and +exposes the two endpoints every later task depends on: + +- ``GET /health`` — returns ``200`` with a JSON status body once the + app has imported core, opened the database, and applied migrations; + a cheap ``SELECT 1`` confirms the connection is live without leaking + the filesystem path on an unauthenticated route. +- ``GET /docs`` — FastAPI's auto-served OpenAPI document (Swagger UI); + ``/openapi.json`` is the raw schema. No extra wiring is needed; + FastAPI serves both by default. + +The database is opened and migrated in a FastAPI lifespan context so a +startup failure (e.g. an unwritable path) surfaces as a startup error +rather than the first request. The connection is closed on shutdown. + +This module exports ONLY the :func:`create_app` factory — there is no +module-level app instance, because building one at import time would +read ``NESTQUEST_DB_PATH`` at import (and fail-fast on a missing value) +before any worker is ready to report it. Serve with:: + + uvicorn --factory api.app:create_app + +so the factory runs on each worker startup and reads the environment +then. +""" +from __future__ import annotations + +from contextlib import asynccontextmanager +from collections.abc import AsyncIterator + +from fastapi import FastAPI + +from api.config import ApiConfig +from api.database import DatabaseState, make_database + + +def _build_lifespan(db_path: str): + """Return a lifespan that opens+migrates then closes one database. + + Captured as a closure so the app factory can hand the configured + path in without threading it through FastAPI's lifespan signature. + """ + + @asynccontextmanager + async def lifespan(app: FastAPI) -> AsyncIterator[None]: + state = make_database(db_path) + app.state.db = state + try: + await state.open_and_migrate() + yield + finally: + await state.close() + + return lifespan + + +def create_app(config: ApiConfig | None = None) -> FastAPI: + """Build the NestQuest API FastAPI app. + + ``config`` defaults to one built from the process environment, so + ``uvicorn --factory api.app:create_app`` picks up + ``NESTQUEST_DB_PATH`` (and fails fast on startup if it is missing). + Tests pass an explicit config so each test gets a hermetic temp + SQLite file. + """ + cfg = config if config is not None else ApiConfig.from_env() + + app = FastAPI( + title="NestQuest API", + description=( + "Standalone HTTP service that owns the NestQuest database and " + "exposes it to the Home Assistant integration (panel plane) and " + "the admin PWA (admin plane)." + ), + version="0.1.0", + lifespan=_build_lifespan(cfg.db_path), + ) + + @app.get("/health", summary="Service health") + async def health() -> dict[str, str]: + """Return service health. + + Returns ``200`` with ``{"status": "ok"}`` once the lifespan has + opened and migrated the database. A cheap ``SELECT 1`` against + the live connection confirms the database is reachable WITHOUT + leaking the filesystem path on an unauthenticated route; if the + connection is dead the query raises and the probe fails. + """ + state: DatabaseState = app.state.db + await state.database.fetch_one("SELECT 1") + return {"status": "ok"} + + return app + + +__all__ = ["create_app"] diff --git a/api/config.py b/api/config.py new file mode 100644 index 0000000..6237abc --- /dev/null +++ b/api/config.py @@ -0,0 +1,68 @@ +"""Configuration for the NestQuest API service. + +The API reads its single configuration knob — the SQLite database path — +from the ``NESTQUEST_DB_PATH`` environment variable. Unlike a dev +convenience default, a missing or empty value is a configuration ERROR, +not a silent fallback to a predictable world-writable temp path: the API +owns the database (it is the writer of record per Feature 16's +contract), and silently dropping data into ``$TMPDIR/nestquest.db`` +would lose it at reboot. ``ApiConfig.from_env`` therefore RAISES when +the variable is unset or empty. This mirrors the integration's +"explicit settings object" discipline (Feature 15's +``core.settings``): the API never reads HA config; callers pass the +resolved path into the database wiring. + +Serving: the app is built with the factory ``api.app:create_app`` — run +it with ``uvicorn --factory api.app:create_app`` so the factory reads +the environment on each worker startup rather than at import time. +""" +from __future__ import annotations + +import os +from dataclasses import dataclass + +#: Environment variable holding the SQLite file path. The API opens +#: this file on startup (creating it if absent) and applies migrations. +#: REQUIRED — unset or empty raises :class:`ConfigError` from +#: :meth:`ApiConfig.from_env`. +DB_PATH_ENV = "NESTQUEST_DB_PATH" + + +class ConfigError(RuntimeError): + """Raised when required API configuration is missing or invalid.""" + + +@dataclass(frozen=True) +class ApiConfig: + """Resolved API configuration. + + Currently a single field — the SQLite path — but kept as a dataclass + so later tasks (auth, SSE, settings persistence) can extend it + without touching the call sites that read ``db_path``. + """ + + db_path: str + + @classmethod + def from_env(cls, env: dict[str, str] | None = None) -> "ApiConfig": + """Build configuration from the process environment. + + ``env`` defaults to ``os.environ``; tests pass an explicit dict + so they are hermetic (no monkeypatch of the global environ). + + Raises :class:`ConfigError` when ``NESTQUEST_DB_PATH`` is unset + or empty/whitespace-only — there is no silent default, because a + default in the temp directory would silently lose data at reboot. + """ + source = env if env is not None else os.environ + raw = source.get(DB_PATH_ENV, "").strip() + if not raw: + raise ConfigError( + f"{DB_PATH_ENV} must be set to a writable SQLite file path " + f"(it is unset or empty — the API will not fall back to a " + f"temp directory and silently lose data)" + ) + return cls(db_path=raw) + + +__all__ = ["ApiConfig", "ConfigError", "DB_PATH_ENV"] diff --git a/api/database.py b/api/database.py new file mode 100644 index 0000000..8142a64 --- /dev/null +++ b/api/database.py @@ -0,0 +1,82 @@ +"""Database wiring for the NestQuest API service. + +The API owns the database (it is the writer of record per Feature 16's +contract). This module owns one application-lifetime +:class:`core.db.NestQuestDatabase`: it opens the SQLite file at the +configured path on startup, applies migrations (creating the schema on +a fresh file), and closes the connection on shutdown. + +The executor callable passed into ``NestQuestDatabase`` mirrors the +contract of ``hass.async_add_executor_job``: schedule a synchronous +callable on a worker and await its result. HA's own version lives on +the event loop; the API is not HA, so it uses +:func:`asyncio.to_thread` for the same effect — sqlite3 calls run on a +worker thread and only plain data returns to the event loop. +""" +from __future__ import annotations + +import asyncio +import logging +from typing import Any + +from api.nestquest_core import core_db, core_migrations + +LOGGER = logging.getLogger(__name__) + +#: The async SQLite wrapper. Re-exported so route handlers and later +#: tasks reach it through the API package, not the bundled core. +NestQuestDatabase = core_db.NestQuestDatabase + +#: The migration runner. Re-exported for the same reason. +apply_migrations = core_migrations.apply_migrations + + +async def _executor(fn: Any, *args: Any, **kwargs: Any) -> Any: + """Run a synchronous callable on a worker thread and await its result. + + Mirrors the contract of ``hass.async_add_executor_job`` so the + bundled ``NestQuestDatabase`` (written against that contract) works + unchanged in the API process: sqlite3 calls never block the event + loop, and only plain Python data (row tuples, + :class:`core.db.ExecutionResult`) crosses back. + """ + return await asyncio.to_thread(fn, *args, **kwargs) + + +class DatabaseState: + """Holds the live database wrapper and its resolved path. + + A small container so the lifespan and the route handlers share one + authoritative reference; FastAPI's lifespan stores one of these on + ``app.state.db`` and the health route reads it back. + """ + + __slots__ = ("database", "db_path") + + def __init__(self, database: NestQuestDatabase, db_path: str) -> None: + self.database = database + self.db_path = db_path + + async def open_and_migrate(self) -> None: + """Open the connection and apply migrations to the latest version.""" + LOGGER.info("Opening NestQuest database at %s", self.db_path) + await self.database.open(self.db_path) + version = await apply_migrations(self.database) + LOGGER.info("NestQuest schema at version %d", version) + + async def close(self) -> None: + """Close the connection; safe to call even if open failed.""" + await self.database.close() + + +def make_database(db_path: str) -> DatabaseState: + """Construct a :class:`DatabaseState` for the given path (not yet open).""" + return DatabaseState(NestQuestDatabase(_executor), db_path) + + +__all__ = [ + "DatabaseState", + "NestQuestDatabase", + "apply_migrations", + "make_database", +] diff --git a/api/nestquest_core.py b/api/nestquest_core.py new file mode 100644 index 0000000..863cf34 --- /dev/null +++ b/api/nestquest_core.py @@ -0,0 +1,110 @@ +"""Transitional coupling: import ``core`` without importing Home Assistant. + +The NestQuest domain ``core`` package (Feature 15) is bundled INSIDE the +Home Assistant integration at ``custom_components/nestquest/core/``. +Importing it the obvious way — ``import custom_components.nestquest.core`` +— would execute ``custom_components/nestquest/__init__.py``, which +imports ``homeassistant``. Home Assistant is NOT installed in the API +service's runtime, so that import raises ``ModuleNotFoundError`` and the +API cannot start. + +``core`` itself is a clean, Home-Assistant-free package: its +``__init__.py`` is a bare docstring and every internal import is +relative (``from .db import ...``). It can therefore be imported by +file location as a standalone package. + +This module loads ``core`` with :func:`importlib.util.spec_from_file_location` +— registering it under the distinct top-level name ``nestquest_core`` +with ``submodule_search_locations`` set to the bundled ``core`` +directory — so its relative imports resolve against that directory +without touching ``sys.path``. No generic top-level name (``db``, +``const``, ``schema``, ...) is published, so nothing here can silently +shadow a future dependency, and the integration's HA-coupled +``__init__.py`` is never executed: ``custom_components.nestquest`` is +never imported. + +Usage:: + + from api.nestquest_core import core_db, core_migrations + # core_db.NestQuestDatabase, core_migrations.apply_migrations, ... + +This is a TRANSITIONAL coupling. Feature 18 (task 430f7f98) converts +the integration into a thin API client and moves ``core`` out of the +integration bundle; once that lands, this bootstrap helper goes away and +the API imports ``core`` as an ordinary installed dependency. + +NOTE on the pytest process: the suite's ``conftest.py`` stubs the +parent integration package and imports ``custom_components.nestquest.core.*`` +BEFORE any API test runs, so two copies of the core package coexist in +the pytest process — the one registered here as ``nestquest_core.*`` +(loaded by file location) and the conftest-stubbed +``custom_components.nestquest.core.*``. Later tasks must NOT compare +class or exception identity across these two copies: a +``nestquest_core.db.NestQuestDatabase`` is a DIFFERENT class object from +``custom_components.nestquest.core.db.NestQuestDatabase`` even though +they share source. Operate on a single copy within any one code path. +""" +from __future__ import annotations + +import importlib +import importlib.util +import sys +from pathlib import Path +from types import ModuleType + +#: The repository root (this file is ``/api/nestquest_core.py``). +_REPO_ROOT: Path = Path(__file__).resolve().parent.parent + +#: The bundled ``core`` package directory, inside the HA integration. +_CORE_DIR: Path = _REPO_ROOT / "custom_components" / "nestquest" / "core" + +#: The ``core`` package's ``__init__.py`` — a bare docstring, so loading +#: it by file location executes no imports of its own. +_CORE_INIT: Path = _CORE_DIR / "__init__.py" + +#: The distinct top-level name under which the bundled ``core`` package +#: is registered in ``sys.modules``. A distinctive name (rather than the +#: generic ``core``) avoids publishing shadow-able top-level names and +#: keeps the bundled copy separate from the conftest-stubbed +#: ``custom_components.nestquest.core`` present in the pytest process. +_CORE_MODULE_NAME = "nestquest_core" + + +def _load_core() -> ModuleType: + """Load the bundled ``core`` package by file location and return it. + + Registers the package under :data:`_CORE_MODULE_NAME` in + ``sys.modules`` with ``submodule_search_locations`` pointing at the + bundled directory, so its relative imports (``from .db import ...``) + resolve to ``nestquest_core.`` against that directory + without mutating ``sys.path``. Idempotent: a second call returns + the already-registered module. + """ + existing = sys.modules.get(_CORE_MODULE_NAME) + if existing is not None: + return existing + spec = importlib.util.spec_from_file_location( + _CORE_MODULE_NAME, + _CORE_INIT, + submodule_search_locations=[str(_CORE_DIR)], + ) + if spec is None or spec.loader is None: + raise ImportError(f"could not load core from {_CORE_INIT}") + module = importlib.util.module_from_spec(spec) + sys.modules[_CORE_MODULE_NAME] = module + spec.loader.exec_module(module) + return module + + +#: The bundled core package, loaded by file location and registered as +#: ``nestquest_core`` in ``sys.modules``. Held here so callers import the +#: submodules through it (``nestquest_core.db``, ``nestquest_core.migrations``) +#: the same relative way the integration does. +core: ModuleType = _load_core() + +#: Re-exported for convenience; route handlers import these names from +#: here rather than reaching into the core package directly. +core_db = importlib.import_module(f"{_CORE_MODULE_NAME}.db") +core_migrations = importlib.import_module(f"{_CORE_MODULE_NAME}.migrations") + +__all__ = ["core", "core_db", "core_migrations"] diff --git a/pyproject.toml b/pyproject.toml index 1857556..754ff79 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -7,11 +7,20 @@ requires-python = ">=3.11" dependencies = [] [dependency-groups] +# fastapi and uvicorn live in the DEV group only so the monorepo's test +# venv can exercise the API app (tests under tests/test_api_health.py). +# They are NOT runtime dependencies of the NestQuest HA integration. +# When the API deployable is packaged (Feature 17), its runtime +# dependencies — fastapi, uvicorn, and any others — MUST be declared in +# that deployable's own dependency manifest, not here. dev = [ + "fastapi>=0.115", + "httpx>=0.28", "jinja2>=3", "pytest>=8.0.0", "pytest-asyncio>=0.24", "pyyaml>=6", + "uvicorn>=0.30", "voluptuous>=0.14", ] diff --git a/tests/test_api_health.py b/tests/test_api_health.py new file mode 100644 index 0000000..f9f120e --- /dev/null +++ b/tests/test_api_health.py @@ -0,0 +1,324 @@ +"""FastAPI skeleton tests: health, OpenAPI, core wiring (task 042dfc5c). + +These tests start the NestQuest API app with httpx against the ASGI +app and assert the skeleton contract of Feature 16's first task: + +- ``GET /health`` returns 200 with a JSON status body (``status: ok``) + and does NOT leak the filesystem path; +- ``GET /docs`` (Swagger UI) is served, and ``GET /openapi.json`` + returns the OpenAPI document; +- the app wires core (opens the configured SQLite file and applies + migrations on startup via the lifespan) and closes the connection on + shutdown (``connected is False`` afterwards); +- ``ApiConfig.from_env`` raises when ``NESTQUEST_DB_PATH`` is missing or + empty, and returns the value when set; +- a startup failure (unwritable path / non-SQLite file) surfaces from + the lifespan rather than the first request; +- each test is hermetic — it points ``NESTQUEST_DB_PATH`` at a fresh + temp file. + +A SEPARATE subprocess test asserts the stronger invariant the task +calls out: the app imports the bundled ``core`` WITHOUT importing +``homeassistant``. That cannot run inside the pytest process because +``tests/conftest.py`` installs mock ``homeassistant`` modules globally +before any test imports the integration; a subprocess with a clean +``sys.modules`` is the only honest proof (the same approach +``tests/test_core_no_ha_smoke.py`` takes for the core bundle). +""" +from __future__ import annotations + +import subprocess +import sys +import tempfile +import textwrap +from pathlib import Path + +import httpx +import pytest + +from api.app import create_app +from api.config import ApiConfig, ConfigError +from api.database import DatabaseState, _executor, make_database + +#: The repo root, so the no-HA subprocess can import the API package. +_REPO_ROOT = Path(__file__).resolve().parent.parent + +#: The marker the no-HA subprocess prints on success. +_SUCCESS_MARKER = "API NO-HA IMPORT OK" + + +@pytest.fixture +def temp_db_path(tmp_path: Path) -> str: + """A fresh per-test SQLite path under pytest's tmp_path.""" + return str(tmp_path / "nestquest.db") + + +class _AppRunner: + """Run the app's lifespan around an httpx AsyncClient. + + httpx's :class:`ASGITransport` does NOT run the ASGI lifespan on its + own, so the database would never open. Starlette exposes the app's + lifespan as ``app.router.lifespan_context``; wrapping the client + session in it drives the same startup/shutdown path uvicorn would, + so ``open_and_migrate`` runs before the first request and ``close`` + runs after the last. + + ``__aexit__`` uses try/finally so a client-side error still shuts + the lifespan down — otherwise a failure mid-test would leak the + database connection open. + """ + + def __init__(self, db_path: str) -> None: + self.app = create_app(ApiConfig(db_path=db_path)) + + async def __aenter__(self) -> httpx.AsyncClient: + self._lifespan = self.app.router.lifespan_context(self.app) + await self._lifespan.__aenter__() + self._client = httpx.AsyncClient( + transport=httpx.ASGITransport(app=self.app), + base_url="http://testserver", + ) + return await self._client.__aenter__() + + async def __aexit__(self, *exc) -> None: + try: + await self._client.__aexit__(*exc) + finally: + await self._lifespan.__aexit__(*exc) + + +@pytest.fixture +async def app_client(temp_db_path: str) -> httpx.AsyncClient: + async with _AppRunner(temp_db_path) as client: + yield client + + +# --- ApiConfig.from_env ------------------------------------------------- + + +def test_from_env_returns_set_value() -> None: + """A set, non-empty NESTQUEST_DB_PATH is returned verbatim.""" + cfg = ApiConfig.from_env({"NESTQUEST_DB_PATH": "/data/nq.db"}) + assert cfg.db_path == "/data/nq.db" + + +def test_from_env_raises_on_empty_value() -> None: + """An empty/whitespace-only value raises ConfigError (no silent default).""" + with pytest.raises(ConfigError): + ApiConfig.from_env({"NESTQUEST_DB_PATH": " "}) + + +def test_from_env_raises_on_missing_value() -> None: + """A missing NESTQUEST_DB_PATH raises ConfigError (no silent default).""" + with pytest.raises(ConfigError): + ApiConfig.from_env({}) + + +# --- health / openapi / docs ------------------------------------------- + + +async def test_health_returns_200_with_status_body( + app_client: httpx.AsyncClient, +) -> None: + """GET /health returns 200 with status ok and no filesystem path.""" + response = await app_client.get("/health") + assert response.status_code == 200 + body = response.json() + assert body == {"status": "ok"} + assert "db_path" not in body + + +async def test_openapi_document_is_served( + app_client: httpx.AsyncClient, +) -> None: + """GET /openapi.json returns the OpenAPI document.""" + response = await app_client.get("/openapi.json") + assert response.status_code == 200 + schema = response.json() + assert schema["openapi"].startswith("3.") + assert "/health" in schema["paths"] + + +async def test_docs_swagger_ui_is_served(app_client: httpx.AsyncClient) -> None: + """GET /docs serves the Swagger UI HTML (OpenAPI at /docs).""" + response = await app_client.get("/docs") + assert response.status_code == 200 + assert "swagger" in response.text.lower() + + +# --- core wiring & shutdown -------------------------------------------- + + +async def test_app_wires_core_and_applies_migrations( + temp_db_path: str, +) -> None: + """The lifespan opens the SQLite file and applies migrations. + + After startup, the file exists and carries the NestQuest schema + version stamp — proving the app imported core, opened the database + at the configured path, and ran the migration runner. + """ + async with _AppRunner(temp_db_path): + assert Path(temp_db_path).exists() + # Re-open the file with a fresh core database to read the version + # stamp independent of the app's connection. Reuse the API's own + # executor so the test exercises the same wiring the app does. + from api.nestquest_core import core_db, core_migrations + + database = core_db.NestQuestDatabase(_executor) + await database.open(temp_db_path) + try: + version = await core_migrations.read_schema_version(database) + finally: + await database.close() + assert version >= 1 + + +async def test_shutdown_closes_the_connection(temp_db_path: str) -> None: + """The lifespan closes the database connection on shutdown. + + After the runner exits, ``DatabaseState.database.connected`` is + False — proving ``close()`` ran (the lifespan's finally block) even + on a clean exit. + """ + runner = _AppRunner(temp_db_path) + async with runner as _client: + state: DatabaseState = runner.app.state.db + assert state.database.connected + assert not state.database.connected + + +# --- startup failure ---------------------------------------------------- + + +async def test_startup_fails_on_unwritable_path(tmp_path: Path) -> None: + """A database path whose parent directory does not exist fails fast. + + ``sqlite3.connect`` raises ``OperationalError`` for a path under a + non-existent directory; the lifespan surfaces that as a startup + error rather than the first request. The connection is closed + through the lifespan's finally block (``close`` is a no-op on a + never-opened wrapper). + """ + bad_path = str(tmp_path / "does_not_exist" / "nestquest.db") + runner = _AppRunner(bad_path) + with pytest.raises(Exception): + async with runner: + pass + + +async def test_startup_fails_on_non_sqlite_file(tmp_path: Path) -> None: + """A path pointing at a non-SQLite file fails fast on startup. + + Opening an existing file that is not a SQLite database makes the + ``PRAGMA journal_mode=WAL`` run during :meth:`open` raise + ``DatabaseError: file is not a database``; the lifespan surfaces it. + """ + bad_file = tmp_path / "not_a_db.bin" + bad_file.write_bytes(b"this is not a sqlite database") + runner = _AppRunner(str(bad_file)) + with pytest.raises(Exception): + async with runner: + pass + + +# --- no-HA subprocess --------------------------------------------------- + +#: The no-HA subprocess script. Runs with ``sys.executable -c`` so it +#: starts with a clean ``sys.modules`` and no ``homeassistant`` installed +#: (the suite's conftest stubs are not present in a subprocess). It +#: builds the app — which triggers the core bootstrap — and asserts +#: neither ``homeassistant`` nor ``custom_components`` entered +#: ``sys.modules``, and that the loaded core package's ``__file__`` +#: points at the bundled directory. +_NO_HA_SCRIPT = textwrap.dedent( + """\ + import sys + import tempfile + from pathlib import Path + + sys.path.insert(0, sys.argv[1]) + REPO_ROOT = Path(sys.argv[1]) + + # homeassistant must not be present before we touch the API. + assert "homeassistant" not in sys.modules, ( + "homeassistant already in sys.modules before app import — the " + "subprocess is not a clean no-HA process" + ) + + from api.app import create_app + from api.config import ApiConfig + from api.nestquest_core import core + + # The app build imports core via api.nestquest_core. core is a + # Home-Assistant-free package; if it (or its loader) ever drags HA + # in, this fails. + assert "homeassistant" not in sys.modules, ( + "homeassistant imported while building the NestQuest API app — " + "the core wiring is no longer Home-Assistant-free" + ) + # The loader registers core under the distinct name "nestquest_core" + # WITHOUT executing custom_components.nestquest.__init__, so the + # parent integration package must never be registered, and no + # sys.path mutation places the integration directory on sys.path. + assert "custom_components" not in sys.modules, ( + "custom_components registered in sys.modules — the core loader " + "executed the integration's HA-coupled __init__ instead of loading " + "core by file location" + ) + assert "nestquest_core" in sys.modules, "core was not imported" + + # core.__file__ must point at the bundled directory inside the + # integration, proving the file-location loader resolved against the + # bundled package rather than some other "core" on sys.path. + expected_core_dir = ( + REPO_ROOT / "custom_components" / "nestquest" / "core" + ) + assert Path(core.__file__).resolve() == ( + expected_core_dir / "__init__.py" + ).resolve(), ( + f"core.__file__={core.__file__} does not point at the bundled " + f"directory {expected_core_dir}" + ) + + # Build the app (the lifespan is NOT run here, so the path is never + # opened — it just needs to be a non-empty string to satisfy the + # fail-fast config check). Use a temp path so nothing is created. + app = create_app(ApiConfig(db_path=str( + Path(tempfile.gettempdir()) / "nq-noha-buildonly.db" + ))) + assert app is not None + + print("API NO-HA IMPORT OK") + """ +) + + +def test_app_imports_core_without_importing_homeassistant() -> None: + """The app imports core without importing homeassistant (subprocess). + + Runs in a subprocess because ``tests/conftest.py`` installs mock + ``homeassistant`` modules into the pytest process's ``sys.modules`` + before any test runs, so an in-process check could never + distinguish "core never touched HA" from "conftest already stubbed + HA for us". The subprocess is a genuine no-HA process; if the + core loader (or core itself) ever grows a ``homeassistant`` + import, the subprocess exits non-zero with ``ModuleNotFoundError``. + """ + result = subprocess.run( + [sys.executable, "-c", _NO_HA_SCRIPT, str(_REPO_ROOT)], + capture_output=True, + text=True, + timeout=120, + ) + if result.returncode != 0: + raise AssertionError( + f"no-HA app-import subprocess exited {result.returncode}.\n" + f"--- stdout ---\n{result.stdout}\n" + f"--- stderr ---\n{result.stderr}" + ) + assert _SUCCESS_MARKER in result.stdout, ( + f"success marker {_SUCCESS_MARKER!r} not printed.\n" + f"--- stdout ---\n{result.stdout}\n" + f"--- stderr ---\n{result.stderr}" + ) diff --git a/uv.lock b/uv.lock index f334244..1f57235 100644 --- a/uv.lock +++ b/uv.lock @@ -2,6 +2,55 @@ version = 1 revision = 3 requires-python = ">=3.11" +[[package]] +name = "annotated-doc" +version = "0.0.5" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/8e/38aa427ed5402449e226975b649c5dc73ccadfefeb95e6aecb8f8ea4b6b6/annotated_doc-0.0.5.tar.gz", hash = "sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb", size = 10758, upload-time = "2026-07-28T13:50:58.129Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/3e/30/e900b21425a860e195f32e37657aa1f7c7f2b1bfb26f03ca209b90933c06/annotated_doc-0.0.5-py3-none-any.whl", hash = "sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101", size = 5302, upload-time = "2026-07-28T13:50:57.239Z" }, +] + +[[package]] +name = "annotated-types" +version = "0.8.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/5f/56/a8120250d128bed162cd73c76d45f6ef9991f3e068f62a8ee060afa3104a/annotated_types-0.8.0.tar.gz", hash = "sha256:13b2beaad985e05e2d6407ee4c4f35590b11f8d693a258a561055cac8f64cab7", size = 15893, upload-time = "2026-07-23T20:16:13.995Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/99/91/8acff4f5e50511b911bbccb72b8628a49c68ce14148cd9f6431094859a90/annotated_types-0.8.0-py3-none-any.whl", hash = "sha256:f072f4d804ea359e4eaf198b1af7a8b0943881a87f31bb764f8bf219bb9419e0", size = 13427, upload-time = "2026-07-23T20:16:12.938Z" }, +] + +[[package]] +name = "anyio" +version = "4.15.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "idna" }, + { name = "typing-extensions", marker = "python_full_version < '3.15'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a9/d2/f4d173e22df740bc37b1db102b386ba719b66e95b0f0d751f556b387e6d2/anyio-4.15.1.tar.gz", hash = "sha256:9f28306018cbd6d329e64a36d58256edff76dd996fe423bc957326e578b82a94", size = 276966, upload-time = "2026-09-05T10:42:39.44Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/12/b8/4bd346e22b28902df4d651910f5242c28d84e4a5c2435ca5c3f797ed7e2e/anyio-4.15.1-py3-none-any.whl", hash = "sha256:6152fdbbf9a77fdec97731721bebf7c4c44f7c29b424b0065826173efc7ed101", size = 132079, upload-time = "2026-09-05T10:42:37.923Z" }, +] + +[[package]] +name = "certifi" +version = "2026.7.22" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/a3/c2/24167ea9858356b47a87a50d39908bfdb72ceeefe0041586e704e5376b3a/certifi-2026.7.22.tar.gz", hash = "sha256:741e2c3b351ddf169a738da9f2c048608ff7f2c5cc02f1ebc6b118bb090d5d55", size = 138112, upload-time = "2026-07-22T03:35:12.644Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, +] + +[[package]] +name = "click" +version = "8.5.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, +] + [[package]] name = "colorama" version = "0.4.6" @@ -11,6 +60,68 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "fastapi" +version = "0.141.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-doc" }, + { name = "pydantic" }, + { name = "starlette" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/8a/02/91e3416a8fdd715abb903a952a6bec7cdd8d14eed55d415fc8595524c319/fastapi-0.141.1.tar.gz", hash = "sha256:e8822fc40db1e1858054d7a949a888695bc9bdce70139178e33bd2871a453ca1", size = 425799, upload-time = "2026-07-29T17:18:05.568Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/cb/03/10388a42375ee7e4ac9b94eb2c5c569c8b5795e377e701c9ac3ad63de890/fastapi-0.141.1-py3-none-any.whl", hash = "sha256:bfb91aa2d334c61cb35ba9a116fc123b3d3df31640b801cf57a7a78ec3f603b3", size = 131954, upload-time = "2026-07-29T17:18:04.364Z" }, +] + +[[package]] +name = "h11" +version = "0.16.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/01/ee/02a2c011bdab74c6fb3c75474d40b3052059d95df7e73351460c8588d963/h11-0.16.0.tar.gz", hash = "sha256:4e35b956cf45792e4caa5885e69fba00bdbc6ffafbfa020300e549b208ee5ff1", size = 101250, upload-time = "2025-04-24T03:35:25.427Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/04/4b/29cac41a4d98d144bf5f6d33995617b185d14b22401f75ca86f384e87ff1/h11-0.16.0-py3-none-any.whl", hash = "sha256:63cf8bbe7522de3bf65932fda1d9c2772064ffb3dae62d55932da54b31cb6c86", size = 37515, upload-time = "2025-04-24T03:35:24.344Z" }, +] + +[[package]] +name = "httpcore" +version = "1.0.9" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "certifi" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8", size = 85484, upload-time = "2025-04-24T22:06:22.219Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55", size = 78784, upload-time = "2025-04-24T22:06:20.566Z" }, +] + +[[package]] +name = "httpx" +version = "0.28.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "certifi" }, + { name = "httpcore" }, + { name = "idna" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc", size = 141406, upload-time = "2024-12-06T15:37:23.222Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad", size = 73517, upload-time = "2024-12-06T15:37:21.509Z" }, +] + +[[package]] +name = "idna" +version = "3.20" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/f5/08/8eea9d4b8302028f3abb2c0813953f7aec26d33b7a8960ed760e65ff29fa/idna-3.20.tar.gz", hash = "sha256:a7db850025b95ded1eae8a46181a1a6c56c92c96f0e2b005d9ff8dc0210cab44", size = 216463, upload-time = "2026-09-17T14:11:04.752Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/58/a2/bb081bab032533a855d44de1d56f8e8426114ff1ba5d1f07a438a0a654f8/idna-3.20-py3-none-any.whl", hash = "sha256:ab7ae7122974553370f0bdb919e1a960b2cd1bc1ef0276416d896db81c14582c", size = 69583, upload-time = "2026-09-17T14:11:03.168Z" }, +] + [[package]] name = "iniconfig" version = "2.3.0" @@ -113,10 +224,13 @@ source = { virtual = "." } [package.dev-dependencies] dev = [ + { name = "fastapi" }, + { name = "httpx" }, { name = "jinja2" }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pyyaml" }, + { name = "uvicorn" }, { name = "voluptuous" }, ] @@ -124,10 +238,13 @@ dev = [ [package.metadata.requires-dev] dev = [ + { name = "fastapi", specifier = ">=0.115" }, + { name = "httpx", specifier = ">=0.28" }, { name = "jinja2", specifier = ">=3" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-asyncio", specifier = ">=0.24" }, { name = "pyyaml", specifier = ">=6" }, + { name = "uvicorn", specifier = ">=0.30" }, { name = "voluptuous", specifier = ">=0.14" }, ] @@ -149,6 +266,123 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pydantic" +version = "2.13.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/53/ef/fc4f868f4e2cee79f863883abffceff107875f569b848507319842d2a681/pydantic-2.13.5.tar.gz", hash = "sha256:51a9c5f7b2f8e636f04c6cada605d9b6a3bf1348fdf945a3d8869b19bba0ee08", size = 845750, upload-time = "2026-08-28T14:04:00.916Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/eb/47/c95ffc2009878c7aac0c5e08528022dcb885933252a88b5f170058014464/pydantic-2.13.5-py3-none-any.whl", hash = "sha256:346a034f080da3755d8e9cb5e00e8b07de1d39e4f6e2c87d8ab7cafa0b269a73", size = 472589, upload-time = "2026-08-28T14:03:59.136Z" }, +] + +[[package]] +name = "pydantic-core" +version = "2.46.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/af/f9/8a06bea35ef8daf588f707784c973a7046e0034c8d8cfb08828eeffb8b75/pydantic_core-2.46.5.tar.gz", hash = "sha256:10416c15b8839ecc4ef4d0885da76da6fd0f67333a0eb8aff6d93c4b8f2910fc", size = 472262, upload-time = "2026-08-28T10:01:31.677Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/a2/b6/81d2d19ea0be2c03664381b59f65fa72fc7969decedae00bc2c4ad835708/pydantic_core-2.46.5-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:a1dee1b804ff4d11c663636cf15d2ea47e9f79cd56c033fb1cbf08924842a48f", size = 2074737, upload-time = "2026-08-28T09:57:57.711Z" }, + { url = "https://files.pythonhosted.org/packages/0c/18/b70da8300e292df4099684ea11b1958043580d2f50d2dc8bf7e542bdd84a/pydantic_core-2.46.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:d625a186a65201c23a9e3b8ed9c47e90a026e03256608cc91851c6709096844f", size = 1921751, upload-time = "2026-08-28T09:57:59.265Z" }, + { url = "https://files.pythonhosted.org/packages/e7/1a/0d590341b6ffa4b4aca83508e6b8db4761aaeacfc15a25ca3815876d4797/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4f8507560a9284e1370bb048ed4282012fbef4e8d109875b95e884d228552061", size = 1948231, upload-time = "2026-08-28T09:58:00.678Z" }, + { url = "https://files.pythonhosted.org/packages/7d/1d/02eb35761c51f2f7b1b042d6ab4cda6600f0c8c88a2243b3f734376201e5/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:5f93c5fe914d75fbec9a49209b00da5f08e9e467d69da2b1510c81940cfd10be", size = 2020708, upload-time = "2026-08-28T09:58:02.267Z" }, + { url = "https://files.pythonhosted.org/packages/4a/ea/f86073830e35d508cc8ddf9c3d9e6e6840fcb88d34bf726b0b4710186f27/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:aca6c767f552b21b10f774aeac128e828eafb796adfa1b666a18bf6321453c3a", size = 2194914, upload-time = "2026-08-28T09:58:03.934Z" }, + { url = "https://files.pythonhosted.org/packages/bb/d7/fc36240d7791ce90939e51608568c33bfdae26202016f9770c229a487d86/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:701b2e04b560eeb4bddf7a25ab8ca476176e34fdbd9a0e18196f0d12d4685f0b", size = 2235622, upload-time = "2026-08-28T09:58:05.516Z" }, + { url = "https://files.pythonhosted.org/packages/cf/bc/3fa2d76b83162820a17da7f645b28d1cba99fc8e1e5fc6517067ec450fa1/pydantic_core-2.46.5-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:49776eab08766a08dfff7012f8b422dcd7e25e43b316eedf0477c24fcfa84b7c", size = 2062091, upload-time = "2026-08-28T09:58:07.135Z" }, + { url = "https://files.pythonhosted.org/packages/ab/9a/095d557bb492c90cd8a70a6dd048bf793d433d03d86c81c11e912e4cd049/pydantic_core-2.46.5-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:a2468d93d181667a7abd66e1b64bb9f76f361b0fef8faddf687456453576f5ee", size = 2089904, upload-time = "2026-08-28T09:58:08.814Z" }, + { url = "https://files.pythonhosted.org/packages/24/98/7b76b1ad10a19a617a52aaa1d80e159115af939b095e86f8e756fd52e0df/pydantic_core-2.46.5-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:53feb344243bb9510a9dec7bf3cf1b64d88a98af5dc7872a5160465f8b198c8e", size = 2132244, upload-time = "2026-08-28T09:58:10.435Z" }, + { url = "https://files.pythonhosted.org/packages/20/32/7d6ca365fadba186a0c8f85de1a701663bce81efd309d9479be58687622f/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:cd5214352ae68f3b5e9af7768bdc5253695ee069675db3480518420b3be881f2", size = 2143901, upload-time = "2026-08-28T09:58:12.033Z" }, + { url = "https://files.pythonhosted.org/packages/f8/09/eb9a6aa57f22fd1541a9c0aa2a1f3aeef3ec65347d33e10a6da2f43e0ee9/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:9432f3598db432cb51c5b37fdbf29a60fcccc79e30d37a05022776a6bc4ab689", size = 2299425, upload-time = "2026-08-28T09:58:13.614Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f9/548a5bb9d4ba8cd26e26daf48052236f6b38bb61e7b7241fbc3c995719eb/pydantic_core-2.46.5-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:8feeac04b5794e513e710af2f9c87d49f31a6dc47967bb264a1fed61a8989bec", size = 2318566, upload-time = "2026-08-28T09:58:15.199Z" }, + { url = "https://files.pythonhosted.org/packages/4a/20/06454d18834c02c406c9133f1a3b485305fd9ee984f9636c2f730bef6a9d/pydantic_core-2.46.5-cp311-cp311-win32.whl", hash = "sha256:892a881d5f68c2b9ea304b7a6c2c60d9343df578a311b0f86b94bc8f1ffe8129", size = 1954258, upload-time = "2026-08-28T09:58:16.813Z" }, + { url = "https://files.pythonhosted.org/packages/9e/c2/718b9deb4b72453b5d8c7447a3b14cb77bef36917ef5f514e0948a4096a0/pydantic_core-2.46.5-cp311-cp311-win_amd64.whl", hash = "sha256:40375c2d05acec10323e45dfe2077ac44bc74659008614af5069034e2cfc781c", size = 2041030, upload-time = "2026-08-28T09:58:18.288Z" }, + { url = "https://files.pythonhosted.org/packages/67/ea/c1d1a5b72d6e1ff7f377a4d9199f6591f095beb5b409a8a5d89f7238d939/pydantic_core-2.46.5-cp311-cp311-win_arm64.whl", hash = "sha256:28a6a556cd3b6066bea827857f9d9cce027c96f776e512f544a581f9e42161f8", size = 2009234, upload-time = "2026-08-28T09:58:19.929Z" }, + { url = "https://files.pythonhosted.org/packages/82/3f/76358795aa7a8c6d4f36e2cb828ad1c90ee118e1393a9281664f5aade9d4/pydantic_core-2.46.5-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:b9fe6fb92520e3fd61f2e49000b6911b188824f089b75973ea06d6267f0b476d", size = 2076516, upload-time = "2026-08-28T09:58:21.576Z" }, + { url = "https://files.pythonhosted.org/packages/db/50/26b091836076ce4cb2fac264186936acc069e0595772cfd02a563bc4761a/pydantic_core-2.46.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:a39ac25a9a2fa4072efdb429833c4a4c8009a51ff9eea3eeae131713cd27991e", size = 1922874, upload-time = "2026-08-28T09:58:23.766Z" }, + { url = "https://files.pythonhosted.org/packages/09/f0/2a8ce3849e299d44e2d2c196b6082643a3235565a735cb51db7a6261f614/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:4fdc8b93a41521988916eeaa271173fcca7fa0803d62f87675aac8dcec1c8e29", size = 1951772, upload-time = "2026-08-28T09:58:25.435Z" }, + { url = "https://files.pythonhosted.org/packages/87/46/ac0dc8bdd9e6048183a14eb127764e7ad9240021c17513074a4711b0e31e/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b98134087d9de723658d17a42c7d0da8d6e2ef08015dee7dc93889047315f5e4", size = 2031832, upload-time = "2026-08-28T09:58:27.102Z" }, + { url = "https://files.pythonhosted.org/packages/c4/c2/339de5bef7be36301a2231eaa52e62163742c2281f11b5f4892bc79785cd/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:e652ab17569c94bff5475520f907b7148b8c24036a8ebbe5cf7cf7493d28579a", size = 2208645, upload-time = "2026-08-28T09:58:28.948Z" }, + { url = "https://files.pythonhosted.org/packages/7b/a0/9ff22b797724262da14427abaed4dd1d864a139693fc5e7809114376a716/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:d925f3d9afd05a8c0fb3a1031463a8d59ebe5e2afad297e29c78be19e13b4e62", size = 2265935, upload-time = "2026-08-28T09:58:30.625Z" }, + { url = "https://files.pythonhosted.org/packages/c0/a4/eb9409ec0736e50aa70a412f16c204ed149516846912f7e6724d4c73ee53/pydantic_core-2.46.5-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:0fc5be0abd4a407e200d844b404e33639a554e7bd0d448e7b9ae181be4789ac2", size = 2066284, upload-time = "2026-08-28T09:58:32.289Z" }, + { url = "https://files.pythonhosted.org/packages/c0/02/7f6156ffc926857f1c37c07d9a388682865a81830ab6a1b637082c25e399/pydantic_core-2.46.5-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:816ff0a6550ffc06c098ccd2e0698600f9aa7da192a79eaa6f9af504a35db869", size = 2105889, upload-time = "2026-08-28T09:58:33.986Z" }, + { url = "https://files.pythonhosted.org/packages/92/b1/e781d357ebe09fc929f995700f1b3503e8897f1cece183ecb1300d4d67e9/pydantic_core-2.46.5-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c7ea57fc63aa7da93a1bd2d644e6577befae10c52c4e36377635eea1056a74f5", size = 2158006, upload-time = "2026-08-28T09:58:35.647Z" }, + { url = "https://files.pythonhosted.org/packages/70/0a/644597d84ab400e50609c192120b85c9681c22d3a20461b9060a79be0a7a/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:efd62a42486f1bda5d24cb4f63d15a3c7768375fe83d36f9417b4ad7a2fb20b3", size = 2158408, upload-time = "2026-08-28T09:58:37.38Z" }, + { url = "https://files.pythonhosted.org/packages/1e/ee/ca3b7b3a4b3769ffe9ce9432a7c9be755de9593a46d3b0d54d0409323e44/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:2bc9419666990c06d7397831f2126a1ecc3594aaa3ff7de5bf2d066802f4e07b", size = 2309609, upload-time = "2026-08-28T09:58:39.22Z" }, + { url = "https://files.pythonhosted.org/packages/ce/52/39fa1f451486019524ca685020390e7ca351832fd874530ba30c8628e6dc/pydantic_core-2.46.5-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:18a09e1e1011b462f2e32774f25859ef1223d5c2b0546a633cf56654710721e0", size = 2342618, upload-time = "2026-08-28T09:58:40.89Z" }, + { url = "https://files.pythonhosted.org/packages/81/5e/468fc630568c61dcef3cd47ad32ffbeed9af643f49208d1ea86ab4f890c4/pydantic_core-2.46.5-cp312-cp312-win32.whl", hash = "sha256:5cb482e9e84c851f4e623fe4acc1ced89168cf1fe18f7089db4548c8f5bbb65b", size = 1939475, upload-time = "2026-08-28T09:58:42.591Z" }, + { url = "https://files.pythonhosted.org/packages/cf/c9/4c19f41b84cf6b622a72fbeed7665b25d47a187d68d47d0d430c07f23268/pydantic_core-2.46.5-cp312-cp312-win_amd64.whl", hash = "sha256:5e81740c09e310f5aa5cbd3e434a01c154d4bef93241c7877b39f211d2b78ba8", size = 2043140, upload-time = "2026-08-28T09:58:44.272Z" }, + { url = "https://files.pythonhosted.org/packages/af/dd/0c1a050299147c746e5256db16d645ab5efd4f78c59937d581a0524e74a2/pydantic_core-2.46.5-cp312-cp312-win_arm64.whl", hash = "sha256:f7b0ec93a2893de856652154d73b7ba622f26fa97726487dcac373de5f4c6084", size = 1997729, upload-time = "2026-08-28T09:58:46.13Z" }, + { url = "https://files.pythonhosted.org/packages/f5/37/5abe39a8372a61d3dc3c1338fc504281c01b32fdb3169cd7187153b56d3e/pydantic_core-2.46.5-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:b7ca9034437b6022f941f4857459562ee00a560b97e7cce8a0ec5a74fc6766e0", size = 2075885, upload-time = "2026-08-28T09:58:47.856Z" }, + { url = "https://files.pythonhosted.org/packages/21/43/6323b1f8b217780454c61304bcd2b38ae4762f50754414124603ccc90bb2/pydantic_core-2.46.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:f332f0e72a5a0400141f830744e141bf9f97917878dbe968669e8a7fefea78ff", size = 1922768, upload-time = "2026-08-28T09:58:49.58Z" }, + { url = "https://files.pythonhosted.org/packages/0f/a3/c05ca796e1197618a774b01e596aeedfefc2f7d8c01ae3054e910b120e8a/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:193375f3548919d3f0b60936ca113ada3e38f264f91b9b8e0508efaad57be931", size = 1951241, upload-time = "2026-08-28T09:58:51.511Z" }, + { url = "https://files.pythonhosted.org/packages/68/32/33bc39ac705c52cffc908e8389f9754fdb208aea5c69cceddf4eb3ce99af/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:79bdfa52f843137045b2d081cc05c120ba6665d29b7559c2c47690906f39279f", size = 2031975, upload-time = "2026-08-28T09:58:53.166Z" }, + { url = "https://files.pythonhosted.org/packages/b0/70/2333e885c0f6a67bc105c5916965dac9b57f2718ee20d81d1a06a4ebdc13/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:24922243639cbdac66c75fcb6fd6495a9cb52b213d62f9a0d16f0310b1ff8038", size = 2208542, upload-time = "2026-08-28T09:58:55.017Z" }, + { url = "https://files.pythonhosted.org/packages/f7/ea/296debfb4264207bbda5936133892e027c0a58875ad53ebd512fba8ec3a2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:c76fe65e607be28c7fd4d56fc3c42b1583aa058ce3408b7ad0fd540171d31f9f", size = 2264692, upload-time = "2026-08-28T09:58:56.767Z" }, + { url = "https://files.pythonhosted.org/packages/d3/f2/9e4de77a6271e07a76d2d58b11c091a979c191ed2939bf80067568b369d2/pydantic_core-2.46.5-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6f7b393a8b3da82f5c1fc0751e6d01ac6c55b93c18226a60bdfba4a724efafd1", size = 2066633, upload-time = "2026-08-28T09:58:58.531Z" }, + { url = "https://files.pythonhosted.org/packages/8d/db/f9e9d0c97445987b2084823d5c240de88087338f04fc2cfaa2df186b8049/pydantic_core-2.46.5-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:7ac031912d54f3d83ef3b3eb98dfabc1608802e2202263d25957eeed40b94761", size = 2105235, upload-time = "2026-08-28T09:59:00.421Z" }, + { url = "https://files.pythonhosted.org/packages/07/c5/79169b047b3b2c3e99e04bc76372af9637e0bf6db638274fa927df96369e/pydantic_core-2.46.5-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:837b396ca3d7b74091ca623f6cbd8351bd42d670a79c2683e79fb089f06a2de5", size = 2157367, upload-time = "2026-08-28T09:59:02.442Z" }, + { url = "https://files.pythonhosted.org/packages/26/b5/ba6057afb7c291bd449f51b867f95aef2072941c4ce4e5c31d6ffd132d3b/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:5ee239d575f80b08eca11f6e20f90c4c695de7825c67eefe6091fbf20dda648e", size = 2158420, upload-time = "2026-08-28T09:59:04.2Z" }, + { url = "https://files.pythonhosted.org/packages/6e/28/2057abecaafdc22912afa819603a51f0a62d40643b7c4871c51721fea9be/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e80675d75ae2cd14372cb65cad5400d9347a3d3f6c13000183f22dfd027283ed", size = 2309588, upload-time = "2026-08-28T09:59:06.048Z" }, + { url = "https://files.pythonhosted.org/packages/71/9d/881156dc404e27479c4246128d73538464cab4a239bec61995e227644c30/pydantic_core-2.46.5-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:9c4b71f10dd532fb7a5cbc8f58707779e64f03a258c2bf8bfbaecfcd9970b519", size = 2341866, upload-time = "2026-08-28T09:59:08.539Z" }, + { url = "https://files.pythonhosted.org/packages/5a/38/d66f443a259f84d13babdceae568e572b0ed26da17ca5d0a649ebb110a67/pydantic_core-2.46.5-cp313-cp313-win32.whl", hash = "sha256:97bf8de4d541598c94a59344eeb988a94c08ff76b5723c41f6567ec18c7892ea", size = 1938580, upload-time = "2026-08-28T09:59:10.402Z" }, + { url = "https://files.pythonhosted.org/packages/2c/1e/1d5371213f4cc9a7ed70c0bfcc7911de22311ee99a662a56077d7292d2ac/pydantic_core-2.46.5-cp313-cp313-win_amd64.whl", hash = "sha256:15f4a94963c95accac15b7b657bb177d3ad82bb90b0d0526d9a9b85079925db5", size = 2041980, upload-time = "2026-08-28T09:59:12.396Z" }, + { url = "https://files.pythonhosted.org/packages/5a/48/4222d90b1c67568bace4dec6dca6271449c66de3595d72b6d098f5fde597/pydantic_core-2.46.5-cp313-cp313-win_arm64.whl", hash = "sha256:d22a945598fb91236b4dd793a6e42e4f3dd7740bb5aace5ebd7d4c08d13bb575", size = 1997213, upload-time = "2026-08-28T09:59:14.245Z" }, + { url = "https://files.pythonhosted.org/packages/8e/8a/14596f2a8367da50cf7cbac48169ee5d9c8e11d486a3b527082384630c72/pydantic_core-2.46.5-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:c1c43ad4339643d70ebb8124e1305a7dab423001eff58bb41a0f731adbc98355", size = 2074081, upload-time = "2026-08-28T09:59:16.141Z" }, + { url = "https://files.pythonhosted.org/packages/ae/d5/d8a4eb6d6c7f66b91dd37c576d76e9e60fba900caf5372c17bcf949febc2/pydantic_core-2.46.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:1a353f84de772f423b5ffb11d7ae352fbbef0f446f3c0b0af0f8236d7233606e", size = 1920497, upload-time = "2026-08-28T09:59:18.065Z" }, + { url = "https://files.pythonhosted.org/packages/8e/26/092079428f86e927e030b2c0ced87df69dbb1c875cdeaa67bf42ea2be746/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5086029a57366b8cf81b130a43908738095c270c21a8d7f0e8bdfdb89718e2f3", size = 1952130, upload-time = "2026-08-28T09:59:20.476Z" }, + { url = "https://files.pythonhosted.org/packages/08/c3/8ec0e290a9ebaebd64047bf5fda94be835c6b1551b02437e4b76778fbcd7/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:46c25dda9d092a06c08db76ffe0a197107904d0dfac653f7d5306bbcd6d6119c", size = 2026371, upload-time = "2026-08-28T09:59:22.227Z" }, + { url = "https://files.pythonhosted.org/packages/01/72/4fd20ad520fb8da0157f95b27a7eb05a72790ef08138e7701ac972c342ea/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:37ea7b83c935e5b0d68c9449b82651accf78a10828b2c02b2f2d9e9496446c21", size = 2202822, upload-time = "2026-08-28T09:59:24.277Z" }, + { url = "https://files.pythonhosted.org/packages/31/b0/d16e0771206b29314f0d52198b720be21e8a99ab2bf11e3bc0d7c9cebdff/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e64e88d5585bea9ce95861079de72006c7fa6d3df4e3a3b65ba31eb979c15c9f", size = 2262756, upload-time = "2026-08-28T09:59:26.608Z" }, + { url = "https://files.pythonhosted.org/packages/2c/9b/59634b7ac631c63b2a37760eb6943af3e29573d6b59a4abc5e7f019d4cee/pydantic_core-2.46.5-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:54d510bac3ee52247af28ed4bb18a1e799f040ac60fd2bf5ccd4c92f1fbe786f", size = 2068352, upload-time = "2026-08-28T09:59:29.044Z" }, + { url = "https://files.pythonhosted.org/packages/08/7c/570abb1ad2155348dc754ea91be22e5aaa18eb6d69a6068f7c6f2679a6ed/pydantic_core-2.46.5-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:a2a5e1d0ff29adddc9f6d6821a66302e4493f8ca898b715b6b1182c2c201ea0a", size = 2104777, upload-time = "2026-08-28T09:59:30.95Z" }, + { url = "https://files.pythonhosted.org/packages/8e/25/5bf74adc65a1ac5b7be3f6cb0bcb5433615c1598a801c19d830d84c98ded/pydantic_core-2.46.5-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:03b9666e41e35d8909852ba191a0607520f81b74eaf12ccf8737005dbb313821", size = 2156312, upload-time = "2026-08-28T09:59:32.604Z" }, + { url = "https://files.pythonhosted.org/packages/90/6a/2ef38830675e050121040618135564ed56b860b45433b02d9b4ebece46f3/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:a91c17edf6eea2402cb5457b4c89e99bc5ed1004aa34c4adf1d4258c1a5c22c2", size = 2150067, upload-time = "2026-08-28T09:59:34.453Z" }, + { url = "https://files.pythonhosted.org/packages/90/ef/a7dbb03a14a64c2a4621f989c615ed9a892535a6cad938fc27079f919d80/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b49924c73a235e969511bf2aabdff3beebf9820931f646c80274d5d780010c47", size = 2304516, upload-time = "2026-08-28T09:59:36.194Z" }, + { url = "https://files.pythonhosted.org/packages/68/f8/6bb4c4b80e8a6fde1904c64a51c62a1d04fcdfa3ea521a66b2ddefa1d885/pydantic_core-2.46.5-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:2cbd9a5eff05e51c447c34dfa4632145b26b09120cf04bd0c871e44c1a5e1c9a", size = 2335223, upload-time = "2026-08-28T09:59:37.931Z" }, + { url = "https://files.pythonhosted.org/packages/2a/80/f46b8c681195190b2c1f1c7c0a81abce60663e987613e09ef64d433dd96b/pydantic_core-2.46.5-cp314-cp314-win32.whl", hash = "sha256:2d5d76654becf5efd62c9e51c3756c67b49498b0c9a40884934c40807adbd074", size = 1934827, upload-time = "2026-08-28T09:59:39.836Z" }, + { url = "https://files.pythonhosted.org/packages/f7/3c/60674207246bc0a4009d2391b7c7251c7159f279c8d2ab8aae8ef46f3dee/pydantic_core-2.46.5-cp314-cp314-win_amd64.whl", hash = "sha256:fa10ef4112775900e7a0661068635eb67b2ab824fbde764de6e0e21982a93db0", size = 2042648, upload-time = "2026-08-28T09:59:41.792Z" }, + { url = "https://files.pythonhosted.org/packages/69/0c/117c562c7c1babdf44576b72a5e496906506c93690387ecfbca7c729ae2e/pydantic_core-2.46.5-cp314-cp314-win_arm64.whl", hash = "sha256:045ab3b6d308439e32b81cc173bba5b9018bc6ed896afd0c65b3b009b1699af5", size = 1989652, upload-time = "2026-08-28T09:59:43.702Z" }, + { url = "https://files.pythonhosted.org/packages/e8/66/9336ae58f9eb68c41d121894e52c4c89eccb07eb8f602a04ee9c3f37736a/pydantic_core-2.46.5-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:8816f3d218beb4b787de5c9759c259b8fa61f9dec42dc7811f320a33771778b7", size = 2065829, upload-time = "2026-08-28T09:59:45.364Z" }, + { url = "https://files.pythonhosted.org/packages/c5/02/bc19b47a96c2d3109760711acf22369e56bd7e405ca52f7ade164d2ead57/pydantic_core-2.46.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:bce57638e08ac148e5778cce7feb968307a727d66f8e2274a543d0cf0c9ad6a3", size = 1905716, upload-time = "2026-08-28T09:59:47.18Z" }, + { url = "https://files.pythonhosted.org/packages/52/a4/70b47c0509923dd98ccfed04fb3e32ea3849c82a0ff2205bb41009b43c00/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:976e1128455aa595ea04c79ccfedff1aaeab96ee013fcc916bed120c4f0ad94f", size = 1934216, upload-time = "2026-08-28T09:59:49.241Z" }, + { url = "https://files.pythonhosted.org/packages/52/ab/aa03b65f7bb198585edf806b906c3223ecf1795543e39e23aec4cce27ad2/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e7b891faeedeafba41b2983e5001a81b6a915b69544c7e7570d1989ce1c36ac7", size = 2010635, upload-time = "2026-08-28T09:59:51.692Z" }, + { url = "https://files.pythonhosted.org/packages/3c/8b/0da06343f30b84ec549aafd309c6456223d5dc8bd36af504c573faad561d/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5f194189415698233dd1114a093a9b56e61e2c57e11b469be3b0506f46f0771c", size = 2209369, upload-time = "2026-08-28T09:59:53.582Z" }, + { url = "https://files.pythonhosted.org/packages/d6/5b/844c4defaa34a3df66eb9257087d121d70c201298b96abdf9f492fc2f1bf/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:82a36973cf8a2ef5406f4fe2edbf8ed0c99629535d959e0b100c76a32535a111", size = 2253238, upload-time = "2026-08-28T09:59:55.484Z" }, + { url = "https://files.pythonhosted.org/packages/f4/64/a4e536cb16d7f61a7fd3120b46c577fc7fa7325992f69c4f52bc786d77d8/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:cdbb78909f52b981d3b2d56b97328d71eb0b974c36bd77c920123a7ebb192829", size = 2065740, upload-time = "2026-08-28T09:59:58.038Z" }, + { url = "https://files.pythonhosted.org/packages/5f/75/aaa38c6bc2d085f6605b34eabdc6a8a4e0b2e61fc9c8e6e52b28e97b3125/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:52e24eacdb536cade636aa90fb851835222becff8484b7001fdc78cb0290f2aa", size = 2087425, upload-time = "2026-08-28T09:59:59.898Z" }, + { url = "https://files.pythonhosted.org/packages/55/ae/fcab4cfc39aba3689e1d20c8b5250ad280957022c09af2ed9cd585602a5e/pydantic_core-2.46.5-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:37ae34309d7bd8c0d61ab839668058f2a7962ea1fc51d105d2db228fe0618034", size = 2139306, upload-time = "2026-08-28T10:00:03.057Z" }, + { url = "https://files.pythonhosted.org/packages/2d/f4/f1d03a4bc9d9acbc62f4d742b8a319af52f71885079868b2ff8e48a651ee/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:0cdbada856a1c69a7624a64d3d9aefe79300bd6ef827b43a4f265010b9b55184", size = 2144589, upload-time = "2026-08-28T10:00:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/83/f3/7a53bb1356de514a4cd295f25b6ac39237895620c0462d2592b76c16e114/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:545f26c504b27c3758439a5e6d9349931f0a04f855668d5fe323c89e82300a38", size = 2288882, upload-time = "2026-08-28T10:00:07.931Z" }, + { url = "https://files.pythonhosted.org/packages/cd/94/5a81583660c175c59d49ffb09f4b3a44debeaf86a19fca664ae1cdd9ee32/pydantic_core-2.46.5-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:ff218293c9c806138dca139765e3b067621be52bcd93cdc14c7711be7ddc90a9", size = 2335210, upload-time = "2026-08-28T10:00:10.177Z" }, + { url = "https://files.pythonhosted.org/packages/5a/9f/5d685c2693b972d1a59c998586e8823712b66603aeff47ee60a4bdaafd37/pydantic_core-2.46.5-cp314-cp314t-win32.whl", hash = "sha256:97cf3eb53a8cccacf9d46686a0926186c9bfb5574f2ed66d3639d5fe117cd3a9", size = 1921180, upload-time = "2026-08-28T10:00:12.35Z" }, + { url = "https://files.pythonhosted.org/packages/70/12/5c94ee16d65a37a15f9e869f5e6256df111154491173801a4c5e800ab548/pydantic_core-2.46.5-cp314-cp314t-win_amd64.whl", hash = "sha256:d2f9fc07a8042a8f95925b35c4f04f469707c981fc33245b6ca187cf5d2dd290", size = 2020515, upload-time = "2026-08-28T10:00:14.774Z" }, + { url = "https://files.pythonhosted.org/packages/63/19/67830dda664e6bdf9285ee2e40f355d0d7d6b92aa0c42e8d217bb8d33d36/pydantic_core-2.46.5-cp314-cp314t-win_arm64.whl", hash = "sha256:acf8a67ba51f4ca9ddbd0e6b3000a65ac51ab734661778b3e7ba64d99a710f2f", size = 1989276, upload-time = "2026-08-28T10:00:16.984Z" }, + { url = "https://files.pythonhosted.org/packages/af/1e/ecca01fce348f7e8afa9572441ff6f7d1cc70d21e4859f33944d10877e1e/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:c14ad3bdc85ee7f318742c457ca3968a92126d144b15721c759033bfb06296c2", size = 2075342, upload-time = "2026-08-28T10:00:51.353Z" }, + { url = "https://files.pythonhosted.org/packages/1f/4c/af80c7a8032dfc897040ad5cb772bebde529a381186499e6e29987f23f8c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0bddb4020d8f04175865ccd17eff3040874fc11fb593f424edb452653b4b947c", size = 1907219, upload-time = "2026-08-28T10:00:53.438Z" }, + { url = "https://files.pythonhosted.org/packages/be/3e/54d89e2b092e778716bf6153634ef479e955f48c261090be23aa1e0fb0b5/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2471fd51c61c610e1dcf7de44d7299283661654d11264ab4802b303368d69c47", size = 1953393, upload-time = "2026-08-28T10:00:55.58Z" }, + { url = "https://files.pythonhosted.org/packages/ea/89/828ee90cda28ce17bdefaa3a6eaf74fe430e113295a10e6126beca559d6c/pydantic_core-2.46.5-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b10ec717381bdbfafef34607824db4c91de69ff085e4fca3b2af91b4fa17e68a", size = 2099024, upload-time = "2026-08-28T10:00:57.794Z" }, + { url = "https://files.pythonhosted.org/packages/df/dd/053c2e4303f791f3b8f8a14ab0b22008e8eb21d868c0c90b4f9be705b76a/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:013d6f3483d81e02e7c328831808f336c8596ee33b4bd4026b9ffb1e960b8942", size = 2062540, upload-time = "2026-08-28T10:01:00.318Z" }, + { url = "https://files.pythonhosted.org/packages/d7/dd/a18df751a5e37dd51bfad7f68e766999125bebe68c9e1d10a493ad01bd63/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:e9c134bb666dd54b778b9fc0d2b50cbb7f979b9e3716f26a88c9ab3b6fc1dd0f", size = 1902040, upload-time = "2026-08-28T10:01:02.529Z" }, + { url = "https://files.pythonhosted.org/packages/b7/13/01d40f9d07ce8a779fd6e0bd8ad4fba91309500dd67b869e2e219d261a6d/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:347ec774390c87326a2e4929d58d3f7e8763a104d5d35f4cd595a4c952366433", size = 1967479, upload-time = "2026-08-28T10:01:05.004Z" }, + { url = "https://files.pythonhosted.org/packages/fa/04/c81d4841331c2178b6fb09ae225425e110ed72d990c9fe556c4ec03d1013/pydantic_core-2.46.5-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8e24d8f05fa2d28513d94e877e9c75ad66175376209b3977f916e240e623193c", size = 2111034, upload-time = "2026-08-28T10:01:07.345Z" }, + { url = "https://files.pythonhosted.org/packages/20/21/22102e9950b3049526d20e811b95396508377d87651edd2b80d2b3d28659/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:ab4b66edffb32d9e951efb3814bd104b8367a7501b81b955cacb5726d897389f", size = 2071333, upload-time = "2026-08-28T10:01:09.636Z" }, + { url = "https://files.pythonhosted.org/packages/d8/18/87aefa427d191e6d3ab1447f1efc1cdcac86af1069239b133e8a0fd7f7c9/pydantic_core-2.46.5-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:337639ba62a11acde6ef3aeb08c8ea755f8ef1fe5e513356c0f36a2b0d7568b0", size = 1912713, upload-time = "2026-08-28T10:01:12.285Z" }, + { url = "https://files.pythonhosted.org/packages/1f/93/fd89e9ad49b1805ca94d24ce1088b7d305f05c35ffafcedb9819d03588a0/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:413a717a410d0c817ef5b786a059415550b3794e1d0c2abffd9efb93a3d9f7b4", size = 2090926, upload-time = "2026-08-28T10:01:15.19Z" }, + { url = "https://files.pythonhosted.org/packages/6f/45/8e59dab6acf8d35f02f0a958980074f31038968bdb2c983fcae9d1efee03/pydantic_core-2.46.5-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1e449def1945a462c464331254e5a44fca7c3b4f9aedf59ec2f50f8066dd8e25", size = 2131303, upload-time = "2026-08-28T10:01:17.937Z" }, + { url = "https://files.pythonhosted.org/packages/d5/a5/e1d4dc5180dd887a9522efc1f8716b8692b7606b1d3273d7862eaf66be44/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:a445486499897b88a7d6c310c88ed64dd37b1b59bfd7ae9107490bbb362f47d6", size = 2145128, upload-time = "2026-08-28T10:01:20.694Z" }, + { url = "https://files.pythonhosted.org/packages/c2/d7/ad493864a7fb21c0c4df98f965e2db430cb25a9d7369b5778d5016c09fd9/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:2d330aaba8621b1edcec8ae2c4050f63b84ccf6d98723a8f212e9684713abf0e", size = 2294560, upload-time = "2026-08-28T10:01:23.495Z" }, + { url = "https://files.pythonhosted.org/packages/02/8e/b41c84c913f29973a268e6c2b5bbf13c95adb9956c126d10da11ba3b2bef/pydantic_core-2.46.5-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:b6acfb46a814762367fb7ba0828b0a17d441b92ce249a0e007474c9072662dda", size = 2317531, upload-time = "2026-08-28T10:01:26.334Z" }, + { url = "https://files.pythonhosted.org/packages/db/1d/068464f23075f66a8f1b806935e9cd9363ee446636ea70d2c22ee8659dbf/pydantic_core-2.46.5-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d0a24b40877af2de4950252be9d21eaf7fb07660f3c2cae1f56c6b599ada5266", size = 2140686, upload-time = "2026-08-28T10:01:28.947Z" }, +] + [[package]] name = "pygments" version = "2.21.0" @@ -242,6 +476,19 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/f1/12/de94a39c2ef588c7e6455cfbe7343d3b2dc9d6b6b2f40c4c6565744c873d/pyyaml-6.0.3-cp314-cp314t-win_arm64.whl", hash = "sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b", size = 149341, upload-time = "2025-09-25T21:32:56.828Z" }, ] +[[package]] +name = "starlette" +version = "1.6.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "anyio" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/b5/b4/205b0d5241d934e8add0c38aa924c4f9fb7330834ff11e5444db964ec3f9/starlette-1.6.0.tar.gz", hash = "sha256:d4e3ac5e546444960c710297a3c9fc3f7ebae1b7e963f3d36173b49da535be9b", size = 2716969, upload-time = "2026-08-08T18:27:57.512Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/cb/6a6a47d5b464bd08695d254f3da6e7986cc70c9fa5d778eda57538edfe56/starlette-1.6.0-py3-none-any.whl", hash = "sha256:a86dd39d14bb45f85a3d18525215a9ef0cfd1f192ac793220e72598c90335f0c", size = 75969, upload-time = "2026-08-08T18:27:56.196Z" }, +] + [[package]] name = "typing-extensions" version = "4.16.0" @@ -251,6 +498,31 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/49/d3/b8441a820a491ddfc024b0b0cf0393375b75ea13866d9c66727e54c2fc80/typing_extensions-4.16.0-py3-none-any.whl", hash = "sha256:481caa481374e813c1b176ada14e97f1f67a4539ce9cfeb3f350d78d6370c2e8", size = 45571, upload-time = "2026-07-02T08:40:04.659Z" }, ] +[[package]] +name = "typing-inspection" +version = "0.4.4" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "typing-extensions" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/a3/26/b09b8010994eccc3c09092e6b34058f36a460eea2d4c3e8b910c695975a0/typing_inspection-0.4.4.tar.gz", hash = "sha256:547274fa6b0a561ccf549cc9524b999a578e737d015d8709d021f9d0d13bea47", size = 76928, upload-time = "2026-08-12T12:37:25.997Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/67/81/4add07e5172b7ac40d8ed5ff580409a7801a4fe26d529bdd915401dabfbe/typing_inspection-0.4.4-py3-none-any.whl", hash = "sha256:65b8397ba37ccbce054456aaccddfc91e6e3083c92824df348d96ca832f3f147", size = 14750, upload-time = "2026-08-12T12:37:24.648Z" }, +] + +[[package]] +name = "uvicorn" +version = "0.53.0" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "click" }, + { name = "h11" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/5d/ad/04bbb797c84fc1f26cb171f7394716f4865ffb8d8c5e1eef42565c2dfa6b/uvicorn-0.53.0.tar.gz", hash = "sha256:a9356f0cb89b3b8621529c5d5eebd69bfe154f4c3f68b4cf2de47e45fa855c2e", size = 110881, upload-time = "2026-09-14T07:44:23.815Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/76/18/0eea75741ee812e9f598b687619ce2454f6c3a1c5cd21ea990ec6bd26f45/uvicorn-0.53.0-py3-none-any.whl", hash = "sha256:e8dca71ec86dce5f04e333f0d56cdedf942446e6643b9cea1af0d6d3a02cb03e", size = 87081, upload-time = "2026-09-14T07:44:22.179Z" }, +] + [[package]] name = "voluptuous" version = "0.16.0" -- 2.55.0 From ca477125d6b9d21d4fcf2dccb7a5ef15f9295321 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Mon, 21 Sep 2026 17:43:51 -0400 Subject: [PATCH 02/15] Extract the panel snapshot builder into core (task 9047a97b) Extract the panel snapshot builder into core (task 9047a97b) --- custom_components/nestquest/__init__.py | 1 + custom_components/nestquest/coordinator.py | 222 ++------------ custom_components/nestquest/core/snapshot.py | 306 +++++++++++++++++++ custom_components/nestquest/sensor.py | 39 +-- tests/test_core_snapshot.py | 306 +++++++++++++++++++ tests/test_entity_matrix.py | 8 +- tests/test_sensors.py | 6 +- 7 files changed, 649 insertions(+), 239 deletions(-) create mode 100644 custom_components/nestquest/core/snapshot.py create mode 100644 tests/test_core_snapshot.py diff --git a/custom_components/nestquest/__init__.py b/custom_components/nestquest/__init__.py index e8bd9fb..6b0eac4 100644 --- a/custom_components/nestquest/__init__.py +++ b/custom_components/nestquest/__init__.py @@ -496,6 +496,7 @@ async def async_setup_entry(hass: HomeAssistant, entry: ConfigEntry) -> bool: hass, entry_id=entry.entry_id, database=database, + settings=settings, update_interval_seconds=entry.options.get( CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL ), diff --git a/custom_components/nestquest/coordinator.py b/custom_components/nestquest/coordinator.py index 39dc777..5a26876 100644 --- a/custom_components/nestquest/coordinator.py +++ b/custom_components/nestquest/coordinator.py @@ -11,6 +11,15 @@ refresh updates the whole board coherently. host clock and never UTC, matching the materialization and completion layers. +The snapshot assembly itself lives in the Home-Assistant-free +:mod:`custom_components.nestquest.core.snapshot` module +(:func:`~core.snapshot.build_snapshot`); this coordinator is now a thin +HA wrapper that resolves the local timezone and reads the settings handed +into its constructor, then delegates to that builder, passing the SAME +HA-local ``now`` it resolved in one clock read. The API service's panel +snapshot route shares the same builder, so both consumers render ONE +snapshot shape. + Presence is resolved through the SAME engine the materializer uses (:class:`~.presence.PresenceEngine`): schedules and overrides are read with the public DAO methods and the engine's anchor-date arithmetic @@ -27,13 +36,11 @@ from __future__ import annotations import asyncio import datetime -from dataclasses import dataclass from zoneinfo import ZoneInfo from homeassistant.core import HomeAssistant from homeassistant.helpers.update_coordinator import DataUpdateCoordinator -from .completion import derive_state from .const import ( CONF_UPDATE_INTERVAL, DEFAULT_UPDATE_INTERVAL, @@ -41,66 +48,21 @@ from .const import ( LOGGER, MIN_UPDATE_INTERVAL, ) -from .dao_children import ChildrenDao -from .dao_instances import ( - CompletionEventsDao, - QuestInstancesDao, -) -from .dao_rules import QuestDefinitionsDao -from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao -from .db import NestQuestDatabase -from .presence import ( - MAX_PREVIEW_SCAN_DAYS, - PresenceEngine, - PresenceOverride, - PresenceSchedule, +from .core.db import NestQuestDatabase +from .core.settings import NestQuestSettings +from .core.snapshot import ( + ChildDaySnapshot, + NestQuestSnapshot, + QuestInstanceView, + build_snapshot, ) - -@dataclass(frozen=True) -class QuestInstanceView: - """One instance plus everything the entities render from it.""" - - instance_id: int - definition_id: int - child_id: int - title: str - icon: str | None - window: str - due_date: str - due_time: str | None - state: str # open | done | missed - overdue: bool - completed_at: str | None - was_on_time: bool | None - - -@dataclass(frozen=True) -class ChildDaySnapshot: - """One active child's day: instances, presence, and rollup counts.""" - - child_id: int - child_name: str - instances: tuple[QuestInstanceView, ...] - present: bool - due_today: int - completed_today: int - remaining_today: int - completion_pct: int - #: For a child away today: the ISO date of their next present day - #: (the panel's away plate renders ``Returns , `` - #: from it). ``None`` when the child is present today or no - #: present day exists within the presence engine's scan cap. - next_present: str | None = None - - -@dataclass(frozen=True) -class NestQuestSnapshot: - """The coordinator's whole-entity data payload.""" - - today_iso: str - children: tuple[ChildDaySnapshot, ...] - cycle_day: int +__all__ = [ + "ChildDaySnapshot", + "NestQuestCoordinator", + "NestQuestSnapshot", + "QuestInstanceView", +] class NestQuestCoordinator(DataUpdateCoordinator): @@ -121,6 +83,7 @@ class NestQuestCoordinator(DataUpdateCoordinator): *, entry_id: str, database: NestQuestDatabase, + settings: NestQuestSettings, update_interval_seconds: int | None = None, ) -> None: interval = update_interval_seconds @@ -138,6 +101,7 @@ class NestQuestCoordinator(DataUpdateCoordinator): ) self.entry_id = entry_id self.database = database + self.settings = settings self._refresh_lock = asyncio.Lock() async def async_refresh(self) -> None: @@ -153,140 +117,6 @@ class NestQuestCoordinator(DataUpdateCoordinator): async def _async_update_data(self) -> NestQuestSnapshot: now = self._local_now() - today = now.date() - today_iso = today.isoformat() - - children = await ChildrenDao(self.database).list_active() - - schedules: dict[int, PresenceSchedule] = {} - overrides: dict[int, list[PresenceOverride]] = {} - for child in children: - schedule = await PresenceSchedulesDao(self.database).get_by_child( - child.id - ) - if schedule is not None: - schedules[child.id] = PresenceSchedule.decode( - child.id, schedule.anchor_date, schedule.pattern - ) - window_end = today + datetime.timedelta( - days=MAX_PREVIEW_SCAN_DAYS - ) - window_end_iso = window_end.isoformat() - ranged = await PresenceOverridesDao( - self.database - ).list_by_child_and_range(child.id, today_iso, window_end_iso) - if ranged: - # The engine validates MODEL objects, not DAO records; - # each record converts losslessly (the schema guarantees - # end >= start and a real is_present). - overrides[child.id] = [ - PresenceOverride( - record.child_id, - record.start_date, - record.end_date, - record.is_present, - record.note, - ) - for record in ranged - ] - engine = PresenceEngine(schedules, overrides) - - instances_dao = QuestInstancesDao(self.database) - events_dao = CompletionEventsDao(self.database) - definitions_dao = QuestDefinitionsDao(self.database) - - snapshots: list[ChildDaySnapshot] = [] - for child in children: - instances = await instances_dao.list_by_date_range( - child.id, today_iso, today_iso - ) - views: list[QuestInstanceView] = [] - for instance in instances: - latest = await events_dao.get_latest_for_instance(instance.id) - definition = await definitions_dao.get(instance.definition_id) - state = derive_state(instance, latest, today) - overdue = False - if state == "open" and instance.due_date == today_iso: - if instance.due_time is not None: - due_moment = datetime.datetime.strptime( - instance.due_time, "%H:%M" - ).time() - overdue = now.time() > due_moment - views.append( - QuestInstanceView( - instance_id=instance.id, - definition_id=instance.definition_id, - child_id=instance.child_id, - title=definition.title if definition else "Quest", - icon=definition.icon if definition else None, - window=instance.window, - due_date=instance.due_date, - due_time=instance.due_time, - state=state, - overdue=overdue, - completed_at=( - latest.occurred_at - if latest is not None - and latest.event_type == "completed" - else None - ), - was_on_time=( - latest.was_on_time - if latest is not None - and latest.event_type == "completed" - else None - ), - ) - ) - due = len(views) - completed = sum(1 for view in views if view.state == "done") - remaining = due - completed - child_present = engine.is_present(child.id, today) - next_present: str | None = None - if not child_present: - try: - next_present = engine.next_present_dates( - child.id, today, 1 - )[0].isoformat() - except ValueError: - next_present = None - snapshots.append( - ChildDaySnapshot( - child_id=child.id, - child_name=child.display_name, - instances=tuple(views), - present=child_present, - due_today=due, - completed_today=completed, - remaining_today=remaining, - completion_pct=( - round(completed * 100 / due) if due else 100 - ), - next_present=next_present, - ) - ) - - return NestQuestSnapshot( - today_iso=today_iso, - children=tuple(snapshots), - cycle_day=_cycle_day(schedules, children, today), + return await build_snapshot( + self.database, self.settings, now ) - - -def _cycle_day( - schedules: dict[int, PresenceSchedule], children, today: datetime.date -) -> int: - """Return today's 1-based day in the first scheduled child's cycle. - - Anchor-date arithmetic ONLY (D-004): the day offset is computed from - the schedule's own anchor, never ISO week numbers or parity, so a - 53-week year cannot silently invert the cycle. - """ - for child in children: - schedule = schedules.get(child.id) - if schedule is None: - continue - days = (today - schedule.anchor_date).days - cycle_length = schedule.cycle_length_weeks * 7 - return (days % cycle_length) + 1 - return 0 diff --git a/custom_components/nestquest/core/snapshot.py b/custom_components/nestquest/core/snapshot.py new file mode 100644 index 0000000..0236960 --- /dev/null +++ b/custom_components/nestquest/core/snapshot.py @@ -0,0 +1,306 @@ +"""Pure, Home-Assistant-free panel snapshot builder. + +The household snapshot the NestQuest panel renders — active children in +sort order, each child's today-instances in the documented payload shape +(missed instances OMITTED from the entity-facing attribute payload, but +retained on the snapshot for the count rollups), per-child counts and +presence, and the custody cycle day — used to be assembled inline inside +:mod:`custom_components.nestquest.coordinator`'s +``_async_update_data``. That assembly is HA-coupled only because its +host is a :class:`~homeassistant.helpers.update_coordinator.DataUpdateCoordinator`; +the assembly itself touches no HA surface. + +This module holds the pure pieces — the snapshot dataclasses +(:class:`QuestInstanceView`, :class:`ChildDaySnapshot`, +:class:`NestQuestSnapshot`), the cycle-day helper (:func:`_cycle_day`), +the panel payload shaper (:func:`instance_payload`), and the async +:func:`build_snapshot` builder — so the HA coordinator and the API +service's panel snapshot route share ONE implementation of both the +snapshot assembly and the entity-facing attribute payload shape. +Nothing here imports :mod:`homeassistant`; the builder takes the +database, the settings object, and the HA-local ``now`` (a single +clock read the caller already resolved, from which it derives +``today``) and returns the snapshot, mirroring exactly what the +coordinator used to do inline. + +``settings`` is accepted because the API route hands in the entry's +:class:`~.settings.NestQuestSettings`; it is CURRENTLY UNUSED — the +assembly reads nothing from it — and is retained so the builder's +contract stays stable if a future panel feature derives scan caps or +rollup behaviour from configuration. No speculative read is wired in. +""" +from __future__ import annotations + +import datetime +from collections.abc import Iterable +from dataclasses import dataclass +from typing import Any + +from .completion import derive_state +from .dao_children import ChildrenDao +from .dao_instances import CompletionEventsDao, QuestInstancesDao +from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao +from .dao_rules import QuestDefinitionsDao +from .db import NestQuestDatabase +from .presence import ( + MAX_PREVIEW_SCAN_DAYS, + PresenceEngine, + PresenceOverride, + PresenceSchedule, +) +from .settings import NestQuestSettings + + +@dataclass(frozen=True) +class QuestInstanceView: + """One instance plus everything the entities render from it.""" + + instance_id: int + definition_id: int + child_id: int + title: str + icon: str | None + window: str + due_date: str + due_time: str | None + state: str # open | done | missed + overdue: bool + completed_at: str | None + was_on_time: bool | None + + +@dataclass(frozen=True) +class ChildDaySnapshot: + """One active child's day: instances, presence, and rollup counts.""" + + child_id: int + child_name: str + instances: tuple[QuestInstanceView, ...] + present: bool + due_today: int + completed_today: int + remaining_today: int + completion_pct: int + #: For a child away today: the ISO date of their next present day + #: (the panel's away plate renders ``Returns , `` + #: from it). ``None`` when the child is present today or no + #: present day exists within the presence engine's scan cap. + next_present: str | None = None + + +@dataclass(frozen=True) +class NestQuestSnapshot: + """The coordinator's whole-entity data payload.""" + + today_iso: str + children: tuple[ChildDaySnapshot, ...] + cycle_day: int + + +def _cycle_day( + schedules: dict[int, PresenceSchedule], children, today: datetime.date +) -> int: + """Return today's 1-based day in the first scheduled child's cycle. + + Anchor-date arithmetic ONLY (D-004): the day offset is computed from + the schedule's own anchor, never ISO week numbers or parity, so a + 53-week year cannot silently invert the cycle. + """ + for child in children: + schedule = schedules.get(child.id) + if schedule is None: + continue + days = (today - schedule.anchor_date).days + cycle_length = schedule.cycle_length_weeks * 7 + return (days % cycle_length) + 1 + return 0 + + +def instance_payload( + instances: Iterable[QuestInstanceView], *, include_missed: bool +) -> list[dict[str, Any]]: + """Shape instance dicts per ENTITIES-AND-SERVICES.md §1. + + The panel payload ``state`` is the documented ``open`` | ``completed`` + spelling, so the builder's derived ``done`` maps to ``completed`` here; + ``open`` passes through. ``missed`` instances are OMITTED from the + entity-facing attribute payload (decision 5) unless ``include_missed`` + is set — the admin payload (D-009) keeps them with their ``missed`` + state. ``on_time`` passes the builder's ``was_on_time`` through + verbatim (``True`` / ``False`` for a completed instance, ``None`` for + an open one). This is the SINGLE shared implementation both the HA + sensor layer and the API panel route read; it touches no Home + Assistant surface. + """ + payload: list[dict[str, Any]] = [] + for view in instances: + if view.state == "missed" and not include_missed: + continue + payload.append( + { + "id": view.instance_id, + "definition_id": view.definition_id, + "child_id": view.child_id, + "title": view.title, + "icon": view.icon, + "window": view.window, + "due_time": view.due_time, + "state": ( + "completed" if view.state == "done" else view.state + ), + "overdue": view.overdue, + "completed_at": view.completed_at, + "on_time": view.was_on_time, + } + ) + return payload + + +async def build_snapshot( + database: NestQuestDatabase, + settings: NestQuestSettings, + now: datetime.datetime, +) -> NestQuestSnapshot: + """Build the household panel snapshot for ``today`` at the ``now`` instant. + + Mirrors the assembly that lived in the coordinator's + ``_async_update_data``: active children in sort order, each child's + today-instances with derived state (open / done / missed via + :func:`~.completion.derive_state`) and the overdue flag, per-child + presence (resolved through :class:`~.presence.PresenceEngine`), the + per-child rollup counts, and the custody cycle day of the first + scheduled active child. Missed instances are retained on the + snapshot (the rollup counts and the panel's missed plate need them) + but the entity-facing attribute payload omits them at the + :func:`instance_payload` layer — this builder produces the shared + shape both consumers read. + + ``now`` is the HA-local timezone-aware datetime the caller already + resolved (a SINGLE clock read); the overdue check compares + ``now.time()`` against the instance's ``due_time`` for today's open + instances. The builder performs NO internal clock read — it never + calls :func:`datetime.datetime.now` — so the instant is pinned in + tests. ``today`` is derived INSIDE the builder as ``now.date()``, + so the calendar date that anchors the due-date and cycle-day + arithmetic is always the SAME clock read as the wall-clock time; + the two can never disagree. ``settings`` is accepted but currently + unused (see the module docstring). + """ + today = now.date() + today_iso = today.isoformat() + + children = await ChildrenDao(database).list_active() + + schedules: dict[int, PresenceSchedule] = {} + overrides: dict[int, list[PresenceOverride]] = {} + for child in children: + schedule = await PresenceSchedulesDao(database).get_by_child( + child.id + ) + if schedule is not None: + schedules[child.id] = PresenceSchedule.decode( + child.id, schedule.anchor_date, schedule.pattern + ) + window_end = today + datetime.timedelta( + days=MAX_PREVIEW_SCAN_DAYS + ) + window_end_iso = window_end.isoformat() + ranged = await PresenceOverridesDao( + database + ).list_by_child_and_range(child.id, today_iso, window_end_iso) + if ranged: + # The engine validates MODEL objects, not DAO records; + # each record converts losslessly (the schema guarantees + # end >= start and a real is_present). + overrides[child.id] = [ + PresenceOverride( + record.child_id, + record.start_date, + record.end_date, + record.is_present, + record.note, + ) + for record in ranged + ] + engine = PresenceEngine(schedules, overrides) + + instances_dao = QuestInstancesDao(database) + events_dao = CompletionEventsDao(database) + definitions_dao = QuestDefinitionsDao(database) + + snapshots: list[ChildDaySnapshot] = [] + for child in children: + instances = await instances_dao.list_by_date_range( + child.id, today_iso, today_iso + ) + views: list[QuestInstanceView] = [] + for instance in instances: + latest = await events_dao.get_latest_for_instance(instance.id) + definition = await definitions_dao.get(instance.definition_id) + state = derive_state(instance, latest, today) + overdue = False + if state == "open" and instance.due_date == today_iso: + if instance.due_time is not None: + due_moment = datetime.datetime.strptime( + instance.due_time, "%H:%M" + ).time() + overdue = now.time() > due_moment + views.append( + QuestInstanceView( + instance_id=instance.id, + definition_id=instance.definition_id, + child_id=instance.child_id, + title=definition.title if definition else "Quest", + icon=definition.icon if definition else None, + window=instance.window, + due_date=instance.due_date, + due_time=instance.due_time, + state=state, + overdue=overdue, + completed_at=( + latest.occurred_at + if latest is not None + and latest.event_type == "completed" + else None + ), + was_on_time=( + latest.was_on_time + if latest is not None + and latest.event_type == "completed" + else None + ), + ) + ) + due = len(views) + completed = sum(1 for view in views if view.state == "done") + remaining = due - completed + child_present = engine.is_present(child.id, today) + next_present: str | None = None + if not child_present: + try: + next_present = engine.next_present_dates( + child.id, today, 1 + )[0].isoformat() + except ValueError: + next_present = None + snapshots.append( + ChildDaySnapshot( + child_id=child.id, + child_name=child.display_name, + instances=tuple(views), + present=child_present, + due_today=due, + completed_today=completed, + remaining_today=remaining, + completion_pct=( + round(completed * 100 / due) if due else 100 + ), + next_present=next_present, + ) + ) + + return NestQuestSnapshot( + today_iso=today_iso, + children=tuple(snapshots), + cycle_day=_cycle_day(schedules, children, today), + ) diff --git a/custom_components/nestquest/sensor.py b/custom_components/nestquest/sensor.py index c340728..eef0417 100644 --- a/custom_components/nestquest/sensor.py +++ b/custom_components/nestquest/sensor.py @@ -39,6 +39,7 @@ from .coordinator import ( NestQuestCoordinator, QuestInstanceView, ) +from .core.snapshot import instance_payload #: Home Assistant's state machine refuses states longer than 255 #: characters (its hard recorder/state limit). These sensors' states @@ -63,40 +64,6 @@ def _state_within_limit(state: Any) -> Any: return state -def _instance_payload( - instances: Iterable[QuestInstanceView], *, include_missed: bool -) -> list[dict[str, Any]]: - """Shape instance dicts per ENTITIES-AND-SERVICES.md §1. - - ``state`` maps the coordinator's derived ``done`` to the panel's - ``completed`` spelling; ``missed`` instances are omitted unless - ``include_missed`` (the admin payload, D-009, keeps them with - their ``missed`` state). - """ - payload: list[dict[str, Any]] = [] - for view in instances: - if view.state == "missed" and not include_missed: - continue - payload.append( - { - "id": view.instance_id, - "definition_id": view.definition_id, - "child_id": view.child_id, - "title": view.title, - "icon": view.icon, - "window": view.window, - "due_time": view.due_time, - "state": ( - "completed" if view.state == "done" else view.state - ), - "overdue": view.overdue, - "completed_at": view.completed_at, - "on_time": view.was_on_time, - } - ) - return payload - - #: State reported by the next-quest sensor when the child owes nothing #: else today. A documented sentinel, never an empty string (an empty #: state reads as "sensor went unavailable" in HA). @@ -215,10 +182,10 @@ class NestQuestQuestsDueTodaySensor(_NestQuestChildDaySensor): if child is None: return None return { - "instances": _instance_payload( + "instances": instance_payload( child.instances, include_missed=False ), - "admin_instances": _instance_payload( + "admin_instances": instance_payload( child.instances, include_missed=True ), "child_id": child.child_id, diff --git a/tests/test_core_snapshot.py b/tests/test_core_snapshot.py new file mode 100644 index 0000000..e795989 --- /dev/null +++ b/tests/test_core_snapshot.py @@ -0,0 +1,306 @@ +"""Test the pure panel snapshot builder (task 9047a97b). + +The snapshot assembly that lived inline in the coordinator's +``_async_update_data`` was extracted into the Home-Assistant-free +:func:`custom_components.nestquest.core.snapshot.build_snapshot`, and +the panel payload shaper (:func:`instance_payload`) with it. These +tests seed a household and assert the pure builder's output against +EXPLICIT literal expected values — not self-equality — so the AWAY +child's ``next_present``, a due-timed quest's ``overdue`` under a +pinned ``now``, the custody ``cycle_day`` (> 1), and the panel payload +dict shape (``done`` -> ``completed``, ``on_time`` passthrough, and +``missed`` omission) are all exercised as literals. +""" +from __future__ import annotations + +import datetime +from zoneinfo import ZoneInfo + +from conftest import wire_entry_to_registry + +from custom_components.nestquest import async_setup_entry +from custom_components.nestquest.children import create_child +from custom_components.nestquest.completion import complete_instance +from custom_components.nestquest.core.snapshot import ( + ChildDaySnapshot, + NestQuestSnapshot, + QuestInstanceView, + build_snapshot, + instance_payload, +) +from custom_components.nestquest.dao_instances import QuestInstancesDao +from custom_components.nestquest.dao_presence import ( + PresenceOverridesDao, + PresenceSchedulesDao, +) +from custom_components.nestquest.materialize import materialize +from custom_components.nestquest.quest_definitions import create_quest_definition +from custom_components.nestquest.recurrence import ScheduleRule + + +async def test_instance_payload_shape_and_missed_omission() -> None: + """``instance_payload`` is the documented panel shape (§1). + + ``done`` maps to ``completed``; ``open`` passes through; ``on_time`` + is the builder's ``was_on_time`` verbatim; ``missed`` instances are + omitted unless ``include_missed`` (the admin payload, D-009). + """ + def view(state: str, *, overdue: bool = False, on_time: bool | None = None, + completed_at: str | None = None, instance_id: int = 1) -> QuestInstanceView: + return QuestInstanceView( + instance_id=instance_id, + definition_id=10, + child_id=3, + title="Fill bird feeder", + icon="bird", + window="afternoon", + due_date="2026-09-21", + due_time="17:00", + state=state, + overdue=overdue, + completed_at=completed_at, + was_on_time=on_time, + ) + + open_view = view("open", overdue=True) + done_view = view("done", on_time=True, completed_at="2026-09-21T12:00:00+00:00") + missed_view = view("missed", instance_id=2) + + panel = instance_payload([open_view, done_view, missed_view], include_missed=False) + assert panel == [ + { + "id": 1, + "definition_id": 10, + "child_id": 3, + "title": "Fill bird feeder", + "icon": "bird", + "window": "afternoon", + "due_time": "17:00", + "state": "open", + "overdue": True, + "completed_at": None, + "on_time": None, + }, + { + "id": 1, + "definition_id": 10, + "child_id": 3, + "title": "Fill bird feeder", + "icon": "bird", + "window": "afternoon", + "due_time": "17:00", + "state": "completed", + "overdue": False, + "completed_at": "2026-09-21T12:00:00+00:00", + "on_time": True, + }, + ], "panel payload omits missed and spells done as completed" + + admin = instance_payload( + [open_view, done_view, missed_view], include_missed=True + ) + assert len(admin) == 3 + assert admin[2]["state"] == "missed", ( + "admin payload keeps missed instances with their missed state" + ) + + +async def test_build_snapshot_explicit_values(hass, make_entry, monkeypatch) -> None: + """The builder produces the documented shape as literals, pinned in time. + + Ada is AWAY today (a 2-week schedule whose absent week 0 covers today, + plus a presence override marking her present on today+2) so + ``present`` is False and ``next_present`` is the override date. Her + open quest due at 10:00 is OVERDUE under the pinned 12:00 ``now``. + Bo's quest due at 17:00 is completed at 12:00 (on time) so its + payload ``state`` is ``completed`` and ``on_time`` is True. The first + scheduled active child is Ada, whose 2-week cycle anchored one day + before today puts ``cycle_day`` at 2. + """ + entry = wire_entry_to_registry(make_entry(), hass.registry) + assert await async_setup_entry(hass, entry) is True + coordinator = entry.runtime_data.coordinator + database = entry.runtime_data.database + settings = entry.runtime_data.settings + + # Single clock read for the seed/snapshot anchor (no midnight skew). + today = coordinator._local_now().date() + time_zone = ZoneInfo(hass.config.time_zone) + # Pin the build instant at 12:00 local: past Ada's 10:00 due time + # (overdue) and before Bo's 17:00, and the completion moment for Bo. + pinned_now = datetime.datetime( + today.year, today.month, today.day, 12, 0, 0, tzinfo=time_zone + ) + today_iso = today.isoformat() + + ada = await create_child(database, "Ada") + bo = await create_child(database, "Bo") + + await create_quest_definition( + database, + "Pack bag", + ScheduleRule.from_dict( + {"rule_type": "daily", "start_date": today_iso} + ), + [ada.id], + [("morning", "10:00")], + ) + await create_quest_definition( + database, + "Brush teeth", + ScheduleRule.from_dict( + {"rule_type": "daily", "start_date": today_iso} + ), + [bo.id], + [("morning", "17:00")], + ) + end = (today + datetime.timedelta(days=3)).isoformat() + await materialize(database, today_iso, end, today=today) + + # Ada: 2-week cycle anchored yesterday. Week 0 (the empty segment) + # covers today, so she is absent today; an override marks her present + # on today+2, so next_present resolves to that date and cycle_day is + # ((today - (today-1)) % 14) + 1 == 2. + await PresenceSchedulesDao(database).upsert_by_child( + ada.id, + cycle_length_weeks=2, + anchor_date=(today - datetime.timedelta(days=1)).isoformat(), + pattern="|0,1,2,3,4,5,6", + ) + returns_date = today + datetime.timedelta(days=2) + await PresenceOverridesDao(database).create( + ada.id, + returns_date.isoformat(), + returns_date.isoformat(), + is_present=True, + note="visit", + ) + + # Complete Bo's instance at the pinned 12:00 (17:00 due => on time). + bo_instances = await QuestInstancesDao(database).list_by_child_and_date( + bo.id, today_iso + ) + await complete_instance( + database, + bo_instances[0].id, + actor_source="panel", + actor_child_id=bo.id, + now=pinned_now, + today=today, + ) + + built = await build_snapshot(database, settings, pinned_now) + + assert isinstance(built, NestQuestSnapshot) + assert built.today_iso == today_iso + assert built.cycle_day == 2, ( + "Ada's 2-week cycle anchored yesterday => day 2 today" + ) + + by_id = {child.child_id: child for child in built.children} + assert list(by_id) == [ada.id, bo.id], ( + "active children appear in sort_order, id order" + ) + ada_child = by_id[ada.id] + bo_child = by_id[bo.id] + + # Ada: away today, returns on the override date, one open+overdue quest. + assert isinstance(ada_child, ChildDaySnapshot) + assert ada_child.present is False + assert ada_child.next_present == returns_date.isoformat() + assert ada_child.due_today == 1 + assert ada_child.completed_today == 0 + assert ada_child.remaining_today == 1 + assert ada_child.completion_pct == 0 + ada_view = ada_child.instances[0] + assert isinstance(ada_view, QuestInstanceView) + assert ada_view.state == "open" + assert ada_view.due_time == "10:00" + assert ada_view.overdue is True, "12:00 now is past the 10:00 due time" + assert ada_view.completed_at is None + assert ada_view.was_on_time is None + + # Bo: present, one completed instance, on time, not overdue. + assert bo_child.present is True + assert bo_child.next_present is None + assert bo_child.due_today == 1 + assert bo_child.completed_today == 1 + assert bo_child.remaining_today == 0 + assert bo_child.completion_pct == 100 + bo_view = bo_child.instances[0] + assert bo_view.state == "done" + assert bo_view.due_time == "17:00" + assert bo_view.overdue is False, "overdue is only computed for open state" + assert bo_view.completed_at == pinned_now.astimezone( + datetime.timezone.utc + ).isoformat() + assert bo_view.was_on_time is True + + # Panel payload shape: Ada open+overdue stays open; Bo done -> completed. + ada_payload = instance_payload(ada_child.instances, include_missed=False) + assert ada_payload == [ + { + "id": ada_view.instance_id, + "definition_id": ada_view.definition_id, + "child_id": ada.id, + "title": "Pack bag", + "icon": None, + "window": "morning", + "due_time": "10:00", + "state": "open", + "overdue": True, + "completed_at": None, + "on_time": None, + } + ] + bo_payload = instance_payload(bo_child.instances, include_missed=False) + assert bo_payload == [ + { + "id": bo_view.instance_id, + "definition_id": bo_view.definition_id, + "child_id": bo.id, + "title": "Brush teeth", + "icon": None, + "window": "morning", + "due_time": "17:00", + "state": "completed", + "overdue": False, + "completed_at": pinned_now.astimezone( + datetime.timezone.utc + ).isoformat(), + "on_time": True, + } + ] + + # Missed omission is real: a missed view is dropped from the panel + # payload and kept in the admin payload with its missed state. + missed_view = QuestInstanceView( + instance_id=999, + definition_id=10, + child_id=ada.id, + title="Stale quest", + icon=None, + window="morning", + due_date=(today - datetime.timedelta(days=1)).isoformat(), + due_time="09:00", + state="missed", + overdue=False, + completed_at=None, + was_on_time=None, + ) + assert instance_payload([missed_view], include_missed=False) == [] + assert ( + instance_payload([missed_view], include_missed=True)[0]["state"] + == "missed" + ) + + # REQUIRED wrapper tie-in: the coordinator's _async_update_data is a + # thin WRAPPER over build_snapshot, so pinning its clock to the SAME + # pinned_now the builder was called with must reproduce the builder's + # snapshot byte-for-byte. The dataclasses are frozen, so == is a full + # deep compare — a wrapper that mis-passed today vs now, or stopped + # calling the builder, would diverge here even though the literal + # assertions above still pass. + monkeypatch.setattr(coordinator, "_local_now", lambda: pinned_now) + await coordinator.async_refresh() + assert coordinator.data == built diff --git a/tests/test_entity_matrix.py b/tests/test_entity_matrix.py index 0bbbe49..f1e8757 100644 --- a/tests/test_entity_matrix.py +++ b/tests/test_entity_matrix.py @@ -186,18 +186,18 @@ async def test_entity_and_event_matrix(hass, make_entry) -> None: original_refresh = coordinator._async_update_data async def _future_dated_refresh(): - import custom_components.nestquest.coordinator as coordinator_module + import custom_components.nestquest.core.snapshot as snapshot_module - real_derive = coordinator_module.derive_state + real_derive = snapshot_module.derive_state def _derive(instance, latest, today): return real_derive(instance, latest, today + datetime.timedelta(days=1)) - coordinator_module.derive_state = _derive + snapshot_module.derive_state = _derive try: return await original_refresh() finally: - coordinator_module.derive_state = real_derive + snapshot_module.derive_state = real_derive coordinator._async_update_data = _future_dated_refresh await coordinator.async_refresh() diff --git a/tests/test_sensors.py b/tests/test_sensors.py index 08641fc..dcacaea 100644 --- a/tests/test_sensors.py +++ b/tests/test_sensors.py @@ -238,16 +238,16 @@ async def test_due_today_payload_omits_missed_for_panel_includes_for_admin( entry, coordinator, (ada, bo, cory) = await _setup_seeded_entry( hass, make_entry ) - import custom_components.nestquest.coordinator as coordinator_module + import custom_components.nestquest.core.snapshot as snapshot_module tomorrow = _today() + datetime.timedelta(days=1) - real_derive_state = coordinator_module.derive_state + real_derive_state = snapshot_module.derive_state def _derive_against_pinned_future(instance, latest, today): return real_derive_state(instance, latest, tomorrow) monkeypatch.setattr( - coordinator_module, "derive_state", _derive_against_pinned_future + snapshot_module, "derive_state", _derive_against_pinned_future ) await coordinator.async_refresh() -- 2.55.0 From 4da762173c485a60afc10ba06c457654f125f00b Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Tue, 22 Sep 2026 22:23:16 -0400 Subject: [PATCH 03/15] Panel plane: service-token auth and snapshot route (#152) --- api/app.py | 11 + api/config.py | 68 +++-- api/dependencies.py | 81 ++++++ api/nestquest_core.py | 17 +- api/routes_panel.py | 183 ++++++++++++++ tests/test_api_health.py | 22 +- tests/test_api_panel.py | 523 +++++++++++++++++++++++++++++++++++++++ 7 files changed, 880 insertions(+), 25 deletions(-) create mode 100644 api/dependencies.py create mode 100644 api/routes_panel.py create mode 100644 tests/test_api_panel.py diff --git a/api/app.py b/api/app.py index bb35c1c..4b6464d 100644 --- a/api/app.py +++ b/api/app.py @@ -8,6 +8,10 @@ exposes the two endpoints every later task depends on: app has imported core, opened the database, and applied migrations; a cheap ``SELECT 1`` confirms the connection is live without leaking the filesystem path on an unauthenticated route. +- ``GET /api/v1/panel/snapshot`` — the panel plane's today snapshot + (service-token protected; see :mod:`api.routes_panel`, whose router + carries the shared :func:`api.dependencies.require_panel_token` + check). - ``GET /docs`` — FastAPI's auto-served OpenAPI document (Swagger UI); ``/openapi.json`` is the raw schema. No extra wiring is needed; FastAPI serves both by default. @@ -35,6 +39,7 @@ from fastapi import FastAPI from api.config import ApiConfig from api.database import DatabaseState, make_database +from api.routes_panel import router as panel_router def _build_lifespan(db_path: str): @@ -79,6 +84,12 @@ def create_app(config: ApiConfig | None = None) -> FastAPI: lifespan=_build_lifespan(cfg.db_path), ) + # The resolved config rides on app.state so the reusable route + # dependencies (the panel service-token check) read the configured + # values from the app rather than module state. + app.state.config = cfg + app.include_router(panel_router) + @app.get("/health", summary="Service health") async def health() -> dict[str, str]: """Return service health. diff --git a/api/config.py b/api/config.py index 6237abc..776e19b 100644 --- a/api/config.py +++ b/api/config.py @@ -1,16 +1,22 @@ """Configuration for the NestQuest API service. -The API reads its single configuration knob — the SQLite database path — -from the ``NESTQUEST_DB_PATH`` environment variable. Unlike a dev -convenience default, a missing or empty value is a configuration ERROR, -not a silent fallback to a predictable world-writable temp path: the API -owns the database (it is the writer of record per Feature 16's -contract), and silently dropping data into ``$TMPDIR/nestquest.db`` -would lose it at reboot. ``ApiConfig.from_env`` therefore RAISES when -the variable is unset or empty. This mirrors the integration's -"explicit settings object" discipline (Feature 15's -``core.settings``): the API never reads HA config; callers pass the -resolved path into the database wiring. +The API reads its configuration knobs from environment variables: + +- ``NESTQUEST_DB_PATH`` — the SQLite database file. The API opens this + file on startup (creating it if absent) and applies migrations. +- ``NESTQUEST_PANEL_TOKEN`` — the static panel-plane service token + (D-012): the credential ``/api/v1/panel/*`` routes require via + :func:`api.dependencies.require_panel_token`. It is stored outside + the repo and handed to the service through the environment. + +Unlike a dev convenience default, a missing or empty value is a +configuration ERROR, not a silent fallback: a default database path +would silently drop data at reboot, and a default (or empty) service +token would either authenticate everyone or lock the panel plane out +for good. ``ApiConfig.from_env`` therefore RAISES when either variable +is unset or empty. This mirrors the integration's "explicit settings +object" discipline (Feature 15's ``core.settings``): the API never +reads HA config; callers pass the resolved values into the wiring. Serving: the app is built with the factory ``api.app:create_app`` — run it with ``uvicorn --factory api.app:create_app`` so the factory reads @@ -27,6 +33,14 @@ from dataclasses import dataclass #: :meth:`ApiConfig.from_env`. DB_PATH_ENV = "NESTQUEST_DB_PATH" +#: Environment variable holding the static panel-plane service token +#: (D-012), checked by :func:`api.dependencies.require_panel_token`. +#: REQUIRED — unset or empty raises :class:`ConfigError` from +#: :meth:`ApiConfig.from_env`; there is no silent default, because an +#: empty configured token would either authenticate every caller or +#: lock the panel plane out permanently. +PANEL_TOKEN_ENV = "NESTQUEST_PANEL_TOKEN" + class ConfigError(RuntimeError): """Raised when required API configuration is missing or invalid.""" @@ -36,12 +50,18 @@ class ConfigError(RuntimeError): class ApiConfig: """Resolved API configuration. - Currently a single field — the SQLite path — but kept as a dataclass - so later tasks (auth, SSE, settings persistence) can extend it - without touching the call sites that read ``db_path``. + Both fields are required (no defaults): ``from_env`` rejects an + unset or empty value for either, and a caller building the dataclass + directly must pass an explicit token — the token check itself fails + closed on an empty configured token, but expecting a caller to + discover that at request time is a wiring mistake, so the field is + mandatory. Kept as a dataclass so later tasks (admin plane, SSE, + settings persistence) can extend it without touching the call sites + that read ``db_path``. """ db_path: str + panel_token: str @classmethod def from_env(cls, env: dict[str, str] | None = None) -> "ApiConfig": @@ -52,17 +72,27 @@ class ApiConfig: Raises :class:`ConfigError` when ``NESTQUEST_DB_PATH`` is unset or empty/whitespace-only — there is no silent default, because a - default in the temp directory would silently lose data at reboot. + default in the temp directory would silently lose data at reboot + — and likewise when ``NESTQUEST_PANEL_TOKEN`` is unset or empty + (a default token is never invented: the panel plane is closed + until a real one is configured). """ source = env if env is not None else os.environ - raw = source.get(DB_PATH_ENV, "").strip() - if not raw: + raw_db_path = source.get(DB_PATH_ENV, "").strip() + if not raw_db_path: raise ConfigError( f"{DB_PATH_ENV} must be set to a writable SQLite file path " f"(it is unset or empty — the API will not fall back to a " f"temp directory and silently lose data)" ) - return cls(db_path=raw) + raw_panel_token = source.get(PANEL_TOKEN_ENV, "").strip() + if not raw_panel_token: + raise ConfigError( + f"{PANEL_TOKEN_ENV} must be set to the panel service token " + f"(it is unset or empty — the API will not start without " + f"one, and no default token is invented)" + ) + return cls(db_path=raw_db_path, panel_token=raw_panel_token) -__all__ = ["ApiConfig", "ConfigError", "DB_PATH_ENV"] +__all__ = ["ApiConfig", "ConfigError", "DB_PATH_ENV", "PANEL_TOKEN_ENV"] diff --git a/api/dependencies.py b/api/dependencies.py new file mode 100644 index 0000000..e15f316 --- /dev/null +++ b/api/dependencies.py @@ -0,0 +1,81 @@ +"""Reusable FastAPI dependencies for the NestQuest API routes. + +The panel plane (``/api/v1/panel/*``) is authenticated by the static +service token configured through ``NESTQUEST_PANEL_TOKEN`` (D-012): the +HA integration presents it as a Bearer credential on every panel +request. :func:`require_panel_token` is the ONE implementation of that +check — every panel route (this task's snapshot route and each later +panel route) declares it as a dependency rather than re-implementing +token handling, so the credential, the failure behaviour, and the +401 contract stay in exactly one place. + +The dependency FAILS CLOSED: a missing ``Authorization`` header, a +malformed one (wrong scheme or empty credential), a token that does not +match the configured value, and an empty configured token all yield +HTTP 401. The comparison is constant-time (:func:`secrets.compare_digest`) +so the wall-clock difference between "wrong length" and "wrong value" +cannot be used to probe the token byte by byte, and every rejection +carries the same detail — nothing in the response distinguishes which +part of the check failed. +""" +from __future__ import annotations + +import secrets +from typing import Annotated + +from fastapi import Header, HTTPException, Request + +#: The authorization scheme the panel plane expects. Matched +#: case-insensitively, as RFC 9110 requires for scheme names. +_BEARER_SCHEME = "bearer" + +#: The 401 detail shared by every rejection — deliberately uniform so +#: the response never reveals which part of the check failed. +_REJECTION_DETAIL = "Missing or invalid panel service token" + +#: Challenge header returned with a 401, per the Bearer auth scheme. +_WWW_AUTHENTICATE = {"WWW-Authenticate": "Bearer"} + + +def _reject() -> HTTPException: + """Build the single 401 the dependency raises for every failure.""" + return HTTPException( + status_code=401, + detail=_REJECTION_DETAIL, + headers=_WWW_AUTHENTICATE, + ) + + +async def require_panel_token( + request: Request, + authorization: Annotated[str | None, Header()] = None, +) -> None: + """Reject the request with 401 unless it carries the panel token. + + Reads the configured token off ``app.state.config`` (set by + :func:`api.app.create_app`) so the dependency stays reusable across + routes and carries no module state of its own. A missing or + malformed ``Authorization`` header, a non-matching credential, and + an EMPTY configured token (a wiring mistake) all raise the same + ``HTTPException(401)`` — the check is closed even when the service + is misconfigured. + + Returns ``None`` on success; FastAPI discards a ``None`` return, + the route handler simply proceeds. + """ + config = getattr(request.app.state, "config", None) + expected = config.panel_token if config is not None else "" + if not expected or authorization is None: + raise _reject() + scheme, _, credential = authorization.partition(" ") + if scheme.lower() != _BEARER_SCHEME or not credential.strip(): + raise _reject() + # compare_digest on bytes so a non-ASCII token raises nothing and a + # timing side channel on the str path is not available. + if not secrets.compare_digest( + credential.strip().encode("utf-8"), expected.encode("utf-8") + ): + raise _reject() + + +__all__ = ["require_panel_token"] diff --git a/api/nestquest_core.py b/api/nestquest_core.py index 863cf34..03b5a10 100644 --- a/api/nestquest_core.py +++ b/api/nestquest_core.py @@ -107,4 +107,19 @@ core: ModuleType = _load_core() core_db = importlib.import_module(f"{_CORE_MODULE_NAME}.db") core_migrations = importlib.import_module(f"{_CORE_MODULE_NAME}.migrations") -__all__ = ["core", "core_db", "core_migrations"] +#: The panel snapshot builder and its payload shaper +#: (``core.snapshot.build_snapshot`` / ``instance_payload``) — the pure +#: assembly the panel routes call. +core_snapshot = importlib.import_module(f"{_CORE_MODULE_NAME}.snapshot") + +#: The explicit settings object (``core.settings.NestQuestSettings``) +#: the snapshot builder's ``settings`` parameter takes. +core_settings = importlib.import_module(f"{_CORE_MODULE_NAME}.settings") + +__all__ = [ + "core", + "core_db", + "core_migrations", + "core_snapshot", + "core_settings", +] diff --git a/api/routes_panel.py b/api/routes_panel.py new file mode 100644 index 0000000..b02b7f0 --- /dev/null +++ b/api/routes_panel.py @@ -0,0 +1,183 @@ +"""Panel-plane routes for the NestQuest API service. + +The panel plane (``/api/v1/panel/*``, D-012) serves the wall panel's +reads. Every route on :data:`router` requires the panel service token +through the ONE reusable dependency :func:`api.dependencies.require_panel_token` +(declared once at the router level, so each later panel route added to +this router inherits the check). + +The snapshot route is a thin adapter over the bundled core: it performs +NO business logic. It reads the live database off ``app.state.db``, +resolves ONE timezone-aware ``now`` (the API host's local time — the +API is the household's "local" host now that the domain no longer lives +inside Home Assistant), calls the pure +:func:`nestquest_core.snapshot.build_snapshot`, shapes each child's +instances through :func:`nestquest_core.snapshot.instance_payload` with +``include_missed=False`` (D-009: missed instances are OMITTED from the +panel payload), and returns the documented JSON shape: + +.. code-block:: json + + { + "today_iso": "2026-09-22", + "cycle_day": 2, + "children": [ + { + "child_id": 1, + "child_name": "Ada", + "present": false, + "next_present": "2026-09-24", + "due_today": 1, + "completed_today": 0, + "remaining_today": 1, + "completion_pct": 0, + "instances": [ + {"id": 7, "definition_id": 3, "child_id": 1, + "title": "Pack bag", "icon": null, "window": "morning", + "due_time": "10:00", "state": "open", "overdue": true, + "completed_at": null, "on_time": null} + ] + } + ] + } + +``children`` follows the household's sort order (the core builder lists +active children ``ORDER BY sort_order, id``); ``state`` is the +documented panel spelling ``open`` | ``completed`` (§1 of +design/ENTITIES-AND-SERVICES.md); ``cycle_day`` is today's 1-based day +in the first scheduled child's custody cycle (0 when no child has one). +The same :class:`PanelSnapshotResponse` Pydantic model is the route's +``response_model``, so the OpenAPI document carries the shape too. +""" +from __future__ import annotations + +import datetime + +from fastapi import APIRouter, Depends, Request +from pydantic import BaseModel + +from api.database import DatabaseState +from api.dependencies import require_panel_token +from api.nestquest_core import core_snapshot, core_settings + +#: All panel-plane routes share this router; the service-token check is +#: attached HERE so every panel route (current and later) requires it. +router = APIRouter( + prefix="/api/v1/panel", + dependencies=[Depends(require_panel_token)], +) + + +class PanelInstancePayload(BaseModel): + """One instance in the documented panel payload shape (§1). + + Mirrors :func:`nestquest_core.snapshot.instance_payload`: + ``state`` is ``open`` | ``completed`` (missed instances never reach + the panel payload), ``on_time`` passes the completion event's flag + through verbatim (``None`` for an open instance), and + ``completed_at`` is the event's UTC timestamp when completed. + """ + + id: int + definition_id: int + child_id: int + title: str + icon: str | None + window: str + due_time: str | None + state: str + overdue: bool + completed_at: str | None + on_time: bool | None + + +class PanelChildPayload(BaseModel): + """One child's day: presence, rollup counts, and today's instances.""" + + child_id: int + child_name: str + present: bool + #: ISO date of the child's next present day while they are away + #: (the away plate renders ``Returns , `` from it); + #: ``None`` when the child is present today. + next_present: str | None + due_today: int + completed_today: int + remaining_today: int + completion_pct: int + instances: list[PanelInstancePayload] + + +class PanelSnapshotResponse(BaseModel): + """The whole household snapshot the panel renders from. + + ``today_iso`` anchors the payload's day; ``cycle_day`` is today's + 1-based day in the first scheduled child's custody cycle (0 when no + child has a schedule); ``children`` is in the household's sort + order. + """ + + today_iso: str + cycle_day: int + children: list[PanelChildPayload] + + +def _local_now() -> datetime.datetime: + """Return the API host's local time as ONE timezone-aware clock read. + + ``now().astimezone()`` attaches the host's local timezone to a + single clock read, so the calendar date and wall-clock time the + builder derives can never disagree. Kept as a module function (not + inlined) so tests pin the instant the same way the coordinator's + ``_local_now`` is pinned. + """ + return datetime.datetime.now().astimezone() + + +@router.get( + "/snapshot", + summary="Today's household snapshot", + response_model=PanelSnapshotResponse, +) +async def panel_snapshot(request: Request) -> PanelSnapshotResponse: + """Return today's full household snapshot for the panel. + + Requires the panel service token (checked by the router's shared + :func:`~api.dependencies.require_panel_token` dependency). Builds + the snapshot through the pure core builder against the live + database and shapes each child's instances with + ``instance_payload(include_missed=False)`` — missed instances are + OMITTED from the panel payload (D-009) — then returns the shape + documented in this module's docstring and + :class:`PanelSnapshotResponse`. + """ + state: DatabaseState = request.app.state.db + snapshot = await core_snapshot.build_snapshot( + state.database, core_settings.NestQuestSettings(), _local_now() + ) + return PanelSnapshotResponse( + today_iso=snapshot.today_iso, + cycle_day=snapshot.cycle_day, + children=[ + PanelChildPayload( + child_id=child.child_id, + child_name=child.child_name, + present=child.present, + next_present=child.next_present, + due_today=child.due_today, + completed_today=child.completed_today, + remaining_today=child.remaining_today, + completion_pct=child.completion_pct, + instances=[ + PanelInstancePayload.model_validate(instance) + for instance in core_snapshot.instance_payload( + child.instances, include_missed=False + ) + ], + ) + for child in snapshot.children + ], + ) + + +__all__ = ["router"] diff --git a/tests/test_api_health.py b/tests/test_api_health.py index f9f120e..efc4cc4 100644 --- a/tests/test_api_health.py +++ b/tests/test_api_health.py @@ -69,7 +69,12 @@ class _AppRunner: """ def __init__(self, db_path: str) -> None: - self.app = create_app(ApiConfig(db_path=db_path)) + # The token value is irrelevant to these tests (the health + # route is unauthenticated); it just satisfies the required + # field — the panel token's own tests live in test_api_panel.py. + self.app = create_app( + ApiConfig(db_path=db_path, panel_token="health-runner-token") + ) async def __aenter__(self) -> httpx.AsyncClient: self._lifespan = self.app.router.lifespan_context(self.app) @@ -97,9 +102,15 @@ async def app_client(temp_db_path: str) -> httpx.AsyncClient: def test_from_env_returns_set_value() -> None: - """A set, non-empty NESTQUEST_DB_PATH is returned verbatim.""" - cfg = ApiConfig.from_env({"NESTQUEST_DB_PATH": "/data/nq.db"}) + """Set, non-empty env values are returned verbatim.""" + cfg = ApiConfig.from_env( + { + "NESTQUEST_DB_PATH": "/data/nq.db", + "NESTQUEST_PANEL_TOKEN": "panel-token", + } + ) assert cfg.db_path == "/data/nq.db" + assert cfg.panel_token == "panel-token" def test_from_env_raises_on_empty_value() -> None: @@ -283,10 +294,11 @@ _NO_HA_SCRIPT = textwrap.dedent( # Build the app (the lifespan is NOT run here, so the path is never # opened — it just needs to be a non-empty string to satisfy the - # fail-fast config check). Use a temp path so nothing is created. + # fail-fast config check). Use a temp path so nothing is created; + # the panel token value is irrelevant to this import-only check. app = create_app(ApiConfig(db_path=str( Path(tempfile.gettempdir()) / "nq-noha-buildonly.db" - ))) + ), panel_token="noha-import-token")) assert app is not None print("API NO-HA IMPORT OK") diff --git a/tests/test_api_panel.py b/tests/test_api_panel.py new file mode 100644 index 0000000..75cd1bd --- /dev/null +++ b/tests/test_api_panel.py @@ -0,0 +1,523 @@ +"""Panel-plane route tests: service token + snapshot (task ae7e90ae). + +These tests exercise the NestQuest API's panel plane against the ASGI +app with httpx: + +- ``GET /api/v1/panel/snapshot`` requires the panel service token + (D-012): a request with NO ``Authorization`` header, a MALFORMED + header, and a WRONG token all return 401; the correct Bearer token + returns 200. +- The 200 body is the documented snapshot shape: ``today_iso``, + ``cycle_day``, and ``children`` in the household's sort order, each + carrying ``child_id``/``child_name``/``present``/``next_present``, + the per-day rollup counts, and its instances in the documented panel + payload shape (ENTITIES-AND-SERVICES.md §1) with missed instances + OMITTED (D-009). +- ``ApiConfig.from_env`` requires ``NESTQUEST_PANEL_TOKEN`` with the + same fail-fast discipline as ``NESTQUEST_DB_PATH``. + +The household is seeded through the SAME core copy the app uses — the +``nestquest_core.*`` package registered by ``api/nestquest_core.py`` — +NOT the conftest-stubbed ``custom_components.nestquest.core`` copy (see +the two-copy caveat in api/nestquest_core.py's docstring: never compare +class or exception identity across the two copies). The seed opens and +migrates its own connection on the app's SQLite path and closes BEFORE +the app's lifespan opens the file, so the two connections never +overlap. + +The route's single clock read (:func:`api.routes_panel._local_now`) is +pinned with monkeypatch the same way the coordinator's ``_local_now`` +is pinned in test_core_snapshot.py, so the seeded rows and the +snapshot's ``today`` can never straddle midnight. +""" +from __future__ import annotations + +import datetime +import importlib +from pathlib import Path +from types import SimpleNamespace +from zoneinfo import ZoneInfo + +import httpx +import pytest + +from api import routes_panel +from api.app import create_app +from api.config import ApiConfig, ConfigError +from api.database import _executor +from api.nestquest_core import ( + core_db, + core_migrations, + core_snapshot, +) + +#: The panel service token the test apps are configured with. +PANEL_TOKEN = "panel-service-token-test" + +# The bundled core submodules, through the SAME copy the app uses +# (nestquest_core.*, NOT the conftest-stubbed custom_components copy). +_children = importlib.import_module("nestquest_core.children") +_quest_definitions = importlib.import_module( + "nestquest_core.quest_definitions" +) +_materialize = importlib.import_module("nestquest_core.materialize") +_recurrence = importlib.import_module("nestquest_core.recurrence") +_dao_presence = importlib.import_module("nestquest_core.dao_presence") +_dao_instances = importlib.import_module("nestquest_core.dao_instances") +_completion = importlib.import_module("nestquest_core.completion") + + +async def _seed_household( + db_path: str, + today: datetime.date, + pinned_now: datetime.datetime, +) -> SimpleNamespace: + """Seed a two-child household on ``db_path`` and return its records. + + Mirrors the test_core_snapshot.py household: an AWAY child (2-week + custody cycle anchored yesterday, empty week 0, an override returns + her today+2) with an open quest due 10:00 — overdue at the pinned + 12:00 — and a present child whose 17:00 quest is completed at + 12:00 (on time). A third quest's instance is materialized due + YESTERDAY (via ``materialize`` with ``today`` pinned to yesterday, + since the no-past guard refuses past-dated writes): a past-due OPEN + instance, i.e. the "missed" view the panel payload must omit. + + Opens, migrates, seeds, and CLOSES its own connection so the app's + lifespan connection never overlaps it. + """ + database = core_db.NestQuestDatabase(_executor) + await database.open(db_path) + try: + await core_migrations.apply_migrations(database) + today_iso = today.isoformat() + yesterday = today - datetime.timedelta(days=1) + yesterday_iso = yesterday.isoformat() + + ada = await _children.create_child(database, "Ada", sort_order=0) + bo = await _children.create_child(database, "Bo", sort_order=1) + + # Every rule starts YESTERDAY so the yesterday materialization + # below (which pins ``today`` to yesterday) fires them. + await _quest_definitions.create_quest_definition( + database, + "Pack bag", + _recurrence.ScheduleRule.from_dict( + {"rule_type": "daily", "start_date": yesterday_iso} + ), + [ada.id], + [("morning", "10:00")], + ) + await _quest_definitions.create_quest_definition( + database, + "Brush teeth", + _recurrence.ScheduleRule.from_dict( + {"rule_type": "daily", "start_date": yesterday_iso} + ), + [bo.id], + [("morning", "17:00")], + ) + # Today's instances first, while Ada has no presence schedule + # (she is present every day, so her instance IS generated). + await _materialize.materialize( + database, + today_iso, + (today + datetime.timedelta(days=3)).isoformat(), + today=today, + ) + # The stale quest is created AFTER the today walk, so its only + # instance is the yesterday one written below. + await _quest_definitions.create_quest_definition( + database, + "Stale chore", + _recurrence.ScheduleRule.from_dict( + {"rule_type": "daily", "start_date": yesterday_iso} + ), + [ada.id], + [("morning", "09:00")], + ) + # Materialize yesterday with ``today`` pinned to yesterday: the + # no-past guard accepts the rows, and they land past-due (no + # completion events) — the missed view. + await _materialize.materialize( + database, yesterday_iso, yesterday_iso, today=yesterday + ) + instances = _dao_instances.QuestInstancesDao(database) + + ada_today = await instances.list_by_child_and_date(ada.id, today_iso) + assert len(ada_today) == 1 + pack_bag_instance = ada_today[0] + bo_today = await instances.list_by_child_and_date(bo.id, today_iso) + assert len(bo_today) == 1 + brush_teeth_instance = bo_today[0] + ada_yesterday = await instances.list_by_child_and_date( + ada.id, yesterday_iso + ) + stale_instance = next( + record + for record in ada_yesterday + if record.due_time == "09:00" + ) + + # Ada's custody schedule, added AFTER materialization (the + # same order test_core_snapshot.py seeds): 2-week cycle + # anchored yesterday, empty week 0 covers today => absent + # today; an override returns her on today+2. + await _dao_presence.PresenceSchedulesDao(database).upsert_by_child( + ada.id, + cycle_length_weeks=2, + anchor_date=yesterday_iso, + pattern="|0,1,2,3,4,5,6", + ) + returns_date = today + datetime.timedelta(days=2) + await _dao_presence.PresenceOverridesDao(database).create( + ada.id, + returns_date.isoformat(), + returns_date.isoformat(), + is_present=True, + note="visit", + ) + + # Complete Bo's today instance at the pinned 12:00 (17:00 due + # => on time). + await _completion.complete_instance( + database, + brush_teeth_instance.id, + actor_source="panel", + actor_child_id=bo.id, + now=pinned_now, + today=today, + ) + return SimpleNamespace( + ada=ada, + bo=bo, + pack_bag_instance=pack_bag_instance, + brush_teeth_instance=brush_teeth_instance, + stale_instance=stale_instance, + returns_date=returns_date, + ) + finally: + await database.close() + + +class _PanelRunner: + """Run the app's lifespan around an httpx AsyncClient. + + Same pattern as test_api_health.py's ``_AppRunner``: httpx's ASGI + transport does not run the lifespan, so it is driven explicitly and + shut down in ``__aexit__``'s finally block even on failure. + """ + + def __init__( + self, db_path: str, panel_token: str = PANEL_TOKEN + ) -> None: + self.app = create_app( + ApiConfig(db_path=db_path, panel_token=panel_token) + ) + + async def __aenter__(self) -> httpx.AsyncClient: + self._lifespan = self.app.router.lifespan_context(self.app) + await self._lifespan.__aenter__() + self._client = httpx.AsyncClient( + transport=httpx.ASGITransport(app=self.app), + base_url="http://testserver", + ) + return await self._client.__aenter__() + + async def __aexit__(self, *exc) -> None: + try: + await self._client.__aexit__(*exc) + finally: + await self._lifespan.__aexit__(*exc) + + +@pytest.fixture +def temp_db_path(tmp_path: Path) -> str: + """A fresh per-test SQLite path under pytest's tmp_path.""" + return str(tmp_path / "nestquest.db") + + +@pytest.fixture +async def panel_client(temp_db_path: str) -> httpx.AsyncClient: + """An app client with an empty (migrated) household database.""" + async with _PanelRunner(temp_db_path) as client: + yield client + + +@pytest.fixture +async def seeded_panel(temp_db_path: str, monkeypatch) -> SimpleNamespace: + """A seeded household served by the app, with the clock pinned. + + One host clock read anchors ``today``; the pinned instant is + 12:00 UTC that day (past Ada's 10:00 due, before Bo's 17:00). The + route's ``_local_now`` is patched to return the pinned instant. + """ + today = datetime.datetime.now().astimezone().date() + time_zone = ZoneInfo("UTC") + pinned_now = datetime.datetime( + today.year, today.month, today.day, 12, 0, 0, tzinfo=time_zone + ) + seed = await _seed_household(temp_db_path, today, pinned_now) + monkeypatch.setattr(routes_panel, "_local_now", lambda: pinned_now) + async with _PanelRunner(temp_db_path) as client: + yield SimpleNamespace( + client=client, + seed=seed, + today=today, + pinned_now=pinned_now, + pinned_utc_iso=pinned_now.astimezone( + datetime.timezone.utc + ).isoformat(), + ) + + +# --- ApiConfig.from_env: the panel token -------------------------------- + + +def test_from_env_reads_panel_token() -> None: + """A set, non-empty NESTQUEST_PANEL_TOKEN is returned verbatim.""" + cfg = ApiConfig.from_env( + { + "NESTQUEST_DB_PATH": "/data/nq.db", + "NESTQUEST_PANEL_TOKEN": "panel-token", + } + ) + assert cfg.panel_token == "panel-token" + + +def test_from_env_requires_panel_token_when_missing() -> None: + """A missing panel token raises ConfigError (no silent default).""" + with pytest.raises(ConfigError): + ApiConfig.from_env({"NESTQUEST_DB_PATH": "/data/nq.db"}) + + +def test_from_env_requires_panel_token_when_empty() -> None: + """An empty/whitespace panel token raises ConfigError.""" + with pytest.raises(ConfigError): + ApiConfig.from_env( + { + "NESTQUEST_DB_PATH": "/data/nq.db", + "NESTQUEST_PANEL_TOKEN": " ", + } + ) + + +# --- service-token auth -------------------------------------------------- + + +async def test_missing_token_is_unauthorized( + panel_client: httpx.AsyncClient, +) -> None: + """A request with no Authorization header returns 401.""" + response = await panel_client.get("/api/v1/panel/snapshot") + assert response.status_code == 401 + assert "www-authenticate" in { + key.lower() for key in response.headers + } + + +async def test_wrong_token_is_unauthorized( + panel_client: httpx.AsyncClient, +) -> None: + """A wrong Bearer token returns 401.""" + response = await panel_client.get( + "/api/v1/panel/snapshot", + headers={"Authorization": "Bearer not-the-panel-token"}, + ) + assert response.status_code == 401 + + +async def test_malformed_authorization_header_is_unauthorized( + panel_client: httpx.AsyncClient, +) -> None: + """A malformed Authorization header returns 401. + + Wrong scheme, empty credential, and an empty header all fail the + same way — the response never reveals which part failed. + """ + for header in ( + "Basic dXNlcjpwYXNz", + "Bearer", + "Bearer ", + "Token not-the-panel-token", + "", + ): + response = await panel_client.get( + "/api/v1/panel/snapshot", + headers={"Authorization": header}, + ) + assert response.status_code == 401, f"header {header!r} passed" + + +# --- snapshot payload ---------------------------------------------------- + + +async def test_valid_token_returns_full_snapshot( + seeded_panel: SimpleNamespace, +) -> None: + """The correct token returns 200 with the documented snapshot shape. + + Children in sort order (Ada sort_order 0, Bo 1); per-child counts, + presence, next_present, and cycle day all asserted as explicit + literals; each child's instances are in the documented panel + payload shape (done -> completed spelling, on_time passthrough). + """ + seed = seeded_panel.seed + response = await seeded_panel.client.get( + "/api/v1/panel/snapshot", + headers={"Authorization": f"Bearer {PANEL_TOKEN}"}, + ) + assert response.status_code == 200 + body = response.json() + + assert body["today_iso"] == seeded_panel.today.isoformat() + # Ada's 2-week cycle anchored yesterday => today is cycle day 2. + assert body["cycle_day"] == 2 + + children = body["children"] + assert [child["child_id"] for child in children] == [ + seed.ada.id, + seed.bo.id, + ], "children appear in sort_order" + + ada, bo = children + + # Ada: away today, returns on the override date; her 10:00 quest + # is open and overdue at the pinned 12:00. + assert ada["child_name"] == "Ada" + assert ada["present"] is False + assert ada["next_present"] == seed.returns_date.isoformat() + assert ada["due_today"] == 1 + assert ada["completed_today"] == 0 + assert ada["remaining_today"] == 1 + assert ada["completion_pct"] == 0 + assert ada["instances"] == [ + { + "id": seed.pack_bag_instance.id, + "definition_id": seed.pack_bag_instance.definition_id, + "child_id": seed.ada.id, + "title": "Pack bag", + "icon": None, + "window": "morning", + "due_time": "10:00", + "state": "open", + "overdue": True, + "completed_at": None, + "on_time": None, + } + ] + + # Bo: present, completed his 17:00 quest at the pinned 12:00 + # (on time), so the panel spelling is "completed". + assert bo["child_name"] == "Bo" + assert bo["present"] is True + assert bo["next_present"] is None + assert bo["due_today"] == 1 + assert bo["completed_today"] == 1 + assert bo["remaining_today"] == 0 + assert bo["completion_pct"] == 100 + assert bo["instances"] == [ + { + "id": seed.brush_teeth_instance.id, + "definition_id": seed.brush_teeth_instance.definition_id, + "child_id": seed.bo.id, + "title": "Brush teeth", + "icon": None, + "window": "morning", + "due_time": "17:00", + "state": "completed", + "overdue": False, + "completed_at": seeded_panel.pinned_utc_iso, + "on_time": True, + } + ] + + +async def test_missed_instances_are_omitted_from_panel_payload( + seeded_panel: SimpleNamespace, +) -> None: + """A missed instance never reaches the panel payload (D-009). + + The household carries a past-due OPEN instance due yesterday (the + state the panel reads as "missed"); it must not appear anywhere in + the response, and Ada's payload carries exactly her ONE today + instance — the past-due rows (the stale chore AND her pack bag due + yesterday) are both omitted rather than rendered. + """ + seed = seeded_panel.seed + response = await seeded_panel.client.get( + "/api/v1/panel/snapshot", + headers={"Authorization": f"Bearer {PANEL_TOKEN}"}, + ) + assert response.status_code == 200 + body = response.json() + + payload_instances = [ + instance + for child in body["children"] + for instance in child["instances"] + ] + assert "missed" not in [ + instance["state"] for instance in payload_instances + ] + past_due_ids = {seed.stale_instance.id} + assert past_due_ids.isdisjoint( + instance["id"] for instance in payload_instances + ), "the past-due open instance leaked into the panel payload" + + ada = next( + child + for child in body["children"] + if child["child_id"] == seed.ada.id + ) + assert [instance["id"] for instance in ada["instances"]] == [ + seed.pack_bag_instance.id + ] + + +def test_panel_shaper_drops_missed_views() -> None: + """The route's shaper omits a missed view (D-009), on the app's copy. + + ``instance_payload(include_missed=False)`` is what the snapshot + route shapes every child's instances with; exercised here on the + SAME core copy the app uses (the two-copy caveat makes this a + distinct object from the conftest-stubbed copy's identical source). + """ + missed_view = core_snapshot.QuestInstanceView( + instance_id=999, + definition_id=1, + child_id=1, + title="Stale chore", + icon=None, + window="morning", + due_date="2026-09-21", + due_time="09:00", + state="missed", + overdue=False, + completed_at=None, + was_on_time=None, + ) + assert core_snapshot.instance_payload( + [missed_view], include_missed=False + ) == [] + + +# --- route documentation ------------------------------------------------- + + +async def test_snapshot_route_is_documented( + panel_client: httpx.AsyncClient, +) -> None: + """The snapshot route and its response shape are in the OpenAPI doc.""" + response = await panel_client.get("/openapi.json") + assert response.status_code == 200 + schema = response.json() + assert "/api/v1/panel/snapshot" in schema["paths"] + assert "get" in schema["paths"]["/api/v1/panel/snapshot"] + documented = { + model["title"] + for model in schema["components"]["schemas"].values() + } + assert "PanelSnapshotResponse" in documented + assert "PanelChildPayload" in documented + assert "PanelInstancePayload" in documented -- 2.55.0 From 550a8b3e1e58ec2f4689140da6310e7c42447bd4 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Tue, 22 Sep 2026 22:38:48 -0400 Subject: [PATCH 04/15] Panel plane: complete route (idempotent) (#153) --- api/nestquest_core.py | 5 ++ api/routes_panel.py | 128 ++++++++++++++++++++++++++++- tests/test_api_panel.py | 173 +++++++++++++++++++++++++++++++++++++++- 3 files changed, 299 insertions(+), 7 deletions(-) diff --git a/api/nestquest_core.py b/api/nestquest_core.py index 03b5a10..8f9a1dc 100644 --- a/api/nestquest_core.py +++ b/api/nestquest_core.py @@ -112,12 +112,17 @@ core_migrations = importlib.import_module(f"{_CORE_MODULE_NAME}.migrations") #: assembly the panel routes call. core_snapshot = importlib.import_module(f"{_CORE_MODULE_NAME}.snapshot") +#: The completion service (``core.completion.complete_instance`` / +#: ``uncomplete_instance``) the panel's write routes call. +core_completion = importlib.import_module(f"{_CORE_MODULE_NAME}.completion") + #: The explicit settings object (``core.settings.NestQuestSettings``) #: the snapshot builder's ``settings`` parameter takes. core_settings = importlib.import_module(f"{_CORE_MODULE_NAME}.settings") __all__ = [ "core", + "core_completion", "core_db", "core_migrations", "core_snapshot", diff --git a/api/routes_panel.py b/api/routes_panel.py index b02b7f0..5c297a1 100644 --- a/api/routes_panel.py +++ b/api/routes_panel.py @@ -52,13 +52,22 @@ The same :class:`PanelSnapshotResponse` Pydantic model is the route's from __future__ import annotations import datetime +import importlib -from fastapi import APIRouter, Depends, Request -from pydantic import BaseModel +from fastapi import APIRouter, Depends, HTTPException, Request +from pydantic import BaseModel, field_validator from api.database import DatabaseState from api.dependencies import require_panel_token -from api.nestquest_core import core_snapshot, core_settings +from api.nestquest_core import ( + core_completion, + core_settings, + core_snapshot, +) + +#: The instance DAO, imported on the app's core copy for the +#: child-mismatch guard on the complete route. +_dao_instances = importlib.import_module("nestquest_core.dao_instances") #: All panel-plane routes share this router; the service-token check is #: attached HERE so every panel route (current and later) requires it. @@ -122,6 +131,37 @@ class PanelSnapshotResponse(BaseModel): children: list[PanelChildPayload] +class PanelCompleteRequest(BaseModel): + """The body of ``POST /api/v1/panel/instances/{id}/complete``. + + Matches the panel's ``nestquest.complete_quest`` payload (§2 of + design/ENTITIES-AND-SERVICES.md): ``actor_child_id`` is the tapped + profile. A positive integer is required — the core completion + layer rejects non-int (and bool) ids anyway, but rejecting them + here with 422 keeps the contract visible in the OpenAPI document + and fails before any database work. + """ + + actor_child_id: int + + model_config = { + "json_schema_extra": { + "description": ( + "The tapped child profile the completion is " + "attributed to (decision 9)." + ) + } + } + + @field_validator("actor_child_id") + @classmethod + def _positive(cls, value: int) -> int: + """Reject zero and negative ids with a validation error.""" + if value < 1: + raise ValueError("actor_child_id must be a positive integer") + return value + + def _local_now() -> datetime.datetime: """Return the API host's local time as ONE timezone-aware clock read. @@ -180,4 +220,86 @@ async def panel_snapshot(request: Request) -> PanelSnapshotResponse: ) +@router.post( + "/instances/{instance_id}/complete", + summary="Complete one quest instance", + status_code=200, +) +async def panel_complete_instance( + instance_id: int, body: PanelCompleteRequest, request: Request +) -> dict[str, str]: + """Complete one quest instance as the tapped child profile. + + Requires the panel service token (checked by the router's shared + :func:`~api.dependencies.require_panel_token` dependency — the ONE + token check; this handler performs NO auth of its own). + + Thin adapter only: resolves the live database off ``app.state.db``, + reads ONE timezone-aware clock (:func:`_local_now`), and delegates + to :func:`nestquest_core.completion.complete_instance` with + ``actor_source="panel"`` and the tapped ``actor_child_id`` — the + append/idempotence logic, the lock, and the actor-shape validation + all live in the core layer, never here. + + Response and idempotence: ``complete_instance`` returns a + :class:`~nestquest_core.completion.CompletionResult` whose + ``appended`` flag reports whether THIS call wrote an event; a + repeat call for an already-completed instance is a no-op inside + the core layer (no duplicate event) and this route still answers + ``{"status": "done"}`` either way, so a retried panel tap succeeds. + + Error mapping (deliberately narrow): the ONLY ``ValueError`` mapped + to 404 is the unknown-``instance_id`` one, identified by the + ``instance_id:`` prefix ``complete_instance`` names the failing + field with. Every other ``ValueError`` (an invalid actor shape, + which the body model and the panel actor contract already make + unreachable) re-raises to the framework's 500 — nothing else is + turned into a 404 or a 500 of our own making. + + Child mismatch: ``complete_instance`` verifies the tapped + ``actor_child_id`` exists but does NOT check it is the child the + instance belongs to. The route performs that check here (one + ``QuestInstancesDao.get_by_id`` read) and answers 404 when the + instance's ``child_id`` differs — completing another child's quest + from the wrong profile must not succeed, and 404 (not 403) keeps + the instance's existence from leaking to a wrong-profile tap. + """ + state: DatabaseState = request.app.state.db + database = state.database + + # Child-mismatch guard (see docstring): the instance must belong to + # the tapped profile before the completion layer is entered. + instance = await _dao_instances.QuestInstancesDao(database).get_by_id( + instance_id + ) + if instance is not None and instance.child_id != body.actor_child_id: + raise HTTPException( + status_code=404, + detail="Quest instance not found for this child", + ) + + try: + result = await core_completion.complete_instance( + database, + instance_id, + actor_source="panel", + actor_child_id=body.actor_child_id, + now=_local_now(), + ) + except ValueError as error: + message = str(error) + if message.startswith("instance_id:"): + # complete_instance names the field in its ValueError: + # "instance_id: quest instance N does not exist". + raise HTTPException( + status_code=404, + detail="Quest instance not found", + ) from error + raise + # The derived state is "done" in both the appended and no-op cases; + # ``result.appended`` distinguishes them for callers that care — + # the panel route returns the same success either way. + return {"status": str(result)} + + __all__ = ["router"] diff --git a/tests/test_api_panel.py b/tests/test_api_panel.py index 75cd1bd..58aa414 100644 --- a/tests/test_api_panel.py +++ b/tests/test_api_panel.py @@ -67,6 +67,30 @@ _dao_instances = importlib.import_module("nestquest_core.dao_instances") _completion = importlib.import_module("nestquest_core.completion") +async def _events_for_instance(database: object, instance_id: int) -> list: + """The instance's completion events, on the app's core copy. + + Uses the SAME ``nestquest_core.*`` copy the app uses (the two-copy + caveat: never mix copies) against the app's live connection. + """ + dao = _dao_instances.CompletionEventsDao(database) + return await dao.list_by_instance(instance_id) + + +async def _derived_state( + database: object, instance: object, today: datetime.date +) -> str: + """The instance's derived state on the app's core copy. + + ``derive_state(instance, latest, today)`` from the app's core copy; + the latest event is fetched through the same copy's DAO against the + app's live connection. + """ + dao = _dao_instances.CompletionEventsDao(database) + latest = await dao.get_latest_for_instance(instance.id) + return _completion.derive_state(instance, latest, today) + + async def _seed_household( db_path: str, today: datetime.date, @@ -215,14 +239,15 @@ class _PanelRunner: ApiConfig(db_path=db_path, panel_token=panel_token) ) - async def __aenter__(self) -> httpx.AsyncClient: + async def __aenter__(self) -> tuple["_PanelRunner", httpx.AsyncClient]: self._lifespan = self.app.router.lifespan_context(self.app) await self._lifespan.__aenter__() self._client = httpx.AsyncClient( transport=httpx.ASGITransport(app=self.app), base_url="http://testserver", ) - return await self._client.__aenter__() + await self._client.__aenter__() + return self, self._client async def __aexit__(self, *exc) -> None: try: @@ -240,7 +265,8 @@ def temp_db_path(tmp_path: Path) -> str: @pytest.fixture async def panel_client(temp_db_path: str) -> httpx.AsyncClient: """An app client with an empty (migrated) household database.""" - async with _PanelRunner(temp_db_path) as client: + async with _PanelRunner(temp_db_path) as runner_and_client: + _, client = runner_and_client yield client @@ -251,6 +277,9 @@ async def seeded_panel(temp_db_path: str, monkeypatch) -> SimpleNamespace: One host clock read anchors ``today``; the pinned instant is 12:00 UTC that day (past Ada's 10:00 due, before Bo's 17:00). The route's ``_local_now`` is patched to return the pinned instant. + + Yields the runner too so tests reach the app's live database (the + SAME ``nestquest_core`` connection) for assertions. """ today = datetime.datetime.now().astimezone().date() time_zone = ZoneInfo("UTC") @@ -259,9 +288,11 @@ async def seeded_panel(temp_db_path: str, monkeypatch) -> SimpleNamespace: ) seed = await _seed_household(temp_db_path, today, pinned_now) monkeypatch.setattr(routes_panel, "_local_now", lambda: pinned_now) - async with _PanelRunner(temp_db_path) as client: + async with _PanelRunner(temp_db_path) as runner_and_client: + runner, client = runner_and_client yield SimpleNamespace( client=client, + database=runner.app.state.db.database, seed=seed, today=today, pinned_now=pinned_now, @@ -521,3 +552,137 @@ async def test_snapshot_route_is_documented( assert "PanelSnapshotResponse" in documented assert "PanelChildPayload" in documented assert "PanelInstancePayload" in documented + + +# --- complete route ------------------------------------------------------- + + +async def test_complete_writes_panel_completion( + seeded_panel: SimpleNamespace, +) -> None: + """The first POST completes the instance as the tapped profile. + + Returns success; the completion event recorded is + ``actor_source='panel'`` with the tapped ``actor_child_id`` and NO + ``actor_user_id`` (the panel actor contract), and the instance's + derived state becomes ``done``. + """ + seed = seeded_panel.seed + response = await seeded_panel.client.post( + f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete", + headers={"Authorization": f"Bearer {PANEL_TOKEN}"}, + json={"actor_child_id": seed.ada.id}, + ) + assert response.status_code == 200 + assert response.json() == {"status": "done"} + + # Assert the recorded event and derived state through the SAME core + # copy the app uses (two-copy caveat: never mix copies), against the + # app's live connection. + events = await _events_for_instance( + seeded_panel.database, seed.pack_bag_instance.id + ) + assert len(events) == 1 + event = events[0] + assert event.event_type == "completed" + assert event.actor_source == "panel" + assert event.actor_child_id == seed.ada.id + assert event.actor_user_id is None + + instance = await _dao_instances.QuestInstancesDao( + seeded_panel.database + ).get_by_id(seed.pack_bag_instance.id) + assert await _derived_state( + seeded_panel.database, instance, seeded_panel.today + ) == "done" + + +async def test_complete_is_idempotent( + seeded_panel: SimpleNamespace, +) -> None: + """A second call is a no-op that still returns success. + + The core completion layer's ``appended`` flag decides: the repeat + call appends nothing, so exactly ONE completion event row exists + after two calls. + """ + seed = seeded_panel.seed + headers = {"Authorization": f"Bearer {PANEL_TOKEN}"} + url = f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete" + body = {"actor_child_id": seed.ada.id} + first = await seeded_panel.client.post(url, headers=headers, json=body) + assert first.status_code == 200 + second = await seeded_panel.client.post(url, headers=headers, json=body) + assert second.status_code == 200 + assert second.json() == {"status": "done"} + + events = await _events_for_instance( + seeded_panel.database, seed.pack_bag_instance.id + ) + assert len(events) == 1, ( + "the idempotent call must not append a second event" + ) + + +async def test_complete_unknown_instance_is_not_found( + panel_client: httpx.AsyncClient, +) -> None: + """Completing a non-existent instance returns 404.""" + response = await panel_client.post( + "/api/v1/panel/instances/999999/complete", + headers={"Authorization": f"Bearer {PANEL_TOKEN}"}, + json={"actor_child_id": 1}, + ) + assert response.status_code == 404 + + +async def test_complete_without_token_is_unauthorized( + panel_client: httpx.AsyncClient, +) -> None: + """A complete request with no Authorization header returns 401.""" + response = await panel_client.post( + "/api/v1/panel/instances/1/complete", + json={"actor_child_id": 1}, + ) + assert response.status_code == 401 + + +async def test_complete_wrong_child_is_not_found( + seeded_panel: SimpleNamespace, +) -> None: + """Tapping the wrong child's profile answers 404, not success. + + ``complete_instance`` does not itself check that the tapped + ``actor_child_id`` is the child the instance belongs to; the route + guards it so a wrong-profile tap can never complete another + child's quest. + """ + seed = seeded_panel.seed + response = await seeded_panel.client.post( + f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete", + headers={"Authorization": f"Bearer {PANEL_TOKEN}"}, + json={"actor_child_id": seed.bo.id}, + ) + assert response.status_code == 404 + # And nothing was completed. + events = await _events_for_instance( + seeded_panel.database, seed.pack_bag_instance.id + ) + assert events == [] + + +async def test_complete_route_is_documented( + panel_client: httpx.AsyncClient, +) -> None: + """The complete route and its request shape are in the OpenAPI doc.""" + response = await panel_client.get("/openapi.json") + assert response.status_code == 200 + schema = response.json() + path = "/api/v1/panel/instances/{instance_id}/complete" + assert path in schema["paths"] + assert "post" in schema["paths"][path] + documented = { + model["title"] + for model in schema["components"]["schemas"].values() + } + assert "PanelCompleteRequest" in documented -- 2.55.0 From a07ce1b2f2475e21b428a1d1b4f32bfdb3e0bd6a Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Tue, 22 Sep 2026 23:08:03 -0400 Subject: [PATCH 05/15] SSE endpoint and the quest-completed transition (#154) --- api/app.py | 5 + api/events.py | 78 +++++++++++ api/nestquest_core.py | 5 + api/routes_panel.py | 55 ++++++++ tests/test_api_sse.py | 295 ++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 438 insertions(+) create mode 100644 api/events.py create mode 100644 tests/test_api_sse.py diff --git a/api/app.py b/api/app.py index 4b6464d..c245926 100644 --- a/api/app.py +++ b/api/app.py @@ -39,6 +39,7 @@ from fastapi import FastAPI from api.config import ApiConfig from api.database import DatabaseState, make_database +from api.events import TransitionPublisher from api.routes_panel import router as panel_router @@ -47,12 +48,16 @@ def _build_lifespan(db_path: str): Captured as a closure so the app factory can hand the configured path in without threading it through FastAPI's lifespan signature. + The lifespan also installs the app's ONE in-process transition + publisher on ``app.state.publisher`` (see :mod:`api.events`) so + every route and SSE subscriber shares that single instance. """ @asynccontextmanager async def lifespan(app: FastAPI) -> AsyncIterator[None]: state = make_database(db_path) app.state.db = state + app.state.publisher = TransitionPublisher() try: await state.open_and_migrate() yield diff --git a/api/events.py b/api/events.py new file mode 100644 index 0000000..f5b7830 --- /dev/null +++ b/api/events.py @@ -0,0 +1,78 @@ +"""The ONE in-process transition-event publisher for the API service. + +Route handlers call :meth:`TransitionPublisher.publish` synchronously +(no ``await``, callable from any async handler); SSE subscribers stream +those transitions through an async generator. The scope of "ONE" is +deliberate: a single :class:`TransitionPublisher` instance is created +in the app lifespan and stored on ``app.state.publisher`` — per-app, +so tests build their own hermetic app without module-level state, and +both the panel complete route and the SSE endpoint resolve the SAME +instance off ``request.app.state`` (exactly one publisher exists per +running app; nothing else may create one). + +Design (small and dependency-free, asyncio only): + +- Each subscriber gets its own ``asyncio.Queue(maxsize=64)``. +- ``publish`` is synchronous and non-blocking: it puts the event on + every live queue with ``put_nowait``. A slow subscriber that lets + its queue fill gets the event DROPPED (oldest overflow) — never a + blocked publisher, never unbounded growth. +- A disconnected subscriber's queue is removed in the generator's + ``finally`` block, so teardown is clean and a closed consumer cannot + leak its queue. + +Fan-out is fire-and-forget by design: the SSE plane mirrors transition +events for the panel UI; a dropped event costs the panel one UI +refresh, and the documented recovery path is the next snapshot poll. +""" +from __future__ import annotations + +import asyncio +from collections.abc import AsyncIterator + +#: Per-subscriber queue capacity. Bounded so a stalled consumer can +#: never make the publisher or another subscriber wait; beyond this +#: the slow subscriber drops the event (see module docstring). +_QUEUE_MAXSIZE = 64 + +__all__ = ["TransitionPublisher"] + + +class TransitionPublisher: + """A single in-process fan-out point for transition events.""" + + def __init__(self) -> None: + """Create the publisher with no subscribers.""" + self._subscribers: set[asyncio.Queue] = set() + + def publish(self, event_type: str, payload: dict) -> None: + """Fan one transition event out to every current subscriber. + + Synchronous and non-blocking: each subscriber's bounded queue + is filled with ``put_nowait``, and a FULL queue silently drops + the event for that (slow) subscriber rather than blocking the + route handler or growing without bound. + """ + for queue in tuple(self._subscribers): + try: + queue.put_nowait((event_type, payload)) + except asyncio.QueueFull: + # The subscriber is not keeping up; drop the event for + # it and move on (the panel recovers by polling). + pass + + async def subscribe(self) -> AsyncIterator[tuple[str, dict]]: + """Yield ``(event_type, payload)`` tuples until the consumer breaks. + + Registers ONE bounded queue for the duration of the iteration + and removes it in the ``finally`` block, so a client disconnect + (or any break out of the loop) tears the subscription down + cleanly with no leaked queue. + """ + queue: asyncio.Queue = asyncio.Queue(maxsize=_QUEUE_MAXSIZE) + self._subscribers.add(queue) + try: + while True: + yield await queue.get() + finally: + self._subscribers.discard(queue) diff --git a/api/nestquest_core.py b/api/nestquest_core.py index 8f9a1dc..6257dab 100644 --- a/api/nestquest_core.py +++ b/api/nestquest_core.py @@ -116,6 +116,10 @@ core_snapshot = importlib.import_module(f"{_CORE_MODULE_NAME}.snapshot") #: ``uncomplete_instance``) the panel's write routes call. core_completion = importlib.import_module(f"{_CORE_MODULE_NAME}.completion") +#: The transition-event builders (``core.events.build_quest_completed_event`` +#: et al.) the panel's write routes build their SSE events with. +core_events = importlib.import_module(f"{_CORE_MODULE_NAME}.events") + #: The explicit settings object (``core.settings.NestQuestSettings``) #: the snapshot builder's ``settings`` parameter takes. core_settings = importlib.import_module(f"{_CORE_MODULE_NAME}.settings") @@ -124,6 +128,7 @@ __all__ = [ "core", "core_completion", "core_db", + "core_events", "core_migrations", "core_snapshot", "core_settings", diff --git a/api/routes_panel.py b/api/routes_panel.py index 5c297a1..ce61b71 100644 --- a/api/routes_panel.py +++ b/api/routes_panel.py @@ -53,14 +53,19 @@ from __future__ import annotations import datetime import importlib +import json + +from collections.abc import AsyncIterator from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi.responses import StreamingResponse from pydantic import BaseModel, field_validator from api.database import DatabaseState from api.dependencies import require_panel_token from api.nestquest_core import ( core_completion, + core_events, core_settings, core_snapshot, ) @@ -299,7 +304,57 @@ async def panel_complete_instance( # The derived state is "done" in both the appended and no-op cases; # ``result.appended`` distinguishes them for callers that care — # the panel route returns the same success either way. + if result.appended: + # An ACTUAL transition: publish the SSE transition event. The + # payload is built by the core builder (never hand-built here) + # and fan-out is fire-and-forget (see api/events.py). + event_type, payload = await core_events.build_quest_completed_event( + database, instance_id, was_on_time=result.was_on_time + ) + request.app.state.publisher.publish(event_type, payload) return {"status": str(result)} +async def _sse_stream(request: Request) -> AsyncIterator[str]: + """Yield the SSE frames for every published transition event. + + One subscriber of the app's single publisher (resolved off + ``app.state.publisher``); each event becomes one + ``event: \\ndata: \\n\\n`` frame. Breaking out of the + publisher's subscription (on client disconnect) tears the + subscription down cleanly — no queue is leaked. + """ + publisher = request.app.state.publisher + async for event_type, payload in publisher.subscribe(): + yield ( + f"event: {event_type}\n" + f"data: {json.dumps(payload)}\n\n" + ) + + +@router.get( + "/events", + summary="Stream panel transition events (SSE)", +) +async def panel_events(request: Request) -> StreamingResponse: + """Stream the app's transition events to the panel (Server-Sent + Events). + + Requires the panel service token (checked by the router's shared + :func:`~api.dependencies.require_panel_token` dependency — the ONE + token check; this handler performs NO auth of its own). + + Returns a ``text/event-stream`` response whose body is produced by + :func:`_sse_stream` off the app's single in-process publisher + (``app.state.publisher``, installed in the lifespan). The stream + ends when the client disconnects, at which point the subscription + is torn down (its queue removed) with nothing leaked. + """ + return StreamingResponse( + _sse_stream(request), + media_type="text/event-stream", + headers={"Cache-Control": "no-cache"}, + ) + + __all__ = ["router"] diff --git a/tests/test_api_sse.py b/tests/test_api_sse.py new file mode 100644 index 0000000..7a91a3b --- /dev/null +++ b/tests/test_api_sse.py @@ -0,0 +1,295 @@ +"""SSE endpoint tests: the panel event stream (task 4d7bae00). + +These tests exercise ``GET /api/v1/panel/events`` — the panel plane's +Server-Sent Events stream — against a REAL server: + +- The stream requires the panel service token (D-012): no/wrong token + returns 401 (the router-level shared dependency, no second check). +- The response is ``text/event-stream`` and each transition arrives as + an ``event: `` / ``data: `` frame. +- Completing an instance through the panel complete route publishes + the ``nestquest_quest_completed`` transition carrying the documented + payload fields (design/ENTITIES-AND-SERVICES.md §3). +- A no-op re-complete publishes nothing (no second completed event). + +Streaming approach (documented): httpx's ASGITransport BUFFERS the +whole response body before returning, so an endless SSE stream hangs +it — exactly the awkward case this task anticipated. These tests +therefore run the app with uvicorn on an ephemeral port (port 0, so +the OS picks a free one) in a daemon thread, and httpx streams from +``http://127.0.0.1:``. Every read is bounded by a timeout so a +wedged stream fails an assertion instead of hanging the suite; the +server is shut down in the fixture's finally block. +""" +from __future__ import annotations + +import asyncio +import datetime +import importlib +import json +import socket +import threading +import time +from pathlib import Path +from types import SimpleNamespace +from zoneinfo import ZoneInfo + +import httpx +import pytest +import uvicorn + +from api import routes_panel +from api.app import create_app +from api.config import ApiConfig +from tests.test_api_panel import PANEL_TOKEN, _seed_household + +#: The documented quest-completed event name (core const, §3). +_core_const = importlib.import_module("nestquest_core.const") +EVENT_QUEST_COMPLETED = _core_const.EVENT_QUEST_COMPLETED + +#: Upper bound for any single stream read; bounds the whole test so a +#: wedged stream fails the assertion instead of hanging the suite. +READ_TIMEOUT = 5.0 + + +def _free_port() -> int: + """Ask the OS for one free TCP port (bound then released).""" + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.bind(("127.0.0.1", 0)) + return sock.getsockname()[1] + + +class _Server: + """Run the app with uvicorn on an ephemeral port, in a thread. + + ``create_app`` runs INSIDE the server thread (uvicorn's factory + mode), so the lifespan — including ``app.state.publisher`` — is + set up per test on the server's own event loop. Daemon thread + + explicit ``should_exit`` shutdown in the fixture's finally block. + """ + + def __init__(self, db_path: str) -> None: + self.port = _free_port() + self._server = uvicorn.Server( + uvicorn.Config( + # A closure factory: reads nothing at import time. + lambda: create_app( + ApiConfig(db_path=db_path, panel_token=PANEL_TOKEN) + ), + host="127.0.0.1", + port=self.port, + log_level="warning", + ) + ) + self._thread = threading.Thread( + target=self._server.run, daemon=True + ) + + def __enter__(self) -> "_Server": + self._thread.start() + deadline = time.monotonic() + READ_TIMEOUT + while not self._server.started: + if time.monotonic() > deadline or not self._thread.is_alive(): + raise RuntimeError("uvicorn did not start") + time.sleep(0.01) + return self + + def __exit__(self, *exc) -> None: + self._server.should_exit = True + self._thread.join(timeout=READ_TIMEOUT) + + def url(self, path: str) -> str: + """The full URL for ``path`` on this server.""" + return f"http://127.0.0.1:{self.port}{path}" + + +class _EventStream: + """A subscribed SSE reader over a live httpx connection. + + Parses frames (``event:``/``data:`` pairs) into + ``(event_type, payload)`` tuples; ``next_event`` awaits the next + parsed frame with a timeout. + """ + + def __init__(self) -> None: + self.queue: asyncio.Queue = asyncio.Queue() + self._task: asyncio.Task | None = None + self._client: httpx.AsyncClient | None = None + self._stream_cm = None + self._response: httpx.Response | None = None + + async def __aenter__(self) -> "_EventStream": + self._client = httpx.AsyncClient(timeout=READ_TIMEOUT) + self._stream_cm = self._client.stream( + "GET", + self.url, + headers={"Authorization": f"Bearer {PANEL_TOKEN}"}, + ) + self._response = await self._stream_cm.__aenter__() + assert self._response.status_code == 200 + assert self._response.headers["content-type"].startswith( + "text/event-stream" + ) + self._task = asyncio.create_task(self._pump()) + return self + + def _set_url(self, url: str) -> "_EventStream": + """Record the stream URL (called before ``__aenter__``).""" + self.url = url + return self + + async def _pump(self) -> None: + """Read the stream and push parsed frames onto the queue.""" + event_type: str | None = None + data_lines: list[str] = [] + try: + async for chunk in self._response.aiter_text(): + for line in chunk.splitlines(): + if line.startswith("event:"): + event_type = line[len("event:"):].strip() + elif line.startswith("data:"): + data_lines.append(line[len("data:"):].strip()) + elif not line and event_type is not None: + payload = json.loads("\n".join(data_lines)) + await self.queue.put((event_type, payload)) + event_type = None + data_lines = [] + except Exception as error: # pragma: no cover - teardown path + await self.queue.put(error) + + async def next_event(self) -> tuple[str, dict]: + """The next parsed ``(event_type, payload)``, with a timeout.""" + item = await asyncio.wait_for( + self.queue.get(), timeout=READ_TIMEOUT + ) + if isinstance(item, Exception): + raise item + return item + + async def __aexit__(self, *exc) -> None: + if self._task is not None: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + await self._stream_cm.__aexit__(*exc) + await self._client.__aexit__(*exc) + + +@pytest.fixture +def temp_db_path(tmp_path: Path) -> str: + """A fresh per-test SQLite path under pytest's tmp_path.""" + return str(tmp_path / "nestquest.db") + + +@pytest.fixture +async def seeded_panel(temp_db_path: str) -> SimpleNamespace: + """A seeded household served by a live uvicorn app, clock pinned. + + Same pinning discipline as the panel tests (12:00 UTC, past Ada's + 10:00 due, before Bo's 17:00): the route's ``_local_now`` is + patched so the completion's ``was_on_time`` is deterministic. The + app is created inside uvicorn's factory, so the patch must happen + BEFORE the server starts. + """ + today = datetime.datetime.now().astimezone().date() + pinned_now = datetime.datetime( + today.year, today.month, today.day, 12, 0, 0, tzinfo=ZoneInfo("UTC") + ) + seed = await _seed_household(temp_db_path, today, pinned_now) + # The app is created inside uvicorn's factory (in the server + # thread), so the patch must be applied BEFORE the server starts. + original = routes_panel._local_now + routes_panel._local_now = lambda: pinned_now + try: + with _Server(temp_db_path) as server: + client = httpx.AsyncClient(base_url=server.url("")) + async with client: + yield SimpleNamespace( + client=client, + seed=seed, + server=server, + url=server.url, + ) + finally: + routes_panel._local_now = original + + +def _stream(url: str) -> _EventStream: + """A subscribed SSE reader for ``url``.""" + return _EventStream()._set_url(url) + + +# --- auth and content type ---------------------------------------------- + + +async def test_events_requires_panel_token(seeded_panel) -> None: + """The SSE route inherits the router's token check (no second one).""" + response = await seeded_panel.client.get("/api/v1/panel/events") + assert response.status_code == 401 + + +# --- the quest-completed transition -------------------------------------- + + +async def test_complete_publishes_quest_completed_event( + seeded_panel, +) -> None: + """A panel completion emits the documented completed transition. + + The stream is subscribed FIRST, the instance is completed through + the panel route, and the emitted frame's type and every documented + payload field (child_id, child_name, instance_id, quest_title, + window, due_date, due_time, occurred_at, was_on_time) are asserted. + """ + seed = seeded_panel.seed + pack_bag = seed.pack_bag_instance + stream = _stream(seeded_panel.url("/api/v1/panel/events")) + async with stream: + response = await seeded_panel.client.post( + f"/api/v1/panel/instances/{pack_bag.id}/complete", + headers={"Authorization": f"Bearer {PANEL_TOKEN}"}, + json={"actor_child_id": seed.ada.id}, + ) + assert response.status_code == 200 + + event_type, payload = await stream.next_event() + assert event_type == EVENT_QUEST_COMPLETED + assert event_type == "nestquest_quest_completed" + assert payload["child_id"] == seed.ada.id + assert payload["child_name"] == "Ada" + assert payload["instance_id"] == pack_bag.id + assert payload["quest_title"] == "Pack bag" + assert payload["window"] == "morning" + assert payload["due_date"] == pack_bag.due_date + assert payload["due_time"] == "10:00" + assert payload["occurred_at"] # strict UTC ISO-8601 stamp + assert payload["was_on_time"] is False # 10:00 due, 12:00 now + + +async def test_no_op_recomplete_publishes_nothing(seeded_panel) -> None: + """A second complete call emits NO second completed event. + + After the first (real) transition, a re-complete is a no-op in the + core layer; the stream must stay silent. + """ + seed = seeded_panel.seed + url = f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete" + body = {"actor_child_id": seed.ada.id} + headers = {"Authorization": f"Bearer {PANEL_TOKEN}"} + stream = _stream(seeded_panel.url("/api/v1/panel/events")) + async with stream: + first = await seeded_panel.client.post(url, headers=headers, json=body) + assert first.status_code == 200 + event_type, _ = await stream.next_event() + assert event_type == EVENT_QUEST_COMPLETED + + second = await seeded_panel.client.post( + url, headers=headers, json=body + ) + assert second.status_code == 200 + with pytest.raises(asyncio.TimeoutError): + # Nothing arrives within the window: the no-op re-complete + # must publish nothing. + await asyncio.wait_for(stream.queue.get(), timeout=0.3) -- 2.55.0 From d4a55b6b407aa9dbcf3cc29b51e453f28afdfb44 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Tue, 22 Sep 2026 23:25:06 -0400 Subject: [PATCH 06/15] SSE: publish uncompleted, child-day-complete and missed (#155) --- api/routes_panel.py | 15 +- api/transitions.py | 83 ++++++++++ custom_components/nestquest/core/events.py | 22 ++- tests/test_api_sse.py | 169 ++++++++++++++++++++- tests/test_events.py | 28 ++++ 5 files changed, 311 insertions(+), 6 deletions(-) create mode 100644 api/transitions.py diff --git a/api/routes_panel.py b/api/routes_panel.py index ce61b71..462458c 100644 --- a/api/routes_panel.py +++ b/api/routes_panel.py @@ -69,6 +69,7 @@ from api.nestquest_core import ( core_settings, core_snapshot, ) +from api import transitions as api_transitions #: The instance DAO, imported on the app's core copy for the #: child-mismatch guard on the complete route. @@ -283,13 +284,14 @@ async def panel_complete_instance( detail="Quest instance not found for this child", ) + now = _local_now() try: result = await core_completion.complete_instance( database, instance_id, actor_source="panel", actor_child_id=body.actor_child_id, - now=_local_now(), + now=now, ) except ValueError as error: message = str(error) @@ -312,6 +314,17 @@ async def panel_complete_instance( database, instance_id, was_on_time=result.was_on_time ) request.app.state.publisher.publish(event_type, payload) + # THEN evaluate the day-complete rule off the completed event's + # payload (the core builder decides when the child's whole day + # is cleared — a zero-quest day and a non-today instance never + # fire it). ``today`` comes from the route's single clock read + # so the calendar date can never disagree with the completion's. + await api_transitions.publish_child_day_complete( + database, + payload, + now.date(), + request.app.state.publisher, + ) return {"status": str(result)} diff --git a/api/transitions.py b/api/transitions.py new file mode 100644 index 0000000..3216eea --- /dev/null +++ b/api/transitions.py @@ -0,0 +1,83 @@ +"""Reusable SSE transition publishers for the API service (task 44c93cfd). + +Each helper wraps the ONE publisher on ``app.state.publisher`` with a +single documented transition: it builds the payload through the core +event builders (``nestquest_core.events``) — never hand-built here — +and fans it out with :meth:`~api.events.TransitionPublisher.publish`. + +The uncomplete route (follow-up task da0226b3) and the API-side missed +sweep do not exist yet; these helpers are the functions they will call, +and the SSE tests exercise them directly. Fan-out stays fire-and-forget +(see api/events.py): a slow subscriber drops the event and recovers by +polling the snapshot. +""" +from __future__ import annotations + +import datetime + +from api.nestquest_core import core_events + + +async def publish_quest_uncompleted( + database: object, instance_id: int, publisher: object +) -> None: + """Publish ONE ``nestquest_quest_uncompleted`` event for an actual + reversal of ``instance_id``. + + The payload is built by the core builder + (:func:`nestquest_core.events.build_quest_uncompleted_events`); + this helper only picks the single event out of the builder's list + and publishes it. + """ + events = await core_events.build_quest_uncompleted_events( + database, instance_id + ) + for event_type, payload in events: + publisher.publish(event_type, payload) + + +async def publish_quest_missed( + database: object, instance_id: int, publisher: object +) -> None: + """Publish ONE ``nestquest_quest_missed`` event for ``instance_id``. + + The payload is built by the core builder + (:func:`nestquest_core.events.build_quest_missed_event`), which + mirrors the documented sweep payload exactly (§3 of + design/ENTITIES-AND-SERVICES.md). + """ + event_type, payload = await core_events.build_quest_missed_event( + database, instance_id + ) + publisher.publish(event_type, payload) + + +async def publish_child_day_complete( + database: object, + completed_payload: dict, + today: datetime.date, + publisher: object, +) -> bool: + """Publish ``nestquest_child_day_complete`` when a completion + cleared the child's whole day; return whether one was published. + + The day-complete rule lives entirely in the core builder + (:func:`nestquest_core.events.build_child_day_complete_event`): + it returns ``None`` when the day did not clear (a zero-quest day + or a non-today instance) and this helper publishes nothing then. + """ + event = await core_events.build_child_day_complete_event( + database, completed_payload, today=today + ) + if event is None: + return False + event_type, payload = event + publisher.publish(event_type, payload) + return True + + +__all__ = [ + "publish_child_day_complete", + "publish_quest_missed", + "publish_quest_uncompleted", +] diff --git a/custom_components/nestquest/core/events.py b/custom_components/nestquest/core/events.py index 01e7015..d066d5b 100644 --- a/custom_components/nestquest/core/events.py +++ b/custom_components/nestquest/core/events.py @@ -14,7 +14,9 @@ fires nothing), and ``nestquest_child_day_complete`` fires when a completion clears the child's whole day (quests were owed and none remain — a zero-quest day can never fire it, matching the all-done binary sensor's rule). ``nestquest_quest_missed`` is the documented -contract Feature 11's nightly sweep fires; nothing here builds it. +contract Feature 11's nightly sweep fires; :func:`build_quest_missed_event` +builds it with exactly the payload the sweep hand-builds (§3), so the +API plane can publish the same shape without re-deriving it. The completed-event and day-complete-event builders are split so the shim can fire ``nestquest_quest_completed`` BEFORE it evaluates the @@ -50,6 +52,7 @@ from .completion import derive_state from .const import ( EVENT_CHILD_DAY_COMPLETE, EVENT_QUEST_COMPLETED, + EVENT_QUEST_MISSED, EVENT_QUEST_UNCOMPLETED, ) from .dao_children import ChildrenDao @@ -188,3 +191,20 @@ async def build_quest_uncompleted_events( """ payload = await _instance_payload(database, instance_id, _now_stamp()) return [(EVENT_QUEST_UNCOMPLETED, payload)] + + +async def build_quest_missed_event( + database: NestQuestDatabase, instance_id: int +) -> tuple[str, dict]: + """Build the ``nestquest_quest_missed`` event for one instance. + + The payload mirrors the nightly sweep's hand-built payload exactly + (the shared :func:`_instance_payload` shape: ``child_id``, + ``child_name``, ``instance_id``, ``quest_title``, ``window``, + ``due_date``, ``due_time``, ``occurred_at``), so the API plane's + missed path publishes the same event shape the integration's sweep + fires. Like the sweep, this builder never mutates the instance or + writes a completion event — it reads only. + """ + payload = await _instance_payload(database, instance_id, _now_stamp()) + return (EVENT_QUEST_MISSED, payload) diff --git a/tests/test_api_sse.py b/tests/test_api_sse.py index 7a91a3b..c9cfcc0 100644 --- a/tests/test_api_sse.py +++ b/tests/test_api_sse.py @@ -46,6 +46,9 @@ from tests.test_api_panel import PANEL_TOKEN, _seed_household #: The documented quest-completed event name (core const, §3). _core_const = importlib.import_module("nestquest_core.const") EVENT_QUEST_COMPLETED = _core_const.EVENT_QUEST_COMPLETED +EVENT_QUEST_UNCOMPLETED = _core_const.EVENT_QUEST_UNCOMPLETED +EVENT_QUEST_MISSED = _core_const.EVENT_QUEST_MISSED +EVENT_CHILD_DAY_COMPLETE = _core_const.EVENT_CHILD_DAY_COMPLETE #: Upper bound for any single stream read; bounds the whole test so a #: wedged stream fails the assertion instead of hanging the suite. @@ -70,12 +73,15 @@ class _Server: def __init__(self, db_path: str) -> None: self.port = _free_port() + self._server_loop: asyncio.AbstractEventLoop | None = None self._server = uvicorn.Server( uvicorn.Config( - # A closure factory: reads nothing at import time. - lambda: create_app( - ApiConfig(db_path=db_path, panel_token=PANEL_TOKEN) - ), + # A closure factory that captures the app's event loop + # as it is created INSIDE the server thread: the + # lifespan's database and publisher belong to that + # thread's loop, so tests driving an API-side + # transition helper directly schedule it there. + self._make_app_factory(db_path), host="127.0.0.1", port=self.port, log_level="warning", @@ -85,6 +91,23 @@ class _Server: target=self._server.run, daemon=True ) + def _make_app_factory(self, db_path: str): + """The uvicorn app factory, closing over ``db_path``. + + Records the running loop the first time uvicorn calls it (in + the server thread) so :meth:`run_on_loop` can target it. + """ + + def factory(): + app = create_app( + ApiConfig(db_path=db_path, panel_token=PANEL_TOKEN) + ) + self._server_loop = asyncio.get_running_loop() + self.app = app + return app + + return factory + def __enter__(self) -> "_Server": self._thread.start() deadline = time.monotonic() + READ_TIMEOUT @@ -102,6 +125,20 @@ class _Server: """The full URL for ``path`` on this server.""" return f"http://127.0.0.1:{self.port}{path}" + def run_on_loop(self, coro) -> None: + """Run ``coro`` on the SERVER's event loop and wait for it. + + The app (its database and publisher) lives on the uvicorn + thread's loop, so a test that drives an API-side transition + helper directly must schedule it there: the helper awaits core + DAO calls and calls ``publisher.publish`` synchronously, and + both must run on the loop that owns them. Bounded by + READ_TIMEOUT so a wedged schedule fails instead of hanging. + """ + return asyncio.run_coroutine_threadsafe( + coro, self._server_loop + ).result(timeout=READ_TIMEOUT) + class _EventStream: """A subscribed SSE reader over a live httpx connection. @@ -284,6 +321,10 @@ async def test_no_op_recomplete_publishes_nothing(seeded_panel) -> None: assert first.status_code == 200 event_type, _ = await stream.next_event() assert event_type == EVENT_QUEST_COMPLETED + # Ada's only quest today: the completion also clears her whole + # day, so the day-complete event follows (task 44c93cfd). + event_type, _ = await stream.next_event() + assert event_type == EVENT_CHILD_DAY_COMPLETE second = await seeded_panel.client.post( url, headers=headers, json=body @@ -293,3 +334,123 @@ async def test_no_op_recomplete_publishes_nothing(seeded_panel) -> None: # Nothing arrives within the window: the no-op re-complete # must publish nothing. await asyncio.wait_for(stream.queue.get(), timeout=0.3) + + +# --- the remaining documented transitions (task 44c93cfd) ----------------- + + +async def test_publish_quest_uncompleted_streams_event(seeded_panel) -> None: + """The reusable uncompleted publisher streams the documented + uncompleted transition with its documented payload fields. + + The admin uncomplete route does not exist yet (deferred to task + da0226b3); the test drives the API-side helper the route will call. + The helper runs on the SERVER's event loop (``run_coroutine_threadsafe`` + on the uvicorn thread's loop) against the app's own live database, + exactly the way the later route handler will call it. + """ + from api import transitions + + seed = seeded_panel.seed + pack_bag = seed.pack_bag_instance + app = seeded_panel.server.app + database = app.state.db.database + stream = _stream(seeded_panel.url("/api/v1/panel/events")) + async with stream: + seeded_panel.server.run_on_loop( + transitions.publish_quest_uncompleted( + database, pack_bag.id, app.state.publisher + ) + ) + event_type, payload = await stream.next_event() + assert event_type == EVENT_QUEST_UNCOMPLETED + assert event_type == "nestquest_quest_uncompleted" + assert payload["child_id"] == seed.ada.id + assert payload["child_name"] == "Ada" + assert payload["instance_id"] == pack_bag.id + assert payload["quest_title"] == "Pack bag" + assert payload["window"] == "morning" + assert payload["due_date"] == pack_bag.due_date + assert payload["due_time"] == "10:00" + assert payload["occurred_at"] + assert "was_on_time" not in payload + + +async def test_last_quest_of_day_publishes_day_complete(seeded_panel) -> None: + """Completing the child's LAST open quest of the day emits the + completed event AND the child-day-complete event carrying + ``quests_due`` and ``quests_completed``. + + Ada has exactly one instance today (Pack bag), so completing it + clears her whole day; a no-op re-complete afterwards publishes + nothing. + """ + seed = seeded_panel.seed + pack_bag = seed.pack_bag_instance + stream = _stream(seeded_panel.url("/api/v1/panel/events")) + async with stream: + response = await seeded_panel.client.post( + f"/api/v1/panel/instances/{pack_bag.id}/complete", + headers={"Authorization": f"Bearer {PANEL_TOKEN}"}, + json={"actor_child_id": seed.ada.id}, + ) + assert response.status_code == 200 + + event_type, payload = await stream.next_event() + assert event_type == EVENT_QUEST_COMPLETED + completed_occurred_at = payload["occurred_at"] + event_type, payload = await stream.next_event() + assert event_type == EVENT_CHILD_DAY_COMPLETE + assert event_type == "nestquest_child_day_complete" + assert payload["child_id"] == seed.ada.id + assert payload["child_name"] == "Ada" + assert payload["quests_due"] == 1 + assert payload["quests_completed"] == 1 + # One transition, one shared timestamp (the core builder + # reuses the completed event's stamp). + assert payload["occurred_at"] == completed_occurred_at + + # No-op re-complete: nothing further on the stream. + second = await seeded_panel.client.post( + f"/api/v1/panel/instances/{pack_bag.id}/complete", + headers={"Authorization": f"Bearer {PANEL_TOKEN}"}, + json={"actor_child_id": seed.ada.id}, + ) + assert second.status_code == 200 + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(stream.queue.get(), timeout=0.3) + + +async def test_publish_quest_missed_streams_event(seeded_panel) -> None: + """The reusable missed publisher streams the documented missed + transition with its documented payload fields (the sweep payload). + + The API-side missed sweep does not exist yet at this queue + position; the test drives the API-side helper the sweep will call + against the seeded past-due open instance, on the server's own + event loop and database. + """ + from api import transitions + + seed = seeded_panel.seed + stale = seed.stale_instance + app = seeded_panel.server.app + database = app.state.db.database + stream = _stream(seeded_panel.url("/api/v1/panel/events")) + async with stream: + seeded_panel.server.run_on_loop( + transitions.publish_quest_missed( + database, stale.id, app.state.publisher + ) + ) + event_type, payload = await stream.next_event() + assert event_type == EVENT_QUEST_MISSED + assert event_type == "nestquest_quest_missed" + assert payload["child_id"] == seed.ada.id + assert payload["child_name"] == "Ada" + assert payload["instance_id"] == stale.id + assert payload["quest_title"] == "Stale chore" + assert payload["window"] == "morning" + assert payload["due_date"] == stale.due_date + assert payload["due_time"] == "09:00" + assert payload["occurred_at"] diff --git a/tests/test_events.py b/tests/test_events.py index 8d520f6..77977b9 100644 --- a/tests/test_events.py +++ b/tests/test_events.py @@ -791,3 +791,31 @@ async def test_shim_day_complete_uses_ha_local_timezone( f"{far_today.isoformat()}, UTC today {utc_today.isoformat()}); " f"got {len(day_complete)} events" ) + + +async def test_build_quest_missed_event_payload_key_set( + hass, make_entry +) -> None: + """The missed-event payload carries exactly the documented keys — + the same shape the nightly sweep hand-builds (§3), with no + ``was_on_time`` (completion-only).""" + from custom_components.nestquest.core.events import ( + build_quest_missed_event, + ) + + entry, child, instances = await _setup_and_seed(hass, make_entry) + database = entry.runtime_data.database + event_type, payload = await build_quest_missed_event( + database, instances[0].id + ) + assert event_type == EVENT_QUEST_MISSED + assert event_type == "nestquest_quest_missed" + assert set(payload) == _UNCOMPLETED_KEYS, ( + f"missed payload keys drift: got {set(payload)} " + f"expected {_UNCOMPLETED_KEYS}" + ) + assert payload["child_id"] == child.id + assert payload["child_name"] == "Ada" + assert payload["instance_id"] == instances[0].id + assert payload["quest_title"] == "Brush teeth" + assert _TIMESTAMP.match(payload["occurred_at"]), payload["occurred_at"] -- 2.55.0 From ca1165de3f0efce8af3ae0860c0548961477de49 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 00:37:30 -0400 Subject: [PATCH 07/15] Admin auth: Authentik JWT validation (#156) --- api/app.py | 17 +- api/auth.py | 406 ++++++++++++++++++ api/config.py | 148 +++++-- api/routes_admin.py | 63 +++ pyproject.toml | 1 + tests/test_api_admin_auth.py | 791 +++++++++++++++++++++++++++++++++++ tests/test_api_health.py | 35 +- tests/test_api_panel.py | 25 +- tests/test_api_sse.py | 12 +- uv.lock | 179 ++++++++ 10 files changed, 1628 insertions(+), 49 deletions(-) create mode 100644 api/auth.py create mode 100644 api/routes_admin.py create mode 100644 tests/test_api_admin_auth.py diff --git a/api/app.py b/api/app.py index c245926..d14c09f 100644 --- a/api/app.py +++ b/api/app.py @@ -12,6 +12,11 @@ exposes the two endpoints every later task depends on: (service-token protected; see :mod:`api.routes_panel`, whose router carries the shared :func:`api.dependencies.require_panel_token` check). +- ``GET /api/v1/admin/ping`` — the admin plane's auth probe, protected + by the Authentik OIDC JWT check (see :mod:`api.routes_admin`, whose + router carries the shared :func:`api.auth.require_admin_jwt` + dependency; the JWKS document it verifies against is cached on + ``app.state.jwks_cache``). - ``GET /docs`` — FastAPI's auto-served OpenAPI document (Swagger UI); ``/openapi.json`` is the raw schema. No extra wiring is needed; FastAPI serves both by default. @@ -37,9 +42,11 @@ from collections.abc import AsyncIterator from fastapi import FastAPI +from api.auth import JwksCache from api.config import ApiConfig from api.database import DatabaseState, make_database from api.events import TransitionPublisher +from api.routes_admin import router as admin_router from api.routes_panel import router as panel_router @@ -90,10 +97,16 @@ def create_app(config: ApiConfig | None = None) -> FastAPI: ) # The resolved config rides on app.state so the reusable route - # dependencies (the panel service-token check) read the configured - # values from the app rather than module state. + # dependencies (the panel service-token check, the admin JWT check) + # read the configured values from the app rather than module state. app.state.config = cfg + # The admin plane's ONE per-app JWKS cache: fetched from the + # configured provider on first use, then reused until its TTL + # lapses (see api.auth.JwksCache). Tests replace it with a + # stub-backed cache so no request ever leaves the process. + app.state.jwks_cache = JwksCache(cfg.oidc_jwks_url) app.include_router(panel_router) + app.include_router(admin_router) @app.get("/health", summary="Service health") async def health() -> dict[str, str]: diff --git a/api/auth.py b/api/auth.py new file mode 100644 index 0000000..8111ebb --- /dev/null +++ b/api/auth.py @@ -0,0 +1,406 @@ +"""Admin-plane authentication for the NestQuest API service. + +The admin plane (``/api/v1/admin/*``, D-012) is authenticated by +Authentik-issued OIDC access tokens: the admin PWA presents its JWT as +a Bearer credential and :func:`require_admin_jwt` — the ONE +implementation of that check — verifies it before any admin route runs. +Verification enforces, without exception: + +- the signature, against the RSA signing key published in the + provider's JWKS document (fetched from the configured + ``NESTQUEST_OIDC_JWKS_URL`` and cached per app — never per request); +- the issuer (``iss`` must equal the configured + ``NESTQUEST_OIDC_ISSUER``); +- the audience (``aud`` must carry the configured + ``NESTQUEST_OIDC_AUDIENCE``); +- expiry (``exp`` is required and enforced, and ``nbf`` is enforced + when the token carries it). + +Every rejection raises the SAME :class:`~fastapi.HTTPException` with +the same detail — nothing in the response distinguishes which check +failed. The check FAILS CLOSED: a missing app config, a malformed or +missing ``Authorization`` header, a non-RS256 algorithm, an unknown +``kid``, and any PyJWT verification error all yield the one 401. + +The token's header (``alg``, ``kid``) is parsed and checked BEFORE the +JWKS cache is consulted, so structurally invalid tokens can never +amplify load on the provider. An unknown ``kid`` forces ONE +TTL-bypassing refetch (throttled, see :class:`JwksCache`) so a rotated +key is picked up without waiting out the TTL, and a JWKS entry that +matches the ``kid`` but carries corrupt key material maps to the same +401. A JWKS FETCH failure is deliberately NOT mapped to 401: the +provider being unreachable is a server-side fault (an HTTP 500 +surfaces) and never poisons the cache, so the plane stays closed while +the real cause is visible in the server log instead of disguised as a +bad credential; a short backoff window after a failed fetch fails fast +(re-raising the recorded fault) rather than fetching once per request. + +This module validates the TOKEN only — it is authentication, not +authorization. Membership in the ``nestquest-admins`` group is the +NEXT task, which extends :func:`require_admin_jwt` (the verified +claims are already its return value, so no rewrite is needed). +""" +from __future__ import annotations + +import asyncio +import binascii +import json +import time +from collections.abc import Awaitable, Callable +from typing import Annotated + +import httpx +import jwt +from fastapi import Header, HTTPException, Request + +#: The authorization scheme the admin plane expects. Matched +#: case-insensitively, as RFC 9110 requires for scheme names. +_BEARER_SCHEME = "bearer" + +#: The ONLY JWT signing algorithm accepted. Pinned to one asymmetric +#: algorithm (Authentik's RS256 default) so neither the header's ``alg`` +#: nor any JWKS key can talk the verifier into a weaker or symmetric +#: scheme; PyJWT re-enforces it at decode time. +_ALLOWED_ALGORITHM = "RS256" + +#: The 401 detail shared by every rejection — deliberately uniform so +#: the response never reveals which part of the check failed. +_REJECTION_DETAIL = "Missing or invalid admin credentials" + +#: Challenge header returned with a 401, per the Bearer auth scheme. +_WWW_AUTHENTICATE = {"WWW-Authenticate": "Bearer"} + +#: Seconds a fetched JWKS document stays trusted before the next +#: fetch. Five minutes bounds how long a rotated-away key keeps +#: verifying while staying far above a per-request fetch. +_JWKS_TTL_SECONDS = 300.0 + +#: Seconds a FAILED JWKS fetch blocks further fetch attempts. Within +#: this window after a failure the cache fails fast — it re-raises the +#: recorded fault instead of touching the provider — so an outage is +#: never amplified into one fetch per request. +_JWKS_BACKOFF_SECONDS = 5.0 + +#: Seconds that must pass before ANOTHER forced (TTL-bypassing) refetch +#: may run. An unmatched ``kid`` forces one refetch to pick up rotated +#: keys; this interval bounds it, so a flood of unknown-``kid`` tokens +#: cannot turn each request into a provider fetch either. +_JWKS_KID_REFETCH_SECONDS = 5.0 + +#: Seconds before the real JWKS fetch gives up on the provider. +_JWKS_TIMEOUT_SECONDS = 5.0 + +#: An async callable that fetches the JWKS document from a URL. The +#: real one (:func:`_fetch_jwks`) uses httpx; tests inject a stub so +#: the suite stays hermetic (no network access). +JwksFetcher = Callable[[str], Awaitable[dict[str, object]]] + + +async def _fetch_jwks(url: str) -> dict[str, object]: + """Fetch the provider's JWKS document over HTTP (the real fetch). + + Uses httpx with a short timeout; a non-2xx response or a body that + is not a ``{"keys": [...]}`` object raises — the cache never stores + a failed or malformed fetch. + """ + async with httpx.AsyncClient(timeout=_JWKS_TIMEOUT_SECONDS) as client: + response = await client.get(url) + response.raise_for_status() + document = response.json() + if not isinstance(document, dict) or not isinstance( + document.get("keys"), list + ): + raise ValueError("JWKS document must be an object with a keys list") + return document + + +class JwksCache: + """One app's cache of the OIDC provider's JWKS document. + + The document is fetched through ``fetcher`` (the real one uses + httpx against the configured JWKS URL) and held for ``ttl_seconds`` + — the dependency NEVER fetches per request. A single fetch is + shared between concurrent requests via an :class:`asyncio.Lock` + (double-checked around it), and a failed fetch is never cached, so + the next request retries once the backoff window lapses rather than + being locked out until the TTL does. + + Two bounds keep the provider out of an amplification loop: + + - ``backoff_seconds``: after a FAILED fetch, :meth:`get` fails fast + for this long — re-raising the recorded fault WITHOUT touching + the fetcher or even the lock — instead of retrying per request. + - ``kid_refetch_seconds``: forced (TTL-bypassing) refetches — the + unmatched-``kid`` path — may run at most this often; a forced + refetch that is bounded out (or one inside the backoff window) + is served from the cached document as-is. + + A successful fetch always clears the backoff; the document itself + is still trusted only for ``ttl_seconds`` — nothing is cached + indefinitely. + + One instance lives on ``app.state.jwks_cache`` (installed by + :func:`api.app.create_app`); tests replace it with one built around + a stub fetcher, keeping the suite hermetic. + """ + + def __init__( + self, + url: str, + *, + ttl_seconds: float = _JWKS_TTL_SECONDS, + backoff_seconds: float = _JWKS_BACKOFF_SECONDS, + kid_refetch_seconds: float = _JWKS_KID_REFETCH_SECONDS, + fetcher: JwksFetcher | None = None, + ) -> None: + self._url = url + self._ttl_seconds = ttl_seconds + self._backoff_seconds = backoff_seconds + self._kid_refetch_seconds = kid_refetch_seconds + self._fetcher: JwksFetcher = ( + fetcher if fetcher is not None else _fetch_jwks + ) + self._document: dict[str, object] | None = None + self._fetched_at: float | None = None + self._failed_until: float | None = None + self._last_error: BaseException | None = None + self._forced_at: float | None = None + self._lock = asyncio.Lock() + + async def get(self, *, force: bool = False) -> dict[str, object]: + """Return the JWKS document, refetching it if the TTL lapsed. + + With ``force=True`` the TTL is bypassed so an unmatched ``kid`` + can pick up a rotated key without waiting it out; the forced + refetch is itself bounded (see the class docstring): inside the + backoff window or the ``kid_refetch_seconds`` interval the + cached document is served as-is instead. A failed fetch is + re-raised to the caller (a provider outage is a server fault, + never a 401) and starts the backoff window, during which every + caller — including forced ones with nothing cached — fails fast + on the recorded fault. + """ + if not force: + fresh = self._fresh_document() + if fresh is not None: + return fresh + error = self._backoff_error() + if error is not None: + # Fail fast OUTSIDE the lock: a request inside the backoff + # window must not queue behind an in-flight fetch. + if force: + document = self._document + if document is not None: + return document + raise error + async with self._lock: + if not force: + fresh = self._fresh_document() + if fresh is not None: + return fresh + error = self._backoff_error() + if error is not None: + if force: + document = self._document + if document is not None: + return document + raise error + if force and self._refetch_throttled(): + document = self._document + if document is not None: + return document + return await self._fetch_locked(force=force) + + def _fresh_document(self) -> dict[str, object] | None: + """The cached document if one exists and is inside its TTL.""" + document = self._document + fetched_at = self._fetched_at + if ( + document is not None + and fetched_at is not None + and (time.monotonic() - fetched_at) < self._ttl_seconds + ): + return document + return None + + def _backoff_error(self) -> BaseException | None: + """The recorded fetch fault while the backoff window is open.""" + failed_until = self._failed_until + last_error = self._last_error + if ( + failed_until is not None + and last_error is not None + and time.monotonic() < failed_until + ): + return last_error + return None + + def _refetch_throttled(self) -> bool: + """Whether a forced refetch ran too recently to run another.""" + forced_at = self._forced_at + return forced_at is not None and ( + time.monotonic() - forced_at + ) < self._kid_refetch_seconds + + async def _fetch_locked(self, *, force: bool) -> dict[str, object]: + """Run ONE fetch attempt (caller holds the lock) and record it. + + A failed fetch starts the ``backoff_seconds`` window and is + re-raised; a successful one stores the document, restarts the + TTL and clears the backoff. A forced attempt stamps + ``_forced_at`` so the ``kid_refetch_seconds`` interval bounds + how often forced refetches may run back-to-back. + """ + if force: + self._forced_at = time.monotonic() + try: + document = await self._fetcher(self._url) + except Exception as error: + self._failed_until = time.monotonic() + self._backoff_seconds + self._last_error = error + raise + self._document = document + self._fetched_at = time.monotonic() + self._failed_until = None + self._last_error = None + return document + + +def _reject() -> HTTPException: + """Build the single 401 the dependency raises for every failure.""" + return HTTPException( + status_code=401, + detail=_REJECTION_DETAIL, + headers=_WWW_AUTHENTICATE, + ) + + +def _matching_key(jwks: dict[str, object], kid: str) -> object | None: + """Return the RSA signing key named ``kid`` from the JWKS. + + Only ``kty: RSA`` entries are considered (the pinned RS256 scheme + is asymmetric RSA); anything else — a missing ``keys`` list, no + entry with that ``kid`` — returns ``None`` rather than falling back + to "some" key. The key is built through PyJWT's JWKS decoder, and + an entry that MATCHES the ``kid`` but carries corrupt key material + (a malformed ``n``/``e`` raises ``ValueError`` — ``binascii.Error`` + is its subclass — or ``TypeError`` instead of a ``PyJWTError``) is + mapped to the one 401 like every other rejection: the caller must + never see a half-built key. + """ + keys = jwks.get("keys") + if not isinstance(keys, list): + raise _reject() + for entry in keys: + if not isinstance(entry, dict): + continue + if entry.get("kid") != kid or entry.get("kty") != "RSA": + continue + try: + return jwt.algorithms.RSAAlgorithm.from_jwk(json.dumps(entry)) + except ( + jwt.PyJWTError, + binascii.Error, + ValueError, + TypeError, + KeyError, + ): + raise _reject() from None + return None + + +async def verify_admin_token( + request: Request, token: str +) -> dict[str, object]: + """Verify ONE admin bearer token and return its verified claims. + + Reads the OIDC settings off ``app.state.config`` and the JWKS cache + off ``app.state.jwks_cache`` (both installed by + :func:`api.app.create_app`) so the check carries no module state. + A missing config or cache, an EMPTY configured issuer/audience/JWKS + URL, a non-RS256 ``alg`` header, an absent ``kid``, an unknown + ``kid``, and any PyJWT verification failure (bad signature, + expired, wrong issuer, wrong audience, not-yet-valid, missing + ``exp``) raise the SAME 401 — the failure mode is never leaked. + ``exp`` is REQUIRED (a token that never expires is never accepted) + and PyJWT enforces ``nbf`` when the token carries it. + + The token's HEADER (``alg``, ``kid``) is parsed and enforced BEFORE + the JWKS cache is consulted, so a structurally invalid token costs + no fetch, no lock, no provider load. When the cached document has + no entry for the ``kid``, ONE forced (TTL-bypassing, throttled) + refetch is made and the match retried once — a rotated key is + picked up without waiting out the TTL — before the 401. + + A JWKS fetch failure propagates (see the module docstring): the + provider being down is a server fault, not a bad credential. + """ + config = getattr(request.app.state, "config", None) + if ( + config is None + or not config.oidc_issuer + or not config.oidc_audience + or not config.oidc_jwks_url + ): + raise _reject() + cache = getattr(request.app.state, "jwks_cache", None) + if cache is None: + raise _reject() + try: + header = jwt.get_unverified_header(token) + except jwt.PyJWTError: + raise _reject() from None + if header.get("alg") != _ALLOWED_ALGORITHM: + raise _reject() + kid = header.get("kid") + if not isinstance(kid, str) or not kid: + raise _reject() + jwks = await cache.get() + try: + key = _matching_key(jwks, kid) + if key is None: + # The kid is not in the cached document: force ONE + # TTL-bypassing refetch (bounded by the cache) and retry + # the match once before rejecting. + jwks = await cache.get(force=True) + key = _matching_key(jwks, kid) + if key is None: + raise _reject() + claims = jwt.decode( + token, + key=key, + algorithms=[_ALLOWED_ALGORITHM], + issuer=config.oidc_issuer, + audience=config.oidc_audience, + options={"require": ["exp"]}, + ) + except jwt.PyJWTError: + raise _reject() from None + return claims + + +async def require_admin_jwt( + request: Request, + authorization: Annotated[str | None, Header()] = None, +) -> dict[str, object]: + """Reject the request with 401 unless it carries a valid admin JWT. + + Extracts the Bearer credential — a missing or malformed + ``Authorization`` header (wrong scheme, empty credential) raises + the one 401 — and delegates the verification to + :func:`verify_admin_token`, returning the verified claims for the + route to use (the probe route surfaces the token's ``sub``). + + The nestquest-admins GROUP check is the NEXT task: it extends this + dependency (e.g. by inspecting the returned claims) so every admin + route inherits it — the 401 contract stays exactly this uniform. + """ + if authorization is None: + raise _reject() + scheme, _, credential = authorization.partition(" ") + if scheme.lower() != _BEARER_SCHEME or not credential.strip(): + raise _reject() + return await verify_admin_token(request, credential.strip()) + + +__all__ = ["JwksCache", "JwksFetcher", "require_admin_jwt", "verify_admin_token"] diff --git a/api/config.py b/api/config.py index 776e19b..6256f3a 100644 --- a/api/config.py +++ b/api/config.py @@ -8,15 +8,25 @@ The API reads its configuration knobs from environment variables: (D-012): the credential ``/api/v1/panel/*`` routes require via :func:`api.dependencies.require_panel_token`. It is stored outside the repo and handed to the service through the environment. +- ``NESTQUEST_OIDC_ISSUER`` — the Authentik OIDC issuer URL whose + ``iss`` claim admin tokens must carry (D-012 admin plane). +- ``NESTQUEST_OIDC_AUDIENCE`` — the OIDC client id the admin tokens + must carry as their ``aud`` claim (the API service's client id). +- ``NESTQUEST_OIDC_JWKS_URL`` — the Authentik endpoint serving the + signing keys (the JWKS document) the admin tokens' signatures are + verified against. Cached per app (see :class:`api.auth.JwksCache`). Unlike a dev convenience default, a missing or empty value is a configuration ERROR, not a silent fallback: a default database path -would silently drop data at reboot, and a default (or empty) service -token would either authenticate everyone or lock the panel plane out -for good. ``ApiConfig.from_env`` therefore RAISES when either variable -is unset or empty. This mirrors the integration's "explicit settings -object" discipline (Feature 15's ``core.settings``): the API never -reads HA config; callers pass the resolved values into the wiring. +would silently drop data at reboot, a default (or empty) service token +would either authenticate everyone or lock the panel plane out for +good, and a default (or empty) OIDC setting would silently leave the +admin plane trusting an issuer that was never configured. All five +variables are therefore REQUIRED — ``ApiConfig.from_env`` RAISES when +any of them is unset or empty. This mirrors the integration's +"explicit settings object" discipline (Feature 15's ``core.settings``): +the API never reads HA config; callers pass the resolved values into +the wiring. Serving: the app is built with the factory ``api.app:create_app`` — run it with ``uvicorn --factory api.app:create_app`` so the factory reads @@ -41,6 +51,28 @@ DB_PATH_ENV = "NESTQUEST_DB_PATH" #: lock the panel plane out permanently. PANEL_TOKEN_ENV = "NESTQUEST_PANEL_TOKEN" +#: Environment variable holding the Authentik OIDC issuer URL (D-012 +#: admin plane): every admin JWT's ``iss`` claim must equal it. Checked +#: by :func:`api.auth.require_admin_jwt`. REQUIRED — unset or empty +#: raises :class:`ConfigError` from :meth:`ApiConfig.from_env`; a default +#: issuer would silently make the admin plane trust tokens from an +#: issuer nobody configured. +OIDC_ISSUER_ENV = "NESTQUEST_OIDC_ISSUER" + +#: Environment variable holding the OIDC audience (the API service's +#: client id at Authentik): every admin JWT's ``aud`` claim must carry +#: it. Checked by :func:`api.auth.require_admin_jwt`. REQUIRED — unset +#: or empty raises :class:`ConfigError` from :meth:`ApiConfig.from_env`. +OIDC_AUDIENCE_ENV = "NESTQUEST_OIDC_AUDIENCE" + +#: Environment variable holding the Authentik JWKS URL: the endpoint +#: serving the signing keys admin JWT signatures are verified against. +#: Fetched and cached by :class:`api.auth.JwksCache`. REQUIRED — unset +#: or empty raises :class:`ConfigError` from :meth:`ApiConfig.from_env`; +#: without the provider's keys no token can be verified, so a silent +#: default would only move the failure to request time. +OIDC_JWKS_URL_ENV = "NESTQUEST_OIDC_JWKS_URL" + class ConfigError(RuntimeError): """Raised when required API configuration is missing or invalid.""" @@ -50,18 +82,25 @@ class ConfigError(RuntimeError): class ApiConfig: """Resolved API configuration. - Both fields are required (no defaults): ``from_env`` rejects an - unset or empty value for either, and a caller building the dataclass - directly must pass an explicit token — the token check itself fails - closed on an empty configured token, but expecting a caller to - discover that at request time is a wiring mistake, so the field is - mandatory. Kept as a dataclass so later tasks (admin plane, SSE, - settings persistence) can extend it without touching the call sites - that read ``db_path``. + Every field is required (no defaults): ``from_env`` rejects an + unset or empty value for each, and a caller building the dataclass + directly must pass explicit values — the token check and the JWT + checks themselves fail closed on empty configured values, but + expecting a caller to discover that at request time is a wiring + mistake, so every field is mandatory. Kept as a dataclass so later + tasks (SSE, settings persistence) can extend it without touching + the call sites that read ``db_path``. """ db_path: str panel_token: str + #: The Authentik OIDC issuer URL admin tokens' ``iss`` must equal. + oidc_issuer: str + #: The OIDC audience (the API service's client id) admin tokens' + #: ``aud`` must carry. + oidc_audience: str + #: The Authentik JWKS URL the signing keys are fetched from. + oidc_jwks_url: str @classmethod def from_env(cls, env: dict[str, str] | None = None) -> "ApiConfig": @@ -70,29 +109,66 @@ class ApiConfig: ``env`` defaults to ``os.environ``; tests pass an explicit dict so they are hermetic (no monkeypatch of the global environ). - Raises :class:`ConfigError` when ``NESTQUEST_DB_PATH`` is unset - or empty/whitespace-only — there is no silent default, because a - default in the temp directory would silently lose data at reboot - — and likewise when ``NESTQUEST_PANEL_TOKEN`` is unset or empty - (a default token is never invented: the panel plane is closed - until a real one is configured). + Raises :class:`ConfigError` when ANY of the five variables is + unset or empty/whitespace-only — there is no silent default: + + - ``NESTQUEST_DB_PATH``: a default in the temp directory would + silently lose data at reboot; + - ``NESTQUEST_PANEL_TOKEN``: a default token is never invented — + the panel plane is closed until a real one is configured; + - the three OIDC variables: an unconfigured issuer/audience/JWKS + URL must not silently leave the admin plane open (a default + JWKS URL or issuer would point at a provider nobody set up). """ source = env if env is not None else os.environ - raw_db_path = source.get(DB_PATH_ENV, "").strip() - if not raw_db_path: - raise ConfigError( - f"{DB_PATH_ENV} must be set to a writable SQLite file path " - f"(it is unset or empty — the API will not fall back to a " - f"temp directory and silently lose data)" - ) - raw_panel_token = source.get(PANEL_TOKEN_ENV, "").strip() - if not raw_panel_token: - raise ConfigError( - f"{PANEL_TOKEN_ENV} must be set to the panel service token " - f"(it is unset or empty — the API will not start without " - f"one, and no default token is invented)" - ) - return cls(db_path=raw_db_path, panel_token=raw_panel_token) + + def require(env_var: str, reason: str) -> str: + """Return the stripped env value or raise ConfigError.""" + value = source.get(env_var, "").strip() + if not value: + raise ConfigError(f"{env_var} {reason}") + return value + + return cls( + db_path=require( + DB_PATH_ENV, + "must be set to a writable SQLite file path (it is unset " + "or empty — the API will not fall back to a temp directory " + "and silently lose data)", + ), + panel_token=require( + PANEL_TOKEN_ENV, + "must be set to the panel service token (it is unset or " + "empty — the API will not start without one, and no " + "default token is invented)", + ), + oidc_issuer=require( + OIDC_ISSUER_ENV, + "must be set to the Authentik OIDC issuer URL (it is " + "unset or empty — the admin plane will not trust tokens " + "from an issuer nobody configured)", + ), + oidc_audience=require( + OIDC_AUDIENCE_ENV, + "must be set to the OIDC audience the admin tokens carry " + "(it is unset or empty — the admin plane will not accept " + "tokens without a configured audience)", + ), + oidc_jwks_url=require( + OIDC_JWKS_URL_ENV, + "must be set to the Authentik JWKS URL (it is unset or " + "empty — admin token signatures cannot be verified " + "without the provider's signing keys)", + ), + ) -__all__ = ["ApiConfig", "ConfigError", "DB_PATH_ENV", "PANEL_TOKEN_ENV"] +__all__ = [ + "ApiConfig", + "ConfigError", + "DB_PATH_ENV", + "OIDC_AUDIENCE_ENV", + "OIDC_ISSUER_ENV", + "OIDC_JWKS_URL_ENV", + "PANEL_TOKEN_ENV", +] diff --git a/api/routes_admin.py b/api/routes_admin.py new file mode 100644 index 0000000..7a2e2a2 --- /dev/null +++ b/api/routes_admin.py @@ -0,0 +1,63 @@ +"""Admin-plane routes for the NestQuest API service. + +The admin plane (``/api/v1/admin/*``, D-012) serves the admin PWA. +Every route on :data:`router` requires a valid Authentik OIDC JWT +through the ONE reusable dependency :func:`api.auth.require_admin_jwt` +(declared once at the router level, so each later admin route added to +this router inherits the check). + +This task is the AUTH probe only: the ping route is a thin adapter +that performs NO business logic and NO auth of its own — it reuses the +router's already-verified claims (FastAPI caches a dependency's result +per request, so the check runs exactly once) and returns the status +plus the token's subject. Authorization — membership in the +``nestquest-admins`` group — is the NEXT task, layered onto +:func:`api.auth.require_admin_jwt` so every admin route inherits it. +""" +from __future__ import annotations + +from typing import Annotated + +from fastapi import APIRouter, Depends +from pydantic import BaseModel + +from api.auth import require_admin_jwt + +#: All admin-plane routes share this router; the JWT check is attached +#: HERE so every admin route (current and later) requires it. +router = APIRouter( + prefix="/api/v1/admin", + dependencies=[Depends(require_admin_jwt)], +) + + +class AdminPingResponse(BaseModel): + """The probe body: the status plus the verified token's subject.""" + + status: str + #: The JWT's ``sub`` claim — the subject the admin PWA authenticated + #: as. ``None`` only for a verified token that carries no ``sub``. + sub: str | None + + +@router.get("/ping", summary="Admin plane probe") +async def admin_ping( + claims: Annotated[dict[str, object], Depends(require_admin_jwt)], +) -> AdminPingResponse: + """Return 200 for a verified admin JWT (the plane's smoke probe). + + Requires a valid Authentik JWT (checked by the router's shared + :func:`~api.auth.require_admin_jwt` dependency — the ONE token + check; this handler performs NO auth of its own). ``claims`` is + the SAME verified-claims dict that dependency produced (FastAPI's + per-request dependency cache), so the token is verified exactly + once per request. + """ + sub = claims.get("sub") + return AdminPingResponse( + status="ok", + sub=sub if isinstance(sub, str) else None, + ) + + +__all__ = ["router"] diff --git a/pyproject.toml b/pyproject.toml index 754ff79..b247a53 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -17,6 +17,7 @@ dev = [ "fastapi>=0.115", "httpx>=0.28", "jinja2>=3", + "pyjwt[crypto]>=2.9", "pytest>=8.0.0", "pytest-asyncio>=0.24", "pyyaml>=6", diff --git a/tests/test_api_admin_auth.py b/tests/test_api_admin_auth.py new file mode 100644 index 0000000..ff161f5 --- /dev/null +++ b/tests/test_api_admin_auth.py @@ -0,0 +1,791 @@ +"""Admin-plane JWT auth tests: Authentik-style OIDC verification (task e70e6bfb). + +These tests exercise the NestQuest API's admin plane against the ASGI +app with httpx, using a locally generated RSA key pair and a stubbed +JWKS fetcher — NO network access: + +- ``GET /api/v1/admin/ping`` requires a valid Authentik OIDC JWT: a + request with NO ``Authorization`` header and a MALFORMED header + return 401; a garbage, expired, wrong-issuer, wrong-audience, + not-yet-valid (future ``nbf``), unknown-``kid``, or bad-signature + token returns 401; a token without ``exp`` is rejected; a VALID + token returns 200 with the verified subject. +- Every 401 carries the SAME detail — nothing in the response + distinguishes which check failed. +- The token's HEADER is validated BEFORE the JWKS cache is touched: a + garbage or non-RS256 token triggers NO fetch at all. +- A JWKS entry that matches the ``kid`` but carries corrupt key + material maps to the same uniform 401. +- The JWKS document is fetched ONCE through the app's + :class:`api.auth.JwksCache` and then reused across requests (the + stub fetcher counts calls); a zero TTL refetches. +- A JWKS OUTAGE fails fast: after one failed fetch the cache re-raises + the fault for the whole backoff window without further fetches, even + under a burst of concurrent requests; an unknown ``kid`` forces one + TTL-bypassing refetch, throttled to at most one per interval. + +``ApiConfig.from_env`` requires the three OIDC variables +(``NESTQUEST_OIDC_ISSUER``, ``NESTQUEST_OIDC_AUDIENCE``, +``NESTQUEST_OIDC_JWKS_URL``) with the same fail-fast discipline as +``NESTQUEST_DB_PATH`` / ``NESTQUEST_PANEL_TOKEN``: tests build the app +with explicit ApiConfig values so they stay hermetic. + +The signature/expiry/audience proof is real, not stubbed: tokens are +signed with a locally generated RSA private key, the app verifies +against the JWKS built from its public key, and the wrong-signature +case signs with a SECOND locally generated key pair. +""" +from __future__ import annotations + +import asyncio +import base64 +import json +import time +from pathlib import Path + +import httpx +import jwt +import pytest +from cryptography.hazmat.primitives.asymmetric import rsa + +from api.app import create_app +from api.auth import JwksCache +from api.config import ApiConfig, ConfigError + +#: The configured OIDC settings the test apps run with (values a real +#: deployment would get from the NESTQUEST_OIDC_* environment vars). +ISSUER = "https://authentik.example.com/application/o/nestquest/" +AUDIENCE = "nestquest-api" +JWKS_URL = "https://authentik.example.com/application/o/nestquest/jwks/" + +#: The ``kid`` the test tokens carry, matching the JWKS entry. +KID = "admin-test-key-1" + +#: The subject a valid token authenticates as. +SUBJECT = "admin-user-1" + +#: The uniform 401 detail every rejection must carry. +REJECTION_DETAIL = "Missing or invalid admin credentials" + + +def _generate_key() -> rsa.RSAPrivateKey: + """Generate one local RSA key pair (no network, no fixtures).""" + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +#: The key pair whose PUBLIC half the stubbed JWKS publishes — tokens +#: signed with it verify; the second pair below proves they don't when +#: signed otherwise. +ADMIN_KEY = _generate_key() +OTHER_KEY = _generate_key() + + +def _b64url_uint(value: int) -> str: + """Base64url-encode a non-negative integer (no padding), per JWK.""" + raw = value.to_bytes((value.bit_length() + 7) // 8, "big") + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def _jwks_for(private_key: rsa.RSAPrivateKey, kid: str) -> dict[str, object]: + """Build a JWKS document publishing ``private_key``'s public half.""" + numbers = private_key.public_key().public_numbers() + return { + "keys": [ + { + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": kid, + "n": _b64url_uint(numbers.n), + "e": _b64url_uint(numbers.e), + } + ] + } + + +class _StubbedFetch: + """A JWKS fetcher stub: returns a fixed document, counts calls.""" + + def __init__(self, document: dict[str, object]) -> None: + self.document = document + self.calls = 0 + + async def __call__(self, url: str) -> dict[str, object]: + self.calls += 1 + return self.document + + +class _FailingFetch: + """A JWKS fetcher stub that always fails, counting the attempts.""" + + def __init__(self) -> None: + self.calls = 0 + + async def __call__(self, url: str) -> dict[str, object]: + self.calls += 1 + raise httpx.ConnectError("provider unreachable") + + +class _FlakyFetch: + """A fetcher that succeeds ONCE, then fails on every later call.""" + + def __init__(self, document: dict[str, object]) -> None: + self.document = document + self.calls = 0 + + async def __call__(self, url: str) -> dict[str, object]: + self.calls += 1 + if self.calls == 1: + return self.document + raise httpx.ConnectError("provider unreachable") + + +class _FailThenServe: + """A fetcher that fails the FIRST call, then serves the document.""" + + def __init__(self, document: dict[str, object]) -> None: + self.document = document + self.calls = 0 + + async def __call__(self, url: str) -> dict[str, object]: + self.calls += 1 + if self.calls == 1: + raise httpx.ConnectError("provider unreachable") + return self.document + + +def _make_token( + signing_key: rsa.RSAPrivateKey = ADMIN_KEY, + *, + kid: str | None = KID, + iss: str = ISSUER, + aud: object = AUDIENCE, + sub: str = SUBJECT, + expires_in: int | None = 300, + nbf_in: int | None = -1, +) -> str: + """Mint one RS256 JWT the way Authentik would (its claims, our key).""" + now = int(time.time()) + claims: dict[str, object] = {"iss": iss, "aud": aud, "sub": sub, "iat": now} + if nbf_in is not None: + claims["nbf"] = now + nbf_in + if expires_in is not None: + claims["exp"] = now + expires_in + headers: dict[str, str] = {} + if kid is not None: + headers["kid"] = kid + return jwt.encode( + claims, signing_key, algorithm="RS256", headers=headers + ) + + +def _token_with_header( + header: dict[str, object], payload: dict[str, object] +) -> str: + """Build a JWT with an EXACT header (bypassing jwt.encode's rules). + + Used to prove header checks (``alg``, ``kid``) happen before the + JWKS cache is consulted; the signature segment is never reached. + """ + def part(obj: dict[str, object]) -> str: + raw = json.dumps(obj).encode("utf-8") + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + return f"{part(header)}.{part(payload)}.c2lnbmF0dXJl" + + +def _verified_payload() -> dict[str, object]: + """Claims that would verify — the header checks fire before them.""" + now = int(time.time()) + return { + "iss": ISSUER, + "aud": AUDIENCE, + "sub": SUBJECT, + "exp": now + 300, + "nbf": now - 1, + } + + +class _AdminRunner: + """Run the app's lifespan around an httpx AsyncClient. + + Same pattern as test_api_health.py's ``_AppRunner``: httpx's ASGI + transport does not run the lifespan, so it is driven explicitly and + shut down in ``__aexit__``'s finally block even on failure. + + The app's JWKS cache is replaced (before the lifespan runs) with + one built around a counting stub fetcher, so verification goes + through the REAL cache logic but never the network. + """ + + def __init__( + self, + db_path: str, + jwks_document: dict[str, object], + *, + ttl_seconds: float | None = None, + backoff_seconds: float | None = None, + kid_refetch_seconds: float | None = None, + ) -> None: + self.app = create_app( + ApiConfig( + db_path=db_path, + panel_token="admin-test-panel-token", + oidc_issuer=ISSUER, + oidc_audience=AUDIENCE, + oidc_jwks_url=JWKS_URL, + ) + ) + self.fetch = _StubbedFetch(jwks_document) + cache_kwargs: dict[str, float] = {} + if ttl_seconds is not None: + cache_kwargs["ttl_seconds"] = ttl_seconds + if backoff_seconds is not None: + cache_kwargs["backoff_seconds"] = backoff_seconds + if kid_refetch_seconds is not None: + cache_kwargs["kid_refetch_seconds"] = kid_refetch_seconds + self.app.state.jwks_cache = JwksCache( + JWKS_URL, fetcher=self.fetch, **cache_kwargs + ) + + async def __aenter__(self) -> httpx.AsyncClient: + self._lifespan = self.app.router.lifespan_context(self.app) + await self._lifespan.__aenter__() + self._client = httpx.AsyncClient( + transport=httpx.ASGITransport(app=self.app), + base_url="http://testserver", + ) + return await self._client.__aenter__() + + async def __aexit__(self, *exc) -> None: + try: + await self._client.__aexit__(*exc) + finally: + await self._lifespan.__aexit__(*exc) + + +@pytest.fixture +def temp_db_path(tmp_path: Path) -> str: + """A fresh per-test SQLite path under pytest's tmp_path.""" + return str(tmp_path / "nestquest.db") + + +@pytest.fixture +async def admin_client( + temp_db_path: str, +) -> httpx.AsyncClient: + """An app client whose provider publishes the admin test key.""" + async with _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID)) as client: + yield client + + +# --- the happy path ------------------------------------------------------- + + +async def test_valid_token_returns_200_with_subject( + admin_client: httpx.AsyncClient, +) -> None: + """A valid token returns 200 with the status and verified subject.""" + response = await admin_client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {_make_token()}"}, + ) + assert response.status_code == 200 + assert response.json() == {"status": "ok", "sub": SUBJECT} + + +async def test_bearer_scheme_is_case_insensitive( + admin_client: httpx.AsyncClient, +) -> None: + """``bearer`` (lowercase) is accepted, as RFC 9110 requires.""" + response = await admin_client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"bearer {_make_token()}"}, + ) + assert response.status_code == 200 + + +# --- 401s: missing / malformed credentials -------------------------------- + + +async def test_missing_token_is_unauthorized( + admin_client: httpx.AsyncClient, +) -> None: + """A request with no Authorization header returns 401.""" + response = await admin_client.get("/api/v1/admin/ping") + assert response.status_code == 401 + assert response.json()["detail"] == REJECTION_DETAIL + assert "www-authenticate" in { + key.lower() for key in response.headers + } + + +@pytest.mark.parametrize( + "header", + [ + "Basic cGFuZWw6dG9rZW4=", # wrong scheme + "Bearer", # scheme, no credential + "Bearer ", # blank credential + "BearerNotAToken", # no scheme separator at all + ], +) +async def test_malformed_header_is_unauthorized( + admin_client: httpx.AsyncClient, header: str +) -> None: + """A malformed Authorization header returns 401.""" + response = await admin_client.get( + "/api/v1/admin/ping", headers={"Authorization": header} + ) + assert response.status_code == 401 + + +async def test_garbage_token_is_unauthorized( + admin_client: httpx.AsyncClient, +) -> None: + """A Bearer credential that is not a JWT at all returns 401.""" + response = await admin_client.get( + "/api/v1/admin/ping", + headers={"Authorization": "Bearer not-a-jwt"}, + ) + assert response.status_code == 401 + + +# --- 401s: token content the verifier must reject --------------------------- + + +async def test_expired_token_is_unauthorized( + admin_client: httpx.AsyncClient, +) -> None: + """A token whose exp has passed returns 401.""" + token = _make_token(expires_in=-60) + response = await admin_client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 401 + + +async def test_wrong_issuer_is_unauthorized( + admin_client: httpx.AsyncClient, +) -> None: + """A token issued by another issuer returns 401.""" + token = _make_token(iss="https://evil.example.com/application/o/nestquest/") + response = await admin_client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 401 + + +async def test_wrong_audience_is_unauthorized( + admin_client: httpx.AsyncClient, +) -> None: + """A token minted for another audience returns 401.""" + token = _make_token(aud="some-other-client") + response = await admin_client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 401 + + +async def test_future_nbf_is_unauthorized( + admin_client: httpx.AsyncClient, +) -> None: + """A token that is not yet valid (future nbf) returns 401.""" + token = _make_token(nbf_in=3600) + response = await admin_client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 401 + + +async def test_token_without_exp_is_unauthorized( + admin_client: httpx.AsyncClient, +) -> None: + """A token with no exp claim returns 401 (expiry is REQUIRED).""" + token = _make_token(expires_in=None) + response = await admin_client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 401 + + +async def test_unknown_kid_is_unauthorized( + admin_client: httpx.AsyncClient, +) -> None: + """A token naming a kid the JWKS does not publish returns 401.""" + token = _make_token(kid="no-such-key") + response = await admin_client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 401 + + +async def test_bad_signature_is_unauthorized( + admin_client: httpx.AsyncClient, +) -> None: + """A token signed by a key the provider never published returns 401. + + Signed with the SECOND local key pair (header kid still points at + the published key), so the signature check itself must fail. + """ + token = _make_token(signing_key=OTHER_KEY) + response = await admin_client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 401 + + +async def test_corrupt_jwks_entry_matching_kid_is_unauthorized( + temp_db_path: str, +) -> None: + """A JWKS entry matching the kid but with corrupt key data → 401. + + ``RSAAlgorithm.from_jwk`` raises ValueError/TypeError (NOT a + PyJWTError) on such an entry; it must map to the SAME uniform 401, + not surface as a server error. + """ + corrupt_jwks: dict[str, object] = { + "keys": [ + {"kty": "RSA", "kid": KID, "n": "AQ", "e": "AQAB"}, + ] + } + runner = _AdminRunner(temp_db_path, corrupt_jwks) + async with runner as client: + response = await client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {_make_token()}"}, + ) + assert response.status_code == 401 + assert response.json()["detail"] == REJECTION_DETAIL + + +# --- the header is checked BEFORE the JWKS cache ----------------------------- + + +async def test_garbage_token_triggers_no_jwks_fetch( + temp_db_path: str, +) -> None: + """A token that is not a JWT costs NO fetch, lock or provider load.""" + runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID)) + async with runner as client: + response = await client.get( + "/api/v1/admin/ping", + headers={"Authorization": "Bearer not-a-jwt"}, + ) + assert response.status_code == 401 + assert response.json()["detail"] == REJECTION_DETAIL + assert runner.fetch.calls == 0 + + +async def test_non_rs256_token_triggers_no_jwks_fetch( + temp_db_path: str, +) -> None: + """A structurally valid JWT with a non-RS256 alg is 401 pre-cache. + + The header carries a kid pointing at the published key, so ONLY the + early alg check can reject it — the fetcher must never be called. + """ + runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID)) + token = _token_with_header( + {"alg": "HS256", "typ": "JWT", "kid": KID}, _verified_payload() + ) + async with runner as client: + response = await client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 401 + assert response.json()["detail"] == REJECTION_DETAIL + assert runner.fetch.calls == 0 + + +# --- the uniform 401 contract ----------------------------------------------- + + +async def test_every_rejection_carries_the_same_detail( + admin_client: httpx.AsyncClient, +) -> None: + """No failure mode is distinguishable in the response body. + + Collects the (status, detail) pair for every rejection — missing + and malformed headers, garbage, expired, wrong issuer, wrong + audience, future nbf, missing exp, unknown kid, bad signature — + and asserts they are ALL the same one 401. + """ + cases: list[dict[str, str]] = [ + {}, + {"Authorization": "Basic cGFuZWw6dG9rZW4="}, + {"Authorization": "Bearer"}, + {"Authorization": "Bearer not-a-jwt"}, + {"Authorization": f"Bearer {_make_token(expires_in=-60)}"}, + {"Authorization": f"Bearer {_make_token(iss='https://evil.example.com/')}"}, + {"Authorization": f"Bearer {_make_token(aud='other-client')}"}, + {"Authorization": f"Bearer {_make_token(nbf_in=3600)}"}, + {"Authorization": f"Bearer {_make_token(expires_in=None)}"}, + {"Authorization": f"Bearer {_make_token(kid='no-such-key')}"}, + {"Authorization": f"Bearer {_make_token(signing_key=OTHER_KEY)}"}, + ] + observed = set() + for headers in cases: + response = await admin_client.get("/api/v1/admin/ping", headers=headers) + observed.add((response.status_code, response.json()["detail"])) + assert observed == {(401, REJECTION_DETAIL)} + + +# --- the JWKS cache ---------------------------------------------------------- + + +async def test_jwks_is_fetched_once_across_requests( + temp_db_path: str, +) -> None: + """Multiple requests reuse ONE cached JWKS document. + + Three separately authenticated requests trigger exactly ONE stub + fetch — the cache is per app and never per request. + """ + runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID)) + token = _make_token() + async with runner as client: + for _ in range(3): + response = await client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200 + assert runner.fetch.calls == 1 + + +async def test_expired_ttl_refetches_the_jwks(temp_db_path: str) -> None: + """A lapsed TTL triggers a fresh fetch instead of serving stale keys.""" + runner = _AdminRunner( + temp_db_path, _jwks_for(ADMIN_KEY, KID), ttl_seconds=0.0 + ) + token = _make_token() + async with runner as client: + for _ in range(2): + response = await client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200 + assert runner.fetch.calls == 2 + + +# --- the JWKS cache under an outage: fail fast, don't amplify --------------- + + +async def test_jwks_outage_fails_fast_during_backoff() -> None: + """After one failed fetch, later requests fail fast for the window.""" + fetch = _FailingFetch() + cache = JwksCache(JWKS_URL, fetcher=fetch) + for _ in range(4): + with pytest.raises(httpx.ConnectError): + await cache.get() + assert fetch.calls == 1 + + +async def test_jwks_outage_burst_calls_the_fetcher_once() -> None: + """A burst of concurrent requests costs ONE fetch, not one each. + + The first caller fetches and fails; every other caller — queued on + the lock or arriving inside the backoff window — fails fast on the + recorded fault without touching the fetcher. + """ + fetch = _FailingFetch() + cache = JwksCache(JWKS_URL, fetcher=fetch) + results = await asyncio.gather( + *(cache.get() for _ in range(6)), return_exceptions=True + ) + assert fetch.calls == 1 + assert all( + isinstance(result, httpx.ConnectError) for result in results + ) + + +async def test_jwks_backoff_lapses_and_recovers() -> None: + """A backoff window is not a lockout: retries resume after it. + + With a zero-length window the very next request retries (and the + successful document is then served from the cache), proving a + failed state is never cached for good. + """ + document = _jwks_for(ADMIN_KEY, KID) + fetch = _FailThenServe(document) + cache = JwksCache(JWKS_URL, fetcher=fetch, backoff_seconds=0.0) + with pytest.raises(httpx.ConnectError): + await cache.get() + assert await cache.get() == document + assert await cache.get() == document + assert fetch.calls == 2 + + +async def test_jwks_outage_is_a_server_fault_not_a_401( + temp_db_path: str, +) -> None: + """A provider outage propagates as a fault, never as a 401.""" + runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID)) + runner.app.state.jwks_cache = JwksCache(JWKS_URL, fetcher=_FailingFetch()) + async with runner as client: + with pytest.raises(httpx.ConnectError): + await client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {_make_token()}"}, + ) + + +# --- the unknown-kid refetch: one forced, bounded attempt -------------------- + + +async def test_unknown_kid_refetch_picks_up_a_rotated_key( + temp_db_path: str, +) -> None: + """An unmatched kid forces ONE refetch, and the retry can verify. + + The JWKS first publishes a DIFFERENT kid: the token is 401 after + exactly two fetches (normal + forced). When the rotated key lands + in the provider's document, the NEXT token forces another refetch + that picks it up — the same token verifies WITHOUT waiting out the + TTL. + """ + runner = _AdminRunner( + temp_db_path, _jwks_for(ADMIN_KEY, "other-key"), kid_refetch_seconds=0.0 + ) + token = _make_token() + async with runner as client: + response = await client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 401 + assert response.json()["detail"] == REJECTION_DETAIL + assert runner.fetch.calls == 2 + + # The rotation lands at the provider (same stub fetcher). + runner.fetch.document = _jwks_for(ADMIN_KEY, KID) + response = await client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 200 + assert runner.fetch.calls == 3 + + +async def test_unknown_kid_refetch_is_throttled(temp_db_path: str) -> None: + """Back-to-back unknown-kid tokens force at most one refetch. + + The interval bounds forced refetches: the second unknown-kid token + inside it is served from the cached document (still unmatched → + 401) with no further fetch. + """ + runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, "other-key")) + token = _make_token() + async with runner as client: + for _ in range(2): + response = await client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 401 + assert runner.fetch.calls == 2 + + +async def test_unknown_kid_refetch_resumes_after_the_interval( + temp_db_path: str, +) -> None: + """A lapsed interval allows the next forced refetch again. + + With a zero-length interval every request may force its refetch: + the first costs one normal fetch plus one forced (two), and the + second — its cached document still fresh — costs just the forced + one (three in total), proving the throttle is a bounded interval + rather than a permanent lockout. + """ + runner = _AdminRunner( + temp_db_path, _jwks_for(ADMIN_KEY, "other-key"), kid_refetch_seconds=0.0 + ) + token = _make_token() + async with runner as client: + for _ in range(2): + response = await client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 401 + assert runner.fetch.calls == 3 + + +async def test_failed_forced_refetch_propagates_then_fails_fast( + temp_db_path: str, +) -> None: + """A failed forced refetch is a fault, and its backoff bounds the rest. + + First request: cache fills (1 fetch), the kid is unmatched, the + forced refetch FAILS (2 fetches) and propagates as the server + fault. Second request inside the backoff window: the cached + document is served, the match fails, and the token is 401 — with + NO third fetch. + """ + runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID)) + runner.app.state.jwks_cache = JwksCache( + JWKS_URL, fetcher=_FlakyFetch(_jwks_for(ADMIN_KEY, "other-key")) + ) + token = _make_token() + async with runner as client: + with pytest.raises(httpx.ConnectError): + await client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {token}"}, + ) + response = await client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 401 + assert response.json()["detail"] == REJECTION_DETAIL + + +# --- ApiConfig.from_env: the OIDC settings ----------------------------------- + + +def _full_env() -> dict[str, str]: + """A from_env dict with every required variable set to a valid value.""" + return { + "NESTQUEST_DB_PATH": "/data/nq.db", + "NESTQUEST_PANEL_TOKEN": "panel-token", + "NESTQUEST_OIDC_ISSUER": ISSUER, + "NESTQUEST_OIDC_AUDIENCE": AUDIENCE, + "NESTQUEST_OIDC_JWKS_URL": JWKS_URL, + } + + +def test_from_env_reads_oidc_settings() -> None: + """Set, non-empty OIDC env values are returned verbatim.""" + cfg = ApiConfig.from_env(_full_env()) + assert cfg.oidc_issuer == ISSUER + assert cfg.oidc_audience == AUDIENCE + assert cfg.oidc_jwks_url == JWKS_URL + + +@pytest.mark.parametrize( + "env_var", + [ + "NESTQUEST_OIDC_ISSUER", + "NESTQUEST_OIDC_AUDIENCE", + "NESTQUEST_OIDC_JWKS_URL", + ], +) +def test_from_env_requires_oidc_setting(env_var: str) -> None: + """A missing OR empty OIDC variable raises ConfigError (fail fast).""" + env = _full_env() + del env[env_var] + with pytest.raises(ConfigError): + ApiConfig.from_env(env) + env[env_var] = " " + with pytest.raises(ConfigError): + ApiConfig.from_env(env) diff --git a/tests/test_api_health.py b/tests/test_api_health.py index efc4cc4..a007d0d 100644 --- a/tests/test_api_health.py +++ b/tests/test_api_health.py @@ -69,11 +69,21 @@ class _AppRunner: """ def __init__(self, db_path: str) -> None: - # The token value is irrelevant to these tests (the health - # route is unauthenticated); it just satisfies the required - # field — the panel token's own tests live in test_api_panel.py. + # The token and OIDC values are irrelevant to these tests (the + # health route is unauthenticated); they just satisfy the + # required fields — the panel token's own tests live in + # test_api_panel.py and the OIDC settings' in + # test_api_admin_auth.py. self.app = create_app( - ApiConfig(db_path=db_path, panel_token="health-runner-token") + ApiConfig( + db_path=db_path, + panel_token="health-runner-token", + oidc_issuer="https://authentik.example.com/application/o/nestquest/", + oidc_audience="nestquest-api", + oidc_jwks_url=( + "https://authentik.example.com/application/o/nestquest/jwks/" + ), + ) ) async def __aenter__(self) -> httpx.AsyncClient: @@ -107,6 +117,9 @@ def test_from_env_returns_set_value() -> None: { "NESTQUEST_DB_PATH": "/data/nq.db", "NESTQUEST_PANEL_TOKEN": "panel-token", + "NESTQUEST_OIDC_ISSUER": "https://idp.example.com/issuer/", + "NESTQUEST_OIDC_AUDIENCE": "nestquest-api", + "NESTQUEST_OIDC_JWKS_URL": "https://idp.example.com/jwks/", } ) assert cfg.db_path == "/data/nq.db" @@ -295,10 +308,16 @@ _NO_HA_SCRIPT = textwrap.dedent( # Build the app (the lifespan is NOT run here, so the path is never # opened — it just needs to be a non-empty string to satisfy the # fail-fast config check). Use a temp path so nothing is created; - # the panel token value is irrelevant to this import-only check. - app = create_app(ApiConfig(db_path=str( - Path(tempfile.gettempdir()) / "nq-noha-buildonly.db" - ), panel_token="noha-import-token")) + # the token and OIDC values are irrelevant to this import-only check. + app = create_app(ApiConfig( + db_path=str(Path(tempfile.gettempdir()) / "nq-noha-buildonly.db"), + panel_token="noha-import-token", + oidc_issuer="https://authentik.example.com/application/o/nestquest/", + oidc_audience="nestquest-api", + oidc_jwks_url=( + "https://authentik.example.com/application/o/nestquest/jwks/" + ), + )) assert app is not None print("API NO-HA IMPORT OK") diff --git a/tests/test_api_panel.py b/tests/test_api_panel.py index 58aa414..5d8489b 100644 --- a/tests/test_api_panel.py +++ b/tests/test_api_panel.py @@ -236,7 +236,15 @@ class _PanelRunner: self, db_path: str, panel_token: str = PANEL_TOKEN ) -> None: self.app = create_app( - ApiConfig(db_path=db_path, panel_token=panel_token) + ApiConfig( + db_path=db_path, + panel_token=panel_token, + oidc_issuer="https://authentik.example.com/application/o/nestquest/", + oidc_audience="nestquest-api", + oidc_jwks_url=( + "https://authentik.example.com/application/o/nestquest/jwks/" + ), + ) ) async def __aenter__(self) -> tuple["_PanelRunner", httpx.AsyncClient]: @@ -311,6 +319,9 @@ def test_from_env_reads_panel_token() -> None: { "NESTQUEST_DB_PATH": "/data/nq.db", "NESTQUEST_PANEL_TOKEN": "panel-token", + "NESTQUEST_OIDC_ISSUER": "https://idp.example.com/issuer/", + "NESTQUEST_OIDC_AUDIENCE": "nestquest-api", + "NESTQUEST_OIDC_JWKS_URL": "https://idp.example.com/jwks/", } ) assert cfg.panel_token == "panel-token" @@ -319,7 +330,14 @@ def test_from_env_reads_panel_token() -> None: def test_from_env_requires_panel_token_when_missing() -> None: """A missing panel token raises ConfigError (no silent default).""" with pytest.raises(ConfigError): - ApiConfig.from_env({"NESTQUEST_DB_PATH": "/data/nq.db"}) + ApiConfig.from_env( + { + "NESTQUEST_DB_PATH": "/data/nq.db", + "NESTQUEST_OIDC_ISSUER": "https://idp.example.com/issuer/", + "NESTQUEST_OIDC_AUDIENCE": "nestquest-api", + "NESTQUEST_OIDC_JWKS_URL": "https://idp.example.com/jwks/", + } + ) def test_from_env_requires_panel_token_when_empty() -> None: @@ -329,6 +347,9 @@ def test_from_env_requires_panel_token_when_empty() -> None: { "NESTQUEST_DB_PATH": "/data/nq.db", "NESTQUEST_PANEL_TOKEN": " ", + "NESTQUEST_OIDC_ISSUER": "https://idp.example.com/issuer/", + "NESTQUEST_OIDC_AUDIENCE": "nestquest-api", + "NESTQUEST_OIDC_JWKS_URL": "https://idp.example.com/jwks/", } ) diff --git a/tests/test_api_sse.py b/tests/test_api_sse.py index c9cfcc0..aea8034 100644 --- a/tests/test_api_sse.py +++ b/tests/test_api_sse.py @@ -100,7 +100,17 @@ class _Server: def factory(): app = create_app( - ApiConfig(db_path=db_path, panel_token=PANEL_TOKEN) + ApiConfig( + db_path=db_path, + panel_token=PANEL_TOKEN, + oidc_issuer=( + "https://authentik.example.com/application/o/nestquest/" + ), + oidc_audience="nestquest-api", + oidc_jwks_url=( + "https://authentik.example.com/application/o/nestquest/jwks/" + ), + ) ) self._server_loop = asyncio.get_running_loop() self.app = app diff --git a/uv.lock b/uv.lock index 1f57235..8f50a83 100644 --- a/uv.lock +++ b/uv.lock @@ -42,6 +42,104 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/0b/a7/71ac2cff56fec219ed242bb11b8efb69fcc4bec75db06fb7bfe35de520e6/certifi-2026.7.22-py3-none-any.whl", hash = "sha256:62f22742b58a1a33014a2b6b706588a8d7e2a88ae7bd1a6ebe8c992928483775", size = 136983, upload-time = "2026-07-22T03:35:11.276Z" }, ] +[[package]] +name = "cffi" +version = "2.1.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "pycparser", marker = "implementation_name != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/9e/ef/008a1939e372c06329a3fce4279c02f328488f3526744906eeec3da7ad5f/cffi-2.1.1.tar.gz", hash = "sha256:dd31f52ea1086513bb9df30f8fcee9b8918323ae067a3d5b78bc826a000712be", size = 530807, upload-time = "2026-08-03T21:21:18.939Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/70/d2/16d99a0c4948febc0ebd133a13b2f688ff7f8cb04da971e1128872ce0c03/cffi-2.1.1-cp311-cp311-macosx_10_15_x86_64.whl", hash = "sha256:c8d2c9fd1f2d16f780d15127abb050d13d1a76c03a4bd87d7e4980e45e511e12", size = 183838, upload-time = "2026-08-03T21:19:29.637Z" }, + { url = "https://files.pythonhosted.org/packages/cd/95/31b535a9f0220ae9f357de4a08d57ce89cb417653c2fd9f075f50822a388/cffi-2.1.1-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:398aff33cee2767e3e781d2554c54bd0dff386bb437581e0d8011fde1a942ec1", size = 184168, upload-time = "2026-08-03T21:19:30.764Z" }, + { url = "https://files.pythonhosted.org/packages/ad/5a/4707a0dc1f203f5dde5a907b0d4e3c25d71120241048bd5bc6f1bb9d4e71/cffi-2.1.1-cp311-cp311-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:154852545011f779917b11c78db2358d095da62a9a172b78ad0a583ee5adc0d0", size = 211805, upload-time = "2026-08-03T21:19:31.867Z" }, + { url = "https://files.pythonhosted.org/packages/ad/66/c19feabb28485b6e0bbaaafa90837a1ef5d302e90f2178bd33f17a49879b/cffi-2.1.1-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:3311ed60d36f83378794e1009ac6258bafbf81f7888b4caa7b35a521e3f95813", size = 218716, upload-time = "2026-08-03T21:19:32.896Z" }, + { url = "https://files.pythonhosted.org/packages/a7/92/500760486c8baab49a7a8a58ba7fc3355ec3974b454b8a09e528efde9e1d/cffi-2.1.1-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:6e192623c49c94421616a5778fba35cf0d5a8d000650c1967ef4448ee5cdd990", size = 205569, upload-time = "2026-08-03T21:19:34.142Z" }, + { url = "https://files.pythonhosted.org/packages/a5/a7/a67c733254d6e7373f7822f8082d8d6beade791e0cf12a7611f376fa61c7/cffi-2.1.1-cp311-cp311-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a6e721d4b0e45d5b65e87534470e67b18dcd092c83f68fba09f152b9cbc061af", size = 204907, upload-time = "2026-08-03T21:19:35.174Z" }, + { url = "https://files.pythonhosted.org/packages/f7/a4/4399daaf8f7dfee9d7c3327fdb0426ee041cc63edc358b93911ceb2bfc7a/cffi-2.1.1-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:34e261f78cb6ceaaa36f42f2613f4380d94d9c759a9c73c769ee6e0247364632", size = 217807, upload-time = "2026-08-03T21:19:36.286Z" }, + { url = "https://files.pythonhosted.org/packages/28/f7/dabe6da2466ecbd82dc62e7342dc6b1065dad990c06f00f0ede9ebf2a0ed/cffi-2.1.1-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7225e4514edb64eb6740324353e0da0711954fd8d7da4576755b1c6e09b697cd", size = 221252, upload-time = "2026-08-03T21:19:37.416Z" }, + { url = "https://files.pythonhosted.org/packages/ce/87/616202d8e51342c07d2534c510111c4cc37201775ce8f60802c9335d1edd/cffi-2.1.1-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:df913725b79db7bcf03448f36b7bf8815363417d5b58deecf9305e3e30f0f21a", size = 214214, upload-time = "2026-08-03T21:19:38.507Z" }, + { url = "https://files.pythonhosted.org/packages/b4/c6/ab025d75d2c26c19b087c0124e75ee31cb65032f4fe345d356d8c507ab97/cffi-2.1.1-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:f5cfbc5fe74540d335175b656c725d74d90e3730c626d92575eea35029d9afaa", size = 219408, upload-time = "2026-08-03T21:19:39.809Z" }, + { url = "https://files.pythonhosted.org/packages/db/e2/7e8109f65445bdc673a7b54f02c677de462db75674220fd1335efc8eb598/cffi-2.1.1-cp311-cp311-win32.whl", hash = "sha256:f8ec5e643a9a937f64e1999eb9f75d072263751912dc5cd06d3c85f8f44be7c3", size = 174470, upload-time = "2026-08-03T21:19:41.246Z" }, + { url = "https://files.pythonhosted.org/packages/73/c0/77ba02423c2f7d7091143c45cd49e0e6575c4c1967394bb542bd923a9b74/cffi-2.1.1-cp311-cp311-win_amd64.whl", hash = "sha256:42f6930c31dc7f50732c9ae793c2786c7b6b044195967bbdde40bb9be81c4cc0", size = 185096, upload-time = "2026-08-03T21:19:42.615Z" }, + { url = "https://files.pythonhosted.org/packages/7c/47/9f1f85f9672ceda4984dc6c4f8824e8558992a2972c3d3c81fb8eb28d4ba/cffi-2.1.1-cp311-cp311-win_arm64.whl", hash = "sha256:c7659f22557c5a0bc4855cd635f55edec690cc008a40768527762cb9fb263455", size = 179941, upload-time = "2026-08-03T21:19:43.747Z" }, + { url = "https://files.pythonhosted.org/packages/10/69/43965eccfdead3b9220015fd1320e117be8c6ed01a62ffab76eeb752f5d5/cffi-2.1.1-cp312-cp312-macosx_10_15_x86_64.whl", hash = "sha256:c8c69575568085ba0b1b10c0249d779a214aea6f6522e949a0fc9fb0fcb449d0", size = 184821, upload-time = "2026-08-03T21:19:44.887Z" }, + { url = "https://files.pythonhosted.org/packages/54/7d/16e5a096677b5e313ca80cd5e5170efa3ea44624a82bb111925522da64b1/cffi-2.1.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:f81b3b8f3d4e343550fa4baa0e479bba9f2d29ce9c2e9b51d1ce1718d7442fcf", size = 184719, upload-time = "2026-08-03T21:19:46.129Z" }, + { url = "https://files.pythonhosted.org/packages/56/e6/8941622732edec876dd17d0453dce07317ae96db34f2ec1436c9d3785986/cffi-2.1.1-cp312-cp312-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:811bd1e21d32de12efca32393a0ab3f5133b54fce9bd44b8bd77ab07da14bf6a", size = 214799, upload-time = "2026-08-03T21:19:47.218Z" }, + { url = "https://files.pythonhosted.org/packages/44/de/f98430906df1545ffde0d543dd124a7a439bc2cd32b36b9c53f805df7333/cffi-2.1.1-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:68e62fe11f30d5ca8289242866f0a5291402d8529ca2178ab8afc5c9694ae890", size = 222389, upload-time = "2026-08-03T21:19:48.331Z" }, + { url = "https://files.pythonhosted.org/packages/6a/5b/717f1526b9957b34456313c31645c5b82b8fb5c3fe9e4752999be7128bfc/cffi-2.1.1-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:4a7c934f7360e8cd64fe9efadcbd10c7c6364f531e432b9a4bf5ccbc9e0e8b50", size = 210249, upload-time = "2026-08-03T21:19:49.543Z" }, + { url = "https://files.pythonhosted.org/packages/64/b3/f8aa4f3e34986c7e4ec45072d1b1b9dd295b6b18007b45518d79726dd725/cffi-2.1.1-cp312-cp312-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:3143d81e29e1e20a9ce10901ec369012947876596f75a222235965f2b7ae832e", size = 208775, upload-time = "2026-08-03T21:19:50.918Z" }, + { url = "https://files.pythonhosted.org/packages/b1/db/dceb9dd5b231e1da801793f8acc9f3c52a7e1afe40bb1aae37e02b0faad5/cffi-2.1.1-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:c1453022f490d2459a11819d83ad1d586e9ff65a12ac3e705ffebd46d3685dcf", size = 221822, upload-time = "2026-08-03T21:19:52.054Z" }, + { url = "https://files.pythonhosted.org/packages/a0/d2/6cd24ae3be000a634109c247d1475d62e5616d0dc78c82770942ec384248/cffi-2.1.1-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:208f941bb9d18e768138677f0a6d2ce01f590df56043dda1df1535ac57c88517", size = 225232, upload-time = "2026-08-03T21:19:53.109Z" }, + { url = "https://files.pythonhosted.org/packages/cb/52/3fa190537004dd7f0ab860a6dc7c0175b8667f68d1e618a46f5498d30250/cffi-2.1.1-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:210019b6c7cf07f081b4c54635c8cf744377001350e29cc0f81c4377b4797735", size = 223597, upload-time = "2026-08-03T21:19:54.515Z" }, + { url = "https://files.pythonhosted.org/packages/80/fb/0bb75b7039588c074b37ae99f40d9bfddf990ecb2fbc346ebccd2e56b9be/cffi-2.1.1-cp312-cp312-win32.whl", hash = "sha256:046bfc24911b37851ee1b51aab8bffe713d89c68c6a057b09484ce9fd5f69b4e", size = 175292, upload-time = "2026-08-03T21:19:55.566Z" }, + { url = "https://files.pythonhosted.org/packages/d9/79/615cc094e2fb508cade7de88d3b4f6c4ec2bab695c97bce9153dc65aadf5/cffi-2.1.1-cp312-cp312-win_amd64.whl", hash = "sha256:f53e442b08449d42821fa4a4fba000095af9f62742a500f978a9f557ec44339a", size = 185919, upload-time = "2026-08-03T21:19:56.89Z" }, + { url = "https://files.pythonhosted.org/packages/70/c6/d0ea84713fe46b243a436a18fcd47d639732747e21635c8a27191b06dc30/cffi-2.1.1-cp312-cp312-win_arm64.whl", hash = "sha256:7bde5e4cc5c10140859842b9d383af292b22639a4dffb725314baf45968cef80", size = 180093, upload-time = "2026-08-03T21:19:58.155Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f4/035513d4117049066b4779dc3b7c0c0fdad175fa13731c9f4003f1cd1478/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphoneos.whl", hash = "sha256:b5bdfd1c873d4e093aabc0ca84c4ca6dbc4f752afb5c86f146d9742580c9da2e", size = 194248, upload-time = "2026-08-03T21:19:59.399Z" }, + { url = "https://files.pythonhosted.org/packages/76/af/2aeb4dbb5fc41a04161ae9ff1518de7cec08e164f44a8ce6a4cf7fd2cd1d/cffi-2.1.1-cp313-cp313-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:31348097ff5bbe827ccc41795d4dd099d9f0625e7def00ee653c137a490c2a6c", size = 196908, upload-time = "2026-08-03T21:20:00.746Z" }, + { url = "https://files.pythonhosted.org/packages/a7/46/2e5fdde8555706dd98139a910ca11be02809f3f605ce956f655d0214e100/cffi-2.1.1-cp313-cp313-macosx_10_15_x86_64.whl", hash = "sha256:9d2055050ea716bd38b7f7f1579c275386646b4894c155a3e2f3cd62ed41b7c6", size = 184805, upload-time = "2026-08-03T21:20:02.02Z" }, + { url = "https://files.pythonhosted.org/packages/55/41/4c7042f317b9217502988f0873af87e16ad606dc20f84e546e3e6ce9764c/cffi-2.1.1-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:19ee6127ee34de7d83ce3d371ebc5ed91addbdcc39f9ab15ce4eb35a4e534971", size = 184764, upload-time = "2026-08-03T21:20:03.141Z" }, + { url = "https://files.pythonhosted.org/packages/43/1f/1c3d90d91811c8f86ced9ed637956c54bfe5b79ca98fe976d7f8c8979f6b/cffi-2.1.1-cp313-cp313-manylinux1_i686.manylinux2014_i686.manylinux_2_17_i686.manylinux_2_5_i686.whl", hash = "sha256:6a8dddef476fab96d066d578fc88526767b836ab5ab21754e1d5bf3879c31c7c", size = 214722, upload-time = "2026-08-03T21:20:04.377Z" }, + { url = "https://files.pythonhosted.org/packages/37/6f/3b5ce4c3b2192d250f04908f2bfd91ef34552ec8f7716a5d4abdb8d67bb2/cffi-2.1.1-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:f16c709686a78c727bbbf059f92b0bf41c6fc60deec706d2dc19f529175a6125", size = 222369, upload-time = "2026-08-03T21:20:05.544Z" }, + { url = "https://files.pythonhosted.org/packages/02/10/4b3c75dde3d9663c9e02ba05c2668b954f671d4bbe346413ca8c696b295a/cffi-2.1.1-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:fcd22650c908d7b7da162bbfaab594a1227a15d1643a98c68b122ac642fa2264", size = 210175, upload-time = "2026-08-03T21:20:06.75Z" }, + { url = "https://files.pythonhosted.org/packages/df/62/14f74b9543e605d17701dc797b815958b8bb70b7624ce1b832ddad48ed6c/cffi-2.1.1-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:aa9511c62d14da7aacc9b4bf51f3f697a621e83b2d6919008243c3aad168eea3", size = 208670, upload-time = "2026-08-03T21:20:08.04Z" }, + { url = "https://files.pythonhosted.org/packages/95/95/86342356ff5953b3fb06f7ef7c5bee212d45e770abc7218d451b9148313c/cffi-2.1.1-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a931079504ecc49efed7744c476a5c343a92fabf66dec2db95edb1b2fdc770e2", size = 221824, upload-time = "2026-08-03T21:20:09.274Z" }, + { url = "https://files.pythonhosted.org/packages/eb/ff/7b3429ff53aafe931ed8a5fc69f481bbef7ba6de87ddcbb63d08f483f613/cffi-2.1.1-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:a2d7755bef5a12ed488f4ef1f1b69ee9191d7396083b755a5d2295f6edb4768b", size = 225148, upload-time = "2026-08-03T21:20:10.7Z" }, + { url = "https://files.pythonhosted.org/packages/34/34/a95870b9221e09cf4f2ce3178b1a210abdfe63a1bd357da940418d7b8d15/cffi-2.1.1-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:e0bcb7e0f677f543555d2adff3bf19c05f66cdb4796e5ff602442ab2fe3c4ef7", size = 223564, upload-time = "2026-08-03T21:20:12.165Z" }, + { url = "https://files.pythonhosted.org/packages/70/ea/839b50531021a647fb5e929f72cf97bc1ff702b5472166164b5b6e76b851/cffi-2.1.1-cp313-cp313-win32.whl", hash = "sha256:334644fbac4eff73d985a17a91226df55d0f394160c4cfb880e084c8f7161cac", size = 175263, upload-time = "2026-08-03T21:20:13.559Z" }, + { url = "https://files.pythonhosted.org/packages/60/a6/8b149b2c3f2e11aaa1618ef64500b45f50f22c57a977a4dff1aff1f91042/cffi-2.1.1-cp313-cp313-win_amd64.whl", hash = "sha256:1aa5645c30469b09530c4ebca77ebf8f17618293c58f8549cb1a543a50236e7d", size = 185688, upload-time = "2026-08-03T21:20:14.69Z" }, + { url = "https://files.pythonhosted.org/packages/01/9a/11f687cb39d6a3504060d5242f04f48c735afb4d3d533958a20594890cb2/cffi-2.1.1-cp313-cp313-win_arm64.whl", hash = "sha256:63bbfd5ded17c4840ac07cd8f1c21ba9d9708141f840b324f422f41b207e3973", size = 180078, upload-time = "2026-08-03T21:20:15.917Z" }, + { url = "https://files.pythonhosted.org/packages/d3/7b/d6bbf82b8b96e7391438898c42f5bd96dd02030fd5b64937d248220003e2/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphoneos.whl", hash = "sha256:7dbb61fe3a7699468030f71bbe5f8a0e326a151daa91beb11a6fc1f980c55e1c", size = 194064, upload-time = "2026-08-03T21:20:17.148Z" }, + { url = "https://files.pythonhosted.org/packages/94/e6/bcc91b283be94735e268487a054004f0aa19947b6348fa367db53230abc8/cffi-2.1.1-cp314-cp314-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:f24fb43132a4c6b4cb4eb029492919b2db645be6808d738f244fd146c03c32cb", size = 196720, upload-time = "2026-08-03T21:20:18.268Z" }, + { url = "https://files.pythonhosted.org/packages/d9/99/c4b0c17cacdc9c3b8f280026286a9826d6a208c0f047591a3c3ce99b91fd/cffi-2.1.1-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:d28630f5854ab07ab1fd4aba756de52326c82e6be15d414b12793f1975048b54", size = 184964, upload-time = "2026-08-03T21:20:19.708Z" }, + { url = "https://files.pythonhosted.org/packages/b3/a9/9db617d05d7367c1ad0ab00b3aa6e6f9281edd689b4ee9ea0e5a84e89c97/cffi-2.1.1-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:661c298b4821edebead0c91edd2b00374d67ad7c5a1f7a91d4442633b79d6a72", size = 184962, upload-time = "2026-08-03T21:20:20.833Z" }, + { url = "https://files.pythonhosted.org/packages/67/b8/b42132ca113dc567d37684437b46ca1dafc885902b02a110a02d5b511857/cffi-2.1.1-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:58acb8ab8e295e6c5ea12f888cbb13cf21511ef2a3303a23f4325c29d17fe5c1", size = 222328, upload-time = "2026-08-03T21:20:22.118Z" }, + { url = "https://files.pythonhosted.org/packages/80/10/c5c0cbf0a657aecf59ef511409734230bf556f05a0d6c9eed7aa5c0a0166/cffi-2.1.1-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:456a61fa52d579ebf9df2e9552ead5129855dbaff6c1e5a9b1bc408809bdc062", size = 209985, upload-time = "2026-08-03T21:20:23.401Z" }, + { url = "https://files.pythonhosted.org/packages/d5/6c/bfa0b87b03b9238148beca990292843c9396ba069b54496596594173de7b/cffi-2.1.1-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a4f00aa42f75d6e4595e8866e748cc1705adc0cddfeb2ca86d0d03993d63ba03", size = 208530, upload-time = "2026-08-03T21:20:24.628Z" }, + { url = "https://files.pythonhosted.org/packages/e9/02/4e7d553a7ac4b4238b38b3c1b80d486e9d4436f8d2acbf87a0997fe3f402/cffi-2.1.1-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:b0431303acaea1089ad4b3e9ce4e6518193def1118d4073ca848635ee4ea2e96", size = 221525, upload-time = "2026-08-03T21:20:25.758Z" }, + { url = "https://files.pythonhosted.org/packages/82/1d/a4aaf9babd75acb4d5f223bff71533bee748dd770a382619a798960ee9ba/cffi-2.1.1-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:64faea20f4e2613363a1a9b9c7dd73058f3ecd00133a511e72ad7c511658f527", size = 225053, upload-time = "2026-08-03T21:20:26.985Z" }, + { url = "https://files.pythonhosted.org/packages/81/10/5dc0e7bdd18e22107054288283380fc97a06ae3f1656a106908d666a3c88/cffi-2.1.1-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:5c58fe613dc5e5336357eff555824a314d8e43282600435c8d1cb6a7a2fedd13", size = 223213, upload-time = "2026-08-03T21:20:28.277Z" }, + { url = "https://files.pythonhosted.org/packages/0b/e9/d0061c364cde06ee43168a0d076ac1da512cbc380d44767b844ba34fe2b6/cffi-2.1.1-cp314-cp314-win32.whl", hash = "sha256:1a18a57b58cfb21fc28d72e876acf10eaed67a1ed96226f92af4df681d571c4c", size = 177682, upload-time = "2026-08-03T21:20:44.288Z" }, + { url = "https://files.pythonhosted.org/packages/a7/06/1c3e01e3ba14c39f6d10bfbac52753b7e22259e38088e5cfe1d704918690/cffi-2.1.1-cp314-cp314-win_amd64.whl", hash = "sha256:3222ba5d678f80a030e6afbcc33dc1ae5cb45facabb61cee2c7016b8432fde48", size = 187949, upload-time = "2026-08-03T21:20:45.623Z" }, + { url = "https://files.pythonhosted.org/packages/87/5b/da4e39efe18eeb89cf580ea9cfc66b6a7c3eadb808fc0cc1d3a295cb5a5d/cffi-2.1.1-cp314-cp314-win_arm64.whl", hash = "sha256:ab36d55f9ed2d067327667c2fea18dda018eb628dd6347aa01dda6cf1f5d3836", size = 182947, upload-time = "2026-08-03T21:20:46.955Z" }, + { url = "https://files.pythonhosted.org/packages/23/59/40338bf421c5accea1d45158170c87006ef1cd371b05c077e76476949728/cffi-2.1.1-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:7750c6449dff7864bb9bb27ddfb0267756189201a3afc911d82b3caacd70dfc3", size = 188504, upload-time = "2026-08-03T21:20:29.495Z" }, + { url = "https://files.pythonhosted.org/packages/7d/47/5ecf1023850036e674c77ec4de86182d309ae344e39e7cba984b7df5d647/cffi-2.1.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:0beceaabe56af686895136a2de78db54ecd8e4046b236b8fd6d6cb61389e9bf2", size = 188259, upload-time = "2026-08-03T21:20:31.291Z" }, + { url = "https://files.pythonhosted.org/packages/2a/9c/92934c3bea9f785b23eba304538c0b4d37a2a96d2431eb3a1bc87a11aa19/cffi-2.1.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:49cbc70e6542d4ccccb936558d1064a8012541e78f821f955cff24e357776c94", size = 223864, upload-time = "2026-08-03T21:20:32.571Z" }, + { url = "https://files.pythonhosted.org/packages/4d/45/ba4c93527bc38616a8bd36488acb69a2212d60486794f0c1f318949bbb76/cffi-2.1.1-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e2d65b31f36619cda3999b78b2aa9632e76b78448e7a56fc4240824200e7c4fc", size = 211538, upload-time = "2026-08-03T21:20:33.808Z" }, + { url = "https://files.pythonhosted.org/packages/80/e9/b6ef565e452acb932fb0cb5443f44a78efbd1233e566f02b5a83855e9115/cffi-2.1.1-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:28907ab9bfb6aa13184cfc17c6b8e1023c5ab6fd7076d8c20a35e59fe04f8f29", size = 210688, upload-time = "2026-08-03T21:20:34.974Z" }, + { url = "https://files.pythonhosted.org/packages/9a/95/eff5f0cee78d2eabc7eebffec40d3fc1876b5f3c95582e018bb4b99601f2/cffi-2.1.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:51b31d1c98274844cfd7838ce00bfc27c7423a4dc00fc0772fc3331c2cc90676", size = 223803, upload-time = "2026-08-03T21:20:36.564Z" }, + { url = "https://files.pythonhosted.org/packages/fa/01/579d39fb8bef00a335a23d83757b44feb24cd6345a2c451b64cb67b9c362/cffi-2.1.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:5e7cecbaadb83884793e05828cee59b210b24583b9c7425d0ba6a754fe22eb4e", size = 226763, upload-time = "2026-08-03T21:20:37.816Z" }, + { url = "https://files.pythonhosted.org/packages/8d/b0/0b44f47c60b01b57b6e2bbd92343f13a85a1d93bc46ccf6e47e244acd99c/cffi-2.1.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:25792eac27877609e7bb06d42ff88278a6624fff2ba9bbb523c09616b117e80f", size = 225688, upload-time = "2026-08-03T21:20:38.959Z" }, + { url = "https://files.pythonhosted.org/packages/eb/d2/3b7176cb570a1d3e27faf67b72f591af508036e0d8b2be2ef9af9e8c84bb/cffi-2.1.1-cp314-cp314t-win32.whl", hash = "sha256:8ef53b2de9bcb9197d31854256575d59dbac0cba72ac627bb291ef5eceb74be4", size = 182868, upload-time = "2026-08-03T21:20:40.388Z" }, + { url = "https://files.pythonhosted.org/packages/56/78/31f00c1bcd97c9bbf55f1bfdf5bc809a5de8887473e90bb9960dca825e80/cffi-2.1.1-cp314-cp314t-win_amd64.whl", hash = "sha256:616f097f2fe415bc92a247f02e11f634e1f9e9a83d327e3c915c15089c87869e", size = 194104, upload-time = "2026-08-03T21:20:41.725Z" }, + { url = "https://files.pythonhosted.org/packages/7b/1b/58496f2ed0a35de575250c02a43ab3cc2c04d494a88fed31c1cabc0fd176/cffi-2.1.1-cp314-cp314t-win_arm64.whl", hash = "sha256:ad2c86c495b899d862ea0f4b42891b8713a3bd45dd4105c7fd51c2a72f39f3a5", size = 186402, upload-time = "2026-08-03T21:20:43.042Z" }, + { url = "https://files.pythonhosted.org/packages/c1/8f/9ebe220eab48a093d1a5a5e339ab0dc7316eef3bb04d63c42f0251b61f50/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphoneos.whl", hash = "sha256:dddad92b554513a31f272570678ba307fb9f618f05e3d4a5eacafff9eae03e1d", size = 194043, upload-time = "2026-08-03T21:20:48.179Z" }, + { url = "https://files.pythonhosted.org/packages/ff/69/844bad3ece306c4782c2ecb93597035b6690d48704b803914c199da1e8b3/cffi-2.1.1-cp315-cp315-ios_13_0_arm64_iphonesimulator.whl", hash = "sha256:da0e573f9f97159390c89d9f1a9e41908b66d408cc5b58d08cf3847d844c531b", size = 196737, upload-time = "2026-08-03T21:20:49.457Z" }, + { url = "https://files.pythonhosted.org/packages/1b/8a/af668013284634733f02d683458a0728739c7d6ddb5e14cb0c20832266fe/cffi-2.1.1-cp315-cp315-macosx_10_15_x86_64.whl", hash = "sha256:fb92203a88b3d3053034db775110081c49d28be6551923805e039924093761e4", size = 184933, upload-time = "2026-08-03T21:20:50.639Z" }, + { url = "https://files.pythonhosted.org/packages/0c/75/2f5207ff6d1a613133b23a5203cc0c2a628313b5eb3974d7956ae3c57950/cffi-2.1.1-cp315-cp315-macosx_11_0_arm64.whl", hash = "sha256:2ae64be792b8966f2c69538199728b290e34726562896df1e5dc8ffd8d8188e8", size = 185002, upload-time = "2026-08-03T21:20:52.173Z" }, + { url = "https://files.pythonhosted.org/packages/e2/31/9e1313b0a6e30e91b3b3d3fff51ae99c857c07738e3afcce1f7334e1b7ab/cffi-2.1.1-cp315-cp315-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:507a24c282e0f42f8ed737cf048572cbf580468da5555764a8331735e9c736b6", size = 222271, upload-time = "2026-08-03T21:20:53.462Z" }, + { url = "https://files.pythonhosted.org/packages/50/e3/f6234a833e6e08c7007003074723c406559eecf9b48dfc97471e5a8eb7a0/cffi-2.1.1-cp315-cp315-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:246fa40ce8645a614ff682e0b70f37134e460eaf93a775e0cbe3cca585a67a80", size = 209919, upload-time = "2026-08-03T21:20:54.783Z" }, + { url = "https://files.pythonhosted.org/packages/0d/fc/5f74e293fced6edb51af3a46c4ccf6c23c9943774ecb375ddbd522c76add/cffi-2.1.1-cp315-cp315-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:471cee653ae88de62096552e6d24ccb4a5adb8c8c9f10b5054d0122c15bf2779", size = 208529, upload-time = "2026-08-03T21:20:56.066Z" }, + { url = "https://files.pythonhosted.org/packages/44/16/29e6d01b388bef055ecd6ca8244b3f4d336bd09e92d5d892187b9601084e/cffi-2.1.1-cp315-cp315-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:aeae0e330c9f6acd681f647d46cefd30c29f93e3392882e792e82080c9691399", size = 221630, upload-time = "2026-08-03T21:20:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/a4/18/fa7f1f6857d5eb88a4ca99ffcbfb7c387a287ccc154c64a73e86314745d7/cffi-2.1.1-cp315-cp315-musllinux_1_2_aarch64.whl", hash = "sha256:42a494cee34437f05546455144f2b5d9ac09b1face62bcfce597d2e521066688", size = 225134, upload-time = "2026-08-03T21:20:58.675Z" }, + { url = "https://files.pythonhosted.org/packages/e0/9f/e8e3dfa04a1b4c241f8c91faacad872b4d4efd051d49764ad4e2fd4b9fea/cffi-2.1.1-cp315-cp315-musllinux_1_2_x86_64.whl", hash = "sha256:cc572dace3f60ef98d7b12ff411d20f5362feb31a0439eab0085bbfd349982d7", size = 223197, upload-time = "2026-08-03T21:20:59.968Z" }, + { url = "https://files.pythonhosted.org/packages/f8/7e/8debeb04f1ab9fe2a6963964cd6f1aaf7192627b83926586a6a4e089c9fa/cffi-2.1.1-cp315-cp315-win32.whl", hash = "sha256:4f42141fc14250de6dde5ee7ea4432be017252d91f19c5ad043c084cea629cac", size = 177683, upload-time = "2026-08-03T21:21:14.901Z" }, + { url = "https://files.pythonhosted.org/packages/e0/31/5158704cc474ab65c1647932e88be78dc0873f47130e253be38bcaf13d01/cffi-2.1.1-cp315-cp315-win_amd64.whl", hash = "sha256:e6e8cff14d6fb0be70a09c0bdc58096f501952d04624ebf867e0e56da2df8960", size = 187897, upload-time = "2026-08-03T21:21:16.108Z" }, + { url = "https://files.pythonhosted.org/packages/cc/4b/b3a2da8570c704ffc0f9762cdc3ec0f02c8573798e0b5cf7f11c82bbb70f/cffi-2.1.1-cp315-cp315-win_arm64.whl", hash = "sha256:27350daa11d4f10c540e6e89dada4c54feb7256ad03e9a4dc075ebad7ba360d1", size = 182935, upload-time = "2026-08-03T21:21:17.271Z" }, + { url = "https://files.pythonhosted.org/packages/d0/ef/5443574510a1207e6f6bc38ba6e1f1de36cb48fef07b2728bb896a21f430/cffi-2.1.1-cp315-cp315t-macosx_10_15_x86_64.whl", hash = "sha256:c26608d2222fb1e94487e4a387d85f13eb55d5ed725cb25a0c589ac4ee60e7bc", size = 188464, upload-time = "2026-08-03T21:21:01.163Z" }, + { url = "https://files.pythonhosted.org/packages/7e/ae/a56fa8c4686ad50e148fcbc8d3ae0d03915ff5c30d795058988c24118cef/cffi-2.1.1-cp315-cp315t-macosx_11_0_arm64.whl", hash = "sha256:4be96343e422f2dfcd12ab5c9f5aebe03f82f737c6bffeca6830b3875cb44aab", size = 188262, upload-time = "2026-08-03T21:21:02.382Z" }, + { url = "https://files.pythonhosted.org/packages/53/b2/6187f46f2912276a3ae284076109cc5c8680482f11f766ccf26db4a86427/cffi-2.1.1-cp315-cp315t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:937c0052c05a31ca1daf18de3158eed4dbfcb9cc107adbea227728d647be701e", size = 223779, upload-time = "2026-08-03T21:21:03.553Z" }, + { url = "https://files.pythonhosted.org/packages/8a/f6/c3ad28bd19f77047a03084424fbd4cbe997303267c14423737324be0385d/cffi-2.1.1-cp315-cp315t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:df423d40ee8654634421812bc3b196da3f9bd7d32929da813f8394c4348a5358", size = 211520, upload-time = "2026-08-03T21:21:04.863Z" }, + { url = "https://files.pythonhosted.org/packages/a0/cd/ccac9013a5bd9fd764de118674ab9c805b5ca10c19270d90ee273f8b2240/cffi-2.1.1-cp315-cp315t-manylinux2014_s390x.manylinux_2_17_s390x.whl", hash = "sha256:a730a083190634c65cca36ba5f489531576ebd79bcd5c8e172130f6453127231", size = 210673, upload-time = "2026-08-03T21:21:06.223Z" }, + { url = "https://files.pythonhosted.org/packages/52/86/2976131c639aead931c5bee5aba67e4b09fbeb8018b6f282f70803f923a7/cffi-2.1.1-cp315-cp315t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:363e05fa78e15116c3c32c210ee36884fd6b9afa6d440e47112c3bd511d64cb6", size = 223835, upload-time = "2026-08-03T21:21:07.539Z" }, + { url = "https://files.pythonhosted.org/packages/ac/0c/33a7aeab2f9c76918c52e084beb39c570db3588133412929e8ec06fab90b/cffi-2.1.1-cp315-cp315t-musllinux_1_2_aarch64.whl", hash = "sha256:770de9db11e84213beec501cfcaa013b019820ca881e03344dea5844f7876d94", size = 226705, upload-time = "2026-08-03T21:21:08.774Z" }, + { url = "https://files.pythonhosted.org/packages/e3/26/2cde30fdde421130bfc18f70395731a6e6b2053c6a1978a5258ff04e72fa/cffi-2.1.1-cp315-cp315t-musllinux_1_2_x86_64.whl", hash = "sha256:7da0c5eff80f0197f3b3d1232ec5a682a9325f4ae9016a78f5f5ca35f9ced1f5", size = 225539, upload-time = "2026-08-03T21:21:09.911Z" }, + { url = "https://files.pythonhosted.org/packages/6d/cd/a361394c94b2129d604bb846f624a8e88255a3ee33129c434a00d715e64f/cffi-2.1.1-cp315-cp315t-win32.whl", hash = "sha256:06c72bb76605a4b0cd0aad6930b69d4baf7dd5d806cfc409b824191099700e66", size = 182707, upload-time = "2026-08-03T21:21:11.226Z" }, + { url = "https://files.pythonhosted.org/packages/9b/b5/ba2b299993c26577d529b6ae29841f9e15b9fcf004d65f423f4fcf94ade9/cffi-2.1.1-cp315-cp315t-win_amd64.whl", hash = "sha256:d9c275eaacd24aa73f94ffd6de08fc3f932424d8b6c376f4bed7cde376fe7bc3", size = 193772, upload-time = "2026-08-03T21:21:12.39Z" }, + { url = "https://files.pythonhosted.org/packages/aa/29/35e016098c814cd93de9cd320c66b5bfba14dc6ecedd3cb518fa7c408c69/cffi-2.1.1-cp315-cp315t-win_arm64.whl", hash = "sha256:d18e5ac0f2f03f4f518d3e23db0f0cad7faa1da8620e9c09461d443bbf6e6692", size = 186360, upload-time = "2026-08-03T21:21:13.636Z" }, +] + [[package]] name = "click" version = "8.5.0" @@ -60,6 +158,62 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/d1/d6/3965ed04c63042e047cb6a3e6ed1a63a35087b6a609aa3a15ed8ac56c221/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", size = 25335, upload-time = "2022-10-25T02:36:20.889Z" }, ] +[[package]] +name = "cryptography" +version = "50.0.1" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "cffi", marker = "platform_python_implementation != 'PyPy'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/bb/ad/5d6702db60b1e40b41ef513b6967ff5848f307d50f8449baf1634f5908f1/cryptography-50.0.1.tar.gz", hash = "sha256:5dd9bda1c12b4162f6ff568eeb5e0ff956c28d14406e875cfe8a63a2d414ff20", size = 880381, upload-time = "2026-08-25T19:45:45.499Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/ba/19/797e2aaac9df6a66f1550f49979dc1b1e39ecd2077501c30efa81e8d5d67/cryptography-50.0.1-cp311-abi3-macosx_11_0_arm64.whl", hash = "sha256:b8f852c65863251b9e3a1b8c150ce21e59b522dbb6a7d4bc80e680d38388e986", size = 4010153, upload-time = "2026-08-25T19:44:03.155Z" }, + { url = "https://files.pythonhosted.org/packages/90/34/9ce9a62ed9dc82ca9fd6a34445b6904af56e5f38b3eae2ed32e49c36053d/cryptography-50.0.1-cp311-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:53e279950892dc102c6b4e52af03ae5ea92fac572a1ddab78ca73a997f62b69f", size = 4723133, upload-time = "2026-08-25T19:44:05.461Z" }, + { url = "https://files.pythonhosted.org/packages/57/26/e6d4fc8512a51a5f9ee7bfdbfb853bce1197087df40c9ad993ad370b846f/cryptography-50.0.1-cp311-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ff838d62ec1bfce4f9ba7fa16f4a7b554cd8d0c299e6be37502161a660c84eef", size = 4712478, upload-time = "2026-08-25T19:44:07.375Z" }, + { url = "https://files.pythonhosted.org/packages/e6/de/d3cdc2815697aae84126cbd6a030ca7b6b452e28a88b501b836bd3aa7a86/cryptography-50.0.1-cp311-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:e74591e283fe6eb956416c929eb58262a719fe0311fd9054c62c3350ed8760d8", size = 4730726, upload-time = "2026-08-25T19:44:09.294Z" }, + { url = "https://files.pythonhosted.org/packages/55/32/38c0d344b98c06d34b5df8946565a9c0d6dbf32c8e0730a7f05f0a3c6cab/cryptography-50.0.1-cp311-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:5fe002589592ed749ce77fe0695fcbd3500dd61d7d6db5858a7544c612fa8e45", size = 5353524, upload-time = "2026-08-25T19:44:11.96Z" }, + { url = "https://files.pythonhosted.org/packages/e1/1b/82f0f0d8858d4432be1af790477edf62aef90324041aa07c57e57bef1af7/cryptography-50.0.1-cp311-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:51593d180cf6d179bde5c5d065bed81386b1f381656ae7d042b7ffc87a9895ad", size = 4746720, upload-time = "2026-08-25T19:44:14.051Z" }, + { url = "https://files.pythonhosted.org/packages/29/ba/042ca458b8c64348c768284b5d23e69b92ed53d057ab779fee628564676d/cryptography-50.0.1-cp311-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:359e62deae718bce96170e223fdcb6357e4fbd3bb7a3a75f4430763532560e49", size = 4361866, upload-time = "2026-08-25T19:44:16.167Z" }, + { url = "https://files.pythonhosted.org/packages/39/3b/e96c1ef71edef71057c7e3c3d982ce8fda554e0c52d0cc19c18845cde3eb/cryptography-50.0.1-cp311-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:e2ca8fd1b6b4b82a1c4cb02841d0837e3c12336c2e24b520ab8ab3b969733d8f", size = 4730028, upload-time = "2026-08-25T19:44:18.085Z" }, + { url = "https://files.pythonhosted.org/packages/e3/38/45abd72ef63f2e7d0754a6cacf97bd8b69512ace7f6130d24c39ece65da2/cryptography-50.0.1-cp311-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:76de83fbd91ac49c0feaaa983d0748fd7a53176afac5fb3bf7478d244f0eb527", size = 5308405, upload-time = "2026-08-25T19:44:20.197Z" }, + { url = "https://files.pythonhosted.org/packages/85/66/6ccca4722987ddedaa7fc9c3f4708af7431f5535666c174350830888c6b7/cryptography-50.0.1-cp311-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:51afcfceb15597cf2635068e4ac9a56b2abde622edde17f37d85fd7b5306497a", size = 4746230, upload-time = "2026-08-25T19:44:22.376Z" }, + { url = "https://files.pythonhosted.org/packages/13/0e/b1f92e013228111413f2e6743948b80bc24dfd3c1b87ba98ceea16f5df89/cryptography-50.0.1-cp311-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:be224a65493ec5b74a158ff22a5522ce4a5ca1e543c647a3a4730d4a09e5f959", size = 4862596, upload-time = "2026-08-25T19:44:24.472Z" }, + { url = "https://files.pythonhosted.org/packages/7e/22/c3654cccc856e9d682817b04ac3ee79731cb09ca6f95996a95c904de2883/cryptography-50.0.1-cp311-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:9ebcdd5519be9b652a46f507817a74591774fc3d6923ac364e4dfa64e36b291b", size = 5014082, upload-time = "2026-08-25T19:44:26.709Z" }, + { url = "https://files.pythonhosted.org/packages/42/8b/cb12b1b60c91b074ca6bf0fdd59aa8f10d8bc5f73af8faece86ef0421b37/cryptography-50.0.1-cp311-abi3-win_amd64.whl", hash = "sha256:aed8db4f6d71c51efb89530e12d9464e7bf2923d46c3205dc794a2a93f8c0648", size = 3842826, upload-time = "2026-08-25T19:44:28.784Z" }, + { url = "https://files.pythonhosted.org/packages/5b/f0/424cb557d99aa86ac55da5e2add02e2882e44047b6264f93ade1b975a993/cryptography-50.0.1-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:30a125032e5642a21ff816e021152bd4e7e94f03eff3f4b7fca41cd22bc3110f", size = 3973525, upload-time = "2026-08-25T19:44:30.7Z" }, + { url = "https://files.pythonhosted.org/packages/4d/72/3a2711d967977ab5fc80b782837c7e8d1ac7445e764c20c381a265c57ef3/cryptography-50.0.1-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:a0b1a59e3a089064a0ec309e9428c8e3ae4e161419d20ac33600767e83fc658a", size = 4708817, upload-time = "2026-08-25T19:44:32.773Z" }, + { url = "https://files.pythonhosted.org/packages/b4/f2/bb1f56e10815b789df0b409a69fa4992ff3d3fef9c72747f4a6b26fed38e/cryptography-50.0.1-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8921d58f426793c5f1b47f0b59575780de9a095214958d0eb37d909593db8367", size = 4697300, upload-time = "2026-08-25T19:44:35.144Z" }, + { url = "https://files.pythonhosted.org/packages/08/bd/ed5396be499ffcf8807a585bfe38b71a1fbdd1c342b4f9b6d0ef5162a946/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:a8f40ea47330e71b594a7e246898f93177c259490c63183dbaf9e571d71ed9a5", size = 4716039, upload-time = "2026-08-25T19:44:37.192Z" }, + { url = "https://files.pythonhosted.org/packages/f6/6e/1cf405c5c8e8df7545378048e954792f00b7f2367af8863ce8b8f3e10607/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_ppc64le.whl", hash = "sha256:a255449073358275b64b67d3f595f268bbef70e72b6edb65e0c70c735bf739c9", size = 5332388, upload-time = "2026-08-25T19:44:39.16Z" }, + { url = "https://files.pythonhosted.org/packages/47/92/b4317e8c32c4f47b062f5398bd79106b220a124546f42be83bf32b761e2a/cryptography-50.0.1-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:8df2de9102026855887e4587084f6eabd80ed0f345b8ad8a7ac27ab9bf4723e0", size = 4730293, upload-time = "2026-08-25T19:44:41.298Z" }, + { url = "https://files.pythonhosted.org/packages/39/0d/a1e7633e2c744d0f2983320a27e924ef2264c79c56e1a58d5fb0a1cfd413/cryptography-50.0.1-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:ac02b07824d4d1001bd4367599f839c19cb171924c796e52c23508ac14c2c0cc", size = 4346031, upload-time = "2026-08-25T19:44:43.245Z" }, + { url = "https://files.pythonhosted.org/packages/88/dd/b215616f9bab3fc18510c78a4e5c9f362d77838503c363dc747c7d4f5c6f/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_aarch64.whl", hash = "sha256:cbf74a81765ee67413503ca6e26dcc4f6f5a519822436cc0a1b97aab6c1b8a17", size = 4715344, upload-time = "2026-08-25T19:44:45.291Z" }, + { url = "https://files.pythonhosted.org/packages/b1/1b/ec3ebd31741d0e963612c4fe43caa39341b9b1e031e469820e42e4c83918/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_ppc64le.whl", hash = "sha256:16c5ecd954b3330ebfb6605eca4fd952da8bef376551d5cc264534e3770a9ee6", size = 5287201, upload-time = "2026-08-25T19:44:47.297Z" }, + { url = "https://files.pythonhosted.org/packages/1a/01/0127d11a762b31a9ee0221894f540318761783f3fdc4bc5d057698caebd5/cryptography-50.0.1-cp314-cp314t-manylinux_2_34_x86_64.whl", hash = "sha256:79bf008d1f9af6071c797ad133e39915dfee7614f18f18f4db9072eb715064a3", size = 4730023, upload-time = "2026-08-25T19:44:49.435Z" }, + { url = "https://files.pythonhosted.org/packages/9e/b9/e7425ebfb599241a0c1d7000f1b466c3062da66c19d9525031315dff7213/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:330fbb252391c596f1ae42c5754449dc924e6ad012dca8efe0d703f9f2d12ec6", size = 4847362, upload-time = "2026-08-25T19:44:51.94Z" }, + { url = "https://files.pythonhosted.org/packages/2d/fd/60d0ddf4defa12e482c9d5e0f554384d6e8ab25341fd15f060028fd92e6a/cryptography-50.0.1-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:42be3bb70596b3abe4ac097b75be223e8b3ab614a0e5de068e3dcc54d71d6149", size = 4999247, upload-time = "2026-08-25T19:44:53.876Z" }, + { url = "https://files.pythonhosted.org/packages/4d/56/bc4f2b209e766c93372cfcd59b781a0b2b59700f62a969580415b699c2b2/cryptography-50.0.1-cp314-cp314t-win_amd64.whl", hash = "sha256:f74455bb086a85d5e81246412602aaa97ed095e504cd40dd261ef50be42205bf", size = 3825806, upload-time = "2026-08-25T19:44:56.209Z" }, + { url = "https://files.pythonhosted.org/packages/84/a9/ee16a903f13755e914d1eecc482fe64d1f10761c3960e5d8fa6837377aff/cryptography-50.0.1-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:ca83d00d9e69cd5eb63f2e69c3a5a59e0cecae5ae14c6ae0b35830fe3b37bad0", size = 4035307, upload-time = "2026-08-25T19:44:58.305Z" }, + { url = "https://files.pythonhosted.org/packages/5e/a5/9ec7e81e8526c0d7a387d73386b2daed3f39e10d81a85930bd1b6bfba65c/cryptography-50.0.1-cp39-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:05ba322c4da95b262a212c345af888ef2c37c88c0509756ea00a0e6d68850f23", size = 4751900, upload-time = "2026-08-25T19:45:00.401Z" }, + { url = "https://files.pythonhosted.org/packages/7e/3c/0e77bd5ffcf078e9dd27d3074aad6c030d9b10d0bf69329d573c927a188c/cryptography-50.0.1-cp39-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:e22dfed744bd4002e909464cb23d2f0b05c6f3113a79ef2e9864a53db737c733", size = 4738357, upload-time = "2026-08-25T19:45:02.786Z" }, + { url = "https://files.pythonhosted.org/packages/27/3a/3c5f80daa4dcd47323c7af8a2fcb90de27a33564d4fcac69846c0972691a/cryptography-50.0.1-cp39-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:4c4188f7c0cf655be5c06342b817ed0f9595b69ffa2b12026e5353eed29dea88", size = 4758474, upload-time = "2026-08-25T19:45:04.889Z" }, + { url = "https://files.pythonhosted.org/packages/6e/2b/214cf0cf93db9628c3c20c896b229f327f6fb1b20e4b3743d8ad3f00af8b/cryptography-50.0.1-cp39-abi3-manylinux_2_28_ppc64le.whl", hash = "sha256:2ebbfb0f1fed745e91796e3e1080a1440423fdae8ece1b995a1d80883a409054", size = 5375862, upload-time = "2026-08-25T19:45:07.163Z" }, + { url = "https://files.pythonhosted.org/packages/d6/51/3f9701867a46b6c1740c9b52fc4d3bed6cbdcfedcc9b6e64305c07f39cff/cryptography-50.0.1-cp39-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:407fe2b6db00939c05c0e945e9914238f2f0a430974839429dafc82b1ee6bee5", size = 4772942, upload-time = "2026-08-25T19:45:09.396Z" }, + { url = "https://files.pythonhosted.org/packages/0d/5c/13ea642e08e2544d0f5396122055f4820cfacb3203562197b5967125ea97/cryptography-50.0.1-cp39-abi3-manylinux_2_31_armv7l.whl", hash = "sha256:2b34d76a652ea2b6faf777c35df230c5637842cd904e04f16230c3f9f03e4361", size = 4383347, upload-time = "2026-08-25T19:45:11.659Z" }, + { url = "https://files.pythonhosted.org/packages/84/d5/7d1fe1cb93f91c428093ff234e128c89ba8ea61a6f26aab406081f9b996e/cryptography-50.0.1-cp39-abi3-manylinux_2_34_aarch64.whl", hash = "sha256:01f41478cf33fc605a6a089cd56d28b45c6c0b45a1928b61797f2621a04bac71", size = 4758050, upload-time = "2026-08-25T19:45:13.745Z" }, + { url = "https://files.pythonhosted.org/packages/dd/04/557fc5ead96a829e0bc812a3b9dc4a52a2f27e4f7f5950da7ff27653a805/cryptography-50.0.1-cp39-abi3-manylinux_2_34_ppc64le.whl", hash = "sha256:fc3ed7ebd2a8c96f5b166de0ab9b624996bef3b07bbeb19364dfb78222c22c80", size = 5332955, upload-time = "2026-08-25T19:45:16.193Z" }, + { url = "https://files.pythonhosted.org/packages/8c/eb/5d7124083e8d8cda8f5b348f544b71ad6f707ad63193758ef4d8e569da02/cryptography-50.0.1-cp39-abi3-manylinux_2_34_x86_64.whl", hash = "sha256:9dde0a357190eb3b1da1bb9ab750e9c85cba82ca5977aa0836cbb94e92611239", size = 4772694, upload-time = "2026-08-25T19:45:18.315Z" }, + { url = "https://files.pythonhosted.org/packages/63/8e/f1f955e0921dd2b6d22eae7e8d24a4c4b638d10735ffbf6a71f99eb0fcb8/cryptography-50.0.1-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:fd3718b960d0b5dd213cdf03f3bcb7000e69dda0de8b956061947ff6bcff5558", size = 4888413, upload-time = "2026-08-25T19:45:20.4Z" }, + { url = "https://files.pythonhosted.org/packages/1f/ab/89e2b798d2c3925f82e2bb72d5979f3d2f6da2dd22ef4a8cd8b70d920039/cryptography-50.0.1-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:2a93d05e34d5f67fba6f891fe85d929999baa7195e853923ea6d7576c9e68c5e", size = 5044355, upload-time = "2026-08-25T19:45:22.353Z" }, + { url = "https://files.pythonhosted.org/packages/99/89/87ef49ffe383ef4e147d27b7bf2088fb0b54ea409dd87b5a89442e5828a5/cryptography-50.0.1-cp39-abi3-win_amd64.whl", hash = "sha256:55d16b1ef3ee0958d893a977b19777887e546c9954ea81b200c3301a864013f2", size = 3875429, upload-time = "2026-08-25T19:45:24.418Z" }, + { url = "https://files.pythonhosted.org/packages/c7/27/8d207af749c453ee17ea087340b3f2b4adef75aadd1d277b1b129bdda84e/cryptography-50.0.1-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9cb3cb952cf5a8abd50c782a98a89d71699715e802fe349704b47f2425b42a94", size = 3974350, upload-time = "2026-08-25T19:45:26.551Z" }, + { url = "https://files.pythonhosted.org/packages/14/9a/6d3a4d7852e22d657438b7bf51f66102c7d71c0e1fafeec652281d0403e5/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_aarch64.whl", hash = "sha256:5fe939deeb161024a6be98229c953b6591fef1f41214497a78fe793a244c017f", size = 4698675, upload-time = "2026-08-25T19:45:28.658Z" }, + { url = "https://files.pythonhosted.org/packages/73/35/5c3717edf9e68a0550ce04e28eab493fe545eccd81742af03f6a75fe260b/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_28_x86_64.whl", hash = "sha256:fb4b9672d389c738b175c4166e78310f8a70358886aacd9173ee03a85ffdc671", size = 4707410, upload-time = "2026-08-25T19:45:30.816Z" }, + { url = "https://files.pythonhosted.org/packages/1d/e0/e786934472e3ac4ecdecc7b129a0ca1a2a40dffdafcf2c3ea9d4397f8def/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_aarch64.whl", hash = "sha256:d63ae8f6481fec907ac0f588eee8a90aefde112c633131fe540e5711ddbb5a4e", size = 4698378, upload-time = "2026-08-25T19:45:33.043Z" }, + { url = "https://files.pythonhosted.org/packages/51/cf/5b3f53a0b74d122f023476ede40ba5d3e70d5cf475f73b899740d26a4fb2/cryptography-50.0.1-pp311-pypy311_pp73-manylinux_2_34_x86_64.whl", hash = "sha256:804728ce710890870f3aaa344b2e161172d258d768ac139d02cfd9092d0d94e6", size = 4706889, upload-time = "2026-08-25T19:45:35.086Z" }, + { url = "https://files.pythonhosted.org/packages/71/44/711e61f7d014be825ef79b285b047292d1bf893732ac1bc030a351fb517f/cryptography-50.0.1-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:693c99b49bd37d0d096e4334c10232c77248c415b98d35236094cdf96d57258b", size = 3824006, upload-time = "2026-08-25T19:45:37.281Z" }, +] + [[package]] name = "fastapi" version = "0.141.1" @@ -227,6 +381,7 @@ dev = [ { name = "fastapi" }, { name = "httpx" }, { name = "jinja2" }, + { name = "pyjwt", extra = ["crypto"] }, { name = "pytest" }, { name = "pytest-asyncio" }, { name = "pyyaml" }, @@ -241,6 +396,7 @@ dev = [ { name = "fastapi", specifier = ">=0.115" }, { name = "httpx", specifier = ">=0.28" }, { name = "jinja2", specifier = ">=3" }, + { name = "pyjwt", extras = ["crypto"], specifier = ">=2.9" }, { name = "pytest", specifier = ">=8.0.0" }, { name = "pytest-asyncio", specifier = ">=0.24" }, { name = "pyyaml", specifier = ">=6" }, @@ -266,6 +422,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/54/20/4d324d65cc6d9205fabedc306948156824eb9f0ee1633355a8f7ec5c66bf/pluggy-1.6.0-py3-none-any.whl", hash = "sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746", size = 20538, upload-time = "2025-05-15T12:30:06.134Z" }, ] +[[package]] +name = "pycparser" +version = "3.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/7d/92392ff7815c21062bea51aa7b87d45576f649f16458d78b7cf94b9ab2e6/pycparser-3.0.tar.gz", hash = "sha256:600f49d217304a5902ac3c37e1281c9fe94e4d0489de643a9504c5cdfdfc6b29", size = 103492, upload-time = "2026-01-21T14:26:51.89Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/0c/c3/44f3fbbfa403ea2a7c779186dc20772604442dde72947e7d01069cbe98e3/pycparser-3.0-py3-none-any.whl", hash = "sha256:b727414169a36b7d524c1c3e31839a521725078d7b2ff038656844266160a992", size = 48172, upload-time = "2026-01-21T14:26:50.693Z" }, +] + [[package]] name = "pydantic" version = "2.13.5" @@ -392,6 +557,20 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/71/46/17f022dd3e953bf20a04a028a21ec746d942f8d2af30fa0f124fa0e6a684/pygments-2.21.0-py3-none-any.whl", hash = "sha256:2363c69b61c4a97c838da3b130dcd6468f4848992b21a82f2a63ec34377137d9", size = 1250147, upload-time = "2026-08-17T08:02:44.912Z" }, ] +[[package]] +name = "pyjwt" +version = "2.14.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/af/c3/8a3b59c25070cc61dc517fbdfa5dc0904670c96f605cc69759dc09166b99/pyjwt-2.14.0.tar.gz", hash = "sha256:77283c83fb56ecf566a886c757a714bc83668e38156de2cce8263302f42e0b86", size = 113177, upload-time = "2026-09-11T13:11:54.638Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/9c/97/672cb32ce0dfea44b740cb7b4f97038463b9cf7c0ead1aacf595572851d6/pyjwt-2.14.0-py3-none-any.whl", hash = "sha256:ad0cef71c756a56e74863c2919cf0985f72decbcfcb550ee2f422e7c62b5eedc", size = 32896, upload-time = "2026-09-11T13:11:53.409Z" }, +] + +[package.optional-dependencies] +crypto = [ + { name = "cryptography" }, +] + [[package]] name = "pytest" version = "9.1.1" -- 2.55.0 From a30b8527890d37c076ae980495f7c5a1ca0ef59e Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 00:59:39 -0400 Subject: [PATCH 08/15] Admin auth: nestquest-admins group gate and service-token rejection (#157) --- api/auth.py | 100 ++++++++++++++++++++++++--- api/routes_admin.py | 34 ++++----- tests/test_api_admin_auth.py | 129 ++++++++++++++++++++++++++++++++++- 3 files changed, 237 insertions(+), 26 deletions(-) diff --git a/api/auth.py b/api/auth.py index 8111ebb..c4b0bc1 100644 --- a/api/auth.py +++ b/api/auth.py @@ -35,16 +35,20 @@ the real cause is visible in the server log instead of disguised as a bad credential; a short backoff window after a failed fetch fails fast (re-raising the recorded fault) rather than fetching once per request. -This module validates the TOKEN only — it is authentication, not -authorization. Membership in the ``nestquest-admins`` group is the -NEXT task, which extends :func:`require_admin_jwt` (the verified -claims are already its return value, so no rewrite is needed). +Beyond authentication, :func:`require_admin` is the ONE dependency +every admin route uses: it REJECTS the panel service token outright — +that credential authenticates the panel plane only, so treating it as +a JWT candidate would blur two credential types into one check — and +then requires the verified JWT's ``groups`` claim to name the +``nestquest-admins`` group (authorization, 403, distinct from the +uniform authentication 401 so the two failure classes stay apart). """ from __future__ import annotations import asyncio import binascii import json +import secrets import time from collections.abc import Awaitable, Callable from typing import Annotated @@ -70,6 +74,11 @@ _REJECTION_DETAIL = "Missing or invalid admin credentials" #: Challenge header returned with a 401, per the Bearer auth scheme. _WWW_AUTHENTICATE = {"WWW-Authenticate": "Bearer"} +#: The authorization group a verified admin JWT must name in its +#: ``groups`` claim. A module constant (the value Authentik's +#: nestquest-admins group maps to) so it is clearly named in one place. +ADMIN_GROUP = "nestquest-admins" + #: Seconds a fetched JWKS document stays trusted before the next #: fetch. Five minutes bounds how long a rotated-away key keeps #: verifying while staying far above a per-request fetch. @@ -266,6 +275,19 @@ class JwksCache: return document +#: 403 detail for the panel service token on an admin route. Deliberately +#: non-leaking: it names the credential TYPE, not its value or where it came +#: from. 403 (not 401) because this is a REFUSED credential type, not an +#: absent/invalid one — challenging re-authentication would suggest the same +#: credential could work with a retry, which it never can here. +_PANEL_TOKEN_REJECTION_DETAIL = "This credential type is not accepted for admin access" + +#: 403 detail for a valid JWT that is not a member of the admin group. +#: Distinct from the uniform 401 on purpose: the token AUTHENTICATED, but +#: the caller is not AUTHORIZED, so the response may say so. +_FORBIDDEN_DETAIL = "Admin access requires membership in the nestquest-admins group" + + def _reject() -> HTTPException: """Build the single 401 the dependency raises for every failure.""" return HTTPException( @@ -391,9 +413,9 @@ async def require_admin_jwt( :func:`verify_admin_token`, returning the verified claims for the route to use (the probe route surfaces the token's ``sub``). - The nestquest-admins GROUP check is the NEXT task: it extends this - dependency (e.g. by inspecting the returned claims) so every admin - route inherits it — the 401 contract stays exactly this uniform. + The nestquest-admins GROUP check lives in :func:`require_admin`, + which wraps this dependency; ``require_admin_jwt`` remains the plain + JWT-validation contract — the 401 path stays exactly this uniform. """ if authorization is None: raise _reject() @@ -403,4 +425,66 @@ async def require_admin_jwt( return await verify_admin_token(request, credential.strip()) -__all__ = ["JwksCache", "JwksFetcher", "require_admin_jwt", "verify_admin_token"] +async def require_admin( + request: Request, + authorization: Annotated[str | None, Header()] = None, +) -> dict[str, object]: + """The ONE admin-plane dependency: service-token refusal + group gate. + + Order matters. FIRST the Bearer credential is compared against the + configured panel service token (``app.state.config.panel_token``): + a match is refused OUTRIGHT with 403 — BEFORE any JWT parsing — so + the panel credential is never even treated as a JWT candidate, let + alone a non-admin JWT. (403 rather than 401 because this is a + refused credential TYPE, not an invalid one: challenging + re-authentication would imply retrying could ever succeed; the + detail names the type only, never the value.) A config with an + EMPTY panel token never matches anything, so it cannot lock every + Bearer request out. + + Otherwise the credential goes through the existing JWT path + (:func:`require_admin_jwt` → :func:`verify_admin_token`): a missing + or malformed header and any invalid JWT still yield the SAME + uniform 401 as before. A VALID JWT is then checked for + authorization: its ``groups`` claim must be a list containing + :data:`ADMIN_GROUP` — a missing, empty, or non-list ``groups`` or + one without the group raises 403 (distinct from the 401 on + purpose: the token authenticated, the caller did not authorize). + + Returns the verified claims for the route. + """ + if authorization is not None: + scheme, _, credential = authorization.partition(" ") + if scheme.lower() == _BEARER_SCHEME and credential.strip(): + config = getattr(request.app.state, "config", None) + if ( + config is not None + and config.panel_token + # compare_digest on bytes (as require_panel_token does) + # so the refusal does not short-circuit on the first + # mismatching byte — a constant-time comparison keeps a + # timing side channel on the credential off the table. + and secrets.compare_digest( + credential.strip().encode("utf-8"), + config.panel_token.encode("utf-8"), + ) + ): + raise HTTPException( + status_code=403, + detail=_PANEL_TOKEN_REJECTION_DETAIL, + ) + claims = await require_admin_jwt(request, authorization) + groups = claims.get("groups") + if not isinstance(groups, list) or ADMIN_GROUP not in groups: + raise HTTPException(status_code=403, detail=_FORBIDDEN_DETAIL) + return claims + + +__all__ = [ + "ADMIN_GROUP", + "JwksCache", + "JwksFetcher", + "require_admin", + "require_admin_jwt", + "verify_admin_token", +] diff --git a/api/routes_admin.py b/api/routes_admin.py index 7a2e2a2..b7164c7 100644 --- a/api/routes_admin.py +++ b/api/routes_admin.py @@ -2,17 +2,17 @@ The admin plane (``/api/v1/admin/*``, D-012) serves the admin PWA. Every route on :data:`router` requires a valid Authentik OIDC JWT -through the ONE reusable dependency :func:`api.auth.require_admin_jwt` -(declared once at the router level, so each later admin route added to -this router inherits the check). +whose ``groups`` claim names ``nestquest-admins``, through the ONE +reusable dependency :func:`api.auth.require_admin` (declared once at +the router level, so each later admin route added to this router +inherits the check). The panel service token is refused outright on +this plane (403), never treated as a JWT. This task is the AUTH probe only: the ping route is a thin adapter that performs NO business logic and NO auth of its own — it reuses the router's already-verified claims (FastAPI caches a dependency's result per request, so the check runs exactly once) and returns the status -plus the token's subject. Authorization — membership in the -``nestquest-admins`` group — is the NEXT task, layered onto -:func:`api.auth.require_admin_jwt` so every admin route inherits it. +plus the token's subject. """ from __future__ import annotations @@ -21,13 +21,15 @@ from typing import Annotated from fastapi import APIRouter, Depends from pydantic import BaseModel -from api.auth import require_admin_jwt +from api.auth import require_admin -#: All admin-plane routes share this router; the JWT check is attached -#: HERE so every admin route (current and later) requires it. +#: All admin-plane routes share this router; the ONE admin dependency +#: (service-token refusal + JWT verification + nestquest-admins group +#: gate) is attached HERE so every admin route (current and later) +#: requires it. router = APIRouter( prefix="/api/v1/admin", - dependencies=[Depends(require_admin_jwt)], + dependencies=[Depends(require_admin)], ) @@ -42,14 +44,14 @@ class AdminPingResponse(BaseModel): @router.get("/ping", summary="Admin plane probe") async def admin_ping( - claims: Annotated[dict[str, object], Depends(require_admin_jwt)], + claims: Annotated[dict[str, object], Depends(require_admin)], ) -> AdminPingResponse: - """Return 200 for a verified admin JWT (the plane's smoke probe). + """Return 200 for a verified JWT naming the nestquest-admins group. - Requires a valid Authentik JWT (checked by the router's shared - :func:`~api.auth.require_admin_jwt` dependency — the ONE token - check; this handler performs NO auth of its own). ``claims`` is - the SAME verified-claims dict that dependency produced (FastAPI's + Requires a valid Authentik JWT with the admin group (checked by the + router's shared :func:`~api.auth.require_admin` dependency — the + ONE check; this handler performs NO auth of its own). ``claims`` + is the SAME verified-claims dict that dependency produced (FastAPI's per-request dependency cache), so the token is verified exactly once per request. """ diff --git a/tests/test_api_admin_auth.py b/tests/test_api_admin_auth.py index ff161f5..e1a19ad 100644 --- a/tests/test_api_admin_auth.py +++ b/tests/test_api_admin_auth.py @@ -49,7 +49,7 @@ import pytest from cryptography.hazmat.primitives.asymmetric import rsa from api.app import create_app -from api.auth import JwksCache +from api.auth import ADMIN_GROUP, JwksCache from api.config import ApiConfig, ConfigError #: The configured OIDC settings the test apps run with (values a real @@ -67,6 +67,16 @@ SUBJECT = "admin-user-1" #: The uniform 401 detail every rejection must carry. REJECTION_DETAIL = "Missing or invalid admin credentials" +#: The 403 detail for the panel service token on an admin route. +PANEL_TOKEN_REJECTION_DETAIL = ( + "This credential type is not accepted for admin access" +) + +#: The 403 detail for a valid JWT without the admin group. +FORBIDDEN_DETAIL = ( + "Admin access requires membership in the nestquest-admins group" +) + def _generate_key() -> rsa.RSAPrivateKey: """Generate one local RSA key pair (no network, no fixtures).""" @@ -163,10 +173,20 @@ def _make_token( sub: str = SUBJECT, expires_in: int | None = 300, nbf_in: int | None = -1, + groups: object = (ADMIN_GROUP,), ) -> str: - """Mint one RS256 JWT the way Authentik would (its claims, our key).""" + """Mint one RS256 JWT the way Authentik would (its claims, our key). + + ``groups`` defaults to the admin group (what a real admin user's + token carries); tests pass a different value — or ``None`` for NO + groups claim at all — to prove the group gate rejects it. + """ now = int(time.time()) claims: dict[str, object] = {"iss": iss, "aud": aud, "sub": sub, "iat": now} + if groups is not None: + claims["groups"] = ( + list(groups) if isinstance(groups, (list, tuple)) else groups + ) if nbf_in is not None: claims["nbf"] = now + nbf_in if expires_in is not None: @@ -789,3 +809,108 @@ def test_from_env_requires_oidc_setting(env_var: str) -> None: env[env_var] = " " with pytest.raises(ConfigError): ApiConfig.from_env(env) + + +# --- the nestquest-admins group gate (task 0f2afb29) ------------------------- + + +async def test_admin_jwt_with_group_returns_200( + admin_client: httpx.AsyncClient, +) -> None: + """A valid JWT whose groups claim names nestquest-admins returns 200.""" + response = await admin_client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {_make_token()}"}, + ) + assert response.status_code == 200 + assert response.json() == {"status": "ok", "sub": SUBJECT} + + +async def test_non_admin_jwt_is_forbidden( + admin_client: httpx.AsyncClient, +) -> None: + """A valid JWT WITHOUT nestquest-admins in groups returns 403.""" + token = _make_token(groups=("some-other-group",)) + response = await admin_client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 403 + assert response.json()["detail"] == FORBIDDEN_DETAIL + + +async def test_empty_groups_is_forbidden( + admin_client: httpx.AsyncClient, +) -> None: + """A valid JWT with an EMPTY groups list returns 403.""" + token = _make_token(groups=()) + response = await admin_client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 403 + + +@pytest.mark.parametrize("groups", ["nestquest-admins", None]) +async def test_malformed_groups_is_forbidden( + admin_client: httpx.AsyncClient, groups: object +) -> None: + """A valid JWT with a non-list (or missing) groups claim returns 403. + + Even a plain STRING naming the group is not enough — the claim must + be a list, so a token crafted with a scalar ``groups`` cannot pass. + """ + token = _make_token(groups=groups) + response = await admin_client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {token}"}, + ) + assert response.status_code == 403 + assert response.json()["detail"] == FORBIDDEN_DETAIL + + +async def test_panel_service_token_is_refused_outright( + temp_db_path: str, +) -> None: + """The panel service token on an admin route is 403, never a JWT. + + 403 (not the uniform 401) proves the credential was refused as a + TYPE, not run through JWT verification — and the JWKS fetcher is + never called, proving it was not treated as a token to verify. + """ + runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID)) + async with runner as client: + response = await client.get( + "/api/v1/admin/ping", + headers={"Authorization": "Bearer admin-test-panel-token"}, + ) + assert response.status_code == 403 + assert response.json()["detail"] == PANEL_TOKEN_REJECTION_DETAIL + assert runner.fetch.calls == 0 + + +async def test_admin_router_is_wired_through_require_admin( + temp_db_path: str, +) -> None: + """The admin router's dependency IS the one require_admin dependency. + + The router-level dependency list carries + :func:`api.auth.require_admin`, so every admin route (current and + later) inherits the service-token refusal and the group gate. + """ + from api.auth import require_admin + from api.routes_admin import router as admin_router + + dependencies = [d.dependency for d in admin_router.dependencies] + assert require_admin in dependencies + + # Behavioral proof: the probe returns 200 for an admin JWT through + # the same dependency. + runner = _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID)) + async with runner as client: + response = await client.get( + "/api/v1/admin/ping", + headers={"Authorization": f"Bearer {_make_token()}"}, + ) + assert response.status_code == 200 + -- 2.55.0 From 0b7f71831b05d76b3790b34ce436fdf17f1629f8 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 01:23:57 -0400 Subject: [PATCH 09/15] Admin routes: children (#158) --- api/nestquest_core.py | 5 + api/routes_admin.py | 293 ++++++++++++++++++- tests/admin_jwt_harness.py | 189 +++++++++++++ tests/conftest.py | 26 +- tests/test_api_admin_auth.py | 190 ++----------- tests/test_api_admin_children.py | 464 +++++++++++++++++++++++++++++++ 6 files changed, 986 insertions(+), 181 deletions(-) create mode 100644 tests/admin_jwt_harness.py create mode 100644 tests/test_api_admin_children.py diff --git a/api/nestquest_core.py b/api/nestquest_core.py index 6257dab..45e795a 100644 --- a/api/nestquest_core.py +++ b/api/nestquest_core.py @@ -124,8 +124,13 @@ 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 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") + __all__ = [ "core", + "core_children", "core_completion", "core_db", "core_events", diff --git a/api/routes_admin.py b/api/routes_admin.py index b7164c7..ff88551 100644 --- a/api/routes_admin.py +++ b/api/routes_admin.py @@ -8,20 +8,48 @@ the router level, so each later admin route added to this router inherits the check). The panel service token is refused outright on this plane (403), never treated as a JWT. -This task is the AUTH probe only: the ping route is a thin adapter -that performs NO business logic and NO auth of its own — it reuses the -router's already-verified claims (FastAPI caches a dependency's result -per request, so the check runs exactly once) and returns the status -plus the token's subject. +The children routes are THIN adapters over the bundled core (Feature +15): each handler validates the request's shape with a Pydantic model, +resolves the live database off ``app.state.db``, calls ONE +:mod:`nestquest_core.children` business function — the name policy, +the no-explicit-NULL rule, the real-bool check and the permutation +check all live THERE, never in a handler — and serializes the returned +:class:`~nestquest_core.dao_children.ChildRecord` into the documented +payload (``id``, ``display_name``, ``colour``, ``avatar_ref``, +``sort_order``, ``is_active``). + +Error mapping (every children route, deliberately narrow): + +- Malformed input the body model or the path parser rejects (a missing + or wrong-typed field, a non-integer child id) is FastAPI's 422 before + any handler code runs. +- A core ``ValueError`` reporting a NON-EXISTENT child (edit or + set_active on an unknown id) is mapped to 404. +- Every OTHER core ``ValueError`` — an empty display name, an explicit + ``null`` colour/avatar_ref (clearing is not supported), a reorder + list that is not a complete permutation of the children (missing, + duplicate, or unknown ids) — is mapped to 422: the body was + well-formed JSON but semantically invalid, the same failure class + Pydantic reports. The core rejects these BEFORE any write, so a 422 + never leaves a half-applied change behind. + +PATCH edit semantics: the body model's fields are optional and only +SUPPLIED fields are forwarded (``model_dump(exclude_unset=True)``), so +an omitted field is never passed to +:func:`nestquest_core.children.edit_child` and stays unchanged — while +an explicit ``null`` IS passed and is rejected by the core rather than +silently ignored. """ from __future__ import annotations -from typing import Annotated +from typing import Annotated, NoReturn -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, Request from pydantic import BaseModel from api.auth import require_admin +from api.database import DatabaseState +from api.nestquest_core import core_children #: All admin-plane routes share this router; the ONE admin dependency #: (service-token refusal + JWT verification + nestquest-admins group @@ -42,6 +70,108 @@ class AdminPingResponse(BaseModel): sub: str | None +class AdminChildResponse(BaseModel): + """One child profile as the admin plane serializes it. + + Mirrors :class:`~nestquest_core.dao_children.ChildRecord` minus the + internal ``created_at`` stamp. + """ + + id: int + display_name: str + colour: str | None + avatar_ref: str | None + sort_order: int + is_active: bool + + +class AdminChildListResponse(BaseModel): + """The children list: every profile in the household's display order.""" + + children: list[AdminChildResponse] + + +class AdminChildCreateRequest(BaseModel): + """The body of ``POST /api/v1/admin/children``. + + ``display_name`` is required (the core trims it and rejects an + empty name); the other fields are optional and default through the + core layer (``colour``/``avatar_ref`` NULL, ``sort_order`` 0). + """ + + display_name: str + colour: str | None = None + avatar_ref: str | None = None + sort_order: int = 0 + + +class AdminChildEditRequest(BaseModel): + """The body of ``PATCH /api/v1/admin/children/{child_id}``. + + Every field is optional; ONLY the fields the request supplies are + forwarded to :func:`nestquest_core.children.edit_child` (the route + dumps the model with ``exclude_unset``), so an omitted field stays + unchanged while an explicit ``null`` — a supplied value — reaches + the core and is rejected there rather than silently ignored. + """ + + display_name: str | None = None + colour: str | None = None + avatar_ref: str | None = None + sort_order: int | None = None + + +class AdminChildActiveRequest(BaseModel): + """The body of ``PATCH /api/v1/admin/children/{child_id}/active``.""" + + is_active: bool + + +class AdminChildReorderRequest(BaseModel): + """The body of ``POST /api/v1/admin/children/reorder``. + + ``ordered_ids`` must be a complete permutation of the household's + child ids (the core rejects a partial, duplicate, or unknown list + before any write). + """ + + ordered_ids: list[int] + + +#: 404 detail for a child id the core layer reports as non-existent. +_CHILD_NOT_FOUND_DETAIL = "Child not found" + + +def _raise_child_error(error: ValueError) -> NoReturn: + """Map a core children ``ValueError`` onto 404 or 422 and raise it. + + The core layer raises ``ValueError`` for two distinct situations, + and this mapping is the children routes' one piece of adapter + logic: a non-existent child on a single-id route (edit, set_active + — the message reads ``child N does not exist``) becomes 404, the + admin plane's not-found answer; every other core ``ValueError`` + becomes 422 with the core's message as the detail — it names the + field and what to fix, and leaks nothing but that validation rule. + """ + if "does not exist" in str(error): + raise HTTPException( + status_code=404, detail=_CHILD_NOT_FOUND_DETAIL + ) from error + raise HTTPException(status_code=422, detail=str(error)) from error + + +def _child_response(record: core_children.ChildRecord) -> AdminChildResponse: + """Serialize one core ChildRecord into the documented payload.""" + return AdminChildResponse( + id=record.id, + display_name=record.display_name, + colour=record.colour, + avatar_ref=record.avatar_ref, + sort_order=record.sort_order, + is_active=record.is_active, + ) + + @router.get("/ping", summary="Admin plane probe") async def admin_ping( claims: Annotated[dict[str, object], Depends(require_admin)], @@ -62,4 +192,153 @@ async def admin_ping( ) +@router.get( + "/children", + summary="List child profiles", + response_model=AdminChildListResponse, +) +async def admin_list_children(request: Request) -> AdminChildListResponse: + """Return every child profile in display order. + + 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.children.list_children` call, active AND + inactive children included (the admin screen manages both), in the + core's display order (``sort_order``, then ``id``). + """ + state: DatabaseState = request.app.state.db + records = await core_children.list_children(state.database) + return AdminChildListResponse( + children=[_child_response(record) for record in records] + ) + + +@router.post( + "/children", + summary="Create a child profile", + status_code=201, + response_model=AdminChildResponse, +) +async def admin_create_child( + body: AdminChildCreateRequest, request: Request +) -> AdminChildResponse: + """Create a child profile and return it as stored. + + 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 model + carries the shape, and the name policy (trim, reject empty), the + duplicate-name warning and the created_at stamp all live in + :func:`nestquest_core.children.create_child`. Errors are mapped by + :func:`_raise_child_error` — create names no existing child, so a + rejection here is always 422. + """ + state: DatabaseState = request.app.state.db + try: + record = await core_children.create_child( + state.database, + body.display_name, + colour=body.colour, + avatar_ref=body.avatar_ref, + sort_order=body.sort_order, + ) + except ValueError as error: + _raise_child_error(error) + return _child_response(record) + + +@router.patch( + "/children/{child_id}", + summary="Edit a child profile", + response_model=AdminChildResponse, +) +async def admin_edit_child( + child_id: int, body: AdminChildEditRequest, request: Request +) -> AdminChildResponse: + """Edit a child profile; only supplied fields change. + + 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 supplied + fields are forwarded with ``exclude_unset`` semantics so an omitted + field is never passed to :func:`nestquest_core.children.edit_child` + and stays unchanged, while an explicit ``null`` is passed and + rejected by the core (clearing is not supported). An unknown child + id is mapped to 404 and any other core ``ValueError`` to 422 + (:func:`_raise_child_error`). + """ + state: DatabaseState = request.app.state.db + try: + record = await core_children.edit_child( + state.database, + child_id, + **body.model_dump(exclude_unset=True), + ) + except ValueError as error: + _raise_child_error(error) + return _child_response(record) + + +@router.patch( + "/children/{child_id}/active", + summary="Set a child's active flag", + response_model=AdminChildResponse, +) +async def admin_set_child_active( + child_id: int, body: AdminChildActiveRequest, request: Request +) -> AdminChildResponse: + """Deactivate (or reactivate) a child; the profile is NEVER deleted. + + 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: ``is_active`` + is passed straight to + :func:`nestquest_core.children.set_child_active` — deactivation is + the only removal path (the core has no delete, so completion + history keeps its references). An unknown child id is mapped to + 404 (:func:`_raise_child_error`); the body model already makes the + core's real-bool check unreachable from this route. + """ + state: DatabaseState = request.app.state.db + try: + record = await core_children.set_child_active( + state.database, child_id, body.is_active + ) + except ValueError as error: + _raise_child_error(error) + return _child_response(record) + + +@router.post( + "/children/reorder", + summary="Reorder the children's panel display order", +) +async def admin_reorder_children( + body: AdminChildReorderRequest, request: Request +) -> dict[str, str]: + """Rewrite the children's display order in one atomic core call. + + 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 posted id + order goes straight to + :func:`nestquest_core.children.reorder_children`, which rejects a + list that is not a complete permutation of the household (missing, + duplicate, or unknown ids) BEFORE any write — mapped to 422 by + :func:`_raise_child_error`, leaving the stored order untouched. On + success the core returns ``None``; the route answers + ``{"status": "ok"}`` (the fresh order is read back via + ``GET /children``). + """ + state: DatabaseState = request.app.state.db + try: + await core_children.reorder_children( + state.database, body.ordered_ids + ) + except ValueError as error: + _raise_child_error(error) + return {"status": "ok"} + + __all__ = ["router"] diff --git a/tests/admin_jwt_harness.py b/tests/admin_jwt_harness.py new file mode 100644 index 0000000..7c19467 --- /dev/null +++ b/tests/admin_jwt_harness.py @@ -0,0 +1,189 @@ +"""Shared admin-plane JWT test harness: local keys, stubbed JWKS. + +The admin-plane API test modules share ONE harness instead of +duplicating it: the auth contract tests (:mod:`tests.test_api_admin_auth`) +and the children route tests (:mod:`tests.test_api_admin_children`) both +drive the ASGI app with httpx using a locally generated RSA key pair and +a stubbed JWKS fetcher — NO network access: + +- ``ADMIN_KEY`` signs the test tokens; :func:`jwks_for` builds the JWKS + document publishing its public half, so genuinely signed tokens + verify, and tokens signed with the second pair (``OTHER_KEY``) fail + the signature check for real. +- :func:`make_token` mints one RS256 JWT the way Authentik would (its + claims, our key), defaulting ``groups`` to the admin group a real + admin's token carries. +- :class:`StubbedFetch` is the counting JWKS fetcher stub. +- :class:`AdminRunner` runs the app's lifespan around an httpx + AsyncClient (httpx's ASGI transport does not run the lifespan, so it + is driven explicitly and shut down in ``__aexit__``'s finally block + even on failure) with the app's JWKS cache swapped for one built + around the stub fetcher. + +The harness carries no tests itself; the ``admin_client`` fixture in +``tests/conftest.py`` hands its runner to the test modules. +""" +from __future__ import annotations + +import base64 +import time + +import httpx +import jwt +from cryptography.hazmat.primitives.asymmetric import rsa + +from api.app import create_app +from api.auth import ADMIN_GROUP, JwksCache +from api.config import ApiConfig + +#: The configured OIDC settings the test apps run with (values a real +#: deployment would get from the NESTQUEST_OIDC_* environment vars). +ISSUER = "https://authentik.example.com/application/o/nestquest/" +AUDIENCE = "nestquest-api" +JWKS_URL = "https://authentik.example.com/application/o/nestquest/jwks/" + +#: The ``kid`` the test tokens carry, matching the JWKS entry. +KID = "admin-test-key-1" + +#: The subject a valid token authenticates as. +SUBJECT = "admin-user-1" + + +def _generate_key() -> rsa.RSAPrivateKey: + """Generate one local RSA key pair (no network, no fixtures).""" + return rsa.generate_private_key(public_exponent=65537, key_size=2048) + + +#: The key pair whose PUBLIC half the stubbed JWKS publishes — tokens +#: signed with it verify; the second pair below proves they don't when +#: signed otherwise. +ADMIN_KEY = _generate_key() +OTHER_KEY = _generate_key() + + +def _b64url_uint(value: int) -> str: + """Base64url-encode a non-negative integer (no padding), per JWK.""" + raw = value.to_bytes((value.bit_length() + 7) // 8, "big") + return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") + + +def jwks_for(private_key: rsa.RSAPrivateKey, kid: str) -> dict[str, object]: + """Build a JWKS document publishing ``private_key``'s public half.""" + numbers = private_key.public_key().public_numbers() + return { + "keys": [ + { + "kty": "RSA", + "use": "sig", + "alg": "RS256", + "kid": kid, + "n": _b64url_uint(numbers.n), + "e": _b64url_uint(numbers.e), + } + ] + } + + +class StubbedFetch: + """A JWKS fetcher stub: returns a fixed document, counts calls.""" + + def __init__(self, document: dict[str, object]) -> None: + self.document = document + self.calls = 0 + + async def __call__(self, url: str) -> dict[str, object]: + self.calls += 1 + return self.document + + +def make_token( + signing_key: rsa.RSAPrivateKey = ADMIN_KEY, + *, + kid: str | None = KID, + iss: str = ISSUER, + aud: object = AUDIENCE, + sub: str = SUBJECT, + expires_in: int | None = 300, + nbf_in: int | None = -1, + groups: object = (ADMIN_GROUP,), +) -> str: + """Mint one RS256 JWT the way Authentik would (its claims, our key). + + ``groups`` defaults to the admin group (what a real admin user's + token carries); tests pass a different value — or ``None`` for NO + groups claim at all — to prove the group gate rejects it. + """ + now = int(time.time()) + claims: dict[str, object] = {"iss": iss, "aud": aud, "sub": sub, "iat": now} + if groups is not None: + claims["groups"] = ( + list(groups) if isinstance(groups, (list, tuple)) else groups + ) + if nbf_in is not None: + claims["nbf"] = now + nbf_in + if expires_in is not None: + claims["exp"] = now + expires_in + headers: dict[str, str] = {} + if kid is not None: + headers["kid"] = kid + return jwt.encode( + claims, signing_key, algorithm="RS256", headers=headers + ) + + +class AdminRunner: + """Run the app's lifespan around an httpx AsyncClient. + + Same pattern as test_api_health.py's ``_AppRunner``: httpx's ASGI + transport does not run the lifespan, so it is driven explicitly and + shut down in ``__aexit__``'s finally block even on failure. + + The app's JWKS cache is replaced (before the lifespan runs) with + one built around a counting stub fetcher, so verification goes + through the REAL cache logic but never the network. + """ + + def __init__( + self, + db_path: str, + jwks_document: dict[str, object], + *, + ttl_seconds: float | None = None, + backoff_seconds: float | None = None, + kid_refetch_seconds: float | None = None, + ) -> None: + self.app = create_app( + ApiConfig( + db_path=db_path, + panel_token="admin-test-panel-token", + oidc_issuer=ISSUER, + oidc_audience=AUDIENCE, + oidc_jwks_url=JWKS_URL, + ) + ) + self.fetch = StubbedFetch(jwks_document) + cache_kwargs: dict[str, float] = {} + if ttl_seconds is not None: + cache_kwargs["ttl_seconds"] = ttl_seconds + if backoff_seconds is not None: + cache_kwargs["backoff_seconds"] = backoff_seconds + if kid_refetch_seconds is not None: + cache_kwargs["kid_refetch_seconds"] = kid_refetch_seconds + self.app.state.jwks_cache = JwksCache( + JWKS_URL, fetcher=self.fetch, **cache_kwargs + ) + + async def __aenter__(self) -> httpx.AsyncClient: + self._lifespan = self.app.router.lifespan_context(self.app) + await self._lifespan.__aenter__() + self._client = httpx.AsyncClient( + transport=httpx.ASGITransport(app=self.app), + base_url="http://testserver", + ) + return await self._client.__aenter__() + + async def __aexit__(self, *exc) -> None: + try: + await self._client.__aexit__(*exc) + finally: + await self._lifespan.__aexit__(*exc) diff --git a/tests/conftest.py b/tests/conftest.py index 1e4b728..ece6b88 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -33,8 +33,11 @@ from pathlib import Path from types import SimpleNamespace from unittest.mock import AsyncMock, MagicMock +import httpx import pytest +from tests.admin_jwt_harness import ADMIN_KEY, KID, AdminRunner, jwks_for + # --------------------------------------------------------------------------- # Homeassistant module provisioning (explicitly mock-only). # --------------------------------------------------------------------------- @@ -953,4 +956,25 @@ def freeze_nestquest_clock( date=datetime.date, ) monkeypatch.setattr(nestquest, "datetime", fake_module) - return frozen, requested \ No newline at end of file + return frozen, requested + + +# --------------------------------------------------------------------------- +# Admin-plane API fixtures (shared by the admin-plane test modules). +# +# The local-key/stubbed-JWKS harness itself lives in +# :mod:`tests.admin_jwt_harness` (imported by name, the same pattern as +# tests.blueprint_helpers); these fixtures hand its runner to every test +# module in tests/ without re-declaring it. +# --------------------------------------------------------------------------- +@pytest.fixture +def temp_db_path(tmp_path: Path) -> str: + """A fresh per-test SQLite path under pytest's tmp_path.""" + return str(tmp_path / "nestquest.db") + + +@pytest.fixture +async def admin_client(temp_db_path: str) -> httpx.AsyncClient: + """An app client whose provider publishes the admin test key.""" + async with AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) as client: + yield client \ No newline at end of file diff --git a/tests/test_api_admin_auth.py b/tests/test_api_admin_auth.py index e1a19ad..c9dbd05 100644 --- a/tests/test_api_admin_auth.py +++ b/tests/test_api_admin_auth.py @@ -2,7 +2,10 @@ These tests exercise the NestQuest API's admin plane against the ASGI app with httpx, using a locally generated RSA key pair and a stubbed -JWKS fetcher — NO network access: +JWKS fetcher — NO network access. That harness (the key pairs, the +JWKS builder, the token minting and the app runner) is shared with the +other admin-plane route tests in :mod:`tests.admin_jwt_harness`; this +module imports it and proves the auth CONTRACT itself: - ``GET /api/v1/admin/ping`` requires a valid Authentik OIDC JWT: a request with NO ``Authorization`` header and a MALFORMED header @@ -41,28 +44,24 @@ import asyncio import base64 import json import time -from pathlib import Path import httpx -import jwt import pytest -from cryptography.hazmat.primitives.asymmetric import rsa -from api.app import create_app -from api.auth import ADMIN_GROUP, JwksCache +from api.auth import JwksCache from api.config import ApiConfig, ConfigError - -#: The configured OIDC settings the test apps run with (values a real -#: deployment would get from the NESTQUEST_OIDC_* environment vars). -ISSUER = "https://authentik.example.com/application/o/nestquest/" -AUDIENCE = "nestquest-api" -JWKS_URL = "https://authentik.example.com/application/o/nestquest/jwks/" - -#: The ``kid`` the test tokens carry, matching the JWKS entry. -KID = "admin-test-key-1" - -#: The subject a valid token authenticates as. -SUBJECT = "admin-user-1" +from tests.admin_jwt_harness import ( + ADMIN_KEY, + AUDIENCE, + ISSUER, + JWKS_URL, + KID, + OTHER_KEY, + SUBJECT, + AdminRunner as _AdminRunner, + jwks_for as _jwks_for, + make_token as _make_token, +) #: The uniform 401 detail every rejection must carry. REJECTION_DETAIL = "Missing or invalid admin credentials" @@ -78,53 +77,6 @@ FORBIDDEN_DETAIL = ( ) -def _generate_key() -> rsa.RSAPrivateKey: - """Generate one local RSA key pair (no network, no fixtures).""" - return rsa.generate_private_key(public_exponent=65537, key_size=2048) - - -#: The key pair whose PUBLIC half the stubbed JWKS publishes — tokens -#: signed with it verify; the second pair below proves they don't when -#: signed otherwise. -ADMIN_KEY = _generate_key() -OTHER_KEY = _generate_key() - - -def _b64url_uint(value: int) -> str: - """Base64url-encode a non-negative integer (no padding), per JWK.""" - raw = value.to_bytes((value.bit_length() + 7) // 8, "big") - return base64.urlsafe_b64encode(raw).rstrip(b"=").decode("ascii") - - -def _jwks_for(private_key: rsa.RSAPrivateKey, kid: str) -> dict[str, object]: - """Build a JWKS document publishing ``private_key``'s public half.""" - numbers = private_key.public_key().public_numbers() - return { - "keys": [ - { - "kty": "RSA", - "use": "sig", - "alg": "RS256", - "kid": kid, - "n": _b64url_uint(numbers.n), - "e": _b64url_uint(numbers.e), - } - ] - } - - -class _StubbedFetch: - """A JWKS fetcher stub: returns a fixed document, counts calls.""" - - def __init__(self, document: dict[str, object]) -> None: - self.document = document - self.calls = 0 - - async def __call__(self, url: str) -> dict[str, object]: - self.calls += 1 - return self.document - - class _FailingFetch: """A JWKS fetcher stub that always fails, counting the attempts.""" @@ -164,41 +116,6 @@ class _FailThenServe: return self.document -def _make_token( - signing_key: rsa.RSAPrivateKey = ADMIN_KEY, - *, - kid: str | None = KID, - iss: str = ISSUER, - aud: object = AUDIENCE, - sub: str = SUBJECT, - expires_in: int | None = 300, - nbf_in: int | None = -1, - groups: object = (ADMIN_GROUP,), -) -> str: - """Mint one RS256 JWT the way Authentik would (its claims, our key). - - ``groups`` defaults to the admin group (what a real admin user's - token carries); tests pass a different value — or ``None`` for NO - groups claim at all — to prove the group gate rejects it. - """ - now = int(time.time()) - claims: dict[str, object] = {"iss": iss, "aud": aud, "sub": sub, "iat": now} - if groups is not None: - claims["groups"] = ( - list(groups) if isinstance(groups, (list, tuple)) else groups - ) - if nbf_in is not None: - claims["nbf"] = now + nbf_in - if expires_in is not None: - claims["exp"] = now + expires_in - headers: dict[str, str] = {} - if kid is not None: - headers["kid"] = kid - return jwt.encode( - claims, signing_key, algorithm="RS256", headers=headers - ) - - def _token_with_header( header: dict[str, object], payload: dict[str, object] ) -> str: @@ -226,79 +143,6 @@ def _verified_payload() -> dict[str, object]: } -class _AdminRunner: - """Run the app's lifespan around an httpx AsyncClient. - - Same pattern as test_api_health.py's ``_AppRunner``: httpx's ASGI - transport does not run the lifespan, so it is driven explicitly and - shut down in ``__aexit__``'s finally block even on failure. - - The app's JWKS cache is replaced (before the lifespan runs) with - one built around a counting stub fetcher, so verification goes - through the REAL cache logic but never the network. - """ - - def __init__( - self, - db_path: str, - jwks_document: dict[str, object], - *, - ttl_seconds: float | None = None, - backoff_seconds: float | None = None, - kid_refetch_seconds: float | None = None, - ) -> None: - self.app = create_app( - ApiConfig( - db_path=db_path, - panel_token="admin-test-panel-token", - oidc_issuer=ISSUER, - oidc_audience=AUDIENCE, - oidc_jwks_url=JWKS_URL, - ) - ) - self.fetch = _StubbedFetch(jwks_document) - cache_kwargs: dict[str, float] = {} - if ttl_seconds is not None: - cache_kwargs["ttl_seconds"] = ttl_seconds - if backoff_seconds is not None: - cache_kwargs["backoff_seconds"] = backoff_seconds - if kid_refetch_seconds is not None: - cache_kwargs["kid_refetch_seconds"] = kid_refetch_seconds - self.app.state.jwks_cache = JwksCache( - JWKS_URL, fetcher=self.fetch, **cache_kwargs - ) - - async def __aenter__(self) -> httpx.AsyncClient: - self._lifespan = self.app.router.lifespan_context(self.app) - await self._lifespan.__aenter__() - self._client = httpx.AsyncClient( - transport=httpx.ASGITransport(app=self.app), - base_url="http://testserver", - ) - return await self._client.__aenter__() - - async def __aexit__(self, *exc) -> None: - try: - await self._client.__aexit__(*exc) - finally: - await self._lifespan.__aexit__(*exc) - - -@pytest.fixture -def temp_db_path(tmp_path: Path) -> str: - """A fresh per-test SQLite path under pytest's tmp_path.""" - return str(tmp_path / "nestquest.db") - - -@pytest.fixture -async def admin_client( - temp_db_path: str, -) -> httpx.AsyncClient: - """An app client whose provider publishes the admin test key.""" - async with _AdminRunner(temp_db_path, _jwks_for(ADMIN_KEY, KID)) as client: - yield client - - # --- the happy path ------------------------------------------------------- diff --git a/tests/test_api_admin_children.py b/tests/test_api_admin_children.py new file mode 100644 index 0000000..248f049 --- /dev/null +++ b/tests/test_api_admin_children.py @@ -0,0 +1,464 @@ +"""Admin-plane children route tests (task e4845baa). + +These tests exercise the admin plane's children 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. + +Every done-condition case is proven against the DATABASE, read through +the SAME core copy the app uses (``nestquest_core.*``; see the two-copy +caveat in api/nestquest_core.py): + +- Authentication runs through the router's ONE ``require_admin`` + dependency: an admin JWT passes, a JWT without the group is 403, an + absent credential is 401 — and no row is written. +- ``POST /api/v1/admin/children``: the created child EXISTS with the + given display name (and the optional colour/avatar_ref/sort_order). +- ``PATCH /api/v1/admin/children/{id}``: an edit PERSISTS the supplied + fields and leaves omitted fields untouched (exclude_unset semantics — + an omitted field is never passed to the core). +- ``PATCH /api/v1/admin/children/{id}/active`` with ``is_active: false`` + sets the active flag FALSE and the row STILL EXISTS (children are + never hard-deleted). +- ``POST /api/v1/admin/children/reorder``: the posted order becomes the + rows' sort_order; a list that is not a complete permutation is 422 + and leaves the stored order untouched. + +Error mapping (documented in api/routes_admin.py): a core ValueError +for a non-existent child is 404; every other core ValueError (an empty +display name, an explicit null colour, a bad permutation) is 422 — the +same failure class FastAPI reports for malformed bodies. +""" +from __future__ import annotations + +import importlib +from types import SimpleNamespace + +import httpx +import pytest + +from tests.admin_jwt_harness import ( + ADMIN_KEY, + KID, + AdminRunner, + jwks_for, + make_token, +) + +# The children business layer 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. +_children = importlib.import_module("nestquest_core.children") + + +def _admin_headers() -> dict[str, str]: + """The Authorization header of a valid nestquest-admins JWT.""" + return {"Authorization": f"Bearer {make_token()}"} + + +async def _children_by_id(database: object) -> dict[int, object]: + """Every child row through the app's core copy, keyed by id.""" + records = await _children.list_children(database) + return {record.id: record for record in records} + + +@pytest.fixture +async def admin_children(temp_db_path: str) -> SimpleNamespace: + """An admin-plane app client plus its live database. + + The same runner the auth tests use (stubbed JWKS, no network); the + fixture also exposes the app's ONE database connection so tests + assert the routes' effects on the stored rows themselves. + """ + 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, + ) + + +# --- authentication: the ONE require_admin dependency guards every route --- + + +async def test_children_routes_refuse_non_admin_and_absent_credentials( + admin_children: SimpleNamespace, +) -> None: + """A non-admin JWT is 403, an absent credential is 401: nothing written.""" + client = admin_children.client + body = {"display_name": "Ada"} + non_admin = make_token(groups=("some-other-group",)) + + forbidden = await client.post( + "/api/v1/admin/children", + json=body, + headers={"Authorization": f"Bearer {non_admin}"}, + ) + assert forbidden.status_code == 403 + assert forbidden.json()["detail"] == ( + "Admin access requires membership in the nestquest-admins group" + ) + + unauthenticated = await client.post("/api/v1/admin/children", json=body) + assert unauthenticated.status_code == 401 + + unauthenticated_list = await client.get("/api/v1/admin/children") + assert unauthenticated_list.status_code == 401 + + # No route ran its handler: the household is still empty. + assert await _children_by_id(admin_children.database) == {} + + +# --- create ----------------------------------------------------------------- + + +async def test_create_child_persists_the_given_name( + admin_children: SimpleNamespace, +) -> None: + """POST /children creates the row; the child exists with that name.""" + response = await admin_children.client.post( + "/api/v1/admin/children", + json={ + "display_name": "Ada", + "colour": "#ff8800", + "avatar_ref": "avatar:bee", + "sort_order": 2, + }, + headers=_admin_headers(), + ) + assert response.status_code == 201 + body = response.json() + assert body["display_name"] == "Ada" + assert body["colour"] == "#ff8800" + assert body["avatar_ref"] == "avatar:bee" + assert body["sort_order"] == 2 + assert body["is_active"] is True + + stored = (await _children_by_id(admin_children.database))[body["id"]] + assert stored.display_name == "Ada" + assert stored.colour == "#ff8800" + assert stored.avatar_ref == "avatar:bee" + assert stored.sort_order == 2 + assert stored.is_active is True + + +async def test_create_child_with_only_a_name_uses_core_defaults( + admin_children: SimpleNamespace, +) -> None: + """Omitted optional fields default through the core layer.""" + response = await admin_children.client.post( + "/api/v1/admin/children", + json={"display_name": "Bo"}, + headers=_admin_headers(), + ) + assert response.status_code == 201 + stored = (await _children_by_id(admin_children.database))[ + response.json()["id"] + ] + assert stored.display_name == "Bo" + assert stored.colour is None + assert stored.avatar_ref is None + assert stored.sort_order == 0 + assert stored.is_active is True + + +@pytest.mark.parametrize( + "body", + [ + {}, # display_name missing + {"display_name": 5}, # wrong type + {"display_name": ""}, # empty: the core rejects it + {"display_name": " "}, # whitespace-only: empty to a parent + {"display_name": "Ada", "sort_order": "first"}, # wrong type + ], +) +async def test_create_child_malformed_input_is_422( + admin_children: SimpleNamespace, body: dict[str, object] +) -> None: + """Malformed bodies are 422 and never create a row.""" + response = await admin_children.client.post( + "/api/v1/admin/children", json=body, headers=_admin_headers() + ) + assert response.status_code == 422 + assert await _children_by_id(admin_children.database) == {} + + +# --- edit ------------------------------------------------------------------- + + +async def test_edit_child_persists_supplied_fields_and_skips_omitted_ones( + admin_children: SimpleNamespace, +) -> None: + """PATCH persists exactly the supplied fields (exclude_unset).""" + client = admin_children.client + created = await client.post( + "/api/v1/admin/children", + json={ + "display_name": "Ada", + "colour": "#ff8800", + "avatar_ref": "avatar:bee", + }, + headers=_admin_headers(), + ) + assert created.status_code == 201 + child_id = created.json()["id"] + + # Only display_name and sort_order: colour/avatar_ref must survive. + edited = await client.patch( + f"/api/v1/admin/children/{child_id}", + json={"display_name": "Ada Lovelace", "sort_order": 4}, + headers=_admin_headers(), + ) + assert edited.status_code == 200 + assert edited.json()["display_name"] == "Ada Lovelace" + assert edited.json()["sort_order"] == 4 + stored = (await _children_by_id(admin_children.database))[child_id] + assert stored.display_name == "Ada Lovelace" + assert stored.sort_order == 4 + # The omitted fields were never passed to the core: unchanged. + assert stored.colour == "#ff8800" + assert stored.avatar_ref == "avatar:bee" + + # A second edit touching only colour leaves the name alone. + recoloured = await client.patch( + f"/api/v1/admin/children/{child_id}", + json={"colour": "#00aa55"}, + headers=_admin_headers(), + ) + assert recoloured.status_code == 200 + stored = (await _children_by_id(admin_children.database))[child_id] + assert stored.colour == "#00aa55" + assert stored.display_name == "Ada Lovelace" + + +async def test_edit_unknown_child_is_404( + admin_children: SimpleNamespace, +) -> None: + """An edit naming a child that does not exist is 404.""" + response = await admin_children.client.patch( + "/api/v1/admin/children/999", + json={"display_name": "Nobody"}, + headers=_admin_headers(), + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Child not found" + + +async def test_edit_explicit_null_colour_is_422_and_changes_nothing( + admin_children: SimpleNamespace, +) -> None: + """An explicit null (clearing) is passed through and rejected by the core. + + Only the fields the request SUPPLIES are forwarded: a null colour is + a supplied None, which the core refuses (the DAO cannot express + NULL) — mapped to 422, with the stored colour left untouched. + """ + created = await admin_children.client.post( + "/api/v1/admin/children", + json={"display_name": "Ada", "colour": "#ff8800"}, + headers=_admin_headers(), + ) + child_id = created.json()["id"] + response = await admin_children.client.patch( + f"/api/v1/admin/children/{child_id}", + json={"colour": None}, + headers=_admin_headers(), + ) + assert response.status_code == 422 + stored = (await _children_by_id(admin_children.database))[child_id] + assert stored.colour == "#ff8800" + + +# --- set_active ------------------------------------------------------------- + + +async def test_deactivation_sets_the_flag_false_and_never_deletes( + admin_children: SimpleNamespace, +) -> None: + """is_active=false deactivates the row; the row STILL exists.""" + client = admin_children.client + created = await client.post( + "/api/v1/admin/children", + json={"display_name": "Ada"}, + headers=_admin_headers(), + ) + child_id = created.json()["id"] + + deactivated = await client.patch( + f"/api/v1/admin/children/{child_id}/active", + json={"is_active": False}, + headers=_admin_headers(), + ) + assert deactivated.status_code == 200 + assert deactivated.json()["is_active"] is False + + # Database proof: the flag is False and the row was NOT hard-deleted. + rows = await _children_by_id(admin_children.database) + stored = rows[child_id] + assert stored.is_active is False + assert stored.display_name == "Ada" + + # Reactivation goes through the same route: the flag is set, not + # one-way. + reactivated = await client.patch( + f"/api/v1/admin/children/{child_id}/active", + json={"is_active": True}, + headers=_admin_headers(), + ) + assert reactivated.status_code == 200 + assert reactivated.json()["is_active"] is True + rows = await _children_by_id(admin_children.database) + assert rows[child_id].is_active is True + + +async def test_set_active_unknown_child_is_404( + admin_children: SimpleNamespace, +) -> None: + """A set_active naming a child that does not exist is 404.""" + response = await admin_children.client.patch( + "/api/v1/admin/children/999/active", + json={"is_active": False}, + headers=_admin_headers(), + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Child not found" + + +async def test_set_active_malformed_body_is_422( + admin_children: SimpleNamespace, +) -> None: + """A non-bool is_active is rejected by the body model (422).""" + response = await admin_children.client.patch( + "/api/v1/admin/children/1/active", + json={"is_active": "maybe"}, + headers=_admin_headers(), + ) + assert response.status_code == 422 + + +# --- reorder ---------------------------------------------------------------- + + +async def test_reorder_writes_the_posted_order_to_sort_order( + admin_children: SimpleNamespace, +) -> None: + """The posted id order becomes the rows' sort_order.""" + client = admin_children.client + ids = [] + for name in ("Ada", "Bo", "Cyd"): + created = await client.post( + "/api/v1/admin/children", + json={"display_name": name}, + headers=_admin_headers(), + ) + assert created.status_code == 201 + ids.append(created.json()["id"]) + + response = await client.post( + "/api/v1/admin/children/reorder", + json={"ordered_ids": [ids[2], ids[0], ids[1]]}, + headers=_admin_headers(), + ) + assert response.status_code == 200 + assert response.json() == {"status": "ok"} + + # Database proof: each posted position became that child's sort_order. + rows = await _children_by_id(admin_children.database) + assert rows[ids[2]].sort_order == 0 + assert rows[ids[0]].sort_order == 1 + assert rows[ids[1]].sort_order == 2 + + +async def test_reorder_rejects_non_permutations_and_writes_nothing( + admin_children: SimpleNamespace, +) -> None: + """A list that is not a complete permutation is 422, order intact. + + The core rejects the list BEFORE any write, so the stored sort_order + values survive every rejected call unchanged. + """ + client = admin_children.client + ids = [] + for name in ("Ada", "Bo"): + created = await client.post( + "/api/v1/admin/children", + json={"display_name": name}, + headers=_admin_headers(), + ) + assert created.status_code == 201 + ids.append(created.json()["id"]) + + bad_lists = [ + ids[:1], # partial: missing an existing child + [ids[0], ids[0], ids[1]], # duplicate id + [*ids, ids[1] + 999], # unknown id + ] + for ordered_ids in bad_lists: + response = await client.post( + "/api/v1/admin/children/reorder", + json={"ordered_ids": ordered_ids}, + headers=_admin_headers(), + ) + assert response.status_code == 422, ordered_ids + + # A non-integer id never reaches the core either (body model). + malformed = await client.post( + "/api/v1/admin/children/reorder", + json={"ordered_ids": ["first", "second"]}, + headers=_admin_headers(), + ) + assert malformed.status_code == 422 + + # Database proof: no write happened for any rejected call. + rows = await _children_by_id(admin_children.database) + assert [rows[child_id].sort_order for child_id in ids] == [0, 0] + + +# --- list (the read model the admin PWA re-renders from) --------------------- + + +async def test_list_children_returns_all_in_display_order( + admin_children: SimpleNamespace, +) -> None: + """GET /children returns every child (inactive included) in order.""" + client = admin_children.client + ada = await client.post( + "/api/v1/admin/children", + json={"display_name": "Ada", "sort_order": 5}, + headers=_admin_headers(), + ) + bo = await client.post( + "/api/v1/admin/children", + json={"display_name": "Bo", "sort_order": 1}, + headers=_admin_headers(), + ) + deactivated = await client.patch( + f"/api/v1/admin/children/{ada.json()['id']}/active", + json={"is_active": False}, + headers=_admin_headers(), + ) + assert deactivated.status_code == 200 + + response = await client.get( + "/api/v1/admin/children", headers=_admin_headers() + ) + assert response.status_code == 200 + children = response.json()["children"] + assert [child["id"] for child in children] == [ + bo.json()["id"], + ada.json()["id"], + ] + by_id = {child["id"]: child for child in children} + assert by_id[ada.json()["id"]]["is_active"] is False + assert by_id[bo.json()["id"]]["is_active"] is True + # The serialized shape is the documented child payload. + assert set(by_id[bo.json()["id"]]) == { + "id", + "display_name", + "colour", + "avatar_ref", + "sort_order", + "is_active", + } -- 2.55.0 From e85180d4927f887ee2e7d65bd5f1ad0fb07c9f19 Mon Sep 17 00:00:00 2001 From: Joshua Date: Wed, 23 Sep 2026 01:54:41 -0400 Subject: [PATCH 10/15] Admin routes: quest definitions (#159) Co-authored-by: Joshua Co-committed-by: Joshua --- api/nestquest_core.py | 16 + api/routes_admin.py | 356 +++++++++++++++++- tests/test_api_admin_questdef.py | 611 +++++++++++++++++++++++++++++++ 3 files changed, 975 insertions(+), 8 deletions(-) create mode 100644 tests/test_api_admin_questdef.py diff --git a/api/nestquest_core.py b/api/nestquest_core.py index 45e795a..1ce326e 100644 --- a/api/nestquest_core.py +++ b/api/nestquest_core.py @@ -128,6 +128,20 @@ core_settings = importlib.import_module(f"{_CORE_MODULE_NAME}.settings") #: the admin plane's children routes call. core_children = importlib.import_module(f"{_CORE_MODULE_NAME}.children") +#: The quest-definition business layer (``core.quest_definitions`` — +#: ``create_quest_definition``, ``edit_quest_definition``, +#: ``set_quest_definition_active``) the admin plane's quest-definition +#: routes call. +core_quest_definitions = importlib.import_module( + f"{_CORE_MODULE_NAME}.quest_definitions" +) + +#: The recurrence model (``core.recurrence.ScheduleRule`` and +#: ``RuleValidationError``) the quest-definition routes build request +#: rules with — the model validates at construction, the routes never +#: re-implement rule validation. +core_recurrence = importlib.import_module(f"{_CORE_MODULE_NAME}.recurrence") + __all__ = [ "core", "core_children", @@ -135,6 +149,8 @@ __all__ = [ "core_db", "core_events", "core_migrations", + "core_quest_definitions", + "core_recurrence", "core_snapshot", "core_settings", ] diff --git a/api/routes_admin.py b/api/routes_admin.py index ff88551..3ed4a71 100644 --- a/api/routes_admin.py +++ b/api/routes_admin.py @@ -18,20 +18,47 @@ check all live THERE, never in a handler — and serializes the returned payload (``id``, ``display_name``, ``colour``, ``avatar_ref``, ``sort_order``, ``is_active``). -Error mapping (every children route, deliberately narrow): +The quest-definition routes follow the same pattern over +:mod:`nestquest_core.quest_definitions`: the body model carries the +shape, the handler builds ONE +:class:`~nestquest_core.recurrence.ScheduleRule` from the request's +``rule`` object through the model's own ``from_dict`` (rule validation +is the MODEL's construction-time job — a bad combination raises +:class:`~nestquest_core.recurrence.RuleValidationError`, never +hand-rolled here), and the call goes to +``create_quest_definition`` / ``edit_quest_definition`` / +``set_quest_definition_active``. The title policy, the window-name +and due-time policy, the assignee checks, the whole-set window +replacement and the never-hard-delete policy (deactivation is the only +removal path) all live in the core. Assignment is NOT settable +through the API: the core's edit path takes no assignees. The edit +route forwards only SUPPLIED fields (``exclude_unset``), like the +children edit. + +Error mapping (every children and quest-definition route, deliberately +narrow): - Malformed input the body model or the path parser rejects (a missing - or wrong-typed field, a non-integer child id) is FastAPI's 422 before - any handler code runs. + or wrong-typed field, a non-integer child or definition id) is + FastAPI's 422 before any handler code runs. - A core ``ValueError`` reporting a NON-EXISTENT child (edit or set_active on an unknown id) is mapped to 404. +- A core ``ValueError`` reporting a NON-EXISTENT DEFINITION (edit or + set_active on an unknown id — the core prefixes that error with + ``definition_id:``) is mapped to 404. Create's rejected-assignee + error names a child from the BODY, not a path id, so it maps to 422. - Every OTHER core ``ValueError`` — an empty display name, an explicit ``null`` colour/avatar_ref (clearing is not supported), a reorder list that is not a complete permutation of the children (missing, - duplicate, or unknown ids) — is mapped to 422: the body was - well-formed JSON but semantically invalid, the same failure class - Pydantic reports. The core rejects these BEFORE any write, so a 422 - never leaves a half-applied change behind. + duplicate, or unknown ids); a rejected rule + (:class:`~nestquest_core.recurrence.RuleValidationError`: unknown + ``rule_type``, a shape-missing field such as a weekly rule without + weekdays, a malformed date, a forbidden field), a bad window name or + due time, a rejected window or assignee list (empty, duplicate, + wrong-typed) — is mapped to 422: the body was well-formed JSON but + semantically invalid, the same failure class Pydantic reports. The + core rejects these BEFORE any write, so a 422 never leaves a + half-applied change behind. PATCH edit semantics: the body model's fields are optional and only SUPPLIED fields are forwarded (``model_dump(exclude_unset=True)``), so @@ -49,7 +76,11 @@ from pydantic import BaseModel from api.auth import require_admin from api.database import DatabaseState -from api.nestquest_core import core_children +from api.nestquest_core import ( + core_children, + core_quest_definitions, + core_recurrence, +) #: All admin-plane routes share this router; the ONE admin dependency #: (service-token refusal + JWT verification + nestquest-admins group @@ -91,6 +122,60 @@ class AdminChildListResponse(BaseModel): children: list[AdminChildResponse] +class AdminRuleResponse(BaseModel): + """A definition's schedule rule as the admin plane serializes it. + + Mirrors :meth:`nestquest_core.recurrence.ScheduleRule.to_dict`: the + model-name ``rule_type`` (e.g. ``monthly_weekday``), the weekday + set as a sorted list, and ``None`` for every field the rule's shape + does not use. + """ + + rule_type: str + interval: int + weekday_set: list[int] | None + day_of_month: int | None + nth_weekday: int | None + nth_weekday_weekday: int | None + month: int | None + start_date: str + end_date: str | None + + +class AdminAssigneeResponse(BaseModel): + """One assigned child: the id and display name the PWA renders.""" + + id: int + display_name: str + + +class AdminDefinitionWindowResponse(BaseModel): + """One window declaration: its name and optional HH:MM due time.""" + + window: str + due_time: str | None + + +class AdminQuestDefinitionResponse(BaseModel): + """One quest definition as the admin plane serializes it. + + Mirrors the core's + :class:`~nestquest_core.quest_definitions.CreatedQuestDefinition` + bundle: the stored row (minus the internal ``schedule_rule_id``/ + ``due_time``/``created_at`` columns) plus its decoded rule, + assignees and windows. + """ + + id: int + title: str + description: str | None + icon: str | None + is_active: bool + rule: AdminRuleResponse + assignees: list[AdminAssigneeResponse] + windows: list[AdminDefinitionWindowResponse] + + class AdminChildCreateRequest(BaseModel): """The body of ``POST /api/v1/admin/children``. @@ -138,6 +223,77 @@ class AdminChildReorderRequest(BaseModel): ordered_ids: list[int] +class AdminRuleRequest(BaseModel): + """The ``rule`` object of a quest-definition create or edit body. + + The fields of :class:`~nestquest_core.recurrence.ScheduleRule` — + nothing more: the shape is the model's, the defaults are the + model's (``interval`` 1, ``start_date`` 1970-01-01). Only + ``rule_type`` is required. The COMBINATION policy (which fields a + shape requires and forbids, the 1..31/1..12/0..6 ranges, the strict + ISO dates) is enforced by the model's constructor, which the route + builds through ``from_dict`` — this model only guards the SHAPE. + """ + + rule_type: str + interval: int = 1 + weekday_set: list[int] | None = None + day_of_month: int | None = None + nth_weekday: int | None = None + nth_weekday_weekday: int | None = None + month: int | None = None + start_date: str = "1970-01-01" + end_date: str | None = None + + +class AdminQuestDefinitionCreateRequest(BaseModel): + """The body of ``POST /api/v1/admin/quest-definitions``. + + ``title``, ``rule``, ``assignee_child_ids`` and ``windows`` are + required (the core rejects an empty title, a non-list or empty + assignee/window list, and unknown window names); ``description`` + and ``icon`` are optional and default to NULL through the core. + ``windows`` entries are a window name (``morning``) or a two-item + ``[window, due_time]`` pair whose due time is a strict 24-hour + HH:MM (or ``null`` for none) — both forms the core normalizes. + """ + + title: str + rule: AdminRuleRequest + assignee_child_ids: list[int] + windows: list[str | tuple[str, str | None]] + description: str | None = None + icon: str | None = None + + +class AdminQuestDefinitionEditRequest(BaseModel): + """The body of ``PATCH /api/v1/admin/quest-definitions/{definition_id}``. + + Every field is optional; ONLY the fields the request supplies are + forwarded to :func:`nestquest_core.quest_definitions.edit_quest_definition` + (the route dumps the model with ``exclude_unset``), so an omitted + field stays unchanged. An explicit ``null`` ``description``/ + ``icon`` IS passed and CLEARS the stored value (the core supports + clearing here, unlike the children edit); an explicit ``null`` + ``rule`` or ``windows`` is passed and rejected by the core. A + supplied ``windows`` list REPLACES the whole window set, and + ``assignees`` are deliberately absent — assignment is not settable + through this route. + """ + + title: str | None = None + description: str | None = None + icon: str | None = None + rule: AdminRuleRequest | None = None + windows: list[str | tuple[str, str | None]] | None = None + + +class AdminQuestDefinitionActiveRequest(BaseModel): + """The body of ``PATCH /api/v1/admin/quest-definitions/{id}/active``.""" + + is_active: bool + + #: 404 detail for a child id the core layer reports as non-existent. _CHILD_NOT_FOUND_DETAIL = "Child not found" @@ -172,6 +328,76 @@ def _child_response(record: core_children.ChildRecord) -> AdminChildResponse: ) +#: 404 detail for a definition id the core layer reports as non-existent. +_QUEST_DEFINITION_NOT_FOUND_DETAIL = "Quest definition not found" + + +def _raise_quest_definition_error(error: ValueError) -> NoReturn: + """Map a core quest-definitions ``ValueError`` onto 404 or 422. + + The mapping mirrors :func:`_raise_child_error` and stays narrow: + the core names a missing definition by prefixing its error with + ``definition_id:`` (edit or set_active on an unknown id — its id + validator's message reads ``definition_id must be an integer`` + WITHOUT the colon, so a malformed id stays 422), and that one case + is the quest-definition routes' 404. Every other ``ValueError`` — + including :class:`~nestquest_core.recurrence.RuleValidationError`, + which subclasses it — becomes 422 with the core's message as the + detail: create's rejected assignee names a child from the BODY (not + a path id, so not 404), a rejected rule/window/title names the + field and what to fix, and none of it leaks more than the rule it + enforces. + """ + if str(error).startswith("definition_id:"): + raise HTTPException( + status_code=404, detail=_QUEST_DEFINITION_NOT_FOUND_DETAIL + ) from error + raise HTTPException(status_code=422, detail=str(error)) from error + + +def _rule_from_request(rule: AdminRuleRequest) -> core_recurrence.ScheduleRule: + """Build the core ScheduleRule from the request's ``rule`` object. + + The recurrence model validates AT CONSTRUCTION — through its own + ``from_dict`` here, which raises + :class:`~nestquest_core.recurrence.RuleValidationError` (a + ``ValueError``) for any bad combination — so the route re-implements + no rule policy of its own. + """ + return core_recurrence.ScheduleRule.from_dict( + rule.model_dump(exclude_unset=True) + ) + + +def _rule_response(rule: core_recurrence.ScheduleRule) -> AdminRuleResponse: + """Serialize one core ScheduleRule via its lossless dict form.""" + return AdminRuleResponse(**rule.to_dict()) + + +def _quest_definition_response( + bundle: core_quest_definitions.CreatedQuestDefinition, +) -> AdminQuestDefinitionResponse: + """Serialize one core CreatedQuestDefinition into the payload.""" + return AdminQuestDefinitionResponse( + id=bundle.definition.id, + title=bundle.definition.title, + description=bundle.definition.description, + icon=bundle.definition.icon, + is_active=bundle.definition.is_active, + rule=_rule_response(bundle.rule), + assignees=[ + AdminAssigneeResponse(id=child.id, display_name=child.display_name) + for child in bundle.assignees + ], + windows=[ + AdminDefinitionWindowResponse( + window=window.window, due_time=window.due_time + ) + for window in bundle.windows + ], + ) + + @router.get("/ping", summary="Admin plane probe") async def admin_ping( claims: Annotated[dict[str, object], Depends(require_admin)], @@ -341,4 +567,118 @@ async def admin_reorder_children( return {"status": "ok"} +@router.post( + "/quest-definitions", + summary="Create a quest definition", + status_code=201, + response_model=AdminQuestDefinitionResponse, +) +async def admin_create_quest_definition( + body: AdminQuestDefinitionCreateRequest, request: Request +) -> AdminQuestDefinitionResponse: + """Create a quest definition (rule + assignees + windows) and return it. + + 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 model + carries the shape, the request's ``rule`` becomes ONE + :class:`~nestquest_core.recurrence.ScheduleRule` (validated by the + model's constructor, :func:`_rule_from_request`), and the title + policy, the assignee and window policies and the atomic + rule+definition+assignees+windows persistence all live in + :func:`nestquest_core.quest_definitions.create_quest_definition`. + Create names no existing definition, so a missing definition can + never be its error: every rejection here is 422 + (:func:`_raise_quest_definition_error`). + """ + state: DatabaseState = request.app.state.db + try: + bundle = await core_quest_definitions.create_quest_definition( + state.database, + body.title, + _rule_from_request(body.rule), + body.assignee_child_ids, + body.windows, + description=body.description, + icon=body.icon, + ) + except ValueError as error: + _raise_quest_definition_error(error) + return _quest_definition_response(bundle) + + +@router.patch( + "/quest-definitions/{definition_id}", + summary="Edit a quest definition", + response_model=AdminQuestDefinitionResponse, +) +async def admin_edit_quest_definition( + definition_id: int, + body: AdminQuestDefinitionEditRequest, + request: Request, +) -> AdminQuestDefinitionResponse: + """Edit a quest definition; only supplied fields change. + + 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 supplied + fields are forwarded with ``exclude_unset`` semantics so an omitted + field is never passed to + :func:`nestquest_core.quest_definitions.edit_quest_definition` and + stays unchanged; a supplied ``windows`` list replaces the WHOLE + window set in the core, and a supplied ``rule`` is rebuilt into a + ScheduleRule (:func:`_rule_from_request`, validated by the model). + An explicit ``null`` ``rule``/``windows`` reaches the core as None + and is rejected there (422), while an explicit ``null`` + ``description``/``icon`` clears the stored value — the core edit + path supports clearing, unlike the children edit. An unknown + definition id is mapped to 404 and any other core ``ValueError`` + to 422 (:func:`_raise_quest_definition_error`). + """ + state: DatabaseState = request.app.state.db + supplied = body.model_dump(exclude_unset=True) + try: + if isinstance(body.rule, AdminRuleRequest): + supplied["rule"] = _rule_from_request(body.rule) + bundle = await core_quest_definitions.edit_quest_definition( + state.database, definition_id, **supplied + ) + except ValueError as error: + _raise_quest_definition_error(error) + return _quest_definition_response(bundle) + + +@router.patch( + "/quest-definitions/{definition_id}/active", + summary="Set a quest definition's active flag", + response_model=AdminQuestDefinitionResponse, +) +async def admin_set_quest_definition_active( + definition_id: int, + body: AdminQuestDefinitionActiveRequest, + request: Request, +) -> AdminQuestDefinitionResponse: + """Deactivate (or reactivate) a definition; it is NEVER deleted. + + 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: ``is_active`` + goes straight to + :func:`nestquest_core.quest_definitions.set_quest_definition_active` + — deactivation is the only removal path (the row, rule, assignees + and windows all survive, so completion history keeps its + references). An unknown definition id is mapped to 404 + (:func:`_raise_quest_definition_error`); the body model already + makes the core's real-bool check unreachable from this route. + """ + state: DatabaseState = request.app.state.db + try: + bundle = await core_quest_definitions.set_quest_definition_active( + state.database, definition_id, body.is_active + ) + except ValueError as error: + _raise_quest_definition_error(error) + return _quest_definition_response(bundle) + + __all__ = ["router"] diff --git a/tests/test_api_admin_questdef.py b/tests/test_api_admin_questdef.py new file mode 100644 index 0000000..0e378fd --- /dev/null +++ b/tests/test_api_admin_questdef.py @@ -0,0 +1,611 @@ +"""Admin-plane quest-definition route tests (task b586a2f1). + +These tests exercise the admin plane's quest-definition 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. + +Every done-condition case is proven against the DATABASE, read through +the SAME core copy the app uses (``nestquest_core.*``; see the two-copy +caveat in api/nestquest_core.py): + +- Authentication runs through the router's ONE ``require_admin`` + dependency: an admin JWT passes, a JWT without the group is 403, an + absent credential is 401 — and no definition is written. +- ``POST /api/v1/admin/quest-definitions``: the created definition + EXISTS with its title, description, icon, decoded rule, assignees + and windows exactly as posted. +- ``PATCH /api/v1/admin/quest-definitions/{id}``: an edit PERSISTS the + supplied fields and leaves omitted fields untouched (exclude_unset + semantics); a supplied ``windows`` list replaces the whole set; + assignment is not settable through the edit. +- ``PATCH /api/v1/admin/quest-definitions/{id}/active`` with + ``is_active: false`` sets the flag FALSE and the definition STILL + EXISTS with its rule, assignees and windows (never hard-deleted); + reactivation restores it. + +Error mapping (documented in api/routes_admin.py): a core ValueError +for a non-existent DEFINITION is 404; every other core ValueError — a +rejected rule (RuleValidationError), a bad window name or due time, a +rejected assignee list (an unknown assignee arrives in the BODY, so it +is 422, never 404) — is 422, the same failure class FastAPI reports +for malformed bodies. +""" +from __future__ import annotations + +import importlib +from types import SimpleNamespace + +import pytest + +from tests.admin_jwt_harness import ( + ADMIN_KEY, + KID, + AdminRunner, + jwks_for, + make_token, +) + +# The quest-definition business layer and its 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 them through api.nestquest_core. +_quest_definitions = importlib.import_module( + "nestquest_core.quest_definitions" +) +_dao_rules = importlib.import_module("nestquest_core.dao_rules") + + +def _admin_headers() -> dict[str, str]: + """The Authorization header of a valid nestquest-admins JWT.""" + return {"Authorization": f"Bearer {make_token()}"} + + +def _weekly_rule_body() -> dict[str, object]: + """A valid weekly rule body: Mondays and Wednesdays from 2026-03-02.""" + return { + "rule_type": "weekly", + "interval": 1, + "weekday_set": [0, 2], + "start_date": "2026-03-02", + } + + +def _weekly_rule_dict() -> dict[str, object]: + """The lossless dict form _weekly_rule_body() must decode back to.""" + return { + "rule_type": "weekly", + "interval": 1, + "weekday_set": [0, 2], + "day_of_month": None, + "nth_weekday": None, + "nth_weekday_weekday": None, + "month": None, + "start_date": "2026-03-02", + "end_date": None, + } + + +async def _create_children( + client: object, names: tuple[str, ...] +) -> list[int]: + """Create ACTIVE children through the admin children route.""" + ids = [] + for name in names: + created = await client.post( + "/api/v1/admin/children", + json={"display_name": name}, + headers=_admin_headers(), + ) + assert created.status_code == 201 + ids.append(created.json()["id"]) + return ids + + +async def _create_definition( + client: object, assignee_ids: list[int] +) -> object: + """POST a valid two-window definition for the given assignees.""" + return await client.post( + "/api/v1/admin/quest-definitions", + json={ + "title": "Tidy the den", + "rule": _weekly_rule_body(), + "assignee_child_ids": assignee_ids, + "windows": ["morning", ["evening", "19:30"]], + "description": "Put everything back where it belongs.", + "icon": "toy-box", + }, + headers=_admin_headers(), + ) + + +async def _definitions_by_id(database: object) -> dict[int, object]: + """Every ACTIVE definition bundle through the app's core copy.""" + bundles = await _quest_definitions.list_active_definitions(database) + return {bundle.definition.id: bundle for bundle in bundles} + + +@pytest.fixture +async def admin_questdefs(temp_db_path: str) -> SimpleNamespace: + """An admin-plane app client plus its live database. + + The same runner the auth and children tests use (stubbed JWKS, no + network); the fixture also exposes the app's ONE database connection + so tests assert the routes' effects on the stored rows themselves. + """ + 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, + ) + + +# --- authentication: the ONE require_admin dependency guards every route --- + + +async def test_quest_definition_routes_refuse_non_admin_and_absent( + admin_questdefs: SimpleNamespace, +) -> None: + """A non-admin JWT is 403, an absent credential is 401: nothing written.""" + client = admin_questdefs.client + body = {"title": "Sweep the porch"} + non_admin = make_token(groups=("some-other-group",)) + + forbidden = await client.post( + "/api/v1/admin/quest-definitions", + json=body, + headers={"Authorization": f"Bearer {non_admin}"}, + ) + assert forbidden.status_code == 403 + assert forbidden.json()["detail"] == ( + "Admin access requires membership in the nestquest-admins group" + ) + + unauthenticated_create = await client.post( + "/api/v1/admin/quest-definitions", json=body + ) + assert unauthenticated_create.status_code == 401 + + unauthenticated_edit = await client.patch( + "/api/v1/admin/quest-definitions/1", json={"title": "X"} + ) + assert unauthenticated_edit.status_code == 401 + + unauthenticated_active = await client.patch( + "/api/v1/admin/quest-definitions/1/active", json={"is_active": False} + ) + assert unauthenticated_active.status_code == 401 + + # No route ran its handler: no definition was written. + assert await _definitions_by_id(admin_questdefs.database) == {} + + +# --- create ----------------------------------------------------------------- + + +async def test_create_persists_definition_with_assignees_and_windows( + admin_questdefs: SimpleNamespace, +) -> None: + """POST /quest-definitions persists rule, assignees and windows.""" + client = admin_questdefs.client + ada, bo = await _create_children(client, ("Ada", "Bo")) + + response = await client.post( + "/api/v1/admin/quest-definitions", + json={ + "title": "Tidy the den", + "rule": _weekly_rule_body(), + "assignee_child_ids": [ada, bo], + "windows": ["morning", ["evening", "19:30"]], + "description": "Put everything back where it belongs.", + "icon": "toy-box", + }, + headers=_admin_headers(), + ) + assert response.status_code == 201 + payload = response.json() + definition_id = payload["id"] + # The serialized shape is the documented definition payload. + assert set(payload) == { + "id", + "title", + "description", + "icon", + "is_active", + "rule", + "assignees", + "windows", + } + assert payload["title"] == "Tidy the den" + assert payload["description"] == "Put everything back where it belongs." + assert payload["icon"] == "toy-box" + assert payload["is_active"] is True + assert payload["rule"] == _weekly_rule_dict() + assert {a["id"]: a["display_name"] for a in payload["assignees"]} == { + ada: "Ada", + bo: "Bo", + } + assert {w["window"]: w["due_time"] for w in payload["windows"]} == { + "morning": None, + "evening": "19:30", + } + + # Database proof through the app's core copy: the definition, its + # decoded rule, its assignees and its windows all exist as posted. + stored = (await _definitions_by_id(admin_questdefs.database))[ + definition_id + ] + assert stored.definition.title == "Tidy the den" + assert stored.definition.description == ( + "Put everything back where it belongs." + ) + assert stored.definition.icon == "toy-box" + assert stored.definition.is_active is True + assert stored.rule.to_dict() == _weekly_rule_dict() + assert sorted(child.id for child in stored.assignees) == [ada, bo] + assert {(w.window, w.due_time) for w in stored.windows} == { + ("morning", None), + ("evening", "19:30"), + } + + +async def test_create_with_required_fields_only_uses_core_defaults( + admin_questdefs: SimpleNamespace, +) -> None: + """Omitted optional fields default to NULL through the core layer.""" + client = admin_questdefs.client + ada = (await _create_children(client, ("Ada",)))[0] + + response = await client.post( + "/api/v1/admin/quest-definitions", + json={ + "title": "Water the plants", + "rule": _weekly_rule_body(), + "assignee_child_ids": [ada], + "windows": ["morning"], + }, + headers=_admin_headers(), + ) + assert response.status_code == 201 + payload = response.json() + assert payload["description"] is None + assert payload["icon"] is None + stored = (await _definitions_by_id(admin_questdefs.database))[ + payload["id"] + ] + assert stored.definition.description is None + assert stored.definition.icon is None + + +async def test_create_without_required_fields_is_422( + admin_questdefs: SimpleNamespace, +) -> None: + """A body missing any required field is 422 and creates nothing.""" + client = admin_questdefs.client + bodies: list[dict[str, object]] = [ + {}, # everything missing + {"title": "Tidy the den"}, # rule, assignees, windows missing + { + "title": "Tidy the den", + "rule": _weekly_rule_body(), + }, # assignees and windows missing + { + "title": "Tidy the den", + "rule": _weekly_rule_body(), + "assignee_child_ids": [1], + }, # windows missing + ] + for body in bodies: + response = await client.post( + "/api/v1/admin/quest-definitions", + json=body, + headers=_admin_headers(), + ) + assert response.status_code == 422, body + assert await _definitions_by_id(admin_questdefs.database) == {} + + +@pytest.mark.parametrize( + "override", + [ + {"title": ""}, # empty: the core rejects it + {"title": " "}, # whitespace-only: empty to a parent + {"assignee_child_ids": []}, # empty assignee list + {"assignee_child_ids": [999, 1000]}, # unknown children: 422 not 404 + {"assignee_child_ids": ["Ada"]}, # wrong-typed ids + {"windows": []}, # empty window set + {"windows": "morning"}, # not a list + {"windows": ["attic"]}, # not a const.QUEST_WINDOWS name + {"windows": [["morning", "25:00"]]}, # not a 24-hour time + {"windows": [["morning", "8:00"]]}, # not a strict HH:MM + {"windows": [["morning", None], "morning"]}, # duplicate window + {"rule": {"rule_type": "weekly"}}, # weekly without weekday_set + {"rule": {"rule_type": "fortnightly"}}, # unknown rule_type + {"rule": {"rule_type": "daily", "interval": 0}}, # interval < 1 + {"rule": {"rule_type": "weekly", "weekday_set": [7]}}, # out of range + {"rule": {"rule_type": "monthly_day"}}, # missing day_of_month + {"rule": {"rule_type": "daily", "day_of_month": 5}}, # forbidden field + {"rule": {"rule_type": "daily", "start_date": "2026-13-01"}}, + {"rule": None}, # an explicit null rule is a supplied invalid value + ], +) +async def test_create_rejections_are_422_and_persist_nothing( + admin_questdefs: SimpleNamespace, override: dict[str, object] +) -> None: + """Rejected bodies are 422 and never create a definition.""" + client = admin_questdefs.client + ids = await _create_children(client, ("Ada", "Bo")) + body: dict[str, object] = { + "title": "Tidy the den", + "rule": _weekly_rule_body(), + "assignee_child_ids": ids, + "windows": ["morning", ["evening", "19:30"]], + } + body.update(override) + + response = await client.post( + "/api/v1/admin/quest-definitions", + json=body, + headers=_admin_headers(), + ) + assert response.status_code == 422, override + assert await _definitions_by_id(admin_questdefs.database) == {} + + +# --- edit ------------------------------------------------------------------- + + +async def test_edit_persists_supplied_fields_and_leaves_omitted_untouched( + admin_questdefs: SimpleNamespace, +) -> None: + """PATCH persists exactly the supplied fields (exclude_unset).""" + client = admin_questdefs.client + ada, bo = await _create_children(client, ("Ada", "Bo")) + created = await _create_definition(client, [ada, bo]) + assert created.status_code == 201 + definition_id = created.json()["id"] + dao = _dao_rules.QuestDefinitionsDao(admin_questdefs.database) + original_rule_id = (await dao.get(definition_id)).schedule_rule_id + + # Only title and windows: description, icon, rule and assignees + # must survive; the supplied windows REPLACE the whole set. + edited = await client.patch( + f"/api/v1/admin/quest-definitions/{definition_id}", + json={"title": "Clear the den", "windows": ["afternoon"]}, + headers=_admin_headers(), + ) + assert edited.status_code == 200 + assert edited.json()["title"] == "Clear the den" + assert {w["window"]: w["due_time"] for w in edited.json()["windows"]} == { + "afternoon": None + } + stored = (await _definitions_by_id(admin_questdefs.database))[ + definition_id + ] + assert stored.definition.title == "Clear the den" + assert {(w.window, w.due_time) for w in stored.windows} == { + ("afternoon", None) + } + # Omitted fields were never passed to the core: unchanged. + assert stored.definition.description == ( + "Put everything back where it belongs." + ) + assert stored.definition.icon == "toy-box" + assert stored.rule.to_dict() == _weekly_rule_dict() + # Assignment is not settable through the edit route: untouched. + assert sorted(child.id for child in stored.assignees) == [ada, bo] + + # A second edit touching only the rule leaves the metadata and the + # windows alone, and updates the rule row IN PLACE (same rule id). + rescheduled = await client.patch( + f"/api/v1/admin/quest-definitions/{definition_id}", + json={"rule": {"rule_type": "daily", "start_date": "2026-04-01"}}, + headers=_admin_headers(), + ) + assert rescheduled.status_code == 200 + assert rescheduled.json()["rule"]["rule_type"] == "daily" + stored = (await _definitions_by_id(admin_questdefs.database))[ + definition_id + ] + assert stored.definition.title == "Clear the den" + assert {(w.window, w.due_time) for w in stored.windows} == { + ("afternoon", None) + } + assert stored.rule.to_dict() == { + "rule_type": "daily", + "interval": 1, + "weekday_set": None, + "day_of_month": None, + "nth_weekday": None, + "nth_weekday_weekday": None, + "month": None, + "start_date": "2026-04-01", + "end_date": None, + } + record = await dao.get(definition_id) + assert record.schedule_rule_id == original_rule_id + rule_row = await _dao_rules.ScheduleRulesDao( + admin_questdefs.database + ).get(record.schedule_rule_id) + assert rule_row.rule_type == "daily" + assert rule_row.start_date == "2026-04-01" + + +async def test_edit_can_clear_description_and_icon_explicitly( + admin_questdefs: SimpleNamespace, +) -> None: + """An explicit null description/icon clears them (the core's edit path). + + Unlike the children edit, the definition edit supports clearing: a + supplied null IS forwarded and writes NULL; everything omitted + stays untouched. + """ + client = admin_questdefs.client + ada = (await _create_children(client, ("Ada",)))[0] + created = await _create_definition(client, [ada]) + definition_id = created.json()["id"] + + response = await client.patch( + f"/api/v1/admin/quest-definitions/{definition_id}", + json={"description": None, "icon": None}, + headers=_admin_headers(), + ) + assert response.status_code == 200 + assert response.json()["description"] is None + assert response.json()["icon"] is None + stored = (await _definitions_by_id(admin_questdefs.database))[ + definition_id + ] + assert stored.definition.description is None + assert stored.definition.icon is None + assert stored.definition.title == "Tidy the den" + assert stored.rule.to_dict() == _weekly_rule_dict() + + +async def test_edit_unknown_definition_is_404( + admin_questdefs: SimpleNamespace, +) -> None: + """An edit naming a definition that does not exist is 404.""" + response = await admin_questdefs.client.patch( + "/api/v1/admin/quest-definitions/999", + json={"title": "Ghost chore"}, + headers=_admin_headers(), + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Quest definition not found" + + +async def test_edit_rejections_are_422_and_change_nothing( + admin_questdefs: SimpleNamespace, +) -> None: + """A rejected rule or window set is 422; the stored state survives. + + The core rejects BEFORE any write (and the DAO edit runs in one + transaction), so a 422 never leaves a half-applied change behind. + """ + client = admin_questdefs.client + ada = (await _create_children(client, ("Ada",)))[0] + created = await _create_definition(client, [ada]) + definition_id = created.json()["id"] + + rejected_bodies: list[dict[str, object]] = [ + {"rule": {"rule_type": "weekly"}}, # weekly without weekday_set + {"rule": None}, # an explicit null rule is rejected by the core + {"windows": ["attic"]}, # unknown window name + {"windows": None}, # an explicit null window set is rejected + {"title": ""}, # the core rejects an empty title + ] + for body in rejected_bodies: + response = await client.patch( + f"/api/v1/admin/quest-definitions/{definition_id}", + json=body, + headers=_admin_headers(), + ) + assert response.status_code == 422, body + + # Database proof: nothing changed for any rejected call. + stored = (await _definitions_by_id(admin_questdefs.database))[ + definition_id + ] + assert stored.definition.title == "Tidy the den" + assert stored.definition.description == ( + "Put everything back where it belongs." + ) + assert stored.rule.to_dict() == _weekly_rule_dict() + assert {(w.window, w.due_time) for w in stored.windows} == { + ("morning", None), + ("evening", "19:30"), + } + + +# --- set_active ------------------------------------------------------------- + + +async def test_set_active_toggles_the_flag_and_the_definition_survives( + admin_questdefs: SimpleNamespace, +) -> None: + """is_active=false deactivates; the definition STILL exists intact. + + Deactivation is the only removal path: the row, rule, assignees and + windows all survive, and reactivation through the same route + restores the flag without anything having been lost. + """ + client = admin_questdefs.client + ada = (await _create_children(client, ("Ada",)))[0] + created = await _create_definition(client, [ada]) + assert created.status_code == 201 + definition_id = created.json()["id"] + + deactivated = await client.patch( + f"/api/v1/admin/quest-definitions/{definition_id}/active", + json={"is_active": False}, + headers=_admin_headers(), + ) + assert deactivated.status_code == 200 + payload = deactivated.json() + assert payload["is_active"] is False + # Everything else came back unchanged: deactivation only flips a flag. + assert payload["title"] == "Tidy the den" + assert payload["rule"] == _weekly_rule_dict() + assert [a["id"] for a in payload["assignees"]] == [ada] + assert {w["window"]: w["due_time"] for w in payload["windows"]} == { + "morning": None, + "evening": "19:30", + } + + # Database proof: the definition left the ACTIVE list but its row, + # rule, assignees and windows were NOT hard-deleted. + assert definition_id not in await _definitions_by_id( + admin_questdefs.database + ) + dao = _dao_rules.QuestDefinitionsDao(admin_questdefs.database) + record = await dao.get(definition_id) + assert record is not None + assert record.is_active is False + assert record.title == "Tidy the den" + assert await dao.list_assignees(definition_id) != [] + assert await dao.list_windows(definition_id) != [] + + # Reactivation goes through the same route: the flag is set, not + # one-way, and the surviving rule is still the one that was stored. + reactivated = await client.patch( + f"/api/v1/admin/quest-definitions/{definition_id}/active", + json={"is_active": True}, + headers=_admin_headers(), + ) + assert reactivated.status_code == 200 + assert reactivated.json()["is_active"] is True + stored = (await _definitions_by_id(admin_questdefs.database))[ + definition_id + ] + assert stored.definition.is_active is True + assert stored.rule.to_dict() == _weekly_rule_dict() + assert [child.id for child in stored.assignees] == [ada] + + +async def test_set_active_unknown_definition_is_404( + admin_questdefs: SimpleNamespace, +) -> None: + """A set_active naming a definition that does not exist is 404.""" + response = await admin_questdefs.client.patch( + "/api/v1/admin/quest-definitions/999/active", + json={"is_active": False}, + headers=_admin_headers(), + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Quest definition not found" + + +async def test_set_active_malformed_body_is_422( + admin_questdefs: SimpleNamespace, +) -> None: + """A non-bool is_active is rejected by the body model (422).""" + response = await admin_questdefs.client.patch( + "/api/v1/admin/quest-definitions/1/active", + json={"is_active": "maybe"}, + headers=_admin_headers(), + ) + assert response.status_code == 422 -- 2.55.0 From a3773fec8c126fc82b3beb824028da99c4e29012 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 02:26:17 -0400 Subject: [PATCH 11/15] Admin routes: presence schedule and override (#160) --- api/nestquest_core.py | 22 + api/routes_admin.py | 267 +++++++- .../nestquest/core/presence_management.py | 161 +++++ .../nestquest/presence_management.py | 10 + custom_components/nestquest/services.py | 44 +- tests/test_api_admin_presence.py | 571 ++++++++++++++++++ 6 files changed, 1040 insertions(+), 35 deletions(-) create mode 100644 custom_components/nestquest/core/presence_management.py create mode 100644 custom_components/nestquest/presence_management.py create mode 100644 tests/test_api_admin_presence.py diff --git a/api/nestquest_core.py b/api/nestquest_core.py index 1ce326e..af50cd2 100644 --- a/api/nestquest_core.py +++ b/api/nestquest_core.py @@ -142,13 +142,35 @@ core_quest_definitions = importlib.import_module( #: re-implement rule validation. core_recurrence = importlib.import_module(f"{_CORE_MODULE_NAME}.recurrence") +#: The presence model (``core.presence.PresenceEngine``, +#: ``PresenceSchedule``, ``PresenceOverride``) the admin plane's +#: presence routes serialize and the tests resolve presence with. +core_presence = importlib.import_module(f"{_CORE_MODULE_NAME}.presence") + +#: The presence-write business layer +#: (``core.presence_management.set_presence_schedule`` et al.) the +#: admin plane's presence routes call — the ONE implementation the HA +#: services share. +core_presence_management = importlib.import_module( + f"{_CORE_MODULE_NAME}.presence_management" +) + +#: The typed presence DAOs (``core.dao_presence``) the admin-plane tests +#: read stored schedules and overrides back through. +core_dao_presence = importlib.import_module( + f"{_CORE_MODULE_NAME}.dao_presence" +) + __all__ = [ "core", "core_children", "core_completion", + "core_dao_presence", "core_db", "core_events", "core_migrations", + "core_presence", + "core_presence_management", "core_quest_definitions", "core_recurrence", "core_snapshot", diff --git a/api/routes_admin.py b/api/routes_admin.py index 3ed4a71..babc316 100644 --- a/api/routes_admin.py +++ b/api/routes_admin.py @@ -35,8 +35,32 @@ through the API: the core's edit path takes no assignees. The edit route forwards only SUPPLIED fields (``exclude_unset``), like the children edit. -Error mapping (every children and quest-definition route, deliberately -narrow): +The presence routes follow the same pattern over +:mod:`nestquest_core.presence_management` — the ONE presence write +layer the HA services share, so the two planes cannot drift: + +- ``PUT /children/{child_id}/presence-schedule`` sets (UPSERTS) the + child's repeating schedule: the body's ``cycle_length_weeks``, + ``anchor_date`` and ``pattern`` go straight into ONE + :class:`~nestquest_core.presence.PresenceSchedule` (the model + validates cycle length, the strict anchor date and the pattern's + week coverage at construction), the core upserts by child (setting + again REPLACES the one schedule a child can have) and regenerates + the child's future instances; the response is the STORED schedule + decoded back through the model. +- ``POST /presence-overrides`` creates one date-range override: the + body becomes ONE :class:`~nestquest_core.presence.PresenceOverride` + (the model validates the dates, ``end >= start``, the real bool and + the note), the core creates it — rejecting a range that overlaps the + same child's existing override — and regenerates; the response is + the stored record with its ``id``. +- ``DELETE /presence-overrides/{override_id}`` deletes one override + (children are never hard-deleted; overrides are deletable by + design) and regenerates that override's child. It answers + ``{"status": "ok"}``. + +Error mapping (every children, quest-definition and presence route, +deliberately narrow): - Malformed input the body model or the path parser rejects (a missing or wrong-typed field, a non-integer child or definition id) is @@ -55,10 +79,21 @@ narrow): ``rule_type``, a shape-missing field such as a weekly rule without weekdays, a malformed date, a forbidden field), a bad window name or due time, a rejected window or assignee list (empty, duplicate, - wrong-typed) — is mapped to 422: the body was well-formed JSON but + wrong-typed); a rejected presence schedule or override (bad cycle + length, a pattern not covering the cycle's weeks, a malformed or + inverted date range, an override overlapping the same child's + existing one) — is mapped to 422: the body was well-formed JSON but semantically invalid, the same failure class Pydantic reports. The core rejects these BEFORE any write, so a 422 never leaves a half-applied change behind. +- A non-existent CHILD on a presence route (setting a schedule or + creating an override for an unknown id) is mapped to 404 through the + SAME :func:`_raise_child_error` the children routes use — the + message reads ``child N does not exist``. A non-existent OVERRIDE + on the delete route is mapped to 404 through + :func:`_raise_presence_override_error`, keyed on the + ``override_id:`` prefix the core's unknown-id error carries (the + same convention as the quest-definition 404). PATCH edit semantics: the body model's fields are optional and only SUPPLIED fields are forwarded (``model_dump(exclude_unset=True)``), so @@ -78,6 +113,8 @@ from api.auth import require_admin from api.database import DatabaseState from api.nestquest_core import ( core_children, + core_presence, + core_presence_management, core_quest_definitions, core_recurrence, ) @@ -294,6 +331,70 @@ class AdminQuestDefinitionActiveRequest(BaseModel): is_active: bool +class AdminPresenceScheduleRequest(BaseModel): + """The body of ``PUT /api/v1/admin/children/{child_id}/presence-schedule``. + + The shape of :class:`~nestquest_core.presence.PresenceSchedule` — + nothing more. ``pattern`` maps each cycle week index (JSON object + keys, coerced to ints by the model) to that week's present weekdays + (Monday=0); every week of the cycle must be covered, which the + PRESENCE MODEL enforces at construction, not this shape. A week + with an empty list means absent every day of that week. + """ + + cycle_length_weeks: int + anchor_date: str + pattern: dict[int, list[int]] + + +class AdminPresenceScheduleResponse(BaseModel): + """A child's repeating presence schedule as the admin plane serializes it. + + The stored schedule decoded back through + :meth:`nestquest_core.presence.PresenceSchedule.decode`: each + pattern week is the sorted list of present weekdays, and the child + has exactly ONE schedule (setting again replaces it). + """ + + child_id: int + cycle_length_weeks: int + anchor_date: str + pattern: dict[int, list[int]] + + +class AdminPresenceOverrideCreateRequest(BaseModel): + """The body of ``POST /api/v1/admin/presence-overrides``. + + The shape of :class:`~nestquest_core.presence.PresenceOverride` — + the strict ISO dates, the ``end_date >= start_date`` order, the + real ``is_present`` bool and the optional note are ALL enforced by + the model's constructor, which the route builds directly from this + body; this model only guards the SHAPE. + """ + + child_id: int + start_date: str + end_date: str + is_present: bool + note: str | None = None + + +class AdminPresenceOverrideResponse(BaseModel): + """One presence override as the admin plane serializes it. + + Mirrors + :class:`~nestquest_core.dao_presence.PresenceOverrideRecord` — the + ``id`` is the handle the DELETE route takes. + """ + + id: int + child_id: int + start_date: str + end_date: str + is_present: bool + note: str | None + + #: 404 detail for a child id the core layer reports as non-existent. _CHILD_NOT_FOUND_DETAIL = "Child not found" @@ -355,6 +456,28 @@ def _raise_quest_definition_error(error: ValueError) -> NoReturn: raise HTTPException(status_code=422, detail=str(error)) from error +#: 404 detail for an override id the core layer reports as non-existent. +_PRESENCE_OVERRIDE_NOT_FOUND_DETAIL = "Presence override not found" + + +def _raise_presence_override_error(error: ValueError) -> NoReturn: + """Map a core presence ``ValueError`` from the DELETE route onto 404/422. + + The mapping mirrors :func:`_raise_quest_definition_error`: the core + names a missing override by prefixing its error with + ``override_id:`` (an unknown id), and that one case is this route's + 404. Every other ``ValueError`` — a non-integer id the path + parser's ``int`` already makes unreachable — becomes 422. The + schedule and override-CREATE routes do NOT use this mapper: their + only 404 is an unknown CHILD, mapped by :func:`_raise_child_error`. + """ + if str(error).startswith("override_id:"): + raise HTTPException( + status_code=404, detail=_PRESENCE_OVERRIDE_NOT_FOUND_DETAIL + ) from error + raise HTTPException(status_code=422, detail=str(error)) from error + + def _rule_from_request(rule: AdminRuleRequest) -> core_recurrence.ScheduleRule: """Build the core ScheduleRule from the request's ``rule`` object. @@ -398,6 +521,34 @@ def _quest_definition_response( ) +def _presence_schedule_response( + schedule: core_presence.PresenceSchedule, +) -> AdminPresenceScheduleResponse: + """Serialize one decoded core PresenceSchedule into the payload.""" + return AdminPresenceScheduleResponse( + child_id=schedule.child_id, + cycle_length_weeks=schedule.cycle_length_weeks, + anchor_date=schedule.anchor_date.isoformat(), + pattern={ + week: sorted(days) for week, days in schedule.pattern.items() + }, + ) + + +def _presence_override_response( + record: core_presence_management.PresenceOverrideRecord, +) -> AdminPresenceOverrideResponse: + """Serialize one core PresenceOverrideRecord into the payload.""" + return AdminPresenceOverrideResponse( + id=record.id, + child_id=record.child_id, + start_date=record.start_date, + end_date=record.end_date, + is_present=record.is_present, + note=record.note, + ) + + @router.get("/ping", summary="Admin plane probe") async def admin_ping( claims: Annotated[dict[str, object], Depends(require_admin)], @@ -681,4 +832,114 @@ async def admin_set_quest_definition_active( return _quest_definition_response(bundle) +@router.put( + "/children/{child_id}/presence-schedule", + summary="Set a child's repeating presence schedule", + response_model=AdminPresenceScheduleResponse, +) +async def admin_set_presence_schedule( + child_id: int, body: AdminPresenceScheduleRequest, request: Request +) -> AdminPresenceScheduleResponse: + """Set (upsert) a child's repeating presence schedule. + + 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 model + carries the shape, and the cycle-length, anchor-date and + pattern-coverage policies all live in the + :class:`~nestquest_core.presence.PresenceSchedule` constructor the + core builds the schedule with — setting again REPLACES the child's + one schedule, and the core regenerates the child's future instances + so they track the new presence. Errors are mapped by + :func:`_raise_child_error`: an unknown child is 404, a rejected + schedule 422. + """ + state: DatabaseState = request.app.state.db + try: + schedule = await core_presence_management.set_presence_schedule( + state.database, + child_id, + body.cycle_length_weeks, + body.anchor_date, + body.pattern, + ) + except ValueError as error: + _raise_child_error(error) + return _presence_schedule_response(schedule) + + +@router.post( + "/presence-overrides", + summary="Create a date-range presence override", + status_code=201, + response_model=AdminPresenceOverrideResponse, +) +async def admin_create_presence_override( + body: AdminPresenceOverrideCreateRequest, request: Request +) -> AdminPresenceOverrideResponse: + """Create one date-range presence override and return it with its id. + + 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 model + carries the shape, and the date/order/bool/note policies live in + the :class:`~nestquest_core.presence.PresenceOverride` constructor + while the same-child no-overlap policy lives in the core's create + path — which then regenerates the child's future instances so an + absent range removes them and a present range restores them. The + response carries the stored ``id``, the handle + ``DELETE /presence-overrides/{id}`` takes. + + Errors are mapped by :func:`_raise_child_error`: an unknown child + is 404; a rejected override (malformed or inverted dates, an + overlapping range) is 422. + """ + state: DatabaseState = request.app.state.db + try: + record = await core_presence_management.create_presence_override( + state.database, + body.child_id, + body.start_date, + body.end_date, + body.is_present, + note=body.note, + ) + except ValueError as error: + _raise_child_error(error) + return _presence_override_response(record) + + +@router.delete( + "/presence-overrides/{override_id}", + summary="Delete a presence override", +) +async def admin_delete_presence_override( + override_id: int, request: Request +) -> dict[str, str]: + """Delete one presence override; children are never hard-deleted. + + 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 id goes + straight to + :func:`nestquest_core.presence_management.delete_presence_override`, + which looks the override up first (an unknown id is refused BEFORE + any delete), removes it and regenerates ITS child's future + instances — the id, never a caller-supplied child, decides which + child's presence is rebuilt. On success the route answers + ``{"status": "ok"}``. + + Error mapping (:func:`_raise_presence_override_error`): an unknown + override id is 404; every other core ``ValueError`` is 422. + """ + state: DatabaseState = request.app.state.db + try: + await core_presence_management.delete_presence_override( + state.database, override_id + ) + except ValueError as error: + _raise_presence_override_error(error) + return {"status": "ok"} + + __all__ = ["router"] diff --git a/custom_components/nestquest/core/presence_management.py b/custom_components/nestquest/core/presence_management.py new file mode 100644 index 0000000..2b48edb --- /dev/null +++ b/custom_components/nestquest/core/presence_management.py @@ -0,0 +1,161 @@ +"""Business layer for presence writes (Feature 09, API admin plane). + +Sits between the write callers (the Feature 09 HA service handlers and +the API admin plane's presence routes) and the typed presence DAOs plus +the pure presence model: callers get the model validation, the upsert / +create / delete operations and the regeneration side effect here, and +never touch the DAO or SQL directly. This is the ONE implementation of +these business rules — every caller constructs the same +:class:`~.presence.PresenceSchedule` / :class:`~.presence.PresenceOverride` +models (whose constructors are total, so no re-validation happens in a +handler) and gets the same post-write +:func:`~.materialize.regenerate_for_child`, so a child's future quest +instances track its changed presence no matter which plane issued the +write. Like the model it is Home-Assistant-free: ``today`` is a plain +calendar date threaded through to the regeneration, defaulting to the +host clock there. + +Upsert policy (Feature 05, unchanged): a child has AT MOST ONE presence +schedule — setting again REPLACES the existing row rather than +duplicating it. Overrides are deletable by design (children are not — +the children layer owns that guardrail). Deleting a nonexistent +override raises ValueError prefixed ``override_id:`` so a caller can +distinguish "unknown id" from a rejected argument the same way the +quest-definitions layer prefixes ``definition_id:``. +""" +from __future__ import annotations + +import datetime + +from .dao_presence import ( + PresenceOverrideRecord, + PresenceOverridesDao, + PresenceSchedulesDao, +) +from .db import NestQuestDatabase +from .materialize import regenerate_for_child +from .presence import PresenceOverride, PresenceSchedule + + +def _validate_override_id(value: object) -> int: + """Reject non-int override ids (bools would silently address id 1).""" + if isinstance(value, bool) or not isinstance(value, int): + raise ValueError(f"override_id must be an integer, got {value!r}") + return value + + +async def set_presence_schedule( + database: NestQuestDatabase, + child_id: int, + cycle_length_weeks: int, + anchor_date: object, + pattern: dict[int, object], + *, + today: datetime.date | None = None, + horizon_days: int | None = None, +) -> PresenceSchedule: + """Set (upsert) a child's repeating presence schedule. + + The :class:`~.presence.PresenceSchedule` model validates at + construction — cycle length 1..4, a strict calendar anchor date, + and a pattern covering week indices 0..n-1 with weekday sets — so + no invalid schedule reaches the DAO. The encoded pattern is + UPSERTed by child (a second set REPLACES the child's existing + schedule; the schema's UNIQUE(child_id) makes duplication + unrepresentable), then :func:`~.materialize.regenerate_for_child` + re-materializes the child's future instances against the new + presence. ``today``/``horizon_days`` thread through to that + regeneration; raising ValueError names what to fix (an unknown + child reads ``child N does not exist``). + + Returns the STORED schedule decoded back through the model — the + round-trip proves the encoded pattern the database now holds is + exactly the one the caller asked for. + """ + schedule = PresenceSchedule( + child_id, cycle_length_weeks, anchor_date, pattern + ) + record = await PresenceSchedulesDao(database).upsert_by_child( + schedule.child_id, + schedule.cycle_length_weeks, + schedule.anchor_date.isoformat(), + schedule.encode(), + ) + await regenerate_for_child( + database, schedule.child_id, today=today, horizon_days=horizon_days + ) + return PresenceSchedule.decode( + record.child_id, record.anchor_date, record.pattern + ) + + +async def create_presence_override( + database: NestQuestDatabase, + child_id: int, + start_date: object, + end_date: object, + is_present: bool, + note: str | None = None, + *, + today: datetime.date | None = None, + horizon_days: int | None = None, +) -> PresenceOverrideRecord: + """Create one date-range presence override and return it as stored. + + The :class:`~.presence.PresenceOverride` model validates at + construction — strict calendar dates, ``end_date`` on or after + ``start_date``, a real ``is_present`` bool, a string-or-None note — + then the DAO insert applies the same-child no-overlap policy (a + conflicting range raises ValueError naming the existing override) + and :func:`~.materialize.regenerate_for_child` re-materializes the + child's future instances against the override's presence answer. + An unknown child reads ``child N does not exist``. + + Returns the stored record WITH its ``id`` — the handle a later + :func:`delete_presence_override` takes. + """ + override = PresenceOverride( + child_id, start_date, end_date, is_present, note=note + ) + record = await PresenceOverridesDao(database).create( + override.child_id, + override.start_date.isoformat(), + override.end_date.isoformat(), + override.is_present, + note=override.note, + ) + await regenerate_for_child( + database, override.child_id, today=today, horizon_days=horizon_days + ) + return record + + +async def delete_presence_override( + database: NestQuestDatabase, + override_id: int, + *, + today: datetime.date | None = None, + horizon_days: int | None = None, +) -> PresenceOverrideRecord: + """Delete one presence override by id and return it as it was stored. + + The override is looked up FIRST and an unknown id raises ValueError + prefixed ``override_id:`` (naming the id) BEFORE any delete, so a + mistyped id can never be silently swallowed by a no-op. The + deletion is followed by :func:`~.materialize.regenerate_for_child` + for the override's own child — the id decides the child, never a + caller-supplied one. ``today``/``horizon_days`` thread through to + that regeneration. + """ + _validate_override_id(override_id) + dao = PresenceOverridesDao(database) + override = await dao.get(override_id) + if override is None: + raise ValueError( + f"override_id: presence override {override_id} does not exist" + ) + await dao.delete(override_id) + await regenerate_for_child( + database, override.child_id, today=today, horizon_days=horizon_days + ) + return override diff --git a/custom_components/nestquest/presence_management.py b/custom_components/nestquest/presence_management.py new file mode 100644 index 0000000..92f89be --- /dev/null +++ b/custom_components/nestquest/presence_management.py @@ -0,0 +1,10 @@ +"""Re-export of :mod:`custom_components.nestquest.core.presence_management` for the integration package. + +The presence-write business layer lives in +:mod:`custom_components.nestquest.core.presence_management` (added for +task e4a9d31b); this module re-exports it so the integration's service +handlers call the ONE implementation the API admin plane shares. +""" +from __future__ import annotations + +from .core.presence_management import * # noqa: F401,F403 diff --git a/custom_components/nestquest/services.py b/custom_components/nestquest/services.py index 2ce49dd..d5606c7 100644 --- a/custom_components/nestquest/services.py +++ b/custom_components/nestquest/services.py @@ -4,9 +4,11 @@ Registration is domain-scoped and happens once (see :func:`async_register_services`); handlers resolve a live database at call time through the injected ``find_runtime`` callable. Permission checks are Feature 09 later tasks — this module only validates payload -shape and forwards to the Feature 03/06/08 business layers (and the -presence DAOs). ``export_history_csv`` is registered with a schema but -raises until Feature 13. ``regenerate`` is supplied by the caller so +shape and forwards to the Feature 03/06/08 business layers (the +presence writes included, through :mod:`.presence_management` — the +ONE implementation the API admin plane shares). +``export_history_csv`` is registered with a schema but raises until +Feature 13. ``regenerate`` is supplied by the caller so the Feature 07 handler stays the one in ``__init__``. """ from __future__ import annotations @@ -22,6 +24,7 @@ from homeassistant.exceptions import HomeAssistantError from . import children as children_layer from . import completion as completion_layer +from . import presence_management as presence_management_layer from . import quest_definitions as quest_definitions_layer from .const import ( DOMAIN, @@ -39,11 +42,8 @@ from .const import ( SERVICE_UNCOMPLETE_QUEST, SERVICE_UPDATE_QUEST_DEFINITION, ) -from .dao_presence import PresenceOverridesDao, PresenceSchedulesDao from .events import fire_quest_completed, fire_quest_uncompleted -from .materialize import regenerate_for_child from .permissions import permission_gate -from .presence import PresenceSchedule from .recurrence import ScheduleRule from .service_policy import SERVICE_POLICY @@ -405,21 +405,12 @@ def _set_presence_pattern( async def _handler(call: Any) -> None: runtime = _require_runtime(hass, find_runtime) data = call.data - schedule = PresenceSchedule( + await presence_management_layer.set_presence_schedule( + runtime.database, data["child_id"], data["cycle_length_weeks"], data["anchor_date"], data["pattern"], - ) - await PresenceSchedulesDao(runtime.database).upsert_by_child( - schedule.child_id, - schedule.cycle_length_weeks, - schedule.anchor_date.isoformat(), - schedule.encode(), - ) - await regenerate_for_child( - runtime.database, - schedule.child_id, **_generation_kwargs(hass, runtime), ) @@ -432,16 +423,13 @@ def _create_presence_override( async def _handler(call: Any) -> None: runtime = _require_runtime(hass, find_runtime) data = call.data - await PresenceOverridesDao(runtime.database).create( + await presence_management_layer.create_presence_override( + runtime.database, data["child_id"], data["start_date"], data["end_date"], data["is_present"], note=data.get("note"), - ) - await regenerate_for_child( - runtime.database, - data["child_id"], **_generation_kwargs(hass, runtime), ) @@ -453,17 +441,9 @@ def _delete_presence_override( ) -> ServiceHandler: async def _handler(call: Any) -> None: runtime = _require_runtime(hass, find_runtime) - dao = PresenceOverridesDao(runtime.database) - override_id = call.data["override_id"] - override = await dao.get(override_id) - if override is None: - raise HomeAssistantError( - f"override_id: presence override {override_id} does not exist" - ) - await dao.delete(override_id) - await regenerate_for_child( + await presence_management_layer.delete_presence_override( runtime.database, - override.child_id, + call.data["override_id"], **_generation_kwargs(hass, runtime), ) diff --git a/tests/test_api_admin_presence.py b/tests/test_api_admin_presence.py new file mode 100644 index 0000000..e86b878 --- /dev/null +++ b/tests/test_api_admin_presence.py @@ -0,0 +1,571 @@ +"""Admin-plane presence route tests (task e4a9d31b). + +These tests exercise the admin plane's presence 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. + +Every done-condition case is proven against the DATABASE and the DOMAIN, +read through the SAME core copy the app uses (``nestquest_core.*``; see +the two-copy caveat in api/nestquest_core.py): + +- Authentication runs through the router's ONE ``require_admin`` + dependency: an admin JWT passes, a JWT without the group is 403, an + absent credential is 401 — and nothing is written. +- ``PUT /api/v1/admin/children/{id}/presence-schedule`` UPSERTS: the + first set persists the schedule, setting again REPLACES it — one + schedule row per child, never a duplicate. +- ``POST /api/v1/admin/presence-overrides`` creates the override (its + ``id`` comes back) and ``DELETE /api/v1/admin/presence-overrides/{id}`` + removes it again. +- The child's RESOLVED presence changes accordingly: the pure + :class:`~nestquest_core.presence.PresenceEngine` built from the + stored rows answers differently before/after each write — a schedule + restricts the child's present weekdays, an override beats the pattern + for its range, and deleting the override restores the pattern's + answer. +- The regeneration side effect lives in the core layer the route + calls: setting an all-absent schedule through the route deletes the + child's future open quest instances and they are NOT rebuilt while + the child is absent every day — the same proof the HA service test + makes, through the API instead. + +Error mapping (documented in api/routes_admin.py): a core ValueError +for a non-existent child (schedule or override create) or override +(delete) is 404; every other core ValueError — a rejected schedule +(cycle length, pattern coverage, anchor date) or override (malformed or +inverted dates, an overlapping range) — is 422, the same failure class +FastAPI reports for malformed bodies. +""" +from __future__ import annotations + +import datetime +import importlib +from types import SimpleNamespace + +import pytest + +from tests.admin_jwt_harness import ( + ADMIN_KEY, + KID, + AdminRunner, + jwks_for, + make_token, +) + +# The presence model, DAOs, business layer and materializer through the +# SAME core copy the app uses (nestquest_core.*, NOT the conftest-stubbed +# custom_components copy) — used for the tests' database and resolved- +# presence assertions only; the routes reach them through api.nestquest_core. +_children = importlib.import_module("nestquest_core.children") +_dao_presence = importlib.import_module("nestquest_core.dao_presence") +_dao_instances = importlib.import_module("nestquest_core.dao_instances") +_materialize = importlib.import_module("nestquest_core.materialize") +_presence = importlib.import_module("nestquest_core.presence") + +#: A Monday anchoring every schedule: cycle week 0 starts here. +ANCHOR_MONDAY = "2026-01-05" +#: The Tuesday of that week — absent under the test schedule. +TUESDAY = "2026-01-06" + + +def _admin_headers() -> dict[str, str]: + """The Authorization header of a valid nestquest-admins JWT.""" + return {"Authorization": f"Bearer {make_token()}"} + + +def _schedule_body( + cycle_length_weeks: int = 1, pattern: dict[int, list[int]] | None = None +) -> dict[str, object]: + """A valid schedule body anchored to the test Monday.""" + return { + "cycle_length_weeks": cycle_length_weeks, + "anchor_date": ANCHOR_MONDAY, + "pattern": {"0": [0, 2, 4]} if pattern is None else pattern, + } + + +def _override_body( + child_id: int, + *, + start_date: str = TUESDAY, + end_date: str = TUESDAY, + is_present: bool = True, +) -> dict[str, object]: + """A valid single-day override body for the test child.""" + return { + "child_id": child_id, + "start_date": start_date, + "end_date": end_date, + "is_present": is_present, + "note": "grandmother's week", + } + + +async def _create_child(client: object, name: str = "Ada") -> int: + """Create one child through the admin children route; return the id.""" + created = await client.post( + "/api/v1/admin/children", + json={"display_name": name}, + headers=_admin_headers(), + ) + assert created.status_code == 201 + return created.json()["id"] + + +def _engine( + database: object, + child_id: int, + schedule_record: object | None, + override_records: list[object], +) -> object: + """Build the pure PresenceEngine from stored rows (the snapshot shape). + + Mirrors what the core snapshot builder assembles: the child's + decoded schedule (absent → present every day) plus its overrides, + so :meth:`PresenceEngine.is_present` answers from the SAME rows the + routes produced. + """ + schedules: dict[int, object] = {} + if schedule_record is not None: + schedules[child_id] = _presence.PresenceSchedule.decode( + schedule_record.child_id, + schedule_record.anchor_date, + schedule_record.pattern, + ) + overrides = { + child_id: [ + _presence.PresenceOverride( + record.child_id, + record.start_date, + record.end_date, + record.is_present, + note=record.note, + ) + for record in override_records + ] + } + return _presence.PresenceEngine(schedules, overrides) + + +async def _child_state( + database: object, child_id: int +) -> tuple[object | None, list[object], object]: + """The child's stored schedule record, overrides and PresenceEngine.""" + schedule_record = await _dao_presence.PresenceSchedulesDao( + database + ).get_by_child(child_id) + override_records = await _dao_presence.PresenceOverridesDao( + database + ).list_by_child_and_range(child_id, "2020-01-01", "2030-01-01") + engine = _engine(database, child_id, schedule_record, override_records) + return schedule_record, override_records, engine + + +@pytest.fixture +async def admin_presence(temp_db_path: str) -> SimpleNamespace: + """An admin-plane app client plus its live database. + + The same runner the auth, children and quest-definition tests use + (stubbed JWKS, no network); the fixture also exposes the app's ONE + database connection so tests assert the routes' effects on the + stored rows and the resolved presence built from them. + """ + 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, + ) + + +# --- authentication: the ONE require_admin dependency guards every route --- + + +async def test_presence_routes_refuse_non_admin_and_absent_credentials( + admin_presence: SimpleNamespace, +) -> None: + """A non-admin JWT is 403, an absent credential is 401: nothing written.""" + client = admin_presence.client + child_id = await _create_child(client) + non_admin = make_token(groups=("some-other-group",)) + non_admin_headers = {"Authorization": f"Bearer {non_admin}"} + + forbidden_schedule = await client.put( + f"/api/v1/admin/children/{child_id}/presence-schedule", + json=_schedule_body(), + headers=non_admin_headers, + ) + assert forbidden_schedule.status_code == 403 + assert forbidden_schedule.json()["detail"] == ( + "Admin access requires membership in the nestquest-admins group" + ) + + forbidden_override = await client.post( + "/api/v1/admin/presence-overrides", + json=_override_body(child_id), + headers=non_admin_headers, + ) + assert forbidden_override.status_code == 403 + + forbidden_delete = await client.delete( + "/api/v1/admin/presence-overrides/1", headers=non_admin_headers + ) + assert forbidden_delete.status_code == 403 + + unauthenticated_schedule = await client.put( + f"/api/v1/admin/children/{child_id}/presence-schedule", + json=_schedule_body(), + ) + assert unauthenticated_schedule.status_code == 401 + + unauthenticated_override = await client.post( + "/api/v1/admin/presence-overrides", json=_override_body(child_id) + ) + assert unauthenticated_override.status_code == 401 + + unauthenticated_delete = await client.delete( + "/api/v1/admin/presence-overrides/1" + ) + assert unauthenticated_delete.status_code == 401 + + # No route ran its handler: the child still has no schedule and no + # overrides (and is present every day, the no-schedule default). + _schedule, _overrides, engine = await _child_state( + admin_presence.database, child_id + ) + assert _schedule is None + assert _overrides == [] + assert engine.is_present(child_id, ANCHOR_MONDAY) is True + + +# --- schedule upsert -------------------------------------------------------- + + +async def test_schedule_upserts_and_replaces_one_row_per_child( + admin_presence: SimpleNamespace, +) -> None: + """PUT /presence-schedule persists, and setting again REPLACES it.""" + client = admin_presence.client + child_id = await _create_child(client) + + first = await client.put( + f"/api/v1/admin/children/{child_id}/presence-schedule", + json=_schedule_body(), + headers=_admin_headers(), + ) + assert first.status_code == 200 + assert first.json() == { + "child_id": child_id, + "cycle_length_weeks": 1, + "anchor_date": ANCHOR_MONDAY, + "pattern": {"0": [0, 2, 4]}, + } + # Database proof: exactly ONE schedule row (the schema's + # UNIQUE(child_id) makes a second row unrepresentable), stored as + # encoded CSV. + schedule, _overrides, _engine = await _child_state( + admin_presence.database, child_id + ) + assert schedule is not None + row_id = schedule.id + assert schedule.cycle_length_weeks == 1 + assert schedule.anchor_date == ANCHOR_MONDAY + assert schedule.pattern == "0,2,4" + + # Setting again UPSERTS: the SAME row is updated in place — new + # values, unchanged id — never a duplicate or a stale first set. + second = await client.put( + f"/api/v1/admin/children/{child_id}/presence-schedule", + json=_schedule_body( + cycle_length_weeks=2, pattern={"0": [], "1": [1, 3]} + ), + headers=_admin_headers(), + ) + assert second.status_code == 200 + assert second.json() == { + "child_id": child_id, + "cycle_length_weeks": 2, + "anchor_date": ANCHOR_MONDAY, + "pattern": {"0": [], "1": [1, 3]}, + } + schedule, _overrides, _engine = await _child_state( + admin_presence.database, child_id + ) + assert schedule is not None + assert schedule.id == row_id + assert schedule.cycle_length_weeks == 2 + assert schedule.pattern == "|1,3" + + +async def test_schedule_changes_the_childs_resolved_presence( + admin_presence: SimpleNamespace, +) -> None: + """The resolved presence (PresenceEngine) follows the stored schedule.""" + client = admin_presence.client + child_id = await _create_child(client) + + # Before any schedule the child is present every day. + _schedule, _overrides, engine = await _child_state( + admin_presence.database, child_id + ) + assert engine.is_present(child_id, ANCHOR_MONDAY) is True + assert engine.is_present(child_id, TUESDAY) is True + + # Mon/Wed/Fri only, week 0 of a one-week cycle. + response = await client.put( + f"/api/v1/admin/children/{child_id}/presence-schedule", + json=_schedule_body(), + headers=_admin_headers(), + ) + assert response.status_code == 200 + _schedule, _overrides, engine = await _child_state( + admin_presence.database, child_id + ) + assert engine.is_present(child_id, ANCHOR_MONDAY) is True + assert engine.is_present(child_id, TUESDAY) is False + + +async def test_schedule_upsert_regenerates_future_instances( + admin_presence: SimpleNamespace, +) -> None: + """Setting an all-absent schedule deletes the child's future instances. + + The regeneration side effect lives in the core layer the route + calls: the child's open instances at or after today are deleted + across ALL its definitions and NOT rebuilt while the child is + absent every day — the same proof the HA service test makes, + exercised here through the API route instead. + """ + client = admin_presence.client + database = admin_presence.database + child_id = await _create_child(client) + created = await client.post( + "/api/v1/admin/quest-definitions", + json={ + "title": "Brush teeth", + "rule": {"rule_type": "daily"}, + "assignee_child_ids": [child_id], + "windows": ["morning"], + }, + headers=_admin_headers(), + ) + assert created.status_code == 201 + + today = datetime.date.today() + start = today.isoformat() + end = (today + datetime.timedelta(days=14)).isoformat() + await _materialize.materialize(database, start, end, today=today) + instances = _dao_instances.QuestInstancesDao(database) + assert await instances.list_by_date_range(child_id, start, end) + + # The route's write must regenerate: absent every day removes every + # open future instance. + response = await client.put( + f"/api/v1/admin/children/{child_id}/presence-schedule", + json=_schedule_body(pattern={"0": []}), + headers=_admin_headers(), + ) + assert response.status_code == 200 + assert await instances.list_by_date_range(child_id, start, end) == [] + + +# --- override create + delete ----------------------------------------------- + + +async def test_override_is_created_then_deleted_and_presence_resolves( + admin_presence: SimpleNamespace, +) -> None: + """POST creates the override, DELETE removes it; presence follows.""" + client = admin_presence.client + database = admin_presence.database + child_id = await _create_child(client) + schedule_response = await client.put( + f"/api/v1/admin/children/{child_id}/presence-schedule", + json=_schedule_body(), + headers=_admin_headers(), + ) + assert schedule_response.status_code == 200 + + # Create: the override comes back with its id and the posted shape. + created = await client.post( + "/api/v1/admin/presence-overrides", + json=_override_body(child_id), + headers=_admin_headers(), + ) + assert created.status_code == 201 + payload = created.json() + assert set(payload) == { + "id", + "child_id", + "start_date", + "end_date", + "is_present", + "note", + } + assert payload["child_id"] == child_id + assert payload["start_date"] == TUESDAY + assert payload["end_date"] == TUESDAY + assert payload["is_present"] is True + assert payload["note"] == "grandmother's week" + + # Database proof: the row exists — and the RESOLVED presence for the + # affected date flips: the override beats the Tuesday-absent pattern. + _schedule, overrides, engine = await _child_state(database, child_id) + assert [record.id for record in overrides] == [payload["id"]] + assert engine.is_present(child_id, TUESDAY) is True + # A date the override does not cover keeps the pattern's answer. + assert engine.is_present(child_id, ANCHOR_MONDAY) is True + + # Delete: the row is gone and the resolved presence follows the + # pattern again. + deleted = await client.delete( + f"/api/v1/admin/presence-overrides/{payload['id']}", + headers=_admin_headers(), + ) + assert deleted.status_code == 200 + assert deleted.json() == {"status": "ok"} + assert ( + await _dao_presence.PresenceOverridesDao(database).get(payload["id"]) + is None + ) + _schedule, overrides, engine = await _child_state(database, child_id) + assert overrides == [] + assert engine.is_present(child_id, TUESDAY) is False + + +# --- error mapping: 404 vs 422 ---------------------------------------------- + + +async def test_schedule_and_override_for_unknown_child_are_404( + admin_presence: SimpleNamespace, +) -> None: + """A presence write naming a child that does not exist is 404.""" + client = admin_presence.client + + schedule = await client.put( + "/api/v1/admin/children/999/presence-schedule", + json=_schedule_body(), + headers=_admin_headers(), + ) + assert schedule.status_code == 404 + assert schedule.json()["detail"] == "Child not found" + + override = await client.post( + "/api/v1/admin/presence-overrides", + json=_override_body(999), + headers=_admin_headers(), + ) + assert override.status_code == 404 + assert override.json()["detail"] == "Child not found" + + +@pytest.mark.parametrize( + "body", + [ + _schedule_body(cycle_length_weeks=0), # below the 1..4 range + _schedule_body(cycle_length_weeks=5), # above the documented cap + # week 1 of the cycle missing: + _schedule_body(cycle_length_weeks=2, pattern={"0": [0]}), + { + "cycle_length_weeks": 1, + "anchor_date": "2026-13-40", + "pattern": {"0": [0]}, + }, + _schedule_body(pattern={"0": [7]}), # weekday out of 0..6 + _schedule_body(pattern={"nope": [0]}), # week index not an integer + _schedule_body(pattern={"0": "0,2,4"}), # weekdays not a list + ], +) +async def test_rejected_schedule_is_422( + admin_presence: SimpleNamespace, body: dict[str, object] +) -> None: + """A rejected schedule is 422 and stores nothing.""" + client = admin_presence.client + child_id = await _create_child(client) + response = await client.put( + f"/api/v1/admin/children/{child_id}/presence-schedule", + json=body, + headers=_admin_headers(), + ) + assert response.status_code == 422 + assert ( + await _dao_presence.PresenceSchedulesDao( + admin_presence.database + ).get_by_child(child_id) + is None + ) + + +@pytest.mark.parametrize( + "body", + [ + _override_body(1, start_date="2026-01-07", end_date="2026-01-06"), + _override_body(1, start_date="2026-02-30", end_date="2026-02-30"), + dict(_override_body(1), note=5), + dict(_override_body(1), child_id="one"), + ], +) +async def test_rejected_override_is_422( + admin_presence: SimpleNamespace, body: dict[str, object] +) -> None: + """A rejected override (inverted/malformed dates, bad note/id) is 422. + + The shape failures (a non-string note, a non-integer child id) are + the body model's; the date failures are the PresenceOverride + model's. A string ``is_present`` like ``"yes"`` is deliberately + absent: Pydantic's lax bool COERCES it to ``True`` (the same design + as the children active route), so it never reaches the model's + real-bool check. + """ + response = await admin_presence.client.post( + "/api/v1/admin/presence-overrides", json=body, headers=_admin_headers() + ) + assert response.status_code == 422 + + +async def test_overlapping_override_is_422_and_first_survives( + admin_presence: SimpleNamespace, +) -> None: + """An override overlapping the child's existing one is 422. + + The conflicting write persists nothing: the child's original + override survives unchanged. + """ + client = admin_presence.client + child_id = await _create_child(client) + created = await client.post( + "/api/v1/admin/presence-overrides", + json=_override_body(child_id), + headers=_admin_headers(), + ) + assert created.status_code == 201 + + response = await client.post( + "/api/v1/admin/presence-overrides", + json=_override_body(child_id, is_present=False), + headers=_admin_headers(), + ) + assert response.status_code == 422 + _schedule, overrides, _engine = await _child_state( + admin_presence.database, child_id + ) + assert [record.id for record in overrides] == [created.json()["id"]] + + +async def test_delete_unknown_override_is_404( + admin_presence: SimpleNamespace, +) -> None: + """Deleting an override id that does not exist is 404.""" + response = await admin_presence.client.delete( + "/api/v1/admin/presence-overrides/999", headers=_admin_headers() + ) + assert response.status_code == 404 + assert response.json()["detail"] == "Presence override not found" + + malformed = await admin_presence.client.delete( + "/api/v1/admin/presence-overrides/not-an-id", + headers=_admin_headers(), + ) + assert malformed.status_code == 422 -- 2.55.0 From f31f6dc80e47ae304393a92699fad4d52e274b52 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 02:43:45 -0400 Subject: [PATCH 12/15] Admin routes: uncomplete and regenerate (#161) --- api/nestquest_core.py | 25 ++ api/routes_admin.py | 356 ++++++++++++++- tests/test_api_admin_uncomplete.py | 694 +++++++++++++++++++++++++++++ 3 files changed, 1073 insertions(+), 2 deletions(-) create mode 100644 tests/test_api_admin_uncomplete.py diff --git a/api/nestquest_core.py b/api/nestquest_core.py index af50cd2..b327200 100644 --- a/api/nestquest_core.py +++ b/api/nestquest_core.py @@ -161,13 +161,38 @@ core_dao_presence = importlib.import_module( f"{_CORE_MODULE_NAME}.dao_presence" ) +#: The materialization walk (``core.materialize.materialize``, +#: ``regenerate_for_definition``, ``regenerate_for_child``) the admin +#: plane's regenerate route calls — the same walk the integration's +#: config/presence changes trigger. +core_materialize = importlib.import_module(f"{_CORE_MODULE_NAME}.materialize") + +#: The core constants module (``core.const.DEFAULT_HORIZON_DAYS``) the +#: regenerate route sizes the household rolling horizon with. +core_const = importlib.import_module(f"{_CORE_MODULE_NAME}.const") + +#: The typed child DAO (``core.dao_children.ChildrenDao.get``) the +#: regenerate route reads a child's existence with before delegating. +core_dao_children = importlib.import_module( + f"{_CORE_MODULE_NAME}.dao_children" +) + +#: The typed definition DAO (``core.dao_rules.QuestDefinitionsDao.get``) +#: the regenerate route reads a definition's existence with before +#: delegating. +core_dao_rules = importlib.import_module(f"{_CORE_MODULE_NAME}.dao_rules") + __all__ = [ "core", "core_children", "core_completion", + "core_const", + "core_dao_children", "core_dao_presence", + "core_dao_rules", "core_db", "core_events", + "core_materialize", "core_migrations", "core_presence", "core_presence_management", diff --git a/api/routes_admin.py b/api/routes_admin.py index babc316..8f26e34 100644 --- a/api/routes_admin.py +++ b/api/routes_admin.py @@ -59,6 +59,39 @@ layer the HA services share, so the two planes cannot drift: design) and regenerates that override's child. It answers ``{"status": "ok"}``. +The uncomplete and regenerate routes (task da0226b3) follow the same +pattern over :mod:`nestquest_core.completion` and +:mod:`nestquest_core.materialize`: + +- ``POST /instances/{instance_id}/uncomplete`` reverses ONE completion: + the handler takes the actor from the VERIFIED JWT (``actor_source`` + ``'user'`` with ``actor_user_id`` = the token's ``sub`` — the D-008 + user-actor shape, so the reversal is attributed to the admin who + called it) and calls :func:`nestquest_core.completion.uncomplete_instance`. + The append-only discipline lives in the core: a reversal APPENDS an + ``uncompleted`` event after the original ``completed`` row — the + original is never edited or deleted — and the instance's derived + state (recomputed from the latest event) returns to ``open``. On an + ACTUAL reversal (``CompletionResult.appended`` True) the route + publishes the ``nestquest_quest_uncompleted`` transition through + :func:`api.transitions.publish_quest_uncompleted` (the payload built + by the core builder); an already-open no-op publishes nothing. The + response carries the derived ``state`` and whether THIS call + ``appended``. +- ``POST /regenerate`` re-materializes the rolling horizon through the + same core path the integration's config/presence changes use. The + body names ONE scope: ``household`` (the default — + :func:`nestquest_core.materialize.materialize` over + ``[today, today + const.DEFAULT_HORIZON_DAYS]``, the core's default + horizon; the API config carries no horizon knob of its own), + ``definition`` (:func:`nestquest_core.materialize.regenerate_for_definition` + — deletes the definition's open future instances and re-runs the + walk), or ``child`` + (:func:`nestquest_core.materialize.regenerate_for_child`). + The upsert is idempotent, so re-running never duplicates an + instance. The response carries the scope, the scoped id (if any) + and the core's upsert ``count``. + Error mapping (every children, quest-definition and presence route, deliberately narrow): @@ -94,6 +127,20 @@ deliberately narrow): :func:`_raise_presence_override_error`, keyed on the ``override_id:`` prefix the core's unknown-id error carries (the same convention as the quest-definition 404). +- The UNCOMPLETE route maps the core completion layer's unknown- + ``instance_id`` error (prefixed ``instance_id:``, the same + convention the panel complete route keys on) to 404; every other + core ``ValueError`` — a rejected actor shape, which a verified token + without a usable ``sub`` claim produces — is 422: the credential + authenticated but the reversal it asks for names no subject to + attribute. Anything ELSE re-raises: no unexpected failure is + swallowed into a 4xx. +- The REGENERATE route's only 404s are the unknown scope ids, decided + by ONE existence read (the definitions/children DAO ``get``) BEFORE + any core call — the core's regenerate path itself takes no existence + stance. Every core ``ValueError`` (a rejected horizon, a malformed + date — unreachable from this body model, but mapped anyway) is 422; + anything else re-raises. PATCH edit semantics: the body model's fields are optional and only SUPPLIED fields are forwarded (``model_dump(exclude_unset=True)``), so @@ -104,15 +151,22 @@ silently ignored. """ from __future__ import annotations -from typing import Annotated, NoReturn +import datetime +from typing import Annotated, Literal, NoReturn from fastapi import APIRouter, Depends, HTTPException, Request -from pydantic import BaseModel +from pydantic import BaseModel, field_validator, model_validator +from api import transitions as api_transitions from api.auth import require_admin from api.database import DatabaseState from api.nestquest_core import ( core_children, + core_completion, + core_const, + core_dao_children, + core_dao_rules, + core_materialize, core_presence, core_presence_management, core_quest_definitions, @@ -395,6 +449,83 @@ class AdminPresenceOverrideResponse(BaseModel): note: str | None +class AdminUncompleteResponse(BaseModel): + """The result of ``POST /api/v1/admin/instances/{id}/uncomplete``. + + ``state`` is the instance's derived state AFTER the reversal — + ``open``, or ``missed`` when it is past due — and ``appended`` + reports whether THIS call appended the reversal event (``False`` + for an already-open no-op). + """ + + instance_id: int + state: str + appended: bool + + +class AdminRegenerateRequest(BaseModel): + """The body of ``POST /api/v1/admin/regenerate``. + + ONE scope per request: ``household`` (the default) re-materializes + every active definition over the rolling horizon; ``definition`` + and ``child`` scope the rebuild to one id. The model enforces the + scope/id pairing so the handler never has to guess: an id without + its scope, a scope without its id, or a zero/negative id is a + validation error (422) before any handler code runs. + """ + + scope: Literal["household", "definition", "child"] = "household" + definition_id: int | None = None + child_id: int | None = None + + @field_validator("definition_id", "child_id") + @classmethod + def _positive(cls, value: int | None) -> int | None: + """Reject zero and negative ids with a validation error.""" + if value is not None and value < 1: + raise ValueError( + "definition_id and child_id must be positive integers" + ) + return value + + @model_validator(mode="after") + def _scope_matches_ids(self) -> "AdminRegenerateRequest": + """Reject an id that does not belong to the requested scope.""" + if self.scope == "household": + if self.definition_id is not None or self.child_id is not None: + raise ValueError( + "scope 'household' takes no definition_id or child_id" + ) + elif self.scope == "definition": + if self.definition_id is None: + raise ValueError("scope 'definition' requires definition_id") + if self.child_id is not None: + raise ValueError("scope 'definition' must not carry child_id") + else: + if self.child_id is None: + raise ValueError("scope 'child' requires child_id") + if self.definition_id is not None: + raise ValueError( + "scope 'child' must not carry definition_id" + ) + return self + + +class AdminRegenerateResponse(BaseModel): + """What a regenerate request rebuilt. + + ``scope`` echoes the request; ``definition_id``/``child_id`` carry + the scoped id (both ``None`` for the household); ``count`` is the + number of instances the core walk upserted — the core's idempotent + count, so a re-run refreshes rows in place and never duplicates. + """ + + scope: str + definition_id: int | None + child_id: int | None + count: int + + #: 404 detail for a child id the core layer reports as non-existent. _CHILD_NOT_FOUND_DETAIL = "Child not found" @@ -478,6 +609,56 @@ def _raise_presence_override_error(error: ValueError) -> NoReturn: raise HTTPException(status_code=422, detail=str(error)) from error +#: 404 detail for an instance id the core layer reports as non-existent. +_QUEST_INSTANCE_NOT_FOUND_DETAIL = "Quest instance not found" + + +def _raise_instance_error(error: ValueError) -> NoReturn: + """Map a core completion ``ValueError`` onto 404 or 422 and raise it. + + The uncomplete route's mapping, keyed the same way the panel + complete route keys its 404: the core names a missing instance by + prefixing its error with ``instance_id:`` + (:func:`nestquest_core.completion.uncomplete_instance` on an + unknown id), and that one case is this route's 404. Every other + ``ValueError`` — a rejected actor shape, which a verified token + without a usable ``sub`` claim produces — is 422 with the core's + message as the detail. The route catches ONLY ``ValueError``, so + an unexpected failure propagates untouched. + """ + if str(error).startswith("instance_id:"): + raise HTTPException( + status_code=404, detail=_QUEST_INSTANCE_NOT_FOUND_DETAIL + ) from error + raise HTTPException(status_code=422, detail=str(error)) from error + + +def _raise_regenerate_error(error: ValueError) -> NoReturn: + """Map a core materialization ``ValueError`` onto 422 and raise it. + + The regenerate route's 404s are decided BEFORE the core call (the + handler's own existence reads of the scoped id), so every + ``ValueError`` that survives to here is a rejected argument — a + malformed horizon or date the core refuses before any write — and + is 422 with the core's message as the detail. Nothing else is + caught: 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. + + Same discipline as the panel route's clock read: a single + ``now().astimezone()`` read anchors the household-local date the + regenerate horizon and the uncomplete stamp derive from, so the + calendar date can never disagree inside one request. Kept as a + module function so tests pin the instant the same way the panel + route's ``_local_now`` is pinned. + """ + return datetime.datetime.now().astimezone() + + def _rule_from_request(rule: AdminRuleRequest) -> core_recurrence.ScheduleRule: """Build the core ScheduleRule from the request's ``rule`` object. @@ -942,4 +1123,175 @@ async def admin_delete_presence_override( return {"status": "ok"} +@router.post( + "/instances/{instance_id}/uncomplete", + summary="Reverse one completion (append a reversal event)", + response_model=AdminUncompleteResponse, +) +async def admin_uncomplete_instance( + instance_id: int, + request: Request, + claims: Annotated[dict[str, object], Depends(require_admin)], +) -> AdminUncompleteResponse: + """Reverse one completion; the original event is never touched. + + 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). ``claims`` is the SAME + verified-claims dict that dependency produced (FastAPI's + per-request dependency cache), and the reversal is attributed to + it: ``actor_source="user"`` with ``actor_user_id`` = the token's + ``sub`` — the D-008 user-actor shape, ``actor_child_id`` never + sent — so the completion log records WHICH admin reversed it. + + Thin adapter: ONE timezone-aware clock read (:func:`_local_now`) + and ONE :func:`nestquest_core.completion.uncomplete_instance` call + — the append-only discipline (a reversal APPENDS an ``uncompleted`` + event after the original ``completed`` row, which is never edited + or deleted), the already-open no-op and the derived-state + recomputation all live in the core layer. + + On an ACTUAL reversal (``CompletionResult.appended`` True) the + route publishes the ``nestquest_quest_uncompleted`` transition on + the SSE stream through + :func:`api.transitions.publish_quest_uncompleted` (the payload is + built by the core builder, never hand-built here; fan-out is + fire-and-forget, see api/events.py). An already-open no-op + (``appended`` False) publishes nothing. + + Response: the derived ``state`` after the reversal — ``open``, or + ``missed`` when the instance is past due — and whether THIS call + ``appended``. Error mapping (:func:`_raise_instance_error`): an + unknown instance is 404; every other core ``ValueError`` is 422. + """ + state: DatabaseState = request.app.state.db + database = state.database + now = _local_now() + sub = claims.get("sub") + try: + result = await core_completion.uncomplete_instance( + database, + instance_id, + actor_source="user", + actor_user_id=sub if isinstance(sub, str) else None, + now=now, + ) + except ValueError as error: + _raise_instance_error(error) + if result.appended: + # An ACTUAL reversal: announce the transition once. A no-op + # (already open) publishes nothing. + await api_transitions.publish_quest_uncompleted( + database, instance_id, request.app.state.publisher + ) + return AdminUncompleteResponse( + instance_id=instance_id, + state=str(result), + appended=result.appended, + ) + + +@router.post( + "/regenerate", + summary="Re-materialize the rolling horizon for one scope", + response_model=AdminRegenerateResponse, +) +async def admin_regenerate( + body: AdminRegenerateRequest, request: Request +) -> AdminRegenerateResponse: + """Re-materialize the rolling horizon for the requested scope. + + 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 over the SAME + materialization path the integration's config/presence changes + trigger, one scope per request: + + - ``household`` (the default): ONE + :func:`nestquest_core.materialize.materialize` call over + ``[today, today + const.DEFAULT_HORIZON_DAYS]`` — the core's + default horizon, which sizes the window here because the API + config carries no horizon knob of its own. The walk is an + idempotent upsert: re-running refreshes rows in place and never + duplicates an instance. + - ``definition``: ONE existence read + (:class:`~nestquest_core.dao_rules.QuestDefinitionsDao.get` — an + unknown id is 404 BEFORE any core call, which the core's + regenerate path itself takes no stance on), then + :func:`nestquest_core.materialize.regenerate_for_definition`. + - ``child``: the same shape through + :class:`~nestquest_core.dao_children.ChildrenDao.get` and + :func:`nestquest_core.materialize.regenerate_for_child`. + + The ONE clock read (:func:`_local_now`) threads the household-local + ``today`` into every core call so the walk's no-past guard and the + delete cutoff share one anchor. Every core call returns the + upsert ``count``, which the response passes through. Error + mapping (:func:`_raise_regenerate_error`): every core + ``ValueError`` is 422; the scope-id 404s are decided above. + """ + state: DatabaseState = request.app.state.db + database = state.database + today = _local_now().date() + + if body.scope == "household": + end_date = today + datetime.timedelta( + days=core_const.DEFAULT_HORIZON_DAYS + ) + try: + count = await core_materialize.materialize( + database, + today.isoformat(), + end_date.isoformat(), + today=today, + ) + except ValueError as error: + _raise_regenerate_error(error) + return AdminRegenerateResponse( + scope="household", definition_id=None, child_id=None, + count=count, + ) + + if body.scope == "definition": + definition_id = body.definition_id + # The request model's scope/id consistency validator already + # guarantees the id for this scope; this only narrows the type. + assert definition_id is not None + definition = await core_dao_rules.QuestDefinitionsDao( + database + ).get(definition_id) + if definition is None: + raise HTTPException( + status_code=404, detail=_QUEST_DEFINITION_NOT_FOUND_DETAIL + ) + try: + count = await core_materialize.regenerate_for_definition( + database, definition_id, today=today + ) + except ValueError as error: + _raise_regenerate_error(error) + return AdminRegenerateResponse( + scope="definition", + definition_id=definition_id, + child_id=None, + count=count, + ) + + child_id = body.child_id + # Same model guarantee as the definition scope above. + assert child_id is not None + child = await core_dao_children.ChildrenDao(database).get(child_id) + if child is None: + raise HTTPException(status_code=404, detail=_CHILD_NOT_FOUND_DETAIL) + try: + count = await core_materialize.regenerate_for_child( + database, child_id, today=today + ) + except ValueError as error: + _raise_regenerate_error(error) + return AdminRegenerateResponse( + scope="child", definition_id=None, child_id=child_id, count=count + ) + + __all__ = ["router"] diff --git a/tests/test_api_admin_uncomplete.py b/tests/test_api_admin_uncomplete.py new file mode 100644 index 0000000..23e8211 --- /dev/null +++ b/tests/test_api_admin_uncomplete.py @@ -0,0 +1,694 @@ +"""Admin-plane uncomplete and regenerate route tests (task da0226b3). + +These tests exercise the admin plane's completion-reversal and +re-materialization 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 household is +seeded with :func:`tests.test_api_panel._seed_household` through the +SAME core copy the app uses (``nestquest_core.*``; the two-copy caveat +in api/nestquest_core.py), and the route's ONE clock read +(:func:`api.routes_admin._local_now`) is pinned the same way the panel +route's is, so seeded rows and route stamps cannot straddle midnight. + +Every done-condition case is proven against the DATABASE, read through +that same core copy: + +- Authentication runs through the router's ONE ``require_admin`` + dependency: an admin JWT passes, a JWT without the group is 403, an + absent credential is 401 — and nothing is written. +- ``POST /api/v1/admin/instances/{id}/uncomplete``: the completion + events log for the instance holds the ORIGINAL ``completed`` row + UNTOUCHED plus ONE appended ``uncompleted`` row attributed to the + verified token's ``sub`` (actor_source ``user``), and the instance's + DERIVED state is ``open`` again; a second uncomplete is a no-op that + appends nothing. +- On an ACTUAL reversal the ``nestquest_quest_uncompleted`` transition + arrives on the SSE stream exactly once (subscribed live as + :mod:`tests.test_api_sse` does); a no-op uncomplete publishes + nothing. +- ``POST /api/v1/admin/regenerate`` re-materializes without + duplicating: running it twice (household, definition and child + scopes) leaves the instance count stable, and the scoped variants + rebuild only their own scope's rows. + +Error mapping (documented in api/routes_admin.py): an unknown +instance is 404 (the core's ``instance_id:`` prefix, the same key the +panel complete route uses); unknown definition/child scope ids are 404 +from the route's own existence reads; malformed input — a bad scope, +a scope/id mismatch, a zero id, a non-integer path id — is 422 before +any handler code runs. +""" +from __future__ import annotations + +import asyncio +import datetime +import importlib +from types import SimpleNamespace +from zoneinfo import ZoneInfo + +import httpx +import pytest + +from api import routes_admin +from api.app import create_app +from api.auth import JwksCache +from api.config import ApiConfig +from tests.admin_jwt_harness import ( + ADMIN_KEY, + AUDIENCE, + ISSUER, + JWKS_URL, + KID, + AdminRunner, + StubbedFetch, + SUBJECT, + jwks_for, + make_token, +) +from tests.test_api_panel import PANEL_TOKEN, _seed_household +from tests.test_api_sse import _EventStream, _Server, _stream + +# The completion and instance-DAO layers 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 them through api.nestquest_core. +_completion = importlib.import_module("nestquest_core.completion") +_dao_instances = importlib.import_module("nestquest_core.dao_instances") + +#: The documented quest-uncompleted event name (core const, §3). +EVENT_QUEST_UNCOMPLETED = importlib.import_module( + "nestquest_core.const" +).EVENT_QUEST_UNCOMPLETED + + +def _admin_headers() -> dict[str, str]: + """The Authorization header of a valid nestquest-admins JWT.""" + return {"Authorization": f"Bearer {make_token()}"} + + +def _pinned_noon() -> tuple[datetime.date, datetime.datetime]: + """One host clock read pinned to 12:00 UTC that day. + + The seed's completion and the route's clock both anchor to this + instant, so nothing can straddle midnight mid-test. + """ + today = datetime.datetime.now().astimezone().date() + return today, datetime.datetime( + today.year, today.month, today.day, 12, 0, 0, tzinfo=ZoneInfo("UTC") + ) + + +async def _instance_count(database: object) -> int: + """The whole ``quest_instances`` row count (the sanctioned probe).""" + row = await database.fetch_one("SELECT COUNT(*) FROM quest_instances") + return row[0] + + +async def _events_for_instance( + database: object, instance_id: int +) -> list: + """The instance's completion events, in append order.""" + dao = _dao_instances.CompletionEventsDao(database) + return await dao.list_by_instance(instance_id) + + +async def _derived_state( + database: object, instance_id: int, today: datetime.date +) -> str: + """The instance's derived state through the app's core copy.""" + dao = _dao_instances.QuestInstancesDao(database) + instance = await dao.get_by_id(instance_id) + assert instance is not None + latest = await _dao_instances.CompletionEventsDao( + database + ).get_latest_for_instance(instance_id) + return _completion.derive_state(instance, latest, today) + + +@pytest.fixture +async def admin_api(temp_db_path: str) -> SimpleNamespace: + """An admin-plane app client plus its live database. + + The same runner the auth and children tests use (stubbed JWKS, no + network); the fixture exposes the app's ONE database connection so + tests assert the routes' effects on the stored rows themselves. + """ + 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, + ) + + +@pytest.fixture +async def admin_household(temp_db_path: str, monkeypatch) -> SimpleNamespace: + """A seeded household served by the admin-plane app, clock pinned. + + ``_seed_household`` completes Bo's today instance (17:00 due, 12:00 + pinned — on time), which is the completion the uncomplete tests + reverse. The route's ``_local_now`` is pinned to the same instant. + """ + today, pinned_now = _pinned_noon() + seed = await _seed_household(temp_db_path, today, pinned_now) + monkeypatch.setattr(routes_admin, "_local_now", lambda: pinned_now) + runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) + async with runner as client: + yield SimpleNamespace( + client=client, + seed=seed, + database=runner.app.state.db.database, + today=today, + ) + + +class _AdminServer(_Server): + """:mod:`tests.test_api_sse`'s live-uvicorn server, admin-enabled. + + Same ephemeral-port uvicorn thread (httpx's ASGI transport buffers + SSE bodies, so a live server is the only way to stream), but the + app factory swaps the JWKS cache for one built around the harness's + stub fetcher, so a real admin JWT verifies with NO network access. + """ + + def _make_app_factory(self, db_path: str): + """The uvicorn app factory with the stubbed JWKS cache.""" + + def factory(): + app = create_app( + ApiConfig( + db_path=db_path, + panel_token=PANEL_TOKEN, + oidc_issuer=ISSUER, + oidc_audience=AUDIENCE, + oidc_jwks_url=JWKS_URL, + ) + ) + app.state.jwks_cache = JwksCache( + JWKS_URL, fetcher=StubbedFetch(jwks_for(ADMIN_KEY, KID)) + ) + self._server_loop = asyncio.get_running_loop() + self.app = app + return app + + return factory + + +@pytest.fixture +async def admin_sse(temp_db_path: str) -> SimpleNamespace: + """A seeded household served by a live uvicorn app, clock pinned. + + Same pinning discipline as the panel tests, applied to + :func:`api.routes_admin._local_now` BEFORE the server starts (the + app is created inside uvicorn's factory thread). + """ + today, pinned_now = _pinned_noon() + seed = await _seed_household(temp_db_path, today, pinned_now) + original = routes_admin._local_now + routes_admin._local_now = lambda: pinned_now + try: + with _AdminServer(temp_db_path) as server: + client = httpx.AsyncClient(base_url=server.url("")) + async with client: + yield SimpleNamespace( + client=client, + seed=seed, + server=server, + database=server.app.state.db.database, + today=today, + ) + finally: + routes_admin._local_now = original + + +# --- authentication: the ONE require_admin dependency guards every route --- + + +async def test_uncomplete_and_regenerate_refuse_non_admin_and_absent( + admin_household: SimpleNamespace, +) -> None: + """A non-admin JWT is 403, an absent credential is 401: nothing written.""" + client = admin_household.client + database = admin_household.database + brush = admin_household.seed.brush_teeth_instance + non_admin = make_token(groups=("some-other-group",)) + events_before = await _events_for_instance(database, brush.id) + instances_before = await _instance_count(database) + + uncomplete_url = f"/api/v1/admin/instances/{brush.id}/uncomplete" + forbidden = await client.post( + uncomplete_url, + headers={"Authorization": f"Bearer {non_admin}"}, + ) + assert forbidden.status_code == 403 + assert forbidden.json()["detail"] == ( + "Admin access requires membership in the nestquest-admins group" + ) + unauthenticated = await client.post(uncomplete_url) + assert unauthenticated.status_code == 401 + + regenerate_bodies = ( + {}, + {"scope": "household"}, + {"scope": "child", "child_id": 1}, + ) + for body in regenerate_bodies: + forbidden = await client.post( + "/api/v1/admin/regenerate", + json=body, + headers={"Authorization": f"Bearer {non_admin}"}, + ) + assert forbidden.status_code == 403, body + unauthenticated = await client.post( + "/api/v1/admin/regenerate", json=body + ) + assert unauthenticated.status_code == 401, body + + # No handler ran: the seeded completion is still the ONLY event for + # the instance and the instance table is untouched. + assert ( + await _events_for_instance(database, brush.id) == events_before + ) + assert await _instance_count(database) == instances_before + + +# --- uncomplete: the append-only reversal ----------------------------------- + + +async def test_uncomplete_appends_reversal_and_derived_state_is_open( + admin_household: SimpleNamespace, +) -> None: + """A reversal APPENDS an uncompleted event; the instance is open again. + + The original completed row is proven byte-identical after the call + (frozen-dataclass equality), the new row carries the verified + token's ``sub`` as the user actor, and the DERIVED state — + recomputed from the latest event — is ``open`` again. + """ + client = admin_household.client + database = admin_household.database + brush = admin_household.seed.brush_teeth_instance + today = admin_household.today + + before = await _events_for_instance(database, brush.id) + assert [event.event_type for event in before] == ["completed"] + assert await _derived_state(database, brush.id, today) == "done" + + response = await client.post( + f"/api/v1/admin/instances/{brush.id}/uncomplete", + headers=_admin_headers(), + ) + assert response.status_code == 200 + body = response.json() + assert body == { + "instance_id": brush.id, + "state": "open", + "appended": True, + } + + events = await _events_for_instance(database, brush.id) + assert len(events) == 2 + # The original completed row is UNTOUCHED — never edited, never + # deleted (append-only completion_events). + assert events[0] == before[0] + assert events[0].event_type == "completed" + assert events[0].actor_source == "panel" + # The reversal is the NEW row, attributed to the verified token. + reversal = events[1] + assert reversal.event_type == "uncompleted" + assert reversal.instance_id == brush.id + assert reversal.child_id == brush.child_id + assert reversal.actor_source == "user" + assert reversal.actor_user_id == SUBJECT + assert reversal.actor_child_id is None + assert reversal.was_on_time is None + # The derived state is recomputed from the latest event: open again. + assert await _derived_state(database, brush.id, today) == "open" + + +async def test_second_uncomplete_is_a_noop_that_appends_nothing( + admin_household: SimpleNamespace, +) -> None: + """A repeat uncomplete appends NOTHING and still succeeds.""" + client = admin_household.client + database = admin_household.database + brush = admin_household.seed.brush_teeth_instance + today = admin_household.today + + first = await client.post( + f"/api/v1/admin/instances/{brush.id}/uncomplete", + headers=_admin_headers(), + ) + assert first.status_code == 200 + assert first.json()["appended"] is True + + second = await client.post( + f"/api/v1/admin/instances/{brush.id}/uncomplete", + headers=_admin_headers(), + ) + assert second.status_code == 200 + body = second.json() + assert body["appended"] is False + assert body["state"] == "open" + + events = await _events_for_instance(database, brush.id) + assert len(events) == 2 + assert [event.event_type for event in events] == [ + "completed", + "uncompleted", + ] + assert await _derived_state(database, brush.id, today) == "open" + + +async def test_uncomplete_unknown_instance_is_404_and_malformed_is_422( + admin_household: SimpleNamespace, +) -> None: + """An unknown instance is 404; a non-integer path id is 422.""" + client = admin_household.client + brush = admin_household.seed.brush_teeth_instance + before = await _events_for_instance( + admin_household.database, brush.id + ) + + unknown = await client.post( + "/api/v1/admin/instances/999/uncomplete", + headers=_admin_headers(), + ) + assert unknown.status_code == 404 + assert unknown.json()["detail"] == "Quest instance not found" + + malformed = await client.post( + "/api/v1/admin/instances/not-an-int/uncomplete", + headers=_admin_headers(), + ) + assert malformed.status_code == 422 + + # Neither rejection wrote anything. + assert ( + await _events_for_instance(admin_household.database, brush.id) + == before + ) + + +async def test_uncomplete_already_open_past_due_reports_missed( + admin_household: SimpleNamespace, +) -> None: + """A no-op uncomplete still reports the honest DERIVED state. + + The seeded stale instance is open and past due: uncompleting it is + a no-op (nothing appended) and the derived state is ``missed`` — + open-but-past-due, never an event type. + """ + client = admin_household.client + database = admin_household.database + stale = admin_household.seed.stale_instance + today = admin_household.today + assert await _derived_state(database, stale.id, today) == "missed" + + response = await client.post( + f"/api/v1/admin/instances/{stale.id}/uncomplete", + headers=_admin_headers(), + ) + assert response.status_code == 200 + body = response.json() + assert body["appended"] is False + assert body["state"] == "missed" + + # The no-op appended nothing: the instance still has NO events. + assert await _events_for_instance(database, stale.id) == [] + + +# --- the SSE transition on an actual reversal -------------------------------- + + +async def test_uncomplete_publishes_exactly_one_uncompleted_transition( + admin_sse: SimpleNamespace, +) -> None: + """An actual reversal streams ONE quest-uncompleted event; a no-op none. + + The stream is subscribed FIRST (test_api_sse's live-server reader), + the completed instance is reversed through the admin route, and the + documented payload fields are asserted; the no-op second call adds + no frame. + """ + seed = admin_sse.seed + brush = seed.brush_teeth_instance + stream = _stream(admin_sse.server.url("/api/v1/panel/events")) + async with stream: + first = await admin_sse.client.post( + f"/api/v1/admin/instances/{brush.id}/uncomplete", + headers=_admin_headers(), + ) + assert first.status_code == 200 + assert first.json()["appended"] is True + + event_type, payload = await stream.next_event() + assert event_type == EVENT_QUEST_UNCOMPLETED + assert event_type == "nestquest_quest_uncompleted" + assert payload["child_id"] == seed.bo.id + assert payload["child_name"] == "Bo" + assert payload["instance_id"] == brush.id + assert payload["quest_title"] == "Brush teeth" + assert payload["window"] == "morning" + assert payload["due_date"] == brush.due_date + assert payload["due_time"] == "17:00" + assert payload["occurred_at"] # strict UTC ISO-8601 stamp + assert "was_on_time" not in payload + + # The no-op second call publishes nothing: the stream stays + # silent past the window. + second = await admin_sse.client.post( + f"/api/v1/admin/instances/{brush.id}/uncomplete", + headers=_admin_headers(), + ) + assert second.status_code == 200 + assert second.json()["appended"] is False + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(stream.queue.get(), timeout=0.3) + + +# --- regenerate: re-materialize without duplicating -------------------------- + + +async def _create_child(client: object, name: str) -> int: + """Create one child through the admin children route.""" + created = await client.post( + "/api/v1/admin/children", + json={"display_name": name}, + headers=_admin_headers(), + ) + assert created.status_code == 201 + return created.json()["id"] + + +async def _create_daily_definition( + client: object, assignee_ids: list[int] +) -> int: + """Create one daily one-window definition for the given assignees.""" + created = await client.post( + "/api/v1/admin/quest-definitions", + json={ + "title": "Tidy the den", + "rule": {"rule_type": "daily", "start_date": "2026-03-02"}, + "assignee_child_ids": assignee_ids, + "windows": ["morning"], + }, + headers=_admin_headers(), + ) + assert created.status_code == 201 + return created.json()["id"] + + +async def test_regenerate_household_never_duplicates( + admin_household: SimpleNamespace, +) -> None: + """Two household regenerations leave the instance count stable. + + The first call re-materializes the rolling horizon + ``[today, today + DEFAULT_HORIZON_DAYS]``; the second walks the + SAME tuples and upserts them in place — the row count never grows + and the core reports the same upsert count both times. + """ + client = admin_household.client + database = admin_household.database + + before = await _instance_count(database) + assert before > 0 # the seed already materialized a few days + + first = await client.post( + "/api/v1/admin/regenerate", json={}, headers=_admin_headers() + ) + assert first.status_code == 200 + body = first.json() + assert body["scope"] == "household" + assert body["definition_id"] is None + assert body["child_id"] is None + assert body["count"] >= before + after_first = await _instance_count(database) + assert after_first >= before + + second = await client.post( + "/api/v1/admin/regenerate", json={}, headers=_admin_headers() + ) + assert second.status_code == 200 + assert second.json()["count"] == body["count"] + # The idempotent walk: the second run's row count is EXACTLY the + # first's — no duplicated instances. + assert await _instance_count(database) == after_first + + +async def test_regenerate_definition_scope_rematerializes( + admin_api: SimpleNamespace, +) -> None: + """The definition scope rebuilds that definition's horizon, twice, stably. + + Creating a definition through the admin route materializes NOTHING + (the core's create path does not regenerate); the scoped regenerate + call is what materializes its instances, and re-running it leaves + the row count exactly where the first run left it. + """ + client = admin_api.client + database = admin_api.database + ada = await _create_child(client, "Ada") + definition_id = await _create_daily_definition(client, [ada]) + + assert await _instance_count(database) == 0 + + first = await client.post( + "/api/v1/admin/regenerate", + json={"scope": "definition", "definition_id": definition_id}, + headers=_admin_headers(), + ) + assert first.status_code == 200 + body = first.json() + assert body["scope"] == "definition" + assert body["definition_id"] == definition_id + assert body["child_id"] is None + assert body["count"] > 0 + after_first = await _instance_count(database) + assert after_first == body["count"] + + second = await client.post( + "/api/v1/admin/regenerate", + json={"scope": "definition", "definition_id": definition_id}, + headers=_admin_headers(), + ) + assert second.status_code == 200 + assert second.json()["count"] == body["count"] + assert await _instance_count(database) == after_first + + +async def test_regenerate_child_scope_touches_only_that_child( + admin_api: SimpleNamespace, +) -> None: + """The child scope rebuilds that child's rows and no others'. + + Two children share one daily definition; the household walk + materializes both. A child-scoped regenerate for Ada re-runs her + side of the walk (same count) while Bo's rows keep their identity, + and re-running it never grows the table. + """ + client = admin_api.client + database = admin_api.database + instances = _dao_instances.QuestInstancesDao(database) + ada = await _create_child(client, "Ada") + bo = await _create_child(client, "Bo") + # One daily definition assigned to BOTH children: the household walk + # gives each child its own rows. + await _create_daily_definition(client, [ada, bo]) + + # Materialize the household first so BOTH children have rows. + seeded = await client.post( + "/api/v1/admin/regenerate", json={}, headers=_admin_headers() + ) + assert seeded.status_code == 200 + assert seeded.json()["scope"] == "household" + + ada_rows = await instances.list_by_date_range( + ada, "2026-03-02", "2100-01-01" + ) + bo_rows = await instances.list_by_date_range( + bo, "2026-03-02", "2100-01-01" + ) + assert ada_rows and bo_rows + total = await _instance_count(database) + assert total == len(ada_rows) + len(bo_rows) + + for expected_count in (len(ada_rows), len(ada_rows)): + response = await client.post( + "/api/v1/admin/regenerate", + json={"scope": "child", "child_id": ada}, + headers=_admin_headers(), + ) + assert response.status_code == 200 + body = response.json() + assert body == { + "scope": "child", + "definition_id": None, + "child_id": ada, + "count": expected_count, + } + # Ada's side re-materialized the same number of rows and Bo's + # rows kept their identity; the table never grew. + assert await instances.list_by_date_range( + ada, "2026-03-02", "2100-01-01" + ) + assert await instances.list_by_date_range( + bo, "2026-03-02", "2100-01-01" + ) == bo_rows + assert await _instance_count(database) == total + + +async def test_regenerate_unknown_scope_ids_are_404( + admin_api: SimpleNamespace, +) -> None: + """An unknown definition or child scope id is 404, nothing written.""" + client = admin_api.client + + unknown_definition = await client.post( + "/api/v1/admin/regenerate", + json={"scope": "definition", "definition_id": 999}, + headers=_admin_headers(), + ) + assert unknown_definition.status_code == 404 + assert unknown_definition.json()["detail"] == "Quest definition not found" + + unknown_child = await client.post( + "/api/v1/admin/regenerate", + json={"scope": "child", "child_id": 999}, + headers=_admin_headers(), + ) + assert unknown_child.status_code == 404 + assert unknown_child.json()["detail"] == "Child not found" + + assert await _instance_count(admin_api.database) == 0 + + +@pytest.mark.parametrize( + "body", + [ + {"scope": "galaxy"}, # unknown scope + {"scope": "definition"}, # scope without its id + {"scope": "child"}, # scope without its id + {"scope": "household", "child_id": 1}, # id without its scope + {"scope": "household", "definition_id": 1}, # id without its scope + { + "scope": "definition", + "definition_id": 1, + "child_id": 2, + }, # scope mismatch + {"scope": "child", "child_id": 0}, # zero id + {"scope": "child", "child_id": "seven"}, # wrong type + ], +) +async def test_regenerate_malformed_bodies_are_422( + admin_api: SimpleNamespace, body: dict[str, object] +) -> None: + """Malformed scope/id combinations are 422 before any core call.""" + response = await admin_api.client.post( + "/api/v1/admin/regenerate", json=body, headers=_admin_headers() + ) + assert response.status_code == 422, body + assert await _instance_count(admin_api.database) == 0 \ No newline at end of file -- 2.55.0 From 0255017cdc1e1840cb5c4496139d090debeec29a Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 03:13:39 -0400 Subject: [PATCH 13/15] Admin routes: history query and CSV export (#162) --- api/nestquest_core.py | 6 + api/routes_admin.py | 197 ++++++- custom_components/nestquest/core/history.py | 405 +++++++++++++++ tests/test_api_admin_history.py | 547 ++++++++++++++++++++ 4 files changed, 1154 insertions(+), 1 deletion(-) create mode 100644 custom_components/nestquest/core/history.py create mode 100644 tests/test_api_admin_history.py diff --git a/api/nestquest_core.py b/api/nestquest_core.py index b327200..d67596a 100644 --- a/api/nestquest_core.py +++ b/api/nestquest_core.py @@ -116,6 +116,11 @@ core_snapshot = importlib.import_module(f"{_CORE_MODULE_NAME}.snapshot") #: ``uncomplete_instance``) the panel's write routes call. core_completion = importlib.import_module(f"{_CORE_MODULE_NAME}.completion") +#: The read-only history layer (``core.history.query_history``, +#: ``export_history_csv``, ``rows_to_csv``) the admin plane's history +#: query and CSV export routes call. +core_history = importlib.import_module(f"{_CORE_MODULE_NAME}.history") + #: The transition-event builders (``core.events.build_quest_completed_event`` #: et al.) the panel's write routes build their SSE events with. core_events = importlib.import_module(f"{_CORE_MODULE_NAME}.events") @@ -192,6 +197,7 @@ __all__ = [ "core_dao_rules", "core_db", "core_events", + "core_history", "core_materialize", "core_migrations", "core_presence", diff --git a/api/routes_admin.py b/api/routes_admin.py index 8f26e34..4ff4553 100644 --- a/api/routes_admin.py +++ b/api/routes_admin.py @@ -92,6 +92,30 @@ pattern over :mod:`nestquest_core.completion` and instance. The response carries the scope, the scoped id (if any) and the core's upsert ``count``. +The history routes (task 2d2dda53) follow the same pattern over +:mod:`nestquest_core.history` — the ONE read-only history layer, which +owns every rule so the two routes cannot drift: + +- ``GET /history?filter=all|reversals|missed&start=YYYY-MM-DD&end=YYYY-MM-DD`` + returns the history rows for ONE filter over the CLOSED due-date + range ``[start, end]``: ``all`` (every completion event — completed + AND uncompleted — whose instance is due in the range), ``reversals`` + (the uncompleted events only) and ``missed`` (the DERIVED past-due + open instances, computed by the core's own derive-state rule — + ``missed`` is never a stored event_type and nothing is appended). + The range scopes by the instance's ``due_date`` (the Feature 13 + rule: history asks what happened to the tasks due that day), not the + event's wall-clock stamp. The filter and date-range policies live + in the core (strict ``YYYY-MM-DD``, ``end >= start``, a + ``ValueError`` naming the offending field); the handler is a thin + adapter that threads ONE clock read (:func:`_local_now`, the missed + derivation's ``today``) and serializes the core's rows. +- ``GET /history.csv?...`` exports the SAME filtered range as CSV: + ONE :func:`nestquest_core.history.export_history_csv` call — the + same query path the JSON route uses — answered as ``text/csv`` with + a ``Content-Disposition`` attachment filename and the documented, + stable header row (Admin spec §5 event-row fields). + Error mapping (every children, quest-definition and presence route, deliberately narrow): @@ -141,6 +165,15 @@ deliberately narrow): stance. Every core ``ValueError`` (a rejected horizon, a malformed date — unreachable from this body model, but mapped anyway) is 422; anything else re-raises. +- The HISTORY routes have no path ids, so they have no 404 case: a + missing query parameter (the request's shape) is FastAPI's 422 + before any handler code runs, and every core ``ValueError`` — an + unknown filter, a non-strict or inverted date range, each naming the + offending field (``filter`` / ``start`` / ``end``) — is 422 + (:func:`_raise_history_error`): well-formed but semantically + invalid, the same failure class Pydantic reports. 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 @@ -154,7 +187,7 @@ from __future__ import annotations import datetime from typing import Annotated, Literal, NoReturn -from fastapi import APIRouter, Depends, HTTPException, Request +from fastapi import APIRouter, Depends, HTTPException, Request, Response from pydantic import BaseModel, field_validator, model_validator from api import transitions as api_transitions @@ -166,6 +199,7 @@ from api.nestquest_core import ( core_const, core_dao_children, core_dao_rules, + core_history, core_materialize, core_presence, core_presence_management, @@ -526,6 +560,47 @@ class AdminRegenerateResponse(BaseModel): count: int +class AdminHistoryRow(BaseModel): + """One history row as the admin plane serializes it. + + Mirrors :class:`nestquest_core.history.HistoryRow` — the Admin spec + §5 event-row fields, the same set the CSV export's header carries. + A stored event carries its UTC ``occurred_at`` stamp and the + ``completed``/``uncompleted`` ``event_type``; a derived missed row + (``missed`` is NEVER a stored event_type) carries ``event_type`` + ``'missed'``, ``occurred_at`` ``None`` (there is no event) and the + ``nightly sweep`` actor. + """ + + occurred_at: str | None + event_type: str + child_name: str + child_id: int + quest_title: str + instance_id: int + window: str + due_date: str + due_time: str | None + actor: str + was_on_time: bool | None + + +class AdminHistoryResponse(BaseModel): + """The history query's answer. + + ``filter``/``start``/``end`` echo the request, ``count`` is the row + count, and ``rows`` are the core's rows in its deterministic order + (``due_date``, then instance, then event id — the per-instance + append order, so a completion and its reversal keep their order). + """ + + filter: str + start: str + end: str + count: int + rows: list[AdminHistoryRow] + + #: 404 detail for a child id the core layer reports as non-existent. _CHILD_NOT_FOUND_DETAIL = "Child not found" @@ -646,6 +721,21 @@ def _raise_regenerate_error(error: ValueError) -> NoReturn: raise HTTPException(status_code=422, detail=str(error)) from error +def _raise_history_error(error: ValueError) -> NoReturn: + """Map a core history ``ValueError`` onto 422 and raise it. + + The history routes have no path ids, so they have no 404 case: + every ``ValueError`` that survives to here is a rejected argument — + an unknown filter, a non-strict date, or an inverted range, each + raised by the core naming the offending field (``filter`` / + ``start`` / ``end``) BEFORE any read — and is 422 with the core's + message as the detail. Only ``ValueError`` is caught by the + handlers, 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. @@ -1294,4 +1384,109 @@ async def admin_regenerate( ) +def _history_row_response(row: core_history.HistoryRow) -> AdminHistoryRow: + """Serialize one core HistoryRow into the documented payload.""" + return AdminHistoryRow( + occurred_at=row.occurred_at, + event_type=row.event_type, + child_name=row.child_name, + child_id=row.child_id, + quest_title=row.quest_title, + instance_id=row.instance_id, + window=row.window, + due_date=row.due_date, + due_time=row.due_time, + actor=row.actor, + was_on_time=row.was_on_time, + ) + + +@router.get( + "/history", + summary="Query the completion history for one filter and range", + response_model=AdminHistoryResponse, +) +async def admin_history_query( + request: Request, + start: str, + end: str, + filter: str = "all", +) -> AdminHistoryResponse: + """Return the history rows for one filter over the closed range. + + 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: ``filter`` + (default ``all``), ``start`` and ``end`` go straight to + :func:`nestquest_core.history.query_history` as STRINGS — the + filter policy (``all`` / ``reversals`` / ``missed``) and the + date-range policy (strict ``YYYY-MM-DD``, ``end >= start``) live in + the core, which raises a ``ValueError`` naming the offending field; + the handler only threads ONE clock read (:func:`_local_now`) as the + missed derivation's ``today`` and serializes the returned rows. + ``start``/``end`` are required query parameters (missing is + FastAPI's 422 before any handler code runs); every core + ``ValueError`` is 422 (:func:`_raise_history_error`). + """ + state: DatabaseState = request.app.state.db + today = _local_now().date() + try: + rows = await core_history.query_history( + state.database, filter, start, end, today=today + ) + except ValueError as error: + _raise_history_error(error) + return AdminHistoryResponse( + filter=filter, + start=start, + end=end, + count=len(rows), + rows=[_history_row_response(row) for row in rows], + ) + + +@router.get( + "/history.csv", + summary="Export the completion history as CSV", + response_class=Response, +) +async def admin_history_csv( + request: Request, + start: str, + end: str, + filter: str = "all", +) -> Response: + """Export the SAME filtered history range as a CSV attachment. + + 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 over ONE + :func:`nestquest_core.history.export_history_csv` call — the same + query path the JSON route uses, so the CSV can never drift from the + query's filtered range — with the core's validation policy and the + :mod:`csv`-based serialization (stable documented header row, RFC + 4180 quoting) living in the core. The answer is ``text/csv`` with + a ``Content-Disposition`` attachment filename naming the range; + the range is core-validated strict ``YYYY-MM-DD`` by the time the + filename is built, so it is injection-safe. Error mapping is the + JSON route's (:func:`_raise_history_error`). + """ + state: DatabaseState = request.app.state.db + today = _local_now().date() + try: + csv_text = await core_history.export_history_csv( + state.database, filter, start, end, today=today + ) + except ValueError as error: + _raise_history_error(error) + filename = f"nestquest-history-{start}-to-{end}.csv" + return Response( + content=csv_text, + media_type="text/csv", + headers={ + "Content-Disposition": f'attachment; filename="{filename}"' + }, + ) + + __all__ = ["router"] diff --git a/custom_components/nestquest/core/history.py b/custom_components/nestquest/core/history.py new file mode 100644 index 0000000..5208af0 --- /dev/null +++ b/custom_components/nestquest/core/history.py @@ -0,0 +1,405 @@ +"""Read-only history layer for the admin plane (Admin spec §5, task 2d2dda53). + +Assembles the History screen's event rows over the append-only +completion log: a query over a CLOSED date range ``[start_date, +end_date]`` for one of three filters — + +- ``all`` — every completion event (completed AND uncompleted) whose + instance is due in the range; +- ``reversals`` — the uncompleted events only; +- ``missed`` — the DERIVED missed instances in the range. + +Range semantics (Feature 13's rule, kept): the range scopes by the +INSTANCE's ``due_date`` — the day the task was owed — not the event's +wall-clock ``occurred_at``, because history reporting asks "what +happened to the tasks due that day". ``missed`` is derived, never +stored: rows come from :func:`~.completion.list_missed_for_child` +(past-due open instances, the same ``_derived_state`` rule the +coordinator uses), and nothing is ever appended for them. + +Row shape: the Admin spec §5 event-row fields, fixed and documented in +:data:`CSV_COLUMNS` — ``occurred_at``, ``event_type``, +``child_name``, ``child_id``, ``quest_title``, ``instance_id``, +``window``, ``due_date``, ``due_time``, ``actor``, ``was_on_time``. +Derived missed rows carry ``event_type`` ``'missed'`` (never a stored +event_type), no ``occurred_at`` (there is no event), ``was_on_time`` +``None`` and the spec's ``nightly sweep`` actor. A stored event's +``actor`` follows the spec's meta shape: the ``actor_user_id`` string +for a user event (``joshua``), ``panel (Child)`` naming the tapped +profile for a panel event. + +Validation policy (module-level, shared by both the JSON query and the +CSV export): the filter must be one of :data:`FILTERS` and the range +must be two strict ``YYYY-MM-DD`` dates with ``end >= start``; +violations raise ``ValueError`` naming the offending field +(``filter`` / ``start`` / ``end``) BEFORE any read. The whole +household read runs under the task-reentrant connection lock so every +row of one answer is one snapshot — the same discipline +:mod:`.completion` gives its read-decide-write paths. + +:func:`rows_to_csv` serializes rows with the :mod:`csv` module (proper +quoting, one stable header row) and :func:`export_history_csv` runs +query + serialization as ONE call, so the CSV export can never drift +from the JSON query's filtered range. No permission check lives here +(that is the admin router's single ``require_admin`` dependency). +""" +from __future__ import annotations + +import csv +import datetime +import io +from dataclasses import dataclass + +from .completion import _reentrant_connection_lock, list_missed_for_child +from .dao_children import ChildRecord, ChildrenDao +from .dao_instances import ( + EVENT_UNCOMPLETED, + CompletionEventRecord, + CompletionEventsDao, + QuestInstanceRecord, + QuestInstancesDao, +) +from .dao_rules import QuestDefinitionsDao, _validate_date +from .db import NestQuestDatabase + +#: The ``all`` filter: every completion event in the range. +FILTER_ALL = "all" + +#: The ``reversals`` filter: uncompleted events only. +FILTER_REVERSALS = "reversals" + +#: The ``missed`` filter: derived missed instances in the range. +FILTER_MISSED = "missed" + +#: The filters the query accepts, in the Admin spec's chip order. +FILTERS = (FILTER_ALL, FILTER_REVERSALS, FILTER_MISSED) + +#: The ``event_type`` a derived missed row reports. ``missed`` is a +#: DERIVED state, never a stored event_type (Feature 08). +EVENT_MISSED = "missed" + +#: The actor a derived missed row reports (Admin spec §5 meta: +#: ``missed · nightly sweep``). +ACTOR_NIGHTLY_SWEEP = "nightly sweep" + +#: The CSV column set, in header order — the Admin spec §5 event-row +#: fields. The header row is exactly this tuple, always, so a consumer +#: can key on column names. +CSV_COLUMNS = ( + "occurred_at", + "event_type", + "child_name", + "child_id", + "quest_title", + "instance_id", + "window", + "due_date", + "due_time", + "actor", + "was_on_time", +) + + +@dataclass(frozen=True) +class HistoryRow: + """One history row: a stored event or a derived missed instance. + + The fields are :data:`CSV_COLUMNS` in order. For a derived missed + row ``occurred_at`` is ``None`` (no event exists) and ``was_on_time`` + is ``None``; for a stored event ``event_type`` is + ``'completed'``/``'uncompleted'`` and the fields come from the event + joined with its instance, child and definition. + """ + + occurred_at: str | None + event_type: str + child_name: str + child_id: int + quest_title: str + instance_id: int + window: str + due_date: str + due_time: str | None + actor: str + was_on_time: bool | None + + +def _validate_filter(filter_name: str) -> str: + """Return the filter, or raise ValueError naming the field.""" + if filter_name not in FILTERS: + raise ValueError( + f"filter must be one of {', '.join(repr(f) for f in FILTERS)}, " + f"got {filter_name!r}" + ) + return filter_name + + +def _validate_range(start_date: str, end_date: str) -> tuple[str, str]: + """Validate the closed range and return it, else raise naming the field. + + Both bounds must be strict ``YYYY-MM-DD`` dates (the shared + :func:`~.dao_rules._validate_date`, so non-padded or re-ordered + shapes are rejected and a non-string raises naming the field) and + ``end`` must be on or after ``start``. + """ + _validate_date(start_date, "start") + _validate_date(end_date, "end") + if end_date < start_date: + raise ValueError( + f"end must be on or after start, got {end_date!r} < " + f"{start_date!r}" + ) + return start_date, end_date + + +def _resolve_today(today: datetime.date | None) -> datetime.date: + """Return the threaded HA-local date, or the host calendar date.""" + if today is None: + return datetime.date.today() + return today + + +def _event_actor( + event: CompletionEventRecord, actor_child: ChildRecord | None +) -> str: + """Return the spec-meta actor string for one stored event. + + A user event reports the ``actor_user_id`` string (the spec's + ``uncompleted · joshua (admin)`` shape); a panel event reports + ``panel (Child)`` naming the tapped profile. The tapped child is + never hard-deleted, so the lookup below is a belt-and-braces + fallback to the stored id, never a silent rewrite of policy. + """ + if event.actor_source == "user": + return event.actor_user_id or "" + if actor_child is not None: + return f"panel ({actor_child.display_name})" + return f"panel ({event.actor_child_id})" + + +def _event_row( + event: CompletionEventRecord, + instance: QuestInstanceRecord, + child: ChildRecord, + quest_title: str, + actor_child: ChildRecord | None, +) -> HistoryRow: + """Join one stored event with its instance, child and definition.""" + return HistoryRow( + occurred_at=event.occurred_at, + event_type=event.event_type, + child_name=child.display_name, + child_id=event.child_id, + quest_title=quest_title, + instance_id=event.instance_id, + window=instance.window, + due_date=instance.due_date, + due_time=instance.due_time, + actor=_event_actor(event, actor_child), + was_on_time=event.was_on_time, + ) + + +def _missed_row( + instance: QuestInstanceRecord, + child: ChildRecord, + quest_title: str, +) -> HistoryRow: + """Build the derived row for one past-due open instance.""" + return HistoryRow( + occurred_at=None, + event_type=EVENT_MISSED, + child_name=child.display_name, + child_id=instance.child_id, + quest_title=quest_title, + instance_id=instance.id, + window=instance.window, + due_date=instance.due_date, + due_time=instance.due_time, + actor=ACTOR_NIGHTLY_SWEEP, + was_on_time=None, + ) + + +def _csv_values(row: HistoryRow) -> list[str]: + """Return one CSV row's cell values in :data:`CSV_COLUMNS` order. + + ``None`` cells serialize as empty (a missed row has no + ``occurred_at``); ``was_on_time`` serializes as ``true``/``false`` + when the event carries the flag and empty when it does not. + """ + return [ + row.occurred_at or "", + row.event_type, + row.child_name, + str(row.child_id), + row.quest_title, + str(row.instance_id), + row.window, + row.due_date, + row.due_time or "", + row.actor, + "" if row.was_on_time is None else str(row.was_on_time).lower(), + ] + + +def rows_to_csv(rows: list[HistoryRow]) -> str: + """Serialize history rows to CSV text with the documented header. + + The FIRST row is always :data:`CSV_COLUMNS`, so the header is + stable and deterministic; the :mod:`csv` module handles quoting + (titles or names containing commas or quotes are quoted per RFC + 4180) and CRLF line endings. + """ + buffer = io.StringIO() + writer = csv.writer(buffer) + writer.writerow(CSV_COLUMNS) + for row in rows: + writer.writerow(_csv_values(row)) + return buffer.getvalue() + + +async def query_history( + database: NestQuestDatabase, + filter_name: str, + start_date: str, + end_date: str, + *, + today: datetime.date | None = None, +) -> list[HistoryRow]: + """Return the history rows for ``filter_name`` over the closed range. + + Validates the filter and the strict ``YYYY-MM-DD`` range (raising + ``ValueError`` naming ``filter`` / ``start`` / ``end``) BEFORE any + read, then builds the rows: + + - ``all`` / ``reversals``: every child's events whose instances are + due in the range (:meth:`~.dao_instances.CompletionEventsDao.list_by_child_and_date_range`, + the Feature 13 due-date scope), ``reversals`` keeping only the + ``uncompleted`` rows. + - ``missed``: :func:`~.completion.list_missed_for_child` per child — + the SAME derived-state rule the coordinator uses, never a new one, + and never a stored event. + + Rows are ordered deterministically by ``due_date``, then instance, + then event id (the per-instance append order, so a completion and + its reversal keep their order). ``today`` pins the HA-local date + the missed derivation compares due dates against (the host calendar + date when omitted). The whole read runs under the task-reentrant + connection lock — :func:`list_missed_for_child` re-enters it — so + one answer is one snapshot. + """ + filter_name = _validate_filter(filter_name) + start_date, end_date = _validate_range(start_date, end_date) + today_date = _resolve_today(today) + + keyed: list[tuple[tuple[str, int, int], HistoryRow]] = [] + async with _reentrant_connection_lock(database): + children = await ChildrenDao(database).list_all() + children_by_id = {child.id: child for child in children} + instances = QuestInstancesDao(database) + definitions = QuestDefinitionsDao(database) + events = CompletionEventsDao(database) + titles: dict[int, str] = {} + + if filter_name == FILTER_MISSED: + for child in children: + for instance in await list_missed_for_child( + database, child.id, start_date, end_date, today=today_date + ): + if instance.definition_id not in titles: + definition = await definitions.get( + instance.definition_id + ) + assert definition is not None, ( + "a materialized instance's definition exists" + ) + titles[instance.definition_id] = definition.title + keyed.append( + ( + (instance.due_date, instance.id, 0), + _missed_row( + instance, + child, + titles[instance.definition_id], + ), + ) + ) + keyed.sort(key=lambda pair: pair[0]) + return [row for _, row in keyed] + + for child in children: + for event in await events.list_by_child_and_date_range( + child.id, start_date, end_date + ): + if filter_name == FILTER_REVERSALS and ( + event.event_type != EVENT_UNCOMPLETED + ): + continue + instance = await instances.get_by_id(event.instance_id) + assert instance is not None, ( + "an event's instance is never deleted (append-only " + "history keeps its references)" + ) + actor_child = ( + children_by_id.get(event.actor_child_id) + if event.actor_child_id is not None + else None + ) + if instance.definition_id not in titles: + definition = await definitions.get(instance.definition_id) + assert definition is not None, ( + "a materialized instance's definition exists" + ) + titles[instance.definition_id] = definition.title + keyed.append( + ( + (instance.due_date, instance.id, event.id), + _event_row( + event, + instance, + child, + titles[instance.definition_id], + actor_child, + ), + ) + ) + keyed.sort(key=lambda pair: pair[0]) + return [row for _, row in keyed] + + +async def export_history_csv( + database: NestQuestDatabase, + filter_name: str, + start_date: str, + end_date: str, + *, + today: datetime.date | None = None, +) -> str: + """Return the filtered history as CSV text, header row included. + + ONE query + serialization call so the CSV export carries exactly + the JSON query's filtered range and order — the same + :func:`query_history` validation, rows and :func:`rows_to_csv` + serializer, never a second implementation. + """ + rows = await query_history( + database, filter_name, start_date, end_date, today=today + ) + return rows_to_csv(rows) + + +__all__ = [ + "ACTOR_NIGHTLY_SWEEP", + "CSV_COLUMNS", + "EVENT_MISSED", + "EVENT_UNCOMPLETED", + "FILTERS", + "FILTER_ALL", + "FILTER_MISSED", + "FILTER_REVERSALS", + "HistoryRow", + "export_history_csv", + "query_history", + "rows_to_csv", +] diff --git a/tests/test_api_admin_history.py b/tests/test_api_admin_history.py new file mode 100644 index 0000000..5364f06 --- /dev/null +++ b/tests/test_api_admin_history.py @@ -0,0 +1,547 @@ +"""Admin-plane history route tests (task 2d2dda53). + +These tests exercise the admin plane's history query and CSV export +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 +household is seeded with :func:`tests.test_api_panel._seed_household` +through the SAME core copy the app uses (``nestquest_core.*``; the +two-copy caveat in api/nestquest_core.py), and the reversal the +history tests read is appended through that same copy's +:func:`nestquest_core.completion.uncomplete_instance` — so the rows +the routes report are the rows the core's own write paths produced. + +The seeded household (clock pinned to noon, the route's ``_local_now`` +pinned to the same instant so nothing straddles midnight): + +- Bo's ``Brush teeth`` TODAY instance: a ``completed`` event (panel + tap, on time) plus the appended ``uncompleted`` reversal — the + completion and the reversal the ``all`` and ``reversals`` filters + report. +- Ada's ``Pack bag`` and ``Stale chore`` YESTERDAY instances and Bo's + ``Brush teeth`` YESTERDAY instance: open, past due, no events — the + DERIVED missed rows the ``missed`` filter reports. + +Proven per the done-condition, everything read back through the API: + +- ``GET /api/v1/admin/history`` returns the expected rows for EACH + filter — ``all`` (the completed event AND the reversal), + ``reversals`` (the uncompleted event only) and ``missed`` (the + derived past-due open instances, actor ``nightly sweep``, no + ``occurred_at``) — and the range scopes by the INSTANCE's due date + (the Feature 13 rule), so the events sit under their instance's due + day, not the wall-clock day. +- ``GET /api/v1/admin/history.csv`` answers ``text/csv`` with a + ``Content-Disposition`` attachment filename, and the parsed body + (csv.reader) carries the documented header columns and exactly the + filtered rows. +- 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. +- Malformed input — an unknown filter, a non-strict date, an inverted + range, a missing parameter — is 422 on both routes (never a 500). +""" +from __future__ import annotations + +import csv +import datetime +import importlib +import io +from types import SimpleNamespace +from zoneinfo import ZoneInfo + +import pytest + +from api import routes_admin +from api.database import _executor +from tests.admin_jwt_harness import ( + ADMIN_KEY, + KID, + SUBJECT, + AdminRunner, + jwks_for, + make_token, +) +from tests.test_api_panel import _seed_household + +# The completion, instance-DAO and db layers through the SAME core copy +# the app uses (nestquest_core.*, NOT the conftest-stubbed +# custom_components copy) — used for the tests' seeding only; the +# routes reach them through api.nestquest_core. +_completion = importlib.import_module("nestquest_core.completion") +_dao_instances = importlib.import_module("nestquest_core.dao_instances") +_core_db = importlib.import_module("nestquest_core.db") + +#: The documented CSV columns (Admin spec §5 event-row fields), in +#: header order — asserted literally so the contract is pinned here, +#: independent of the core constant. +DOCUMENTED_CSV_COLUMNS = [ + "occurred_at", + "event_type", + "child_name", + "child_id", + "quest_title", + "instance_id", + "window", + "due_date", + "due_time", + "actor", + "was_on_time", +] + + +def _admin_headers() -> dict[str, str]: + """The Authorization header of a valid nestquest-admins JWT.""" + return {"Authorization": f"Bearer {make_token()}"} + + +def _pinned_noon() -> tuple[datetime.date, datetime.datetime]: + """One host clock read pinned to 12:00 UTC that day. + + The seed's completion, the reversal and the route's clock all + anchor to this instant (the reversal five minutes later), so + nothing can straddle midnight mid-test. + """ + today = datetime.datetime.now().astimezone().date() + return today, datetime.datetime( + today.year, today.month, today.day, 12, 0, 0, tzinfo=ZoneInfo("UTC") + ) + + +@pytest.fixture +async def history_household(temp_db_path: str, monkeypatch) -> SimpleNamespace: + """A seeded household served by the admin-plane app, clock pinned. + + Beyond :func:`_seed_household`'s rows (Bo's today completion, the + past-due open yesterday instances), the fixture appends ONE + ``uncompleted`` reversal on Bo's today instance through the core + completion layer — attributed to the harness subject, stamped five + minutes after the pinned completion — and exposes the yesterday + instances the missed filter must report. + """ + today, pinned_now = _pinned_noon() + yesterday = today - datetime.timedelta(days=1) + seed = await _seed_household(temp_db_path, today, pinned_now) + + database = _core_db.NestQuestDatabase(_executor) + await database.open(temp_db_path) + try: + instances = _dao_instances.QuestInstancesDao(database) + ada_yesterday = await instances.list_by_child_and_date( + seed.ada.id, yesterday.isoformat() + ) + pack_bag_yesterday = next( + record for record in ada_yesterday if record.due_time == "10:00" + ) + bo_yesterday = await instances.list_by_child_and_date( + seed.bo.id, yesterday.isoformat() + ) + brush_teeth_yesterday = next( + record for record in bo_yesterday if record.due_time == "17:00" + ) + await _completion.uncomplete_instance( + database, + seed.brush_teeth_instance.id, + actor_source="user", + actor_user_id=SUBJECT, + now=pinned_now + datetime.timedelta(minutes=5), + today=today, + ) + finally: + await database.close() + + monkeypatch.setattr(routes_admin, "_local_now", lambda: pinned_now) + runner = AdminRunner(temp_db_path, jwks_for(ADMIN_KEY, KID)) + async with runner as client: + yield SimpleNamespace( + client=client, + seed=seed, + database=runner.app.state.db.database, + today=today, + yesterday=yesterday, + pack_bag_yesterday=pack_bag_yesterday, + brush_teeth_yesterday=brush_teeth_yesterday, + ) + + +# --- authentication: the ONE require_admin dependency guards both routes ---- + + +async def test_history_routes_refuse_non_admin_and_absent( + history_household: SimpleNamespace, +) -> None: + """A non-admin JWT is 403, an absent credential is 401, both routes.""" + client = history_household.client + query = f"start={history_household.yesterday.isoformat()}" + query += f"&end={history_household.today.isoformat()}" + non_admin = make_token(groups=("some-other-group",)) + for path in ("/api/v1/admin/history", "/api/v1/admin/history.csv"): + forbidden = await client.get( + f"{path}?{query}", + headers={"Authorization": f"Bearer {non_admin}"}, + ) + assert forbidden.status_code == 403, path + assert forbidden.json()["detail"] == ( + "Admin access requires membership in the nestquest-admins group" + ) + unauthenticated = await client.get(f"{path}?{query}") + assert unauthenticated.status_code == 401, path + + +# --- the JSON history query: the expected rows per filter -------------------- + + +async def test_history_all_returns_completed_and_reversal( + history_household: SimpleNamespace, +) -> None: + """``all`` returns the completed event AND the reversal, in order. + + Both events belong to Bo's Brush teeth instance due TODAY, so the + due-date-scoped range [yesterday, today] reports them under today; + the rows are ordered by due date, then instance, then event id — + the completed row before its reversal. + """ + client = history_household.client + seed = history_household.seed + brush = seed.brush_teeth_instance + yesterday_iso = history_household.yesterday.isoformat() + today_iso = history_household.today.isoformat() + + response = await client.get( + "/api/v1/admin/history", + params={"filter": "all", "start": yesterday_iso, "end": today_iso}, + headers=_admin_headers(), + ) + assert response.status_code == 200 + body = response.json() + assert body["filter"] == "all" + assert body["start"] == yesterday_iso + assert body["end"] == today_iso + assert body["count"] == 2 + + completed, reversal = body["rows"] + assert completed == { + "occurred_at": f"{today_iso}T12:00:00+00:00", + "event_type": "completed", + "child_name": "Bo", + "child_id": brush.child_id, + "quest_title": "Brush teeth", + "instance_id": brush.id, + "window": "morning", + "due_date": today_iso, + "due_time": "17:00", + "actor": "panel (Bo)", + "was_on_time": True, + } + assert reversal == { + "occurred_at": f"{today_iso}T12:05:00+00:00", + "event_type": "uncompleted", + "child_name": "Bo", + "child_id": brush.child_id, + "quest_title": "Brush teeth", + "instance_id": brush.id, + "window": "morning", + "due_date": today_iso, + "due_time": "17:00", + "actor": SUBJECT, + "was_on_time": None, + } + + +async def test_history_reversals_returns_only_the_uncompleted_event( + history_household: SimpleNamespace, +) -> None: + """``reversals`` returns the uncompleted event only, user-attributed.""" + client = history_household.client + seed = history_household.seed + brush = seed.brush_teeth_instance + today_iso = history_household.today.isoformat() + + response = await client.get( + "/api/v1/admin/history", + params={ + "filter": "reversals", + "start": today_iso, + "end": today_iso, + }, + headers=_admin_headers(), + ) + assert response.status_code == 200 + body = response.json() + assert body["filter"] == "reversals" + assert body["count"] == 1 + (reversal,) = body["rows"] + assert reversal["event_type"] == "uncompleted" + assert reversal["instance_id"] == brush.id + assert reversal["actor"] == SUBJECT + assert reversal["occurred_at"] == f"{today_iso}T12:05:00+00:00" + assert reversal["was_on_time"] is None + + +async def test_history_missed_returns_derived_missed_instances( + history_household: SimpleNamespace, +) -> None: + """``missed`` returns the DERIVED past-due open instances. + + Three seeded instances are open and due yesterday: Ada's Pack bag + and Stale chore and Bo's Brush teeth. Each is a DERIVED row — + never a stored event: no ``occurred_at``, ``was_on_time`` None and + the spec's ``nightly sweep`` actor. + """ + client = history_household.client + seed = history_household.seed + yesterday_iso = history_household.yesterday.isoformat() + + response = await client.get( + "/api/v1/admin/history", + params={ + "filter": "missed", + "start": yesterday_iso, + "end": yesterday_iso, + }, + headers=_admin_headers(), + ) + assert response.status_code == 200 + body = response.json() + assert body["filter"] == "missed" + assert body["count"] == 3 + + rows = body["rows"] + assert all(row["event_type"] == "missed" for row in rows) + assert all(row["occurred_at"] is None for row in rows) + assert all(row["was_on_time"] is None for row in rows) + assert all(row["actor"] == "nightly sweep" for row in rows) + assert all(row["due_date"] == yesterday_iso for row in rows) + assert { + (row["child_name"], row["quest_title"], row["due_time"]) + for row in rows + } == { + ("Ada", "Pack bag", "10:00"), + ("Bo", "Brush teeth", "17:00"), + ("Ada", "Stale chore", "09:00"), + } + assert { + row["instance_id"] + for row in rows + } == { + history_household.pack_bag_yesterday.id, + history_household.brush_teeth_yesterday.id, + seed.stale_instance.id, + } + # Deterministic order: due date, then instance id. + keys = [(row["due_date"], row["instance_id"]) for row in rows] + assert keys == sorted(keys) + + +async def test_history_range_scopes_by_the_instance_due_date( + history_household: SimpleNamespace, +) -> None: + """The range scopes by the INSTANCE's due date, not the wall clock. + + Both events OCCURRED today but belong to the instance due today, so + a yesterday-only ``all`` range reports nothing; the missed filter + over a today-only range reports nothing either (a due-TODAY open + instance is not past due). + """ + client = history_household.client + yesterday_iso = history_household.yesterday.isoformat() + today_iso = history_household.today.isoformat() + + yesterday_all = await client.get( + "/api/v1/admin/history", + params={"filter": "all", "start": yesterday_iso, "end": yesterday_iso}, + headers=_admin_headers(), + ) + assert yesterday_all.status_code == 200 + assert yesterday_all.json()["count"] == 0 + + today_missed = await client.get( + "/api/v1/admin/history", + params={"filter": "missed", "start": today_iso, "end": today_iso}, + headers=_admin_headers(), + ) + assert today_missed.status_code == 200 + assert today_missed.json()["count"] == 0 + + +# --- the CSV export: documented columns and the filtered rows ---------------- + + +async def test_history_csv_carries_documented_columns_and_all_rows( + history_household: SimpleNamespace, +) -> None: + """The CSV export carries the documented header and the filtered rows. + + The body is parsed with csv.reader: the first row is the documented + column set, the remaining rows are exactly the ``all`` filter's + rows (the completion and its reversal) in the same order the JSON + query reports them. + """ + client = history_household.client + seed = history_household.seed + brush = seed.brush_teeth_instance + yesterday_iso = history_household.yesterday.isoformat() + today_iso = history_household.today.isoformat() + + response = await client.get( + "/api/v1/admin/history.csv", + params={"filter": "all", "start": yesterday_iso, "end": today_iso}, + headers=_admin_headers(), + ) + assert response.status_code == 200 + assert response.headers["content-type"].startswith("text/csv") + disposition = response.headers["content-disposition"] + assert disposition.startswith("attachment;") + assert ( + f'filename="nestquest-history-{yesterday_iso}-to-{today_iso}.csv"' + in disposition + ) + + parsed = list(csv.reader(io.StringIO(response.text))) + assert parsed[0] == DOCUMENTED_CSV_COLUMNS + assert len(parsed) == 3 # header + completed + reversal + assert parsed[1] == [ + f"{today_iso}T12:00:00+00:00", + "completed", + "Bo", + str(brush.child_id), + "Brush teeth", + str(brush.id), + "morning", + today_iso, + "17:00", + "panel (Bo)", + "true", + ] + assert parsed[2] == [ + f"{today_iso}T12:05:00+00:00", + "uncompleted", + "Bo", + str(brush.child_id), + "Brush teeth", + str(brush.id), + "morning", + today_iso, + "17:00", + SUBJECT, + "", + ] + + +async def test_history_csv_missed_rows_are_derived_rows( + history_household: SimpleNamespace, +) -> None: + """The CSV export of the ``missed`` filter carries the derived rows. + + Same filtered range as the JSON query's missed answer: three rows + with an empty ``occurred_at`` (no event exists), the ``missed`` + event type and the ``nightly sweep`` actor. + """ + client = history_household.client + seed = history_household.seed + yesterday_iso = history_household.yesterday.isoformat() + + response = await client.get( + "/api/v1/admin/history.csv", + params={ + "filter": "missed", + "start": yesterday_iso, + "end": yesterday_iso, + }, + headers=_admin_headers(), + ) + assert response.status_code == 200 + parsed = list(csv.reader(io.StringIO(response.text))) + assert parsed[0] == DOCUMENTED_CSV_COLUMNS + assert len(parsed) == 4 # header + the three derived missed rows + missed_rows = parsed[1:] + assert all(row[1] == "missed" for row in missed_rows) + assert all(row[0] == "" for row in missed_rows) + assert all(row[9] == "nightly sweep" for row in missed_rows) + assert all(row[10] == "" for row in missed_rows) + assert { + (row[2], row[4], row[8]) for row in missed_rows + } == { + ("Ada", "Pack bag", "10:00"), + ("Bo", "Brush teeth", "17:00"), + ("Ada", "Stale chore", "09:00"), + } + assert {row[5] for row in missed_rows} == { + str(history_household.pack_bag_yesterday.id), + str(history_household.brush_teeth_yesterday.id), + str(seed.stale_instance.id), + } + + +# --- validation: malformed filter and date range are 422, never 500 ---------- + + +@pytest.mark.parametrize( + "params", + [ + {"filter": "everything", "start": "2026-03-01", "end": "2026-03-02"}, + {"filter": "all", "start": "2026/03/01", "end": "2026-03-02"}, + {"filter": "all", "start": "2026-3-1", "end": "2026-03-02"}, + {"filter": "all", "start": "2026-03-02", "end": "2026-03-01"}, + {"filter": "all", "start": "not-a-date", "end": "2026-03-02"}, + ], +) +async def test_history_query_rejects_malformed_params_as_422( + history_household: SimpleNamespace, params: dict[str, str] +) -> None: + """An unknown filter or a rejected range is 422 naming the field.""" + client = history_household.client + response = await client.get( + "/api/v1/admin/history", params=params, headers=_admin_headers() + ) + assert response.status_code == 422, params + detail = response.json()["detail"] + assert any( + name in detail for name in ("filter", "start", "end") + ), detail + + +async def test_history_csv_rejects_malformed_params_as_422( + history_household: SimpleNamespace, +) -> None: + """The CSV route maps the same rejections to 422.""" + client = history_household.client + unknown_filter = await client.get( + "/api/v1/admin/history.csv", + params={"filter": "galaxy", "start": "2026-03-01", "end": "2026-03-02"}, + headers=_admin_headers(), + ) + assert unknown_filter.status_code == 422 + assert "filter" in unknown_filter.json()["detail"] + + inverted = await client.get( + "/api/v1/admin/history.csv", + params={"filter": "all", "start": "2026-03-02", "end": "2026-03-01"}, + headers=_admin_headers(), + ) + assert inverted.status_code == 422 + assert "end must be on or after start" in inverted.json()["detail"] + + +async def test_history_query_missing_params_are_422( + history_household: SimpleNamespace, +) -> None: + """A missing required query parameter is FastAPI's 422 (the shape).""" + client = history_household.client + no_params = await client.get( + "/api/v1/admin/history", headers=_admin_headers() + ) + assert no_params.status_code == 422 + missing_start = await client.get( + "/api/v1/admin/history", + params={"end": "2026-03-02"}, + headers=_admin_headers(), + ) + assert missing_start.status_code == 422 + missing_end = await client.get( + "/api/v1/admin/history", + params={"start": "2026-03-01"}, + headers=_admin_headers(), + ) + assert missing_end.status_code == 422 -- 2.55.0 From fa4415c0d565efa92dfd111fba954c087af7bb41 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 03:46:46 -0400 Subject: [PATCH 14/15] Admin routes: settings read and update (#163) --- api/nestquest_core.py | 12 + api/routes_admin.py | 146 +++++++ .../nestquest/core/settings_store.py | 210 +++++++++ tests/test_api_admin_settings.py | 398 ++++++++++++++++++ 4 files changed, 766 insertions(+) create mode 100644 custom_components/nestquest/core/settings_store.py create mode 100644 tests/test_api_admin_settings.py 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 -- 2.55.0 From b1dbb8e1a5fa3ddd7437ad97281cb0a36e2119b2 Mon Sep 17 00:00:00 2001 From: dev_agent <17+dev_agent@code.cubecraftcreations.com> Date: Wed, 23 Sep 2026 04:23:38 -0400 Subject: [PATCH 15/15] Run the missed sweep in the API and publish quest-missed on SSE (task 058c7b69) (#164) --- api/app.py | 13 +- api/nestquest_core.py | 7 + api/routes_admin.py | 94 +++ api/scheduler.py | 173 ++++++ custom_components/nestquest/core/sweep.py | 143 +++++ custom_components/nestquest/sweep.py | 132 ++--- tests/test_api_missed_sweep.py | 671 ++++++++++++++++++++++ 7 files changed, 1136 insertions(+), 97 deletions(-) create mode 100644 api/scheduler.py create mode 100644 custom_components/nestquest/core/sweep.py create mode 100644 tests/test_api_missed_sweep.py diff --git a/api/app.py b/api/app.py index d14c09f..be6a525 100644 --- a/api/app.py +++ b/api/app.py @@ -48,6 +48,7 @@ from api.database import DatabaseState, make_database from api.events import TransitionPublisher from api.routes_admin import router as admin_router from api.routes_panel import router as panel_router +from api.scheduler import MissedSweepScheduler def _build_lifespan(db_path: str): @@ -57,7 +58,11 @@ def _build_lifespan(db_path: str): path in without threading it through FastAPI's lifespan signature. The lifespan also installs the app's ONE in-process transition publisher on ``app.state.publisher`` (see :mod:`api.events`) so - every route and SSE subscriber shares that single instance. + every route and SSE subscriber shares that single instance, and + starts the daily missed-sweep scheduler + (:class:`api.scheduler.MissedSweepScheduler`) against the same + database and publisher — cancelled before the connection closes on + shutdown. """ @asynccontextmanager @@ -65,10 +70,16 @@ def _build_lifespan(db_path: str): state = make_database(db_path) app.state.db = state app.state.publisher = TransitionPublisher() + scheduler = MissedSweepScheduler( + state.database, publisher=app.state.publisher + ) + app.state.sweep_scheduler = scheduler try: await state.open_and_migrate() + scheduler.start() yield finally: + await scheduler.stop() await state.close() return lifespan diff --git a/api/nestquest_core.py b/api/nestquest_core.py index 5cfb60d..1fe8408 100644 --- a/api/nestquest_core.py +++ b/api/nestquest_core.py @@ -198,6 +198,12 @@ core_dao_children = importlib.import_module( #: delegating. core_dao_rules = importlib.import_module(f"{_CORE_MODULE_NAME}.dao_rules") +#: The HA-free missed sweep (``core.sweep.run_missed_sweep`` + +#: ``SWEEP_WATERMARK_KEY``) the admin plane's trigger route calls and +#: the daily scheduler runs — the ONE sweep policy the integration's +#: bus shim shares. +core_sweep = importlib.import_module(f"{_CORE_MODULE_NAME}.sweep") + __all__ = [ "core", "core_children", @@ -218,4 +224,5 @@ __all__ = [ "core_snapshot", "core_settings", "core_settings_store", + "core_sweep", ] diff --git a/api/routes_admin.py b/api/routes_admin.py index 6c8ff9a..21d3e4c 100644 --- a/api/routes_admin.py +++ b/api/routes_admin.py @@ -139,6 +139,23 @@ are deliberately not unified here: module's uniform None-as-absent rule). The response is the updated effective settings, the same shape ``GET`` returns. +The missed-sweep trigger (task 058c7b69) follows the same pattern over +:mod:`nestquest_core.sweep` — the HA-free sweep policy the +integration's rollover listener shares: + +- ``POST /missed-sweep`` runs the sweep for the API host's local + ``today`` (ONE :func:`_local_now` clock read threaded into the core + call) and publishes each returned ``(event_type, payload)`` pair + DIRECTLY on the app's ONE transition publisher — the sweep-built + payload, never re-fetched (the core commits its watermark before + returning, so a failed re-fetch after it would lose the missed event + forever), the same publish-what-the-core-built shape + api/scheduler.py uses. The watermark rule + (a same-day rerun announces nothing, a post-downtime run sweeps the + accumulated window once), the no-completion-event rule and the + read-only-over-the-domain-tables guarantee all live in the core; the + response reports how many transitions were published. + Error mapping (every children, quest-definition and presence route, deliberately narrow): @@ -237,6 +254,7 @@ from api.nestquest_core import ( core_recurrence, core_settings, core_settings_store, + core_sweep, ) #: All admin-plane routes share this router; the ONE admin dependency @@ -654,6 +672,17 @@ class AdminSettingsResponse(BaseModel): celebration_enabled: bool +class AdminMissedSweepResponse(BaseModel): + """The result of ``POST /api/v1/admin/missed-sweep``. + + ``fired`` is the number of ``nestquest_quest_missed`` transitions + the run built AND this route published on the SSE stream — zero + when the sweep's watermark already covers today (a same-day rerun). + """ + + fired: int + + #: 404 detail for a child id the core layer reports as non-existent. _CHILD_NOT_FOUND_DETAIL = "Child not found" @@ -803,6 +832,19 @@ def _raise_settings_error(error: ValueError) -> NoReturn: raise HTTPException(status_code=422, detail=str(error)) from error +def _raise_sweep_error(error: ValueError) -> NoReturn: + """Map a core missed-sweep ``ValueError`` onto 422 and raise it. + + The sweep route has no path ids, so it has no 404 case: a core + ``ValueError`` — a rejected date the sweep's query refuses before + any read, unreachable from this handler because ``today`` comes + from the route's own clock read — 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. @@ -1635,4 +1677,56 @@ async def admin_update_settings( return _settings_response(settings) +@router.post( + "/missed-sweep", + summary="Run the nightly missed-quest sweep now", + response_model=AdminMissedSweepResponse, +) +async def admin_run_missed_sweep( + request: Request, +) -> AdminMissedSweepResponse: + """Run the missed-quest sweep and publish what it built. + + 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 over the SAME + HA-free sweep policy the integration's rollover listener runs + (:mod:`nestquest_core.sweep`): ONE timezone-aware clock read + (:func:`_local_now`) pins the API host's local ``today``, and ONE + :func:`nestquest_core.sweep.run_missed_sweep` call does everything + else — the past-due open-instance query, the documented payload + build and the watermark update (a same-day rerun is an empty + no-op, and a run after downtime sweeps the accumulated window + once). The route is read-only over the domain tables: it never + mutates an instance and never writes a completion event. + + Each returned ``(event_type, payload)`` pair is published DIRECTLY + on the app's ONE transition publisher (``request.app.state.publisher``, + fan-out fire-and-forget, see api/events.py) — the exact payload the + sweep built, never re-fetched and never hand-built here, the same + publish-what-the-core-built shape api/scheduler.py uses. This is a + correctness rule, not a style preference: the core commits the + idempotency watermark BEFORE returning, so a re-fetch-based publish + loop opens a loss window — a concurrent regenerate deleting the + instance, or a transient database error, would raise uncaught (a + bare 500) AFTER the watermark advanced, and no future sweep would + ever re-examine that instance. Publishing the built payload leaves + no such window, and every event of one run keeps the ONE shared + ``occurred_at`` stamp the sweep computed for it. The response + reports ``fired`` — the number of transitions published. Error + mapping (:func:`_raise_sweep_error`): every core ``ValueError`` is + 422; anything else re-raises. + """ + state: DatabaseState = request.app.state.db + database = state.database + today = _local_now().date() + try: + events = await core_sweep.run_missed_sweep(database, today=today) + except ValueError as error: + _raise_sweep_error(error) + for event_type, payload in events: + request.app.state.publisher.publish(event_type, payload) + return AdminMissedSweepResponse(fired=len(events)) + + __all__ = ["router"] diff --git a/api/scheduler.py b/api/scheduler.py new file mode 100644 index 0000000..32dcc07 --- /dev/null +++ b/api/scheduler.py @@ -0,0 +1,173 @@ +"""Daily missed-sweep background scheduler for the API service. + +The API service has no Home Assistant, so nobody re-fires the +integration's day-rollover listener for it: this module owns the API +plane's own background job. One :class:`MissedSweepScheduler` per app +(installed on ``app.state`` by the lifespan) sleeps until the +configured ``day_rollover_time`` — read fresh from the settings store +at the top of EVERY cycle, so an admin's settings change applies on +the next scheduling decision without a restart — then runs the +HA-free core's :func:`nestquest_core.sweep.run_missed_sweep` for the +API host's local date and publishes each returned +``nestquest_quest_missed`` event on the app's ONE transition publisher +(the same SSE stream every route publishes through). + +All sweep policy lives in the core (the watermark, the +``[watermark, today)`` window, the no-completion-event rule): this +scheduler only decides WHEN. A same-night rerun — e.g. two workers, +or a settings change moving the rollover past itself — fires nothing: +the core's watermark makes the run an empty no-op. + +Testability: ``clock`` (an aware-now callable) and ``sleep`` (an +awaitable delay) are injectable, so tests pin the time, fast-forward +through the wait, and observe the computed delays without waiting a +real day. Cancellation: :meth:`stop` cancels the loop task and waits +for it, so the lifespan's shutdown is clean and a mid-run sweep is +interrupted before the connection closes. A failed iteration (a +database hiccup, a rejected setting) is logged and retried after a +fixed backoff rather than silently killing the job. +""" +from __future__ import annotations + +import asyncio +import datetime +import logging + +from api.nestquest_core import core_settings_store, core_sweep + +LOGGER = logging.getLogger(__name__) + +#: Backoff after a failed scheduling cycle (settings read or sweep +#: error), so a persistent failure retries every minute instead of +#: spinning or dying. +_RETRY_BACKOFF_SECONDS = 60.0 + + +def _delay_seconds(target: datetime.time, now: datetime.datetime) -> float: + """Seconds from ``now`` until the next occurrence of ``target``. + + ``target`` is a plain ``HH:MM`` time of day: the next wall-clock + occurrence strictly AFTER ``now`` (an equal time schedules for + tomorrow, so the just-run sweep is never instantly re-run). + """ + scheduled = now.replace( + hour=target.hour, minute=target.minute, second=0, microsecond=0 + ) + if scheduled <= now: + scheduled += datetime.timedelta(days=1) + return (scheduled - now).total_seconds() + + +class MissedSweepScheduler: + """Run the missed sweep once per day at ``day_rollover_time``.""" + + def __init__( + self, + database: object, + *, + publisher: object | None = None, + clock: object | None = None, + sleep: object | None = None, + ) -> None: + """Create the scheduler (not yet running). + + ``database`` is the app's live :class:`~api.database.NestQuestDatabase`. + ``publisher`` is the app's ONE + :class:`~api.events.TransitionPublisher` (publishing is skipped + when ``None``). ``clock`` defaults to the API host's local + aware now (``datetime.datetime.now().astimezone()``) — the same + single-clock discipline the routes' ``_local_now`` uses; + ``sleep`` defaults to :func:`asyncio.sleep`. + """ + self._database = database + self._publisher = publisher + self._clock = clock if clock is not None else ( + lambda: datetime.datetime.now().astimezone() + ) + self._sleep = sleep if sleep is not None else asyncio.sleep + self._task: asyncio.Task | None = None + + def start(self) -> None: + """Start the background loop (idempotent: a running one stays).""" + if self._task is None or self._task.done(): + self._task = asyncio.create_task( + self._run_forever(), name="nestquest-missed-sweep" + ) + + async def stop(self) -> None: + """Cancel the loop and wait for it to finish (safe if never started).""" + if self._task is not None: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + self._task = None + + @property + def running(self) -> bool: + """Whether the background loop task is alive.""" + return self._task is not None and not self._task.done() + + async def _run_forever(self) -> None: + """Schedule → sweep → repeat, forever (until cancelled).""" + while True: + try: + settings = await core_settings_store.load_settings( + self._database + ) + rollover = datetime.time.fromisoformat( + settings.day_rollover_time + ) + except Exception: + # Not silent: the failure is logged and retried after + # the backoff, so one bad settings document or a + # transient database error never kills the scheduler. + LOGGER.exception( + "NestQuest missed-sweep scheduler failed to resolve " + "the day rollover time; retrying in %gs", + _RETRY_BACKOFF_SECONDS, + ) + await self._sleep(_RETRY_BACKOFF_SECONDS) + continue + delay = _delay_seconds(rollover, self._clock()) + LOGGER.info( + "NestQuest missed sweep scheduled for %s (%.0fs from now)", + settings.day_rollover_time, + delay, + ) + await self._sleep(delay) + try: + await self._run_once(self._clock().date()) + except Exception: + # Not silent either: the sweep error is logged and the + # loop backs off before the next cycle, whose watermark + # re-read makes an already-swept day a no-op. + LOGGER.exception( + "NestQuest scheduled missed sweep failed; retrying in " + "%gs", + _RETRY_BACKOFF_SECONDS, + ) + await self._sleep(_RETRY_BACKOFF_SECONDS) + + async def _run_once(self, today: datetime.date) -> None: + """Run ONE sweep for ``today`` and publish what it built. + + A failure propagates to :meth:`_run_forever`, which logs it and + backs off before the next cycle; the watermark makes a re-run + of an already-swept day announce nothing twice. + """ + events = await core_sweep.run_missed_sweep( + self._database, today=today + ) + for event_type, payload in events: + if self._publisher is not None: + self._publisher.publish(event_type, payload) + LOGGER.info( + "NestQuest scheduled missed sweep for %s published %d event(s)", + today.isoformat(), + len(events), + ) + + +__all__ = ["MissedSweepScheduler"] diff --git a/custom_components/nestquest/core/sweep.py b/custom_components/nestquest/core/sweep.py new file mode 100644 index 0000000..346893a --- /dev/null +++ b/custom_components/nestquest/core/sweep.py @@ -0,0 +1,143 @@ +"""The nightly missed-quest sweep, Home-Assistant-free (Feature 11). + +This is the HA-free heart of the missed sweep, shared by the two +planes: the Home Assistant integration's :mod:`~..sweep` shim (which +fires the returned events on ``hass.bus``) and the API service (which +publishes them on the SSE stream and can also trigger the run on +demand). It owns the ONE sweep policy: + +At the configured day-rollover time (or on an explicit trigger) this +job finds every instance whose ``due_date`` is before the +household-local date, that has NO completion event, and that falls +after the persisted sweep watermark — and RETURNS one +``nestquest_quest_missed`` event per instance with the documented +payload. Like every core module, it never touches the Home Assistant +event bus and never mutates an instance: ``missed`` is derived state, +never an event_type (Feature 08's contract), so the sweep is read-only +over the domain tables. + +Idempotency is the watermark: after a run, ``nestquest_meta_state`` +records the household-local date the sweep ran, and the next run only +sweeps instances due in ``[watermark, today)``. A second run the same +night therefore fires nothing new, and a run after days of downtime +sweeps exactly the accumulated window once — no per-instance journal, +no re-announcing old misses. An instance swept as missed that is +later completed simply gains its completion event; history keeps both +rows (append-only), and the sweep's later runs skip it via the +no-completion-event rule. + +The whole watermark read → query → build → write span runs inside the +per-database sweep lock (one lock per connection wrapper and running +loop, the settings store's pattern): an overlapping startup sweep and +a rollover sweep queue instead of interleaving, and the loser re-reads +the watermark INSIDE the lock — sees the winner's date — and announces +nothing twice. + +The caller supplies ``today`` as a plain :class:`datetime.date`: the +household-local calendar date, read from ``hass.config.time_zone`` by +the integration or from the API host's local clock by the API. This +module never reads a timezone database or config of its own. +""" +from __future__ import annotations + +import asyncio +import datetime +import logging + +from .const import EVENT_QUEST_MISSED +from .dao_meta import MetaStateDao +from .dao_instances import QuestInstancesDao +from .db import NestQuestDatabase +# Intentional intra-package reuse of the shared payload builder: the +# missed event's payload is the documented §3 instance payload, and +# building it through the ONE builder keeps this sweep's payloads from +# drifting from the completion/uncompleted builders' shape. +from .events import _instance_payload + +LOGGER = logging.getLogger(__name__) + +#: meta_state key holding the household-local date the sweep last ran. +SWEEP_WATERMARK_KEY = "missed_sweep_last_run_date" + +#: One asyncio.Lock per (connection wrapper, running loop), mirroring +#: the settings store's pattern: the whole watermark read → query → +#: build → write span is one critical section, so an overlapping +#: startup sweep and rollover sweep queue instead of double-announcing. +#: The loser re-reads the watermark INSIDE the lock, sees the winner's +#: date, and fires nothing. +_SWEEP_LOCKS: dict[tuple[int, int], asyncio.Lock] = {} + + +def _sweep_lock(database: NestQuestDatabase) -> asyncio.Lock: + """Return the sweep lock bound to ``database``'s running loop.""" + loop = asyncio.get_running_loop() + key = (id(database), id(loop)) + lock = _SWEEP_LOCKS.get(key) + if lock is None: + lock = asyncio.Lock() + _SWEEP_LOCKS[key] = lock + return lock + + +#: Strict UTC ISO-8601 timestamp (the DAO layers' policy): one shape, +#: explicit offset, second precision. +_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00" + + +async def run_missed_sweep( + database: NestQuestDatabase, + *, + today: datetime.date, +) -> list[tuple[str, dict]]: + """Build one missed event per unswept past-due open instance. + + ``today`` is the household-local calendar date the sweep runs for + (the caller owns the clock: the integration reads it from + ``hass.config.time_zone``, the API from its host's local time). + Returns the ``(event_type, payload)`` tuples to announce, EMPTY + when the watermark already covers ``today`` (a same-night rerun) — + the caller decides how to publish them and announces nothing on + its own. + + The whole watermark read → query → build → write span runs inside + the per-database sweep lock: an overlapping startup sweep and a + rollover sweep queue instead of interleaving, and the loser + re-reads the watermark inside the lock — sees the winner's date — + and announces nothing twice. Every event in one run shares one + ``occurred_at`` stamp (one run, one announcement moment), and the + payload comes from the shared + :func:`~.events._instance_payload` builder so the shape can never + drift from the documented §3 contract. + """ + today_iso = today.isoformat() + dao = MetaStateDao(database) + async with _sweep_lock(database): + watermark = await dao.get(SWEEP_WATERMARK_KEY) + if watermark == today_iso: + LOGGER.debug( + "NestQuest missed sweep already ran for %s", today_iso + ) + return [] + + instances_dao = QuestInstancesDao(database) + rows = await instances_dao.list_missed_unswept(today_iso, watermark) + now_stamp = datetime.datetime.now(datetime.timezone.utc).strftime( + _UTC_TIMESTAMP_FORMAT + ) + events: list[tuple[str, dict]] = [] + for instance in rows: + events.append( + ( + EVENT_QUEST_MISSED, + await _instance_payload( + database, instance.id, now_stamp + ), + ) + ) + await dao.set(SWEEP_WATERMARK_KEY, today_iso) + LOGGER.info( + "NestQuest missed sweep for %s built %d event(s)", + today_iso, + len(events), + ) + return events diff --git a/custom_components/nestquest/sweep.py b/custom_components/nestquest/sweep.py index e60ab72..937f969 100644 --- a/custom_components/nestquest/sweep.py +++ b/custom_components/nestquest/sweep.py @@ -1,70 +1,40 @@ -"""The nightly missed-quest sweep (Feature 11). +"""The nightly missed-quest sweep (Feature 11) — the Home Assistant shim. -At the configured day-rollover time (the same HA-local listener that -re-materializes the rolling horizon) this job finds every instance -whose ``due_date`` is before the HA-local date, that has NO completion -event, and that falls after the persisted sweep watermark — and fires -ONE ``nestquest_quest_missed`` event per instance with the documented -payload. The sweep is read-only over the domain tables: it never -mutates an instance and never writes a completion event (``missed`` -is derived state, never an event_type — Feature 08's contract). +The sweep's policy is the HA-free core's :mod:`~.core.sweep`: the +watermark read → ``list_missed_unswept`` query → payload build → +watermark span, all inside the per-database sweep lock, shared with +the API plane. This module is the integration's thin adapter over it: +at the configured day-rollover time (the same HA-local listener that +re-materializes the rolling horizon) and once at setup it calls the +core's :func:`~.core.sweep.run_missed_sweep` and fires each returned +event on ``hass.bus`` — ONE ``nestquest_quest_missed`` per past-due +open instance with the documented payload. The core never touches +Home Assistant; the firing lives here. -Idempotency is the watermark: after a run, ``nestquest_meta_state`` -records the HA-local date the sweep ran, and the next run only -sweeps instances due in ``[watermark, today)``. A second run the -same night therefore fires nothing new, and a run after days of -downtime sweeps exactly the accumulated window once — no -per-instance journal, no re-announcing old misses. An instance swept -as missed that is later completed simply gains its completion event; -history keeps both rows (append-only), and the sweep's later runs -skip it via the no-completion-event rule. +Idempotency is the core's watermark: after a run, ``nestquest_meta_state`` +records the HA-local date the sweep ran, and the next run only sweeps +instances due in ``[watermark, today)`` — a second run the same night +fires nothing new, and a run after days of downtime sweeps exactly the +accumulated window once. An instance swept as missed that is later +completed simply gains its completion event; the sweep is read-only +over the domain tables (``missed`` is derived state, never an +event_type — Feature 08's contract). """ from __future__ import annotations -import asyncio import datetime import logging from zoneinfo import ZoneInfo -from .const import EVENT_QUEST_MISSED -from .dao_children import ChildrenDao -from .dao_instances import QuestInstancesDao -from .dao_meta import MetaStateDao -from .dao_rules import QuestDefinitionsDao +from .core.sweep import ( + SWEEP_WATERMARK_KEY, + run_missed_sweep as _run_core_missed_sweep, +) from .db import NestQuestDatabase LOGGER = logging.getLogger(__name__) -#: meta_state key holding the HA-local date the sweep last ran. -SWEEP_WATERMARK_KEY = "missed_sweep_last_run_date" - -#: One asyncio.Lock per (connection wrapper, running loop), mirroring -#: the migration runner's pattern: the whole watermark read → query → -#: fire → write span is one critical section, so an overlapping startup -#: sweep and rollover sweep queue instead of double-announcing. The -#: loser re-reads the watermark INSIDE the lock, sees the winner's -#: date, and fires nothing. -_SWEEP_LOCKS: dict[tuple[int, int], asyncio.Lock] = {} - - -def _sweep_lock(database: NestQuestDatabase) -> asyncio.Lock: - """Return the sweep lock bound to ``database``'s running loop.""" - loop = asyncio.get_running_loop() - key = (id(database), id(loop)) - lock = _SWEEP_LOCKS.get(key) - if lock is None: - lock = asyncio.Lock() - _SWEEP_LOCKS[key] = lock - return lock - -#: UTC ISO-8601 timestamp policy shared with the DAO layers. -_UTC_TIMESTAMP_FORMAT = "%Y-%m-%dT%H:%M:%S+00:00" - - -def _now_stamp() -> str: - return datetime.datetime.now(datetime.timezone.utc).strftime( - _UTC_TIMESTAMP_FORMAT - ) +__all__ = ["SWEEP_WATERMARK_KEY", "run_missed_sweep"] async def run_missed_sweep( @@ -79,54 +49,24 @@ async def run_missed_sweep( materialization and completion layers); when omitted it is read from ``hass.config.time_zone``. Returns the number of events fired. Fires nothing and returns 0 when the watermark already - covers ``today`` (a same-night rerun). - - The whole watermark read → query → fire → write span runs inside - the per-database sweep lock: an overlapping startup sweep and a - rollover sweep queue instead of interleaving, and the loser - re-reads the watermark inside the lock — sees the winner's date — - and announces nothing twice. + covers ``today`` (a same-night rerun) — the watermark read, the + query, the payload build and the watermark write all live in the + core's locked + :func:`~.core.sweep.run_missed_sweep`; this shim only fires each + returned event on the bus and counts them. """ today_date = ( today if today is not None else datetime.datetime.now(ZoneInfo(hass.config.time_zone)).date() ) - today_iso = today_date.isoformat() - dao = MetaStateDao(database) - async with _sweep_lock(database): - watermark = await dao.get(SWEEP_WATERMARK_KEY) - if watermark == today_iso: - LOGGER.debug( - "NestQuest missed sweep already ran for %s", today_iso - ) - return 0 - - instances_dao = QuestInstancesDao(database) - rows = await instances_dao.list_missed_unswept(today_iso, watermark) - children_dao = ChildrenDao(database) - definitions_dao = QuestDefinitionsDao(database) - now_stamp = _now_stamp() - fired = 0 - for instance in rows: - child = await children_dao.get(instance.child_id) - definition = await definitions_dao.get(instance.definition_id) - hass.bus.async_fire( - EVENT_QUEST_MISSED, - { - "child_id": instance.child_id, - "child_name": child.display_name if child else None, - "instance_id": instance.id, - "quest_title": definition.title if definition else None, - "window": instance.window, - "due_date": instance.due_date, - "due_time": instance.due_time, - "occurred_at": now_stamp, - }, - ) - fired += 1 - await dao.set(SWEEP_WATERMARK_KEY, today_iso) + events = await _run_core_missed_sweep(database, today=today_date) + for event_type, payload in events: + hass.bus.async_fire(event_type, payload) + fired = len(events) LOGGER.info( - "NestQuest missed sweep for %s fired %d event(s)", today_iso, fired + "NestQuest missed sweep for %s fired %d event(s)", + today_date.isoformat(), + fired, ) return fired \ No newline at end of file diff --git a/tests/test_api_missed_sweep.py b/tests/test_api_missed_sweep.py new file mode 100644 index 0000000..c86c9a5 --- /dev/null +++ b/tests/test_api_missed_sweep.py @@ -0,0 +1,671 @@ +"""Admin-plane missed-sweep trigger tests (task 058c7b69). + +These tests exercise ``POST /api/v1/admin/missed-sweep`` — the admin +plane's trigger for the nightly missed-quest sweep — against a REAL +server (uvicorn on an ephemeral port, the SSE tests' documented +approach: httpx's ASGI transport buffers whole bodies, so a live +stream needs a live server), plus the API service's daily scheduler: + +- The trigger requires the router's ONE ``require_admin`` check: a + non-admin JWT is 403 and an absent credential is 401, and neither + refusal runs the sweep. +- Seeded past-due open instances: ONE ``nestquest_quest_missed`` + transition per instance arrives on the LIVE SSE stream with the + documented payload fields (child_id, child_name, instance_id, + quest_title, window, due_date, due_time, occurred_at — §3), while + the seeded TODAY-due instance publishes nothing (the sweep's rule + is ``due_date`` strictly before ``today``). +- A same-day rerun publishes nothing (the core watermark). +- A run after downtime (``today`` advanced two days) publishes the + accumulated window once — the instance due on day one fires on the + catch-up run — and a further rerun is silent again. +- Publishing is the api/scheduler.py shape — the sweep-BUILT + ``(event_type, payload)`` pairs go straight to the publisher, no + per-event re-fetch: all events of one run share the run's ONE + ``occurred_at`` stamp, and a deterministic concurrent-deletion case + (the sweep is wrapped to delete one swept instance after it built + its events and committed its watermark) still answers 200 and + streams every built event — a re-fetch there would 500 and lose the + missed event forever. +- The daily scheduler (:class:`api.scheduler.MissedSweepScheduler`) + computes the delay to the configured ``day_rollover_time``, runs the + sweep when the (injectable) clock reaches it, publishes the built + events on its publisher, advances to the NEXT day's rollover, and + cancels cleanly. + +The household is seeded through the SAME core copy the app uses (the +``nestquest_core.*`` package registered by ``api/nestquest_core.py``; +see the two-copy caveat there) and the sweep's watermark is read back +through the same copy's :class:`~nestquest_core.dao_meta.MetaStateDao` +— on the SERVER's event loop for the uvicorn-hosted app (the database +belongs to that thread's loop, so assertions schedule there), on the +test's own loop for the scheduler test's standalone database. +""" +from __future__ import annotations + +import asyncio +import datetime +import importlib +import threading +import time +from pathlib import Path +from types import SimpleNamespace + +import httpx +import pytest +import uvicorn + +from api import routes_admin +from api.app import create_app +from api.auth import JwksCache +from api.config import ApiConfig +from api.database import _executor +from api.nestquest_core import core_db as _core_db +from api.nestquest_core import core_migrations as _core_migrations +from api.scheduler import MissedSweepScheduler +from tests.admin_jwt_harness import ( + ADMIN_KEY, + AUDIENCE, + ISSUER, + JWKS_URL, + KID, + StubbedFetch, + jwks_for, + make_token, +) +from tests.test_api_panel import PANEL_TOKEN +from tests.test_api_sse import EVENT_QUEST_MISSED, READ_TIMEOUT, _free_port, _stream + +# The bundled core submodules, through the SAME copy the app uses +# (nestquest_core.*, NOT the conftest-stubbed custom_components copy). +_children = importlib.import_module("nestquest_core.children") +_quest_definitions = importlib.import_module( + "nestquest_core.quest_definitions" +) +_materialize = importlib.import_module("nestquest_core.materialize") +_recurrence = importlib.import_module("nestquest_core.recurrence") +_dao_instances = importlib.import_module("nestquest_core.dao_instances") +_dao_meta = importlib.import_module("nestquest_core.dao_meta") +_settings_store = importlib.import_module("nestquest_core.settings_store") +_core_sweep = importlib.import_module("nestquest_core.sweep") + +#: The well-known ``nestquest_meta_state`` key holding the sweep +#: watermark — asserted literally so the contract is pinned here. +SWEEP_WATERMARK_KEY = "missed_sweep_last_run_date" + + +def _admin_headers() -> dict[str, str]: + """The Authorization header of a valid nestquest-admins JWT.""" + return {"Authorization": f"Bearer {make_token()}"} + + +async def _seed_missed_household( + db_path: str, today: datetime.date +) -> SimpleNamespace: + """Seed the missed-sweep household on ``db_path`` and return records. + + Two children; two quests whose ONLY instances are due YESTERDAY + (created via ``materialize`` with ``today`` pinned to yesterday — + the no-past guard accepts the rows, and they land past-due with no + completion events: the sweep's targets), and one quest due TODAY + (not past-due: the sweep must NOT announce it). The yesterday + rules carry ``end_date`` so the later today-walk cannot give them + a second instance. Opens, migrates, seeds and CLOSES its own + connection so the app's lifespan connection never overlaps it. + """ + database = _core_db.NestQuestDatabase(_executor) + await database.open(db_path) + try: + await _core_migrations.apply_migrations(database) + today_iso = today.isoformat() + yesterday = today - datetime.timedelta(days=1) + yesterday_iso = yesterday.isoformat() + + ada = await _children.create_child(database, "Ada", sort_order=0) + bo = await _children.create_child(database, "Bo", sort_order=1) + + yesterday_rule = _recurrence.ScheduleRule.from_dict( + { + "rule_type": "daily", + "start_date": yesterday_iso, + "end_date": yesterday_iso, + } + ) + await _quest_definitions.create_quest_definition( + database, + "Stale chore", + yesterday_rule, + [ada.id], + [("morning", "09:00")], + ) + await _quest_definitions.create_quest_definition( + database, + "Old chore", + yesterday_rule, + [bo.id], + [("evening", "18:30")], + ) + # Past-due open instances for the two yesterday quests (the + # walk pins ``today`` to yesterday so the rows are accepted). + await _materialize.materialize( + database, yesterday_iso, yesterday_iso, today=yesterday + ) + # Today's quest: created after the yesterday walk so it has no + # past-due instance, then materialized for today only. + await _quest_definitions.create_quest_definition( + database, + "Recoverable chore", + _recurrence.ScheduleRule.from_dict( + {"rule_type": "daily", "start_date": today_iso} + ), + [ada.id], + [("afternoon", "17:00")], + ) + await _materialize.materialize( + database, today_iso, today_iso, today=today + ) + + instances = _dao_instances.QuestInstancesDao(database) + ada_yesterday = await instances.list_by_child_and_date( + ada.id, yesterday_iso + ) + assert len(ada_yesterday) == 1 + stale = ada_yesterday[0] + bo_yesterday = await instances.list_by_child_and_date( + bo.id, yesterday_iso + ) + assert len(bo_yesterday) == 1 + old = bo_yesterday[0] + ada_today = await instances.list_by_child_and_date(ada.id, today_iso) + assert len(ada_today) == 1 + recoverable = ada_today[0] + return SimpleNamespace( + ada=ada, + bo=bo, + stale_instance=stale, + old_instance=old, + recoverable_instance=recoverable, + yesterday_iso=yesterday_iso, + today_iso=today_iso, + ) + finally: + await database.close() + + +class _AdminServer: + """Run the app with uvicorn on an ephemeral port, in a thread. + + The SSE tests' documented approach (see tests/test_api_sse.py): + uvicorn's factory mode runs ``create_app`` INSIDE the server + thread, so the lifespan — database, publisher, the daily sweep + scheduler — belongs to that thread's event loop. The app's JWKS + cache is swapped for one built around the stubbed fetcher BEFORE + the server starts, so admin JWTs genuinely verify with no network. + """ + + def __init__(self, db_path: str) -> None: + self.port = _free_port() + self._server_loop: asyncio.AbstractEventLoop | None = None + self._server = uvicorn.Server( + uvicorn.Config( + self._make_app_factory(db_path), + host="127.0.0.1", + port=self.port, + log_level="warning", + ) + ) + self._thread = threading.Thread(target=self._server.run, daemon=True) + + def _make_app_factory(self, db_path: str): + """The uvicorn app factory, closing over ``db_path``.""" + + def factory(): + app = create_app( + ApiConfig( + db_path=db_path, + panel_token=PANEL_TOKEN, + oidc_issuer=ISSUER, + oidc_audience=AUDIENCE, + oidc_jwks_url=JWKS_URL, + ) + ) + app.state.jwks_cache = JwksCache( + JWKS_URL, fetcher=StubbedFetch(jwks_for(ADMIN_KEY, KID)) + ) + self._server_loop = asyncio.get_running_loop() + self.app = app + return app + + return factory + + def __enter__(self) -> "_AdminServer": + self._thread.start() + deadline = time.monotonic() + READ_TIMEOUT + while not self._server.started: + if time.monotonic() > deadline or not self._thread.is_alive(): + raise RuntimeError("uvicorn did not start") + time.sleep(0.01) + return self + + def __exit__(self, *exc) -> None: + self._server.should_exit = True + self._thread.join(timeout=READ_TIMEOUT) + + def url(self, path: str) -> str: + """The full URL for ``path`` on this server.""" + return f"http://127.0.0.1:{self.port}{path}" + + def run_on_loop(self, coro) -> object: + """Run ``coro`` on the SERVER's event loop and wait for it. + + The app (and its database) lives on the uvicorn thread's loop, + so database assertions schedule there. Bounded by + READ_TIMEOUT so a wedged schedule fails instead of hanging. + """ + return asyncio.run_coroutine_threadsafe( + coro, self._server_loop + ).result(timeout=READ_TIMEOUT) + + +def _watermark(server: _AdminServer) -> str | None: + """The stored sweep watermark, read on the server's own loop.""" + database = server.app.state.db.database + return server.run_on_loop( + _dao_meta.MetaStateDao(database).get(SWEEP_WATERMARK_KEY) + ) + + +@pytest.fixture +def temp_db_path(tmp_path: Path) -> str: + """A fresh per-test SQLite path under pytest's tmp_path.""" + return str(tmp_path / "nestquest.db") + + +@pytest.fixture +async def missed_app(temp_db_path: str) -> SimpleNamespace: + """The seeded household served by a live uvicorn app, clock pinned. + + ``routes_admin._local_now`` is pinned to today 12:00 (the same + pinning discipline the panel/SSE tests use) BEFORE the server + starts, so the trigger route's ``today`` matches the seed; the + downtime test moves the pin forward mid-test. Restored in + ``finally`` even on failure. + """ + now_local = datetime.datetime.now().astimezone() + pinned_now = now_local.replace(hour=12, minute=0, second=0, microsecond=0) + today = pinned_now.date() + seed = await _seed_missed_household(temp_db_path, today) + original = routes_admin._local_now + routes_admin._local_now = lambda: pinned_now + try: + with _AdminServer(temp_db_path) as server: + client = httpx.AsyncClient(base_url=server.url("")) + async with client: + yield SimpleNamespace( + client=client, + seed=seed, + server=server, + today=today, + pinned_now=pinned_now, + ) + finally: + routes_admin._local_now = original + + +# --- the trigger: one missed transition per past-due open instance ----- + + +async def test_trigger_publishes_one_missed_event_per_instance( + missed_app, +) -> None: + """The trigger streams one documented missed transition per instance. + + The stream is subscribed FIRST, the sweep is triggered through the + route as an admin, and each emitted frame's type and every + documented payload field are asserted; the TODAY-due instance must + NOT appear (the sweep's rule is strictly past-due). + """ + ns = missed_app + seed = ns.seed + stream = _stream(ns.server.url("/api/v1/panel/events")) + async with stream: + response = await ns.client.post( + "/api/v1/admin/missed-sweep", headers=_admin_headers() + ) + assert response.status_code == 200 + assert response.json() == {"fired": 2} + + event_type, payload = await stream.next_event() + assert event_type == EVENT_QUEST_MISSED + assert event_type == "nestquest_quest_missed" + assert payload["child_id"] == seed.ada.id + assert payload["child_name"] == "Ada" + assert payload["instance_id"] == seed.stale_instance.id + assert payload["quest_title"] == "Stale chore" + assert payload["window"] == "morning" + assert payload["due_date"] == seed.yesterday_iso + assert payload["due_time"] == "09:00" + assert payload["occurred_at"] # strict UTC ISO-8601 stamp + run_stamp = payload["occurred_at"] + + event_type, payload = await stream.next_event() + assert event_type == EVENT_QUEST_MISSED + assert payload["child_id"] == seed.bo.id + assert payload["child_name"] == "Bo" + assert payload["instance_id"] == seed.old_instance.id + assert payload["quest_title"] == "Old chore" + assert payload["window"] == "evening" + assert payload["due_date"] == seed.yesterday_iso + assert payload["due_time"] == "18:30" + # One run, ONE stamp: the route publishes the sweep-built + # payloads directly, so every event of this run carries the + # same ``occurred_at`` the sweep computed for the whole run. + assert payload["occurred_at"] == run_stamp + + # Exactly one transition per instance: today's not-past-due + # quest never arrives. + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(stream.queue.get(), timeout=0.3) + + # The run recorded the watermark: the household-local date the + # sweep ran for, under the well-known meta_state key. + assert _watermark(ns.server) == seed.today_iso + + +async def test_trigger_publishes_sweep_built_payloads_without_refetch( + missed_app, +) -> None: + """Publishing never re-fetches: a concurrently deleted instance + cannot fail the trigger or lose its already-built missed event. + + The core sweep commits the idempotency watermark BEFORE returning, + so a publish loop that re-fetches each instance opens a loss + window: an instance deleted inside that window (e.g. a concurrent + regenerate) would raise ``ValueError`` uncaught — a bare 500 — and + the missed event would be gone forever (the watermark already + advanced, so no future sweep re-examines it). The route publishes + the sweep-built ``(event_type, payload)`` pairs DIRECTLY (the + api/scheduler.py shape), which this test exercises deterministically: + the sweep is wrapped to delete one swept instance AFTER the sweep + built its events and committed its watermark but BEFORE the route + publishes, and the trigger must still answer 200 with ``fired: 2`` + and stream BOTH sweep-built payloads — the deleted instance's + included, stamped with the run's ONE shared ``occurred_at``. + """ + ns = missed_app + seed = ns.seed + database = ns.server.app.state.db.database + original_sweep = _core_sweep.run_missed_sweep + + async def sweep_then_delete_stale(sweep_database, *, today): + events = await original_sweep(sweep_database, today=today) + # The concurrent deletion lands after the sweep built its + # events and committed its watermark, but before the route + # publishes: the row is gone while the built event survives. + await sweep_database.execute( + "DELETE FROM quest_instances WHERE id = ?", + (seed.stale_instance.id,), + ) + return events + + _core_sweep.run_missed_sweep = sweep_then_delete_stale + try: + stream = _stream(ns.server.url("/api/v1/panel/events")) + async with stream: + response = await ns.client.post( + "/api/v1/admin/missed-sweep", headers=_admin_headers() + ) + assert response.status_code == 200 + assert response.json() == {"fired": 2} + + # The FIRST event is the deleted instance's — published + # from the payload built before the deletion, never + # re-fetched after it (a re-fetch would 500 here). + event_type, payload = await stream.next_event() + assert event_type == EVENT_QUEST_MISSED + assert payload["instance_id"] == seed.stale_instance.id + assert payload["child_name"] == "Ada" + assert payload["quest_title"] == "Stale chore" + assert payload["window"] == "morning" + assert payload["due_date"] == seed.yesterday_iso + assert payload["occurred_at"] + run_stamp = payload["occurred_at"] + + event_type, payload = await stream.next_event() + assert event_type == EVENT_QUEST_MISSED + assert payload["instance_id"] == seed.old_instance.id + assert payload["occurred_at"] == run_stamp + + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(stream.queue.get(), timeout=0.3) + finally: + _core_sweep.run_missed_sweep = original_sweep + + # The watermark advanced even though one instance was deleted + # mid-flight — nothing was lost to the publish-time failure window. + assert _watermark(ns.server) == seed.today_iso + + +async def test_same_day_rerun_publishes_nothing(missed_app) -> None: + """A second trigger the same day publishes nothing (the watermark).""" + ns = missed_app + headers = _admin_headers() + first = await ns.client.post("/api/v1/admin/missed-sweep", headers=headers) + assert first.status_code == 200 + assert first.json() == {"fired": 2} + + stream = _stream(ns.server.url("/api/v1/panel/events")) + async with stream: + second = await ns.client.post( + "/api/v1/admin/missed-sweep", headers=headers + ) + assert second.status_code == 200 + assert second.json() == {"fired": 0} + with pytest.raises(asyncio.TimeoutError): + # The watermark makes the rerun an empty no-op: the stream + # stays silent. + await asyncio.wait_for(stream.queue.get(), timeout=0.3) + + +async def test_run_after_downtime_publishes_accumulated_window_once( + missed_app, +) -> None: + """After days of downtime the catch-up run sweeps the window once. + + The first trigger sweeps yesterday's instances (watermark := today). + ``today`` then jumps two days: the accumulated window + ``[watermark, today)`` holds exactly the seeded TODAY-due instance + (no completion events), which fires once — and a same-day rerun is + silent again. + """ + ns = missed_app + headers = _admin_headers() + first = await ns.client.post("/api/v1/admin/missed-sweep", headers=headers) + assert first.status_code == 200 + assert first.json() == {"fired": 2} + + # Advance the pinned clock two days: the app is "down" over the + # rollovers in between. + routes_admin._local_now = lambda: ns.pinned_now + datetime.timedelta( + days=2 + ) + later_today = (ns.today + datetime.timedelta(days=2)).isoformat() + stream = _stream(ns.server.url("/api/v1/panel/events")) + async with stream: + catchup = await ns.client.post( + "/api/v1/admin/missed-sweep", headers=headers + ) + assert catchup.status_code == 200 + assert catchup.json() == {"fired": 1} + + event_type, payload = await stream.next_event() + assert event_type == EVENT_QUEST_MISSED + assert payload["instance_id"] == ns.seed.recoverable_instance.id + assert payload["child_id"] == ns.seed.ada.id + assert payload["quest_title"] == "Recoverable chore" + assert payload["window"] == "afternoon" + assert payload["due_date"] == ns.seed.today_iso + assert payload["due_time"] == "17:00" + assert payload["occurred_at"] + + with pytest.raises(asyncio.TimeoutError): + await asyncio.wait_for(stream.queue.get(), timeout=0.3) + + # The watermark moved to the new today... + assert _watermark(ns.server) == later_today + # ...so a further rerun publishes nothing. + again = await ns.client.post("/api/v1/admin/missed-sweep", headers=headers) + assert again.status_code == 200 + assert again.json() == {"fired": 0} + + +# --- authentication: the router's ONE require_admin dependency ---------- + + +async def test_trigger_refuses_non_admin_and_absent_credentials( + missed_app, +) -> None: + """A non-admin JWT is 403, an absent credential is 401; none sweep.""" + ns = missed_app + non_admin = make_token(groups=("some-other-group",)) + forbidden = await ns.client.post( + "/api/v1/admin/missed-sweep", + headers={"Authorization": f"Bearer {non_admin}"}, + ) + assert forbidden.status_code == 403 + assert forbidden.json()["detail"] == ( + "Admin access requires membership in the nestquest-admins group" + ) + absent = await ns.client.post("/api/v1/admin/missed-sweep") + assert absent.status_code == 401 + + # Neither refusal ran the sweep: the watermark is still unset, and + # the next admin trigger fires the full seeded set. + assert _watermark(ns.server) is None + after = await ns.client.post( + "/api/v1/admin/missed-sweep", headers=_admin_headers() + ) + assert after.status_code == 200 + assert after.json() == {"fired": 2} + + +# --- the daily scheduler ------------------------------------------------- + + +class _RecordingPublisher: + """A transition-publisher stub recording every published event.""" + + def __init__(self) -> None: + self.published: list[tuple[str, dict]] = [] + + def publish(self, event_type: str, payload: dict) -> None: + self.published.append((event_type, payload)) + + +async def test_scheduler_runs_sweep_at_rollover_and_cancels_cleanly( + temp_db_path: str, +) -> None: + """The scheduler sweeps at the configured rollover and stops clean. + + The injectable clock starts at 10:00 and the settings pin the + rollover to 10:05, so the first computed delay is five minutes; + the injectable sleep fast-forwards the clock, the sweep runs for + the (now advanced) clock's date, and the seeded past-due open + instance is published on the recording publisher exactly once. + The next cycle schedules for the NEXT day's rollover (23h55m). + ``stop`` cancels the loop task and the scheduler reports stopped. + """ + database = _core_db.NestQuestDatabase(_executor) + await database.open(temp_db_path) + try: + await _core_migrations.apply_migrations(database) + now_local = datetime.datetime.now().astimezone() + start = now_local.replace( + hour=10, minute=0, second=0, microsecond=0 + ) + ada = await _children.create_child(database, "Ada", sort_order=0) + yesterday = (start.date() - datetime.timedelta(days=1)).isoformat() + await _quest_definitions.create_quest_definition( + database, + "Stale chore", + _recurrence.ScheduleRule.from_dict( + { + "rule_type": "daily", + "start_date": yesterday, + "end_date": yesterday, + } + ), + [ada.id], + [("morning", "09:00")], + ) + await _materialize.materialize( + database, yesterday, yesterday, today=start.date() - datetime.timedelta(days=1) + ) + await _settings_store.update_settings( + database, {"day_rollover_time": "10:05"} + ) + + clock_state = {"now": start} + delays: list[float] = [] + + def clock() -> datetime.datetime: + return clock_state["now"] + + async def sleep_stub(seconds: float) -> None: + delays.append(seconds) + if len(delays) == 1: + # Fast-forward the clock across the rollover wait ONCE, + # so the first sweep runs at the rollover instant; the + # clock then stays frozen at that instant, so the + # follow-up cycles sweep the same (already-watermarked) + # date and the assertions read a stable state. + clock_state["now"] = clock_state["now"] + datetime.timedelta( + seconds=seconds + ) + await asyncio.sleep(0) + + publisher = _RecordingPublisher() + scheduler = MissedSweepScheduler( + database, publisher=publisher, clock=clock, sleep=sleep_stub + ) + scheduler.start() + try: + deadline = time.monotonic() + READ_TIMEOUT + while not publisher.published: + if time.monotonic() > deadline: + pytest.fail("scheduler never published a missed event") + await asyncio.sleep(0.01) + # The second cycle's scheduling decision may lag the first + # publication by a few awaits; wait for it the same way. + while len(delays) < 2: + if time.monotonic() > deadline: + pytest.fail("scheduler never scheduled a second cycle") + await asyncio.sleep(0.01) + + event_type, payload = publisher.published[0] + assert event_type == "nestquest_quest_missed" + assert payload["child_id"] == ada.id + assert payload["quest_title"] == "Stale chore" + assert payload["window"] == "morning" + assert payload["due_date"] == yesterday + assert payload["due_time"] == "09:00" + assert payload["occurred_at"] + + # The rollover delay was honored exactly... + assert delays[0] == pytest.approx(300.0) + # ...and the next cycle targets the NEXT day's rollover + # (the just-passed instant schedules for tomorrow). + assert delays[1] == pytest.approx(86400.0) + # The watermark pins the run to the sweep-day's date. + watermark = await _dao_meta.MetaStateDao(database).get( + SWEEP_WATERMARK_KEY + ) + assert watermark == start.date().isoformat() + finally: + await scheduler.stop() + assert not scheduler.running + # Exactly one announcement: the watermark makes every further + # cycle an empty no-op. + assert len(publisher.published) == 1 + finally: + await database.close() \ No newline at end of file -- 2.55.0