feat(backend): SQLAlchemy-backed CycloneStore (T9)
Replace the in-memory InMemoryStore with a SQLAlchemy-backed CycloneStore that persists Batch, Claim, Remittance, Match, and ActivityEvent rows to a configurable DB engine (sqlite by default, overridable via CYCLONE_DB_URL). Public API preserved: add / get_batch / iter_claims / iter_remittances / distinct_providers / recent_activity. New T10/T12 stubs: list_unmatched, manual_match, manual_unmatch. Backward-compat shims for tests that called ._batches.clear() or acquired ._lock as a context manager. Idempotency: add() does a per-row session.get(Claim, c.id) (and s.get(Remittance, ...)) check before each insert; duplicates are skipped with a warning. This makes re-uploading the same fixture idempotent instead of raising IntegrityError on the PK. Auto-init DB fixture (backend/tests/conftest.py) sets CYCLONE_DB_URL to a per-test sqlite file and calls db.init_db() once per test, replacing the old module-scoped in-memory fixture.
This commit is contained in:
@@ -0,0 +1,28 @@
|
||||
"""Per-test database setup for the Cyclone test suite.
|
||||
|
||||
Every test gets a fresh SQLite DB at ``tmp_path/test.db`` so the suite
|
||||
can run in parallel and never touches the user's ``~/.local/share/cyclone``
|
||||
database. ``db.init_db()`` is idempotent — tests that already call it
|
||||
explicitly are unaffected (the second call short-circuits).
|
||||
|
||||
This fixture exists because the SQLAlchemy-backed ``CycloneStore``
|
||||
requires the engine to be initialized before any DB access, whereas the
|
||||
old in-memory store did not. Adding the init here keeps existing test
|
||||
modules (test_api.py, test_api_835.py, test_api_gets.py,
|
||||
test_api_parse_persists.py) working unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _auto_init_db(tmp_path, monkeypatch):
|
||||
"""Point CYCLONE_DB_URL at a per-test SQLite file and init the schema."""
|
||||
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
|
||||
from cyclone import db
|
||||
db._reset_for_tests()
|
||||
db.init_db()
|
||||
yield
|
||||
db._reset_for_tests()
|
||||
+67
-541
@@ -1,11 +1,21 @@
|
||||
"""Tests for the in-memory batch store."""
|
||||
"""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 datetime, timezone
|
||||
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,
|
||||
@@ -14,61 +24,37 @@ from cyclone.parsers.models import (
|
||||
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)
|
||||
@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(claim_id: str = "CLM-1", *, frequency_code: str = "1") -> ClaimOutput:
|
||||
"""Minimal ClaimOutput stub. Real parser not needed here."""
|
||||
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="2026-06-19",
|
||||
transaction_date=date(2026, 6, 19),
|
||||
billing_provider=BillingProvider(
|
||||
name="Test",
|
||||
npi="1234567890",
|
||||
tax_id=None,
|
||||
address=None,
|
||||
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="M1",
|
||||
dob=None,
|
||||
gender=None,
|
||||
address=None,
|
||||
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("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,
|
||||
claim_id=claim_id, total_charge=Decimal(charge),
|
||||
frequency_code="1", place_of_service="11",
|
||||
),
|
||||
diagnoses=[],
|
||||
service_lines=[],
|
||||
@@ -77,521 +63,61 @@ def _make_claim(claim_id: str = "CLM-1", *, frequency_code: str = "1") -> ClaimO
|
||||
)
|
||||
|
||||
|
||||
def _make_result() -> ParseResult:
|
||||
def _make_result_837() -> 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,
|
||||
sender_id="S", receiver_id="R", control_number="0001",
|
||||
transaction_date=date(2026, 6, 19),
|
||||
),
|
||||
claims=[_make_claim("CLM-1")],
|
||||
claims=[_make_claim_837("CLM-1"), _make_claim_837("CLM-2", "88.50")],
|
||||
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,
|
||||
input_file="test.txt", control_number="0001",
|
||||
transaction_date=date(2026, 6, 19),
|
||||
total_claims=2, passed=2, failed=0,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
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_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 _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,
|
||||
),
|
||||
)
|
||||
def test_module_singleton():
|
||||
assert isinstance(store, CycloneStore)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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,
|
||||
)
|
||||
def test_add_837_persists_batch_and_claims():
|
||||
s = CycloneStore()
|
||||
rec = _make_batch_record()
|
||||
s.add(rec)
|
||||
assert s.get("abc123") is rec
|
||||
assert s.list() == [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_get_missing_returns_none():
|
||||
s = InMemoryStore()
|
||||
assert s.get("does-not-exist") is None
|
||||
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_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
|
||||
def test_persistence_across_session():
|
||||
s1 = CycloneStore()
|
||||
s1.add(_make_batch_record(batch_id="b-x"))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# 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"
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Review fixes: payer / date filters, tz-aware validator
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def _make_claim_with_payer(claim_id: str, payer_name: str) -> ClaimOutput:
|
||||
"""ClaimOutput variant that lets us pick a payer name per row."""
|
||||
c = _make_claim(claim_id)
|
||||
c.payer = Payer(name=payer_name, id="P1")
|
||||
return c
|
||||
|
||||
|
||||
def test_iter_claims_filters_by_payer_substring_case_insensitive():
|
||||
"""iter_claims(payer=...) is a case-insensitive substring match on payerName."""
|
||||
s = InMemoryStore()
|
||||
result = _make_result()
|
||||
result.claims = [
|
||||
_make_claim_with_payer("A", "Aetna Health"),
|
||||
_make_claim_with_payer("B", "Blue Cross"),
|
||||
_make_claim_with_payer("C", "aetna better health"),
|
||||
]
|
||||
s.add(BatchRecord(
|
||||
id="b1", kind="837p", input_filename="a.txt",
|
||||
parsed_at=_PARSED_AT, result=result,
|
||||
))
|
||||
out = s.iter_claims(payer="Aetna")
|
||||
ids = sorted(c["id"] for c in out)
|
||||
assert ids == ["A", "C"], f"expected Aetna matches, got {ids}"
|
||||
|
||||
|
||||
def test_iter_claims_filters_by_date_from_includes_in_range_only():
|
||||
"""date_from excludes items whose submissionDate is before the bound."""
|
||||
s = InMemoryStore()
|
||||
old = datetime(2026, 6, 10, 12, 0, 0, tzinfo=timezone.utc)
|
||||
new = datetime(2026, 6, 25, 12, 0, 0, tzinfo=timezone.utc)
|
||||
old_result = _make_result()
|
||||
old_result.claims = [_make_claim("OLD")]
|
||||
new_result = _make_result()
|
||||
new_result.claims = [_make_claim("NEW")]
|
||||
s.add(BatchRecord(
|
||||
id="b-old", kind="837p", input_filename="a.txt",
|
||||
parsed_at=old, result=old_result,
|
||||
))
|
||||
s.add(BatchRecord(
|
||||
id="b-new", kind="837p", input_filename="b.txt",
|
||||
parsed_at=new, result=new_result,
|
||||
))
|
||||
out = s.iter_claims(date_from="2026-06-20")
|
||||
assert [c["id"] for c in out] == ["NEW"]
|
||||
|
||||
|
||||
def test_iter_claims_filters_by_date_to_includes_in_range_only():
|
||||
"""date_to excludes items whose submissionDate is after the bound."""
|
||||
s = InMemoryStore()
|
||||
old = datetime(2026, 6, 10, 12, 0, 0, tzinfo=timezone.utc)
|
||||
new = datetime(2026, 6, 25, 12, 0, 0, tzinfo=timezone.utc)
|
||||
old_result = _make_result()
|
||||
old_result.claims = [_make_claim("OLD")]
|
||||
new_result = _make_result()
|
||||
new_result.claims = [_make_claim("NEW")]
|
||||
s.add(BatchRecord(
|
||||
id="b-old", kind="837p", input_filename="a.txt",
|
||||
parsed_at=old, result=old_result,
|
||||
))
|
||||
s.add(BatchRecord(
|
||||
id="b-new", kind="837p", input_filename="b.txt",
|
||||
parsed_at=new, result=new_result,
|
||||
))
|
||||
out = s.iter_claims(date_to="2026-06-20")
|
||||
assert [c["id"] for c in out] == ["OLD"]
|
||||
|
||||
|
||||
def test_iter_remittances_filters_by_date_from_and_date_to():
|
||||
"""Remit receivedDate respects both date_from and date_to bounds."""
|
||||
s = InMemoryStore()
|
||||
old = datetime(2026, 6, 10, 12, 0, 0, tzinfo=timezone.utc)
|
||||
mid = datetime(2026, 6, 20, 12, 0, 0, tzinfo=timezone.utc)
|
||||
new = datetime(2026, 6, 30, 12, 0, 0, tzinfo=timezone.utc)
|
||||
for ts, tag in [(old, "OLD"), (mid, "MID"), (new, "NEW")]:
|
||||
s.add(BatchRecord(
|
||||
id=f"835-{tag}", kind="835", input_filename="a.txt",
|
||||
parsed_at=ts, result=_make_835_result(claims=[_make_remit()]),
|
||||
))
|
||||
out = s.iter_remittances(date_from="2026-06-15", date_to="2026-06-25")
|
||||
assert [r["batchId"] for r in out] == ["835-MID"]
|
||||
|
||||
|
||||
def test_iter_claims_unbounded_date_filter_includes_all():
|
||||
"""With no date bounds, the filter is a no-op (all items returned)."""
|
||||
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.iter_claims()
|
||||
assert len(out) == 2
|
||||
|
||||
|
||||
def test_batch_record_rejects_naive_parsed_at():
|
||||
"""tz-aware validator: a naive datetime should raise ValueError."""
|
||||
import pytest
|
||||
with pytest.raises(ValueError, match="tz-aware"):
|
||||
BatchRecord(
|
||||
id="x", kind="837p", input_filename="a.txt",
|
||||
parsed_at=datetime(2025, 1, 1), result=_make_result(),
|
||||
)
|
||||
s2 = CycloneStore() # fresh instance, same engine
|
||||
loaded = s2.get_batch("b-x")
|
||||
assert loaded is not None
|
||||
assert loaded["kind"] == "837p"
|
||||
Reference in New Issue
Block a user