258 lines
8.4 KiB
Python
258 lines
8.4 KiB
Python
"""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. Derive it
|
|
# from the claim id so two claims in the same batch are easy to tell
|
|
# apart in test assertions (no longer required by a unique constraint
|
|
# — see migration 0003 — but still a useful invariant for tests).
|
|
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"
|
|
|
|
|
|
def test_find_existing_batch_for_claim_returns_none_for_unknown():
|
|
"""Unknown claim_id -> None."""
|
|
from cyclone import store as store_mod # noqa: F401
|
|
assert store_mod.find_existing_batch_for_claim("nope") is None
|
|
|
|
|
|
def test_find_existing_batch_for_claim_returns_batch_id():
|
|
"""Known claim_id -> batch_id of the holding batch."""
|
|
from cyclone import db as _db
|
|
from cyclone.db import Batch, Claim
|
|
from datetime import datetime, timezone
|
|
|
|
with _db.SessionLocal()() as s:
|
|
s.add(Batch(
|
|
id="B1", kind="837p", input_filename="x.txt",
|
|
parsed_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
|
raw_result_json={},
|
|
))
|
|
s.add(Claim(id="CLM-A", batch_id="B1", patient_control_number="M1"))
|
|
s.commit()
|
|
|
|
from cyclone import store as store_mod # noqa: F401
|
|
assert store_mod.find_existing_batch_for_claim("CLM-A") == "B1"
|
|
|
|
|
|
def test_find_existing_batch_for_remit_returns_none_for_unknown():
|
|
"""Unknown remit id -> None."""
|
|
from cyclone import store as store_mod # noqa: F401
|
|
assert store_mod.find_existing_batch_for_remit("nope") is None
|
|
|
|
|
|
def test_find_existing_batch_for_remit_returns_batch_id():
|
|
"""Known remit id -> batch_id of the holding batch."""
|
|
from cyclone import db as _db
|
|
from cyclone.db import Batch, Remittance
|
|
from datetime import datetime, timezone
|
|
|
|
with _db.SessionLocal()() as s:
|
|
s.add(Batch(
|
|
id="B2", kind="835", input_filename="y.txt",
|
|
parsed_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
|
raw_result_json={},
|
|
))
|
|
s.add(Remittance(
|
|
id="CLP-A", batch_id="B2",
|
|
payer_claim_control_number="CLP-A",
|
|
status_code="1",
|
|
received_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
|
|
))
|
|
s.commit()
|
|
|
|
from cyclone import store as store_mod # noqa: F401
|
|
assert store_mod.find_existing_batch_for_remit("CLP-A") == "B2" |