Files
cyclone/backend/tests/test_inbox_state.py
T
Tyler 534130ee2b feat(db): migration 0014 relaxes claims/remittances PK to (batch_id, id)
Migration 0014 changes the PRIMARY KEYs of claims and remittances from
single-column id to composite (batch_id, id). Enables the spec'd
cross-batch CLM01 / CLP01 collision workflow: same CLM01 in multiple
batches is now representable (resubmits), pre-flight dedup 409 path is
genuinely exercisable, force-insert can skip pre-existing duplicates.

Strategy: PRAGMA defer_foreign_keys = ON + table recreation for every
table whose FKs pointed at the old single-column PK. Tables recreated:
remittances, claims, matches, cas_adjustments, service_line_payments,
line_reconciliations. Each child table gains a batch_id (or
remittance_batch_id) column for the composite FK side; INSERT INTO new
SELECT FROM old JOINs populate it from the already-recreated parent.

Cross-table FKs (remittances.claim_id, claims.matched_remittance_id)
cannot be SQL-enforced with composite PKs (SQLite has no ALTER
CONSTRAINT). Dropped at SQL level; enforced via application-layer
invariants in store.manual_match / manual_unmatch / reconcile.run and
the dedup.preflight_* helpers.

ORM updates (db.py):
- Claim / Remittance: composite PK via explicit PrimaryKeyConstraint in
  __table_args__ (column order matches SQL: batch_id, id).
- New Claim.matched_remittance_batch_id column.
- Match / CasAdjustment / ServiceLinePayment / LineReconciliation:
  added the batch side of their composite FK to the parent table.
- SQLAlchemy before_insert events auto-populate the batch side of the
  composite FK from session.new, then identity_map, then SQL fallback.

Production code updates:
- store.manual_match / manual_unmatch: also write
  matched_remittance_batch_id on the claim (was missing).
- api.py manual-match endpoint: same fix.
- reconcile.run: same fix for auto-matched pairs.

Test updates: replaced s.get(Claim, X) with the composite key
(batch_id, id) where batch_id is known, or s.query().filter().first()
where the test only knows the id. Tests that previously inserted a Match
row pointing at a non-existent Remittance now seed the parent Remittance
so the new NOT NULL composite FK is satisfied.
2026-06-21 18:56:18 -06:00

171 lines
5.4 KiB
Python

"""TDD: apply_999_rejections moves claims to REJECTED on 999 AK5 R/E.
Plan: docs/superpowers/plans/2026-06-20-cyclone-workflow-automation.md
Task 3 (T3).
"""
from __future__ import annotations
from datetime import date, datetime, timezone
import pytest
from cyclone import db
from cyclone.db import Batch, Claim, ClaimState
from cyclone.inbox_state import apply_999_rejections
from cyclone.parsers.models import BatchSummary, Envelope
from cyclone.parsers.models_999 import (
AcknowledgmentHeader,
FunctionalGroupAck,
ParseResult999,
SetAcceptReject,
SetFunctionalGroupResponse,
)
@pytest.fixture(autouse=True)
def _setup(tmp_path, monkeypatch):
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/inbox.db")
db._reset_for_tests()
db.init_db()
def _seed_batch() -> None:
with db.SessionLocal()() as s:
s.add(Batch(
id="B-1", kind="837p", input_filename="x.txt",
parsed_at=datetime.now(timezone.utc),
totals_json={"total_claims": 1},
validation_json={"passed": True, "warnings": [], "errors": []},
raw_result_json={"_": "stub"},
))
s.commit()
def _add_claim(*, claim_id: str, pcn: str, state: ClaimState = ClaimState.SUBMITTED) -> None:
_seed_batch()
with db.SessionLocal()() as s:
s.add(Claim(
id=claim_id, batch_id="B-1",
patient_control_number=pcn, state=state,
))
s.commit()
def _make_999(*, claim_refs: list[tuple[str, str]]) -> ParseResult999:
"""claim_refs: list of (set_control_number, accept_code)."""
set_responses = [
SetFunctionalGroupResponse(
ak2=AcknowledgmentHeader(
functional_id_code="837", group_control_number=ref,
),
set_control_number=ref,
transaction_set_identifier="837",
segment_errors=[],
set_accept_reject=SetAcceptReject(code=code), # type: ignore[arg-type]
)
for ref, code in claim_refs
]
return ParseResult999(
envelope=Envelope(
sender_id="RECEIVER", receiver_id="SUBMITTER",
control_number="000000001", transaction_date=date(2024, 1, 1),
implementation_guide="005010X231A1",
),
functional_group_acks=[
FunctionalGroupAck(
ak1=AcknowledgmentHeader(functional_id_code="HC", group_control_number="0001"),
received_count=len(set_responses), accepted_count=0,
rejected_count=len(set_responses), ack_code="R",
),
],
set_responses=set_responses,
summary=BatchSummary(
input_file="x.txt", total_claims=len(set_responses),
passed=0, failed=len(set_responses),
),
)
def _lookup_factory(session):
"""Build a claim_lookup closure that uses the given session.
The session must be the same one passed to apply_999_rejections so
that the returned Claim is attached to that session's transaction.
"""
def _lookup(pcn: str):
return session.query(Claim).filter_by(patient_control_number=pcn).first()
return _lookup
def test_rejection_moves_submitted_claim_to_rejected_state():
_add_claim(claim_id="CLP-1", pcn="PCN-001")
with db.SessionLocal()() as s:
result = apply_999_rejections(
s,
_make_999(claim_refs=[("PCN-001", "R")]),
claim_lookup=_lookup_factory(s),
)
s.commit()
assert result.matched == ["CLP-1"]
assert result.orphans == []
with db.SessionLocal()() as s:
# Migration 0014: composite PK — claim is in batch "B-1"; use
# filter-by-id since the test has only one claim with this id.
c = s.query(Claim).filter(Claim.id == "CLP-1").first()
assert c.state == ClaimState.REJECTED
assert c.rejected_at is not None
assert "999" in (c.rejection_reason or "")
def test_accepted_set_leaves_claim_alone():
_add_claim(claim_id="CLP-1", pcn="PCN-002")
with db.SessionLocal()() as s:
result = apply_999_rejections(
s,
_make_999(claim_refs=[("PCN-002", "A")]),
claim_lookup=_lookup_factory(s),
)
s.commit()
assert result.matched == []
with db.SessionLocal()() as s:
# Migration 0014: composite PK — see test_rejection_moves...
c = s.query(Claim).filter(Claim.id == "CLP-1").first()
assert c.state == ClaimState.SUBMITTED
assert c.rejected_at is None
assert c.rejection_reason is None
def test_unknown_pcn_recorded_as_orphan_not_error():
with db.SessionLocal()() as s:
result = apply_999_rejections(
s,
_make_999(claim_refs=[("PCN-NONEXISTENT", "R")]),
claim_lookup=_lookup_factory(s),
)
s.commit()
assert result.matched == []
assert result.orphans == ["PCN-NONEXISTENT"]
def test_already_rejected_claim_is_idempotent():
_add_claim(claim_id="CLP-1", pcn="PCN-003", state=ClaimState.REJECTED)
with db.SessionLocal()() as s:
result = apply_999_rejections(
s,
_make_999(claim_refs=[("PCN-003", "R")]),
claim_lookup=_lookup_factory(s),
)
s.commit()
assert result.matched == [] # no-op
with db.SessionLocal()() as s:
# Migration 0014: composite PK — see test_rejection_moves...
c = s.query(Claim).filter(Claim.id == "CLP-1").first()
assert c.state == ClaimState.REJECTED