forked from HomeLab/unraid-mcp
BREAKING CHANGE: Replaces 15 separate MCP tools (unraid_info, unraid_array, unraid_storage, unraid_docker, unraid_vm, unraid_notifications, unraid_rclone, unraid_users, unraid_keys, unraid_health, unraid_settings, unraid_customization, unraid_plugins, unraid_oidc, unraid_live) with a single `unraid` tool using action (domain) + subaction (operation) routing. New interface: unraid(action="system", subaction="overview") replaces unraid_info(action="overview"). All 15 domains and ~108 subactions preserved. - Add unraid_mcp/tools/unraid.py (1891 lines, all domains consolidated) - Remove 15 individual tool files - Update tools/__init__.py to register single unified tool - Update server.py for new tool registration pattern - Update subscriptions/manager.py and resources.py for new tool names - Update all 25 test files + integration/contract/safety/schema/property tests - Update mcporter smoke-test script for new tool interface - Bump version 0.6.0 → 1.0.0 Co-authored-by: Claude <noreply@anthropic.com>
977 lines
34 KiB
Python
977 lines
34 KiB
Python
"""Contract tests: validate GraphQL response shapes with Pydantic models.
|
|
|
|
These tests document and enforce the response structure that callers of each
|
|
tool action can rely on. A Pydantic ValidationError here means the tool's
|
|
response shape changed — a breaking change for any downstream consumer.
|
|
|
|
Coverage:
|
|
- Docker: list, details, networks, start/stop mutations
|
|
- Info: overview, array, metrics, services, online, registration, network
|
|
- Storage: shares, disks, disk_details, log_files
|
|
- Notifications: overview, list, create
|
|
"""
|
|
|
|
from collections.abc import Generator
|
|
from typing import Any
|
|
from unittest.mock import AsyncMock, patch
|
|
|
|
import pytest
|
|
from pydantic import BaseModel, ValidationError
|
|
|
|
from tests.conftest import make_tool_fn
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Pydantic contract models
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
# --- Docker ---
|
|
|
|
|
|
class DockerContainer(BaseModel):
|
|
"""Minimal shape of a container as returned by docker/list."""
|
|
|
|
id: str
|
|
names: list[str]
|
|
state: str
|
|
image: str | None = None
|
|
status: str | None = None
|
|
autoStart: bool | None = None
|
|
|
|
|
|
class DockerContainerDetails(BaseModel):
|
|
"""Extended shape returned by docker/details."""
|
|
|
|
id: str
|
|
names: list[str]
|
|
state: str
|
|
image: str | None = None
|
|
imageId: str | None = None
|
|
command: str | None = None
|
|
created: Any = None
|
|
ports: list[Any] | None = None
|
|
sizeRootFs: Any = None
|
|
labels: Any = None
|
|
status: str | None = None
|
|
autoStart: bool | None = None
|
|
|
|
|
|
class DockerNetwork(BaseModel):
|
|
"""Shape of a docker network entry."""
|
|
|
|
id: str
|
|
name: str
|
|
driver: str | None = None
|
|
scope: str | None = None
|
|
|
|
|
|
class DockerMutationResult(BaseModel):
|
|
"""Shape returned by docker start/stop/pause/unpause mutations."""
|
|
|
|
success: bool
|
|
subaction: str
|
|
container: Any = None
|
|
|
|
|
|
class DockerListResult(BaseModel):
|
|
"""Top-level shape of docker/list response."""
|
|
|
|
containers: list[Any]
|
|
|
|
|
|
class DockerNetworkListResult(BaseModel):
|
|
"""Top-level shape of docker/networks response."""
|
|
|
|
networks: list[Any]
|
|
|
|
|
|
# --- Info ---
|
|
|
|
|
|
class InfoOverviewSummary(BaseModel):
|
|
"""Summary block inside info/overview response."""
|
|
|
|
hostname: str | None = None
|
|
uptime: Any = None
|
|
cpu: str | None = None
|
|
os: str | None = None
|
|
memory_summary: str | None = None
|
|
|
|
|
|
class InfoOverviewResult(BaseModel):
|
|
"""Top-level shape of info/overview."""
|
|
|
|
summary: dict[str, Any]
|
|
details: dict[str, Any]
|
|
|
|
|
|
class ArraySummary(BaseModel):
|
|
"""Summary block inside info/array response."""
|
|
|
|
state: str | None = None
|
|
num_data_disks: int
|
|
num_parity_disks: int
|
|
num_cache_pools: int
|
|
overall_health: str
|
|
|
|
|
|
class InfoArrayResult(BaseModel):
|
|
"""Top-level shape of info/array."""
|
|
|
|
summary: dict[str, Any]
|
|
details: dict[str, Any]
|
|
|
|
|
|
class CpuMetrics(BaseModel):
|
|
percentTotal: float | None = None
|
|
|
|
|
|
class MemoryMetrics(BaseModel):
|
|
total: Any = None
|
|
used: Any = None
|
|
free: Any = None
|
|
available: Any = None
|
|
buffcache: Any = None
|
|
percentTotal: float | None = None
|
|
|
|
|
|
class InfoMetricsResult(BaseModel):
|
|
"""Top-level shape of info/metrics."""
|
|
|
|
cpu: dict[str, Any] | None = None
|
|
memory: dict[str, Any] | None = None
|
|
|
|
|
|
class ServiceEntry(BaseModel):
|
|
"""Shape of a single service in info/services response."""
|
|
|
|
name: str
|
|
online: bool | None = None
|
|
version: str | None = None
|
|
|
|
|
|
class InfoServicesResult(BaseModel):
|
|
services: list[Any]
|
|
|
|
|
|
class InfoOnlineResult(BaseModel):
|
|
online: bool | None = None
|
|
|
|
|
|
class RegistrationResult(BaseModel):
|
|
"""Shape of info/registration response."""
|
|
|
|
id: str | None = None
|
|
type: str | None = None
|
|
state: str | None = None
|
|
expiration: Any = None
|
|
|
|
|
|
class InfoNetworkResult(BaseModel):
|
|
"""Shape of info/network response."""
|
|
|
|
accessUrls: list[Any]
|
|
httpPort: Any = None
|
|
httpsPort: Any = None
|
|
localTld: str | None = None
|
|
useSsl: Any = None
|
|
|
|
|
|
# --- Storage ---
|
|
|
|
|
|
class ShareEntry(BaseModel):
|
|
"""Shape of a single share in storage/shares response."""
|
|
|
|
id: str
|
|
name: str
|
|
free: Any = None
|
|
used: Any = None
|
|
size: Any = None
|
|
|
|
|
|
class StorageSharesResult(BaseModel):
|
|
shares: list[Any]
|
|
|
|
|
|
class DiskEntry(BaseModel):
|
|
"""Minimal shape of a disk in storage/disks response."""
|
|
|
|
id: str
|
|
device: str | None = None
|
|
name: str | None = None
|
|
|
|
|
|
class StorageDisksResult(BaseModel):
|
|
disks: list[Any]
|
|
|
|
|
|
class DiskDetailsSummary(BaseModel):
|
|
"""Summary block in storage/disk_details response."""
|
|
|
|
disk_id: str | None = None
|
|
device: str | None = None
|
|
name: str | None = None
|
|
serial_number: str | None = None
|
|
size_formatted: str
|
|
temperature: str
|
|
|
|
|
|
class StorageDiskDetailsResult(BaseModel):
|
|
"""Top-level shape of storage/disk_details."""
|
|
|
|
summary: dict[str, Any]
|
|
details: dict[str, Any]
|
|
|
|
|
|
class LogFileEntry(BaseModel):
|
|
"""Shape of a log file entry in storage/log_files response."""
|
|
|
|
name: str
|
|
path: str
|
|
size: Any = None
|
|
modifiedAt: Any = None
|
|
|
|
|
|
class StorageLogFilesResult(BaseModel):
|
|
log_files: list[Any]
|
|
|
|
|
|
# --- Notifications ---
|
|
|
|
|
|
class NotificationCountBucket(BaseModel):
|
|
"""Counts within a single severity bucket."""
|
|
|
|
info: int | None = None
|
|
warning: int | None = None
|
|
alert: int | None = None
|
|
total: int | None = None
|
|
|
|
|
|
class NotificationOverviewResult(BaseModel):
|
|
"""Top-level shape of notifications/overview."""
|
|
|
|
unread: dict[str, Any] | None = None
|
|
archive: dict[str, Any] | None = None
|
|
|
|
|
|
class NotificationEntry(BaseModel):
|
|
"""Shape of a single notification in notifications/list response."""
|
|
|
|
id: str
|
|
title: str | None = None
|
|
subject: str | None = None
|
|
description: str | None = None
|
|
importance: str | None = None
|
|
type: str | None = None
|
|
timestamp: Any = None
|
|
formattedTimestamp: Any = None
|
|
link: Any = None
|
|
|
|
|
|
class NotificationListResult(BaseModel):
|
|
notifications: list[Any]
|
|
|
|
|
|
class NotificationCreateResult(BaseModel):
|
|
success: bool
|
|
notification: dict[str, Any]
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fixtures
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
@pytest.fixture
|
|
def _docker_mock() -> Generator[AsyncMock, None, None]:
|
|
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock) as mock:
|
|
yield mock
|
|
|
|
|
|
@pytest.fixture
|
|
def _info_mock() -> Generator[AsyncMock, None, None]:
|
|
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock) as mock:
|
|
yield mock
|
|
|
|
|
|
@pytest.fixture
|
|
def _storage_mock() -> Generator[AsyncMock, None, None]:
|
|
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock) as mock:
|
|
yield mock
|
|
|
|
|
|
@pytest.fixture
|
|
def _notifications_mock() -> Generator[AsyncMock, None, None]:
|
|
with patch("unraid_mcp.tools.unraid.make_graphql_request", new_callable=AsyncMock) as mock:
|
|
yield mock
|
|
|
|
|
|
def _docker_tool():
|
|
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
|
|
|
|
|
def _info_tool():
|
|
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
|
|
|
|
|
def _storage_tool():
|
|
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
|
|
|
|
|
def _notifications_tool():
|
|
return make_tool_fn("unraid_mcp.tools.unraid", "register_unraid_tool", "unraid")
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Docker contract tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestDockerListContract:
|
|
"""docker/list always returns {"containers": [...]}."""
|
|
|
|
async def test_list_result_has_containers_key(self, _docker_mock: AsyncMock) -> None:
|
|
_docker_mock.return_value = {"docker": {"containers": []}}
|
|
result = await _docker_tool()(action="docker", subaction="list")
|
|
DockerListResult(**result)
|
|
|
|
async def test_list_containers_conform_to_shape(self, _docker_mock: AsyncMock) -> None:
|
|
_docker_mock.return_value = {
|
|
"docker": {
|
|
"containers": [
|
|
{"id": "c1", "names": ["nginx"], "state": "running", "image": "nginx:latest"},
|
|
{"id": "c2", "names": ["redis"], "state": "exited", "autoStart": False},
|
|
]
|
|
}
|
|
}
|
|
result = await _docker_tool()(action="docker", subaction="list")
|
|
validated = DockerListResult(**result)
|
|
for container in validated.containers:
|
|
DockerContainer(**container)
|
|
|
|
async def test_list_empty_containers_is_valid(self, _docker_mock: AsyncMock) -> None:
|
|
_docker_mock.return_value = {"docker": {"containers": []}}
|
|
result = await _docker_tool()(action="docker", subaction="list")
|
|
validated = DockerListResult(**result)
|
|
assert validated.containers == []
|
|
|
|
async def test_list_container_minimal_fields_valid(self, _docker_mock: AsyncMock) -> None:
|
|
"""A container with only id, names, and state should validate."""
|
|
_docker_mock.return_value = {
|
|
"docker": {"containers": [{"id": "abc123", "names": ["plex"], "state": "running"}]}
|
|
}
|
|
result = await _docker_tool()(action="docker", subaction="list")
|
|
container_raw = result["containers"][0]
|
|
DockerContainer(**container_raw)
|
|
|
|
async def test_list_missing_names_fails_contract(self, _docker_mock: AsyncMock) -> None:
|
|
"""A container missing required 'names' field must fail validation."""
|
|
_docker_mock.return_value = {
|
|
"docker": {"containers": [{"id": "abc123", "state": "running"}]}
|
|
}
|
|
result = await _docker_tool()(action="docker", subaction="list")
|
|
with pytest.raises(ValidationError):
|
|
DockerContainer(**result["containers"][0])
|
|
|
|
|
|
class TestDockerDetailsContract:
|
|
"""docker/details returns the raw container dict (not wrapped)."""
|
|
|
|
async def test_details_conforms_to_shape(self, _docker_mock: AsyncMock) -> None:
|
|
cid = "a" * 64 + ":local"
|
|
_docker_mock.return_value = {
|
|
"docker": {
|
|
"containers": [
|
|
{
|
|
"id": cid,
|
|
"names": ["plex"],
|
|
"state": "running",
|
|
"image": "plexinc/pms:latest",
|
|
"status": "Up 3 hours",
|
|
"ports": [],
|
|
"autoStart": True,
|
|
}
|
|
]
|
|
}
|
|
}
|
|
result = await _docker_tool()(action="docker", subaction="details", container_id=cid)
|
|
DockerContainerDetails(**result)
|
|
|
|
async def test_details_has_required_fields(self, _docker_mock: AsyncMock) -> None:
|
|
cid = "b" * 64 + ":local"
|
|
_docker_mock.return_value = {
|
|
"docker": {"containers": [{"id": cid, "names": ["sonarr"], "state": "exited"}]}
|
|
}
|
|
result = await _docker_tool()(action="docker", subaction="details", container_id=cid)
|
|
assert "id" in result
|
|
assert "names" in result
|
|
assert "state" in result
|
|
|
|
|
|
class TestDockerNetworksContract:
|
|
"""docker/networks returns {"networks": [...]}."""
|
|
|
|
async def test_networks_result_has_networks_key(self, _docker_mock: AsyncMock) -> None:
|
|
_docker_mock.return_value = {
|
|
"docker": {"networks": [{"id": "net:1", "name": "bridge", "driver": "bridge"}]}
|
|
}
|
|
result = await _docker_tool()(action="docker", subaction="networks")
|
|
DockerNetworkListResult(**result)
|
|
|
|
async def test_network_entries_conform_to_shape(self, _docker_mock: AsyncMock) -> None:
|
|
_docker_mock.return_value = {
|
|
"docker": {
|
|
"networks": [
|
|
{"id": "net:1", "name": "bridge", "driver": "bridge", "scope": "local"},
|
|
{"id": "net:2", "name": "host", "driver": "host", "scope": "local"},
|
|
]
|
|
}
|
|
}
|
|
result = await _docker_tool()(action="docker", subaction="networks")
|
|
for net in result["networks"]:
|
|
DockerNetwork(**net)
|
|
|
|
async def test_empty_networks_is_valid(self, _docker_mock: AsyncMock) -> None:
|
|
_docker_mock.return_value = {"docker": {"networks": []}}
|
|
result = await _docker_tool()(action="docker", subaction="networks")
|
|
validated = DockerNetworkListResult(**result)
|
|
assert validated.networks == []
|
|
|
|
|
|
class TestDockerMutationContract:
|
|
"""docker start/stop return {"success": bool, "action": str, "container": ...}."""
|
|
|
|
async def test_start_mutation_result_shape(self, _docker_mock: AsyncMock) -> None:
|
|
cid = "c" * 64 + ":local"
|
|
_docker_mock.side_effect = [
|
|
{"docker": {"containers": [{"id": cid, "names": ["plex"]}]}},
|
|
{"docker": {"start": {"id": cid, "names": ["plex"], "state": "running"}}},
|
|
]
|
|
result = await _docker_tool()(action="docker", subaction="start", container_id=cid)
|
|
validated = DockerMutationResult(**result)
|
|
assert validated.success is True
|
|
assert validated.subaction == "start"
|
|
|
|
async def test_stop_mutation_result_shape(self, _docker_mock: AsyncMock) -> None:
|
|
cid = "d" * 64 + ":local"
|
|
_docker_mock.side_effect = [
|
|
{"docker": {"containers": [{"id": cid, "names": ["nginx"]}]}},
|
|
{"docker": {"stop": {"id": cid, "names": ["nginx"], "state": "exited"}}},
|
|
]
|
|
result = await _docker_tool()(action="docker", subaction="stop", container_id=cid)
|
|
validated = DockerMutationResult(**result)
|
|
assert validated.success is True
|
|
assert validated.subaction == "stop"
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Info contract tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestInfoOverviewContract:
|
|
"""info/overview returns {"summary": {...}, "details": {...}}."""
|
|
|
|
async def test_overview_has_summary_and_details(self, _info_mock: AsyncMock) -> None:
|
|
_info_mock.return_value = {
|
|
"info": {
|
|
"os": {
|
|
"platform": "linux",
|
|
"distro": "Unraid",
|
|
"release": "6.12.0",
|
|
"hostname": "tootie",
|
|
"uptime": 86400,
|
|
"arch": "x64",
|
|
},
|
|
"cpu": {
|
|
"manufacturer": "Intel",
|
|
"brand": "Core i7-9700K",
|
|
"cores": 8,
|
|
"threads": 8,
|
|
},
|
|
"memory": {"layout": []},
|
|
}
|
|
}
|
|
result = await _info_tool()(action="system", subaction="overview")
|
|
validated = InfoOverviewResult(**result)
|
|
assert isinstance(validated.summary, dict)
|
|
assert isinstance(validated.details, dict)
|
|
|
|
async def test_overview_summary_contains_hostname(self, _info_mock: AsyncMock) -> None:
|
|
_info_mock.return_value = {
|
|
"info": {
|
|
"os": {
|
|
"hostname": "myserver",
|
|
"distro": "Unraid",
|
|
"release": "6.12",
|
|
"platform": "linux",
|
|
"arch": "x64",
|
|
"uptime": 100,
|
|
},
|
|
"cpu": {"manufacturer": "AMD", "brand": "Ryzen", "cores": 4, "threads": 8},
|
|
"memory": {"layout": []},
|
|
}
|
|
}
|
|
result = await _info_tool()(action="system", subaction="overview")
|
|
InfoOverviewSummary(**result["summary"])
|
|
assert result["summary"]["hostname"] == "myserver"
|
|
|
|
async def test_overview_details_mirrors_raw_info(self, _info_mock: AsyncMock) -> None:
|
|
raw_info = {
|
|
"os": {
|
|
"hostname": "srv",
|
|
"distro": "Unraid",
|
|
"release": "6",
|
|
"platform": "linux",
|
|
"arch": "x64",
|
|
},
|
|
"cpu": {"manufacturer": "Intel", "brand": "Xeon", "cores": 16, "threads": 32},
|
|
"memory": {"layout": []},
|
|
}
|
|
_info_mock.return_value = {"info": raw_info}
|
|
result = await _info_tool()(action="system", subaction="overview")
|
|
assert result["details"] == raw_info
|
|
|
|
|
|
class TestInfoArrayContract:
|
|
"""info/array returns {"summary": {...}, "details": {...}} with health analysis."""
|
|
|
|
async def test_array_result_shape(self, _info_mock: AsyncMock) -> None:
|
|
_info_mock.return_value = {
|
|
"array": {
|
|
"id": "array:1",
|
|
"state": "STARTED",
|
|
"capacity": {"kilobytes": {"free": 1000000, "used": 500000, "total": 1500000}},
|
|
"parities": [{"id": "p1", "status": "DISK_OK"}],
|
|
"disks": [{"id": "d1", "status": "DISK_OK"}, {"id": "d2", "status": "DISK_OK"}],
|
|
"caches": [],
|
|
"boot": None,
|
|
}
|
|
}
|
|
result = await _info_tool()(action="system", subaction="array")
|
|
validated = InfoArrayResult(**result)
|
|
assert isinstance(validated.summary, dict)
|
|
assert isinstance(validated.details, dict)
|
|
|
|
async def test_array_summary_contains_required_fields(self, _info_mock: AsyncMock) -> None:
|
|
_info_mock.return_value = {
|
|
"array": {
|
|
"state": "STARTED",
|
|
"capacity": {"kilobytes": {"free": 500000, "used": 250000, "total": 750000}},
|
|
"parities": [],
|
|
"disks": [{"id": "d1", "status": "DISK_OK"}],
|
|
"caches": [],
|
|
}
|
|
}
|
|
result = await _info_tool()(action="system", subaction="array")
|
|
ArraySummary(**result["summary"])
|
|
|
|
async def test_array_health_overall_healthy(self, _info_mock: AsyncMock) -> None:
|
|
_info_mock.return_value = {
|
|
"array": {
|
|
"state": "STARTED",
|
|
"capacity": {"kilobytes": {"free": 1000000, "used": 0, "total": 1000000}},
|
|
"parities": [{"id": "p1", "status": "DISK_OK", "warning": None, "critical": None}],
|
|
"disks": [{"id": "d1", "status": "DISK_OK", "warning": None, "critical": None}],
|
|
"caches": [],
|
|
}
|
|
}
|
|
result = await _info_tool()(action="system", subaction="array")
|
|
assert result["summary"]["overall_health"] == "HEALTHY"
|
|
|
|
async def test_array_health_critical_with_failed_disk(self, _info_mock: AsyncMock) -> None:
|
|
_info_mock.return_value = {
|
|
"array": {
|
|
"state": "DEGRADED",
|
|
"capacity": {"kilobytes": {"free": 0, "used": 0, "total": 0}},
|
|
"parities": [{"id": "p1", "status": "DISK_DSBL"}],
|
|
"disks": [],
|
|
"caches": [],
|
|
}
|
|
}
|
|
result = await _info_tool()(action="system", subaction="array")
|
|
assert result["summary"]["overall_health"] == "CRITICAL"
|
|
|
|
|
|
class TestInfoMetricsContract:
|
|
"""info/metrics returns {"cpu": {...}, "memory": {...}}."""
|
|
|
|
async def test_metrics_result_shape(self, _info_mock: AsyncMock) -> None:
|
|
_info_mock.return_value = {
|
|
"metrics": {
|
|
"cpu": {"percentTotal": 12.5},
|
|
"memory": {
|
|
"total": 16384,
|
|
"used": 8192,
|
|
"free": 4096,
|
|
"available": 6144,
|
|
"buffcache": 2048,
|
|
"percentTotal": 50.0,
|
|
},
|
|
}
|
|
}
|
|
result = await _info_tool()(action="system", subaction="metrics")
|
|
validated = InfoMetricsResult(**result)
|
|
assert validated.cpu is not None
|
|
assert validated.memory is not None
|
|
|
|
async def test_metrics_cpu_percent_in_range(self, _info_mock: AsyncMock) -> None:
|
|
_info_mock.return_value = {
|
|
"metrics": {"cpu": {"percentTotal": 75.3}, "memory": {"percentTotal": 60.0}}
|
|
}
|
|
result = await _info_tool()(action="system", subaction="metrics")
|
|
cpu_pct = result["cpu"]["percentTotal"]
|
|
assert 0.0 <= cpu_pct <= 100.0
|
|
|
|
|
|
class TestInfoServicesContract:
|
|
"""info/services returns {"services": [...]}."""
|
|
|
|
async def test_services_result_shape(self, _info_mock: AsyncMock) -> None:
|
|
_info_mock.return_value = {
|
|
"services": [
|
|
{"name": "nginx", "online": True, "version": "1.25"},
|
|
{"name": "docker", "online": True, "version": "24.0"},
|
|
]
|
|
}
|
|
result = await _info_tool()(action="system", subaction="services")
|
|
validated = InfoServicesResult(**result)
|
|
for svc in validated.services:
|
|
ServiceEntry(**svc)
|
|
|
|
async def test_services_empty_list_is_valid(self, _info_mock: AsyncMock) -> None:
|
|
_info_mock.return_value = {"services": []}
|
|
result = await _info_tool()(action="system", subaction="services")
|
|
InfoServicesResult(**result)
|
|
assert result["services"] == []
|
|
|
|
|
|
class TestInfoOnlineContract:
|
|
"""info/online returns {"online": bool|None}."""
|
|
|
|
async def test_online_true_shape(self, _info_mock: AsyncMock) -> None:
|
|
_info_mock.return_value = {"online": True}
|
|
result = await _info_tool()(action="system", subaction="online")
|
|
validated = InfoOnlineResult(**result)
|
|
assert validated.online is True
|
|
|
|
async def test_online_false_shape(self, _info_mock: AsyncMock) -> None:
|
|
_info_mock.return_value = {"online": False}
|
|
result = await _info_tool()(action="system", subaction="online")
|
|
validated = InfoOnlineResult(**result)
|
|
assert validated.online is False
|
|
|
|
|
|
class TestInfoNetworkContract:
|
|
"""info/network returns access URLs and port configuration."""
|
|
|
|
async def test_network_result_shape(self, _info_mock: AsyncMock) -> None:
|
|
_info_mock.return_value = {
|
|
"servers": [
|
|
{
|
|
"id": "s1",
|
|
"lanip": "192.168.1.10",
|
|
"wanip": "1.2.3.4",
|
|
"localurl": "http://tower.local",
|
|
"remoteurl": "https://myunraid.net/s1",
|
|
}
|
|
],
|
|
"vars": {"port": 80, "portssl": 443, "localTld": "local", "useSsl": "no"},
|
|
}
|
|
result = await _info_tool()(action="system", subaction="network")
|
|
validated = InfoNetworkResult(**result)
|
|
assert isinstance(validated.accessUrls, list)
|
|
|
|
async def test_network_empty_servers_still_valid(self, _info_mock: AsyncMock) -> None:
|
|
_info_mock.return_value = {
|
|
"servers": [],
|
|
"vars": {"port": 80, "portssl": 443, "localTld": "local", "useSsl": "no"},
|
|
}
|
|
result = await _info_tool()(action="system", subaction="network")
|
|
validated = InfoNetworkResult(**result)
|
|
assert validated.accessUrls == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Storage contract tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestStorageSharesContract:
|
|
"""storage/shares returns {"shares": [...]}."""
|
|
|
|
async def test_shares_result_shape(self, _storage_mock: AsyncMock) -> None:
|
|
_storage_mock.return_value = {
|
|
"shares": [
|
|
{"id": "share:1", "name": "media", "free": 500000, "used": 100000, "size": 600000},
|
|
{"id": "share:2", "name": "appdata", "free": 200000, "used": 50000, "size": 250000},
|
|
]
|
|
}
|
|
result = await _storage_tool()(action="disk", subaction="shares")
|
|
validated = StorageSharesResult(**result)
|
|
for share in validated.shares:
|
|
ShareEntry(**share)
|
|
|
|
async def test_shares_empty_list_is_valid(self, _storage_mock: AsyncMock) -> None:
|
|
_storage_mock.return_value = {"shares": []}
|
|
result = await _storage_tool()(action="disk", subaction="shares")
|
|
StorageSharesResult(**result)
|
|
assert result["shares"] == []
|
|
|
|
async def test_shares_missing_name_fails_contract(self, _storage_mock: AsyncMock) -> None:
|
|
"""A share without required 'name' must fail contract validation."""
|
|
_storage_mock.return_value = {"shares": [{"id": "share:1", "free": 100}]}
|
|
result = await _storage_tool()(action="disk", subaction="shares")
|
|
with pytest.raises(ValidationError):
|
|
ShareEntry(**result["shares"][0])
|
|
|
|
|
|
class TestStorageDisksContract:
|
|
"""storage/disks returns {"disks": [...]}."""
|
|
|
|
async def test_disks_result_shape(self, _storage_mock: AsyncMock) -> None:
|
|
_storage_mock.return_value = {
|
|
"disks": [
|
|
{"id": "disk:1", "device": "sda", "name": "WD_RED_4TB"},
|
|
{"id": "disk:2", "device": "sdb", "name": "Seagate_8TB"},
|
|
]
|
|
}
|
|
result = await _storage_tool()(action="disk", subaction="disks")
|
|
validated = StorageDisksResult(**result)
|
|
for disk in validated.disks:
|
|
DiskEntry(**disk)
|
|
|
|
async def test_disks_empty_list_is_valid(self, _storage_mock: AsyncMock) -> None:
|
|
_storage_mock.return_value = {"disks": []}
|
|
result = await _storage_tool()(action="disk", subaction="disks")
|
|
StorageDisksResult(**result)
|
|
assert result["disks"] == []
|
|
|
|
|
|
class TestStorageDiskDetailsContract:
|
|
"""storage/disk_details returns {"summary": {...}, "details": {...}}."""
|
|
|
|
async def test_disk_details_result_shape(self, _storage_mock: AsyncMock) -> None:
|
|
_storage_mock.return_value = {
|
|
"disk": {
|
|
"id": "disk:1",
|
|
"device": "sda",
|
|
"name": "WD_RED_4TB",
|
|
"serialNum": "WD-12345678",
|
|
"size": 4000000000,
|
|
"temperature": 35,
|
|
}
|
|
}
|
|
result = await _storage_tool()(action="disk", subaction="disk_details", disk_id="disk:1")
|
|
validated = StorageDiskDetailsResult(**result)
|
|
assert isinstance(validated.summary, dict)
|
|
assert isinstance(validated.details, dict)
|
|
|
|
async def test_disk_details_summary_has_required_fields(self, _storage_mock: AsyncMock) -> None:
|
|
_storage_mock.return_value = {
|
|
"disk": {
|
|
"id": "disk:2",
|
|
"device": "sdb",
|
|
"name": "Seagate",
|
|
"serialNum": "ST-ABC",
|
|
"size": 8000000000,
|
|
"temperature": 40,
|
|
}
|
|
}
|
|
result = await _storage_tool()(action="disk", subaction="disk_details", disk_id="disk:2")
|
|
DiskDetailsSummary(**result["summary"])
|
|
|
|
async def test_disk_details_temperature_formatted(self, _storage_mock: AsyncMock) -> None:
|
|
_storage_mock.return_value = {
|
|
"disk": {
|
|
"id": "disk:3",
|
|
"device": "sdc",
|
|
"name": "MyDisk",
|
|
"serialNum": "XYZ",
|
|
"size": 2000000000,
|
|
"temperature": 38,
|
|
}
|
|
}
|
|
result = await _storage_tool()(action="disk", subaction="disk_details", disk_id="disk:3")
|
|
assert "°C" in result["summary"]["temperature"]
|
|
|
|
async def test_disk_details_no_temperature_shows_na(self, _storage_mock: AsyncMock) -> None:
|
|
_storage_mock.return_value = {
|
|
"disk": {
|
|
"id": "disk:4",
|
|
"device": "sdd",
|
|
"name": "NoDisk",
|
|
"serialNum": "000",
|
|
"size": 1000000000,
|
|
"temperature": None,
|
|
}
|
|
}
|
|
result = await _storage_tool()(action="disk", subaction="disk_details", disk_id="disk:4")
|
|
assert result["summary"]["temperature"] == "N/A"
|
|
|
|
|
|
class TestStorageLogFilesContract:
|
|
"""storage/log_files returns {"log_files": [...]}."""
|
|
|
|
async def test_log_files_result_shape(self, _storage_mock: AsyncMock) -> None:
|
|
_storage_mock.return_value = {
|
|
"logFiles": [
|
|
{
|
|
"name": "syslog",
|
|
"path": "/var/log/syslog",
|
|
"size": 1024,
|
|
"modifiedAt": "2026-03-15",
|
|
},
|
|
{
|
|
"name": "messages",
|
|
"path": "/var/log/messages",
|
|
"size": 512,
|
|
"modifiedAt": "2026-03-14",
|
|
},
|
|
]
|
|
}
|
|
result = await _storage_tool()(action="disk", subaction="log_files")
|
|
validated = StorageLogFilesResult(**result)
|
|
for log_file in validated.log_files:
|
|
LogFileEntry(**log_file)
|
|
|
|
async def test_log_files_empty_list_is_valid(self, _storage_mock: AsyncMock) -> None:
|
|
_storage_mock.return_value = {"logFiles": []}
|
|
result = await _storage_tool()(action="disk", subaction="log_files")
|
|
StorageLogFilesResult(**result)
|
|
assert result["log_files"] == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Notifications contract tests
|
|
# ---------------------------------------------------------------------------
|
|
|
|
|
|
class TestNotificationsOverviewContract:
|
|
"""notifications/overview returns {"unread": {...}, "archive": {...}}."""
|
|
|
|
async def test_overview_result_shape(self, _notifications_mock: AsyncMock) -> None:
|
|
_notifications_mock.return_value = {
|
|
"notifications": {
|
|
"overview": {
|
|
"unread": {"info": 2, "warning": 1, "alert": 0, "total": 3},
|
|
"archive": {"info": 10, "warning": 5, "alert": 2, "total": 17},
|
|
}
|
|
}
|
|
}
|
|
result = await _notifications_tool()(action="notification", subaction="overview")
|
|
validated = NotificationOverviewResult(**result)
|
|
assert validated.unread is not None
|
|
assert validated.archive is not None
|
|
|
|
async def test_overview_unread_bucket_conforms(self, _notifications_mock: AsyncMock) -> None:
|
|
_notifications_mock.return_value = {
|
|
"notifications": {
|
|
"overview": {
|
|
"unread": {"info": 0, "warning": 0, "alert": 1, "total": 1},
|
|
"archive": {"info": 0, "warning": 0, "alert": 0, "total": 0},
|
|
}
|
|
}
|
|
}
|
|
result = await _notifications_tool()(action="notification", subaction="overview")
|
|
NotificationCountBucket(**result["unread"])
|
|
NotificationCountBucket(**result["archive"])
|
|
|
|
async def test_overview_empty_counts_valid(self, _notifications_mock: AsyncMock) -> None:
|
|
_notifications_mock.return_value = {
|
|
"notifications": {
|
|
"overview": {
|
|
"unread": {"info": 0, "warning": 0, "alert": 0, "total": 0},
|
|
"archive": {"info": 0, "warning": 0, "alert": 0, "total": 0},
|
|
}
|
|
}
|
|
}
|
|
result = await _notifications_tool()(action="notification", subaction="overview")
|
|
NotificationOverviewResult(**result)
|
|
|
|
|
|
class TestNotificationsListContract:
|
|
"""notifications/list returns {"notifications": [...]}."""
|
|
|
|
async def test_list_result_shape(self, _notifications_mock: AsyncMock) -> None:
|
|
_notifications_mock.return_value = {
|
|
"notifications": {
|
|
"list": [
|
|
{
|
|
"id": "notif:1",
|
|
"title": "Array degraded",
|
|
"subject": "Storage alert",
|
|
"description": "Disk 3 failed",
|
|
"importance": "ALERT",
|
|
"type": "UNREAD",
|
|
"timestamp": 1741000000,
|
|
"formattedTimestamp": "Mar 15 2026",
|
|
"link": None,
|
|
}
|
|
]
|
|
}
|
|
}
|
|
result = await _notifications_tool()(action="notification", subaction="list")
|
|
validated = NotificationListResult(**result)
|
|
for notif in validated.notifications:
|
|
NotificationEntry(**notif)
|
|
|
|
async def test_list_empty_notifications_valid(self, _notifications_mock: AsyncMock) -> None:
|
|
_notifications_mock.return_value = {"notifications": {"list": []}}
|
|
result = await _notifications_tool()(action="notification", subaction="list")
|
|
NotificationListResult(**result)
|
|
assert result["notifications"] == []
|
|
|
|
async def test_list_notification_missing_id_fails_contract(
|
|
self, _notifications_mock: AsyncMock
|
|
) -> None:
|
|
"""A notification missing required 'id' field must fail contract validation."""
|
|
_notifications_mock.return_value = {
|
|
"notifications": {"list": [{"title": "No ID here", "importance": "INFO"}]}
|
|
}
|
|
result = await _notifications_tool()(action="notification", subaction="list")
|
|
with pytest.raises(ValidationError):
|
|
NotificationEntry(**result["notifications"][0])
|
|
|
|
|
|
class TestNotificationsCreateContract:
|
|
"""notifications/create returns {"success": bool, "notification": {...}}."""
|
|
|
|
async def test_create_result_shape(self, _notifications_mock: AsyncMock) -> None:
|
|
_notifications_mock.return_value = {
|
|
"createNotification": {
|
|
"id": "notif:new",
|
|
"title": "Test notification",
|
|
"importance": "INFO",
|
|
}
|
|
}
|
|
result = await _notifications_tool()(
|
|
action="notification",
|
|
subaction="create",
|
|
title="Test notification",
|
|
subject="Test subject",
|
|
description="This is a test",
|
|
importance="INFO",
|
|
)
|
|
validated = NotificationCreateResult(**result)
|
|
assert validated.success is True
|
|
assert "id" in validated.notification
|
|
|
|
async def test_create_notification_has_id(self, _notifications_mock: AsyncMock) -> None:
|
|
_notifications_mock.return_value = {
|
|
"createNotification": {"id": "notif:42", "title": "Alert!", "importance": "ALERT"}
|
|
}
|
|
result = await _notifications_tool()(
|
|
action="notification",
|
|
subaction="create",
|
|
title="Alert!",
|
|
subject="Critical issue",
|
|
description="Something went wrong",
|
|
importance="ALERT",
|
|
)
|
|
assert result["notification"]["id"] == "notif:42"
|
|
assert result["notification"]["importance"] == "ALERT"
|