b5e27927e0
Three related changes for real CO Medicaid data: 1. Drop UNIQUE(batch_id, patient_control_number) on claims and UNIQUE(batch_id, payer_claim_control_number) on remittances. The X12 spec allows multiple CLM segments per 2000B subscriber loop and 835 ERAs can repeat a payer_claim_control_number for reversals. Claim/ remittance identity is provided by the primary key (claims.id = CLM01, remittances.id = CLP01). 2. Add validator rule R835_MULTI_BPR warning for files with multiple BPR segments (CO Medicaid split-payment pattern). The parser already sums BPR02 paid_amounts; this surfaces the non-standard data to operators. 3. Skip R835_BAL_BPR_vs_CLP04 when BPR01='I' (Information Only 835). In that mode BPR02 is informational and the per-claim CLP04 totals are authoritative — a diff is expected, not an error. Migration 0003 handles the drop with IF EXISTS so fresh DBs skip cleanly. Updates affected tests to reflect new schema (no UNIQUE constraint on batch_id + patient_control_number / payer_claim_control_number). Fixes test_api_835::test_prodfile_round_trip_persists_separately which was failing on real production data.
212 lines
9.0 KiB
Python
212 lines
9.0 KiB
Python
"""Successful parses must persist to the store; failed parses must not."""
|
|
|
|
from __future__ import annotations
|
|
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from cyclone.api import app
|
|
from cyclone.store import store as global_store
|
|
|
|
FIXTURE = Path(__file__).parent / "fixtures" / "co_medicaid_837p.txt"
|
|
FIXTURE_835 = Path(__file__).parent / "fixtures" / "co_medicaid_835.txt"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clear_store():
|
|
"""Reset the module-level store before and after each test."""
|
|
with global_store._lock:
|
|
global_store._batches.clear()
|
|
yield
|
|
with global_store._lock:
|
|
global_store._batches.clear()
|
|
|
|
|
|
@pytest.fixture
|
|
def client() -> TestClient:
|
|
return TestClient(app)
|
|
|
|
|
|
def test_successful_parse_837_creates_batch(client: TestClient):
|
|
assert len(global_store.list()) == 0
|
|
text = FIXTURE.read_text()
|
|
resp = client.post(
|
|
"/api/parse-837",
|
|
files={"file": ("test.txt", text, "text/plain")},
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
assert len(global_store.list()) == 1
|
|
rec = global_store.list()[0]
|
|
assert rec.kind == "837p"
|
|
assert rec.input_filename == "test.txt"
|
|
|
|
|
|
def test_failed_parse_837_does_not_create_batch(client: TestClient):
|
|
"""An empty / garbage file should NOT create a batch."""
|
|
assert len(global_store.list()) == 0
|
|
resp = client.post(
|
|
"/api/parse-837",
|
|
files={"file": ("garbage.txt", "not-edi", "text/plain")},
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
# Either a 4xx from the parser, or a 200 with 0 claims — the contract
|
|
# is that NO batch is added either way.
|
|
assert resp.status_code in (200, 400, 422)
|
|
assert len(global_store.list()) == 0
|
|
|
|
|
|
def test_validation_failed_837_does_not_create_batch(client: TestClient):
|
|
"""A parse that produced claims with validation errors (422) must not be stored.
|
|
|
|
The spec only mandates "do not store on CycloneParseError or unhandled
|
|
exception"; we choose the stricter interpretation that 422 validation
|
|
failures are also excluded so the data on disk is always clean.
|
|
"""
|
|
# Construct a minimal but technically malformed 837P that the parser
|
|
# will accept but validation will reject. We inject a wrong NPI
|
|
# length to trigger the per-claim NPI validation rule.
|
|
text = FIXTURE.read_text()
|
|
# Replace the provider NPI (XX*1881068062) with an invalid 8-digit NPI
|
|
# (the rule is NPI must be 10 digits). This keeps the parse step
|
|
# green while failing claim validation.
|
|
text = text.replace("XX*1881068062", "XX*12345678")
|
|
resp = client.post(
|
|
"/api/parse-837",
|
|
files={"file": ("bad.txt", text, "text/plain")},
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
assert resp.status_code == 422, resp.text
|
|
assert len(global_store.list()) == 0
|
|
|
|
|
|
def test_parse_835_response_includes_reconciliation_summary(
|
|
client: TestClient, tmp_path: Path
|
|
):
|
|
"""A successful 835 parse returns matched/unmatched counts in JSON."""
|
|
fixture = FIXTURE_835.read_text()
|
|
p = tmp_path / "era.txt"
|
|
p.write_text(fixture)
|
|
with open(p, "rb") as f:
|
|
r = client.post(
|
|
"/api/parse-835",
|
|
files={"file": ("era.txt", f, "text/plain")},
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
assert r.status_code == 200
|
|
body = r.json()
|
|
assert "reconciliation" in body, f"missing reconciliation: {list(body.keys())}"
|
|
rec = body["reconciliation"]
|
|
assert "matched" in rec
|
|
assert "unmatched_claims" in rec
|
|
assert "unmatched_remittances" in rec
|
|
assert "skipped" in rec
|
|
|
|
|
|
# --------------------------------------------------------------------------- #
|
|
# Production 837P round-trip (parser → API → store → DB)
|
|
# --------------------------------------------------------------------------- #
|
|
|
|
|
|
# Path to the production 837P files from axiscare. These are NOT in git
|
|
# (.gitignore: ``docs/prodfiles/*/``) — the test is skipped if the directory
|
|
# is missing so it stays green in clean checkouts but exercises real data
|
|
# when ops has dropped files in for pipeline validation.
|
|
PRODFILE_DIR = Path(__file__).parent.parent.parent / "docs" / "prodfiles" / "837p-from-axiscare"
|
|
|
|
# (filename, expected_control_number, expected_total_claims). Derived from
|
|
# /tmp/parse_prodfile.py on 2026-06-20 against the 7 files in PRODFILE_DIR.
|
|
# The first two files share control_number 991102984 — a deliberate replay.
|
|
EXPECTED_PRODFILES: list[tuple[str, str, int]] = [
|
|
("tp11525703-837P-20260618151119397-1of1.txt", "991102984", 141),
|
|
("tp11525703-837P-20260618153339862-1of1.txt", "991102984", 141),
|
|
("tp11525703-837P-20260618153343460-1of1.txt", "991102983", 97),
|
|
("tp11525703-837P-20260618153346107-1of1.txt", "991102982", 28),
|
|
("tp11525703-837P-20260618153349188-1of1.txt", "991102981", 69),
|
|
("tp11525703-837P-20260618153354947-1of1.txt", "991102978", 149),
|
|
("tp11525703-837P-20260618153358831-1of1.txt", "991102977", 99),
|
|
]
|
|
|
|
|
|
@pytest.mark.skipif(
|
|
not PRODFILE_DIR.is_dir(),
|
|
reason=f"production files not present at {PRODFILE_DIR} (gitignored)",
|
|
)
|
|
def test_prodfile_round_trip_persists_separately(client: TestClient):
|
|
"""All 7 production 837P files from axiscare must parse, persist as
|
|
separate batches, and be retrievable by id.
|
|
|
|
Exercises the full FastAPI path (parse → validate → store.add → DB)
|
|
against real production data — 3 distinct billing-provider NPIs, 2
|
|
distinct transaction dates, 6 unique control numbers, 724 claims total.
|
|
"""
|
|
assert len(global_store.list()) == 0
|
|
|
|
# 1. POST every file. Each must return 200 with the expected envelope
|
|
# and summary — proves the parse + payer-config + validation gates
|
|
# work on real EDI, not just the synthetic fixture.
|
|
for filename, expected_ctrl, expected_claims in EXPECTED_PRODFILES:
|
|
path = PRODFILE_DIR / filename
|
|
with open(path, "rb") as f:
|
|
resp = client.post(
|
|
"/api/parse-837",
|
|
files={"file": (filename, f, "text/plain")},
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
assert resp.status_code == 200, f"{filename}: {resp.text}"
|
|
body = resp.json()
|
|
assert body["envelope"]["control_number"] == expected_ctrl, filename
|
|
assert body["envelope"]["sender_id"] == "11525703", filename
|
|
assert body["summary"]["total_claims"] == expected_claims, filename
|
|
assert body["summary"]["passed"] == expected_claims, filename
|
|
assert body["summary"]["failed"] == 0, filename
|
|
|
|
# 2. Seven batches landed in the store (one per file). Confirms the
|
|
# store.add() path was hit for every upload and that the in-process
|
|
# state matches what the response said.
|
|
batches = global_store.list()
|
|
assert len(batches) == 7
|
|
assert {b.kind for b in batches} == {"837p"}
|
|
assert {b.input_filename for b in batches} == {f for f, _, _ in EXPECTED_PRODFILES}
|
|
|
|
# 3. Each persisted batch carries the full ParseResult: envelope,
|
|
# summary, claims. Also sanity-check real-world variety: 3 NPIs,
|
|
# 2 transaction dates (one each for 2026-06-11 vs 2026-06-17).
|
|
by_filename = {b.input_filename: b for b in batches}
|
|
total_claims = 0
|
|
distinct_npis: set[str] = set()
|
|
distinct_dates: set[str] = set()
|
|
for filename, expected_ctrl, expected_claims in EXPECTED_PRODFILES:
|
|
rec = by_filename[filename]
|
|
assert rec.result.envelope.control_number == expected_ctrl, filename
|
|
assert rec.result.summary.total_claims == expected_claims, filename
|
|
assert len(rec.result.claims) == expected_claims, filename
|
|
total_claims += rec.result.summary.total_claims
|
|
for claim in rec.result.claims:
|
|
assert claim.billing_provider.npi, f"{filename} claim {claim.claim_id} missing NPI"
|
|
distinct_npis.add(claim.billing_provider.npi)
|
|
distinct_dates.add(str(claim.transaction_date))
|
|
assert total_claims == 724
|
|
assert len(distinct_npis) >= 3, f"expected ≥3 NPIs across the batch, got {distinct_npis}"
|
|
assert len(distinct_dates) >= 2, f"expected ≥2 dates, got {distinct_dates}"
|
|
|
|
# 4. Read path: every batch is retrievable by id and round-trips with
|
|
# the same claim count that was just written. Covers global_store.get
|
|
# and the DB-backed lookup that powers /api/batches/{id}.
|
|
for rec in batches:
|
|
fetched = global_store.get(rec.id)
|
|
assert fetched is not None, rec.id
|
|
assert fetched.id == rec.id
|
|
assert len(fetched.result.claims) == rec.result.summary.total_claims
|
|
assert fetched.result.envelope.control_number == rec.result.envelope.control_number
|
|
|
|
# 5. Replay sanity: the first two files share control_number 991102984
|
|
# but were persisted as TWO distinct batches (no implicit dedup).
|
|
# Documents the current behavior; flip the assertion if/when a
|
|
# duplicate-control-number policy is added.
|
|
ctrl_991102984 = [b for b in batches if b.result.envelope.control_number == "991102984"]
|
|
assert len(ctrl_991102984) == 2
|
|
assert len({b.id for b in ctrl_991102984}) == 2
|