"""Phase 8: presence. Who is on which screen and packet, with the role shown; locked sessions drop out and come back on unlock; revoked sessions are gone at once. Driven through the EventHub directly (as test_events.py does) and through the API (`/api/events`, `/api/auth/lock`, `/api/auth/unlock`, the idle lock in `resolve`). """ from __future__ import annotations import asyncio import json import uuid from collections.abc import AsyncIterator, Iterator from typing import Any import pytest from fakedir import FAKE_PASSWORD, FakeDirectory from fastapi.testclient import TestClient from pshelpers import ORIGIN_HEADERS, needs_pg, sign_in from packetstation import db, events from packetstation.settings import Settings USER_KEYS = {"user_id", "display_name", "role", "screen", "packet_id", "since"} def _sub( hub: events.EventHub, name: str, *, screen: str | None = None, packet_id: int | None = None, role: str = "Supervisor", user_id: Any = None, session_id: Any = None, ) -> events.Subscriber: return hub.subscribe( session_id=session_id or uuid.uuid4(), user_id=user_id or uuid.uuid4(), display_name=name, screen=screen, packet_id=packet_id, role=role, ) async def _drain(q: asyncio.Queue[events.Item]) -> list[events.Item]: await asyncio.sleep(0) # let call_soon_threadsafe callbacks run out = [] while not q.empty(): out.append(q.get_nowait()) return out def _on(hub: events.EventHub, packet_id: int) -> list[tuple[str, str, str | None]]: return [(u["display_name"], u["role"], u["screen"]) for u in hub.presence()["users"] if u["packet_id"] == packet_id] def _names(hub: events.EventHub) -> list[str]: return [u["display_name"] for u in hub.presence()["users"]] def _session(hub: events.EventHub, sub: events.Subscriber, action: str) -> None: hub._deliver(json.dumps({"event": "session", "session_id": sub.session_id, "action": action})) # ================================================================ the hub def test_two_users_on_one_packet_see_each_other() -> None: async def run() -> None: hub = events.EventHub() wes = _sub(hub, "Wes Super", screen="supervisor", packet_id=7, role="Supervisor") andy = _sub(hub, "Andy Welder", screen="stall", packet_id=7, role="Weld Stall") _sub(hub, "Clara Clerk", screen="clerical", role="Clerical") users = hub.presence()["users"] assert all(set(u) == USER_KEYS for u in users), users assert all(u["since"] for u in users) assert sorted(_on(hub, 7)) == [ ("Andy Welder", "Weld Stall", "stall"), ("Wes Super", "Supervisor", "supervisor"), ] last = (await _drain(wes.queue))[-1] assert last[0] == "presence" and {u["display_name"] for u in last[1]["users"]} == { "Wes Super", "Andy Welder", "Clara Clerk", } assert (await _drain(andy.queue))[-1] == last asyncio.run(run()) def test_one_entry_per_user_screen_and_packet() -> None: async def run() -> None: hub = events.EventHub() uid = uuid.uuid4() _sub(hub, "Wes Super", screen="supervisor", packet_id=7, user_id=uid) _sub(hub, "Wes Super", screen="supervisor", packet_id=7, user_id=uid) # a second tab _sub(hub, "Wes Super", screen="supervisor", packet_id=8, user_id=uid) assert len(_on(hub, 7)) == 1 and len(_on(hub, 8)) == 1 assert _names(hub) == ["Wes Super", "Wes Super"] asyncio.run(run()) def test_leaving_drops_out() -> None: async def run() -> None: hub = events.EventHub() wes = _sub(hub, "Wes Super", packet_id=7) andy = _sub(hub, "Andy Welder", packet_id=7, role="Weld Stall") await _drain(wes.queue) hub.unsubscribe(andy) got = await _drain(wes.queue) assert got and got[-1][0] == "presence" assert [u["display_name"] for u in got[-1][1]["users"]] == ["Wes Super"] assert _on(hub, 7) == [("Wes Super", "Supervisor", None)] asyncio.run(run()) def test_locked_hides_and_unlocked_brings_back() -> None: async def run() -> None: hub = events.EventHub() wes = _sub(hub, "Wes Super", packet_id=7) andy = _sub(hub, "Andy Welder", packet_id=7, role="Weld Stall") await _drain(wes.queue) await _drain(andy.queue) _session(hub, andy, "locked") assert _names(hub) == ["Wes Super"] to_wes = await _drain(wes.queue) assert [n for n, _ in to_wes] == ["presence"] # the others get presence, never the session event assert [u["display_name"] for u in to_wes[-1][1]["users"]] == ["Wes Super"] to_andy = await _drain(andy.queue) assert ("session", {"session_id": andy.session_id, "action": "locked"}) in to_andy _session(hub, andy, "unlocked") assert sorted(_names(hub)) == ["Andy Welder", "Wes Super"] to_wes = await _drain(wes.queue) assert to_wes and to_wes[-1][0] == "presence" and len(to_wes[-1][1]["users"]) == 2 assert ("session", {"session_id": andy.session_id, "action": "unlocked"}) in await _drain(andy.queue) asyncio.run(run()) def test_a_locked_session_hides_only_itself() -> None: async def run() -> None: hub = events.EventHub() uid = uuid.uuid4() tablet = _sub(hub, "Wes Super", packet_id=7, user_id=uid) _sub(hub, "Wes Super", packet_id=7, user_id=uid) # the same person on a second device _session(hub, tablet, "locked") assert _on(hub, 7) == [("Wes Super", "Supervisor", None)] asyncio.run(run()) def test_revoked_is_gone_at_once() -> None: async def run() -> None: hub = events.EventHub() wes = _sub(hub, "Wes Super", packet_id=7) andy = _sub(hub, "Andy Welder", packet_id=7, role="Weld Stall") await _drain(wes.queue) _session(hub, andy, "revoked") assert _names(hub) == ["Wes Super"] # before the stream has ended got = await _drain(wes.queue) assert got and got[-1][0] == "presence" and len(got[-1][1]["users"]) == 1 assert ("session", {"session_id": andy.session_id, "action": "revoked"}) in await _drain(andy.queue) asyncio.run(run()) def test_stream_forwards_lock_and_keeps_going() -> None: async def run() -> list[str]: hub = events.EventHub() sub = _sub(hub, "Wes Super", screen="supervisor") await _drain(sub.queue) async def connected() -> bool: return False gen = events.stream(hub, sub, hello={"session_id": sub.session_id}, is_disconnected=connected, ping_seconds=5) chunks = [await gen.__anext__()] _session(hub, sub, "locked") for _ in range(3): chunks.append(await asyncio.wait_for(gen.__anext__(), 2)) if chunks[-1].startswith("event: session"): break _session(hub, sub, "revoked") rest = [c async for c in gen] assert hub.presence() == {"users": []} return chunks + rest chunks = asyncio.run(run()) assert chunks[0].startswith("event: hello\n") names = [c.split("\n", 1)[0] for c in chunks[1:]] assert "event: session" in names # the lock reached the locked session's own stream assert any('"action":"locked"' in c for c in chunks) def test_presence_by_screen() -> None: async def run() -> None: hub = events.EventHub() _sub(hub, "Wes Super", screen="supervisor") _sub(hub, "Lou Lead", screen="supervisor") _sub(hub, "Quinn QC", screen="qc", role="Q.C.") locked = _sub(hub, "Andy Welder", screen="stall", role="Weld Stall") _session(hub, locked, "locked") by = hub.presence_by_screen() assert sorted(u["display_name"] for u in by["supervisor"]) == ["Lou Lead", "Wes Super"] assert [u["display_name"] for u in by["qc"]] == ["Quinn QC"] assert not by.get("stall") asyncio.run(run()) # ================================================================ through the API @pytest.fixture def client(pg_settings: Settings, fake_dir: FakeDirectory, dev_visual: None) -> Iterator[TestClient]: from packetstation.main import create_app with TestClient(create_app(pg_settings, directory=fake_dir), headers=ORIGIN_HEADERS) as c: yield c def _signed(client: TestClient, sam: str) -> tuple[TestClient, str]: c = TestClient(client.app, headers=ORIGIN_HEADERS) r = sign_in(c, sam) assert r.status_code == 200, r.text return c, str(r.json()["session"]["id"]) @needs_pg def test_events_stream_carries_the_role(client: TestClient, monkeypatch: pytest.MonkeyPatch) -> None: """The stream body is replaced by one that answers the hub's presence and ends, so the request returns.""" from packetstation.api import events as events_api async def snapshot( hub: events.EventHub, sub: events.Subscriber, *, hello: dict[str, Any], **_: Any ) -> AsyncIterator[str]: try: yield events.format_sse("presence", hub.presence()) finally: hub.unsubscribe(sub) monkeypatch.setattr(events_api, "stream", snapshot) def users(c: TestClient, **params: Any) -> list[dict[str, Any]]: r = c.get("/api/events", params=params) assert r.status_code == 200, r.text line = next(x for x in r.text.splitlines() if x.startswith("data: ")) out: list[dict[str, Any]] = json.loads(line[6:])["users"] return out wes, _ = _signed(client, "sup.wes") me = users(wes, screen="supervisor", packet_id=5) assert [(u["display_name"], u["role"], u["screen"], u["packet_id"]) for u in me] == [ ("Wes Super", "Supervisor", "supervisor", 5) ] assert me[0]["since"] # a screen the user does not have: no screen, the first role's label other = users(wes, screen="qc") assert [(u["role"], u["screen"]) for u in other] == [("Supervisor", None)] for sam, screen, role in ( ("pm.paula", "pm", "Project Mgmt"), ("weld.andy", "stall", "Weld Stall"), ("clerk.clara", "clerical", "Clerical"), ("wh.wade", "warehouse", "Warehouse"), ("qc.quinn", "qc", "Q.C."), ("admin.ann", "admin", "Admin"), ): c, _ = _signed(client, sam) assert [u["role"] for u in users(c, screen=screen)] == [role], sam paula, _ = _signed(client, "pm.paula") assert [u["role"] for u in users(paula)] == ["Project Mgmt"] # no screen: the primary role async def _wait_session(q: asyncio.Queue[events.Item], action: str) -> dict[str, Any]: while True: name, data = await asyncio.wait_for(q.get(), timeout=10) if name == "session" and data.get("action") == action: return data @needs_pg def test_lock_and_unlock_publish_session_events(client: TestClient, pg_settings: Settings) -> None: wes, sid = _signed(client, "sup.wes") async def run() -> None: hub = events.EventHub(connect=lambda: db.connect_as("app", pg_settings, autocommit=True)) sub = _sub(hub, "Wes Super", screen="supervisor", packet_id=3, session_id=sid) other = _sub(hub, "Andy Welder", screen="stall", packet_id=3, role="Weld Stall") hub.start() try: for _ in range(100): if hub.connected: break await asyncio.sleep(0.05) assert hub.connected r = await asyncio.to_thread(wes.post, "/api/auth/lock") assert r.status_code == 204, r.text data = await _wait_session(sub.queue, "locked") assert data["session_id"] == sid assert _names(hub) == ["Andy Welder"] assert other.queue.qsize() >= 1 # the others got a presence update r = await asyncio.to_thread( wes.post, "/api/auth/unlock", json={"factor": "password", "secret": FAKE_PASSWORD} ) assert r.status_code == 200, r.text await _wait_session(sub.queue, "unlocked") assert sorted(_names(hub)) == ["Andy Welder", "Wes Super"] finally: await asyncio.to_thread(hub.stop) asyncio.run(run()) @needs_pg def test_idle_lock_publishes(client: TestClient, pg_settings: Settings) -> None: wes, sid = _signed(client, "sup.wes") async def run() -> None: hub = events.EventHub(connect=lambda: db.connect_as("app", pg_settings, autocommit=True)) sub = _sub(hub, "Wes Super", session_id=sid) hub.start() try: for _ in range(100): if hub.connected: break await asyncio.sleep(0.05) assert hub.connected with db.connect_as("app", pg_settings, autocommit=True) as conn: conn.execute("UPDATE sessions SET last_seen_at = now() - interval '5 minutes' WHERE id = %s", (sid,)) r = await asyncio.to_thread(wes.get, "/api/auth/me") assert r.status_code == 200 and r.json()["session"]["locked"] is True, r.text await _wait_session(sub.queue, "locked") assert _names(hub) == [] finally: await asyncio.to_thread(hub.stop) asyncio.run(run())