Files
mcp-synology-container/tests/test_modules/test_registry.py
T
marcus 18fe063691 fix: v0.5.1 — pull_image API (Image/pull_start, not Registry/pull_start)
The 0.5.0 prompt mis-attributed the API: pull_start lives on
SYNO.Docker.Image, not SYNO.Docker.Registry (live DSM capture).
search and tags ARE correctly on Registry; only pull_start belongs
to Image. Registry/pull_start returns "Method does not exist".

Parameters are unchanged (repository + tag both JSON-encoded), and
the Image/list polling for completion detection is untouched.

Tests updated to assert SYNO.Docker.Image/pull_start. CLAUDE.md
DSM-quirks section consolidates the Image vs. Registry split so
this trap is documented for future surface additions. References
#3 (already closed).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-18 13:32:13 +02:00

388 lines
13 KiB
Python

"""Tests for modules/registry.py."""
import json
from unittest.mock import AsyncMock
import pytest
def make_mock_mcp():
tools: dict = {}
class MockMCP:
def tool(self):
def decorator(fn):
tools[fn.__name__] = fn
return fn
return decorator
return MockMCP(), tools
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),
)
# ──────────────────────────────────────────────────────────────────────────────
# search_registry — Issue #5
# ──────────────────────────────────────────────────────────────────────────────
SEARCH_RESPONSE = {
"data": [
{
"name": "caddy",
"description": (
"Caddy 2 is a powerful, enterprise-ready, open source web "
"server with automatic HTTPS written in Go."
),
"downloads": 650989718,
"is_automated": False,
"is_official": True,
"star_count": 881,
"registry": "https://registry.hub.docker.com",
},
{
"name": "abiosoft/caddy",
"description": "Caddy is a lightweight, general-purpose web server.",
"downloads": 111974910,
"is_automated": True,
"is_official": False,
"star_count": 289,
"registry": "https://registry.hub.docker.com",
},
],
"limit": 20,
"offset": 0,
"page_size": 20,
"total": 6647,
}
@pytest.mark.asyncio
async def test_search_registry_renders_hits():
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
client.request.return_value = SEARCH_RESPONSE
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["search_registry"](query="caddy")
assert "caddy" in result
assert "abiosoft/caddy" in result
assert "[official]" in result
assert "881" in result # stars
assert "650989718" in result # downloads
assert "2 of 6647" in result
@pytest.mark.asyncio
async def test_search_registry_uses_json_encoded_query():
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
client.request.return_value = SEARCH_RESPONSE
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
await tools["search_registry"](query="nginx", limit=10)
call = client.request.call_args
assert call.args[0] == "SYNO.Docker.Registry"
assert call.args[1] == "search"
assert call.kwargs.get("version") == 1
params = call.kwargs.get("params", {})
assert json.loads(params["q"]) == "nginx"
assert params["offset"] == "0"
assert params["limit"] == "10"
assert params["page_size"] == "10"
@pytest.mark.asyncio
async def test_search_registry_empty():
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
client.request.return_value = {"data": [], "total": 0}
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["search_registry"](query="does-not-exist")
assert "No results" in result
@pytest.mark.asyncio
async def test_search_registry_api_error():
from mcp_synology_container.dsm_client import SynologyError
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
client.request.side_effect = SynologyError("Auth failure", code=119)
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["search_registry"](query="caddy")
assert "Error" in result
# ──────────────────────────────────────────────────────────────────────────────
# list_image_tags — bonus
# ──────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_list_image_tags_array_response():
"""DSM returns the envelope's data field directly as a list."""
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
client.request.return_value = [
{"tag": "latest"},
{"tag": "3.20"},
{"tag": "3.19"},
]
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["list_image_tags"](repository="alpine")
assert "alpine" in result
assert "latest" in result
assert "3.20" in result
assert "3.19" in result
assert "3 total" in result
@pytest.mark.asyncio
async def test_list_image_tags_uses_json_repo_param():
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
client.request.return_value = [{"tag": "latest"}]
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
await tools["list_image_tags"](repository="grafana/grafana")
call = client.request.call_args
assert call.args[0] == "SYNO.Docker.Registry"
assert call.args[1] == "tags"
assert call.kwargs.get("version") == 1
params = call.kwargs.get("params", {})
assert json.loads(params["repo"]) == "grafana/grafana"
@pytest.mark.asyncio
async def test_list_image_tags_limit_truncates():
from mcp_synology_container.modules.registry import register_registry
tags = [{"tag": f"v{i}"} for i in range(120)]
client = AsyncMock()
client.request.return_value = tags
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["list_image_tags"](repository="alpine", limit=10)
assert "120 total" in result
assert "Showing first 10 of 120" in result
# Tag #10 must not appear (only v0..v9)
assert " v9" in result
assert " v10" not in result
@pytest.mark.asyncio
async def test_list_image_tags_empty():
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
# Empty list → DsmClient.request coerces to {} via `or {}`
client.request.return_value = {}
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["list_image_tags"](repository="nonexistent/image")
assert "No tags found" in result
@pytest.mark.asyncio
async def test_list_image_tags_api_error():
from mcp_synology_container.dsm_client import SynologyError
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
client.request.side_effect = SynologyError("Boom", code=100)
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["list_image_tags"](repository="alpine")
assert "Error" in result
# ──────────────────────────────────────────────────────────────────────────────
# pull_image — Issue #3
# ──────────────────────────────────────────────────────────────────────────────
@pytest.mark.asyncio
async def test_pull_image_preview():
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["pull_image"](repository="nginx", tag="1.24")
assert "Preview" in result
assert "nginx:1.24" in result
assert "confirmed=True" in result
client.request.assert_not_called()
@pytest.mark.asyncio
async def test_pull_image_already_present():
"""If image is already in Image/list, no pull_start call is made."""
from mcp_synology_container.modules.registry import register_registry
client = AsyncMock()
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Image" and method == "list":
return {
"images": [
{"id": "sha256:aaaa", "repository": "nginx", "tags": ["1.24"]},
]
}
raise AssertionError(f"Unexpected call: {api}/{method}")
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
assert "already present" in result
# Only the Image/list pre-check was called; no pull_start of any kind.
calls = client.request.call_args_list
assert all(c.args[1] != "pull_start" for c in calls)
@pytest.mark.asyncio
async def test_pull_image_confirmed_success(monkeypatch):
"""pull_start succeeds, then Image/list shows the new tag on first poll."""
import mcp_synology_container.modules.registry as registry_mod
from mcp_synology_container.modules.registry import register_registry
# Make polling instant
async def fake_sleep(_):
return None
monkeypatch.setattr(registry_mod.asyncio, "sleep", fake_sleep)
state = {"pulled": False}
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Image" and method == "list":
if state["pulled"]:
return {
"images": [
{"id": "sha256:aaaa", "repository": "nginx", "tags": ["1.24"]},
]
}
return {"images": []}
if api == "SYNO.Docker.Image" and method == "pull_start":
state["pulled"] = True
return {}
raise AssertionError(f"Unexpected call: {api}/{method}")
client = AsyncMock()
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
assert "Pulled" in result
assert "nginx:1.24" in result
# Verify pull_start was invoked on SYNO.Docker.Image (not Registry)
# with JSON-encoded params.
pull_calls = [c for c in client.request.call_args_list if c.args[1] == "pull_start"]
assert len(pull_calls) == 1
assert pull_calls[0].args[0] == "SYNO.Docker.Image"
params = pull_calls[0].kwargs.get("params", {})
assert json.loads(params["repository"]) == "nginx"
assert json.loads(params["tag"]) == "1.24"
assert pull_calls[0].kwargs.get("version") == 1
@pytest.mark.asyncio
async def test_pull_image_timeout(monkeypatch):
"""If the image never appears, the tool returns a still-running hint."""
import mcp_synology_container.modules.registry as registry_mod
from mcp_synology_container.modules.registry import register_registry
# Make polling instant
async def fake_sleep(_):
return None
monkeypatch.setattr(registry_mod.asyncio, "sleep", fake_sleep)
# Shrink the budget so the loop exits quickly
monkeypatch.setattr(registry_mod, "_PULL_POLL_TIMEOUT", 0.05)
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Image" and method == "list":
return {"images": []}
if api == "SYNO.Docker.Image" and method == "pull_start":
return {}
raise AssertionError(f"Unexpected call: {api}/{method}")
client = AsyncMock()
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
assert "still running" in result
assert "nginx:1.24" in result
@pytest.mark.asyncio
async def test_pull_image_start_error():
from mcp_synology_container.dsm_client import SynologyError
from mcp_synology_container.modules.registry import register_registry
async def mock_request(api, method, **kwargs):
if api == "SYNO.Docker.Image" and method == "list":
return {"images": []}
if api == "SYNO.Docker.Image" and method == "pull_start":
raise SynologyError("Permission denied", code=105)
raise AssertionError(f"Unexpected call: {api}/{method}")
client = AsyncMock()
client.request.side_effect = mock_request
mcp, tools = make_mock_mcp()
register_registry(mcp, make_config(), client)
result = await tools["pull_image"](repository="nginx", tag="1.24", confirmed=True)
assert "Error starting pull" in result