SSE endpoint publishing quest-completed transitions (task 4d7bae00)
The panel needs live transitions, so add ONE in-process publisher (api/events.py, installed on app.state.publisher in the lifespan) and a GET /api/v1/panel/events SSE route on the panel router — it inherits the single require_panel_token check. The complete route now builds the event through the core builder and publishes it only when the core's .appended flag says an actual transition happened; a no-op re-complete publishes nothing. Tests stream from a real uvicorn instance on an ephemeral port because httpx's ASGITransport buffers endless SSE bodies.
This commit is contained in:
@@ -0,0 +1,295 @@
|
||||
"""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: <type>`` / ``data: <json>`` 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:<port>``. 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)
|
||||
Reference in New Issue
Block a user