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

77 lines
2.4 KiB
Python

"""TDD: 999 ingest endpoint moves claims to REJECTED on AK5 R/E.
Plan: docs/superpowers/plans/2026-06-20-cyclone-workflow-automation.md
Task 4 (T4). Wires apply_999_rejections into the /api/parse-999 endpoint.
"""
from __future__ import annotations
import io
from datetime import datetime, timezone
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, ClaimState
@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()
yield
@pytest.fixture
def client():
with TestClient(app) as c:
yield c
def _seed_claim(*, claim_id: str, patient_control_number: str) -> 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.add(Claim(
id=claim_id, batch_id="B-1",
patient_control_number=patient_control_number,
state=ClaimState.SUBMITTED,
))
s.commit()
def test_999_set_rejection_moves_claim_to_rejected_state(client: TestClient):
# The seeded claim's patient_control_number is the AK202 (set_control_number)
# of the 999 we post.
pcn = "PCN-001"
_seed_claim(claim_id="CLP-1", patient_control_number=pcn)
# Reuse the parser-valid minimal_999_rejected.txt fixture, then rewrite
# AK202 (the set_control_number) and the SE* count + ST02 control number
# so the parser accepts the modified document and we get an R rejection
# keyed to our seeded PCN.
fixture = (Path(__file__).parent / "fixtures" / "minimal_999_rejected.txt").read_text()
raw = fixture.replace("AK2*837*0001~", f"AK2*837*{pcn}~")
r = client.post(
"/api/parse-999",
files={"file": ("sample.999", io.BytesIO(raw.encode()), "text/plain")},
)
assert r.status_code == 200, r.text
with db.SessionLocal()() as s:
# Migration 0014: composite PK (batch_id, id).
c = s.get(Claim, ("B-1", "CLP-1"))
assert c.state == ClaimState.REJECTED
assert c.rejected_at is not None
assert "999" in (c.rejection_reason or "")