"""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