Panel plane: complete route (idempotent) #153
@@ -112,12 +112,17 @@ core_migrations = importlib.import_module(f"{_CORE_MODULE_NAME}.migrations")
|
|||||||
#: assembly the panel routes call.
|
#: assembly the panel routes call.
|
||||||
core_snapshot = importlib.import_module(f"{_CORE_MODULE_NAME}.snapshot")
|
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 explicit settings object (``core.settings.NestQuestSettings``)
|
||||||
#: the snapshot builder's ``settings`` parameter takes.
|
#: the snapshot builder's ``settings`` parameter takes.
|
||||||
core_settings = importlib.import_module(f"{_CORE_MODULE_NAME}.settings")
|
core_settings = importlib.import_module(f"{_CORE_MODULE_NAME}.settings")
|
||||||
|
|
||||||
__all__ = [
|
__all__ = [
|
||||||
"core",
|
"core",
|
||||||
|
"core_completion",
|
||||||
"core_db",
|
"core_db",
|
||||||
"core_migrations",
|
"core_migrations",
|
||||||
"core_snapshot",
|
"core_snapshot",
|
||||||
|
|||||||
+125
-3
@@ -52,13 +52,22 @@ The same :class:`PanelSnapshotResponse` Pydantic model is the route's
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import datetime
|
import datetime
|
||||||
|
import importlib
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, Request
|
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||||
from pydantic import BaseModel
|
from pydantic import BaseModel, field_validator
|
||||||
|
|
||||||
from api.database import DatabaseState
|
from api.database import DatabaseState
|
||||||
from api.dependencies import require_panel_token
|
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
|
#: All panel-plane routes share this router; the service-token check is
|
||||||
#: attached HERE so every panel route (current and later) requires it.
|
#: attached HERE so every panel route (current and later) requires it.
|
||||||
@@ -122,6 +131,37 @@ class PanelSnapshotResponse(BaseModel):
|
|||||||
children: list[PanelChildPayload]
|
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:
|
def _local_now() -> datetime.datetime:
|
||||||
"""Return the API host's local time as ONE timezone-aware clock read.
|
"""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"]
|
__all__ = ["router"]
|
||||||
|
|||||||
+169
-4
@@ -67,6 +67,30 @@ _dao_instances = importlib.import_module("nestquest_core.dao_instances")
|
|||||||
_completion = importlib.import_module("nestquest_core.completion")
|
_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(
|
async def _seed_household(
|
||||||
db_path: str,
|
db_path: str,
|
||||||
today: datetime.date,
|
today: datetime.date,
|
||||||
@@ -215,14 +239,15 @@ class _PanelRunner:
|
|||||||
ApiConfig(db_path=db_path, panel_token=panel_token)
|
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)
|
self._lifespan = self.app.router.lifespan_context(self.app)
|
||||||
await self._lifespan.__aenter__()
|
await self._lifespan.__aenter__()
|
||||||
self._client = httpx.AsyncClient(
|
self._client = httpx.AsyncClient(
|
||||||
transport=httpx.ASGITransport(app=self.app),
|
transport=httpx.ASGITransport(app=self.app),
|
||||||
base_url="http://testserver",
|
base_url="http://testserver",
|
||||||
)
|
)
|
||||||
return await self._client.__aenter__()
|
await self._client.__aenter__()
|
||||||
|
return self, self._client
|
||||||
|
|
||||||
async def __aexit__(self, *exc) -> None:
|
async def __aexit__(self, *exc) -> None:
|
||||||
try:
|
try:
|
||||||
@@ -240,7 +265,8 @@ def temp_db_path(tmp_path: Path) -> str:
|
|||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
async def panel_client(temp_db_path: str) -> httpx.AsyncClient:
|
async def panel_client(temp_db_path: str) -> httpx.AsyncClient:
|
||||||
"""An app client with an empty (migrated) household database."""
|
"""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
|
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
|
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
|
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.
|
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()
|
today = datetime.datetime.now().astimezone().date()
|
||||||
time_zone = ZoneInfo("UTC")
|
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)
|
seed = await _seed_household(temp_db_path, today, pinned_now)
|
||||||
monkeypatch.setattr(routes_panel, "_local_now", lambda: 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(
|
yield SimpleNamespace(
|
||||||
client=client,
|
client=client,
|
||||||
|
database=runner.app.state.db.database,
|
||||||
seed=seed,
|
seed=seed,
|
||||||
today=today,
|
today=today,
|
||||||
pinned_now=pinned_now,
|
pinned_now=pinned_now,
|
||||||
@@ -521,3 +552,137 @@ async def test_snapshot_route_is_documented(
|
|||||||
assert "PanelSnapshotResponse" in documented
|
assert "PanelSnapshotResponse" in documented
|
||||||
assert "PanelChildPayload" in documented
|
assert "PanelChildPayload" in documented
|
||||||
assert "PanelInstancePayload" 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
|
||||||
|
|||||||
Reference in New Issue
Block a user