mirror of
https://github.com/jmagar/unraid-mcp.git
synced 2026-03-23 12:39:24 -07:00
feat: add 5 notification mutations + comprehensive refactors from PR review
New notification actions (archive_many, create_unique, unarchive_many, unarchive_all, recalculate) bring unraid_notifications to 14 actions. Also includes continuation of CodeRabbit/PR review fixes: - Remove redundant try-except in virtualization.py (silent failure fix) - Add QueryCache protocol with get/put/invalidate_all to core/client.py - Refactor subscriptions (manager, diagnostics, resources, utils) - Update config (logging, settings) for improved structure - Expand test coverage: http_layer, safety guards, schema validation - Minor cleanups: array, docker, health, keys tools Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -228,9 +228,7 @@ class TestGraphQLErrorHandling:
|
||||
@respx.mock
|
||||
async def test_idempotent_start_error_returns_success(self) -> None:
|
||||
respx.post(API_URL).mock(
|
||||
return_value=_graphql_response(
|
||||
errors=[{"message": "Container already running"}]
|
||||
)
|
||||
return_value=_graphql_response(errors=[{"message": "Container already running"}])
|
||||
)
|
||||
result = await make_graphql_request(
|
||||
'mutation { docker { start(id: "x") } }',
|
||||
@@ -242,9 +240,7 @@ class TestGraphQLErrorHandling:
|
||||
@respx.mock
|
||||
async def test_idempotent_stop_error_returns_success(self) -> None:
|
||||
respx.post(API_URL).mock(
|
||||
return_value=_graphql_response(
|
||||
errors=[{"message": "Container not running"}]
|
||||
)
|
||||
return_value=_graphql_response(errors=[{"message": "Container not running"}])
|
||||
)
|
||||
result = await make_graphql_request(
|
||||
'mutation { docker { stop(id: "x") } }',
|
||||
@@ -275,7 +271,13 @@ class TestInfoToolRequests:
|
||||
async def test_overview_sends_correct_query(self) -> None:
|
||||
route = respx.post(API_URL).mock(
|
||||
return_value=_graphql_response(
|
||||
{"info": {"os": {"platform": "linux", "hostname": "tower"}, "cpu": {}, "memory": {}}}
|
||||
{
|
||||
"info": {
|
||||
"os": {"platform": "linux", "hostname": "tower"},
|
||||
"cpu": {},
|
||||
"memory": {},
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
@@ -329,9 +331,7 @@ class TestInfoToolRequests:
|
||||
|
||||
@respx.mock
|
||||
async def test_online_sends_correct_query(self) -> None:
|
||||
route = respx.post(API_URL).mock(
|
||||
return_value=_graphql_response({"online": True})
|
||||
)
|
||||
route = respx.post(API_URL).mock(return_value=_graphql_response({"online": True}))
|
||||
tool = self._get_tool()
|
||||
await tool(action="online")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
@@ -374,9 +374,7 @@ class TestDockerToolRequests:
|
||||
async def test_list_sends_correct_query(self) -> None:
|
||||
route = respx.post(API_URL).mock(
|
||||
return_value=_graphql_response(
|
||||
{"docker": {"containers": [
|
||||
{"id": "c1", "names": ["plex"], "state": "running"}
|
||||
]}}
|
||||
{"docker": {"containers": [{"id": "c1", "names": ["plex"], "state": "running"}]}}
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
@@ -389,10 +387,16 @@ class TestDockerToolRequests:
|
||||
container_id = "a" * 64
|
||||
route = respx.post(API_URL).mock(
|
||||
return_value=_graphql_response(
|
||||
{"docker": {"start": {
|
||||
"id": container_id, "names": ["plex"],
|
||||
"state": "running", "status": "Up",
|
||||
}}}
|
||||
{
|
||||
"docker": {
|
||||
"start": {
|
||||
"id": container_id,
|
||||
"names": ["plex"],
|
||||
"state": "running",
|
||||
"status": "Up",
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
@@ -406,10 +410,16 @@ class TestDockerToolRequests:
|
||||
container_id = "b" * 64
|
||||
route = respx.post(API_URL).mock(
|
||||
return_value=_graphql_response(
|
||||
{"docker": {"stop": {
|
||||
"id": container_id, "names": ["sonarr"],
|
||||
"state": "exited", "status": "Exited",
|
||||
}}}
|
||||
{
|
||||
"docker": {
|
||||
"stop": {
|
||||
"id": container_id,
|
||||
"names": ["sonarr"],
|
||||
"state": "exited",
|
||||
"status": "Exited",
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
@@ -451,9 +461,11 @@ class TestDockerToolRequests:
|
||||
async def test_networks_sends_correct_query(self) -> None:
|
||||
route = respx.post(API_URL).mock(
|
||||
return_value=_graphql_response(
|
||||
{"dockerNetworks": [
|
||||
{"id": "n1", "name": "bridge", "driver": "bridge", "scope": "local"}
|
||||
]}
|
||||
{
|
||||
"dockerNetworks": [
|
||||
{"id": "n1", "name": "bridge", "driver": "bridge", "scope": "local"}
|
||||
]
|
||||
}
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
@@ -464,9 +476,7 @@ class TestDockerToolRequests:
|
||||
@respx.mock
|
||||
async def test_check_updates_sends_correct_query(self) -> None:
|
||||
route = respx.post(API_URL).mock(
|
||||
return_value=_graphql_response(
|
||||
{"docker": {"containerUpdateStatuses": []}}
|
||||
)
|
||||
return_value=_graphql_response({"docker": {"containerUpdateStatuses": []}})
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(action="check_updates")
|
||||
@@ -485,17 +495,29 @@ class TestDockerToolRequests:
|
||||
call_count += 1
|
||||
if "StopContainer" in body["query"]:
|
||||
return _graphql_response(
|
||||
{"docker": {"stop": {
|
||||
"id": container_id, "names": ["app"],
|
||||
"state": "exited", "status": "Exited",
|
||||
}}}
|
||||
{
|
||||
"docker": {
|
||||
"stop": {
|
||||
"id": container_id,
|
||||
"names": ["app"],
|
||||
"state": "exited",
|
||||
"status": "Exited",
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
if "StartContainer" in body["query"]:
|
||||
return _graphql_response(
|
||||
{"docker": {"start": {
|
||||
"id": container_id, "names": ["app"],
|
||||
"state": "running", "status": "Up",
|
||||
}}}
|
||||
{
|
||||
"docker": {
|
||||
"start": {
|
||||
"id": container_id,
|
||||
"names": ["app"],
|
||||
"state": "running",
|
||||
"status": "Up",
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
return _graphql_response({"docker": {"containers": []}})
|
||||
|
||||
@@ -522,10 +544,16 @@ class TestDockerToolRequests:
|
||||
)
|
||||
if "StartContainer" in body["query"]:
|
||||
return _graphql_response(
|
||||
{"docker": {"start": {
|
||||
"id": resolved_id, "names": ["plex"],
|
||||
"state": "running", "status": "Up",
|
||||
}}}
|
||||
{
|
||||
"docker": {
|
||||
"start": {
|
||||
"id": resolved_id,
|
||||
"names": ["plex"],
|
||||
"state": "running",
|
||||
"status": "Up",
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
return _graphql_response({})
|
||||
|
||||
@@ -546,17 +574,17 @@ class TestVMToolRequests:
|
||||
|
||||
@staticmethod
|
||||
def _get_tool():
|
||||
return make_tool_fn(
|
||||
"unraid_mcp.tools.virtualization", "register_vm_tool", "unraid_vm"
|
||||
)
|
||||
return make_tool_fn("unraid_mcp.tools.virtualization", "register_vm_tool", "unraid_vm")
|
||||
|
||||
@respx.mock
|
||||
async def test_list_sends_correct_query(self) -> None:
|
||||
route = respx.post(API_URL).mock(
|
||||
return_value=_graphql_response(
|
||||
{"vms": {"domains": [
|
||||
{"id": "v1", "name": "win10", "state": "running", "uuid": "u1"}
|
||||
]}}
|
||||
{
|
||||
"vms": {
|
||||
"domains": [{"id": "v1", "name": "win10", "state": "running", "uuid": "u1"}]
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
@@ -567,9 +595,7 @@ class TestVMToolRequests:
|
||||
|
||||
@respx.mock
|
||||
async def test_start_sends_mutation_with_id(self) -> None:
|
||||
route = respx.post(API_URL).mock(
|
||||
return_value=_graphql_response({"vm": {"start": True}})
|
||||
)
|
||||
route = respx.post(API_URL).mock(return_value=_graphql_response({"vm": {"start": True}}))
|
||||
tool = self._get_tool()
|
||||
result = await tool(action="start", vm_id="vm-123")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
@@ -579,9 +605,7 @@ class TestVMToolRequests:
|
||||
|
||||
@respx.mock
|
||||
async def test_stop_sends_mutation_with_id(self) -> None:
|
||||
route = respx.post(API_URL).mock(
|
||||
return_value=_graphql_response({"vm": {"stop": True}})
|
||||
)
|
||||
route = respx.post(API_URL).mock(return_value=_graphql_response({"vm": {"stop": True}}))
|
||||
tool = self._get_tool()
|
||||
await tool(action="stop", vm_id="vm-456")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
@@ -615,10 +639,14 @@ class TestVMToolRequests:
|
||||
async def test_details_finds_vm_by_name(self) -> None:
|
||||
respx.post(API_URL).mock(
|
||||
return_value=_graphql_response(
|
||||
{"vms": {"domains": [
|
||||
{"id": "v1", "name": "win10", "state": "running", "uuid": "uuid-1"},
|
||||
{"id": "v2", "name": "ubuntu", "state": "stopped", "uuid": "uuid-2"},
|
||||
]}}
|
||||
{
|
||||
"vms": {
|
||||
"domains": [
|
||||
{"id": "v1", "name": "win10", "state": "running", "uuid": "uuid-1"},
|
||||
{"id": "v2", "name": "ubuntu", "state": "stopped", "uuid": "uuid-2"},
|
||||
]
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
@@ -642,9 +670,15 @@ class TestArrayToolRequests:
|
||||
async def test_parity_status_sends_correct_query(self) -> None:
|
||||
route = respx.post(API_URL).mock(
|
||||
return_value=_graphql_response(
|
||||
{"array": {"parityCheckStatus": {
|
||||
"progress": 50, "speed": "100 MB/s", "errors": 0,
|
||||
}}}
|
||||
{
|
||||
"array": {
|
||||
"parityCheckStatus": {
|
||||
"progress": 50,
|
||||
"speed": "100 MB/s",
|
||||
"errors": 0,
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
@@ -706,9 +740,7 @@ class TestStorageToolRequests:
|
||||
|
||||
@staticmethod
|
||||
def _get_tool():
|
||||
return make_tool_fn(
|
||||
"unraid_mcp.tools.storage", "register_storage_tool", "unraid_storage"
|
||||
)
|
||||
return make_tool_fn("unraid_mcp.tools.storage", "register_storage_tool", "unraid_storage")
|
||||
|
||||
@respx.mock
|
||||
async def test_shares_sends_correct_query(self) -> None:
|
||||
@@ -737,10 +769,16 @@ class TestStorageToolRequests:
|
||||
async def test_disk_details_sends_variable(self) -> None:
|
||||
route = respx.post(API_URL).mock(
|
||||
return_value=_graphql_response(
|
||||
{"disk": {
|
||||
"id": "d1", "device": "sda", "name": "Disk 1",
|
||||
"serialNum": "SN123", "size": 1000000, "temperature": 35,
|
||||
}}
|
||||
{
|
||||
"disk": {
|
||||
"id": "d1",
|
||||
"device": "sda",
|
||||
"name": "Disk 1",
|
||||
"serialNum": "SN123",
|
||||
"size": 1000000,
|
||||
"temperature": 35,
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
@@ -766,10 +804,14 @@ class TestStorageToolRequests:
|
||||
async def test_logs_sends_path_and_lines_variables(self) -> None:
|
||||
route = respx.post(API_URL).mock(
|
||||
return_value=_graphql_response(
|
||||
{"logFile": {
|
||||
"path": "/var/log/syslog", "content": "log line",
|
||||
"totalLines": 100, "startLine": 1,
|
||||
}}
|
||||
{
|
||||
"logFile": {
|
||||
"path": "/var/log/syslog",
|
||||
"content": "log line",
|
||||
"totalLines": 100,
|
||||
"startLine": 1,
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
@@ -787,9 +829,7 @@ class TestStorageToolRequests:
|
||||
|
||||
@respx.mock
|
||||
async def test_unassigned_sends_correct_query(self) -> None:
|
||||
route = respx.post(API_URL).mock(
|
||||
return_value=_graphql_response({"unassignedDevices": []})
|
||||
)
|
||||
route = respx.post(API_URL).mock(return_value=_graphql_response({"unassignedDevices": []}))
|
||||
tool = self._get_tool()
|
||||
result = await tool(action="unassigned")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
@@ -817,9 +857,13 @@ class TestNotificationsToolRequests:
|
||||
async def test_overview_sends_correct_query(self) -> None:
|
||||
route = respx.post(API_URL).mock(
|
||||
return_value=_graphql_response(
|
||||
{"notifications": {"overview": {
|
||||
"unread": {"info": 1, "warning": 0, "alert": 0, "total": 1},
|
||||
}}}
|
||||
{
|
||||
"notifications": {
|
||||
"overview": {
|
||||
"unread": {"info": 1, "warning": 0, "alert": 0, "total": 1},
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
@@ -833,9 +877,7 @@ class TestNotificationsToolRequests:
|
||||
return_value=_graphql_response({"notifications": {"list": []}})
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(
|
||||
action="list", list_type="ARCHIVE", importance="WARNING", offset=5, limit=10
|
||||
)
|
||||
await tool(action="list", list_type="ARCHIVE", importance="WARNING", offset=5, limit=10)
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "ListNotifications" in body["query"]
|
||||
filt = body["variables"]["filter"]
|
||||
@@ -859,9 +901,13 @@ class TestNotificationsToolRequests:
|
||||
async def test_create_sends_input_variables(self) -> None:
|
||||
route = respx.post(API_URL).mock(
|
||||
return_value=_graphql_response(
|
||||
{"createNotification": {
|
||||
"id": "n1", "title": "Test", "importance": "INFO",
|
||||
}}
|
||||
{
|
||||
"createNotification": {
|
||||
"id": "n1",
|
||||
"title": "Test",
|
||||
"importance": "INFO",
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
@@ -870,21 +916,19 @@ class TestNotificationsToolRequests:
|
||||
title="Test",
|
||||
subject="Sub",
|
||||
description="Desc",
|
||||
importance="normal",
|
||||
importance="info",
|
||||
)
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
assert "CreateNotification" in body["query"]
|
||||
inp = body["variables"]["input"]
|
||||
assert inp["title"] == "Test"
|
||||
assert inp["subject"] == "Sub"
|
||||
assert inp["importance"] == "NORMAL" # uppercased from "normal"
|
||||
assert inp["importance"] == "INFO" # uppercased from "info"
|
||||
|
||||
@respx.mock
|
||||
async def test_archive_sends_id_variable(self) -> None:
|
||||
route = respx.post(API_URL).mock(
|
||||
return_value=_graphql_response(
|
||||
{"archiveNotification": {"id": "notif-1"}}
|
||||
)
|
||||
return_value=_graphql_response({"archiveNotification": {"id": "notif-1"}})
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(action="archive", notification_id="notif-1")
|
||||
@@ -901,9 +945,7 @@ class TestNotificationsToolRequests:
|
||||
@respx.mock
|
||||
async def test_delete_sends_id_and_type(self) -> None:
|
||||
route = respx.post(API_URL).mock(
|
||||
return_value=_graphql_response(
|
||||
{"deleteNotification": {"unread": {"total": 0}}}
|
||||
)
|
||||
return_value=_graphql_response({"deleteNotification": {"unread": {"total": 0}}})
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(
|
||||
@@ -920,9 +962,7 @@ class TestNotificationsToolRequests:
|
||||
@respx.mock
|
||||
async def test_archive_all_sends_importance_when_provided(self) -> None:
|
||||
route = respx.post(API_URL).mock(
|
||||
return_value=_graphql_response(
|
||||
{"archiveAll": {"archive": {"total": 1}}}
|
||||
)
|
||||
return_value=_graphql_response({"archiveAll": {"archive": {"total": 1}}})
|
||||
)
|
||||
tool = self._get_tool()
|
||||
await tool(action="archive_all", importance="warning")
|
||||
@@ -941,9 +981,7 @@ class TestRCloneToolRequests:
|
||||
|
||||
@staticmethod
|
||||
def _get_tool():
|
||||
return make_tool_fn(
|
||||
"unraid_mcp.tools.rclone", "register_rclone_tool", "unraid_rclone"
|
||||
)
|
||||
return make_tool_fn("unraid_mcp.tools.rclone", "register_rclone_tool", "unraid_rclone")
|
||||
|
||||
@respx.mock
|
||||
async def test_list_remotes_sends_correct_query(self) -> None:
|
||||
@@ -962,9 +1000,15 @@ class TestRCloneToolRequests:
|
||||
async def test_config_form_sends_provider_type(self) -> None:
|
||||
route = respx.post(API_URL).mock(
|
||||
return_value=_graphql_response(
|
||||
{"rclone": {"configForm": {
|
||||
"id": "form1", "dataSchema": {}, "uiSchema": {},
|
||||
}}}
|
||||
{
|
||||
"rclone": {
|
||||
"configForm": {
|
||||
"id": "form1",
|
||||
"dataSchema": {},
|
||||
"uiSchema": {},
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
@@ -977,9 +1021,15 @@ class TestRCloneToolRequests:
|
||||
async def test_create_remote_sends_input_variables(self) -> None:
|
||||
route = respx.post(API_URL).mock(
|
||||
return_value=_graphql_response(
|
||||
{"rclone": {"createRCloneRemote": {
|
||||
"name": "my-s3", "type": "s3", "parameters": {},
|
||||
}}}
|
||||
{
|
||||
"rclone": {
|
||||
"createRCloneRemote": {
|
||||
"name": "my-s3",
|
||||
"type": "s3",
|
||||
"parameters": {},
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
@@ -1025,18 +1075,20 @@ class TestUsersToolRequests:
|
||||
|
||||
@staticmethod
|
||||
def _get_tool():
|
||||
return make_tool_fn(
|
||||
"unraid_mcp.tools.users", "register_users_tool", "unraid_users"
|
||||
)
|
||||
return make_tool_fn("unraid_mcp.tools.users", "register_users_tool", "unraid_users")
|
||||
|
||||
@respx.mock
|
||||
async def test_me_sends_correct_query(self) -> None:
|
||||
route = respx.post(API_URL).mock(
|
||||
return_value=_graphql_response(
|
||||
{"me": {
|
||||
"id": "u1", "name": "admin",
|
||||
"description": "Admin", "roles": ["admin"],
|
||||
}}
|
||||
{
|
||||
"me": {
|
||||
"id": "u1",
|
||||
"name": "admin",
|
||||
"description": "Admin",
|
||||
"roles": ["admin"],
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
@@ -1061,9 +1113,7 @@ class TestKeysToolRequests:
|
||||
@respx.mock
|
||||
async def test_list_sends_correct_query(self) -> None:
|
||||
route = respx.post(API_URL).mock(
|
||||
return_value=_graphql_response(
|
||||
{"apiKeys": [{"id": "k1", "name": "my-key"}]}
|
||||
)
|
||||
return_value=_graphql_response({"apiKeys": [{"id": "k1", "name": "my-key"}]})
|
||||
)
|
||||
tool = self._get_tool()
|
||||
result = await tool(action="list")
|
||||
@@ -1088,10 +1138,16 @@ class TestKeysToolRequests:
|
||||
async def test_create_sends_input_variables(self) -> None:
|
||||
route = respx.post(API_URL).mock(
|
||||
return_value=_graphql_response(
|
||||
{"apiKey": {"create": {
|
||||
"id": "k2", "name": "new-key",
|
||||
"key": "secret", "roles": ["read"],
|
||||
}}}
|
||||
{
|
||||
"apiKey": {
|
||||
"create": {
|
||||
"id": "k2",
|
||||
"name": "new-key",
|
||||
"key": "secret",
|
||||
"roles": ["read"],
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
@@ -1147,15 +1203,11 @@ class TestHealthToolRequests:
|
||||
|
||||
@staticmethod
|
||||
def _get_tool():
|
||||
return make_tool_fn(
|
||||
"unraid_mcp.tools.health", "register_health_tool", "unraid_health"
|
||||
)
|
||||
return make_tool_fn("unraid_mcp.tools.health", "register_health_tool", "unraid_health")
|
||||
|
||||
@respx.mock
|
||||
async def test_test_connection_sends_online_query(self) -> None:
|
||||
route = respx.post(API_URL).mock(
|
||||
return_value=_graphql_response({"online": True})
|
||||
)
|
||||
route = respx.post(API_URL).mock(return_value=_graphql_response({"online": True}))
|
||||
tool = self._get_tool()
|
||||
result = await tool(action="test_connection")
|
||||
body = _extract_request_body(route.calls.last.request)
|
||||
@@ -1166,21 +1218,23 @@ class TestHealthToolRequests:
|
||||
@respx.mock
|
||||
async def test_check_sends_comprehensive_query(self) -> None:
|
||||
route = respx.post(API_URL).mock(
|
||||
return_value=_graphql_response({
|
||||
"info": {
|
||||
"machineId": "m1",
|
||||
"time": 1234567890,
|
||||
"versions": {"unraid": "7.0"},
|
||||
"os": {"uptime": 86400},
|
||||
},
|
||||
"array": {"state": "STARTED"},
|
||||
"notifications": {
|
||||
"overview": {"unread": {"alert": 0, "warning": 1, "total": 3}},
|
||||
},
|
||||
"docker": {
|
||||
"containers": [{"id": "c1", "state": "running", "status": "Up"}],
|
||||
},
|
||||
})
|
||||
return_value=_graphql_response(
|
||||
{
|
||||
"info": {
|
||||
"machineId": "m1",
|
||||
"time": 1234567890,
|
||||
"versions": {"unraid": "7.0"},
|
||||
"os": {"uptime": 86400},
|
||||
},
|
||||
"array": {"state": "STARTED"},
|
||||
"notifications": {
|
||||
"overview": {"unread": {"alert": 0, "warning": 1, "total": 3}},
|
||||
},
|
||||
"docker": {
|
||||
"containers": [{"id": "c1", "state": "running", "status": "Up"}],
|
||||
},
|
||||
}
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
result = await tool(action="check")
|
||||
@@ -1191,9 +1245,7 @@ class TestHealthToolRequests:
|
||||
|
||||
@respx.mock
|
||||
async def test_test_connection_measures_latency(self) -> None:
|
||||
respx.post(API_URL).mock(
|
||||
return_value=_graphql_response({"online": True})
|
||||
)
|
||||
respx.post(API_URL).mock(return_value=_graphql_response({"online": True}))
|
||||
tool = self._get_tool()
|
||||
result = await tool(action="test_connection")
|
||||
assert "latency_ms" in result
|
||||
@@ -1202,18 +1254,21 @@ class TestHealthToolRequests:
|
||||
@respx.mock
|
||||
async def test_check_reports_warning_on_alerts(self) -> None:
|
||||
respx.post(API_URL).mock(
|
||||
return_value=_graphql_response({
|
||||
"info": {
|
||||
"machineId": "m1", "time": 0,
|
||||
"versions": {"unraid": "7.0"},
|
||||
"os": {"uptime": 0},
|
||||
},
|
||||
"array": {"state": "STARTED"},
|
||||
"notifications": {
|
||||
"overview": {"unread": {"alert": 3, "warning": 0, "total": 5}},
|
||||
},
|
||||
"docker": {"containers": []},
|
||||
})
|
||||
return_value=_graphql_response(
|
||||
{
|
||||
"info": {
|
||||
"machineId": "m1",
|
||||
"time": 0,
|
||||
"versions": {"unraid": "7.0"},
|
||||
"os": {"uptime": 0},
|
||||
},
|
||||
"array": {"state": "STARTED"},
|
||||
"notifications": {
|
||||
"overview": {"unread": {"alert": 3, "warning": 0, "total": 5}},
|
||||
},
|
||||
"docker": {"containers": []},
|
||||
}
|
||||
)
|
||||
)
|
||||
tool = self._get_tool()
|
||||
result = await tool(action="check")
|
||||
@@ -1252,24 +1307,16 @@ class TestCrossCuttingConcerns:
|
||||
@respx.mock
|
||||
async def test_tool_error_from_http_layer_propagates(self) -> None:
|
||||
"""When an HTTP error occurs, the ToolError bubbles up through the tool."""
|
||||
respx.post(API_URL).mock(
|
||||
return_value=httpx.Response(500, text="Server Error")
|
||||
)
|
||||
tool = make_tool_fn(
|
||||
"unraid_mcp.tools.info", "register_info_tool", "unraid_info"
|
||||
)
|
||||
respx.post(API_URL).mock(return_value=httpx.Response(500, text="Server Error"))
|
||||
tool = make_tool_fn("unraid_mcp.tools.info", "register_info_tool", "unraid_info")
|
||||
with pytest.raises(ToolError, match="Unraid API returned HTTP 500"):
|
||||
await tool(action="online")
|
||||
|
||||
@respx.mock
|
||||
async def test_network_error_propagates_through_tool(self) -> None:
|
||||
"""When a network error occurs, the ToolError bubbles up through the tool."""
|
||||
respx.post(API_URL).mock(
|
||||
side_effect=httpx.ConnectError("Connection refused")
|
||||
)
|
||||
tool = make_tool_fn(
|
||||
"unraid_mcp.tools.info", "register_info_tool", "unraid_info"
|
||||
)
|
||||
respx.post(API_URL).mock(side_effect=httpx.ConnectError("Connection refused"))
|
||||
tool = make_tool_fn("unraid_mcp.tools.info", "register_info_tool", "unraid_info")
|
||||
with pytest.raises(ToolError, match="Network error connecting to Unraid API"):
|
||||
await tool(action="online")
|
||||
|
||||
@@ -1277,12 +1324,8 @@ class TestCrossCuttingConcerns:
|
||||
async def test_graphql_error_propagates_through_tool(self) -> None:
|
||||
"""When a GraphQL error occurs, the ToolError bubbles up through the tool."""
|
||||
respx.post(API_URL).mock(
|
||||
return_value=_graphql_response(
|
||||
errors=[{"message": "Permission denied"}]
|
||||
)
|
||||
)
|
||||
tool = make_tool_fn(
|
||||
"unraid_mcp.tools.info", "register_info_tool", "unraid_info"
|
||||
return_value=_graphql_response(errors=[{"message": "Permission denied"}])
|
||||
)
|
||||
tool = make_tool_fn("unraid_mcp.tools.info", "register_info_tool", "unraid_info")
|
||||
with pytest.raises(ToolError, match="Permission denied"):
|
||||
await tool(action="online")
|
||||
|
||||
@@ -88,8 +88,7 @@ class TestDestructiveActionRegistries:
|
||||
"""Each tool's DESTRUCTIVE_ACTIONS must exactly match the audited set."""
|
||||
info = KNOWN_DESTRUCTIVE[tool_key]
|
||||
assert info["runtime_set"] == info["actions"], (
|
||||
f"{tool_key}: DESTRUCTIVE_ACTIONS is {info['runtime_set']}, "
|
||||
f"expected {info['actions']}"
|
||||
f"{tool_key}: DESTRUCTIVE_ACTIONS is {info['runtime_set']}, expected {info['actions']}"
|
||||
)
|
||||
|
||||
@pytest.mark.parametrize("tool_key", list(KNOWN_DESTRUCTIVE.keys()))
|
||||
@@ -131,7 +130,8 @@ class TestDestructiveActionRegistries:
|
||||
missing.extend(
|
||||
f"{tool_key}/{action_name}"
|
||||
for action_name in mutations
|
||||
if ("delete" in action_name or "remove" in action_name) and action_name not in destructive
|
||||
if ("delete" in action_name or "remove" in action_name)
|
||||
and action_name not in destructive
|
||||
)
|
||||
assert not missing, (
|
||||
f"Mutations with 'delete'/'remove' not in DESTRUCTIVE_ACTIONS: {missing}"
|
||||
@@ -198,7 +198,11 @@ def _mock_keys_graphql() -> Generator[AsyncMock, None, None]:
|
||||
_TOOL_REGISTRY = {
|
||||
"docker": ("unraid_mcp.tools.docker", "register_docker_tool", "unraid_docker"),
|
||||
"vm": ("unraid_mcp.tools.virtualization", "register_vm_tool", "unraid_vm"),
|
||||
"notifications": ("unraid_mcp.tools.notifications", "register_notifications_tool", "unraid_notifications"),
|
||||
"notifications": (
|
||||
"unraid_mcp.tools.notifications",
|
||||
"register_notifications_tool",
|
||||
"unraid_notifications",
|
||||
),
|
||||
"rclone": ("unraid_mcp.tools.rclone", "register_rclone_tool", "unraid_rclone"),
|
||||
"keys": ("unraid_mcp.tools.keys", "register_keys_tool", "unraid_keys"),
|
||||
}
|
||||
@@ -275,7 +279,11 @@ class TestConfirmAllowsExecution:
|
||||
|
||||
async def test_docker_update_all_with_confirm(self, _mock_docker_graphql: AsyncMock) -> None:
|
||||
_mock_docker_graphql.return_value = {
|
||||
"docker": {"updateAllContainers": [{"id": "c1", "names": ["app"], "state": "running", "status": "Up"}]}
|
||||
"docker": {
|
||||
"updateAllContainers": [
|
||||
{"id": "c1", "names": ["app"], "state": "running", "status": "Up"}
|
||||
]
|
||||
}
|
||||
}
|
||||
tool_fn = make_tool_fn("unraid_mcp.tools.docker", "register_docker_tool", "unraid_docker")
|
||||
result = await tool_fn(action="update_all", confirm=True)
|
||||
@@ -305,7 +313,12 @@ class TestConfirmAllowsExecution:
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_notifications_delete_with_confirm(self, _mock_notif_graphql: AsyncMock) -> None:
|
||||
_mock_notif_graphql.return_value = {"deleteNotification": {"unread": {"total": 0}}}
|
||||
_mock_notif_graphql.return_value = {
|
||||
"deleteNotification": {
|
||||
"unread": {"info": 0, "warning": 0, "alert": 0, "total": 0},
|
||||
"archive": {"info": 0, "warning": 0, "alert": 0, "total": 0},
|
||||
}
|
||||
}
|
||||
tool_fn = make_tool_fn(
|
||||
"unraid_mcp.tools.notifications", "register_notifications_tool", "unraid_notifications"
|
||||
)
|
||||
@@ -317,8 +330,15 @@ class TestConfirmAllowsExecution:
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_notifications_delete_archived_with_confirm(self, _mock_notif_graphql: AsyncMock) -> None:
|
||||
_mock_notif_graphql.return_value = {"deleteArchivedNotifications": {"archive": {"total": 0}}}
|
||||
async def test_notifications_delete_archived_with_confirm(
|
||||
self, _mock_notif_graphql: AsyncMock
|
||||
) -> None:
|
||||
_mock_notif_graphql.return_value = {
|
||||
"deleteArchivedNotifications": {
|
||||
"unread": {"info": 0, "warning": 0, "alert": 0, "total": 0},
|
||||
"archive": {"info": 0, "warning": 0, "alert": 0, "total": 0},
|
||||
}
|
||||
}
|
||||
tool_fn = make_tool_fn(
|
||||
"unraid_mcp.tools.notifications", "register_notifications_tool", "unraid_notifications"
|
||||
)
|
||||
|
||||
@@ -153,10 +153,25 @@ class TestInfoQueries:
|
||||
from unraid_mcp.tools.info import QUERIES
|
||||
|
||||
expected_actions = {
|
||||
"overview", "array", "network", "registration", "connect",
|
||||
"variables", "metrics", "services", "display", "config",
|
||||
"online", "owner", "settings", "server", "servers",
|
||||
"flash", "ups_devices", "ups_device", "ups_config",
|
||||
"overview",
|
||||
"array",
|
||||
"network",
|
||||
"registration",
|
||||
"connect",
|
||||
"variables",
|
||||
"metrics",
|
||||
"services",
|
||||
"display",
|
||||
"config",
|
||||
"online",
|
||||
"owner",
|
||||
"settings",
|
||||
"server",
|
||||
"servers",
|
||||
"flash",
|
||||
"ups_devices",
|
||||
"ups_device",
|
||||
"ups_config",
|
||||
}
|
||||
assert set(QUERIES.keys()) == expected_actions
|
||||
|
||||
@@ -314,8 +329,13 @@ class TestDockerQueries:
|
||||
from unraid_mcp.tools.docker import QUERIES
|
||||
|
||||
expected = {
|
||||
"list", "details", "logs", "networks",
|
||||
"network_details", "port_conflicts", "check_updates",
|
||||
"list",
|
||||
"details",
|
||||
"logs",
|
||||
"networks",
|
||||
"network_details",
|
||||
"port_conflicts",
|
||||
"check_updates",
|
||||
}
|
||||
assert set(QUERIES.keys()) == expected
|
||||
|
||||
@@ -520,7 +540,19 @@ class TestNotificationMutations:
|
||||
def test_all_notification_mutations_covered(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.notifications import MUTATIONS
|
||||
|
||||
expected = {"create", "archive", "unread", "delete", "delete_archived", "archive_all"}
|
||||
expected = {
|
||||
"create",
|
||||
"archive",
|
||||
"unread",
|
||||
"delete",
|
||||
"delete_archived",
|
||||
"archive_all",
|
||||
"archive_many",
|
||||
"create_unique",
|
||||
"unarchive_many",
|
||||
"unarchive_all",
|
||||
"recalculate",
|
||||
}
|
||||
assert set(MUTATIONS.keys()) == expected
|
||||
|
||||
|
||||
@@ -713,8 +745,7 @@ class TestSchemaCompleteness:
|
||||
failures.append(f"{tool_name}/MUTATIONS/{action}: {errors[0]}")
|
||||
|
||||
assert not failures, (
|
||||
f"{len(failures)} of {total} operations failed validation:\n"
|
||||
+ "\n".join(failures)
|
||||
f"{len(failures)} of {total} operations failed validation:\n" + "\n".join(failures)
|
||||
)
|
||||
|
||||
def test_schema_has_query_type(self, schema: GraphQLSchema) -> None:
|
||||
|
||||
@@ -43,6 +43,7 @@ class TestArrayValidation:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="correct is required"):
|
||||
await tool_fn(action="parity_start")
|
||||
_mock_graphql.assert_not_called()
|
||||
|
||||
|
||||
class TestArrayActions:
|
||||
@@ -53,6 +54,8 @@ class TestArrayActions:
|
||||
assert result["success"] is True
|
||||
assert result["action"] == "parity_start"
|
||||
_mock_graphql.assert_called_once()
|
||||
call_args = _mock_graphql.call_args
|
||||
assert call_args[0][1] == {"correct": False}
|
||||
|
||||
async def test_parity_start_with_correct(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"parityCheck": {"start": True}}
|
||||
|
||||
@@ -90,7 +90,7 @@ class TestNotificationsActions:
|
||||
title="Test",
|
||||
subject="Test Subject",
|
||||
description="Test Desc",
|
||||
importance="normal",
|
||||
importance="info",
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
@@ -101,7 +101,12 @@ class TestNotificationsActions:
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_delete_with_confirm(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"deleteNotification": {"unread": {"total": 0}}}
|
||||
_mock_graphql.return_value = {
|
||||
"deleteNotification": {
|
||||
"unread": {"info": 0, "warning": 0, "alert": 0, "total": 0},
|
||||
"archive": {"info": 0, "warning": 0, "alert": 0, "total": 0},
|
||||
}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(
|
||||
action="delete",
|
||||
@@ -112,7 +117,12 @@ class TestNotificationsActions:
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_archive_all(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"archiveAll": {"archive": {"total": 1}}}
|
||||
_mock_graphql.return_value = {
|
||||
"archiveAll": {
|
||||
"unread": {"info": 0, "warning": 0, "alert": 0, "total": 0},
|
||||
"archive": {"info": 0, "warning": 0, "alert": 0, "total": 1},
|
||||
}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="archive_all")
|
||||
assert result["success"] is True
|
||||
@@ -138,7 +148,12 @@ class TestNotificationsActions:
|
||||
assert filter_var["offset"] == 5
|
||||
|
||||
async def test_delete_archived(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"deleteArchivedNotifications": {"archive": {"total": 0}}}
|
||||
_mock_graphql.return_value = {
|
||||
"deleteArchivedNotifications": {
|
||||
"unread": {"info": 0, "warning": 0, "alert": 0, "total": 0},
|
||||
"archive": {"info": 0, "warning": 0, "alert": 0, "total": 0},
|
||||
}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="delete_archived", confirm=True)
|
||||
assert result["success"] is True
|
||||
@@ -165,8 +180,8 @@ class TestNotificationsCreateValidation:
|
||||
importance="invalid",
|
||||
)
|
||||
|
||||
async def test_info_importance_rejected(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""INFO is listed in old docstring examples but rejected by the validator."""
|
||||
async def test_normal_importance_rejected(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""NORMAL is not a valid GraphQL NotificationImportance value (INFO/WARNING/ALERT are)."""
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="importance must be one of"):
|
||||
await tool_fn(
|
||||
@@ -174,7 +189,7 @@ class TestNotificationsCreateValidation:
|
||||
title="T",
|
||||
subject="S",
|
||||
description="D",
|
||||
importance="info",
|
||||
importance="normal",
|
||||
)
|
||||
|
||||
async def test_alert_importance_accepted(self, _mock_graphql: AsyncMock) -> None:
|
||||
@@ -193,7 +208,7 @@ class TestNotificationsCreateValidation:
|
||||
title="x" * 201,
|
||||
subject="S",
|
||||
description="D",
|
||||
importance="normal",
|
||||
importance="info",
|
||||
)
|
||||
|
||||
async def test_subject_too_long_rejected(self, _mock_graphql: AsyncMock) -> None:
|
||||
@@ -204,7 +219,7 @@ class TestNotificationsCreateValidation:
|
||||
title="T",
|
||||
subject="x" * 501,
|
||||
description="D",
|
||||
importance="normal",
|
||||
importance="info",
|
||||
)
|
||||
|
||||
async def test_description_too_long_rejected(self, _mock_graphql: AsyncMock) -> None:
|
||||
@@ -215,17 +230,118 @@ class TestNotificationsCreateValidation:
|
||||
title="T",
|
||||
subject="S",
|
||||
description="x" * 2001,
|
||||
importance="normal",
|
||||
importance="info",
|
||||
)
|
||||
|
||||
async def test_title_at_max_accepted(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"createNotification": {"id": "n:1", "importance": "NORMAL"}}
|
||||
_mock_graphql.return_value = {"createNotification": {"id": "n:1", "importance": "INFO"}}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(
|
||||
action="create",
|
||||
title="x" * 200,
|
||||
subject="S",
|
||||
description="D",
|
||||
importance="normal",
|
||||
importance="info",
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
class TestNewNotificationMutations:
|
||||
async def test_archive_many_success(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {
|
||||
"archiveNotifications": {
|
||||
"unread": {"info": 0, "warning": 0, "alert": 0, "total": 0},
|
||||
"archive": {"info": 2, "warning": 0, "alert": 0, "total": 2},
|
||||
}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="archive_many", notification_ids=["n:1", "n:2"])
|
||||
assert result["success"] is True
|
||||
call_args = _mock_graphql.call_args
|
||||
assert call_args[0][1] == {"ids": ["n:1", "n:2"]}
|
||||
|
||||
async def test_archive_many_requires_ids(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="notification_ids"):
|
||||
await tool_fn(action="archive_many")
|
||||
|
||||
async def test_create_unique_success(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {
|
||||
"notifyIfUnique": {"id": "n:1", "title": "Test", "importance": "INFO"}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(
|
||||
action="create_unique",
|
||||
title="Test",
|
||||
subject="Subj",
|
||||
description="Desc",
|
||||
importance="info",
|
||||
)
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_create_unique_returns_none_when_duplicate(
|
||||
self, _mock_graphql: AsyncMock
|
||||
) -> None:
|
||||
_mock_graphql.return_value = {"notifyIfUnique": None}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(
|
||||
action="create_unique",
|
||||
title="T",
|
||||
subject="S",
|
||||
description="D",
|
||||
importance="info",
|
||||
)
|
||||
assert result["success"] is True
|
||||
assert result["duplicate"] is True
|
||||
|
||||
async def test_create_unique_requires_fields(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="requires title"):
|
||||
await tool_fn(action="create_unique")
|
||||
|
||||
async def test_unarchive_many_success(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {
|
||||
"unarchiveNotifications": {
|
||||
"unread": {"info": 2, "warning": 0, "alert": 0, "total": 2},
|
||||
"archive": {"info": 0, "warning": 0, "alert": 0, "total": 0},
|
||||
}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="unarchive_many", notification_ids=["n:1", "n:2"])
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_unarchive_many_requires_ids(self, _mock_graphql: AsyncMock) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="notification_ids"):
|
||||
await tool_fn(action="unarchive_many")
|
||||
|
||||
async def test_unarchive_all_success(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {
|
||||
"unarchiveAll": {
|
||||
"unread": {"info": 5, "warning": 1, "alert": 0, "total": 6},
|
||||
"archive": {"info": 0, "warning": 0, "alert": 0, "total": 0},
|
||||
}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="unarchive_all")
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_unarchive_all_with_importance(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {
|
||||
"unarchiveAll": {"unread": {"total": 1}, "archive": {"total": 0}}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
await tool_fn(action="unarchive_all", importance="WARNING")
|
||||
call_args = _mock_graphql.call_args
|
||||
assert call_args[0][1] == {"importance": "WARNING"}
|
||||
|
||||
async def test_recalculate_success(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {
|
||||
"recalculateOverview": {
|
||||
"unread": {"info": 3, "warning": 1, "alert": 0, "total": 4},
|
||||
"archive": {"info": 10, "warning": 0, "alert": 0, "total": 10},
|
||||
}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
result = await tool_fn(action="recalculate")
|
||||
assert result["success"] is True
|
||||
|
||||
Reference in New Issue
Block a user