167 lines
6.1 KiB
Python
167 lines
6.1 KiB
Python
"""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",
|
||
} |