Files
cyclone/backend/tests/test_store.py
T
Tyler b5e27927e0 fix(835): drop over-constraining UNIQUE constraints, add multi-BPR warning
Three related changes for real CO Medicaid data:

1. Drop UNIQUE(batch_id, patient_control_number) on claims and
   UNIQUE(batch_id, payer_claim_control_number) on remittances. The X12
   spec allows multiple CLM segments per 2000B subscriber loop and 835
   ERAs can repeat a payer_claim_control_number for reversals. Claim/
   remittance identity is provided by the primary key (claims.id = CLM01,
   remittances.id = CLP01).

2. Add validator rule R835_MULTI_BPR warning for files with multiple BPR
   segments (CO Medicaid split-payment pattern). The parser already sums
   BPR02 paid_amounts; this surfaces the non-standard data to operators.

3. Skip R835_BAL_BPR_vs_CLP04 when BPR01='I' (Information Only 835).
   In that mode BPR02 is informational and the per-claim CLP04 totals
   are authoritative — a diff is expected, not an error.

Migration 0003 handles the drop with IF EXISTS so fresh DBs skip cleanly.
Updates affected tests to reflect new schema (no UNIQUE constraint on
batch_id + patient_control_number / payer_claim_control_number).

Fixes test_api_835::test_prodfile_round_trip_persists_separately which
was failing on real production data.
2026-06-20 18:10:07 -06:00

203 lines
6.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"