feat(backend): wire parse-837/parse-835 to persist to InMemoryStore

This commit is contained in:
Tyler
2026-06-19 19:25:23 -06:00
parent ca645db518
commit 86e69d5b95
2 changed files with 107 additions and 0 deletions
+26
View File
@@ -17,6 +17,7 @@ from __future__ import annotations
import json import json
import logging import logging
import uuid
from typing import Any, Iterator from typing import Any, Iterator
from fastapi import FastAPI, File, HTTPException, Query, Request, UploadFile from fastapi import FastAPI, File, HTTPException, Query, Request, UploadFile
@@ -32,6 +33,7 @@ from cyclone.parsers.payer import PayerConfig, PayerConfig835
from cyclone.parsers.parse_837 import parse from cyclone.parsers.parse_837 import parse
from cyclone.parsers.parse_835 import parse as parse_835 from cyclone.parsers.parse_835 import parse as parse_835
from cyclone.parsers.validator_835 import validate as validate_835 from cyclone.parsers.validator_835 import validate as validate_835
from cyclone.store import BatchRecord, store, utcnow
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -195,11 +197,24 @@ async def parse_837(
if _has_claim_validation_errors(result): if _has_claim_validation_errors(result):
# Per spec: 422 when claims failed validation. # Per spec: 422 when claims failed validation.
# Body still includes the full ParseResult so the client can show errors. # Body still includes the full ParseResult so the client can show errors.
# Validation-failed parses are NOT persisted (the data is suspect);
# only parses that survive validation end up in the store.
return JSONResponse( return JSONResponse(
status_code=422, status_code=422,
content=json.loads(result.model_dump_json()), content=json.loads(result.model_dump_json()),
) )
# Persist the cleaned-up result so the cleaned result is what clients
# retrieve via /api/batches/{id}.
rec = BatchRecord(
id=uuid.uuid4().hex,
kind="837p",
input_filename=file.filename or "upload.txt",
parsed_at=utcnow(),
result=result,
)
store.add(rec)
if _client_wants_json(request): if _client_wants_json(request):
return JSONResponse(content=json.loads(result.model_dump_json())) return JSONResponse(content=json.loads(result.model_dump_json()))
@@ -321,6 +336,17 @@ async def parse_835_endpoint(
content=json.loads(result.model_dump_json()), content=json.loads(result.model_dump_json()),
) )
# Persist the cleaned-up result so the cleaned result is what clients
# retrieve via /api/batches/{id}.
rec = BatchRecord(
id=uuid.uuid4().hex,
kind="835",
input_filename=file.filename or "upload.txt",
parsed_at=utcnow(),
result=result,
)
store.add(rec)
if _client_wants_json(request): if _client_wants_json(request):
return JSONResponse(content=json.loads(result.model_dump_json())) return JSONResponse(content=json.loads(result.model_dump_json()))
+81
View File
@@ -0,0 +1,81 @@
"""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"
@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