Files
cyclone/backend/tests/test_rebill_pull_999_acks.py

167 lines
6.1 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""999-ack dump from Gainwell for MarJun 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",
}