Files
cyclone/backend/tests/test_store.py
T
2026-06-19 19:13:52 -06:00

488 lines
16 KiB
Python

"""Tests for the in-memory batch store."""
from __future__ import annotations
from datetime import datetime, timezone
from decimal import Decimal
from cyclone.parsers.models import (
BatchSummary,
BillingProvider,
ClaimHeader,
ClaimOutput,
Envelope,
Payer,
ParseResult,
Subscriber,
ValidationIssue,
ValidationReport,
)
from cyclone.parsers.models_835 import (
ClaimAdjustment,
ClaimPayment,
FinancialInfo,
ParseResult835,
Payer835,
Payee835,
ReassociationTrace,
ServicePayment,
)
from cyclone.parsers.payer import PayerConfig835
from cyclone.store import BatchRecord, InMemoryStore, store
# ---------------------------------------------------------------------------
# Stubs (match real model field names)
# ---------------------------------------------------------------------------
_PARSED_AT = datetime(2026, 6, 19, 12, 0, 0, tzinfo=timezone.utc)
def _make_claim(claim_id: str = "CLM-1", *, frequency_code: str = "1") -> ClaimOutput:
"""Minimal ClaimOutput stub. Real parser not needed here."""
return ClaimOutput(
claim_id=claim_id,
control_number="0001",
transaction_date="2026-06-19",
billing_provider=BillingProvider(
name="Test",
npi="1234567890",
tax_id=None,
address=None,
),
subscriber=Subscriber(
first_name="Jane",
last_name="Doe",
member_id="M1",
dob=None,
gender=None,
address=None,
),
payer=Payer(name="Test Payer", id="P1"),
claim=ClaimHeader(
claim_id=claim_id,
total_charge=Decimal("0.00"),
place_of_service="11",
facility_code_qualifier=None,
frequency_code=frequency_code,
provider_signature=None,
assignment=None,
release_of_info=None,
prior_auth=None,
),
diagnoses=[],
service_lines=[],
validation=ValidationReport(passed=True, errors=[], warnings=[]),
raw_segments=[],
)
def _make_result() -> ParseResult:
return ParseResult(
envelope=Envelope(
sender_id="SENDER",
receiver_id="RECEIVER",
control_number="0001",
transaction_date="2026-06-19",
transaction_time=None,
implementation_guide=None,
),
claims=[_make_claim("CLM-1")],
summary=BatchSummary(
input_file="test.txt",
control_number="0001",
transaction_date="2026-06-19",
total_claims=1,
passed=1,
failed=0,
failed_claim_ids=[],
issues_by_rule={},
output_dir=None,
),
)
def _make_remit(
*,
status_code: str = "1",
service_payments: list[ServicePayment] | None = None,
) -> ClaimPayment:
return ClaimPayment(
payer_claim_control_number="PCN-1",
status_code=status_code,
status_label=None,
total_charge=Decimal("100.00"),
total_paid=Decimal("80.00"),
patient_responsibility=Decimal("0.00"),
claim_filing_indicator="MC",
original_claim_id=None,
frequency_code=None,
facility_type=None,
service_payments=service_payments or [],
)
def _make_835_result(claims: list[ClaimPayment] | None = None) -> ParseResult835:
return ParseResult835(
envelope=Envelope(
sender_id="SENDER",
receiver_id="RECEIVER",
control_number="0001",
transaction_date="2026-06-19",
transaction_time=None,
implementation_guide=None,
),
financial_info=FinancialInfo(
handling_code="C",
paid_amount=Decimal("80.00"),
credit_debit_flag="C",
payment_method=None,
payer_tax_id=None,
payment_date=None,
),
trace=ReassociationTrace(
trace_type_code="1",
trace_number="TRN1",
originating_company_id="OC1",
payer_id=None,
),
payer=Payer835(name="CO_TXIX", id="7912900843", address=None, contact_url=None),
payee=Payee835(name="Acme Clinic", npi="1234567890", address=None),
claims=claims or [],
summary=BatchSummary(
input_file="test.txt",
control_number="0001",
transaction_date="2026-06-19",
total_claims=1,
passed=1,
failed=0,
failed_claim_ids=[],
issues_by_rule={},
output_dir=None,
),
)
# ---------------------------------------------------------------------------
# Task 1: BatchRecord + InMemoryStore
# ---------------------------------------------------------------------------
def test_module_singleton_store_exists():
"""The module-level `store` should be importable and be an InMemoryStore."""
assert isinstance(store, InMemoryStore)
def test_in_memory_store_starts_empty():
s = InMemoryStore()
assert s.list() == []
def test_add_and_get_round_trip():
s = InMemoryStore()
result = _make_result()
rec = BatchRecord(
id="abc123",
kind="837p",
input_filename="test.txt",
parsed_at=_PARSED_AT,
result=result,
)
s.add(rec)
assert s.get("abc123") is rec
assert s.list() == [rec]
def test_get_missing_returns_none():
s = InMemoryStore()
assert s.get("does-not-exist") is None
def test_batch_record_parsed_at_accepts_datetime():
"""Spec 6.1: parsed_at is a tz-aware datetime, not a string."""
rec = BatchRecord(
id="x",
kind="837p",
input_filename="a.txt",
parsed_at=_PARSED_AT,
result=_make_result(),
)
assert isinstance(rec.parsed_at, datetime)
assert rec.parsed_at.tzinfo is not None
# ---------------------------------------------------------------------------
# Task 2: Mappers
# ---------------------------------------------------------------------------
def test_to_ui_claim_maps_status_submitted():
from cyclone.store import to_ui_claim
claim = _make_claim("CLM-1") # passed=True, frequency_code="1"
out = to_ui_claim(claim, batch_id="b1", parsed_at=_PARSED_AT)
assert out["id"] == "CLM-1"
assert out["status"] == "submitted"
assert out["providerNpi"] == "1234567890"
assert out["batchId"] == "b1"
assert out["parsedAt"] == "2026-06-19T12:00:00Z"
def test_to_ui_claim_maps_status_pending_when_warnings_no_freq_one():
"""passed + warnings + non-1 frequency_code → pending (rule 2)."""
from cyclone.store import to_ui_claim
claim = _make_claim("CLM-2", frequency_code="7")
claim.validation = ValidationReport(
passed=True, errors=[],
warnings=[ValidationIssue(rule="W001", severity="warning", message="soft")],
)
out = to_ui_claim(claim, batch_id="b1", parsed_at=_PARSED_AT)
assert out["status"] == "pending"
def test_to_ui_claim_submitted_takes_precedence_over_pending():
"""passed + frequency_code=1 + warnings → submitted (rule 1 beats rule 2)."""
from cyclone.store import to_ui_claim
claim = _make_claim("CLM-3", frequency_code="1")
claim.validation = ValidationReport(
passed=True, errors=[],
warnings=[ValidationIssue(rule="W001", severity="warning", message="soft")],
)
out = to_ui_claim(claim, batch_id="b1", parsed_at=_PARSED_AT)
assert out["status"] == "submitted"
def test_to_ui_claim_maps_status_denied_when_failed():
from cyclone.store import to_ui_claim
claim = _make_claim("CLM-4")
claim.validation = ValidationReport(
passed=False,
errors=[ValidationIssue(rule="E001", severity="error", message="hard")],
warnings=[],
)
out = to_ui_claim(claim, batch_id="b1", parsed_at=_PARSED_AT)
assert out["status"] == "denied"
def test_to_ui_claim_maps_status_draft_when_r050_diagnosis_present():
"""!passed with an R050_diagnosis_present error → draft (carve-out)."""
from cyclone.store import to_ui_claim
claim = _make_claim("CLM-5")
claim.validation = ValidationReport(
passed=False,
errors=[ValidationIssue(
rule="R050_diagnosis_present", severity="error",
message="missing principal diagnosis",
)],
warnings=[],
)
out = to_ui_claim(claim, batch_id="b1", parsed_at=_PARSED_AT)
assert out["status"] == "draft"
def test_to_ui_claim_maps_status_draft_when_passed_no_freq_no_warnings():
"""passed + non-1 frequency + no warnings → draft."""
from cyclone.store import to_ui_claim
claim = _make_claim("CLM-6", frequency_code="7")
out = to_ui_claim(claim, batch_id="b1", parsed_at=_PARSED_AT)
assert out["status"] == "draft"
def test_to_ui_remittance_maps_status_received_for_paid():
from cyclone.store import to_ui_remittance
cp = _make_remit(status_code="1")
out = to_ui_remittance(cp, batch_id="b1", parsed_at=_PARSED_AT)
assert out["status"] == "received"
assert out["denialReason"] is None
assert out["validationWarnings"] == []
def test_to_ui_remittance_denial_reason_from_cas_for_code_4():
"""CLP02 == 4 → received with denialReason populated from first CAS."""
from cyclone.store import to_ui_remittance
sp = ServicePayment(
line_number=1,
procedure_qualifier="HC",
procedure_code="99213",
modifiers=[],
charge=Decimal("100.00"),
payment=Decimal("0.00"),
units=None,
unit_type=None,
service_date=None,
adjustments=[
ClaimAdjustment(
group_code="CO", reason_code="97",
amount=Decimal("100.00"), quantity=None,
),
],
)
cp = _make_remit(status_code="4", service_payments=[sp])
out = to_ui_remittance(cp, batch_id="b1", parsed_at=_PARSED_AT)
assert out["status"] == "received"
assert out["denialReason"] == "CO-97: $100.00"
def test_to_ui_remittance_denial_reason_none_when_no_cas_for_code_4():
"""CLP02 == 4 with no CAS segments → denialReason stays None."""
from cyclone.store import to_ui_remittance
cp = _make_remit(status_code="4", service_payments=[])
out = to_ui_remittance(cp, batch_id="b1", parsed_at=_PARSED_AT)
assert out["status"] == "received"
assert out["denialReason"] is None
def test_to_ui_remittance_validation_warning_for_invalid_clp02():
"""CLP02 outside the allowlist → received + validationWarnings populated."""
from cyclone.store import to_ui_remittance
cp = _make_remit(status_code="99") # not in {"1","2","3","4","19","20","21","22","23","25"}
out = to_ui_remittance(
cp, batch_id="b1", parsed_at=_PARSED_AT,
payer_config=PayerConfig835.generic_835(),
)
assert out["status"] == "received"
assert any("99" in w and "allowlist" in w for w in out["validationWarnings"])
def test_to_ui_remittance_no_warning_for_allowed_clp02():
"""CLP02 in the allowlist → empty validationWarnings."""
from cyclone.store import to_ui_remittance
cp = _make_remit(status_code="1")
out = to_ui_remittance(
cp, batch_id="b1", parsed_at=_PARSED_AT,
payer_config=PayerConfig835.generic_835(),
)
assert out["validationWarnings"] == []
def test_to_ui_remittance_fills_payer_name():
"""payer_name kwarg flows into the remittance's payerName field."""
from cyclone.store import to_ui_remittance
cp = _make_remit(status_code="1")
out = to_ui_remittance(
cp, batch_id="b1", parsed_at=_PARSED_AT, payer_name="CO_TXIX",
)
assert out["payerName"] == "CO_TXIX"
def test_to_ui_provider_aggregates_claims():
from cyclone.store import to_ui_provider
out = to_ui_provider(
npi="1234567890", name="Acme Clinic", tax_id="99-9999999",
address="1 Main", city="Denver", state="CO", zip="80202", phone="303-555-0100",
claim_count=5, outstanding_ar=250.0,
)
assert out["npi"] == "1234567890"
assert out["claimCount"] == 5
def test_to_activity_event_serializes_datetime_to_iso_z():
from cyclone.store import to_activity_event
out = to_activity_event(
id="a1", kind="claim_submitted",
message="Claim CLM-1 submitted",
timestamp=_PARSED_AT,
npi="1234567890", amount=100.0,
)
assert out["id"] == "a1"
assert out["kind"] == "claim_submitted"
assert out["amount"] == 100.0
assert out["timestamp"] == "2026-06-19T12:00:00Z"
# ---------------------------------------------------------------------------
# Task 3: Iterators
# ---------------------------------------------------------------------------
def test_iter_claims_filters_by_status():
s = InMemoryStore()
result = _make_result()
a = _make_claim("A", frequency_code="1") # passed + freq=1 → submitted
b = _make_claim("B", frequency_code="7")
b.validation = ValidationReport(
passed=True, errors=[],
warnings=[ValidationIssue(rule="W1", severity="warning", message="x")],
) # passed + freq=7 + warnings → pending
result.claims = [a, b]
s.add(BatchRecord(
id="1", kind="837p", input_filename="a.txt",
parsed_at=_PARSED_AT, result=result,
))
out = list(s.iter_claims(status="submitted"))
assert all(c["status"] == "submitted" for c in out)
assert any(c["id"] == "A" for c in out)
out_pending = list(s.iter_claims(status="pending"))
assert all(c["status"] == "pending" for c in out_pending)
assert any(c["id"] == "B" for c in out_pending)
def test_iter_claims_filters_by_batch_id():
s = InMemoryStore()
s.add(BatchRecord(
id="b1", kind="837p", input_filename="a.txt",
parsed_at=_PARSED_AT, result=_make_result(),
))
out = list(s.iter_claims(batch_id="b1"))
assert all(c["batchId"] == "b1" for c in out)
def test_iter_claims_sorts_by_billed_amount_desc():
s = InMemoryStore()
result = _make_result()
result.claims = [
_make_claim("cheap"),
_make_claim("expensive"),
]
result.claims[0].claim.total_charge = Decimal("50.00")
result.claims[1].claim.total_charge = Decimal("500.00")
s.add(BatchRecord(
id="b1", kind="837p", input_filename="a.txt",
parsed_at=_PARSED_AT, result=result,
))
out = list(s.iter_claims(sort="billedAmount", order="desc"))
assert out[0]["id"] == "expensive"
assert out[1]["id"] == "cheap"
def test_distinct_providers_aggregates_claims():
s = InMemoryStore()
result = _make_result()
result.claims = [_make_claim("A"), _make_claim("B")]
s.add(BatchRecord(
id="b1", kind="837p", input_filename="a.txt",
parsed_at=_PARSED_AT, result=result,
))
out = s.distinct_providers()
assert len(out) == 1
assert out[0]["npi"] == "1234567890"
assert out[0]["claimCount"] == 2
def test_recent_activity_returns_newest_first():
older = datetime(2026, 6, 19, 11, 0, 0, tzinfo=timezone.utc)
s = InMemoryStore()
s.add(BatchRecord(
id="b1", kind="837p", input_filename="a.txt",
parsed_at=older, result=_make_result(),
))
s.add(BatchRecord(
id="b2", kind="837p", input_filename="b.txt",
parsed_at=_PARSED_AT, result=_make_result(),
))
out = s.recent_activity(limit=10)
assert out[0]["timestamp"] == "2026-06-19T12:00:00Z"
assert out[1]["timestamp"] == "2026-06-19T11:00:00Z"
def test_iter_remittances_fills_payer_name_from_batch():
"""iter_remittances should populate payerName from ParseResult835.payer.name."""
s = InMemoryStore()
cp = _make_remit(status_code="1")
s.add(BatchRecord(
id="835-1", kind="835", input_filename="a.txt",
parsed_at=_PARSED_AT, result=_make_835_result(claims=[cp]),
))
out = list(s.iter_remittances())
assert len(out) == 1
assert out[0]["payerName"] == "CO_TXIX"