612 lines
20 KiB
Python
612 lines
20 KiB
Python
"""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
|