Files
cyclone/backend/tests/test_apply_claim_ack_links.py
T
Nora a97f1d1350 feat(sp28): apply_claim_ack_links orchestrator + 999/277CA/TA1 helpers
The auto-linker closes the operator gap where every inbound 999 /
277CA / TA1 ack was persisted but never linked back to the claim it
acknowledges. Five pure helpers land in cyclone/claim_acks.py:

- lookup_claims_for_ack_set_response — D10 two-pass join. Primary is
  Batch.envelope.control_number (== source 837 ST02 for Gainwell
  batches); fallback is Claim.patient_control_number. Pass 1 wins,
  the two paths cannot both fire.
- apply_999_acceptances — walks parsed_999.set_responses, emits one
  ClaimAck per AK2 per matched claim (one-ack-to-many supported).
  Both accepted AND rejected AK2s link; per-AK2 granularity.
- apply_277ca_acks — same shape for parsed_277ca.claim_statuses.
  STC category code carried on the link row's
  set_accept_reject_code so the UI can render the lane inline
  without re-parsing raw_json.
- apply_ta1_envelope_link — envelope-level link. The link row has
  claim_id NULL + batch_id populated (the spec's batch-level TA1
  trace). Sender/receiver matching is delegated to a closure the
  caller supplies.
- link_manual — manually link an ack to a claim. Used by the new
  /api/acks/{kind}/{id}/match-claim endpoint. Idempotent.

All five helpers are pure (callers own the session); idempotent via
the partial unique index ux_claim_acks_dedup (helpers pre-check to
avoid IntegrityError log noise on re-ingest); flush-only (callers
commit).

14 of 15 named tests pass (the facade surface check belongs in
Phase 3 once store/claim_acks.py lands).

Steps 2.1/2.2/2.3/2.4/2.5 of the SP28 implementation plan.
2026-07-02 11:29:02 -06:00

788 lines
28 KiB
Python

"""Tests for :mod:`cyclone.claim_acks` (SP28 auto-linker).
Two flavours:
* Pure-unit tests (no DB) for the walk-through logic of
``apply_999_acceptances`` etc. via stubs.
* Integration tests against the test DB (autouse conftest) that
exercise ``lookup_claims_for_ack_set_response``'s two-pass D10 join,
per-AK2 granularity, idempotent re-ingest, and the manual link.
The 8 named tests from spec §6 are here as
``test_999_*`` / ``test_277ca_*`` / ``test_ta1_*`` /
``test_reingest_*`` / ``test_manual_link_*`` /
``test_unlink_does_not_revert_claim_state``.
Additional tests cover the D10 two-pass join (Step 2.5 of the plan)
and the store facade wiring.
"""
from __future__ import annotations
from datetime import date, datetime, timezone
from decimal import Decimal
import pytest
from cyclone import claim_acks as ca
from cyclone import db
from cyclone.claim_acks import (
ClaimAckLinkResult,
apply_277ca_acks,
apply_999_acceptances,
apply_ta1_envelope_link,
link_manual,
lookup_claims_for_ack_set_response,
)
from cyclone.db import (
Batch,
Claim,
ClaimState,
ClaimAck,
)
from cyclone.parsers.models import (
BatchSummary,
ClaimHeader,
Envelope,
ParseResult,
)
from cyclone.parsers.models_277ca import (
AcknowledgmentHeader,
ClaimStatus,
ParseResult277CA,
)
from cyclone.parsers.models_999 import (
AcknowledgmentHeader as AckHeader999,
ParseResult999,
SetAcceptReject,
SetFunctionalGroupResponse,
)
from cyclone.parsers.models_ta1 import ParseResultTa1, Ta1Ack
from cyclone.store import store
# ---------------------------------------------------------------------------
# Fixture seeds
# ---------------------------------------------------------------------------
def _seed_batch(
session,
*,
batch_id: str = "B1",
envelope_control: str = "991102989",
sender_id: str = "SENDER",
receiver_id: str = "RECEIVER",
):
"""Insert one Batch row whose raw_result_json envelope carries
``envelope_control`` so D10 Pass 1 can resolve it via
``batch_envelope_index``."""
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": sender_id,
"receiver_id": receiver_id,
"control_number": envelope_control,
"transaction_date": "2026-07-02",
},
},
)
session.add(b)
session.commit()
session.refresh(b)
return b
def _seed_claim(
session,
*,
claim_id: str,
batch_id: str,
patient_control_number: str | None = None,
state: ClaimState = ClaimState.SUBMITTED,
):
c = Claim(
id=claim_id,
batch_id=batch_id,
patient_control_number=patient_control_number or claim_id,
charge_amount=Decimal("100.00"),
state=state,
)
session.add(c)
session.commit()
session.refresh(c)
return c
def _seed_ack_link_index(*, envelope_control_to_batch: dict[str, str]):
"""Build a closure compatible with ``lookup_claims_for_ack_set_response``."""
def _lookup(scn: str) -> str | None:
return envelope_control_to_batch.get(scn)
return _lookup
def _parse_999_two_ak2s() -> ParseResult999:
"""Two-AK2 999 — one accepted, one rejected."""
sr_accepted = SetFunctionalGroupResponse(
ak2=AckHeader999(functional_id_code="837", group_control_number="991102989"),
set_control_number="991102989",
transaction_set_identifier="837",
segment_errors=[],
set_accept_reject=SetAcceptReject(code="A"),
)
sr_rejected = SetFunctionalGroupResponse(
ak2=AckHeader999(functional_id_code="837", group_control_number="991102990"),
set_control_number="991102990",
transaction_set_identifier="837",
segment_errors=[],
set_accept_reject=SetAcceptReject(code="R"),
)
return ParseResult999(
envelope=Envelope(
sender_id="PAYER", receiver_id="SUBMITTER",
control_number="000000001", transaction_date=date(2026, 7, 2),
),
functional_group_acks=[],
set_responses=[sr_accepted, sr_rejected],
summary=BatchSummary(
input_file="two_ak2.txt", control_number="000000001",
transaction_date=date(2026, 7, 2),
total_claims=2, passed=1, failed=1,
),
)
def _parse_277ca_one_accepted() -> ParseResult277CA:
return ParseResult277CA(
envelope=Envelope(
sender_id="PAYER", receiver_id="SUBMITTER",
control_number="000000077", transaction_date=date(2026, 7, 2),
),
bht=AcknowledgmentHeader(
hierarchical_structure_code="0085",
transaction_set_purpose_code="08",
reference_identification="REFNUM001",
),
summary=BatchSummary(
input_file="277_one_accepted.txt", control_number="000000077",
transaction_date=date(2026, 7, 2),
total_claims=1, passed=1, failed=0,
),
claim_statuses=[
ClaimStatus(
status_code="A3:19:PR",
classification="accepted",
payer_claim_control_number="CLAIM001",
),
],
)
def _parse_277ca_one_rejected() -> ParseResult277CA:
return ParseResult277CA(
envelope=Envelope(
sender_id="PAYER", receiver_id="SUBMITTER",
control_number="000000078", transaction_date=date(2026, 7, 2),
),
bht=AcknowledgmentHeader(
hierarchical_structure_code="0085",
transaction_set_purpose_code="08",
reference_identification="REFNUM002",
),
summary=BatchSummary(
input_file="277_one_rejected.txt", control_number="000000078",
transaction_date=date(2026, 7, 2),
total_claims=1, passed=0, failed=1,
),
claim_statuses=[
ClaimStatus(
status_code="A6:19:PR",
classification="rejected",
payer_claim_control_number="CLAIM002",
),
],
)
def _parse_ta1() -> ParseResultTa1:
return ParseResultTa1(
envelope=Envelope(
sender_id="RECEIVER", receiver_id="SENDER",
control_number="000000001", transaction_date=date(2026, 7, 2),
),
ta1=Ta1Ack(
control_number="000000001",
interchange_date=date(2026, 7, 2),
interchange_time="1200",
ack_code="A",
note_code="000",
ack_generated_date=date(2026, 7, 2),
),
source_batch_id="TA1-000000001",
)
# ---------------------------------------------------------------------------
# Spec §6 tests
# ---------------------------------------------------------------------------
def test_999_auto_creates_per_ak2_link_rows():
"""Step 2.5 (named after spec §6).
Two AK2s in one 999 — one accepted, one rejected. They reference
two distinct batches (one claim per batch), so per-AK2 granularity
produces two link rows with two different set_accept_reject codes.
"""
with db.SessionLocal()() as s:
_seed_batch(s, batch_id="B-999A", envelope_control="991102989")
_seed_claim(s, claim_id="CLM-A", batch_id="B-999A",
patient_control_number="t991102989o1cA")
_seed_batch(s, batch_id="B-999B", envelope_control="991102990")
_seed_claim(s, claim_id="CLM-B", batch_id="B-999B",
patient_control_number="t991102990o1cB")
# Index keyed by set_control_number -> batch_id (D10 batch
# envelope index).
idx = _seed_ack_link_index(envelope_control_to_batch={
"991102989": "B-999A",
"991102990": "B-999B",
})
parsed = _parse_999_two_ak2s()
out = apply_999_acceptances(s, parsed, ack_id=42,
batch_envelope_index=idx)
s.commit()
assert sorted(out.linked) == sorted([("CLM-A", 0), ("CLM-B", 1)])
assert out.orphans == []
with db.SessionLocal()() as s:
rows = (
s.query(ClaimAck)
.filter(ClaimAck.ack_kind == "999", ClaimAck.ack_id == 42)
.order_by(ClaimAck.id.asc())
.all()
)
assert len(rows) == 2
codes = {r.set_accept_reject_code for r in rows}
assert codes == {"A", "R"}
assert all(r.linked_by == "auto" for r in rows)
def test_999_orphan_does_not_create_link():
"""An AK2 whose set_control_number doesn't resolve stays orphan."""
with db.SessionLocal()() as s:
_seed_batch(s, batch_id="B-999-ORPHAN", envelope_control="0001")
parsed = _parse_999_two_ak2s()
idx = _seed_ack_link_index(envelope_control_to_batch={})
out = apply_999_acceptances(s, parsed, ack_id=99,
batch_envelope_index=idx)
s.commit()
assert out.linked == []
assert sorted(out.orphans) == ["991102989", "991102990"]
with db.SessionLocal()() as s:
n = s.query(ClaimAck).filter(ClaimAck.ack_kind == "999").count()
assert n == 0
def test_277ca_accepted_creates_link_with_no_state_mutation():
"""Accepted 277CA → link row, no payer_rejected stamp."""
with db.SessionLocal()() as s:
_seed_batch(s, batch_id="B-277A", envelope_control="991102989")
claim = _seed_claim(s, claim_id="CLM-Z", batch_id="B-277A",
patient_control_number="CLAIM001")
idx = _seed_ack_link_index(envelope_control_to_batch={})
parsed = _parse_277ca_one_accepted()
def _pcn_lookup(pcn: str):
return (s.query(Claim)
.filter_by(patient_control_number=pcn)
.first())
out = apply_277ca_acks(s, parsed, ack_id=7,
batch_envelope_index=idx,
pc_claim_lookup=_pcn_lookup)
s.commit()
assert out.linked == [("CLM-Z", None)]
with db.SessionLocal()() as s:
c = s.get(Claim, "CLM-Z")
assert c.payer_rejected_at is None
link = s.query(ClaimAck).filter_by(claim_id="CLM-Z").one()
assert link.set_accept_reject_code.startswith("A3")
assert link.linked_by == "auto"
def test_277ca_rejected_creates_link_and_mutates_state():
"""Rejected 277CA → link row + payer_rejected_at stamp (mirrors
existing apply_277ca_rejections test)."""
from cyclone.inbox_state_277ca import apply_277ca_rejections
with db.SessionLocal()() as s:
_seed_batch(s, batch_id="B-277R", envelope_control="991102989")
_seed_claim(s, claim_id="CLM-Y", batch_id="B-277R",
patient_control_number="CLAIM002")
idx = _seed_ack_link_index(envelope_control_to_batch={})
parsed = _parse_277ca_one_rejected()
# First, run the existing rejection mutator (it owns
# payer_rejected_at + state). Then run the auto-linker.
apply_277ca_rejections(
s, parsed,
claim_lookup=lambda pcn: (s.query(Claim)
.filter_by(patient_control_number=pcn)
.first()),
two77ca_id=7,
)
def _pcn_lookup(pcn: str):
return (s.query(Claim)
.filter_by(patient_control_number=pcn)
.first())
out = apply_277ca_acks(s, parsed, ack_id=7,
batch_envelope_index=idx,
pc_claim_lookup=_pcn_lookup)
s.commit()
assert out.linked == [("CLM-Y", None)]
with db.SessionLocal()() as s:
c = s.get(Claim, "CLM-Y")
assert c.payer_rejected_at is not None
assert c.payer_rejected_status_code and c.payer_rejected_status_code.startswith("A6")
link = s.query(ClaimAck).filter_by(claim_id="CLM-Y").one()
assert link.set_accept_reject_code.startswith("A6")
def test_ta1_links_to_most_recent_matching_batch():
"""TA1's batch_lookup closure picks the most-recent batch whose
envelope.sender_id/receiver_id matches the TA1 envelope."""
ta1 = _parse_ta1()
# TA1 carries the swapped ISA sender/receiver (the receiving side
# is sending the ACK back to the original submitter).
with db.SessionLocal()() as s:
# Two older batches whose envelope sender/receiver do NOT
# match the TA1.
_seed_batch(s, batch_id="T1-OLD", envelope_control="0001",
sender_id="SENDER", receiver_id="RECEIVER")
_seed_batch(s, batch_id="T2-NEW", envelope_control="0002",
sender_id="SENDER", receiver_id="RECEIVER")
# The matching batch has the swapped sender/receiver; ordered
# by parsed_at DESC and prefixed T3-MATCH so the most-recent
# one wins.
_seed_batch(s, batch_id="T3-MATCH", envelope_control="0003",
sender_id="RECEIVER", receiver_id="SENDER")
def _batch_lookup(sender_id, receiver_id):
rows = (
s.query(Batch)
.filter(Batch.kind == "837")
.order_by(Batch.parsed_at.desc())
.all()
)
for row in rows:
env = (row.raw_result_json or {}).get("envelope") or {}
if (
env.get("sender_id") == sender_id
and env.get("receiver_id") == receiver_id
):
return row
return None
out = apply_ta1_envelope_link(s, ta1, ack_id=11,
batch_lookup=_batch_lookup)
s.commit()
assert out.linked == [(None, None)]
with db.SessionLocal()() as s:
link = (
s.query(ClaimAck)
.filter_by(ack_kind="ta1", ack_id=11)
.one()
)
assert link.claim_id is None
assert link.batch_id == "T3-MATCH"
assert link.set_accept_reject_code == "A"
def test_reingest_same_999_is_idempotent():
"""Submitting the same 999 twice produces exactly one ClaimAck
row per AK2 (the partial unique index enforces this)."""
with db.SessionLocal()() as s:
_seed_batch(s, batch_id="B-IDEMP", envelope_control="991102989")
_seed_claim(s, claim_id="CLM-I", batch_id="B-IDEMP",
patient_control_number="t991102989o1cI")
idx = _seed_ack_link_index(envelope_control_to_batch={
"991102989": "B-IDEMP",
"991102990": "B-IDEMP",
})
parsed = _parse_999_two_ak2s()
out1 = apply_999_acceptances(s, parsed, ack_id=101,
batch_envelope_index=idx)
s.commit()
with db.SessionLocal()() as s:
out2 = apply_999_acceptances(s, parsed, ack_id=101,
batch_envelope_index=idx)
s.commit()
# Each call returns the freshly linked rows. Second call finds
# existing rows so linked is empty.
assert len(out1.linked) == 2
assert out2.linked == [] # idempotent — no duplicate rows
with db.SessionLocal()() as s:
n = s.query(ClaimAck).filter_by(ack_id=101).count()
assert n == 2
def test_manual_link_endpoint_idempotent():
"""link_manual returns the existing row when called twice."""
with db.SessionLocal()() as s:
_seed_batch(s, batch_id="B-MAN", envelope_control="991102989")
_seed_claim(s, claim_id="CLM-M", batch_id="B-MAN")
row1, created1 = link_manual(s, claim_id="CLM-M", ack_kind="999",
ack_id=200)
s.commit()
with db.SessionLocal()() as s:
row2, created2 = link_manual(s, claim_id="CLM-M", ack_kind="999",
ack_id=200)
s.commit()
assert created1 is True
assert created2 is False
assert row1.id == row2.id
def test_manual_link_any_user_succeeds():
"""Manual link does NOT raise (any-logged-in user posture per D5/D9)."""
# The helper itself is identity-independent; the auth posture is
# enforced at the API layer. Here we assert the helper runs to
# completion (no permission check) when called by any caller.
with db.SessionLocal()() as s:
_seed_batch(s, batch_id="B-AUTH", envelope_control="991102989")
_seed_claim(s, claim_id="CLM-N", batch_id="B-AUTH")
row, created = link_manual(s, claim_id="CLM-N", ack_kind="999",
ack_id=300)
s.commit()
assert created is True
assert row.linked_by == "manual"
def test_manual_link_rejects_terminal_claim():
"""A claim in state=REVERSED should NOT be link-able (the API
layer maps that to 409; the helper itself lets the API pick the
claim up and surfaces the state). Here we only assert that
``link_manual`` does NOT raise — the API is responsible for the
409 decision via :class:`cyclone.db.ClaimState`."""
with db.SessionLocal()() as s:
_seed_batch(s, batch_id="B-TERM", envelope_control="991102989")
claim = _seed_claim(
s, claim_id="CLM-R", batch_id="B-TERM",
state=ClaimState.REVERSED,
)
# Helper itself does no state check — the API maps state to
# 409. We just exercise the helper so it's clear the door is
# open; the API test asserts the 409.
row, created = link_manual(s, claim_id="CLM-R", ack_kind="999",
ack_id=400)
s.commit()
assert created is True
assert row.claim_id == "CLM-R"
def test_unlink_does_not_revert_claim_state():
"""After unlink via remove_claim_ack, the claim retains its state
— unlinking only removes the link row, not the state mutation
from the original 999/277CA."""
with db.SessionLocal()() as s:
_seed_batch(s, batch_id="B-UL", envelope_control="991102989")
_seed_claim(s, claim_id="CLM-UL", batch_id="B-UL",
state=ClaimState.REJECTED)
# Only the first AK2 resolves (single batch, single claim);
# the second becomes an orphan.
idx = _seed_ack_link_index(envelope_control_to_batch={
"991102989": "B-UL",
})
# Submit a 999 with one AK2 so we get one link row.
sr = SetFunctionalGroupResponse(
ak2=AckHeader999(functional_id_code="837",
group_control_number="991102989"),
set_control_number="991102989",
transaction_set_identifier="837",
segment_errors=[],
set_accept_reject=SetAcceptReject(code="R"),
)
parsed = ParseResult999(
envelope=Envelope(
sender_id="P", receiver_id="S",
control_number="0001", transaction_date=date(2026, 7, 2),
),
functional_group_acks=[],
set_responses=[sr],
summary=BatchSummary(
input_file="one_ak2.txt", control_number="0001",
transaction_date=date(2026, 7, 2),
total_claims=1, passed=0, failed=1,
),
)
apply_999_acceptances(s, parsed, ack_id=500,
batch_envelope_index=idx)
s.commit()
with db.SessionLocal()() as s:
links = (
s.query(ClaimAck)
.filter_by(claim_id="CLM-UL")
.all()
)
assert len(links) == 1
s.delete(links[0])
s.commit()
with db.SessionLocal()() as s:
claim = s.get(Claim, "CLM-UL")
assert claim.state == ClaimState.REJECTED
assert (
s.query(ClaimAck).filter_by(claim_id="CLM-UL").first() is None
)
# ---------------------------------------------------------------------------
# Step 2.5 — D10 two-pass join + multi-claim batch coverage
# ---------------------------------------------------------------------------
def test_lookup_claims_two_pass_join():
"""D10: Pass 1 (batch.envelope.control_number) + Pass 2 (PCN)."""
with db.SessionLocal()() as s:
_seed_batch(s, batch_id="B-TP-A", envelope_control="991102989")
_seed_claim(s, claim_id="C-TP-A", batch_id="B-TP-A",
patient_control_number="t991102989o1cA")
# No matching batch — Pass 2 alone resolves it.
_seed_batch(s, batch_id="B-TP-B", envelope_control="OTHER")
_seed_claim(s, claim_id="C-TP-B", batch_id="B-TP-B",
patient_control_number="991102987")
# Pass 1: "991102989" -> B-TP-A -> [C-TP-A]
idx = _seed_ack_link_index(envelope_control_to_batch={
"991102989": "B-TP-A",
"991102990": "B-TP-A",
})
# PCN-only claim (no batch match). Pass 2 must find it.
def _pcn_lookup(pcn: str):
return s.query(Claim).filter_by(patient_control_number=pcn).first()
pass1 = lookup_claims_for_ack_set_response(
s, "991102989",
batch_envelope_index=idx,
pc_claim_lookup=_pcn_lookup,
)
pass2 = lookup_claims_for_ack_set_response(
s, "991102987",
batch_envelope_index=idx,
pc_claim_lookup=_pcn_lookup,
)
miss = lookup_claims_for_ack_set_response(
s, "0001",
batch_envelope_index=idx,
pc_claim_lookup=_pcn_lookup,
)
assert [c.id for c in pass1] == ["C-TP-A"]
assert [c.id for c in pass2] == ["C-TP-B"]
assert miss == []
def test_lookup_claims_pass1_wins_over_pass2():
"""If a PCN matches a claim in a DIFFERENT batch than the Pass 1
batch, only the Pass 1 result is returned (no double-fire)."""
with db.SessionLocal()() as s:
# Claim A lives in B1 (whose ST02 == '991102989'). Pass 1 hits.
_seed_batch(s, batch_id="B-PP-1", envelope_control="991102989")
_seed_claim(s, claim_id="C-PP-A", batch_id="B-PP-1",
patient_control_number="991102989")
# Claim B lives in B2 (ST02 == 'OTHER') but has the same PCN
# by coincidence. Pass 1 misses for '991102989' on B2 so Pass 2
# SHOULD match it — but only when Pass 1 returns nothing.
_seed_batch(s, batch_id="B-PP-2", envelope_control="OTHER")
_seed_claim(s, claim_id="C-PP-B", batch_id="B-PP-2",
patient_control_number="991102989")
idx = _seed_ack_link_index(envelope_control_to_batch={
"991102989": "B-PP-1",
})
rows = lookup_claims_for_ack_set_response(
s, "991102989",
batch_envelope_index=idx,
pc_claim_lookup=lambda pcn: s.query(Claim)
.filter_by(patient_control_number=pcn)
.first(),
)
# Pass 1 must win and skip Pass 2.
assert [c.id for c in rows] == ["C-PP-A"]
def test_999_linker_emits_one_row_per_claim_in_multi_claim_batch():
"""One 999 AK2 + N claims in one batch → N ClaimAck rows for that
single AK2 (one-ack-to-many)."""
with db.SessionLocal()() as s:
_seed_batch(s, batch_id="B-MC", envelope_control="991102989")
_seed_claim(s, claim_id="C-MC-1", batch_id="B-MC",
patient_control_number="t991102989o1c1")
_seed_claim(s, claim_id="C-MC-2", batch_id="B-MC",
patient_control_number="t991102989o1c2")
idx = _seed_ack_link_index(envelope_control_to_batch={
"991102989": "B-MC",
})
# 999 with one AK2 whose set_control_number matches the batch.
sr = SetFunctionalGroupResponse(
ak2=AckHeader999(functional_id_code="837",
group_control_number="991102989"),
set_control_number="991102989",
transaction_set_identifier="837",
segment_errors=[],
set_accept_reject=SetAcceptReject(code="A"),
)
parsed = ParseResult999(
envelope=Envelope(
sender_id="P", receiver_id="S",
control_number="0001", transaction_date=date(2026, 7, 2),
),
functional_group_acks=[],
set_responses=[sr],
summary=BatchSummary(
input_file="one_ak2.txt", control_number="0001",
transaction_date=date(2026, 7, 2),
total_claims=1, passed=1, failed=0,
),
)
out = apply_999_acceptances(s, parsed, ack_id=42,
batch_envelope_index=idx)
s.commit()
assert sorted(out.linked) == sorted([("C-MC-1", 0), ("C-MC-2", 0)])
with db.SessionLocal()() as s:
n = s.query(ClaimAck).filter_by(ack_kind="999").count()
assert n == 2
# ---------------------------------------------------------------------------
# Step 3.4 — facade wiring test (placeholder; full Phase 3 adds the
# store methods). The presence-only check verifies the public surface
# later exposes the same names — kept here so the plan-step stays
# trackable.
# ---------------------------------------------------------------------------
def test_store_facade_exposes_claim_ack_methods():
"""Step 3.4: facade must expose add_claim_ack + 4 read methods."""
expected = [
"add_claim_ack",
"list_acks_for_claim",
"list_claims_for_ack",
"find_ack_orphans",
"remove_claim_ack",
"batch_envelope_index",
]
for name in expected:
assert hasattr(store, name), f"missing CycloneStore.{name}"
# ---------------------------------------------------------------------------
# Step 2.1 — pure walk-through unit test (no DB) for the orphan tracking
# ---------------------------------------------------------------------------
def test_999_linker_walks_set_responses():
"""Stub-based: one AK2 resolves, one orphan, no DB."""
# Mock claim_lookup: "AAA" -> Claim-like, otherwise None.
class _StubClaim:
def __init__(self, cid):
self.id = cid
from cyclone.parsers.models_999 import (
ParseResult999, SetAcceptReject, SetFunctionalGroupResponse,
AcknowledgmentHeader as AckHdr,
)
parse_result = ParseResult999.model_construct()
parse_result.envelope = Envelope.model_construct(
sender_id="", receiver_id="", control_number="",
transaction_date=date(2026, 7, 2),
)
parse_result.functional_group_acks = []
parse_result.summary = BatchSummary(
input_file="stub.txt", control_number="",
transaction_date=date(2026, 7, 2),
total_claims=2, passed=1, failed=1,
)
parse_result.set_responses = [
SetFunctionalGroupResponse.model_construct(
ak2=AckHdr(functional_id_code="837", group_control_number="AAA"),
set_control_number="AAA",
transaction_set_identifier="837",
segment_errors=[],
set_accept_reject=SetAcceptReject.model_construct(code="A"),
),
SetFunctionalGroupResponse.model_construct(
ak2=AckHdr(functional_id_code="837", group_control_number="BBB"),
set_control_number="BBB",
transaction_set_identifier="837",
segment_errors=[],
set_accept_reject=SetAcceptReject.model_construct(code="R"),
),
]
seen: list[str] = []
def _pcn_lookup(pcn):
seen.append(pcn)
return _StubClaim(f"CLM-{pcn}") if pcn == "AAA" else None
class _StubQuery:
def filter(self, *args, **kwargs):
return self
def first(self):
return None # no existing link rows in stub
class _StubSession:
# Pretend to be a Session; capture adds and answer the dedup
# query with "no existing rows" so the stub stays pure-unit.
def __init__(self):
self.added: list[ClaimAck] = []
def add(self, obj): # noqa: D401
self.added.append(obj)
def flush(self): # noqa: D401
pass
def query(self, *args, **kwargs):
return _StubQuery()
s = _StubSession()
out = apply_999_acceptances(
s, parse_result,
ack_id=1,
batch_envelope_index=lambda scn: None, # Pass 1 misses → Pass 2 fires
pc_claim_lookup=_pcn_lookup,
)
assert seen == ["AAA", "BBB"]
assert [(cid, idx) for (cid, idx) in out.linked] == [("CLM-AAA", 0)]
assert out.orphans == ["BBB"]
assert len(s.added) == 1
assert s.added[0].claim_id == "CLM-AAA"
assert s.added[0].ack_kind == "999"
assert s.added[0].set_accept_reject_code == "A"