Add list_images and delete_image tools (Gruppe 1)
- list_images: lists all local Docker images sorted by size desc, shows size (human-readable), creation date, in-use marker, and update-available marker; gracefully handles container list failure - delete_image: accepts name:tag or image hash, blocks deletion when image is in use by a container, requires confirmed=True to execute; default shows a dry-run preview - 16 unit tests covering all paths (mock DSM client) - ruff format + check clean Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,8 +1,9 @@
|
||||
"""Tests for modules/images.py."""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
def make_mock_mcp():
|
||||
tools: dict = {}
|
||||
@@ -12,6 +13,7 @@ def make_mock_mcp():
|
||||
def decorator(fn):
|
||||
tools[fn.__name__] = fn
|
||||
return fn
|
||||
|
||||
return decorator
|
||||
|
||||
return MockMCP(), tools
|
||||
@@ -19,6 +21,7 @@ def make_mock_mcp():
|
||||
|
||||
def make_config():
|
||||
from mcp_synology_container.config import AppConfig, ConnectionConfig
|
||||
|
||||
return AppConfig(
|
||||
schema_version=1,
|
||||
connection=ConnectionConfig(host="nas.local", port=443, https=True, verify_ssl=True),
|
||||
@@ -32,6 +35,7 @@ SAMPLE_IMAGES = {
|
||||
"repository": "nginx",
|
||||
"tags": ["1.24"],
|
||||
"size": 50 * 1024 * 1024,
|
||||
"created": 1700000000,
|
||||
"upgradable": True,
|
||||
},
|
||||
{
|
||||
@@ -39,6 +43,7 @@ SAMPLE_IMAGES = {
|
||||
"repository": "postgres",
|
||||
"tags": ["15"],
|
||||
"size": 80 * 1024 * 1024,
|
||||
"created": 1700000000,
|
||||
"upgradable": False,
|
||||
},
|
||||
{
|
||||
@@ -46,11 +51,274 @@ SAMPLE_IMAGES = {
|
||||
"repository": "redis",
|
||||
"tags": ["7"],
|
||||
"size": 30 * 1024 * 1024,
|
||||
"created": 1700000000,
|
||||
"upgradable": False,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
SAMPLE_CONTAINERS = {
|
||||
"containers": [
|
||||
{"name": "my-nginx", "image_id": "sha256:aaaa", "status": "running"},
|
||||
]
|
||||
}
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# list_images
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_images_sorted_by_size():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image":
|
||||
return SAMPLE_IMAGES
|
||||
if api == "SYNO.Docker.Container":
|
||||
return SAMPLE_CONTAINERS
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_images"]()
|
||||
# postgres (80 MiB) should appear before nginx (50 MiB) before redis (30 MiB)
|
||||
pos_postgres = result.index("postgres")
|
||||
pos_nginx = result.index("nginx")
|
||||
pos_redis = result.index("redis")
|
||||
assert pos_postgres < pos_nginx < pos_redis
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_images_shows_in_use():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image":
|
||||
return SAMPLE_IMAGES
|
||||
if api == "SYNO.Docker.Container":
|
||||
return SAMPLE_CONTAINERS
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_images"]()
|
||||
assert "[in use]" in result
|
||||
assert "[update available]" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_images_no_images():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image":
|
||||
return {"images": []}
|
||||
return {"containers": []}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_images"]()
|
||||
assert "No local images found" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_images_api_error():
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = SynologyError("API unavailable", code=102)
|
||||
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_images"]()
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_list_images_container_error_graceful():
|
||||
"""Container list failure must not prevent image listing."""
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image":
|
||||
return SAMPLE_IMAGES
|
||||
raise SynologyError("containers unavailable", code=102)
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["list_images"]()
|
||||
assert "postgres" in result # images still listed
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# delete_image
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_image_preview():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "list":
|
||||
return SAMPLE_IMAGES
|
||||
if api == "SYNO.Docker.Container":
|
||||
return {"containers": []}
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["delete_image"](image_id="redis:7")
|
||||
assert "Preview" in result
|
||||
assert "redis:7" in result
|
||||
# Should not have called the delete method
|
||||
calls = [str(c) for c in client.request.call_args_list]
|
||||
assert not any("delete" in c for c in calls)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_image_confirmed():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "list":
|
||||
return SAMPLE_IMAGES
|
||||
if api == "SYNO.Docker.Container":
|
||||
return {"containers": []}
|
||||
if api == "SYNO.Docker.Image" and method == "delete":
|
||||
return {}
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["delete_image"](image_id="redis:7", confirmed=True)
|
||||
assert "Deleted" in result
|
||||
assert "redis:7" in result
|
||||
assert "freed" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_image_not_found():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "list":
|
||||
return SAMPLE_IMAGES
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["delete_image"](image_id="nonexistent:latest", confirmed=True)
|
||||
assert "not found" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_image_in_use_blocked():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "list":
|
||||
return SAMPLE_IMAGES
|
||||
if api == "SYNO.Docker.Container":
|
||||
return SAMPLE_CONTAINERS # nginx is in use
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["delete_image"](image_id="nginx:1.24", confirmed=True)
|
||||
assert "Cannot delete" in result
|
||||
assert "my-nginx" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_image_by_hash():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "list":
|
||||
return SAMPLE_IMAGES
|
||||
if api == "SYNO.Docker.Container":
|
||||
return {"containers": []}
|
||||
if api == "SYNO.Docker.Image" and method == "delete":
|
||||
return {}
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["delete_image"](image_id="sha256:cccc", confirmed=True)
|
||||
assert "Deleted" in result
|
||||
assert "redis" in result
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_delete_image_api_error():
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
|
||||
async def mock_request(api, method, **kwargs):
|
||||
if api == "SYNO.Docker.Image" and method == "list":
|
||||
return SAMPLE_IMAGES
|
||||
if api == "SYNO.Docker.Container":
|
||||
return {"containers": []}
|
||||
if api == "SYNO.Docker.Image" and method == "delete":
|
||||
raise SynologyError("delete failed", code=1)
|
||||
return {}
|
||||
|
||||
client.request.side_effect = mock_request
|
||||
mcp, tools = make_mock_mcp()
|
||||
register_images(mcp, make_config(), client)
|
||||
|
||||
result = await tools["delete_image"](image_id="redis:7", confirmed=True)
|
||||
assert "Error" in result
|
||||
|
||||
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
# check_image_updates (existing tests preserved)
|
||||
# ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_image_updates_all():
|
||||
@@ -75,7 +343,13 @@ async def test_check_image_updates_all_up_to_date():
|
||||
client = AsyncMock()
|
||||
client.request.return_value = {
|
||||
"images": [
|
||||
{"id": "sha256:aaaa", "repository": "nginx", "tags": ["1.24"], "size": 50 * 1024 * 1024, "upgradable": False},
|
||||
{
|
||||
"id": "sha256:aaaa",
|
||||
"repository": "nginx",
|
||||
"tags": ["1.24"],
|
||||
"size": 50 * 1024 * 1024,
|
||||
"upgradable": False,
|
||||
},
|
||||
]
|
||||
}
|
||||
|
||||
@@ -102,8 +376,8 @@ async def test_check_image_updates_no_images():
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_image_updates_api_error():
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
from mcp_synology_container.dsm_client import SynologyError
|
||||
from mcp_synology_container.modules.images import register_images
|
||||
|
||||
client = AsyncMock()
|
||||
client.request.side_effect = SynologyError("API unavailable", code=102)
|
||||
|
||||
Reference in New Issue
Block a user