Admin routes: uncomplete and regenerate #161

3 changed files with 1073 additions and 2 deletions
+25
View File
@@ -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",
+354 -2
View File
@@ -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"]
+694
View File
@@ -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