Files
unraid-mcp/tests/contract/test_response_contracts.py
Jacob Magar dab1cd6995 refactor(tools)!: consolidate 15 individual tools into single unified unraid tool
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>
2026-03-16 02:29:57 -04:00

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"