Panel plane: complete route (idempotent) (#153)

This commit was merged in pull request #153.
This commit is contained in:
2026-09-22 22:38:48 -04:00
parent 4da762173c
commit 550a8b3e1e
3 changed files with 299 additions and 7 deletions
+5
View File
@@ -112,12 +112,17 @@ core_migrations = importlib.import_module(f"{_CORE_MODULE_NAME}.migrations")
#: assembly the panel routes call.
core_snapshot = importlib.import_module(f"{_CORE_MODULE_NAME}.snapshot")
#: The completion service (``core.completion.complete_instance`` /
#: ``uncomplete_instance``) the panel's write routes call.
core_completion = importlib.import_module(f"{_CORE_MODULE_NAME}.completion")
#: The explicit settings object (``core.settings.NestQuestSettings``)
#: the snapshot builder's ``settings`` parameter takes.
core_settings = importlib.import_module(f"{_CORE_MODULE_NAME}.settings")
__all__ = [
"core",
"core_completion",
"core_db",
"core_migrations",
"core_snapshot",
+125 -3
View File
@@ -52,13 +52,22 @@ The same :class:`PanelSnapshotResponse` Pydantic model is the route's
from __future__ import annotations
import datetime
import importlib
from fastapi import APIRouter, Depends, Request
from pydantic import BaseModel
from fastapi import APIRouter, Depends, HTTPException, Request
from pydantic import BaseModel, field_validator
from api.database import DatabaseState
from api.dependencies import require_panel_token
from api.nestquest_core import core_snapshot, core_settings
from api.nestquest_core import (
core_completion,
core_settings,
core_snapshot,
)
#: The instance DAO, imported on the app's core copy for the
#: child-mismatch guard on the complete route.
_dao_instances = importlib.import_module("nestquest_core.dao_instances")
#: All panel-plane routes share this router; the service-token check is
#: attached HERE so every panel route (current and later) requires it.
@@ -122,6 +131,37 @@ class PanelSnapshotResponse(BaseModel):
children: list[PanelChildPayload]
class PanelCompleteRequest(BaseModel):
"""The body of ``POST /api/v1/panel/instances/{id}/complete``.
Matches the panel's ``nestquest.complete_quest`` payload (§2 of
design/ENTITIES-AND-SERVICES.md): ``actor_child_id`` is the tapped
profile. A positive integer is required — the core completion
layer rejects non-int (and bool) ids anyway, but rejecting them
here with 422 keeps the contract visible in the OpenAPI document
and fails before any database work.
"""
actor_child_id: int
model_config = {
"json_schema_extra": {
"description": (
"The tapped child profile the completion is "
"attributed to (decision 9)."
)
}
}
@field_validator("actor_child_id")
@classmethod
def _positive(cls, value: int) -> int:
"""Reject zero and negative ids with a validation error."""
if value < 1:
raise ValueError("actor_child_id must be a positive integer")
return value
def _local_now() -> datetime.datetime:
"""Return the API host's local time as ONE timezone-aware clock read.
@@ -180,4 +220,86 @@ async def panel_snapshot(request: Request) -> PanelSnapshotResponse:
)
@router.post(
"/instances/{instance_id}/complete",
summary="Complete one quest instance",
status_code=200,
)
async def panel_complete_instance(
instance_id: int, body: PanelCompleteRequest, request: Request
) -> dict[str, str]:
"""Complete one quest instance as the tapped child profile.
Requires the panel service token (checked by the router's shared
:func:`~api.dependencies.require_panel_token` dependency — the ONE
token check; this handler performs NO auth of its own).
Thin adapter only: resolves the live database off ``app.state.db``,
reads ONE timezone-aware clock (:func:`_local_now`), and delegates
to :func:`nestquest_core.completion.complete_instance` with
``actor_source="panel"`` and the tapped ``actor_child_id`` — the
append/idempotence logic, the lock, and the actor-shape validation
all live in the core layer, never here.
Response and idempotence: ``complete_instance`` returns a
:class:`~nestquest_core.completion.CompletionResult` whose
``appended`` flag reports whether THIS call wrote an event; a
repeat call for an already-completed instance is a no-op inside
the core layer (no duplicate event) and this route still answers
``{"status": "done"}`` either way, so a retried panel tap succeeds.
Error mapping (deliberately narrow): the ONLY ``ValueError`` mapped
to 404 is the unknown-``instance_id`` one, identified by the
``instance_id:`` prefix ``complete_instance`` names the failing
field with. Every other ``ValueError`` (an invalid actor shape,
which the body model and the panel actor contract already make
unreachable) re-raises to the framework's 500 — nothing else is
turned into a 404 or a 500 of our own making.
Child mismatch: ``complete_instance`` verifies the tapped
``actor_child_id`` exists but does NOT check it is the child the
instance belongs to. The route performs that check here (one
``QuestInstancesDao.get_by_id`` read) and answers 404 when the
instance's ``child_id`` differs — completing another child's quest
from the wrong profile must not succeed, and 404 (not 403) keeps
the instance's existence from leaking to a wrong-profile tap.
"""
state: DatabaseState = request.app.state.db
database = state.database
# Child-mismatch guard (see docstring): the instance must belong to
# the tapped profile before the completion layer is entered.
instance = await _dao_instances.QuestInstancesDao(database).get_by_id(
instance_id
)
if instance is not None and instance.child_id != body.actor_child_id:
raise HTTPException(
status_code=404,
detail="Quest instance not found for this child",
)
try:
result = await core_completion.complete_instance(
database,
instance_id,
actor_source="panel",
actor_child_id=body.actor_child_id,
now=_local_now(),
)
except ValueError as error:
message = str(error)
if message.startswith("instance_id:"):
# complete_instance names the field in its ValueError:
# "instance_id: quest instance N does not exist".
raise HTTPException(
status_code=404,
detail="Quest instance not found",
) from error
raise
# The derived state is "done" in both the appended and no-op cases;
# ``result.appended`` distinguishes them for callers that care —
# the panel route returns the same success either way.
return {"status": str(result)}
__all__ = ["router"]
+169 -4
View File
@@ -67,6 +67,30 @@ _dao_instances = importlib.import_module("nestquest_core.dao_instances")
_completion = importlib.import_module("nestquest_core.completion")
async def _events_for_instance(database: object, instance_id: int) -> list:
"""The instance's completion events, on the app's core copy.
Uses the SAME ``nestquest_core.*`` copy the app uses (the two-copy
caveat: never mix copies) against the app's live connection.
"""
dao = _dao_instances.CompletionEventsDao(database)
return await dao.list_by_instance(instance_id)
async def _derived_state(
database: object, instance: object, today: datetime.date
) -> str:
"""The instance's derived state on the app's core copy.
``derive_state(instance, latest, today)`` from the app's core copy;
the latest event is fetched through the same copy's DAO against the
app's live connection.
"""
dao = _dao_instances.CompletionEventsDao(database)
latest = await dao.get_latest_for_instance(instance.id)
return _completion.derive_state(instance, latest, today)
async def _seed_household(
db_path: str,
today: datetime.date,
@@ -215,14 +239,15 @@ class _PanelRunner:
ApiConfig(db_path=db_path, panel_token=panel_token)
)
async def __aenter__(self) -> httpx.AsyncClient:
async def __aenter__(self) -> tuple["_PanelRunner", httpx.AsyncClient]:
self._lifespan = self.app.router.lifespan_context(self.app)
await self._lifespan.__aenter__()
self._client = httpx.AsyncClient(
transport=httpx.ASGITransport(app=self.app),
base_url="http://testserver",
)
return await self._client.__aenter__()
await self._client.__aenter__()
return self, self._client
async def __aexit__(self, *exc) -> None:
try:
@@ -240,7 +265,8 @@ def temp_db_path(tmp_path: Path) -> str:
@pytest.fixture
async def panel_client(temp_db_path: str) -> httpx.AsyncClient:
"""An app client with an empty (migrated) household database."""
async with _PanelRunner(temp_db_path) as client:
async with _PanelRunner(temp_db_path) as runner_and_client:
_, client = runner_and_client
yield client
@@ -251,6 +277,9 @@ async def seeded_panel(temp_db_path: str, monkeypatch) -> SimpleNamespace:
One host clock read anchors ``today``; the pinned instant is
12:00 UTC that day (past Ada's 10:00 due, before Bo's 17:00). The
route's ``_local_now`` is patched to return the pinned instant.
Yields the runner too so tests reach the app's live database (the
SAME ``nestquest_core`` connection) for assertions.
"""
today = datetime.datetime.now().astimezone().date()
time_zone = ZoneInfo("UTC")
@@ -259,9 +288,11 @@ async def seeded_panel(temp_db_path: str, monkeypatch) -> SimpleNamespace:
)
seed = await _seed_household(temp_db_path, today, pinned_now)
monkeypatch.setattr(routes_panel, "_local_now", lambda: pinned_now)
async with _PanelRunner(temp_db_path) as client:
async with _PanelRunner(temp_db_path) as runner_and_client:
runner, client = runner_and_client
yield SimpleNamespace(
client=client,
database=runner.app.state.db.database,
seed=seed,
today=today,
pinned_now=pinned_now,
@@ -521,3 +552,137 @@ async def test_snapshot_route_is_documented(
assert "PanelSnapshotResponse" in documented
assert "PanelChildPayload" in documented
assert "PanelInstancePayload" in documented
# --- complete route -------------------------------------------------------
async def test_complete_writes_panel_completion(
seeded_panel: SimpleNamespace,
) -> None:
"""The first POST completes the instance as the tapped profile.
Returns success; the completion event recorded is
``actor_source='panel'`` with the tapped ``actor_child_id`` and NO
``actor_user_id`` (the panel actor contract), and the instance's
derived state becomes ``done``.
"""
seed = seeded_panel.seed
response = await seeded_panel.client.post(
f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete",
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
json={"actor_child_id": seed.ada.id},
)
assert response.status_code == 200
assert response.json() == {"status": "done"}
# Assert the recorded event and derived state through the SAME core
# copy the app uses (two-copy caveat: never mix copies), against the
# app's live connection.
events = await _events_for_instance(
seeded_panel.database, seed.pack_bag_instance.id
)
assert len(events) == 1
event = events[0]
assert event.event_type == "completed"
assert event.actor_source == "panel"
assert event.actor_child_id == seed.ada.id
assert event.actor_user_id is None
instance = await _dao_instances.QuestInstancesDao(
seeded_panel.database
).get_by_id(seed.pack_bag_instance.id)
assert await _derived_state(
seeded_panel.database, instance, seeded_panel.today
) == "done"
async def test_complete_is_idempotent(
seeded_panel: SimpleNamespace,
) -> None:
"""A second call is a no-op that still returns success.
The core completion layer's ``appended`` flag decides: the repeat
call appends nothing, so exactly ONE completion event row exists
after two calls.
"""
seed = seeded_panel.seed
headers = {"Authorization": f"Bearer {PANEL_TOKEN}"}
url = f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete"
body = {"actor_child_id": seed.ada.id}
first = await seeded_panel.client.post(url, headers=headers, json=body)
assert first.status_code == 200
second = await seeded_panel.client.post(url, headers=headers, json=body)
assert second.status_code == 200
assert second.json() == {"status": "done"}
events = await _events_for_instance(
seeded_panel.database, seed.pack_bag_instance.id
)
assert len(events) == 1, (
"the idempotent call must not append a second event"
)
async def test_complete_unknown_instance_is_not_found(
panel_client: httpx.AsyncClient,
) -> None:
"""Completing a non-existent instance returns 404."""
response = await panel_client.post(
"/api/v1/panel/instances/999999/complete",
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
json={"actor_child_id": 1},
)
assert response.status_code == 404
async def test_complete_without_token_is_unauthorized(
panel_client: httpx.AsyncClient,
) -> None:
"""A complete request with no Authorization header returns 401."""
response = await panel_client.post(
"/api/v1/panel/instances/1/complete",
json={"actor_child_id": 1},
)
assert response.status_code == 401
async def test_complete_wrong_child_is_not_found(
seeded_panel: SimpleNamespace,
) -> None:
"""Tapping the wrong child's profile answers 404, not success.
``complete_instance`` does not itself check that the tapped
``actor_child_id`` is the child the instance belongs to; the route
guards it so a wrong-profile tap can never complete another
child's quest.
"""
seed = seeded_panel.seed
response = await seeded_panel.client.post(
f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete",
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
json={"actor_child_id": seed.bo.id},
)
assert response.status_code == 404
# And nothing was completed.
events = await _events_for_instance(
seeded_panel.database, seed.pack_bag_instance.id
)
assert events == []
async def test_complete_route_is_documented(
panel_client: httpx.AsyncClient,
) -> None:
"""The complete route and its request shape are in the OpenAPI doc."""
response = await panel_client.get("/openapi.json")
assert response.status_code == 200
schema = response.json()
path = "/api/v1/panel/instances/{instance_id}/complete"
assert path in schema["paths"]
assert "post" in schema["paths"][path]
documented = {
model["title"]
for model in schema["components"]["schemas"].values()
}
assert "PanelCompleteRequest" in documented