9e16b8d9bd
Phase 4 of SP28 (Ack↔Claim Auto-Link). Refactors the per-AK2 helpers in cyclone.claim_acks to return ClaimAckLinkRow dataclasses instead of mutating the session directly — the orchestrator (the 999 / 277CA / TA1 handlers + the matching parse-* API endpoints) now persists each row via cycl_store.add_claim_ack so the publish-from-store contract owns the live-tail event emission. * handle_999 / handle_277ca / handle_ta1 — build batch envelope index outside the work session (SQLite + concurrent sessions causes 'database is locked'), call apply_X to get ClaimAckLinkRow dataclasses, snapshot the rows before committing the work session, then call cycl_store.add_claim_ack per row in fresh sessions. * /api/parse-999 / /api/parse-277ca / /api/parse-ta1 — mirror the handler chain with event_bus passed through so live-tail subscribers on the claim and ack sides see the new rows the moment they land. Adds a 'claim_ack_links_count' field to each ack response (spec §4). * lookup_claims_for_ack_set_response — now accepts either a callable OR a plain dict as batch_envelope_index (the store returns a dict; tests pass callables). * test_apply_claim_ack_links.py — 15 tests updated to assert on the dataclass shape and exercise the full helper→add_claim_ack cycle (so idempotency is verified at the store layer). * test_e2e_999_to_claim_drawer.py — 2 new tests covering the D10 two-pass join end-to-end via FastAPI TestClient (Pass 1 via ST02 + Pass 2 via PCN fallback).
175 lines
6.0 KiB
Python
175 lines
6.0 KiB
Python
"""End-to-end ingest tests for SP28: 999 file → claim_acks rows.
|
|
|
|
Step 4.5 of the plan. Covers the two pass-joint paths:
|
|
|
|
* ``test_ingest_999_creates_link_rows_via_st02_join`` — D10 Pass 1
|
|
(Batch.envelope.control_number == ST02). The fixture's ST02
|
|
(``991102989``) matches a pre-seeded Batch's envelope so Pass 1
|
|
resolves the claim by ``Claim.batch_id``. PCN mismatch on purpose.
|
|
|
|
* ``test_ingest_999_creates_link_rows_via_pcn_fallback`` — D10 Pass 2
|
|
(Claim.patient_control_number). No Batch matches, so Pass 2 fires
|
|
and resolves by PCN.
|
|
|
|
Both tests use the FastAPI test client to exercise the full
|
|
parse-999 → apply_999_acceptances → add_claim_ack chain end-to-end,
|
|
then verify the row exists in the DB and the response shape includes
|
|
the new ``claim_ack_links_count`` field.
|
|
"""
|
|
from __future__ import annotations
|
|
|
|
from datetime import datetime, timezone
|
|
from decimal import Decimal
|
|
from pathlib import Path
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from cyclone import db
|
|
from cyclone.api import app
|
|
from cyclone.db import Batch, Claim, ClaimAck, ClaimState
|
|
from cyclone.store import store
|
|
|
|
|
|
GAINWELL_999 = (
|
|
Path(__file__).parent / "fixtures" / "minimal_999_ik5_gainwell.txt"
|
|
)
|
|
ACCEPTED_999 = Path(__file__).parent / "fixtures" / "minimal_999.txt"
|
|
|
|
|
|
@pytest.fixture(autouse=True)
|
|
def clear_store():
|
|
"""Reset the module-level store before and after each test.
|
|
|
|
The conftest's autouse fixtures set up the tmp DB; here we just
|
|
make sure the in-memory store shim is clean too (no-op for the
|
|
DB-backed store but matches the rest of the test suite's style).
|
|
"""
|
|
with store._lock:
|
|
store._batches.clear()
|
|
yield
|
|
with store._lock:
|
|
store._batches.clear()
|
|
|
|
|
|
@pytest.fixture
|
|
def client() -> TestClient:
|
|
return TestClient(app)
|
|
|
|
|
|
def _seed_batch_envelope(*, batch_id: str, envelope_control: str):
|
|
"""Insert one Batch row whose raw_result_json envelope carries
|
|
``envelope_control`` so D10 Pass 1 can resolve it via
|
|
``batch_envelope_index``."""
|
|
with db.SessionLocal()() as s:
|
|
b = Batch(
|
|
id=batch_id,
|
|
kind="837",
|
|
input_filename=f"{batch_id}.txt",
|
|
parsed_at=datetime(2026, 7, 2, 12, 0, tzinfo=timezone.utc),
|
|
raw_result_json={
|
|
"envelope": {
|
|
"sender_id": "SUBMITTER",
|
|
"receiver_id": "RECEIVER",
|
|
"control_number": envelope_control,
|
|
"transaction_date": "2026-07-02",
|
|
},
|
|
},
|
|
)
|
|
s.add(b)
|
|
s.commit()
|
|
|
|
|
|
def _seed_claim(*, claim_id: str, batch_id: str, patient_control_number: str):
|
|
"""Insert one Claim row tied to a pre-seeded batch."""
|
|
with db.SessionLocal()() as s:
|
|
c = Claim(
|
|
id=claim_id,
|
|
batch_id=batch_id,
|
|
patient_control_number=patient_control_number,
|
|
charge_amount=Decimal("100.00"),
|
|
state=ClaimState.SUBMITTED,
|
|
)
|
|
s.add(c)
|
|
s.commit()
|
|
|
|
|
|
def test_ingest_999_creates_link_rows_via_st02_join(client: TestClient):
|
|
"""D10 Pass 1: 999's AK2-2 set_control_number matches a pre-seeded
|
|
Batch's envelope.control_number, so the auto-linker resolves
|
|
the claim by Claim.batch_id (NOT by PCN).
|
|
|
|
PCN is intentionally non-matching to prove Pass 1 won — if Pass 2
|
|
had fired instead, the PCN lookup would have returned no claim
|
|
and the row would have been an orphan.
|
|
"""
|
|
# Seed a Batch whose envelope control number == the Gainwell
|
|
# 999's AK2-2 set_control_number ("991102989").
|
|
_seed_batch_envelope(batch_id="B-GAINWELL", envelope_control="991102989")
|
|
# Seed a Claim in that batch, but with a PCN that does NOT match
|
|
# "991102989" — Pass 2 must not fire.
|
|
_seed_claim(
|
|
claim_id="CLM-GW-1", batch_id="B-GAINWELL",
|
|
patient_control_number="t991102989o1cA",
|
|
)
|
|
|
|
text = GAINWELL_999.read_text()
|
|
resp = client.post(
|
|
"/api/parse-999",
|
|
files={"file": ("minimal_999_ik5_gainwell.txt", text, "text/plain")},
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
# New response field from spec §4.
|
|
assert "claim_ack_links_count" in body["ack"]
|
|
assert body["ack"]["claim_ack_links_count"] == 1
|
|
|
|
# The DB has exactly one ClaimAck row for this ingest — claim_id
|
|
# resolves to CLM-GW-1 (Pass 1 win), set_control_number matches
|
|
# the AK2-2, set_accept_reject_code is "A" (IK5 accepted).
|
|
with db.SessionLocal()() as s:
|
|
rows = (
|
|
s.query(ClaimAck)
|
|
.filter(ClaimAck.ack_kind == "999")
|
|
.all()
|
|
)
|
|
assert len(rows) == 1
|
|
assert rows[0].claim_id == "CLM-GW-1"
|
|
assert rows[0].set_control_number == "991102989"
|
|
assert rows[0].set_accept_reject_code == "A"
|
|
assert rows[0].ak2_index == 0
|
|
assert rows[0].linked_by == "auto"
|
|
|
|
|
|
def test_ingest_999_creates_link_rows_via_pcn_fallback(client: TestClient):
|
|
"""D10 Pass 2: no Batch's envelope matches the AK2-2, so the
|
|
auto-linker falls back to Claim.patient_control_number."""
|
|
# Seed a Batch whose envelope control number does NOT match.
|
|
_seed_batch_envelope(batch_id="B-OTHER", envelope_control="999999999")
|
|
# Seed a Claim in some other batch whose PCN equals the 999's
|
|
# set_control_number ("0001" for the minimal accepted fixture).
|
|
_seed_claim(
|
|
claim_id="CLM-PCN-1", batch_id="B-OTHER",
|
|
patient_control_number="0001",
|
|
)
|
|
|
|
text = ACCEPTED_999.read_text()
|
|
resp = client.post(
|
|
"/api/parse-999",
|
|
files={"file": ("minimal_999.txt", text, "text/plain")},
|
|
headers={"Accept": "application/json"},
|
|
)
|
|
assert resp.status_code == 200, resp.text
|
|
body = resp.json()
|
|
assert body["ack"]["claim_ack_links_count"] == 1
|
|
|
|
with db.SessionLocal()() as s:
|
|
rows = (
|
|
s.query(ClaimAck)
|
|
.filter(ClaimAck.ack_kind == "999")
|
|
.all()
|
|
)
|
|
assert len(rows) == 1
|
|
assert rows[0].claim_id == "CLM-PCN-1"
|
|
assert rows[0].set_control_number == "0001" |