"""999-ack dump from Gainwell for Mar–Jun 2026. Reconciles pulled 999 acks against the in-window 4,509 NOT_IN_835 visits. Visits that are 999-rejected go in one bucket; visits that simply never made it to submission go in another. These tests exercise the pure-function surface of ``cyclone.rebill.pull_999_acks`` (no SFTP, no DB). The ``pull_and_classify`` orchestrator wraps the existing ``Scheduler.process_inbound_files`` machinery and is integration- covered by the existing ``test_api_pull_inbound.py`` / CLI smoke tests — adding a new test here would just duplicate that coverage and require a live SFTP server. """ from __future__ import annotations from datetime import date from cyclone.rebill.pull_999_acks import ( Bucket, PullResult, classify_not_in_835_visits, ) # --------------------------------------------------------------------------- # Core: split by 999-rejection presence # --------------------------------------------------------------------------- def test_classify_splits_by_999_rejection_presence() -> None: visits = [ ("J813715", date(2026, 6, 27), "T1019"), ("OTHER", date(2026, 6, 27), "T1019"), ] nine99_rejected = {("J813715", date(2026, 6, 27), "T1019")} out = classify_not_in_835_visits(visits, nine99_rejected) assert out["J813715"].value == Bucket.REJECTED_AT_999.value assert out["OTHER"].value == Bucket.NEVER_SUBMITTED.value # --------------------------------------------------------------------------- # Edge cases # --------------------------------------------------------------------------- def test_classify_empty_input_returns_empty_dict() -> None: """No visits → empty bucket map (no-op).""" out = classify_not_in_835_visits([], set()) assert out == {} # Also: empty visits + non-empty rejection set stays empty. out2 = classify_not_in_835_visits( [], {("J813715", date(2026, 6, 27), "T1019")}, ) assert out2 == {} def test_classify_all_rejected() -> None: """Every visit is in the 999-rejection set → every bucket is REJECTED_AT_999.""" v1 = ("MEM001", date(2026, 3, 15), "T1019") v2 = ("MEM002", date(2026, 4, 1), "T1019") out = classify_not_in_835_visits([v1, v2], {v1, v2}) assert out == { "MEM001": Bucket.REJECTED_AT_999, "MEM002": Bucket.REJECTED_AT_999, } def test_classify_all_never_submitted() -> None: """Visits present, rejection set empty → every bucket is NEVER_SUBMITTED.""" visits = [ ("G1", date(2026, 3, 1), "T1019"), ("G2", date(2026, 3, 2), "T1019"), ("G3", date(2026, 3, 3), "T1019"), ] out = classify_not_in_835_visits(visits, set()) assert out == {m: Bucket.NEVER_SUBMITTED for m in ("G1", "G2", "G3")} def test_classify_keyed_by_member_id() -> None: """Output is a dict keyed by member_id, not by the visit tuple. The spec's contract is "keyed on member_id" — Pipeline B groups by member for the ISO-week rebill, so the classification map collapses to one entry per member. The visit tuple's procedure and dos parts are the *match key* against the 999 rejection set, not the *output key*. When two visits for the same member resolve to different buckets (one rejected, one not), the dict-construction order means the *last* visit wins. This test pins that semantic — Pipeline B re-resolves per-visit at the next layer so the member-level bucket is just a coarse pre-filter. """ visits = [ ("SHARED", date(2026, 5, 1), "T1019"), ("SHARED", date(2026, 5, 8), "T1019"), ] # First visit IS in the rejected set, second is not. nine99_rejected = {("SHARED", date(2026, 5, 1), "T1019")} out = classify_not_in_835_visits(visits, nine99_rejected) # Last-write-wins: the second visit is NEVER_SUBMITTED, so the # member-level bucket ends up as NEVER_SUBMITTED. This is the # documented coarse-filter semantic — Pipeline B does per-visit # re-resolution downstream. assert out == {"SHARED": Bucket.NEVER_SUBMITTED} # Reverse the rejection set: only the second visit is rejected. # Last visit wins → REJECTED_AT_999. nine99_rejected = {("SHARED", date(2026, 5, 8), "T1019")} out = classify_not_in_835_visits(visits, nine99_rejected) assert out == {"SHARED": Bucket.REJECTED_AT_999} def test_classify_distinct_members_dont_collide() -> None: """Two distinct members, only one rejected → independent bucket entries.""" visits = [ ("ALICE", date(2026, 6, 1), "T1019"), ("BOB", date(2026, 6, 1), "T1019"), ] nine99_rejected = {("ALICE", date(2026, 6, 1), "T1019")} out = classify_not_in_835_visits(visits, nine99_rejected) assert out == { "ALICE": Bucket.REJECTED_AT_999, "BOB": Bucket.NEVER_SUBMITTED, } # --------------------------------------------------------------------------- # PullResult shape — guards against accidental field drift # --------------------------------------------------------------------------- def test_pull_result_is_frozen_dataclass() -> None: """``PullResult`` must be frozen so callers can't mutate the summary.""" pr = PullResult( total_pulled=10, rejected_at_999=3, not_in_835=4, rejected_breakdown={"R": 2, "E": 1}, ) assert pr.total_pulled == 10 assert pr.rejected_at_999 == 3 assert pr.not_in_835 == 4 assert pr.rejected_breakdown == {"R": 2, "E": 1} # Frozen: assignment must raise. import dataclasses try: pr.total_pulled = 99 # type: ignore[misc] except dataclasses.FrozenInstanceError: pass else: raise AssertionError("PullResult must be frozen") def test_bucket_values_are_json_friendly_strings() -> None: """Bucket values serialize cleanly to JSON (string-enum contract).""" import json payload = { "j1": Bucket.REJECTED_AT_999.value, "j2": Bucket.NEVER_SUBMITTED.value, } # Round-trip — no enum leakage into the JSON output. assert json.loads(json.dumps(payload)) == { "j1": "REJECTED_AT_999", "j2": "NEVER_SUBMITTED", }