diff --git a/api/nestquest_core.py b/api/nestquest_core.py index 03b5a10..8f9a1dc 100644 --- a/api/nestquest_core.py +++ b/api/nestquest_core.py @@ -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", diff --git a/api/routes_panel.py b/api/routes_panel.py index b02b7f0..5c297a1 100644 --- a/api/routes_panel.py +++ b/api/routes_panel.py @@ -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"] diff --git a/tests/test_api_panel.py b/tests/test_api_panel.py index 75cd1bd..58aa414 100644 --- a/tests/test_api_panel.py +++ b/tests/test_api_panel.py @@ -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