Files
cyclone/backend/tests/test_api_parse_persists.py
T
Tyler de55003484 chore: stash in-progress test edit
Captures the modified state of test_api_parse_persists.py so the
in-progress test work is preserved on the claims-unique-fix branch
as the sp-prefixed worktrees are cleaned up. The .venv and
.gitignore_local noise in the working tree are intentionally left
untracked.
2026-06-22 11:05:56 -06:00

417 lines
18 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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
# --------------------------------------------------------------------------- #
# Cross-pipeline reconciliation (837 + 835 together)
# --------------------------------------------------------------------------- #
PRODFILE_835_DIR_FOR_XP = (
Path(__file__).parent.parent.parent / "docs" / "prodfiles" / "835fromco"
)
@pytest.mark.skipif(
not (PRODFILE_DIR.is_dir() and PRODFILE_835_DIR_FOR_XP.is_dir()),
reason=f"production 837 or 835 files not present (gitignored): {PRODFILE_DIR}, {PRODFILE_835_DIR_FOR_XP}",
)
def test_prodfile_cross_pipeline_reconciles(client: TestClient):
"""All production 837s + all production 835s end-to-end.
Pipeline under test:
POST /api/parse-837 (×N) → store.add (claim rows) → DB
POST /api/parse-835 (×M) → store.add (remit rows) → reconcile.run → DB
Designed to survive variable 837 / 835 file counts and arbitrary PCN
overlap. Hard-codes NO match counts. Instead asserts invariants:
1. Every 837 file parses (200, total_claims ≥ 1) and lands as one
837p batch.
2. Every 835 file parses (200, total_claims ≥ 1) and lands as one
835 batch; each carries an R835_MULTI_BPR warning.
3. After all batches: store carries len(837 files) + len(835 files)
batches with the expected kind split.
4. After all 835s loaded: store.list_unmatched() returns shapes:
unmatched_claims = total_unique_837_pcns - total_matched
unmatched_remittances = total_unique_835_pcns - total_matched
Both counts are ≥ 0; the matched count is a function of how
many 837 PCNs happen to appear in the 835 set — that's data-
dependent and not asserted directly.
5. Per-835 reconciliation summaries are well-formed (matched,
unmatched_claims, unmatched_remittances, skipped — all ints).
Files are discovered via glob so adding more 837 files (the
production set grows over time) needs no test edit.
"""
from cyclone.store import CycloneStore
assert len(global_store.list()) == 0
# Discover files dynamically — works for N 837s and M 835s.
prodfiles_837 = sorted(p for p in PRODFILE_DIR.iterdir() if p.is_file())
prodfiles_835 = sorted(p for p in PRODFILE_835_DIR_FOR_XP.iterdir() if p.is_file())
assert prodfiles_837, f"no 837 files at {PRODFILE_DIR}"
assert prodfiles_835, f"no 835 files at {PRODFILE_835_DIR_FOR_XP}"
# 1. Load every 837 first (claims must exist before 835 reconcile runs).
unique_837_pcns: set[str] = set()
for path in prodfiles_837:
with open(path, "rb") as f:
resp = client.post(
"/api/parse-837",
files={"file": (path.name, f, "text/plain")},
headers={"Accept": "application/json"},
)
assert resp.status_code == 200, f"{path.name}: {resp.text}"
body = resp.json()
assert body["summary"]["total_claims"] >= 1, path.name
assert body["summary"]["failed"] == 0, path.name
for claim in body["claims"]:
pcn = claim.get("claim_id") or claim.get("patient_control_number")
if pcn:
unique_837_pcns.add(pcn)
# 2. Load every 835. Each triggers reconcile.run against the existing
# 837 claims; the per-batch reconciliation summary records how many
# of THIS batch's remits matched.
unique_835_pcns: set[str] = set()
per_835_matched: list[int] = []
per_835_summary_keys = {"matched", "unmatched_claims", "unmatched_remittances", "skipped"}
for path in prodfiles_835:
with open(path, "rb") as f:
resp = client.post(
"/api/parse-835",
files={"file": (path.name, f, "application/octet-stream")},
headers={"Accept": "application/json"},
)
assert resp.status_code == 200, f"{path.name}: {resp.text}"
body = resp.json()
assert body["summary"]["total_claims"] >= 1, path.name
assert body["summary"]["failed"] == 0, path.name
# CO Medicaid split-payment pattern: validator surfaces the
# non-standard data as a warning; the batch still passes.
assert any(
issue["rule"] == "R835_MULTI_BPR"
for issue in body["validation"]["warnings"]
), f"{path.name}: missing R835_MULTI_BPR warning"
# Per-claim PCN extraction — UI shape is claim_id (not PCN).
for cp in body["claims"]:
pcn = cp.get("claim_id") or cp.get("payer_claim_control_number")
if pcn:
unique_835_pcns.add(pcn)
# Reconciliation summary must be well-formed.
rec = body["reconciliation"]
assert set(rec.keys()) >= per_835_summary_keys, path.name
for key in per_835_summary_keys:
assert isinstance(rec[key], int), (path.name, key, rec[key])
assert rec["skipped"] == 0, path.name
per_835_matched.append(rec["matched"])
# 3. Store shape: one batch per file, split by kind.
batches = global_store.list()
assert len(batches) == len(prodfiles_837) + len(prodfiles_835)
by_kind: dict[str, set[str]] = {"837p": set(), "835": set()}
for b in batches:
by_kind[b.kind].add(b.input_filename)
assert by_kind["837p"] == {p.name for p in prodfiles_837}
assert by_kind["835"] == {p.name for p in prodfiles_835}
# 4. Reconciliation invariants. matched_count is bounded by the size
# of the smaller set; unmatched counts are the disjoint remainder.
fresh_store = CycloneStore()
unmatched = fresh_store.list_unmatched(kind="both")
unmatched_claims = len(unmatched["claims"])
unmatched_remits = len(unmatched["remittances"])
total_matched = sum(per_835_matched)
assert 0 <= total_matched <= len(unique_837_pcns), (
f"matched {total_matched} outside [0, {len(unique_837_pcns)}]"
)
# Every matched pair consumes one claim and one remit.
assert unmatched_claims == len(unique_837_pcns) - total_matched, (
f"unmatched_claims {unmatched_claims} != "
f"{len(unique_837_pcns)} - {total_matched}"
)
assert unmatched_remits == len(unique_835_pcns) - total_matched, (
f"unmatched_remittances {unmatched_remits} != "
f"{len(unique_835_pcns)} - {total_matched}"
)
# The two PCN sets are independent sources of truth: total deduped
# claim rows + remittance rows in the DB must equal the input totals
# adjusted for cross-batch dedup. The DB-level ground truth:
claim_rows = sum(
len(b.result.claims) for b in batches if b.kind == "837p"
)
# Deduped claim rows == unique PCNs (one Claim row per PCN due to
# the store.add dedup logic, same as 835 PCNs).
assert claim_rows >= len(unique_837_pcns), (
f"claim_rows {claim_rows} < unique_837_pcns {len(unique_837_pcns)}"
)
def test_409_response_includes_existing_batch_id_for_837(client: TestClient) -> None:
"""When a CLM01 already exists in a prior batch, the 409 body has existing_batch_id."""
from datetime import datetime, timezone
from cyclone import db as _db
from cyclone.db import Batch, Claim
# Seed a prior batch with claim CLM-X.
with _db.SessionLocal()() as s:
s.add(Batch(
id="PRIOR", kind="837p", input_filename="prior.txt",
parsed_at=datetime(2026, 1, 1, tzinfo=timezone.utc),
raw_result_json={},
))
s.add(Claim(id="CLM-X", batch_id="PRIOR", patient_control_number="M"))
s.commit()
# Build a file with two CLM* segments both using CLM-X (forces PK collision).
text = (
"ISA*00* *00* *ZZ*SUBMITTERID *ZZ*RECEIVERID "
"*240101*1200*^*00501*000000001*0*P*:~\n"
"GS*HC*SUBMITTERID*RECEIVERID*20240101*1200*1*X*005010X222A1~\n"
"ST*837*0001*005010X222A1~\n"
"BHT*0019*00*1*20240101*1200*CH~\n"
"NM1*41*2*SUBMITTER*****46*SUBMITTERID~\n"
"PER*IC*CONTACT*TE*5555555555~\n"
"NM1*40*2*RECEIVER*****46*RECEIVERID~\n"
"HL*1**20*1~\n"
"NM1*85*2*BILLING*****XX*1881068062~\n"
"N3*123 MAIN*\nN4*DENVER*CO*80202~\n"
"REF*EI*123456789~\n"
"HL*2*1*22*0~\n"
"SBR*P*18*******CI~\n"
"NM1*IL*1*DOE*JOHN****MI*M~\n"
"N3*456 ELM*\nN4*DENVER*CO*80202~\n"
"DMG*D8*19700101*M~\n"
"NM1*PR*2*MEDICAID*****PI*MCD~\n"
"CLM*CLM-X*100***11:B:1*Y*A*Y*Y~\n"
"LX*1~\nSV1*HC:99213*100*UN*1***1~\n"
"DTP*472*D8*20240101~\n"
"CLM*CLM-X*100***11:B:1*Y*A*Y*Y~\n"
"LX*2~\nSV1*HC:99213*100*UN*1***1~\n"
"DTP*472*D8*20240101~\n"
"SE*30*0001~\n"
"GE*1*1~\n"
"IEA*1*000000001~\n"
)
resp = client.post(
"/api/parse-837",
files={"file": ("dup.txt", text, "text/plain")},
headers={"Accept": "application/json"},
)
assert resp.status_code == 409, resp.text
body = resp.json()
assert body.get("existing_batch_id") == "PRIOR"