fix: address 14 PR review comments from coderabbitai/chatgpt-codex

- guards.py: split confirm bypass into explicit check; use .get() for
  dict description to prevent KeyError on missing action keys
- resources.py: use `is not None` for logs stream cache check; add
  on-demand subscribe_once fallback when auto_start is disabled so
  resources return real data instead of a perpetual "connecting" placeholder
- setup.py: always prompt before overwriting credentials even on failed
  probe (transient outage ≠ bad credentials); update elicitation message
- unraid.py: always elicit_reset_confirmation before overwriting creds;
  use asyncio.to_thread() for os.path.realpath() to avoid blocking async
- test_health.py: update test for new always-prompt-on-overwrite behavior;
  add test for declined-reset on failed probe
- test_resources.py: add tests for logs-stream None check, auto_start
  disabled fallback (success and failure), and fallback error recovery
- test-tools.sh: add suite_live() covering cpu/memory/cpu_telemetry/
  notifications_overview/log_tail; include in sequential and parallel runners
- CLAUDE.md: correct unraid_live → live action reference; document that
  setup always prompts before overwriting; note subscribe_once fallback
This commit is contained in:
Jacob Magar
2026-03-16 03:10:01 -04:00
parent efaab031ae
commit 884319ab11
8 changed files with 149 additions and 17 deletions

View File

@@ -600,6 +600,18 @@ suite_oidc() {
# provider and validate_session require IDs — skipped
}
suite_live() {
printf '\n%b== live (snapshot subscriptions) ==%b\n' "${C_BOLD}" "${C_RESET}" | tee -a "${LOG_FILE}"
# Note: these subactions open a transient WebSocket and wait for the first event.
# Event-driven actions (parity_progress, ups_status, notifications_overview,
# owner, server_status) return status=no_recent_events when no events arrive.
run_test "live: cpu" '{"action":"live","subaction":"cpu"}'
run_test "live: memory" '{"action":"live","subaction":"memory"}'
run_test "live: cpu_telemetry" '{"action":"live","subaction":"cpu_telemetry"}'
run_test "live: notifications_overview" '{"action":"live","subaction":"notifications_overview"}'
run_test "live: log_tail" '{"action":"live","subaction":"log_tail"}'
}
# ---------------------------------------------------------------------------
# Print final summary
# ---------------------------------------------------------------------------
@@ -650,6 +662,7 @@ run_parallel() {
suite_customization
suite_plugin
suite_oidc
suite_live
)
local pids=()
@@ -705,6 +718,7 @@ run_sequential() {
suite_customization
suite_plugin
suite_oidc
suite_live
}
# ---------------------------------------------------------------------------

View File

@@ -309,8 +309,8 @@ async def test_health_setup_already_configured_user_confirms_reset() -> None:
@pytest.mark.asyncio
async def test_health_setup_credentials_exist_but_connection_fails() -> None:
"""setup proceeds with elicitation when credentials exist but connection fails."""
async def test_health_setup_credentials_exist_but_connection_fails_user_confirms() -> None:
"""setup prompts for confirmation even on failed probe, then reconfigures if confirmed."""
tool_fn = _make_tool()
mock_path = MagicMock()
@@ -322,6 +322,10 @@ async def test_health_setup_credentials_exist_but_connection_fails() -> None:
"unraid_mcp.tools.unraid.make_graphql_request",
new=AsyncMock(side_effect=Exception("connection refused")),
),
patch(
"unraid_mcp.core.setup.elicit_reset_confirmation",
new=AsyncMock(return_value=True),
),
patch(
"unraid_mcp.core.setup.elicit_and_configure", new=AsyncMock(return_value=True)
) as mock_configure,
@@ -332,6 +336,32 @@ async def test_health_setup_credentials_exist_but_connection_fails() -> None:
assert "configured" in result.lower() or "success" in result.lower()
@pytest.mark.asyncio
async def test_health_setup_credentials_exist_connection_fails_user_declines() -> None:
"""setup returns 'no changes' when credentials exist (even with failed probe) and user declines."""
tool_fn = _make_tool()
mock_path = MagicMock()
mock_path.exists.return_value = True
with (
patch("unraid_mcp.config.settings.CREDENTIALS_ENV_PATH", mock_path),
patch(
"unraid_mcp.tools.unraid.make_graphql_request",
new=AsyncMock(side_effect=Exception("connection refused")),
),
patch(
"unraid_mcp.core.setup.elicit_reset_confirmation",
new=AsyncMock(return_value=False),
),
patch("unraid_mcp.core.setup.elicit_and_configure") as mock_configure,
):
result = await tool_fn(action="health", subaction="setup", ctx=MagicMock())
mock_configure.assert_not_called()
assert "no changes" in result.lower()
@pytest.mark.asyncio
async def test_health_setup_ctx_none_already_configured_returns_no_changes() -> None:
"""When ctx=None and credentials are working, setup returns 'already configured' gracefully."""

View File

@@ -100,3 +100,59 @@ class TestLogsStreamResource:
result = await resource.fn()
parsed = json.loads(result)
assert "status" in parsed
async def test_logs_stream_returns_data_with_empty_dict(
self, _mock_ensure_started: AsyncMock
) -> None:
"""Empty dict cache hit must return data, not 'connecting' status."""
with patch("unraid_mcp.subscriptions.resources.subscription_manager") as mock_mgr:
mock_mgr.get_resource_data = AsyncMock(return_value={})
mcp = _make_resources()
local_provider = mcp.providers[0]
resource = local_provider._components["resource:unraid://logs/stream@"]
result = await resource.fn()
assert json.loads(result) == {}
class TestAutoStartDisabledFallback:
"""When auto_start is disabled, resources fall back to on-demand subscribe_once."""
@pytest.mark.parametrize("action", list(SNAPSHOT_ACTIONS.keys()))
async def test_fallback_returns_subscribe_once_data(
self, action: str, _mock_ensure_started: AsyncMock
) -> None:
fallback_data = {"systemMetricsCpu": {"percentTotal": 42.0}}
with (
patch("unraid_mcp.subscriptions.resources.subscription_manager") as mock_mgr,
patch(
"unraid_mcp.subscriptions.resources.subscribe_once",
new=AsyncMock(return_value=fallback_data),
),
):
mock_mgr.get_resource_data = AsyncMock(return_value=None)
mock_mgr.last_error = {}
mock_mgr.auto_start_enabled = False
mcp = _make_resources()
resource = mcp.providers[0]._components[f"resource:unraid://live/{action}@"]
result = await resource.fn()
assert json.loads(result) == fallback_data
@pytest.mark.parametrize("action", list(SNAPSHOT_ACTIONS.keys()))
async def test_fallback_failure_returns_connecting(
self, action: str, _mock_ensure_started: AsyncMock
) -> None:
"""When on-demand fallback itself fails, still return 'connecting' status."""
with (
patch("unraid_mcp.subscriptions.resources.subscription_manager") as mock_mgr,
patch(
"unraid_mcp.subscriptions.resources.subscribe_once",
new=AsyncMock(side_effect=Exception("WebSocket failed")),
),
):
mock_mgr.get_resource_data = AsyncMock(return_value=None)
mock_mgr.last_error = {}
mock_mgr.auto_start_enabled = False
mcp = _make_resources()
resource = mcp.providers[0]._components[f"resource:unraid://live/{action}@"]
result = await resource.fn()
assert json.loads(result)["status"] == "connecting"