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] 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