"""SSE endpoint tests: the panel event stream (task 4d7bae00). These tests exercise ``GET /api/v1/panel/events`` — the panel plane's Server-Sent Events stream — against a REAL server: - The stream requires the panel service token (D-012): no/wrong token returns 401 (the router-level shared dependency, no second check). - The response is ``text/event-stream`` and each transition arrives as an ``event: `` / ``data: `` frame. - Completing an instance through the panel complete route publishes the ``nestquest_quest_completed`` transition carrying the documented payload fields (design/ENTITIES-AND-SERVICES.md §3). - A no-op re-complete publishes nothing (no second completed event). Streaming approach (documented): httpx's ASGITransport BUFFERS the whole response body before returning, so an endless SSE stream hangs it — exactly the awkward case this task anticipated. These tests therefore run the app with uvicorn on an ephemeral port (port 0, so the OS picks a free one) in a daemon thread, and httpx streams from ``http://127.0.0.1:``. Every read is bounded by a timeout so a wedged stream fails an assertion instead of hanging the suite; the server is shut down in the fixture's finally block. """ from __future__ import annotations import asyncio import datetime import importlib import json import socket import threading import time from pathlib import Path from types import SimpleNamespace from zoneinfo import ZoneInfo import httpx import pytest import uvicorn from api import routes_panel from api.app import create_app from api.config import ApiConfig from tests.test_api_panel import PANEL_TOKEN, _seed_household #: The documented quest-completed event name (core const, §3). _core_const = importlib.import_module("nestquest_core.const") EVENT_QUEST_COMPLETED = _core_const.EVENT_QUEST_COMPLETED #: Upper bound for any single stream read; bounds the whole test so a #: wedged stream fails the assertion instead of hanging the suite. READ_TIMEOUT = 5.0 def _free_port() -> int: """Ask the OS for one free TCP port (bound then released).""" with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: sock.bind(("127.0.0.1", 0)) return sock.getsockname()[1] class _Server: """Run the app with uvicorn on an ephemeral port, in a thread. ``create_app`` runs INSIDE the server thread (uvicorn's factory mode), so the lifespan — including ``app.state.publisher`` — is set up per test on the server's own event loop. Daemon thread + explicit ``should_exit`` shutdown in the fixture's finally block. """ def __init__(self, db_path: str) -> None: self.port = _free_port() self._server = uvicorn.Server( uvicorn.Config( # A closure factory: reads nothing at import time. lambda: create_app( ApiConfig(db_path=db_path, panel_token=PANEL_TOKEN) ), host="127.0.0.1", port=self.port, log_level="warning", ) ) self._thread = threading.Thread( target=self._server.run, daemon=True ) def __enter__(self) -> "_Server": self._thread.start() deadline = time.monotonic() + READ_TIMEOUT while not self._server.started: if time.monotonic() > deadline or not self._thread.is_alive(): raise RuntimeError("uvicorn did not start") time.sleep(0.01) return self def __exit__(self, *exc) -> None: self._server.should_exit = True self._thread.join(timeout=READ_TIMEOUT) def url(self, path: str) -> str: """The full URL for ``path`` on this server.""" return f"http://127.0.0.1:{self.port}{path}" class _EventStream: """A subscribed SSE reader over a live httpx connection. Parses frames (``event:``/``data:`` pairs) into ``(event_type, payload)`` tuples; ``next_event`` awaits the next parsed frame with a timeout. """ def __init__(self) -> None: self.queue: asyncio.Queue = asyncio.Queue() self._task: asyncio.Task | None = None self._client: httpx.AsyncClient | None = None self._stream_cm = None self._response: httpx.Response | None = None async def __aenter__(self) -> "_EventStream": self._client = httpx.AsyncClient(timeout=READ_TIMEOUT) self._stream_cm = self._client.stream( "GET", self.url, headers={"Authorization": f"Bearer {PANEL_TOKEN}"}, ) self._response = await self._stream_cm.__aenter__() assert self._response.status_code == 200 assert self._response.headers["content-type"].startswith( "text/event-stream" ) self._task = asyncio.create_task(self._pump()) return self def _set_url(self, url: str) -> "_EventStream": """Record the stream URL (called before ``__aenter__``).""" self.url = url return self async def _pump(self) -> None: """Read the stream and push parsed frames onto the queue.""" event_type: str | None = None data_lines: list[str] = [] try: async for chunk in self._response.aiter_text(): for line in chunk.splitlines(): if line.startswith("event:"): event_type = line[len("event:"):].strip() elif line.startswith("data:"): data_lines.append(line[len("data:"):].strip()) elif not line and event_type is not None: payload = json.loads("\n".join(data_lines)) await self.queue.put((event_type, payload)) event_type = None data_lines = [] except Exception as error: # pragma: no cover - teardown path await self.queue.put(error) async def next_event(self) -> tuple[str, dict]: """The next parsed ``(event_type, payload)``, with a timeout.""" item = await asyncio.wait_for( self.queue.get(), timeout=READ_TIMEOUT ) if isinstance(item, Exception): raise item return item async def __aexit__(self, *exc) -> None: if self._task is not None: self._task.cancel() try: await self._task except asyncio.CancelledError: pass await self._stream_cm.__aexit__(*exc) await self._client.__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 seeded_panel(temp_db_path: str) -> SimpleNamespace: """A seeded household served by a live uvicorn app, clock pinned. Same pinning discipline as the panel tests (12:00 UTC, past Ada's 10:00 due, before Bo's 17:00): the route's ``_local_now`` is patched so the completion's ``was_on_time`` is deterministic. The app is created inside uvicorn's factory, so the patch must happen BEFORE the server starts. """ today = datetime.datetime.now().astimezone().date() pinned_now = datetime.datetime( today.year, today.month, today.day, 12, 0, 0, tzinfo=ZoneInfo("UTC") ) seed = await _seed_household(temp_db_path, today, pinned_now) # The app is created inside uvicorn's factory (in the server # thread), so the patch must be applied BEFORE the server starts. original = routes_panel._local_now routes_panel._local_now = lambda: pinned_now try: with _Server(temp_db_path) as server: client = httpx.AsyncClient(base_url=server.url("")) async with client: yield SimpleNamespace( client=client, seed=seed, server=server, url=server.url, ) finally: routes_panel._local_now = original def _stream(url: str) -> _EventStream: """A subscribed SSE reader for ``url``.""" return _EventStream()._set_url(url) # --- auth and content type ---------------------------------------------- async def test_events_requires_panel_token(seeded_panel) -> None: """The SSE route inherits the router's token check (no second one).""" response = await seeded_panel.client.get("/api/v1/panel/events") assert response.status_code == 401 # --- the quest-completed transition -------------------------------------- async def test_complete_publishes_quest_completed_event( seeded_panel, ) -> None: """A panel completion emits the documented completed transition. The stream is subscribed FIRST, the instance is completed through the panel route, and the emitted frame's type and every documented payload field (child_id, child_name, instance_id, quest_title, window, due_date, due_time, occurred_at, was_on_time) are asserted. """ seed = seeded_panel.seed pack_bag = seed.pack_bag_instance stream = _stream(seeded_panel.url("/api/v1/panel/events")) async with stream: response = await seeded_panel.client.post( f"/api/v1/panel/instances/{pack_bag.id}/complete", headers={"Authorization": f"Bearer {PANEL_TOKEN}"}, json={"actor_child_id": seed.ada.id}, ) assert response.status_code == 200 event_type, payload = await stream.next_event() assert event_type == EVENT_QUEST_COMPLETED assert event_type == "nestquest_quest_completed" assert payload["child_id"] == seed.ada.id assert payload["child_name"] == "Ada" assert payload["instance_id"] == pack_bag.id assert payload["quest_title"] == "Pack bag" assert payload["window"] == "morning" assert payload["due_date"] == pack_bag.due_date assert payload["due_time"] == "10:00" assert payload["occurred_at"] # strict UTC ISO-8601 stamp assert payload["was_on_time"] is False # 10:00 due, 12:00 now async def test_no_op_recomplete_publishes_nothing(seeded_panel) -> None: """A second complete call emits NO second completed event. After the first (real) transition, a re-complete is a no-op in the core layer; the stream must stay silent. """ seed = seeded_panel.seed url = f"/api/v1/panel/instances/{seed.pack_bag_instance.id}/complete" body = {"actor_child_id": seed.ada.id} headers = {"Authorization": f"Bearer {PANEL_TOKEN}"} stream = _stream(seeded_panel.url("/api/v1/panel/events")) async with stream: first = await seeded_panel.client.post(url, headers=headers, json=body) assert first.status_code == 200 event_type, _ = await stream.next_event() assert event_type == EVENT_QUEST_COMPLETED second = await seeded_panel.client.post( url, headers=headers, json=body ) assert second.status_code == 200 with pytest.raises(asyncio.TimeoutError): # Nothing arrives within the window: the no-op re-complete # must publish nothing. await asyncio.wait_for(stream.queue.get(), timeout=0.3)