mirror of
https://github.com/jmagar/unraid-mcp.git
synced 2026-03-23 12:39:24 -07:00
fix: flash_backup validation, smoke test assertions, docker/notification test coverage
- storage.py: validate initiateFlashBackup response before returning success=True - test-tools.sh: remove set -e/inherit_errexit; add success assertion to smoke tests - test_destructive_guards.py: add confirm-guard tests for new docker destructive actions - test_docker.py: assert mutation variables in organizer tests; add items branch test - test_query_validation.py: add 5 missing notification mutation schema test methods - test_notifications.py: use lowercase importance to test uppercasing logic Resolves review threads PRRT_kwDOO6Hdxs50FgPb PRRT_kwDOO6Hdxs50FgO4 PRRT_kwDOO6Hdxs50FgO8 PRRT_kwDOO6Hdxs50FgPI PRRT_kwDOO6Hdxs50FgPL PRRT_kwDOO6Hdxs50FgPm PRRT_kwDOO6Hdxs50E2iK PRRT_kwDOO6Hdxs50E2im
This commit is contained in:
@@ -148,6 +148,8 @@ _DESTRUCTIVE_TEST_CASES: list[tuple[str, str, dict]] = [
|
||||
# Docker
|
||||
("docker", "remove", {"container_id": "abc123"}),
|
||||
("docker", "update_all", {}),
|
||||
("docker", "delete_entries", {"entry_ids": ["e1"]}),
|
||||
("docker", "reset_template_mappings", {}),
|
||||
# VM
|
||||
("vm", "force_stop", {"vm_id": "test-vm-uuid"}),
|
||||
("vm", "reset", {"vm_id": "test-vm-uuid"}),
|
||||
@@ -290,6 +292,28 @@ class TestConfirmAllowsExecution:
|
||||
assert result["success"] is True
|
||||
assert result["action"] == "update_all"
|
||||
|
||||
async def test_docker_delete_entries_with_confirm(
|
||||
self, _mock_docker_graphql: AsyncMock
|
||||
) -> None:
|
||||
organizer_response = {
|
||||
"version": 1.0,
|
||||
"views": [{"id": "default", "name": "Default", "rootId": "root", "flatEntries": []}],
|
||||
}
|
||||
_mock_docker_graphql.return_value = {"deleteDockerEntries": organizer_response}
|
||||
tool_fn = make_tool_fn("unraid_mcp.tools.docker", "register_docker_tool", "unraid_docker")
|
||||
result = await tool_fn(action="delete_entries", entry_ids=["e1"], confirm=True)
|
||||
assert result["success"] is True
|
||||
assert result["action"] == "delete_entries"
|
||||
|
||||
async def test_docker_reset_template_mappings_with_confirm(
|
||||
self, _mock_docker_graphql: AsyncMock
|
||||
) -> None:
|
||||
_mock_docker_graphql.return_value = {"resetDockerTemplateMappings": True}
|
||||
tool_fn = make_tool_fn("unraid_mcp.tools.docker", "register_docker_tool", "unraid_docker")
|
||||
result = await tool_fn(action="reset_template_mappings", confirm=True)
|
||||
assert result["success"] is True
|
||||
assert result["action"] == "reset_template_mappings"
|
||||
|
||||
async def test_docker_remove_with_confirm(self, _mock_docker_graphql: AsyncMock) -> None:
|
||||
cid = "a" * 64 + ":local"
|
||||
_mock_docker_graphql.side_effect = [
|
||||
|
||||
@@ -537,6 +537,36 @@ class TestNotificationMutations:
|
||||
errors = _validate_operation(schema, MUTATIONS["archive_all"])
|
||||
assert not errors, f"archive_all mutation validation failed: {errors}"
|
||||
|
||||
def test_archive_many_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.notifications import MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["archive_many"])
|
||||
assert not errors, f"archive_many mutation validation failed: {errors}"
|
||||
|
||||
def test_create_unique_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.notifications import MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["create_unique"])
|
||||
assert not errors, f"create_unique mutation validation failed: {errors}"
|
||||
|
||||
def test_unarchive_many_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.notifications import MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["unarchive_many"])
|
||||
assert not errors, f"unarchive_many mutation validation failed: {errors}"
|
||||
|
||||
def test_unarchive_all_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.notifications import MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["unarchive_all"])
|
||||
assert not errors, f"unarchive_all mutation validation failed: {errors}"
|
||||
|
||||
def test_recalculate_mutation(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.notifications import MUTATIONS
|
||||
|
||||
errors = _validate_operation(schema, MUTATIONS["recalculate"])
|
||||
assert not errors, f"recalculate mutation validation failed: {errors}"
|
||||
|
||||
def test_all_notification_mutations_covered(self, schema: GraphQLSchema) -> None:
|
||||
from unraid_mcp.tools.notifications import MUTATIONS
|
||||
|
||||
|
||||
@@ -70,7 +70,9 @@ class TestDockerValidation:
|
||||
await tool_fn(action="remove", container_id="abc123")
|
||||
|
||||
@pytest.mark.parametrize("action", ["start", "stop", "details", "logs", "pause", "unpause"])
|
||||
async def test_container_actions_require_id(self, _mock_graphql: AsyncMock, action: str) -> None:
|
||||
async def test_container_actions_require_id(
|
||||
self, _mock_graphql: AsyncMock, action: str
|
||||
) -> None:
|
||||
tool_fn = _make_tool()
|
||||
with pytest.raises(ToolError, match="container_id"):
|
||||
await tool_fn(action=action)
|
||||
@@ -102,13 +104,7 @@ class TestDockerActions:
|
||||
# First call resolves ID, second performs start
|
||||
cid = "a" * 64 + ":local"
|
||||
_mock_graphql.side_effect = [
|
||||
{
|
||||
"docker": {
|
||||
"containers": [
|
||||
{"id": cid, "names": ["plex"]}
|
||||
]
|
||||
}
|
||||
},
|
||||
{"docker": {"containers": [{"id": cid, "names": ["plex"]}]}},
|
||||
{
|
||||
"docker": {
|
||||
"start": {
|
||||
@@ -239,8 +235,14 @@ class TestDockerActions:
|
||||
_mock_graphql.return_value = {
|
||||
"docker": {
|
||||
"containers": [
|
||||
{"id": "abcdef1234560000000000000000000000000000000000000000000000000000:local", "names": ["plex"]},
|
||||
{"id": "abcdef1234561111111111111111111111111111111111111111111111111111:local", "names": ["sonarr"]},
|
||||
{
|
||||
"id": "abcdef1234560000000000000000000000000000000000000000000000000000:local",
|
||||
"names": ["plex"],
|
||||
},
|
||||
{
|
||||
"id": "abcdef1234561111111111111111111111111111111111111111111111111111:local",
|
||||
"names": ["sonarr"],
|
||||
},
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -344,7 +346,10 @@ class TestDockerNetworkErrors:
|
||||
await tool_fn(action="list")
|
||||
|
||||
|
||||
_ORGANIZER_RESPONSE = {"version": 1.0, "views": [{"id": "default", "name": "Default", "rootId": "root", "flatEntries": []}]}
|
||||
_ORGANIZER_RESPONSE = {
|
||||
"version": 1.0,
|
||||
"views": [{"id": "default", "name": "Default", "rootId": "root", "flatEntries": []}],
|
||||
}
|
||||
|
||||
|
||||
class TestDockerOrganizerMutations:
|
||||
@@ -352,6 +357,8 @@ class TestDockerOrganizerMutations:
|
||||
_mock_graphql.return_value = {"createDockerFolder": _ORGANIZER_RESPONSE}
|
||||
result = await _make_tool()(action="create_folder", folder_name="Media")
|
||||
assert result["success"] is True
|
||||
call_vars = _mock_graphql.call_args[0][1]
|
||||
assert call_vars["name"] == "Media"
|
||||
|
||||
async def test_create_folder_requires_name(self, _mock_graphql: AsyncMock) -> None:
|
||||
with pytest.raises(ToolError, match="folder_name"):
|
||||
@@ -361,6 +368,8 @@ class TestDockerOrganizerMutations:
|
||||
_mock_graphql.return_value = {"setDockerFolderChildren": _ORGANIZER_RESPONSE}
|
||||
result = await _make_tool()(action="set_folder_children", children_ids=["c1"])
|
||||
assert result["success"] is True
|
||||
call_vars = _mock_graphql.call_args[0][1]
|
||||
assert call_vars["childrenIds"] == ["c1"]
|
||||
|
||||
async def test_set_folder_children_requires_children(self, _mock_graphql: AsyncMock) -> None:
|
||||
with pytest.raises(ToolError, match="children_ids"):
|
||||
@@ -376,13 +385,20 @@ class TestDockerOrganizerMutations:
|
||||
|
||||
async def test_delete_entries_success(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"deleteDockerEntries": _ORGANIZER_RESPONSE}
|
||||
result = await _make_tool()(action="delete_entries", entry_ids=["e1"], confirm=True)
|
||||
result = await _make_tool()(action="delete_entries", entry_ids=["e1", "e2"], confirm=True)
|
||||
assert result["success"] is True
|
||||
call_vars = _mock_graphql.call_args[0][1]
|
||||
assert call_vars["entryIds"] == ["e1", "e2"]
|
||||
|
||||
async def test_move_to_folder_success(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"moveDockerEntriesToFolder": _ORGANIZER_RESPONSE}
|
||||
result = await _make_tool()(action="move_to_folder", source_entry_ids=["e1"], destination_folder_id="f1")
|
||||
result = await _make_tool()(
|
||||
action="move_to_folder", source_entry_ids=["e1"], destination_folder_id="f1"
|
||||
)
|
||||
assert result["success"] is True
|
||||
call_vars = _mock_graphql.call_args[0][1]
|
||||
assert call_vars["sourceEntryIds"] == ["e1"]
|
||||
assert call_vars["destinationFolderId"] == "f1"
|
||||
|
||||
async def test_move_to_folder_requires_source_ids(self, _mock_graphql: AsyncMock) -> None:
|
||||
with pytest.raises(ToolError, match="source_entry_ids"):
|
||||
@@ -394,17 +410,31 @@ class TestDockerOrganizerMutations:
|
||||
|
||||
async def test_move_to_position_success(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"moveDockerItemsToPosition": _ORGANIZER_RESPONSE}
|
||||
result = await _make_tool()(action="move_to_position", source_entry_ids=["e1"], destination_folder_id="f1", position=2.0)
|
||||
result = await _make_tool()(
|
||||
action="move_to_position",
|
||||
source_entry_ids=["e1"],
|
||||
destination_folder_id="f1",
|
||||
position=2.0,
|
||||
)
|
||||
assert result["success"] is True
|
||||
call_vars = _mock_graphql.call_args[0][1]
|
||||
assert call_vars["sourceEntryIds"] == ["e1"]
|
||||
assert call_vars["destinationFolderId"] == "f1"
|
||||
assert call_vars["position"] == 2.0
|
||||
|
||||
async def test_move_to_position_requires_position(self, _mock_graphql: AsyncMock) -> None:
|
||||
with pytest.raises(ToolError, match="position"):
|
||||
await _make_tool()(action="move_to_position", source_entry_ids=["e1"], destination_folder_id="f1")
|
||||
await _make_tool()(
|
||||
action="move_to_position", source_entry_ids=["e1"], destination_folder_id="f1"
|
||||
)
|
||||
|
||||
async def test_rename_folder_success(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"renameDockerFolder": _ORGANIZER_RESPONSE}
|
||||
result = await _make_tool()(action="rename_folder", folder_id="f1", new_folder_name="New")
|
||||
assert result["success"] is True
|
||||
call_vars = _mock_graphql.call_args[0][1]
|
||||
assert call_vars["folderId"] == "f1"
|
||||
assert call_vars["newName"] == "New"
|
||||
|
||||
async def test_rename_folder_requires_folder_id(self, _mock_graphql: AsyncMock) -> None:
|
||||
with pytest.raises(ToolError, match="folder_id"):
|
||||
@@ -418,6 +448,22 @@ class TestDockerOrganizerMutations:
|
||||
_mock_graphql.return_value = {"createDockerFolderWithItems": _ORGANIZER_RESPONSE}
|
||||
result = await _make_tool()(action="create_folder_with_items", folder_name="New")
|
||||
assert result["success"] is True
|
||||
call_vars = _mock_graphql.call_args[0][1]
|
||||
assert call_vars["name"] == "New"
|
||||
assert "sourceEntryIds" not in call_vars # not forwarded when not provided
|
||||
|
||||
async def test_create_folder_with_items_with_source_ids(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""Passing source_entry_ids must forward sourceEntryIds to the mutation."""
|
||||
_mock_graphql.return_value = {"createDockerFolderWithItems": _ORGANIZER_RESPONSE}
|
||||
result = await _make_tool()(
|
||||
action="create_folder_with_items",
|
||||
folder_name="Media",
|
||||
source_entry_ids=["c1", "c2"],
|
||||
)
|
||||
assert result["success"] is True
|
||||
call_vars = _mock_graphql.call_args[0][1]
|
||||
assert call_vars["name"] == "Media"
|
||||
assert call_vars["sourceEntryIds"] == ["c1", "c2"]
|
||||
|
||||
async def test_create_folder_with_items_requires_name(self, _mock_graphql: AsyncMock) -> None:
|
||||
with pytest.raises(ToolError, match="folder_name"):
|
||||
@@ -427,13 +473,17 @@ class TestDockerOrganizerMutations:
|
||||
_mock_graphql.return_value = {"updateDockerViewPreferences": _ORGANIZER_RESPONSE}
|
||||
result = await _make_tool()(action="update_view_prefs", view_prefs={"sort": "name"})
|
||||
assert result["success"] is True
|
||||
call_vars = _mock_graphql.call_args[0][1]
|
||||
assert call_vars["prefs"] == {"sort": "name"}
|
||||
|
||||
async def test_update_view_prefs_requires_prefs(self, _mock_graphql: AsyncMock) -> None:
|
||||
with pytest.raises(ToolError, match="view_prefs"):
|
||||
await _make_tool()(action="update_view_prefs")
|
||||
|
||||
async def test_sync_templates_success(self, _mock_graphql: AsyncMock) -> None:
|
||||
_mock_graphql.return_value = {"syncDockerTemplatePaths": {"scanned": 5, "matched": 4, "skipped": 1, "errors": []}}
|
||||
_mock_graphql.return_value = {
|
||||
"syncDockerTemplatePaths": {"scanned": 5, "matched": 4, "skipped": 1, "errors": []}
|
||||
}
|
||||
result = await _make_tool()(action="sync_templates")
|
||||
assert result["success"] is True
|
||||
|
||||
|
||||
@@ -327,11 +327,12 @@ class TestNewNotificationMutations:
|
||||
assert result["success"] is True
|
||||
|
||||
async def test_unarchive_all_with_importance(self, _mock_graphql: AsyncMock) -> None:
|
||||
"""Lowercase importance input must be uppercased before being sent to GraphQL."""
|
||||
_mock_graphql.return_value = {
|
||||
"unarchiveAll": {"unread": {"total": 1}, "archive": {"total": 0}}
|
||||
}
|
||||
tool_fn = _make_tool()
|
||||
await tool_fn(action="unarchive_all", importance="WARNING")
|
||||
await tool_fn(action="unarchive_all", importance="warning")
|
||||
call_args = _mock_graphql.call_args
|
||||
assert call_args[0][1] == {"importance": "WARNING"}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user