"""Tests for clarify SSE long-connection (mirrors test_approval_sse.py structure). Covers: - Static analysis of backend and frontend code - Unit tests for subscribe/unsubscribe/notify lifecycle - Concurrency safety of subscriber registry """ import os import queue import threading import textwrap import pytest # ── Paths ──────────────────────────────────────────────────────────────────── _ROUTES = os.path.join(os.path.dirname(__file__), "..", "api", "routes.py") _CLARIFY = os.path.join(os.path.dirname(__file__), "..", "api", "clarify.py") _MESSAGES = os.path.join(os.path.dirname(__file__), "..", "static", "messages.js") def _read(path): with open(path) as f: return f.read() # ══════════════════════════════════════════════════════════════════════════════ # 1. Static analysis — verify code structure without importing the server # ══════════════════════════════════════════════════════════════════════════════ @pytest.mark.parametrize("marker", [ "_clarify_sse_subscribers", "def sse_subscribe", "def sse_unsubscribe", "_clarify_sse_notify", ]) class TestClarifySSEBackendMarkers: def test_clarify_module_has_marker(self, marker): src = _read(_CLARIFY) assert marker in src, f"api/clarify.py missing: {marker}" class TestClarifySSEBackendCode: def test_submit_pending_calls_notify(self): src = _read(_CLARIFY) assert "_clarify_sse_notify(" in src, ( "submit_pending should call _clarify_sse_notify inside _lock" ) def test_resolve_clarify_calls_notify(self): src = _read(_CLARIFY) # Count occurrences — resolve should notify on both branches assert src.count("_clarify_sse_notify(") >= 2, ( "resolve_clarify should call _clarify_sse_notify for both queue-has-more and empty cases" ) class TestClarifySSERoutesCode: def test_route_registered(self): src = _read(_ROUTES) assert '"/api/clarify/stream"' in src, "Missing /api/clarify/stream route" def test_handler_function_exists(self): src = _read(_ROUTES) assert "def _handle_clarify_sse_stream(" in src def test_imports_sse_subscribe(self): src = _read(_ROUTES) assert "clarify_sse_subscribe" in src def test_imports_sse_unsubscribe(self): src = _read(_ROUTES) assert "clarify_sse_unsubscribe" in src class TestClarifySSEFrontendCode: """Frontend clarify transport. As of #3913 the frontend no longer opens a clarify-stream EventSource (it was one of six persistent SSE streams that exhausted the browser's 6-per-origin HTTP/1.1 pool). ``startClarifyPolling`` now routes straight to the HTTP fallback poller. The backend SSE route stays for compatibility (tests above); these pin the poll-only frontend against regression. """ @pytest.fixture(autouse=True) def _load_js(self): self.js = _read(_MESSAGES) def _clarify_polling_body(self): start = self.js.index("function startClarifyPolling(") end = self.js.index("\nfunction ", start + 1) return self.js[start:end] def test_does_not_open_clarify_stream(self): body = self._clarify_polling_body() assert "api/clarify/stream" not in body, \ "Frontend must not open the clarify-stream EventSource (conn-pool exhaustion, #3913)" assert "new EventSource" not in body, \ "startClarifyPolling must not construct an EventSource — it polls over HTTP now" def test_routes_directly_to_fallback_poll(self): body = self._clarify_polling_body() assert "_startClarifyFallbackPoll(sid)" in body, \ "startClarifyPolling must route to the HTTP fallback poller" def test_frontend_has_fallback_poll(self): assert "_startClarifyFallbackPoll" in self.js or "_clarifyFallbackTimer" in self.js def test_fallback_poll_hits_pending_endpoint(self): assert 'api("/api/clarify/pending?session_id="' in self.js, \ "Clarify fallback poll must query /api/clarify/pending" assert "EventSource('/api/clarify/stream" not in self.js, \ "No root-absolute clarify EventSource may remain (subpath-mount safety)" def test_frontend_fallback_interval_3s(self): # Fallback poll interval should be 3000ms assert "3000" in self.js def test_frontend_stop_defensively_closes_event_source(self): # _clarifyEventSource stays declared (always null now) with a # null-guarded close() so any future re-introduction stays safe. assert "_clarifyEventSource" in self.js assert ".close()" in self.js # ══════════════════════════════════════════════════════════════════════════════ # 2. Unit tests — import clarify module directly # ══════════════════════════════════════════════════════════════════════════════ @pytest.fixture() def clarify_mod(): """Import api.clarify fresh (module-level state is shared).""" from api import clarify return clarify @pytest.fixture(autouse=True) def _cleanup_subscribers(clarify_mod): """Clear SSE subscribers between tests to avoid leakage.""" yield clarify_mod._clarify_sse_subscribers.clear() class TestClarifySSEUnit: def test_subscribe_returns_queue(self, clarify_mod): q = clarify_mod.sse_subscribe("s1") assert isinstance(q, queue.Queue) assert q.maxsize == 16 def test_unsubscribe_removes_queue(self, clarify_mod): q = clarify_mod.sse_subscribe("s1") clarify_mod.sse_unsubscribe("s1", q) assert "s1" not in clarify_mod._clarify_sse_subscribers def test_unsubscribe_cleans_empty_session(self, clarify_mod): q = clarify_mod.sse_subscribe("s1") clarify_mod.sse_unsubscribe("s1", q) assert "s1" not in clarify_mod._clarify_sse_subscribers def test_unsubscribe_unknown_queue_no_error(self, clarify_mod): q = queue.Queue() clarify_mod.sse_unsubscribe("s1", q) # should not raise def test_multiple_subscribers_same_session(self, clarify_mod): q1 = clarify_mod.sse_subscribe("s1") q2 = clarify_mod.sse_subscribe("s1") assert len(clarify_mod._clarify_sse_subscribers["s1"]) == 2 clarify_mod.sse_unsubscribe("s1", q1) assert len(clarify_mod._clarify_sse_subscribers["s1"]) == 1 def test_notify_delivers_to_all_subscribers(self, clarify_mod): q1 = clarify_mod.sse_subscribe("s1") q2 = clarify_mod.sse_subscribe("s1") clarify_mod._clarify_sse_notify("s1", {"question": "test?"}, 1) assert q1.get(timeout=1)["pending"]["question"] == "test?" assert q2.get(timeout=1)["pending"]["question"] == "test?" def test_cross_session_isolation(self, clarify_mod): q1 = clarify_mod.sse_subscribe("s1") q2 = clarify_mod.sse_subscribe("s2") clarify_mod._clarify_sse_notify("s1", {"question": "q1"}, 1) assert q1.get(timeout=1)["pending"]["question"] == "q1" assert q2.empty() def test_queue_overflow_drops_silently(self, clarify_mod): q = clarify_mod.sse_subscribe("s1") for i in range(20): # maxsize=16 clarify_mod._clarify_sse_notify("s1", {"q": i}, i + 1) # Should not raise; some messages dropped count = 0 while not q.empty(): q.get_nowait() count += 1 assert count <= 16 def test_submit_pending_triggers_notify(self, clarify_mod): q = clarify_mod.sse_subscribe("s1") clarify_mod.submit_pending("s1", {"question": "hello?", "choices_offered": []}) payload = q.get(timeout=1) assert payload["pending"] is not None assert payload["pending"]["question"] == "hello?" assert payload["pending_count"] == 1 def test_unsubscribe_mid_notify_safe(self, clarify_mod): q1 = clarify_mod.sse_subscribe("s1") q2 = clarify_mod.sse_subscribe("s1") clarify_mod.sse_unsubscribe("s1", q2) clarify_mod._clarify_sse_notify("s1", {"question": "safe?"}, 1) assert q1.get(timeout=1)["pending"]["question"] == "safe?" # ══════════════════════════════════════════════════════════════════════════════ # 3. Concurrency tests # ══════════════════════════════════════════════════════════════════════════════ class TestClarifySSEConcurrency: def test_concurrent_subscribe_unsubscribe(self, clarify_mod): errors = [] def worker(): try: for _ in range(50): q = clarify_mod.sse_subscribe("s1") clarify_mod.sse_unsubscribe("s1", q) except Exception as e: errors.append(e) threads = [threading.Thread(target=worker) for _ in range(4)] for t in threads: t.start() for t in threads: t.join(timeout=5) assert not errors def test_concurrent_notify_and_subscribe(self, clarify_mod): received = [] lock = threading.Lock() q = clarify_mod.sse_subscribe("s1") def notifier(): for i in range(20): clarify_mod._clarify_sse_notify("s1", {"q": i}, i + 1) def subscriber(): for _ in range(20): q2 = clarify_mod.sse_subscribe("s1") clarify_mod.sse_unsubscribe("s1", q2) t1 = threading.Thread(target=notifier) t2 = threading.Thread(target=subscriber) t1.start() t2.start() t1.join(timeout=5) t2.join(timeout=5) # Just verify no crash — some events may have been dropped while not q.empty(): payload = q.get_nowait() with lock: received.append(payload) # Should have received at least some events assert len(received) > 0