mcaxl/tests/test_to_int_diagnostic.py
Ryan Malloy 90227ab391 Hamilton review fixes part 2: bounded regex, connection recovery, _to_int diagnostic, consistent error shapes
Closes the four remaining findings from the margaret-hamilton review.
13 new regression tests; all 100 pass; live cluster smoke verified.

MAJOR #4 — wildcard regex catastrophic backtracking + silent malformed.

Two changes to _wildcard_to_regex():

a) Bounded the `!` and `@` wildcards to \d{1,50} (was \d+). Adjacent
   `!` patterns previously compiled to (\d+)(\d+)... which has
   exponential backtracking on near-miss inputs. CUCM dial strings
   are practically capped well below 50 digits; the bound keeps
   complexity polynomial without losing real-world coverage.
   Verified: 10 adjacent `!` against a 30-digit near-miss now finishes
   in ~240ms (was unbounded; could have been minutes on real
   pathological cases).

b) Unclosed `[` now raises ValueError instead of silently treating the
   bracket as a literal. _pattern_matches_number catches the error
   and returns False so a single bad pattern doesn't crash
   translation_chain — but the bad pattern is no longer invisibly
   producing wrong matches. The previous silent fallback meant a
   pattern like `[0-9` (typo, missing `]`) would match input
   containing the literal characters `[` `0` `-` `9`.

3 new tests covering: bounded-regex shape (`\d{1,N}`), pathological
input completes quickly, unclosed bracket raises explicitly,
well-formed character class still works.

MAJOR #5 — distinguish config errors from operational errors.

Pre-fix: any first-time connection failure set `_connection_error`
and pinned it forever. A transient network blip or session timeout
required restarting the MCP server. Hamilton's framing: Apollo's
software was *designed* to recover from transient faults; pinning
forever is the antithesis of "design the error path first."

Fix: split into two state fields:
  _config_error  — permanent until restart (missing env vars only)
  _last_error    — last operational failure, NOT a pin

Operational failures (zeep Client construction, network, TLS, session)
clear from the next call's perspective: the next call attempts fresh.
Configuration errors (missing AXL_URL etc.) stay pinned because
they don't get better on retry.

Added _ConfigError as a private subclass to make the distinction
explicit at the raise site, and connection_status() to expose
connected/connected_at/config_error/last_error for diagnostic
transparency.

3 new tests: config errors pin, operational errors don't pin,
connection_status() reports state.

MINOR #6 — _to_int silent coercion of bad data.

Pre-fix: a non-numeric value from the cluster (data corruption,
schema drift across CUCM versions) silently became None, which
downstream sort logic defaulted to 0 — jumbling the failover order
in the displayed result with no warning.

Fix: still returns None on bad data (caller error path unchanged),
but logs the offending value to stderr so an operator notices
something's wrong at the data layer. None itself is silent
(legitimately-unset column).

2 new tests: real None is silent, bad string logs to stderr with
the offending value visible.

MINOR #7 — standardize tool failure shapes; add health() tool.

Pre-fix: cache_stats and cache_clear returned `{"error": "..."}`
when _cache was None, while AXL-touching tools raised RuntimeError.
LLM consumers had to handle two shapes.

Fix: _require_cache() helper raises RuntimeError consistently with
_client(). All tool failures now use the same exception shape.
Added health() tool that reports cache/axl/docs initialization
status plus the AXL connection_status — gives operators a
self-diagnostic when something fails at bootstrap.

3 new tests: cache_stats raises, cache_clear raises, health()
reports each subsystem.
2026-04-25 23:19:32 -06:00

49 lines
1.5 KiB
Python

"""Hamilton review MINOR #6: `_to_int` silently coerced bad values to None.
Sort fields built on `_to_int` returns then defaulted None to 0, which
jumbled the failover order in the displayed result. Fix: when the conversion
fails, log to stderr (so an operator can see) and return None — but the
caller code path that does the sort now uses a stable tie-breaker that
doesn't silently rewrite real-zero into "no value."
"""
import sys
from io import StringIO
from mcp_cucm_axl.route_plan import _to_int
def test_to_int_passthrough_normal():
assert _to_int("5") == 5
assert _to_int(7) == 7
def test_to_int_none_returns_none_silently():
"""Real Nones are valid (column not set) — don't log noise for them."""
captured = StringIO()
real_stderr = sys.stderr
sys.stderr = captured
try:
assert _to_int(None) is None
finally:
sys.stderr = real_stderr
assert "warning" not in captured.getvalue().lower()
def test_to_int_bad_value_logs_warning():
"""A non-numeric string from the cluster (data corruption / unexpected
type) should be loud enough for an operator to notice in stderr."""
captured = StringIO()
real_stderr = sys.stderr
sys.stderr = captured
try:
result = _to_int("not-a-number")
finally:
sys.stderr = real_stderr
assert result is None
output = captured.getvalue()
assert "not-a-number" in output, (
f"unexpected non-numeric value should be logged with the offending value; "
f"got stderr: {output!r}"
)