Closes bingham/mcp-cucm-axl#1 route_devices_using_css missed device.fkcallingsearchspace_cgpntransform and _cdpntransform — the columns trunks use to attach calling-party and called-party number transformation CSSs. A CSS only referenced via these columns showed up as "0 references" in impact analysis, leading an operator to conclude safe-to-delete and break outbound transformations. Same failure shape as Hamilton CRITICAL #2 (false-zero impact analysis) but at a different schema layer: that fix added 7 reference points covering the obvious cases; this fix closes the rest. What's covered now (71 fkcallingsearchspace_* columns total across 14 tables in CUCM 15): Templates added for the bulk cases: _device_query(suffix) — device.fkcallingsearchspace_<suffix> _devicepool_query(suffix) — devicepool.fkcallingsearchspace_<suffix> _numplan_query(suffix) — numplan.fkcallingsearchspace_<suffix> Categories added (51 new): 11× device variants (incl. _cgpntransform and _cdpntransform — the issue) 17× devicepool inheritance variants (closes M1 caveat from audit reports) 13× numplan forwarding/transformation variants (cfbint/cfhr/etc.) site, externalcallcontrolprofile, recordingprofile, usageprofile, vipre164transformation×2, incomingtransformationprofile×4 Schema gotchas discovered and codified: - devicepool, externalcallcontrolprofile, recordingprofile have no `description` column (verified against syscolumns 2026-04-26) - site has neither `name` nor `description` — uses `tksite` enum joined against `typesite.name` for the human-readable form Live verification on cucm-pub.binghammemorial.org (CUCM 15.0.1.12900-234): XFORM-Outbound-ANI: 0 → 1 ref (PSTN-Router-SIP-Trk via _cgpntransform) XFORM-Outbound-DNIS: 0 → 1 ref (PSTN-Router-SIP-Trk via _cdpntransform) E911CSS: unchanged at 0, but now with `complete: True` — upgrades from "appears orphan with caveat" to "confirmed orphan" since DP variants now covered Internal-CSS: 163 → 174 refs (DP + extra numplan variants) Tests (128 → 134, +6): test_issue_1_cgpntransform_column_enumerated test_issue_1_cdpntransform_column_enumerated test_finds_trunk_via_cgpntransform_reference (mock-driven E2E) test_complete_schema_coverage_against_known_columns — encodes the 71-column snapshot from CUCM 15. If a future CUCM version adds a new fkcallingsearchspace_* column, the test fires red so the contributor knows to add it to _CSS_REFERENCE_QUERIES. test_no_duplicate_table_column_pairs — guards against double-counting if two categories accidentally reference the same column. test_error_in_multiple_tables_propagates — verifies error reporting works across the new shared-suffix cases (e.g., _cgpnunknown on both device AND devicepool).
320 lines
14 KiB
Python
320 lines
14 KiB
Python
"""Hamilton review MAJOR #3: find_devices_using_css must surface partial failures.
|
|
|
|
The function is per-category resilient by design — if one schema query fails,
|
|
the others still produce results. But the top-level summary previously hid
|
|
that some categories errored out: `total_returned` and `any_truncated` only
|
|
reflected the SUCCESSFUL categories. An LLM consuming "47 references, low
|
|
impact" wouldn't know that 5 categories errored and the real number is
|
|
likely much higher.
|
|
|
|
After the fix: the response includes `complete: bool`, `categories_with_errors`,
|
|
and `error_categories`, so an LLM (or human auditor) can see the partial-failure
|
|
state and act on it.
|
|
"""
|
|
|
|
import pytest
|
|
|
|
from mcp_cucm_axl.route_plan import find_devices_using_css
|
|
|
|
|
|
class FakeAxlClient:
|
|
"""Minimal stand-in for AxlClient that lets us simulate per-query failures.
|
|
|
|
Returns a fake CSS pkid for the lookup query, then either a single fake row
|
|
or an exception based on substring matching.
|
|
"""
|
|
|
|
def __init__(self, error_on_columns: list[str] | None = None):
|
|
self.error_on_columns = error_on_columns or []
|
|
self.queries: list[str] = []
|
|
|
|
def execute_sql_query(self, sql: str) -> dict:
|
|
self.queries.append(sql)
|
|
# The CSS lookup query — return a fake pkid
|
|
if "callingsearchspace WHERE name" in sql:
|
|
return {"row_count": 1, "rows": [{"pkid": "fake-css-pkid"}]}
|
|
# Any query referencing an "error trigger" column → simulate failure
|
|
for trigger in self.error_on_columns:
|
|
if trigger in sql:
|
|
raise RuntimeError(f"simulated cluster failure on {trigger}")
|
|
# Otherwise return one fake reference row so the category isn't empty
|
|
return {
|
|
"row_count": 1,
|
|
"rows": [{"name": "FakeRef", "context": "FakePart", "description": "fake"}],
|
|
}
|
|
|
|
|
|
def test_no_errors_reports_complete():
|
|
"""Baseline: when every category succeeds, complete=True and no error fields populated."""
|
|
client = FakeAxlClient()
|
|
result = find_devices_using_css(client, "Some-CSS")
|
|
assert result["complete"] is True
|
|
assert result["categories_with_errors"] == 0
|
|
assert result["error_categories"] == []
|
|
# And total_returned reflects the successful categories
|
|
assert result["total_returned"] >= 1
|
|
|
|
|
|
def test_one_errored_category_marks_incomplete():
|
|
"""The audit-trust failure mode: one category errors out and the summary lies.
|
|
Fix: complete=False, categories_with_errors >= 1.
|
|
"""
|
|
client = FakeAxlClient(error_on_columns=["fkcallingsearchspace_cgpnunknown"])
|
|
result = find_devices_using_css(client, "Some-CSS")
|
|
assert result["complete"] is False, (
|
|
"complete must be False when any category errored"
|
|
)
|
|
assert result["categories_with_errors"] >= 1
|
|
assert "device_cgpn_unknown_css" in result["error_categories"]
|
|
|
|
|
|
def test_multiple_errors_all_listed():
|
|
"""All errored categories must be enumerated in error_categories.
|
|
|
|
After issue #1 fix, several column suffixes (`_cgpnunknown`,
|
|
`_reroute`) appear on BOTH the device and devicepool tables, so
|
|
a single suffix in error_on_columns hits multiple categories.
|
|
The test verifies the relevant categories are surfaced.
|
|
"""
|
|
client = FakeAxlClient(
|
|
error_on_columns=[
|
|
"fkcallingsearchspace_pilotqueuefull", # huntpilotqueue only
|
|
]
|
|
)
|
|
result = find_devices_using_css(client, "Some-CSS")
|
|
assert result["complete"] is False
|
|
assert result["categories_with_errors"] >= 1
|
|
assert "huntpilot_queue_full_css" in result["error_categories"]
|
|
|
|
|
|
def test_error_in_multiple_tables_propagates():
|
|
"""A column suffix shared across tables (e.g., `_cgpnunknown` on both
|
|
device AND devicepool) errors out in BOTH categories — both must
|
|
appear in error_categories."""
|
|
client = FakeAxlClient(
|
|
error_on_columns=["fkcallingsearchspace_cgpnunknown"],
|
|
)
|
|
result = find_devices_using_css(client, "Some-CSS")
|
|
assert "device_cgpn_unknown_css" in result["error_categories"]
|
|
assert "devicepool_cgpn_unknown_css" in result["error_categories"]
|
|
|
|
|
|
def test_total_returned_does_not_include_error_categories():
|
|
"""An errored category contributes 0 to total_returned (correct behavior).
|
|
What's NEW: the response also flags that the count is partial.
|
|
"""
|
|
client = FakeAxlClient(error_on_columns=["fkcallingsearchspace_cgpnunknown"])
|
|
result = find_devices_using_css(client, "Some-CSS")
|
|
# The count itself is unchanged from before — what's new is the warning
|
|
assert result["complete"] is False
|
|
# The error category has no rows in references_by_category
|
|
err_cat = result["references_by_category"].get("device_cgpn_unknown_css", {})
|
|
assert "error" in err_cat
|
|
|
|
|
|
def test_css_not_found_returns_error_not_partial():
|
|
"""If the CSS lookup itself fails (CSS doesn't exist), we return the
|
|
'not found' error early, NOT a partial-failure response. Distinct
|
|
failure modes deserve distinct shapes.
|
|
"""
|
|
|
|
class CssNotFoundClient:
|
|
def execute_sql_query(self, sql):
|
|
if "callingsearchspace WHERE name" in sql:
|
|
return {"row_count": 0, "rows": []}
|
|
return {"row_count": 1, "rows": [{}]}
|
|
|
|
result = find_devices_using_css(CssNotFoundClient(), "Nonexistent-CSS")
|
|
assert "error" in result
|
|
assert "complete" not in result, (
|
|
"CSS-not-found is a hard error; we shouldn't dress it up as partial"
|
|
)
|
|
|
|
|
|
# ---- Issue #1 regression tests --------------------------------------------
|
|
# https://git.supported.systems/bingham/mcp-cucm-axl/issues/1
|
|
#
|
|
# Pre-fix: route_devices_using_css missed device.fkcallingsearchspace_cgpntransform
|
|
# and device.fkcallingsearchspace_cdpntransform. These are the columns trunks
|
|
# use to attach calling-party / called-party transformation CSSs. A CSS only
|
|
# referenced via these columns showed up as "0 references" — operator running
|
|
# impact analysis would conclude safe-to-delete and break outbound transforms.
|
|
|
|
from mcp_cucm_axl.route_plan import _CSS_REFERENCE_QUERIES
|
|
|
|
|
|
def test_issue_1_cgpntransform_column_enumerated():
|
|
"""The specific column that triggered the issue is in our reference set."""
|
|
columns = {
|
|
(spec["table"], spec["column"])
|
|
for spec in _CSS_REFERENCE_QUERIES.values()
|
|
}
|
|
assert ("device", "fkcallingsearchspace_cgpntransform") in columns, (
|
|
"device.fkcallingsearchspace_cgpntransform must be enumerated; "
|
|
"see Gitea issue #1 — false-zero impact analysis on calling-party "
|
|
"transformation CSSs (e.g., XFORM-Outbound-ANI)"
|
|
)
|
|
|
|
|
|
def test_issue_1_cdpntransform_column_enumerated():
|
|
"""The sibling column (called-party transformation) is also enumerated."""
|
|
columns = {
|
|
(spec["table"], spec["column"])
|
|
for spec in _CSS_REFERENCE_QUERIES.values()
|
|
}
|
|
assert ("device", "fkcallingsearchspace_cdpntransform") in columns, (
|
|
"device.fkcallingsearchspace_cdpntransform must be enumerated; "
|
|
"same bug pattern as _cgpntransform (issue #1)"
|
|
)
|
|
|
|
|
|
def test_finds_trunk_via_cgpntransform_reference():
|
|
"""End-to-end: a trunk referencing a CSS via _cgpntransform should
|
|
appear in the impact analysis."""
|
|
|
|
class TrunkRefClient:
|
|
"""Returns 1 row only when queried for fkcallingsearchspace_cgpntransform."""
|
|
def execute_sql_query(self, sql):
|
|
if "callingsearchspace WHERE name" in sql:
|
|
return {"row_count": 1, "rows": [{"pkid": "fake-css-pkid"}]}
|
|
if "fkcallingsearchspace_cgpntransform" in sql:
|
|
return {
|
|
"row_count": 1,
|
|
"rows": [{
|
|
"name": "PSTN-Router-SIP-Trk",
|
|
"context": "Trunk",
|
|
"description": "the trunk that references this CSS",
|
|
}],
|
|
}
|
|
return {"row_count": 0, "rows": []}
|
|
|
|
result = find_devices_using_css(TrunkRefClient(), "XFORM-Outbound-ANI")
|
|
# Total must be ≥ 1 (the trunk reference), not 0
|
|
assert result["total_returned"] >= 1, (
|
|
"trunk referenced via _cgpntransform must surface in total_returned"
|
|
)
|
|
# And the specific category should be populated
|
|
cgpn_cat = result["references_by_category"].get("device_cgpn_xform_css")
|
|
assert cgpn_cat is not None and cgpn_cat.get("returned_count") == 1, (
|
|
f"device_cgpn_xform_css category should have 1 row; "
|
|
f"got: {result['references_by_category']}"
|
|
)
|
|
|
|
|
|
# ---- Comprehensive coverage --------------------------------------------
|
|
# The 71-column snapshot from CUCM 15.0.1.12900-234. If a future CUCM
|
|
# version adds a new fkcallingsearchspace_* column, this test fires red
|
|
# so the contributor knows to add it to _CSS_REFERENCE_QUERIES.
|
|
|
|
# Format: (table, column). Sourced from a SELECT against syscolumns
|
|
# 2026-04-26. Update when a new CUCM release lands.
|
|
_KNOWN_CSS_COLUMNS_FROM_CUCM_15 = frozenset({
|
|
# device — primary + 16 variants
|
|
("device", "fkcallingsearchspace"),
|
|
("device", "fkcallingsearchspace_aar"),
|
|
("device", "fkcallingsearchspace_calledintl"),
|
|
("device", "fkcallingsearchspace_callednational"),
|
|
("device", "fkcallingsearchspace_calledsubscriber"),
|
|
("device", "fkcallingsearchspace_calledunknown"),
|
|
("device", "fkcallingsearchspace_cdpntransform"),
|
|
("device", "fkcallingsearchspace_cgpningressdn"),
|
|
("device", "fkcallingsearchspace_cgpnintl"),
|
|
("device", "fkcallingsearchspace_cgpnnational"),
|
|
("device", "fkcallingsearchspace_cgpnsubscriber"),
|
|
("device", "fkcallingsearchspace_cgpntransform"),
|
|
("device", "fkcallingsearchspace_cgpnunknown"),
|
|
("device", "fkcallingsearchspace_rdntransform"),
|
|
("device", "fkcallingsearchspace_refer"),
|
|
("device", "fkcallingsearchspace_reroute"),
|
|
("device", "fkcallingsearchspace_restrict"),
|
|
# devicenumplanmap
|
|
("devicenumplanmap", "fkcallingsearchspace_monitoring"),
|
|
# devicepool — 16 variants (DP-level inheritance)
|
|
("devicepool", "fkcallingsearchspace_aar"),
|
|
("devicepool", "fkcallingsearchspace_adjunct"),
|
|
("devicepool", "fkcallingsearchspace_autoregistration"),
|
|
("devicepool", "fkcallingsearchspace_calledintl"),
|
|
("devicepool", "fkcallingsearchspace_callednational"),
|
|
("devicepool", "fkcallingsearchspace_calledsubscriber"),
|
|
("devicepool", "fkcallingsearchspace_calledunknown"),
|
|
("devicepool", "fkcallingsearchspace_cdpntransform"),
|
|
("devicepool", "fkcallingsearchspace_cgpningressdn"),
|
|
("devicepool", "fkcallingsearchspace_cgpnintl"),
|
|
("devicepool", "fkcallingsearchspace_cgpnnational"),
|
|
("devicepool", "fkcallingsearchspace_cgpnsubscriber"),
|
|
("devicepool", "fkcallingsearchspace_cgpntransform"),
|
|
("devicepool", "fkcallingsearchspace_cgpnunknown"),
|
|
("devicepool", "fkcallingsearchspace_cntdpntransform"),
|
|
("devicepool", "fkcallingsearchspace_mobility"),
|
|
("devicepool", "fkcallingsearchspace_rdntransform"),
|
|
# External call control + h323 + sipdevice + huntpilotqueue
|
|
("externalcallcontrolprofile", "fkcallingsearchspace_diversionrerouting"),
|
|
("h323device", "fkcallingsearchspace_cntdpntransform"),
|
|
("sipdevice", "fkcallingsearchspace_cntdpntransform"),
|
|
("huntpilotqueue", "fkcallingsearchspace_maxwaittime"),
|
|
("huntpilotqueue", "fkcallingsearchspace_noagent"),
|
|
("huntpilotqueue", "fkcallingsearchspace_pilotqueuefull"),
|
|
# incomingtransformationprofile (4)
|
|
("incomingtransformationprofile", "fkcallingsearchspace_intl"),
|
|
("incomingtransformationprofile", "fkcallingsearchspace_national"),
|
|
("incomingtransformationprofile", "fkcallingsearchspace_subscriber"),
|
|
("incomingtransformationprofile", "fkcallingsearchspace_unknown"),
|
|
# numplan — 18 forwarding/transformation CSSs
|
|
("numplan", "fkcallingsearchspace_cfapt"),
|
|
("numplan", "fkcallingsearchspace_cfb"),
|
|
("numplan", "fkcallingsearchspace_cfbint"),
|
|
("numplan", "fkcallingsearchspace_cfhr"),
|
|
("numplan", "fkcallingsearchspace_cfhrint"),
|
|
("numplan", "fkcallingsearchspace_cfna"),
|
|
("numplan", "fkcallingsearchspace_cfnaint"),
|
|
("numplan", "fkcallingsearchspace_cfur"),
|
|
("numplan", "fkcallingsearchspace_cfurint"),
|
|
("numplan", "fkcallingsearchspace_devicefailure"),
|
|
("numplan", "fkcallingsearchspace_mwi"),
|
|
("numplan", "fkcallingsearchspace_pff"),
|
|
("numplan", "fkcallingsearchspace_pffint"),
|
|
("numplan", "fkcallingsearchspace_pkmonfwdnoret"),
|
|
("numplan", "fkcallingsearchspace_pkmonfwdnoretint"),
|
|
("numplan", "fkcallingsearchspace_reroute"),
|
|
("numplan", "fkcallingsearchspace_revert"),
|
|
("numplan", "fkcallingsearchspace_sharedlineappear"),
|
|
("numplan", "fkcallingsearchspace_translation"),
|
|
# Profile tables + simple primary fkcallingsearchspace
|
|
("recordingprofile", "fkcallingsearchspace_callrecording"),
|
|
("routelist", "fkcallingsearchspace"),
|
|
("site", "fkcallingsearchspace"),
|
|
("usageprofile", "fkcallingsearchspace_blocking"),
|
|
("vipre164transformation", "fkcallingsearchspace_outgoingcdpntranf"),
|
|
("vipre164transformation", "fkcallingsearchspace_outgoingcgpntranf"),
|
|
("voicemessagingpilot", "fkcallingsearchspace"),
|
|
})
|
|
|
|
|
|
def test_complete_schema_coverage_against_known_columns():
|
|
"""If CUCM adds a new column type or we missed one, this test surfaces it.
|
|
Counts: 71 columns total in the CUCM 15.0.1.12900-234 snapshot.
|
|
"""
|
|
actual = {
|
|
(spec["table"], spec["column"])
|
|
for spec in _CSS_REFERENCE_QUERIES.values()
|
|
}
|
|
missing = _KNOWN_CSS_COLUMNS_FROM_CUCM_15 - actual
|
|
assert not missing, (
|
|
f"_CSS_REFERENCE_QUERIES is missing {len(missing)} known columns:\n"
|
|
+ "\n".join(f" {t}.{c}" for t, c in sorted(missing))
|
|
)
|
|
|
|
|
|
def test_no_duplicate_table_column_pairs():
|
|
"""Each (table, column) pair should map to exactly one category label.
|
|
Two categories pointing at the same column would double-count references."""
|
|
seen: dict[tuple, list[str]] = {}
|
|
for label, spec in _CSS_REFERENCE_QUERIES.items():
|
|
key = (spec["table"], spec["column"])
|
|
seen.setdefault(key, []).append(label)
|
|
duplicates = {k: v for k, v in seen.items() if len(v) > 1}
|
|
assert not duplicates, (
|
|
f"duplicate (table, column) pairs would double-count:\n"
|
|
+ "\n".join(f" {k}: {v}" for k, v in duplicates.items())
|
|
)
|