Panel plane: complete route (idempotent) (#153)
This commit was merged in pull request #153.
This commit is contained in:
@@ -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
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user