Co-authored-by: dev_agent <17+dev_agent@code.cubecraftcreations.com> Co-committed-by: dev_agent <17+dev_agent@code.cubecraftcreations.com>
717 lines
25 KiB
Python
717 lines
25 KiB
Python
"""Panel-plane route tests: service token + snapshot (task ae7e90ae).
|
|
|
|
These tests exercise the NestQuest API's panel plane against the ASGI
|
|
app with httpx:
|
|
|
|
- ``GET /api/v1/panel/snapshot`` requires the panel service token
|
|
(D-012): a request with NO ``Authorization`` header, a MALFORMED
|
|
header, and a WRONG token all return 401; the correct Bearer token
|
|
returns 200.
|
|
- The 200 body is the documented snapshot shape: ``today_iso``,
|
|
``cycle_day``, and ``children`` in the household's sort order, each
|
|
carrying ``child_id``/``child_name``/``present``/``next_present``,
|
|
the per-day rollup counts, and its instances in the documented panel
|
|
payload shape (ENTITIES-AND-SERVICES.md §1) with missed instances
|
|
OMITTED (D-009).
|
|
- ``ApiConfig.from_env`` requires ``NESTQUEST_PANEL_TOKEN`` with the
|
|
same fail-fast discipline as ``NESTQUEST_DB_PATH``.
|
|
|
|
The household is seeded through the SAME core copy the app uses — the
|
|
``nestquest_core.*`` package registered by ``api/nestquest_core.py`` —
|
|
NOT the conftest-stubbed ``custom_components.nestquest.core`` copy (see
|
|
the two-copy caveat in api/nestquest_core.py's docstring: never compare
|
|
class or exception identity across the two copies). The seed opens and
|
|
migrates its own connection on the app's SQLite path and closes BEFORE
|
|
the app's lifespan opens the file, so the two connections never
|
|
overlap.
|
|
|
|
The route's single clock read (:func:`api.routes_panel._local_now`) is
|
|
pinned with monkeypatch the same way the coordinator's ``_local_now``
|
|
is pinned in test_core_snapshot.py, so the seeded rows and the
|
|
snapshot's ``today`` can never straddle midnight.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
import datetime
|
|
import importlib
|
|
from pathlib import Path
|
|
from types import SimpleNamespace
|
|
from zoneinfo import ZoneInfo
|
|
|
|
import httpx
|
|
import pytest
|
|
|
|
from api import routes_panel
|
|
from api.app import create_app
|
|
from api.config import ApiConfig, ConfigError
|
|
from api.database import _executor
|
|
from api.nestquest_core import (
|
|
core_db,
|
|
core_migrations,
|
|
core_snapshot,
|
|
)
|
|
|
|
#: The panel service token the test apps are configured with.
|
|
PANEL_TOKEN = "panel-service-token-test"
|
|
|
|
# The bundled core submodules, through the SAME copy the app uses
|
|
# (nestquest_core.*, NOT the conftest-stubbed custom_components copy).
|
|
_children = importlib.import_module("nestquest_core.children")
|
|
_quest_definitions = importlib.import_module(
|
|
"nestquest_core.quest_definitions"
|
|
)
|
|
_materialize = importlib.import_module("nestquest_core.materialize")
|
|
_recurrence = importlib.import_module("nestquest_core.recurrence")
|
|
_dao_presence = importlib.import_module("nestquest_core.dao_presence")
|
|
_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,
|
|
pinned_now: datetime.datetime,
|
|
) -> SimpleNamespace:
|
|
"""Seed a two-child household on ``db_path`` and return its records.
|
|
|
|
Mirrors the test_core_snapshot.py household: an AWAY child (2-week
|
|
custody cycle anchored yesterday, empty week 0, an override returns
|
|
her today+2) with an open quest due 10:00 — overdue at the pinned
|
|
12:00 — and a present child whose 17:00 quest is completed at
|
|
12:00 (on time). A third quest's instance is materialized due
|
|
YESTERDAY (via ``materialize`` with ``today`` pinned to yesterday,
|
|
since the no-past guard refuses past-dated writes): a past-due OPEN
|
|
instance, i.e. the "missed" view the panel payload must omit.
|
|
|
|
Opens, migrates, seeds, and CLOSES its own connection so the app's
|
|
lifespan connection never overlaps it.
|
|
"""
|
|
database = core_db.NestQuestDatabase(_executor)
|
|
await database.open(db_path)
|
|
try:
|
|
await core_migrations.apply_migrations(database)
|
|
today_iso = today.isoformat()
|
|
yesterday = today - datetime.timedelta(days=1)
|
|
yesterday_iso = yesterday.isoformat()
|
|
|
|
ada = await _children.create_child(database, "Ada", sort_order=0)
|
|
bo = await _children.create_child(database, "Bo", sort_order=1)
|
|
|
|
# Every rule starts YESTERDAY so the yesterday materialization
|
|
# below (which pins ``today`` to yesterday) fires them.
|
|
await _quest_definitions.create_quest_definition(
|
|
database,
|
|
"Pack bag",
|
|
_recurrence.ScheduleRule.from_dict(
|
|
{"rule_type": "daily", "start_date": yesterday_iso}
|
|
),
|
|
[ada.id],
|
|
[("morning", "10:00")],
|
|
)
|
|
await _quest_definitions.create_quest_definition(
|
|
database,
|
|
"Brush teeth",
|
|
_recurrence.ScheduleRule.from_dict(
|
|
{"rule_type": "daily", "start_date": yesterday_iso}
|
|
),
|
|
[bo.id],
|
|
[("morning", "17:00")],
|
|
)
|
|
# Today's instances first, while Ada has no presence pattern
|
|
# (she is present every day, so her instance IS generated).
|
|
await _materialize.materialize(
|
|
database,
|
|
today_iso,
|
|
(today + datetime.timedelta(days=3)).isoformat(),
|
|
today=today,
|
|
)
|
|
# The stale quest's rule ends YESTERDAY, so create's own
|
|
# regeneration writes nothing and its only instance is the
|
|
# yesterday one written below.
|
|
await _quest_definitions.create_quest_definition(
|
|
database,
|
|
"Stale chore",
|
|
_recurrence.ScheduleRule.from_dict(
|
|
{
|
|
"rule_type": "daily",
|
|
"start_date": yesterday_iso,
|
|
"end_date": yesterday_iso,
|
|
}
|
|
),
|
|
[ada.id],
|
|
[("morning", "09:00")],
|
|
)
|
|
# Materialize yesterday with ``today`` pinned to yesterday: the
|
|
# no-past guard accepts the rows, and they land past-due (no
|
|
# completion events) — the missed view.
|
|
await _materialize.materialize(
|
|
database, yesterday_iso, yesterday_iso, today=yesterday
|
|
)
|
|
instances = _dao_instances.QuestInstancesDao(database)
|
|
|
|
ada_today = await instances.list_by_child_and_date(ada.id, today_iso)
|
|
assert len(ada_today) == 1
|
|
pack_bag_instance = ada_today[0]
|
|
bo_today = await instances.list_by_child_and_date(bo.id, today_iso)
|
|
assert len(bo_today) == 1
|
|
brush_teeth_instance = bo_today[0]
|
|
ada_yesterday = await instances.list_by_child_and_date(
|
|
ada.id, yesterday_iso
|
|
)
|
|
stale_instance = next(
|
|
record
|
|
for record in ada_yesterday
|
|
if record.due_time == "09:00"
|
|
)
|
|
|
|
# Ada's custody pattern (one home pattern), added AFTER materialization (the
|
|
# same order test_core_snapshot.py seeds): 2-week cycle
|
|
# anchored yesterday, empty week 0 covers today => absent
|
|
# today; an override returns her on today+2.
|
|
await _dao_presence.PresencePatternsDao(database).create(
|
|
ada.id,
|
|
name="Custody",
|
|
kind="home",
|
|
cycle_length_weeks=2,
|
|
anchor_date=yesterday_iso,
|
|
pattern="|0,1,2,3,4,5,6",
|
|
)
|
|
returns_date = today + datetime.timedelta(days=2)
|
|
await _dao_presence.PresenceOverridesDao(database).create(
|
|
ada.id,
|
|
returns_date.isoformat(),
|
|
returns_date.isoformat(),
|
|
is_present=True,
|
|
note="visit",
|
|
)
|
|
|
|
# Complete Bo's today instance at the pinned 12:00 (17:00 due
|
|
# => on time).
|
|
await _completion.complete_instance(
|
|
database,
|
|
brush_teeth_instance.id,
|
|
actor_source="panel",
|
|
actor_child_id=bo.id,
|
|
now=pinned_now,
|
|
today=today,
|
|
)
|
|
return SimpleNamespace(
|
|
ada=ada,
|
|
bo=bo,
|
|
pack_bag_instance=pack_bag_instance,
|
|
brush_teeth_instance=brush_teeth_instance,
|
|
stale_instance=stale_instance,
|
|
returns_date=returns_date,
|
|
)
|
|
finally:
|
|
await database.close()
|
|
|
|
|
|
class _PanelRunner:
|
|
"""Run the app's lifespan around an httpx AsyncClient.
|
|
|
|
Same pattern as test_api_health.py's ``_AppRunner``: httpx's ASGI
|
|
transport does not run the lifespan, so it is driven explicitly and
|
|
shut down in ``__aexit__``'s finally block even on failure.
|
|
"""
|
|
|
|
def __init__(
|
|
self, db_path: str, panel_token: str = PANEL_TOKEN
|
|
) -> None:
|
|
self.app = create_app(
|
|
ApiConfig(
|
|
db_path=db_path,
|
|
panel_token=panel_token,
|
|
oidc_issuer="https://authentik.example.com/application/o/nestquest/",
|
|
oidc_audience="nestquest-api",
|
|
oidc_jwks_url=(
|
|
"https://authentik.example.com/application/o/nestquest/jwks/"
|
|
),
|
|
)
|
|
)
|
|
|
|
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",
|
|
)
|
|
await self._client.__aenter__()
|
|
return self, self._client
|
|
|
|
async def __aexit__(self, *exc) -> None:
|
|
try:
|
|
await self._client.__aexit__(*exc)
|
|
finally:
|
|
await self._lifespan.__aexit__(*exc)
|
|
|
|
|
|
@pytest.fixture
|
|
def temp_db_path(tmp_path: Path) -> str:
|
|
"""A fresh per-test SQLite path under pytest's tmp_path."""
|
|
return str(tmp_path / "nestquest.db")
|
|
|
|
|
|
@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 runner_and_client:
|
|
_, client = runner_and_client
|
|
yield client
|
|
|
|
|
|
@pytest.fixture
|
|
async def seeded_panel(temp_db_path: str, monkeypatch) -> SimpleNamespace:
|
|
"""A seeded household served by the app, with the clock pinned.
|
|
|
|
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")
|
|
pinned_now = datetime.datetime(
|
|
today.year, today.month, today.day, 12, 0, 0, tzinfo=time_zone
|
|
)
|
|
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 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,
|
|
pinned_utc_iso=pinned_now.astimezone(
|
|
datetime.timezone.utc
|
|
).isoformat(),
|
|
)
|
|
|
|
|
|
# --- ApiConfig.from_env: the panel token --------------------------------
|
|
|
|
|
|
def test_from_env_reads_panel_token() -> None:
|
|
"""A set, non-empty NESTQUEST_PANEL_TOKEN is returned verbatim."""
|
|
cfg = ApiConfig.from_env(
|
|
{
|
|
"NESTQUEST_DB_PATH": "/data/nq.db",
|
|
"NESTQUEST_PANEL_TOKEN": "panel-token",
|
|
"NESTQUEST_OIDC_ISSUER": "https://idp.example.com/issuer/",
|
|
"NESTQUEST_OIDC_AUDIENCE": "nestquest-api",
|
|
"NESTQUEST_OIDC_JWKS_URL": "https://idp.example.com/jwks/",
|
|
}
|
|
)
|
|
assert cfg.panel_token == "panel-token"
|
|
|
|
|
|
def test_from_env_requires_panel_token_when_missing() -> None:
|
|
"""A missing panel token raises ConfigError (no silent default)."""
|
|
with pytest.raises(ConfigError):
|
|
ApiConfig.from_env(
|
|
{
|
|
"NESTQUEST_DB_PATH": "/data/nq.db",
|
|
"NESTQUEST_OIDC_ISSUER": "https://idp.example.com/issuer/",
|
|
"NESTQUEST_OIDC_AUDIENCE": "nestquest-api",
|
|
"NESTQUEST_OIDC_JWKS_URL": "https://idp.example.com/jwks/",
|
|
}
|
|
)
|
|
|
|
|
|
def test_from_env_requires_panel_token_when_empty() -> None:
|
|
"""An empty/whitespace panel token raises ConfigError."""
|
|
with pytest.raises(ConfigError):
|
|
ApiConfig.from_env(
|
|
{
|
|
"NESTQUEST_DB_PATH": "/data/nq.db",
|
|
"NESTQUEST_PANEL_TOKEN": " ",
|
|
"NESTQUEST_OIDC_ISSUER": "https://idp.example.com/issuer/",
|
|
"NESTQUEST_OIDC_AUDIENCE": "nestquest-api",
|
|
"NESTQUEST_OIDC_JWKS_URL": "https://idp.example.com/jwks/",
|
|
}
|
|
)
|
|
|
|
|
|
# --- service-token auth --------------------------------------------------
|
|
|
|
|
|
async def test_missing_token_is_unauthorized(
|
|
panel_client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""A request with no Authorization header returns 401."""
|
|
response = await panel_client.get("/api/v1/panel/snapshot")
|
|
assert response.status_code == 401
|
|
assert "www-authenticate" in {
|
|
key.lower() for key in response.headers
|
|
}
|
|
|
|
|
|
async def test_wrong_token_is_unauthorized(
|
|
panel_client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""A wrong Bearer token returns 401."""
|
|
response = await panel_client.get(
|
|
"/api/v1/panel/snapshot",
|
|
headers={"Authorization": "Bearer not-the-panel-token"},
|
|
)
|
|
assert response.status_code == 401
|
|
|
|
|
|
async def test_malformed_authorization_header_is_unauthorized(
|
|
panel_client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""A malformed Authorization header returns 401.
|
|
|
|
Wrong scheme, empty credential, and an empty header all fail the
|
|
same way — the response never reveals which part failed.
|
|
"""
|
|
for header in (
|
|
"Basic dXNlcjpwYXNz",
|
|
"Bearer",
|
|
"Bearer ",
|
|
"Token not-the-panel-token",
|
|
"",
|
|
):
|
|
response = await panel_client.get(
|
|
"/api/v1/panel/snapshot",
|
|
headers={"Authorization": header},
|
|
)
|
|
assert response.status_code == 401, f"header {header!r} passed"
|
|
|
|
|
|
# --- snapshot payload ----------------------------------------------------
|
|
|
|
|
|
async def test_valid_token_returns_full_snapshot(
|
|
seeded_panel: SimpleNamespace,
|
|
) -> None:
|
|
"""The correct token returns 200 with the documented snapshot shape.
|
|
|
|
Children in sort order (Ada sort_order 0, Bo 1); per-child counts,
|
|
presence, next_present, and cycle day all asserted as explicit
|
|
literals; each child's instances are in the documented panel
|
|
payload shape (done -> completed spelling, on_time passthrough).
|
|
"""
|
|
seed = seeded_panel.seed
|
|
response = await seeded_panel.client.get(
|
|
"/api/v1/panel/snapshot",
|
|
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
|
)
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
|
|
assert body["today_iso"] == seeded_panel.today.isoformat()
|
|
# Ada's 2-week cycle anchored yesterday => today is cycle day 2.
|
|
assert body["cycle_day"] == 2
|
|
|
|
children = body["children"]
|
|
assert [child["child_id"] for child in children] == [
|
|
seed.ada.id,
|
|
seed.bo.id,
|
|
], "children appear in sort_order"
|
|
|
|
ada, bo = children
|
|
|
|
# Ada: away today, returns on the override date; her 10:00 quest
|
|
# is open and overdue at the pinned 12:00.
|
|
assert ada["child_name"] == "Ada"
|
|
assert ada["present"] is False
|
|
assert ada["next_present"] == seed.returns_date.isoformat()
|
|
assert ada["due_today"] == 1
|
|
assert ada["completed_today"] == 0
|
|
assert ada["remaining_today"] == 1
|
|
assert ada["completion_pct"] == 0
|
|
assert ada["instances"] == [
|
|
{
|
|
"id": seed.pack_bag_instance.id,
|
|
"definition_id": seed.pack_bag_instance.definition_id,
|
|
"child_id": seed.ada.id,
|
|
"title": "Pack bag",
|
|
"icon": None,
|
|
"window": "morning",
|
|
"due_time": "10:00",
|
|
"state": "open",
|
|
"overdue": True,
|
|
"completed_at": None,
|
|
"on_time": None,
|
|
}
|
|
]
|
|
|
|
# Bo: present, completed his 17:00 quest at the pinned 12:00
|
|
# (on time), so the panel spelling is "completed".
|
|
assert bo["child_name"] == "Bo"
|
|
assert bo["present"] is True
|
|
assert bo["next_present"] is None
|
|
assert bo["due_today"] == 1
|
|
assert bo["completed_today"] == 1
|
|
assert bo["remaining_today"] == 0
|
|
assert bo["completion_pct"] == 100
|
|
assert bo["instances"] == [
|
|
{
|
|
"id": seed.brush_teeth_instance.id,
|
|
"definition_id": seed.brush_teeth_instance.definition_id,
|
|
"child_id": seed.bo.id,
|
|
"title": "Brush teeth",
|
|
"icon": None,
|
|
"window": "morning",
|
|
"due_time": "17:00",
|
|
"state": "completed",
|
|
"overdue": False,
|
|
"completed_at": seeded_panel.pinned_utc_iso,
|
|
"on_time": True,
|
|
}
|
|
]
|
|
|
|
|
|
async def test_missed_instances_are_omitted_from_panel_payload(
|
|
seeded_panel: SimpleNamespace,
|
|
) -> None:
|
|
"""A missed instance never reaches the panel payload (D-009).
|
|
|
|
The household carries a past-due OPEN instance due yesterday (the
|
|
state the panel reads as "missed"); it must not appear anywhere in
|
|
the response, and Ada's payload carries exactly her ONE today
|
|
instance — the past-due rows (the stale chore AND her pack bag due
|
|
yesterday) are both omitted rather than rendered.
|
|
"""
|
|
seed = seeded_panel.seed
|
|
response = await seeded_panel.client.get(
|
|
"/api/v1/panel/snapshot",
|
|
headers={"Authorization": f"Bearer {PANEL_TOKEN}"},
|
|
)
|
|
assert response.status_code == 200
|
|
body = response.json()
|
|
|
|
payload_instances = [
|
|
instance
|
|
for child in body["children"]
|
|
for instance in child["instances"]
|
|
]
|
|
assert "missed" not in [
|
|
instance["state"] for instance in payload_instances
|
|
]
|
|
past_due_ids = {seed.stale_instance.id}
|
|
assert past_due_ids.isdisjoint(
|
|
instance["id"] for instance in payload_instances
|
|
), "the past-due open instance leaked into the panel payload"
|
|
|
|
ada = next(
|
|
child
|
|
for child in body["children"]
|
|
if child["child_id"] == seed.ada.id
|
|
)
|
|
assert [instance["id"] for instance in ada["instances"]] == [
|
|
seed.pack_bag_instance.id
|
|
]
|
|
|
|
|
|
def test_panel_shaper_drops_missed_views() -> None:
|
|
"""The route's shaper omits a missed view (D-009), on the app's copy.
|
|
|
|
``instance_payload(include_missed=False)`` is what the snapshot
|
|
route shapes every child's instances with; exercised here on the
|
|
SAME core copy the app uses (the two-copy caveat makes this a
|
|
distinct object from the conftest-stubbed copy's identical source).
|
|
"""
|
|
missed_view = core_snapshot.QuestInstanceView(
|
|
instance_id=999,
|
|
definition_id=1,
|
|
child_id=1,
|
|
title="Stale chore",
|
|
icon=None,
|
|
window="morning",
|
|
due_date="2026-09-21",
|
|
due_time="09:00",
|
|
state="missed",
|
|
overdue=False,
|
|
completed_at=None,
|
|
was_on_time=None,
|
|
)
|
|
assert core_snapshot.instance_payload(
|
|
[missed_view], include_missed=False
|
|
) == []
|
|
|
|
|
|
# --- route documentation -------------------------------------------------
|
|
|
|
|
|
async def test_snapshot_route_is_documented(
|
|
panel_client: httpx.AsyncClient,
|
|
) -> None:
|
|
"""The snapshot route and its response shape are in the OpenAPI doc."""
|
|
response = await panel_client.get("/openapi.json")
|
|
assert response.status_code == 200
|
|
schema = response.json()
|
|
assert "/api/v1/panel/snapshot" in schema["paths"]
|
|
assert "get" in schema["paths"]["/api/v1/panel/snapshot"]
|
|
documented = {
|
|
model["title"]
|
|
for model in schema["components"]["schemas"].values()
|
|
}
|
|
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
|