b5e27927e0
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.
203 lines
6.4 KiB
Python
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" |