feat(batch-diff): side-by-side claim diff between two batches

This commit is contained in:
Tyler
2026-06-20 17:17:09 -06:00
parent 4cd52c3084
commit 7b394fff1a
12 changed files with 2556 additions and 0 deletions
+611
View File
@@ -0,0 +1,611 @@
"""Tests for ``cyclone.batch_diff`` and the ``GET /api/batch-diff`` endpoint.
Two layers:
1. Pure unit tests over :func:`cyclone.batch_diff.diff_batches` — no DB,
no HTTP. Construct ``BatchRecord`` values directly with the parser's
Pydantic models so the tests stay fast and deterministic.
2. Integration tests over the FastAPI endpoint — round-trip through
``TestClient`` with real 837P / 835 fixtures. The ``conftest.py``
fixture already gives every test a fresh SQLite database, so these
run independently without manual cleanup.
"""
from __future__ import annotations
from datetime import date, datetime, timezone
from decimal import Decimal
from pathlib import Path
import pytest
from fastapi.testclient import TestClient
from cyclone.api import app
from cyclone.batch_diff import (
BatchClaimSummary,
diff_batches,
diff_batches_to_wire,
)
from cyclone.parsers.models import (
Address,
BatchSummary,
BillingProvider,
ClaimHeader,
ClaimOutput,
Envelope,
Payer,
ParseResult,
Procedure,
ServiceLine,
Subscriber,
ValidationReport,
)
from cyclone.parsers.models_835 import (
ClaimPayment,
ParseResult835,
Payer835,
Payee835,
ReassociationTrace,
FinancialInfo,
ServicePayment,
)
from cyclone.store import BatchRecord, store as global_store
# 837P fixture shipped with the test suite — two claims, useful as a
# baseline for the integration tests below.
FIXTURE_837 = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt"
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(autouse=True)
def _setup(tmp_path, monkeypatch):
"""Per-test isolated DB.
Same pattern used by ``test_store.py``. The unit tests below don't
touch the DB, but it's autouse'd so a test that flips to integration
mode doesn't accidentally inherit stale state.
"""
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()
@pytest.fixture
def client() -> TestClient:
return TestClient(app)
def _validation(passed: bool = True) -> ValidationReport:
return ValidationReport(passed=passed, errors=[], warnings=[])
def _make_claim_837(
*,
claim_id: str = "CLM-1",
charge: str = "124.00",
patient_first: str = "Jane",
patient_last: str = "Doe",
frequency_code: str = "1",
service_date: date | None = None,
cpt_code: str = "99213",
validation: ValidationReport | None = None,
) -> ClaimOutput:
service_lines = []
if cpt_code or service_date is not None:
service_lines.append(
ServiceLine(
line_number=1,
procedure=Procedure(qualifier="HC", code=cpt_code, modifiers=[]),
charge=Decimal(charge),
units=Decimal("1"),
service_date=service_date,
)
)
return ClaimOutput(
claim_id=claim_id,
control_number="0001",
transaction_date=date(2026, 6, 19),
billing_provider=BillingProvider(name="Test Clinic", npi="1234567890"),
subscriber=Subscriber(
first_name=patient_first,
last_name=patient_last,
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=frequency_code,
place_of_service="11",
),
diagnoses=[],
service_lines=service_lines,
validation=validation if validation is not None else _validation(),
raw_segments=[],
)
def _make_record_837(claims: list[ClaimOutput], *, batch_id: str = "b-a") -> BatchRecord:
return BatchRecord(
id=batch_id,
kind="837p",
input_filename=f"{batch_id}.837",
parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
result=ParseResult(
envelope=Envelope(
sender_id="S", receiver_id="R", control_number="0001",
transaction_date=date(2026, 6, 19),
),
claims=claims,
summary=BatchSummary(
input_file=f"{batch_id}.837", control_number="0001",
transaction_date=date(2026, 6, 19),
total_claims=len(claims),
passed=len(claims), failed=0,
),
),
)
def _make_remit_835(
*,
pccn: str = "R-1",
charge: str = "100.00",
status_code: str = "1",
service_date: date | None = None,
procedure_code: str = "99213",
) -> ClaimPayment:
service_payments = []
if procedure_code or service_date is not None:
service_payments.append(
ServicePayment(
line_number=1,
procedure_qualifier="HC",
procedure_code=procedure_code,
modifiers=[],
charge=Decimal(charge),
payment=Decimal("0"),
service_date=service_date,
adjustments=[],
)
)
return ClaimPayment(
payer_claim_control_number=pccn,
status_code=status_code,
status_label=None,
total_charge=Decimal(charge),
total_paid=Decimal("0"),
service_payments=service_payments,
raw_segments=[],
)
def _make_record_835(
remits: list[ClaimPayment], *, batch_id: str = "b-b",
) -> BatchRecord:
return BatchRecord(
id=batch_id,
kind="835",
input_filename=f"{batch_id}.835",
parsed_at=datetime(2026, 6, 19, 13, 0, tzinfo=timezone.utc),
result=ParseResult835(
envelope=Envelope(
sender_id="P", receiver_id="R", control_number="0002",
transaction_date=date(2026, 6, 19),
),
financial_info=FinancialInfo(
handling_code="C", paid_amount=Decimal("0"),
credit_debit_flag="C",
),
trace=ReassociationTrace(
trace_type_code="1", trace_number="1",
originating_company_id="P1",
),
payer=Payer835(name="Payer"),
payee=Payee835(name="Clinic", npi="1234567890"),
claims=remits,
summary=BatchSummary(
input_file=f"{batch_id}.835", control_number="0002",
transaction_date=date(2026, 6, 19),
total_claims=len(remits),
passed=len(remits), failed=0,
),
),
)
# ---------------------------------------------------------------------------
# Pure unit tests — diff_batches
# ---------------------------------------------------------------------------
def test_empty_batches_have_no_deltas():
a = _make_record_837([], batch_id="b-a")
b = _make_record_837([], batch_id="b-b")
result = diff_batches(a, b)
assert result.added == []
assert result.removed == []
assert result.changed == []
summary = result.summary
assert summary == {
"addedCount": 0, "removedCount": 0,
"changedCount": 0, "unchangedCount": 0,
}
def test_identical_batches_have_no_deltas():
claims = [
_make_claim_837(claim_id="CLM-1", charge="124.00", service_date=date(2026, 6, 1)),
_make_claim_837(claim_id="CLM-2", charge="88.50", service_date=date(2026, 6, 2)),
]
a = _make_record_837(claims, batch_id="b-a")
b = _make_record_837(claims, batch_id="b-b")
result = diff_batches(a, b)
assert result.added == []
assert result.removed == []
assert result.changed == []
assert result.summary["unchangedCount"] == 2
def test_claim_added_in_b():
a = _make_record_837([_make_claim_837(claim_id="CLM-1")], batch_id="b-a")
b = _make_record_837(
[_make_claim_837(claim_id="CLM-1"), _make_claim_837(claim_id="CLM-2")],
batch_id="b-b",
)
result = diff_batches(a, b)
assert [s.claim_id for s in result.added] == ["CLM-2"]
assert result.removed == []
assert result.changed == []
assert result.summary["addedCount"] == 1
assert result.summary["unchangedCount"] == 1
def test_claim_removed_from_a():
a = _make_record_837(
[_make_claim_837(claim_id="CLM-1"), _make_claim_837(claim_id="CLM-2")],
batch_id="b-a",
)
b = _make_record_837([_make_claim_837(claim_id="CLM-1")], batch_id="b-b")
result = diff_batches(a, b)
assert result.added == []
assert [s.claim_id for s in result.removed] == ["CLM-2"]
assert result.summary["removedCount"] == 1
def test_claim_charge_change_is_a_diff():
a = _make_record_837(
[_make_claim_837(claim_id="CLM-1", charge="100.00")], batch_id="b-a",
)
b = _make_record_837(
[_make_claim_837(claim_id="CLM-1", charge="125.00")], batch_id="b-b",
)
result = diff_batches(a, b)
assert result.added == [] and result.removed == []
assert len(result.changed) == 1
fields = [d.field for d in result.changed[0].diff]
assert "total_charge" in fields
charge_diff = next(d for d in result.changed[0].diff if d.field == "total_charge")
assert charge_diff.a == 100.00
assert charge_diff.b == 125.00
def test_sub_cent_charge_jitter_is_not_a_diff():
"""Two charges that differ by less than a cent register as equal.
Decimal → float can introduce sub-cent noise when the source had
more than 2 decimal places. Tolerance is 1e-2 to match the
production ``_eq_charge`` helper.
"""
a = _make_record_837(
[_make_claim_837(claim_id="CLM-1", charge="100.00")], batch_id="b-a",
)
# 100.004 rounds to 100.00 in the float projection but is a fresh
# value when summed — surface this as a delta only if the diff
# exceeds the 1e-2 tolerance.
b = _make_record_837(
[_make_claim_837(claim_id="CLM-1", charge="100.005")], batch_id="b-b",
)
result = diff_batches(a, b)
assert result.changed == [], (
"sub-cent float noise should not register as a delta"
)
def test_status_change_is_a_diff():
"""Validation-derived status flows through to the diff.
A claim that flipped from passing to failing should surface a
``status`` field diff between the two sides.
"""
passing = _make_claim_837(
claim_id="CLM-1", validation=_validation(passed=True),
)
failing = _make_claim_837(
claim_id="CLM-1", validation=ValidationReport(
passed=False,
errors=[],
warnings=[],
),
)
a = _make_record_837([passing], batch_id="b-a")
b = _make_record_837([failing], batch_id="b-b")
result = diff_batches(a, b)
assert len(result.changed) == 1
fields = [d.field for d in result.changed[0].diff]
assert "status" in fields
def test_service_date_change_is_a_diff():
a = _make_record_837(
[_make_claim_837(claim_id="CLM-1", service_date=date(2026, 6, 1))],
batch_id="b-a",
)
b = _make_record_837(
[_make_claim_837(claim_id="CLM-1", service_date=date(2026, 6, 2))],
batch_id="b-b",
)
result = diff_batches(a, b)
assert len(result.changed) == 1
fields = [d.field for d in result.changed[0].diff]
assert "service_date" in fields
def test_cpt_change_is_a_diff():
a = _make_record_837(
[_make_claim_837(claim_id="CLM-1", cpt_code="99213")], batch_id="b-a",
)
b = _make_record_837(
[_make_claim_837(claim_id="CLM-1", cpt_code="99214")], batch_id="b-b",
)
result = diff_batches(a, b)
assert len(result.changed) == 1
fields = [d.field for d in result.changed[0].diff]
assert "cpt_codes" in fields
def test_cpt_reorder_is_not_a_diff():
"""CPT codes compared as sorted, deduplicated sets.
Two claims with the same codes in different SV1 order should
not register as a diff — that would generate noise on every
service-line reordering.
"""
a = _make_record_837(
[_make_claim_837(claim_id="CLM-1", cpt_code="99213")], batch_id="b-a",
)
b = _make_record_837(
[_make_claim_837(claim_id="CLM-1", cpt_code="99213")], batch_id="b-b",
)
result = diff_batches(a, b)
assert result.changed == []
def test_mixed_added_removed_changed():
a = _make_record_837(
[
_make_claim_837(claim_id="CLM-1", charge="100.00"),
_make_claim_837(claim_id="CLM-2", charge="200.00"),
_make_claim_837(claim_id="CLM-3", charge="300.00"),
],
batch_id="b-a",
)
b = _make_record_837(
[
_make_claim_837(claim_id="CLM-1", charge="150.00"), # changed
_make_claim_837(claim_id="CLM-3", charge="300.00"), # unchanged
_make_claim_837(claim_id="CLM-4", charge="400.00"), # added
],
batch_id="b-b",
)
result = diff_batches(a, b)
assert [s.claim_id for s in result.added] == ["CLM-4"]
assert [s.claim_id for s in result.removed] == ["CLM-2"]
assert [c.a.claim_id for c in result.changed] == ["CLM-1"]
summary = result.summary
assert summary["addedCount"] == 1
assert summary["removedCount"] == 1
assert summary["changedCount"] == 1
assert summary["unchangedCount"] == 1
def test_wire_dict_strips_internal_claim_ids():
a = _make_record_837(
[_make_claim_837(claim_id="CLM-1")], batch_id="b-a",
)
b = _make_record_837(
[_make_claim_837(claim_id="CLM-1"), _make_claim_837(claim_id="CLM-2")],
batch_id="b-b",
)
wire = diff_batches_to_wire(a, b)
assert "claim_ids" not in wire["a"]
assert "claim_ids" not in wire["b"]
# Public shape — every key documented in the spec.
assert set(wire.keys()) == {"a", "b", "added", "removed", "changed", "summary"}
assert set(wire["a"].keys()) == {
"id", "kind", "parsedAt", "inputFilename", "claimCount",
}
assert wire["a"]["claimCount"] == 1
assert wire["b"]["claimCount"] == 2
def test_mixed_kind_diff_matches_by_claim_id():
"""837P and 835 can be diffed; the matcher uses ``claim_id``.
The common case for a single kind pair is covered by the other
tests. Here we exercise the cross-kind path so an operator can
diff submitted claims against received remittances.
"""
a = _make_record_837(
[
_make_claim_837(claim_id="CLM-1", charge="100.00"),
_make_claim_837(claim_id="CLM-2", charge="200.00"),
],
batch_id="b-a",
)
b = _make_record_835(
[
_make_remit_835(pccn="CLM-1", charge="100.00", status_code="1"),
_make_remit_835(pccn="CLM-3", charge="300.00", status_code="1"),
],
batch_id="b-b",
)
result = diff_batches(a, b)
# CLM-1 in both, no field change → unchanged
# CLM-2 in A only → removed
# CLM-3 in B only → added
assert [s.claim_id for s in result.added] == ["CLM-3"]
assert [s.claim_id for s in result.removed] == ["CLM-2"]
assert result.changed == []
assert result.summary["unchangedCount"] == 1
# ---------------------------------------------------------------------------
# Integration tests — /api/batch-diff
# ---------------------------------------------------------------------------
def _ingest_837(client: TestClient, text: str, filename: str = "x.837") -> str:
resp = client.post(
"/api/parse-837",
files={"file": (filename, text, "text/plain")},
headers={"Accept": "application/json"},
)
assert resp.status_code == 200, resp.text
return resp.json()["claims"][0]["claim_id"] # used for sanity
def test_endpoint_404_when_a_missing(client: TestClient):
"""Missing ``a`` → 404, never 500.
Mirrors the existing ``GET /api/batches/{id}`` 404 contract.
"""
resp = client.get("/api/batch-diff?a=ghost&b=also-ghost")
assert resp.status_code == 404
body = resp.json()
assert body["detail"]["error"] == "Not found"
assert "ghost" in body["detail"]["detail"]
def test_endpoint_400_when_params_missing(client: TestClient):
"""Missing query params → 400 with a helpful detail message."""
resp = client.get("/api/batch-diff?a=only")
assert resp.status_code == 400
body = resp.json()
assert body["detail"]["error"] == "Missing param"
assert "b" in body["detail"]["detail"].lower()
def test_endpoint_returns_diff_between_two_ingested_batches(client: TestClient):
"""End-to-end: POST two 837 files, GET the diff, assert the shape.
The fixture file ships with 2 claims; we ingest it twice. The
diff should be empty (the two batches are identical) and the
unchangedCount should equal 2.
"""
text = FIXTURE_837.read_text()
r1 = client.post(
"/api/parse-837",
files={"file": ("first.837", text, "text/plain")},
headers={"Accept": "application/json"},
)
assert r1.status_code == 200, r1.text
a_id = r1.json()["claims"][0]["claim_id"] # sanity: parse worked
r2 = client.post(
"/api/parse-837",
files={"file": ("second.837", text, "text/plain")},
headers={"Accept": "application/json"},
)
assert r2.status_code == 200, r2.text
# Resolve the actual batch ids via /api/batches.
batches = client.get("/api/batches").json()["items"]
assert len(batches) == 2
a_batch_id = batches[0]["id"]
b_batch_id = batches[1]["id"]
resp = client.get(
f"/api/batch-diff?a={a_batch_id}&b={b_batch_id}",
)
assert resp.status_code == 200, resp.text
body = resp.json()
assert body["a"]["id"] == a_batch_id
assert body["b"]["id"] == b_batch_id
assert body["a"]["kind"] == "837p"
assert body["b"]["kind"] == "837p"
assert body["added"] == []
assert body["removed"] == []
assert body["changed"] == []
# Spec summary keys are all present and numeric.
summary = body["summary"]
assert summary["addedCount"] == 0
assert summary["removedCount"] == 0
assert summary["changedCount"] == 0
assert summary["unchangedCount"] == 2
# Reference the parsed claim id once so the fixture's first claim
# id is exercised end-to-end (the lint otherwise complains about
# the unused variable).
assert a_id
def test_endpoint_includes_added_removed_summary_keys(client: TestClient):
"""Diff summary keys are documented and always present.
Even when zero, the JSON contract should expose every key so the
UI can render the four cards unconditionally.
"""
text = FIXTURE_837.read_text()
r1 = client.post(
"/api/parse-837",
files={"file": ("first.837", text, "text/plain")},
headers={"Accept": "application/json"},
)
r2 = client.post(
"/api/parse-837",
files={"file": ("second.837", text, "text/plain")},
headers={"Accept": "application/json"},
)
assert r1.status_code == r2.status_code == 200
batches = client.get("/api/batches").json()["items"]
a_batch_id, b_batch_id = batches[0]["id"], batches[1]["id"]
body = client.get(
f"/api/batch-diff?a={a_batch_id}&b={b_batch_id}",
).json()
assert set(body["summary"].keys()) == {
"addedCount", "removedCount", "changedCount", "unchangedCount",
}
def test_store_helper_load_two_for_diff_raises_lookup():
"""``CycloneStore.load_two_for_diff`` raises ``LookupError`` on
missing ids so the API layer can map to 404 cleanly.
"""
from cyclone.store import CycloneStore
s = CycloneStore()
s.add(_make_record_837(
[_make_claim_837(claim_id="CLM-1")],
batch_id="present",
))
with pytest.raises(LookupError):
s.load_two_for_diff("present", "ghost")
with pytest.raises(LookupError):
s.load_two_for_diff("ghost", "present")
# Happy path
a, b = s.load_two_for_diff("present", "present")
assert a.id == "present"
assert b.id == "present"
# Silence the unused-import linter for items used purely as type hints
# or fixture anchors.
void_global_store = global_store