Files
cyclone/backend/tests/test_db_models.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

389 lines
14 KiB
Python

"""Tests for ORM model definitions and CRUD."""
from __future__ import annotations
import enum
from datetime import date, datetime, timezone
from decimal import Decimal
import pytest
from cyclone import db
from cyclone.db import ActivityEvent, Base, Batch, CasAdjustment, Claim, ClaimState, Match, Remittance
@pytest.fixture(autouse=True)
def _setup(tmp_path, monkeypatch):
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
db._reset_for_tests()
db.init_db()
def test_claim_state_enum_values():
assert ClaimState.SUBMITTED.value == "submitted"
assert ClaimState.RECEIVED.value == "received"
assert ClaimState.PAID.value == "paid"
assert ClaimState.PARTIAL.value == "partial"
assert ClaimState.DENIED.value == "denied"
assert ClaimState.RECONCILED.value == "reconciled"
assert ClaimState.REVERSED.value == "reversed"
# 8 values total (REJECTED was added for 999 AK9 set-level R/E).
assert len(list(ClaimState)) == 8
def test_create_and_query_batch():
with db.SessionLocal()() as s:
b = Batch(
id="b1",
kind="837p",
input_filename="test.txt",
parsed_at=datetime(2026, 6, 19, 12, 0, tzinfo=timezone.utc),
totals_json={"total_claims": 1},
validation_json={"passed": True, "warnings": [], "errors": []},
raw_result_json={"_": "stub"},
)
s.add(b)
s.commit()
with db.SessionLocal()() as s:
loaded = s.get(Batch, "b1")
assert loaded is not None
assert loaded.kind == "837p"
assert loaded.totals_json == {"total_claims": 1}
def test_create_and_query_claim_with_default_state():
with db.SessionLocal()() as s:
s.add(Batch(
id="b1", kind="837p", input_filename="x",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
c = Claim(
id="CLM-1",
batch_id="b1",
patient_control_number="CLM-1",
service_date_from=date(2026, 6, 1),
service_date_to=date(2026, 6, 1),
charge_amount=Decimal("124.00"),
provider_npi="1234567890",
payer_id="SKCO0",
state=ClaimState.SUBMITTED,
)
s.add(c)
s.commit()
# Migration 0014: composite PK (batch_id, id). PK lookups use tuples.
with db.SessionLocal()() as s:
loaded = s.get(Claim, ("b1", "CLM-1"))
assert loaded is not None
assert loaded.state == ClaimState.SUBMITTED
assert loaded.charge_amount == Decimal("124.00")
assert loaded.service_date_from == date(2026, 6, 1)
def test_claim_uses_db_default_when_state_omitted():
"""The `state` column has a DB-side DEFAULT of 'submitted' (per migration
and ORM `default=`). When the caller omits `state=`, the loaded value
must be ClaimState.SUBMITTED, not None."""
with db.SessionLocal()() as s:
s.add(Batch(
id="b1", kind="837p", input_filename="x",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
c = Claim(
id="CLM-1",
batch_id="b1",
patient_control_number="CLM-1",
charge_amount=Decimal("0"),
)
s.add(c)
s.commit()
with db.SessionLocal()() as s:
loaded = s.get(Claim, ("b1", "CLM-1"))
assert loaded is not None
assert loaded.state == ClaimState.SUBMITTED
def test_state_before_reversal_round_trips():
"""`state_before_reversal` is nullable but stores a ClaimState enum.
Setting it to PAID must round-trip as ClaimState.PAID after commit+reload."""
with db.SessionLocal()() as s:
s.add(Batch(
id="b1", kind="837p", input_filename="x",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
c = Claim(
id="CLM-1",
batch_id="b1",
patient_control_number="CLM-1",
charge_amount=Decimal("50.00"),
state_before_reversal=ClaimState.PAID,
)
s.add(c)
s.commit()
with db.SessionLocal()() as s:
loaded = s.get(Claim, ("b1", "CLM-1"))
assert loaded is not None
assert loaded.state_before_reversal == ClaimState.PAID
def test_batch_cascade_delete_drops_claims():
"""Deleting a Batch must cascade-delete its claims (FK is ON DELETE CASCADE
in migration; ORM relationship uses cascade='all, delete-orphan' which makes
SQLAlchemy emit the child DELETE at flush time even without
PRAGMA foreign_keys=ON)."""
with db.SessionLocal()() as s:
s.add(Batch(
id="b1", kind="837p", input_filename="x",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
s.add(Claim(
id="CLM-1",
batch_id="b1",
patient_control_number="CLM-1",
charge_amount=Decimal("0"),
))
s.commit()
with db.SessionLocal()() as s:
batch = s.get(Batch, "b1")
assert batch is not None
s.delete(batch)
s.commit()
with db.SessionLocal()() as s:
assert s.get(Batch, "b1") is None
# Composite PK lookup — both batch_id AND id are required.
assert s.get(Claim, ("b1", "CLM-1")) is None
def test_create_and_query_remittance():
with db.SessionLocal()() as s:
s.add(Batch(
id="b1", kind="835", input_filename="x",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
r = Remittance(
id="CLP-1",
batch_id="b1",
payer_claim_control_number="CLP-1",
status_code="1",
total_charge=Decimal("124.00"),
total_paid=Decimal("124.00"),
received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
service_date=date(2026, 6, 1),
is_reversal=False,
)
s.add(r)
s.commit()
with db.SessionLocal()() as s:
loaded = s.get(Remittance, ("b1", "CLP-1"))
assert loaded is not None
assert loaded.status_code == "1"
assert loaded.total_paid == Decimal("124.00")
assert loaded.is_reversal is False
assert loaded.adjustment_amount == Decimal("0") # default
def test_cas_adjustment_aggregation():
"""Sum of CAS rows for a Remittance equals expected total."""
with db.SessionLocal()() as s:
s.add(Batch(
id="b1", kind="835", input_filename="x",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
r = Remittance(
id="CLP-1", batch_id="b1",
payer_claim_control_number="CLP-1", status_code="1",
total_charge=Decimal("124.00"), total_paid=Decimal("62.00"),
received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
)
s.add(r)
s.flush()
# Migration 0014: remittance_batch_id is required (NOT NULL) on
# CasAdjustment; it mirrors remittance_id's batch.
s.add_all([
CasAdjustment(remittance_id="CLP-1", remittance_batch_id="b1",
group_code="CO", reason_code="45",
amount=Decimal("30.00")),
CasAdjustment(remittance_id="CLP-1", remittance_batch_id="b1",
group_code="PR", reason_code="1",
amount=Decimal("32.00")),
])
s.commit()
from sqlalchemy import select, func
with db.SessionLocal()() as s:
total = s.execute(
select(func.sum(CasAdjustment.amount))
.where(CasAdjustment.remittance_id == "CLP-1")
).scalar_one()
assert total == Decimal("62.00")
def test_remittance_raw_json_round_trips_dict():
"""JSONText on Remittance.raw_json round-trips a dict, not a string."""
with db.SessionLocal()() as s:
s.add(Batch(
id="b1", kind="835", input_filename="x",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
r = Remittance(
id="CLP-1", batch_id="b1", payer_claim_control_number="CLP-1",
status_code="1",
received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
raw_json={"x12_segments": ["CLP", "NM1", "SVC"], "version": "005010X221A1"},
)
s.add(r)
s.commit()
with db.SessionLocal()() as s:
loaded = s.get(Remittance, ("b1", "CLP-1"))
assert loaded.raw_json == {"x12_segments": ["CLP", "NM1", "SVC"], "version": "005010X221A1"}
def test_remittance_allows_duplicate_pcn_in_same_batch():
"""An 835 ERA can carry multiple CLP segments with the same
payer_claim_control_number (e.g. reversals re-referencing the
original PCN). Remittance identity is by the composite PK
(batch_id, id) — id (CLP01) distinguishes rows in the same batch.
This test documents the absence of the (batch_id, PCN) constraint
removed in migration 0003.
"""
with db.SessionLocal()() as s:
s.add(Batch(
id="b1", kind="835", input_filename="x",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
s.add(Remittance(
id="CLP-1", batch_id="b1", payer_claim_control_number="SAME",
status_code="1",
received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
s.commit()
with db.SessionLocal()() as s:
s.add(Remittance(
id="CLP-2", batch_id="b1", payer_claim_control_number="SAME",
status_code="2",
received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
s.commit() # no IntegrityError — duplicates are allowed
with db.SessionLocal()() as s:
rows = s.query(Remittance).filter(Remittance.batch_id == "b1").all()
assert {r.id for r in rows} == {"CLP-1", "CLP-2"}
assert {r.payer_claim_control_number for r in rows} == {"SAME"}
def test_remittance_cascade_delete_drops_cas_adjustments():
"""Deleting a Remittance drops its CAS rows via ORM cascade."""
with db.SessionLocal()() as s:
s.add(Batch(
id="b1", kind="835", input_filename="x",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
r = Remittance(
id="CLP-1", batch_id="b1", payer_claim_control_number="CLP-1",
status_code="1", total_paid=Decimal("62.00"),
received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
)
s.add(r)
s.flush()
s.add(CasAdjustment(remittance_id="CLP-1", remittance_batch_id="b1",
group_code="CO", reason_code="45",
amount=Decimal("30.00")))
s.commit()
with db.SessionLocal()() as s:
r = s.get(Remittance, ("b1", "CLP-1"))
s.delete(r)
s.commit()
with db.SessionLocal()() as s:
from sqlalchemy import select
cas = s.execute(
select(CasAdjustment).where(CasAdjustment.remittance_id == "CLP-1")
).all()
assert len(cas) == 0
def test_create_match_and_activity_event():
with db.SessionLocal()() as s:
s.add(Batch(
id="b1", kind="835", input_filename="x",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
s.add(Claim(
id="CLM-1", batch_id="b1", patient_control_number="CLM-1",
charge_amount=Decimal("124.00"), state=ClaimState.SUBMITTED,
))
s.add(Remittance(
id="CLP-1", batch_id="b1", payer_claim_control_number="CLM-1",
status_code="1", total_charge=Decimal("124.00"), total_paid=Decimal("124.00"),
received_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
# Migration 0014: Match needs batch_id + remittance_batch_id (NOT NULL).
m = Match(
claim_id="CLM-1", batch_id="b1",
remittance_id="CLP-1", remittance_batch_id="b1",
strategy="auto", is_reversal=False,
matched_at=datetime(2026, 6, 19, 13, 0, tzinfo=timezone.utc),
)
s.add(m)
s.flush()
s.add(ActivityEvent(
ts=datetime(2026, 6, 19, 13, 0, tzinfo=timezone.utc),
kind="reconcile", batch_id="b1", claim_id="CLM-1",
payload_json={"matched": 1},
))
s.commit()
with db.SessionLocal()() as s:
loaded = s.get(Match, 1) # autoincrement id
assert loaded is not None
assert loaded.strategy == "auto"
assert loaded.is_reversal is False
events = s.query(ActivityEvent).all()
assert len(events) == 1
assert events[0].kind == "reconcile"
assert events[0].payload_json == {"matched": 1}
def test_match_multiple_rows_per_claim():
"""Spec-bug fix (T10): a Claim can have multiple Match rows
(original payment + reversal = audit trail). UNIQUE on claim_id
was removed; the constraint test that previously lived here
asserted the old, incorrect design.
"""
with db.SessionLocal()() as s:
s.add(Batch(
id="b1", kind="835", input_filename="x",
parsed_at=datetime(2026, 6, 19, tzinfo=timezone.utc),
))
s.add(Claim(id="CLM-1", batch_id="b1", patient_control_number="CLM-1",
charge_amount=Decimal("0"), state=ClaimState.SUBMITTED))
s.add(Remittance(id="CLP-1", batch_id="b1", payer_claim_control_number="x",
status_code="1", received_at=datetime.now(timezone.utc)))
s.add(Remittance(id="CLP-2", batch_id="b1", payer_claim_control_number="y",
status_code="1", received_at=datetime.now(timezone.utc)))
# Migration 0014: batch_id + remittance_batch_id required on Match.
s.add(Match(claim_id="CLM-1", batch_id="b1",
remittance_id="CLP-1", remittance_batch_id="b1",
strategy="auto", matched_at=datetime.now(timezone.utc)))
s.add(Match(claim_id="CLM-1", batch_id="b1",
remittance_id="CLP-2", remittance_batch_id="b1",
strategy="manual",
matched_at=datetime.now(timezone.utc),
is_reversal=True, prior_claim_state=ClaimState.PAID))
# No IntegrityError — two Match rows per claim are now allowed.
s.commit()
rows = s.query(Match).filter(Match.claim_id == "CLM-1").all()
assert len(rows) == 2
assert {r.remittance_id for r in rows} == {"CLP-1", "CLP-2"}