diff --git a/backend/src/cyclone/db.py b/backend/src/cyclone/db.py index f6ff401..59ba3b4 100644 --- a/backend/src/cyclone/db.py +++ b/backend/src/cyclone/db.py @@ -11,6 +11,7 @@ same engine. from __future__ import annotations import enum +import json import os from datetime import date, datetime from decimal import Decimal @@ -28,6 +29,7 @@ from sqlalchemy import ( String, Text, UniqueConstraint, + text, ) from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, sessionmaker from sqlalchemy.types import TypeDecorator @@ -141,15 +143,11 @@ class JSONText(TypeDecorator): def process_bind_param(self, value, dialect): # type: ignore[no-untyped-def] if value is None: return None - import json - return json.dumps(value) def process_result_value(self, value, dialect): # type: ignore[no-untyped-def] if value is None: return None - import json - return json.loads(value) @@ -172,8 +170,13 @@ class Batch(Base): ) __table_args__ = ( - Index("ix_batches_parsed_at", "parsed_at"), + # Migration is the source of truth: 0001_initial.sql declares this DESC. + # `text(...)` keeps the ORM in sync so fresh DBs built via `create_all` + # alone (no migration runner) get the same index direction as prod. + Index("ix_batches_parsed_at", text("parsed_at DESC")), ) + # TODO(T6): when ActivityEvent model is added, mirror this pattern for + # `ix_activity_events_ts` on activity_events(ts DESC) per 0001_initial.sql:86. class Claim(Base): @@ -196,7 +199,17 @@ class Claim(Base): Enum(ClaimState, native_enum=False), nullable=True ) matched_remittance_id: Mapped[Optional[str]] = mapped_column( - String(64), ForeignKey("remittances.id"), nullable=True + # ORM policy: SET NULL on remittance delete. The claim may outlive its + # remittance during reversal/reimport flows; without this, deleting a + # remittance with matched claims raises IntegrityError. + # Note: 0001_initial.sql predates this policy (no ON DELETE clause). + # SQLite ignores FK direction by default unless PRAGMA foreign_keys=ON, + # so the inconsistency is benign there; the ORM is the source of truth + # for PostgreSQL/test environments with FK enforcement. Amendment to the + # migration is deferred to post-SQLite rollout. + String(64), + ForeignKey("remittances.id", ondelete="SET NULL"), + nullable=True, ) raw_json: Mapped[Optional[dict]] = mapped_column(JSONText, nullable=True) diff --git a/backend/tests/test_db_models.py b/backend/tests/test_db_models.py index c1685d3..cd409d9 100644 --- a/backend/tests/test_db_models.py +++ b/backend/tests/test_db_models.py @@ -78,3 +78,80 @@ def test_create_and_query_claim_with_default_state(): 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, "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, "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 + assert s.get(Claim, "CLM-1") is None