"""Tests for the SQLAlchemy-backed CycloneStore. Public API is preserved from the in-memory version: add / get_batch / iter_claims / iter_remittances / distinct_providers / recent_activity. New methods: list_unmatched / manual_match / manual_unmatch. """ from __future__ import annotations from datetime import date, datetime, timezone from decimal import Decimal import pytest from cyclone import db from cyclone.store import BatchRecord, CycloneStore, store from cyclone.parsers.models import ( Address, BatchSummary, BillingProvider, ClaimHeader, ClaimOutput, Envelope, Payer, ParseResult, Subscriber, ValidationReport, ) @pytest.fixture(autouse=True) def _setup(tmp_path, monkeypatch): monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db") db._reset_for_tests() db.init_db() yield db._reset_for_tests() 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", ), # Member_id doubles as patient_control_number in the DB; the # ``uq_claims_batch_pcn`` unique constraint requires it to be # distinct within a batch, so derive it from the claim id. 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() -> ParseResult: return ParseResult( envelope=Envelope( sender_id="S", receiver_id="R", control_number="0001", transaction_date=date(2026, 6, 19), ), claims=[_make_claim_837("CLM-1"), _make_claim_837("CLM-2", "88.50")], summary=BatchSummary( input_file="test.txt", control_number="0001", transaction_date=date(2026, 6, 19), total_claims=2, passed=2, failed=0, ), ) def _make_batch_record(kind: str = "837p", result=None, batch_id: str = "b-uuid-1"): return BatchRecord( id=batch_id, kind=kind, input_filename="test.txt", parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc), result=result or _make_result_837(), ) def test_module_singleton(): assert isinstance(store, CycloneStore) @pytest.mark.asyncio async def test_add_837_publishes_claim_written_and_activity_recorded_events(): """store.add() with event_bus= should publish one claim_written event per inserted claim and one activity_recorded event per activity row.""" from cyclone.pubsub import EventBus bus = EventBus() claim_q = bus.subscribe(["claim_written"]) activity_q = bus.subscribe(["activity_recorded"]) s = CycloneStore() rec = _make_batch_record() s.add(rec, event_bus=bus) # Two claims + two claim_submitted activity events expected. import asyncio claims = [] for _ in range(2): claims.append(await asyncio.wait_for(claim_q.__anext__(), timeout=0.5)) activities = [] for _ in range(2): activities.append(await asyncio.wait_for(activity_q.__anext__(), timeout=0.5)) # Each event has _kind and a payload with the expected shape. for ev in claims: assert ev["_kind"] == "claim_written" assert "id" in ev for ev in activities: assert ev["_kind"] == "activity_recorded" assert ev["kind"] == "claim_submitted" assert "claimId" in ev def test_add_835_publishes_remittance_written_and_activity_recorded_events(): """835 batches publish remittance_written events (one per remit) and activity_recorded events (one per activity row).""" import asyncio from cyclone.pubsub import EventBus from cyclone.store import BatchRecord835 # Reuse the 835 result helper from test_store_reconcile.py. from test_store_reconcile import _make_835_result, _make_remit_with_cas bus = EventBus() remit_q = bus.subscribe(["remittance_written"]) activity_q = bus.subscribe(["activity_recorded"]) cp = _make_remit_with_cas(remit_id="CLP-PUB", pcn="PCN-PUB") rec = BatchRecord835( id="b-835-1", kind="835", input_filename="test835.txt", parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc), result=_make_835_result([cp]), ) CycloneStore().add(rec, event_bus=bus) loop = asyncio.new_event_loop() try: remits = [] for _ in range(1): remits.append(loop.run_until_complete( asyncio.wait_for(remit_q.__anext__(), timeout=0.5) )) activities = [] for _ in range(1): activities.append(loop.run_until_complete( asyncio.wait_for(activity_q.__anext__(), timeout=0.5) )) finally: loop.close() for ev in remits: assert ev["_kind"] == "remittance_written" assert ev["id"] == "PCN-PUB" for ev in activities: assert ev["_kind"] == "activity_recorded" assert ev["kind"] == "remit_received" assert ev["remittanceId"] == "PCN-PUB" def test_add_837_persists_batch_and_claims(): s = CycloneStore() rec = _make_batch_record() s.add(rec) # New session confirms persistence. with db.SessionLocal()() as session: from cyclone.db import Batch, Claim, ClaimState b = session.get(Batch, "b-uuid-1") assert b is not None assert b.kind == "837p" claims = session.query(Claim).all() assert len(claims) == 2 assert all(c.state == ClaimState.SUBMITTED for c in claims) def test_iter_claims_returns_dicts(): s = CycloneStore() s.add(_make_batch_record()) rows = s.iter_claims() assert len(rows) == 2 assert all("id" in r and "state" in r for r in rows) def test_persistence_across_session(): s1 = CycloneStore() s1.add(_make_batch_record(batch_id="b-x")) s2 = CycloneStore() # fresh instance, same engine loaded = s2.get_batch("b-x") assert loaded is not None assert loaded["kind"] == "837p"