mcaxl/tests/test_prompts_package.py
Ryan Malloy 8aaeb04417 Add 4 audit prompts: phone_inventory, user_audit, inbound_did_audit, hunt_pilot_audit
Builds on the prompts-package extraction. Each new prompt embeds
schema-verified SQL plus a findings template tuned to surface
audit-actionable issues (orphans, drift, capacity outliers, security
posture).

phone_inventory_report(filter=None):
  Aggregates by model / device pool / CSS, then anomaly queries for
  phones with no description, phones whose description echoes their
  MAC-based name, phones with no owner, phones in non-default CSS.
  Cross-references owner status (phones owned by inactive users
  surface as findings).

user_audit(focus=full|admin|inactive|app_users):
  End user + application user inventory, role/group assignments via
  the enduserdirgroupmap → dirgroup → functionroledirgroupmap →
  functionrole join chain. Security-critical findings: app users
  with admin-grade role memberships, local-user accounts with admin
  privileges, phones owned by inactive users.

inbound_did_audit():
  Reusable form of today's cucm-inbound-did-inventory work. XFORM-
  Inbound-DNIS curated list categorized (pass-through, block-trans,
  specific renames, wildcards, catch-all hazard). Cross-checked
  against Internal-PT route patterns and the operator-curated
  PSTN-Screen-PT spam blocklist. Findings for orphan target
  extensions and the silent !-catch-all risk.

hunt_pilot_audit():
  Hunt pilot inventory with queue settings, line group membership,
  and distribution algorithm decoding. Schema knowledge already
  Hamilton-verified: huntpilotqueue joins via fknumplan_pilot, NOT
  fknumplan (the test asserts the correct column appears in the
  rendered prompt). Findings: queue misconfigurations (NULL
  destinations, infinite max-wait), empty line groups, dead pilots
  with no route-list destination.

Implementation notes:
  - Each prompt's SQL was validated against the live cluster
    (cucm-pub.binghammemorial.org, CUCM 15.0.1.12900-234).
  - user_audit originally used UNION ALL with NULL-typed status
    column for the headcounts query; Informix rejected it. Split
    into two simpler queries (commented in the prompt body).
  - phone_inventory_report uses a Hamilton-style SQL escape for
    the optional name_filter (single quotes doubled).
  - All four prompts gracefully degrade when the docs index isn't
    loaded (verified by test_all_new_prompts_render_without_docs).

114 → 123 tests; 5 → 9 prompts. Full live-cluster verification:
  - 12 phone models, 629 Cisco 7841 phones (largest model)
  - 1,246 active end users, 25 application users
  - Hunt pilots with named distribution algorithms (Broadcast, Top
    Down, etc.) — confirms typedistributealgorithm join works
  - Hamilton-fixed huntpilotqueue.fknumplan_pilot column verified
    in the embedded SQL via dedicated regression test.
2026-04-25 23:57:01 -06:00

244 lines
8.3 KiB
Python

"""Tests for the extracted prompts package.
The render() functions are pure (they take docs as a parameter, no module
globals), so they're trivially unit-testable. We verify each one renders
without raising both with and without a docs index.
"""
import json
from pathlib import Path
import pytest
from mcp_cucm_axl import prompts
from mcp_cucm_axl.docs_loader import DocsIndex
@pytest.fixture
def fake_docs(tmp_path: Path) -> DocsIndex:
chunks = [
{
"id": "cucm::v15::admin::Route-Plan::0",
"text": "Route plan defines call routing.",
"heading_path": ["Route Plan"],
"source_path": str(tmp_path / "fake.md"),
"product": "cucm",
"version": "v15",
"doc": "system-config-guide",
},
{
"id": "cucm::v15::admin::SIP-Trunk::0",
"text": "SIP trunks connect CUCM to other call control entities.",
"heading_path": ["SIP Trunk Configuration"],
"source_path": str(tmp_path / "fake.md"),
"product": "cucm",
"version": "v15",
"doc": "system-config-guide",
},
]
(tmp_path / "chunks.jsonl").write_text(
"\n".join(json.dumps(c) for c in chunks)
)
(tmp_path / "index_meta.json").write_text(
json.dumps({"model_name": "test", "embedding_dim": 384, "products": ["cucm"]})
)
idx = DocsIndex.load(tmp_path)
assert idx is not None
return idx
# ---- route_plan_overview ----------------------------------------------------
def test_route_plan_overview_renders_with_docs(fake_docs):
text = prompts.route_plan_overview.render(fake_docs)
assert "Route Plan Overview" in text
assert "axl_version" in text
assert "route_partitions" in text
def test_route_plan_overview_renders_without_docs():
# Graceful degradation — no docs index should still produce a usable prompt
text = prompts.route_plan_overview.render(None)
assert "Route Plan Overview" in text
assert "cisco-docs index is not loaded" in text
# ---- investigate_pattern ---------------------------------------------------
def test_investigate_pattern_includes_pattern_in_output(fake_docs):
text = prompts.investigate_pattern.render(fake_docs, "10.911", "CER911-PT")
assert "10.911" in text
assert "CER911-PT" in text
def test_investigate_pattern_no_partition(fake_docs):
text = prompts.investigate_pattern.render(fake_docs, "9.@")
assert "9.@" in text
# The partition clause should not appear when no partition is given
assert "in partition" not in text
# ---- audit_routing ----------------------------------------------------------
def test_audit_routing_default_focus(fake_docs):
text = prompts.audit_routing.render(fake_docs)
assert "focus: `full`" in text
def test_audit_routing_custom_focus(fake_docs):
text = prompts.audit_routing.render(fake_docs, "translations")
assert "focus: `translations`" in text
# ---- cucm_sql_help ----------------------------------------------------------
def test_cucm_sql_help_includes_question(fake_docs):
q = "How do I find phones with no associated user?"
text = prompts.cucm_sql_help.render(fake_docs, q)
assert q in text
assert "axl_describe_table" in text
def test_cucm_sql_help_handles_empty_question(fake_docs):
# No substantive keywords — must still render something useful
text = prompts.cucm_sql_help.render(fake_docs, "x y z")
assert "axl_list_tables" in text
# ---- sip_trunk_report (NEW) ------------------------------------------------
def test_sip_trunk_report_includes_query_1(fake_docs):
text = prompts.sip_trunk_report.render(fake_docs)
# Query 1 essentials: device JOIN sipdevice, the trunk_name column,
# and the WHERE filter on Trunk class
assert "FROM device d" in text
assert "JOIN sipdevice sd" in text
assert "tc.name = 'Trunk'" in text
def test_sip_trunk_report_includes_query_2_and_tools(fake_docs):
text = prompts.sip_trunk_report.render(fake_docs)
assert "siptrunkdestination" in text
# And references the existing MCP tool for route group traversal
assert "route_lists_and_groups()" in text
def test_sip_trunk_report_with_name_filter_injects_safe_like(fake_docs):
text = prompts.sip_trunk_report.render(fake_docs, "PSTN")
# The filter should appear as a LIKE clause and the scope note should
# reflect the narrowing
assert "%PSTN%" in text
assert "narrowed to trunks" in text
def test_sip_trunk_report_name_filter_escaped(fake_docs):
# SQL injection defense: a single-quote in the name_filter must be
# doubled before going into the LIKE pattern
text = prompts.sip_trunk_report.render(fake_docs, "O'Reilly")
assert "O''Reilly" in text
def test_sip_trunk_report_renders_without_docs():
text = prompts.sip_trunk_report.render(None)
assert "SIP Trunk Report" in text
assert "cisco-docs index is not loaded" in text
# ---- registration smoke test -----------------------------------------------
def test_all_prompts_registered_in_server():
"""Confirm each prompt module's render() is wired through a FastMCP shim
in server.py. Catches the case where a new module is added but the
shim wasn't (the prompt would be invisible to the LLM)."""
import asyncio
from mcp_cucm_axl import server
async def _list():
registered = await server.mcp.list_prompts()
return {p.name for p in registered}
names = asyncio.run(_list())
assert names == {
"route_plan_overview",
"investigate_pattern",
"audit_routing",
"cucm_sql_help",
"sip_trunk_report",
"phone_inventory_report",
"user_audit",
"inbound_did_audit",
"hunt_pilot_audit",
}, f"unexpected prompt set: {names}"
# ---- new prompts: render-with-and-without-docs smoke tests -----------------
def test_phone_inventory_report_renders(fake_docs):
text = prompts.phone_inventory_report.render(fake_docs)
assert "Phone Inventory" in text
# Embedded SQL should query the `device` table with tkclass='Phone'
assert "tc.name = 'Phone'" in text
assert "all phones" in text
def test_phone_inventory_report_with_filter(fake_docs):
text = prompts.phone_inventory_report.render(fake_docs, "Lobby")
assert "%Lobby%" in text
assert "narrowed" in text
def test_phone_inventory_report_filter_escaped(fake_docs):
text = prompts.phone_inventory_report.render(fake_docs, "O'Hara")
assert "O''Hara" in text # SQL injection defense
def test_user_audit_default_focus(fake_docs):
text = prompts.user_audit.render(fake_docs)
assert "focus: `full`" in text
assert "applicationuser" in text
assert "enduser" in text
def test_user_audit_admin_focus(fake_docs):
text = prompts.user_audit.render(fake_docs, "admin")
assert "focus: `admin`" in text
def test_user_audit_unknown_focus_falls_back_to_full(fake_docs):
text = prompts.user_audit.render(fake_docs, "bogus_value")
assert "focus: `full`" in text
def test_inbound_did_audit_renders(fake_docs):
text = prompts.inbound_did_audit.render(fake_docs)
assert "Inbound DID Audit" in text
assert "XFORM-Inbound-DNIS" in text
assert "PSTN-Screen-PT" in text
def test_hunt_pilot_audit_uses_correct_column(fake_docs):
"""Hamilton bonus finding: huntpilotqueue joins via fknumplan_pilot,
NOT fknumplan. The prompt's embedded SQL must use the correct column
or every audit run will silently fail one category."""
text = prompts.hunt_pilot_audit.render(fake_docs)
assert "fknumplan_pilot" in text, (
"hunt_pilot_audit must use the verified column name `fknumplan_pilot`; "
"`fknumplan` was the silent-failure version we fixed in Hamilton review"
)
assert "tkpatternusage = 7" in text # Hunt Pilot type code
def test_all_new_prompts_render_without_docs():
"""Graceful degradation: each prompt produces usable output even when
the docs index isn't loaded."""
for name, fn, args in [
("phone_inventory_report", prompts.phone_inventory_report.render, ()),
("user_audit", prompts.user_audit.render, ()),
("inbound_did_audit", prompts.inbound_did_audit.render, ()),
("hunt_pilot_audit", prompts.hunt_pilot_audit.render, ()),
]:
text = fn(None, *args)
assert "cisco-docs index is not loaded" in text, (
f"{name} failed graceful degradation"
)