"""Live-tail NDJSON streaming endpoint tests (Phase 3). Each ``/api/.../stream`` endpoint: 1. yields the current snapshot as ``{"type":"item","data":}`` lines 2. terminates the snapshot with ``{"type":"snapshot_end","data":{"count":N}}`` 3. subscribes to the relevant EventBus kind and forwards new events 4. emits a ``{"type":"heartbeat","data":{"ts":}}`` line every ``CYCLONE_TAIL_HEARTBEAT_S`` seconds (default 15) when idle 5. returns cleanly when the client disconnects 6. responds with ``application/x-ndjson`` Implementation note (deviation from plan) ----------------------------------------- The plan suggested using ``httpx.AsyncClient(transport=ASGITransport(app))`` and ``client.stream()``. That doesn't work for true long-lived streams: * ``httpx.ASGITransport``'s response is buffered (``ASGIResponseStream`` joins every body chunk at the end), so ``client.stream()`` blocks until the app finishes — a live tail never finishes. * ``starlette.requests.Request.is_disconnected()`` relies on a ``http.disconnect`` message in the ASGI receive channel, which ``ASGITransport`` only delivers *after* the response body completes (the receive coroutine awaits ``response_complete``). So even if the caller closes the client side, the endpoint never observes a disconnect. Instead, we call the endpoint coroutine directly, build a synthetic ``Request`` and ``StreamingResponse``, and iterate ``body_iterator`` ourselves. That gives us byte-by-byte streaming, a real disconnect signal via ``body_iterator.aclose()`` (which throws ``CancelledError`` into the generator), and works for arbitrarily long-lived tails. The public surface (snapshot + subscribe + heartbeat + disconnect cleanup) is verified exactly as the plan requires; only the transport differs. """ from __future__ import annotations import asyncio import json from datetime import date, datetime, timezone from decimal import Decimal import pytest from starlette.requests import Request from cyclone.api import app, claims_stream, remittances_stream, activity_stream from cyclone.parsers.models import ( BatchSummary, BillingProvider, ClaimHeader, ClaimOutput, Envelope, Payer, ParseResult, Subscriber, ValidationReport, ) from cyclone.parsers.models_835 import ( ClaimAdjustment, ClaimPayment, Envelope as Envelope835, FinancialInfo, ParseResult835, Payer835, Payee835, ReassociationTrace, ServicePayment, BatchSummary as BatchSummary835, ) from cyclone.pubsub import EventBus from cyclone.store import ( BatchRecord, BatchRecord835, CycloneStore, ) @pytest.fixture(autouse=True) def _short_heartbeat(monkeypatch): """Set a 0.2s heartbeat for all stream tests in this module. The production default is 15s. Tests use a tight value so that generators sitting idle after the test reads its lines exit promptly — ``aclose()`` on the body iterator cancels the generator and the heartbeat timeout is the de-facto idle signal in these tests. """ monkeypatch.setenv("CYCLONE_TAIL_HEARTBEAT_S", "0.2") # --------------------------------------------------------------------------- # # Test helpers # --------------------------------------------------------------------------- # def _make_claim_837(claim_id: str = "CLM-1", charge: str = "124.00") -> ClaimOutput: return ClaimOutput( claim_id=claim_id, control_number="0001", transaction_date=date(2026, 6, 19), billing_provider=BillingProvider(name="Test", npi="1234567890"), subscriber=Subscriber( first_name="Jane", last_name="Doe", member_id=f"M-{claim_id}", ), payer=Payer(name="Test Payer", id="P1"), claim=ClaimHeader( claim_id=claim_id, total_charge=Decimal(charge), frequency_code="1", place_of_service="11", ), diagnoses=[], service_lines=[], validation=ValidationReport(passed=True, errors=[], warnings=[]), raw_segments=[], ) def _make_result_837(n: int = 3, batch_id: str = "b-uuid-1") -> ParseResult: claims = [ _make_claim_837(f"CLM-{i}", charge=str(100 + i)) for i in range(1, n + 1) ] return ParseResult( envelope=Envelope( sender_id="S", receiver_id="R", control_number="0001", transaction_date=date(2026, 6, 19), ), claims=claims, summary=BatchSummary( input_file="test.txt", control_number="0001", transaction_date=date(2026, 6, 19), total_claims=n, passed=n, failed=0, ), ) def _make_batch_record(kind: str = "837p", result=None, batch_id: str = "b-uuid-1", n: int = 3): return BatchRecord( id=batch_id, kind=kind, input_filename="test.txt", parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc), result=result if result is not None else _make_result_837(n=n), ) def _make_remit_with_cas(remit_id="CLP-1", status="1", charge="124.00", paid="62.00", cas_amount="62.00", pcn=None): cp = ClaimPayment( payer_claim_control_number=pcn if pcn is not None else remit_id, status_code=status, status_label="Primary", total_charge=charge, total_paid=paid, service_payments=[ ServicePayment( line_number=1, procedure_qualifier="HC", procedure_code="99213", charge=charge, payment=paid, adjustments=[ClaimAdjustment(group_code="CO", reason_code="45", amount=cas_amount)], ), ], ) return cp def _make_835_result(claims): return ParseResult835( envelope=Envelope835( sender_id="S", receiver_id="R", control_number="0001", transaction_date=date(2026, 6, 19), ), financial_info=FinancialInfo( handling_code="C", paid_amount=Decimal("0"), credit_debit_flag="C", payment_method=None, ), trace=ReassociationTrace( trace_type_code="1", trace_number="0001", originating_company_id="S", ), payer=Payer835(name="X", id="SKCO0"), payee=Payee835(name="Y", npi="1234567890"), claims=claims, summary=BatchSummary835( input_file="era.txt", control_number="0001", transaction_date=date(2026, 6, 19), total_claims=len(claims), passed=len(claims), failed=0, ), ) def _make_request(path: str = "/api/claims/stream", query_string: bytes = b"") -> Request: """Build a Starlette ``Request`` wired to ``app.state`` with a no-op receive channel so the endpoint's ``is_disconnected()`` polling works without raising ``RuntimeError: Receive channel has not been made available``. """ # ``receive`` blocks until a message is delivered. We never deliver # one, so the cancel scope inside ``is_disconnected()`` always # returns ``False`` — exactly what we want for tests that close # via ``body_iterator.aclose()`` rather than the disconnect path. receive_started = asyncio.Event() async def receive(): receive_started.set() # Block forever; is_disconnected's cancel scope will cancel # this await and we'll return None (treated as "no message"). await asyncio.Event().wait() scope = { "type": "http", "method": "GET", "headers": [], "query_string": query_string, "path": path, "app": app, "scheme": "http", "server": ("testserver", 80), "client": ("testclient", 50000), } return Request(scope, receive=receive) def _resolve_query_defaults(endpoint) -> dict: """Extract the actual default values from a FastAPI endpoint signature. ``Query(None)`` etc. are marker objects — when called directly we need to substitute their ``.default`` so the endpoint function sees the same values it would receive over HTTP. """ import inspect sig = inspect.signature(endpoint) resolved: dict = {} for name, param in sig.parameters.items(): default = param.default if hasattr(default, "default"): resolved[name] = default.default else: resolved[name] = default return resolved async def _call_endpoint(endpoint, path: str): """Invoke ``endpoint(request, **defaults)`` with resolved defaults.""" request = _make_request(path) defaults = _resolve_query_defaults(endpoint) # Drop ``request`` — it's the positional we pass explicitly. defaults.pop("request", None) return await endpoint(request, **defaults) async def _read_lines(body_iter, n: int, timeout: float = 5.0) -> list[str]: """Read up to ``n`` lines from a StreamingResponse body iterator.""" buffer = b"" lines: list[str] = [] async with asyncio.timeout(timeout): while len(lines) < n: try: chunk = await body_iter.__anext__() except StopAsyncIteration: break buffer += chunk while b"\n" in buffer and len(lines) < n: raw, buffer = buffer.split(b"\n", 1) if raw: lines.append(raw.decode("utf-8")) return lines async def _read_one_line(body_iter, timeout: float = 5.0) -> str | None: """Read a single NDJSON line from the body iterator, or None on timeout.""" buffer = b"" async with asyncio.timeout(timeout): while True: try: chunk = await body_iter.__anext__() except StopAsyncIteration: return None buffer += chunk if b"\n" in buffer: raw, _ = buffer.split(b"\n", 1) if raw: return raw.decode("utf-8") async def _drain_until_disconnect(body_iter, max_chunks: int = 20) -> None: """Close the body iterator and swallow the cancellation.""" try: await body_iter.aclose() except (asyncio.CancelledError, GeneratorExit): pass async def _read_until_type( body_iter, target: str, *, timeout: float = 5.0, max_lines: int = 50 ) -> dict | None: """Read lines from the body iterator until one has ``type == target``. Used by tests that need to skip past heartbeats or multi-item snapshot prefixes to assert on a specific event. Returns the first matching parsed object, or ``None`` if ``max_lines`` is reached without a match. """ buffer = b"" seen = 0 async with asyncio.timeout(timeout): while seen < max_lines: try: chunk = await body_iter.__anext__() except StopAsyncIteration: return None buffer += chunk while b"\n" in buffer: raw, buffer = buffer.split(b"\n", 1) if not raw: continue seen += 1 obj = json.loads(raw.decode("utf-8")) if obj.get("type") == target: return obj return None # --------------------------------------------------------------------------- # # Task 11 — claims stream # --------------------------------------------------------------------------- # async def test_claims_stream_yields_snapshot_then_snapshot_end(): """3 claims pre-loaded → 3 item lines + 1 snapshot_end line.""" s = CycloneStore() s.add(_make_batch_record(n=3)) response = await _call_endpoint(claims_stream, "/api/claims/stream") assert response.media_type.startswith("application/x-ndjson") lines = await _read_lines(response.body_iterator, n=4) items = [json.loads(line) for line in lines] assert len(items) == 4 # First three are items; the order isn't guaranteed without an # explicit sort, so check shape not exact ordering. for item in items[:3]: assert item["type"] == "item" assert "id" in item["data"] # Fourth is the snapshot_end terminator. assert items[3] == {"type": "snapshot_end", "data": {"count": 3}} await _drain_until_disconnect(response.body_iterator) async def test_claims_stream_emits_new_item_after_publish(): """After snapshot_end, a bus.publish('claim_written', …) → new item line.""" s = CycloneStore() s.add(_make_batch_record(n=1)) response = await _call_endpoint(claims_stream, "/api/claims/stream") # Drain snapshot phase: 1 item + 1 snapshot_end. lines = await _read_lines(response.body_iterator, n=2) assert json.loads(lines[1]) == { "type": "snapshot_end", "data": {"count": 1}, } # Advance the generator one more step so ``subscribe_raw`` runs # and registers the subscriber queue. The next yield will be a # heartbeat (with the 0.2s env var set in some tests); we don't # care about its content here, just that we've moved past the # sync subscription code. _ = await _read_one_line(response.body_iterator, timeout=2.0) # Now publish a claim_written event and assert a new item line # streams out. payload = { "id": "NEW-CLM-1", "patientName": "Live Tail", "providerNpi": "1234567890", "payerName": "Live Payer", "cptCode": "99213", "billedAmount": 999.99, "receivedAmount": 0.0, "status": "submitted", "state": "submitted", "denialReason": None, "submissionDate": "2026-06-19T12:00:00Z", "batchId": "b-uuid-1", "parsedAt": "2026-06-19T12:00:00Z", } await app.state.event_bus.publish("claim_written", payload) new_line = await _read_one_line(response.body_iterator, timeout=1.0) assert new_line is not None, "no item line arrived after publish" obj = json.loads(new_line) assert obj["type"] == "item" assert obj["data"]["id"] == "NEW-CLM-1" await _drain_until_disconnect(response.body_iterator) async def test_claims_stream_heartbeat_after_idle(monkeypatch): """With heartbeat=0.2s, a heartbeat line appears within ~1s of idle.""" monkeypatch.setenv("CYCLONE_TAIL_HEARTBEAT_S", "0.2") s = CycloneStore() s.add(_make_batch_record(n=1)) response = await _call_endpoint(claims_stream, "/api/claims/stream") # Drain snapshot (2 lines). lines = await _read_lines(response.body_iterator, n=2) assert json.loads(lines[1]) == { "type": "snapshot_end", "data": {"count": 1}, } # Read subsequent lines until we see a heartbeat or time out. deadline = asyncio.get_event_loop().time() + 1.5 saw_heartbeat = False while asyncio.get_event_loop().time() < deadline: new_line = await _read_one_line( response.body_iterator, timeout=0.5, ) if new_line is None: continue obj = json.loads(new_line) if obj["type"] == "heartbeat": assert "ts" in obj["data"] saw_heartbeat = True break assert saw_heartbeat, "no heartbeat line arrived within 1.5s" await _drain_until_disconnect(response.body_iterator) # --------------------------------------------------------------------------- # # Task 12 — remittances stream # --------------------------------------------------------------------------- # async def test_remittances_stream_yields_snapshot_then_subscribes(): """Pre-loaded 835 batch → snapshot (1 item + snapshot_end).""" s = CycloneStore() cp = _make_remit_with_cas(remit_id="CLP-1", pcn="PCN-1") rec = BatchRecord835( id="b-835-1", kind="835", input_filename="era.txt", parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc), result=_make_835_result([cp]), ) s.add(rec) response = await _call_endpoint( remittances_stream, "/api/remittances/stream", ) assert response.media_type.startswith("application/x-ndjson") # Read the full snapshot (1 item + snapshot_end), skipping nothing. snap_end = await _read_until_type( response.body_iterator, "snapshot_end", timeout=2.0, ) assert snap_end == {"type": "snapshot_end", "data": {"count": 1}} await _drain_until_disconnect(response.body_iterator) async def test_remittances_stream_emits_after_upsert_remittance(): """A bus.publish('remittance_written', …) → new item line on stream.""" s = CycloneStore() cp = _make_remit_with_cas(remit_id="CLP-0", pcn="PCN-0") rec = BatchRecord835( id="b-835-0", kind="835", input_filename="era0.txt", parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc), result=_make_835_result([cp]), ) s.add(rec) response = await _call_endpoint( remittances_stream, "/api/remittances/stream", ) # Drain snapshot to the terminator. snap_end = await _read_until_type( response.body_iterator, "snapshot_end", timeout=2.0, ) assert snap_end == {"type": "snapshot_end", "data": {"count": 1}} # Advance one more step so the subscription is registered before # we publish. The next yielded line may be a heartbeat (with the # 0.2s heartbeat fixture) or a real event — either is fine, we # just need ``subscribe_raw`` to have run. _ = await _read_one_line(response.body_iterator, timeout=2.0) payload = { "id": "PCN-NEW", "claimId": "", "payerName": "Tail Payer", "paidAmount": 12.34, "adjustmentAmount": 0.0, "status": "received", "denialReason": None, "validationWarnings": [], "receivedDate": "2026-06-19T12:00:00Z", "batchId": "b-835-1", "parsedAt": "2026-06-19T12:00:00Z", "adjustments": [], } await app.state.event_bus.publish("remittance_written", payload) # Read until the new item line appears (skip any heartbeats that # race in before the published event). new_obj = await _read_until_type( response.body_iterator, "item", timeout=2.0, ) assert new_obj is not None, "no item line arrived after publish" assert new_obj["data"]["id"] == "PCN-NEW" await _drain_until_disconnect(response.body_iterator) # --------------------------------------------------------------------------- # # Task 13 — activity stream # --------------------------------------------------------------------------- # async def test_activity_stream_yields_snapshot_then_subscribes(): """An 837 batch seeds activity rows → snapshot has at least 1 item.""" s = CycloneStore() s.add(_make_batch_record(n=2)) response = await _call_endpoint( activity_stream, "/api/activity/stream", ) assert response.media_type.startswith("application/x-ndjson") # Read to snapshot_end — activity may have multiple items. snap_end = await _read_until_type( response.body_iterator, "snapshot_end", timeout=2.0, ) assert snap_end is not None assert snap_end["data"]["count"] >= 1 await _drain_until_disconnect(response.body_iterator) async def test_activity_stream_emits_after_record_activity(): """A bus.publish('activity_recorded', …) → new item line on stream.""" s = CycloneStore() s.add(_make_batch_record(n=1)) response = await _call_endpoint( activity_stream, "/api/activity/stream", ) # Drain snapshot. snap_end = await _read_until_type( response.body_iterator, "snapshot_end", timeout=2.0, ) assert snap_end is not None # Advance one step so subscription is registered. _ = await _read_one_line(response.body_iterator, timeout=2.0) payload = { "kind": "claim_submitted", "ts": "2026-06-19T12:00:00Z", "batchId": "b-uuid-1", "claimId": "LIVE-CLM", "remittanceId": None, "payload": {"message": "live tail event"}, } await app.state.event_bus.publish("activity_recorded", payload) new_obj = await _read_until_type( response.body_iterator, "item", timeout=2.0, ) assert new_obj is not None, "no item line arrived after publish" assert new_obj["data"]["kind"] == "claim_submitted" assert new_obj["data"]["claimId"] == "LIVE-CLM" await _drain_until_disconnect(response.body_iterator) # --------------------------------------------------------------------------- # # Task 14 — disconnect cancels subscription # --------------------------------------------------------------------------- # async def test_client_disconnect_cancels_subscription(monkeypatch): """After closing the body iterator, no claim_written subscribers remain. Closes the body iterator (the test-side disconnect signal — see the module docstring). The generator must propagate the ``CancelledError`` and tear down its bus subscription. """ monkeypatch.setenv("CYCLONE_TAIL_HEARTBEAT_S", "0.1") s = CycloneStore() s.add(_make_batch_record(n=1)) bus: EventBus = app.state.event_bus response = await _call_endpoint(claims_stream, "/api/claims/stream") # Read the full snapshot to the terminator. snap_end = await _read_until_type( response.body_iterator, "snapshot_end", timeout=2.0, ) assert snap_end == {"type": "snapshot_end", "data": {"count": 1}} # Advance one step into the subscription phase so subscribe_raw # has run and registered the queue. _ = await _read_one_line(response.body_iterator, timeout=2.0) # Pre-condition: at least one subscriber registered while the # stream was open. assert len(bus._subscribers.get("claim_written", [])) >= 1 # Client disconnect: close the body iterator. The endpoint # generator receives ``CancelledError`` via Starlette's # cancellation path and returns; its `bus.subscribe_raw` queue is # released. Wait briefly for cleanup. await _drain_until_disconnect(response.body_iterator) for _ in range(50): if not bus._subscribers.get("claim_written"): break await asyncio.sleep(0.02) assert bus._subscribers.get("claim_written", []) == [], ( f"subscribers leaked after disconnect: " f"{bus._subscribers.get('claim_written')!r}" )