Admin routes: uncomplete and regenerate
The admin plane could reverse nothing and re-materialize nothing: a
mis-tapped completion could only be fixed by hand in the database, and
a config or presence change left the household's rolling horizon
stale until the integration itself regenerated it.
- POST /api/v1/admin/instances/{id}/uncomplete appends the reversal
through core uncomplete_instance (actor_source 'user' with the
verified JWT's sub — D-008), so the original completed row is never
edited or deleted and the derived state returns to open. An actual
reversal publishes the quest-uncompleted transition via the existing
api.transitions helper; an already-open no-op publishes nothing and
still answers with the honest derived state (open, or missed when
past due).
- POST /api/v1/admin/regenerate re-materializes the rolling horizon
through the same core path the integration uses: household (default)
= materialize over [today, today + const.DEFAULT_HORIZON_DAYS] (the
API config supplies no horizon knob), definition/child =
regenerate_for_definition / regenerate_for_child after ONE DAO
existence read each (unknown id -> 404, which the core's regenerate
path itself does not check). The walk is idempotent, so re-running
never duplicates an instance.
Both handlers are thin adapters: Pydantic models carry the request
shape (scope/id pairing is the model's job), the business rules stay
in core, and the error mapping stays narrow — unknown
instance/definition/child 404, rejected argument 422, anything else
re-raises.
This commit is contained in:
@@ -161,13 +161,38 @@ core_dao_presence = importlib.import_module(
|
|||||||
f"{_CORE_MODULE_NAME}.dao_presence"
|
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__ = [
|
__all__ = [
|
||||||
"core",
|
"core",
|
||||||
"core_children",
|
"core_children",
|
||||||
"core_completion",
|
"core_completion",
|
||||||
|
"core_const",
|
||||||
|
"core_dao_children",
|
||||||
"core_dao_presence",
|
"core_dao_presence",
|
||||||
|
"core_dao_rules",
|
||||||
"core_db",
|
"core_db",
|
||||||
"core_events",
|
"core_events",
|
||||||
|
"core_materialize",
|
||||||
"core_migrations",
|
"core_migrations",
|
||||||
"core_presence",
|
"core_presence",
|
||||||
"core_presence_management",
|
"core_presence_management",
|
||||||
|
|||||||
+354
-2
@@ -59,6 +59,39 @@ layer the HA services share, so the two planes cannot drift:
|
|||||||
design) and regenerates that override's child. It answers
|
design) and regenerates that override's child. It answers
|
||||||
``{"status": "ok"}``.
|
``{"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,
|
Error mapping (every children, quest-definition and presence route,
|
||||||
deliberately narrow):
|
deliberately narrow):
|
||||||
|
|
||||||
@@ -94,6 +127,20 @@ deliberately narrow):
|
|||||||
:func:`_raise_presence_override_error`, keyed on the
|
:func:`_raise_presence_override_error`, keyed on the
|
||||||
``override_id:`` prefix the core's unknown-id error carries (the
|
``override_id:`` prefix the core's unknown-id error carries (the
|
||||||
same convention as the quest-definition 404).
|
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
|
PATCH edit semantics: the body model's fields are optional and only
|
||||||
SUPPLIED fields are forwarded (``model_dump(exclude_unset=True)``), so
|
SUPPLIED fields are forwarded (``model_dump(exclude_unset=True)``), so
|
||||||
@@ -104,15 +151,22 @@ silently ignored.
|
|||||||
"""
|
"""
|
||||||
from __future__ import annotations
|
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 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.auth import require_admin
|
||||||
from api.database import DatabaseState
|
from api.database import DatabaseState
|
||||||
from api.nestquest_core import (
|
from api.nestquest_core import (
|
||||||
core_children,
|
core_children,
|
||||||
|
core_completion,
|
||||||
|
core_const,
|
||||||
|
core_dao_children,
|
||||||
|
core_dao_rules,
|
||||||
|
core_materialize,
|
||||||
core_presence,
|
core_presence,
|
||||||
core_presence_management,
|
core_presence_management,
|
||||||
core_quest_definitions,
|
core_quest_definitions,
|
||||||
@@ -395,6 +449,83 @@ class AdminPresenceOverrideResponse(BaseModel):
|
|||||||
note: str | None
|
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.
|
#: 404 detail for a child id the core layer reports as non-existent.
|
||||||
_CHILD_NOT_FOUND_DETAIL = "Child not found"
|
_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
|
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:
|
def _rule_from_request(rule: AdminRuleRequest) -> core_recurrence.ScheduleRule:
|
||||||
"""Build the core ScheduleRule from the request's ``rule`` object.
|
"""Build the core ScheduleRule from the request's ``rule`` object.
|
||||||
|
|
||||||
@@ -942,4 +1123,175 @@ async def admin_delete_presence_override(
|
|||||||
return {"status": "ok"}
|
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"]
|
__all__ = ["router"]
|
||||||
|
|||||||
@@ -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
|
||||||
Reference in New Issue
Block a user