Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import sys
from pathlib import Path


def pytest_configure(config):
"""Add project root to sys.path so tests can import the package."""
project_root = Path(__file__).parent.parent
if str(project_root) not in sys.path:
sys.path.insert(0, str(project_root))
Comment on lines +5 to +9
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

cd /tmp && find . -name "conftest.py" -type f 2>/dev/null | head -20

Repository: jmagar/unraid-mcp

Length of output: 43


🏁 Script executed:

cat -n tests/conftest.py 2>/dev/null || echo "File not found in root"

Repository: jmagar/unraid-mcp

Length of output: 399


🏁 Script executed:

# Check if this is a pytest project and get more context
ls -la tests/ 2>/dev/null | head -20

Repository: jmagar/unraid-mcp

Length of output: 342


🏁 Script executed:

# Search for pytest_configure usage in the codebase
rg "pytest_configure" --type py

Repository: jmagar/unraid-mcp

Length of output: 108


🏁 Script executed:

# Look for ruff configuration files
find . -type f \( -name "pyproject.toml" -o -name "ruff.toml" -o -name ".ruff.toml" \) | head -10

Repository: jmagar/unraid-mcp

Length of output: 89


🏁 Script executed:

# Check for any existing noqa comments in the file
cat -n tests/conftest.py

Repository: jmagar/unraid-mcp

Length of output: 399


🏁 Script executed:

# Check if there are any ruff rules configured in pyproject.toml
fd "pyproject.toml" -x cat {} \; 2>/dev/null | head -100

Repository: jmagar/unraid-mcp

Length of output: 2296


🏁 Script executed:

cat -n ruff.toml

Repository: jmagar/unraid-mcp

Length of output: 2286


🏁 Script executed:

# Check if ARG rule is enabled in ruff config
cat ruff.toml && echo "---" && cat pyproject.toml | grep -A 20 "\[tool.ruff"

Repository: jmagar/unraid-mcp

Length of output: 2806


Rename unused config parameter to silence Ruff ARG001.

The pytest_configure hook requires the parameter by signature, but config is unused in the function body. Rename it to _config to follow Python convention for intentionally unused parameters.

Suggested fix
-def pytest_configure(config):
+def pytest_configure(_config):
🧰 Tools
🪛 Ruff (0.14.14)

[warning] 5-5: Unused function argument: config

(ARG001)

🤖 Prompt for AI Agents
In `@tests/conftest.py` around lines 5 - 9, Rename the unused parameter in the
pytest_configure hook from config to _config to satisfy Ruff ARG001; update the
function signature def pytest_configure(config): to def
pytest_configure(_config): and ensure any references (there are none) would be
updated accordingly so the hook still matches pytest's expected signature while
signaling the parameter is intentionally unused.

336 changes: 336 additions & 0 deletions tests/integration/test_live_queries.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,336 @@
#!/usr/bin/env python3
"""Live API test: fires every GraphQL query from the codebase against the Unraid server."""

import asyncio
import sys
import time
from pathlib import Path

import httpx

# Add project root to path
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.append(str(PROJECT_ROOT))
Comment on lines +11 to +13
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟡 Minor

🧩 Analysis chain

🏁 Script executed:

# First, find and read the target file
find . -name "test_live_queries.py" -type f

Repository: jmagar/unraid-mcp

Length of output: 101


🏁 Script executed:

# Also search for sys.path usage patterns in tests directory
rg "sys\.path" tests/ -A 2 -B 2

Repository: jmagar/unraid-mcp

Length of output: 1197


🏁 Script executed:

# Check if there's an installed unraid_mcp package referenced
rg "unraid_mcp" . --include="*.py" | head -20

Repository: jmagar/unraid-mcp

Length of output: 502


🏁 Script executed:

# Read the actual test_live_queries.py file to see line numbers and full context
head -20 tests/integration/test_live_queries.py

Repository: jmagar/unraid-mcp

Length of output: 534


🏁 Script executed:

# Check the conftest.py to understand the import setup
cat tests/conftest.py

Repository: jmagar/unraid-mcp

Length of output: 336


🏁 Script executed:

# Check if there's a setup.py or pyproject.toml to understand package structure
ls -la | grep -E "setup|pyproject"

Repository: jmagar/unraid-mcp

Length of output: 126


🏁 Script executed:

# Verify pyproject.toml to confirm unraid_mcp is a real package that could be installed
cat pyproject.toml | head -30

Repository: jmagar/unraid-mcp

Length of output: 957


🏁 Script executed:

# Check if there are any other sys.path manipulations in integration tests
rg "sys\.path" tests/integration/ -A 1 -B 1

Repository: jmagar/unraid-mcp

Length of output: 486


Use sys.path.insert(0, ...) to ensure the local package takes precedence on import.

sys.path.append() places the project root after site-packages, allowing an installed unraid_mcp package to be imported instead of the working tree version. This matters if the test is run directly as a script (bypassing pytest's conftest). Use insert(0, ...) instead.

🛠️ Suggested fix
-sys.path.append(str(PROJECT_ROOT))
+sys.path.insert(0, str(PROJECT_ROOT))
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
# Add project root to path
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.append(str(PROJECT_ROOT))
# Add project root to path
PROJECT_ROOT = Path(__file__).parent.parent.parent
sys.path.insert(0, str(PROJECT_ROOT))
🤖 Prompt for AI Agents
In `@tests/integration/test_live_queries.py` around lines 11 - 13, Replace the
sys.path.append usage that adds PROJECT_ROOT with sys.path.insert(0,
str(PROJECT_ROOT)) so the local working-tree package (PROJECT_ROOT) takes
precedence over any installed packages; update the code around the PROJECT_ROOT
variable and the import modification (where sys.path.append(str(PROJECT_ROOT))
is used) to use sys.path.insert(0, str(PROJECT_ROOT)).


# Now import from the application config
try:
from unraid_mcp.config.settings import (
UNRAID_API_KEY,
UNRAID_API_URL,
UNRAID_VERIFY_SSL,
)
except ImportError:
# Fallback or error if not found (e.g. if not installed as package)
print(
"Error: Could not import unraid_mcp.config.settings. Ensure project root is in PYTHONPATH."
)
sys.exit(1)

API_URL = UNRAID_API_URL

if not API_URL:
print("WARNING: UNRAID_API_URL is not set. Skipping integration tests.")
sys.exit(0)
API_KEY = UNRAID_API_KEY

if not API_KEY:
# Fail loudly if API_KEY is missing, to prevent accidental commits of secrets
# or running without proper configuration.
raise ValueError(
"UNRAID_API_KEY environment variable is required (check .env file or export UNRAID_API_KEY='...')"
)

HEADERS = {
"Content-Type": "application/json",
"X-API-Key": API_KEY,
"User-Agent": "UnraidMCPServer/0.1.0",
}

# Queries that fail due to confirmed Unraid API server-side bugs.
# These are reported separately and don't count as test failures.
KNOWN_SERVER_ISSUES = {
"rclone/get_rclone_config_form": "INTERNAL_SERVER_ERROR: configForm resolver fails with 'url must not start with a slash'",
}

# Queries that return errors because the feature is unavailable on the server
# (e.g. VMs not enabled). Valid queries, just no data.
KNOWN_UNAVAILABLE = {
"vm/list_vms",
"vm/get_vm_details_both_fields",
}

TESTS = [
(
"system/get_system_info",
"""query GetSystemInfo {
info {
os { platform distro release codename kernel arch hostname logofile serial build uptime }
cpu { manufacturer brand vendor family model stepping revision voltage speed speedmin speedmax threads cores processors socket cache flags }
memory { layout { bank type clockSpeed formFactor manufacturer partNum serialNum } }
baseboard { manufacturer model version serial assetTag }
system { manufacturer model version serial uuid sku }
versions {
core { unraid api kernel }
packages { openssl node npm pm2 git nginx php docker }
}
machineId
time
}
}""",
None,
),
(
"system/get_array_status",
"""query GetArrayStatus {
array {
id state
capacity { kilobytes { free used total } disks { free used total } }
boot { id idx name device size status rotational temp numReads numWrites numErrors fsSize fsFree fsUsed exportable type warning critical fsType comment format transport color }
parities { id idx name device size status rotational temp numReads numWrites numErrors fsSize fsFree fsUsed exportable type warning critical fsType comment format transport color }
disks { id idx name device size status rotational temp numReads numWrites numErrors fsSize fsFree fsUsed exportable type warning critical fsType comment format transport color }
caches { id idx name device size status rotational temp numReads numWrites numErrors fsSize fsFree fsUsed exportable type warning critical fsType comment format transport color }
}
}""",
None,
),
(
"system/get_network_config",
"""query GetNetworkConfig {
network { id accessUrls { type name ipv4 ipv6 } }
}""",
None,
),
(
"system/get_registration_info",
"""query GetRegistrationInfo {
registration { id type keyFile { location contents } state expiration updateExpiration }
}""",
None,
),
(
"system/get_connect_settings",
"""query GetConnectSettingsForm {
settings { unified { values } }
}""",
None,
),
(
"system/get_unraid_variables",
"""query GetSelectiveUnraidVariables {
vars {
id version name timeZone comment security workgroup domain domainShort
hideDotFiles localMaster enableFruit useNtp domainLogin sysModel
sysFlashSlots useSsl port portssl localTld bindMgt useTelnet porttelnet
useSsh portssh startPage startArray shutdownTimeout
shareSmbEnabled shareNfsEnabled shareAfpEnabled shareCacheEnabled
shareAvahiEnabled safeMode startMode configValid configError
joinStatus deviceCount flashGuid flashProduct flashVendor
mdState mdVersion shareCount shareSmbCount shareNfsCount shareAfpCount
shareMoverActive csrfToken
}
}""",
None,
),
(
"storage/get_shares_info",
"""query GetSharesInfo {
shares { id name free used size include exclude cache nameOrig comment allocator splitLevel floor cow color luksStatus }
}""",
None,
),
(
"storage/get_notifications_overview",
"""query GetNotificationsOverview {
notifications { overview { unread { info warning alert total } archive { info warning alert total } } }
}""",
None,
),
(
"storage/list_notifications",
"""query ListNotifications($filter: NotificationFilter!) {
notifications { list(filter: $filter) { id title subject description importance link type timestamp formattedTimestamp } }
}""",
{"filter": {"type": "UNREAD", "offset": 0, "limit": 5}},
),
(
"storage/list_available_log_files",
"""query ListLogFiles {
logFiles { name path size modifiedAt }
}""",
None,
),
(
"storage/list_physical_disks",
"""query ListPhysicalDisksMinimal {
disks { id device name }
}""",
None,
),
(
"docker/list_docker_containers",
"""query ListDockerContainers {
docker { containers(skipCache: false) { id names image state status autoStart } }
}""",
None,
),
(
"docker/container_detail_fields",
"""query GetAllContainerDetailsForFiltering {
docker { containers(skipCache: false) {
id names image imageId command created
ports { ip privatePort publicPort type }
sizeRootFs labels state status
hostConfig { networkMode }
networkSettings mounts autoStart
} }
}""",
None,
),
(
"vm/list_vms",
"""query ListVMs {
vms { id domains { id name state uuid } }
}""",
None,
),
(
"vm/get_vm_details_both_fields",
"""query GetVmDetails {
vms { domains { id name state uuid } domain { id name state uuid } }
}""",
None,
),
(
"rclone/list_rclone_remotes",
"""query ListRCloneRemotes {
rclone { remotes { name type parameters config } }
}""",
None,
),
(
"rclone/get_rclone_config_form",
"""query GetRCloneConfigForm($formOptions: RCloneConfigFormInput) {
rclone { configForm(formOptions: $formOptions) { id dataSchema uiSchema } }
}""",
None,
),
(
"health/health_check",
"""query ComprehensiveHealthCheck {
info { machineId time versions { core { unraid } } os { uptime } }
array { state }
notifications { overview { unread { alert warning total } } }
docker { containers(skipCache: true) { id state status } }
}""",
None,
),
]


async def run_tests():
results = []
# SSL verification can be toggled via env var (default False for local testing)
verify_ssl = UNRAID_VERIFY_SSL
async with httpx.AsyncClient(verify=verify_ssl, follow_redirects=False) as client:
for name, query, variables in TESTS:
payload = {"query": query}
if variables:
payload["variables"] = variables

start = time.time()
try:
r = await client.post(API_URL, json=payload, headers=HEADERS, timeout=30.0)
elapsed = round((time.time() - start) * 1000)

if r.status_code != 200:
results.append((name, "HTTP_ERROR", f"Status {r.status_code}", elapsed))
continue

try:
data = r.json()
except ValueError:
# Catch JSONDecodeError (subclass of ValueError) if response is not valid JSON
results.append(
(
name,
"JSON_ERR",
f"Invalid JSON (status {r.status_code}): {r.text[:100]}",
elapsed,
)
)
continue
if "errors" in data and data["errors"]:
err_msgs = "; ".join(e.get("message", str(e))[:120] for e in data["errors"])
_data_content = data.get("data")
has_data = bool(
isinstance(_data_content, dict)
and any(v is not None for v in _data_content.values())
)
if name in KNOWN_SERVER_ISSUES:
status = "KNOWN_BUG"
elif name in KNOWN_UNAVAILABLE:
status = "UNAVAIL"
elif has_data:
status = "PARTIAL"
else:
status = "GQL_ERROR"
results.append((name, status, err_msgs, elapsed))
else:
results.append((name, "OK", "", elapsed))
except Exception as e:
elapsed = round((time.time() - start) * 1000)
results.append((name, "EXCEPTION", str(e)[:120], elapsed))

# Print results
STATUS_ICONS = {
"OK": " OK",
"PARTIAL": "WARN",
"UNAVAIL": "SKIP",
"KNOWN_BUG": " BUG",
"GQL_ERROR": "FAIL",
"HTTP_ERROR": "FAIL",
"EXCEPTION": "FAIL",
"JSON_ERR": "FAIL",
}
print(f"{'Tool':<42} {'Status':<12} {'Time':>6} Detail")
print("-" * 130)
for name, status, err, elapsed in results:
icon = STATUS_ICONS.get(status, "FAIL")
if status == "KNOWN_BUG":
detail = KNOWN_SERVER_ISSUES.get(name, err)[:70]
elif status == "UNAVAIL":
detail = "Feature unavailable on server"
else:
detail = err[:70] if err else ""
print(f"{name:<42} {icon:<12} {elapsed:>5}ms {detail}")

ok = sum(1 for _, s, _, _ in results if s == "OK")
unavail = sum(1 for _, s, _, _ in results if s == "UNAVAIL")
known = sum(1 for _, s, _, _ in results if s == "KNOWN_BUG")
partial = sum(1 for _, s, _, _ in results if s == "PARTIAL")
fail = sum(
1 for _, s, _, _ in results if s in ("GQL_ERROR", "HTTP_ERROR", "EXCEPTION", "JSON_ERR")
)
print(
f"\nTotal: {len(results)} | OK: {ok} | Unavailable: {unavail} | Known bugs: {known} | Partial: {partial} | Failed: {fail}"
)

# Print details only for unexpected failures
failures = [
(n, s, e)
for n, s, e, _ in results
if s in ("GQL_ERROR", "HTTP_ERROR", "EXCEPTION", "PARTIAL", "JSON_ERR")
]
if failures:
print("\n=== UNEXPECTED FAILURES ===")
for name, status, err in failures:
print(f"\n{name} [{status}]:")
print(f" {err}")
else:
print("\nNo unexpected failures.")

return len(failures)


if __name__ == "__main__":
sys.exit(asyncio.run(run_tests()))
Loading