Initial commit: Node-RED MCP server (Python/FastMCP)
22-tool MCP server for Node-RED flow management with Pydantic models, incremental flow patching, layout linting, and flexible auth (token, basic, OAuth2). 137 tests, full ruff compliance. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
0
tests/__init__.py
Normal file
0
tests/__init__.py
Normal file
251
tests/conftest.py
Normal file
251
tests/conftest.py
Normal file
@@ -0,0 +1,251 @@
|
||||
"""Shared test fixtures for nodered_mcp tests."""
|
||||
|
||||
from typing import Any
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from nodered_mcp.client import NodeRedClient
|
||||
|
||||
|
||||
# ── Sample API data fixtures ──────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_flows_data() -> list[dict[str, Any]]:
|
||||
"""Raw Node-RED flows API response (heterogeneous array of tabs, nodes, subflows)."""
|
||||
return [
|
||||
# Two tabs
|
||||
{
|
||||
"id": "tab1",
|
||||
"type": "tab",
|
||||
"label": "Main Flow",
|
||||
"disabled": False,
|
||||
"info": "Primary workspace",
|
||||
},
|
||||
{
|
||||
"id": "tab2",
|
||||
"type": "tab",
|
||||
"label": "Test Flow",
|
||||
"disabled": True,
|
||||
"info": "",
|
||||
},
|
||||
# Several nodes of different types
|
||||
{
|
||||
"id": "inject1",
|
||||
"type": "inject",
|
||||
"z": "tab1",
|
||||
"name": "Test Inject",
|
||||
"topic": "",
|
||||
"payload": "test",
|
||||
"payloadType": "str",
|
||||
"repeat": "",
|
||||
"crontab": "",
|
||||
"once": False,
|
||||
"onceDelay": 0.1,
|
||||
"x": 100,
|
||||
"y": 100,
|
||||
"wires": [["function1"]],
|
||||
},
|
||||
{
|
||||
"id": "function1",
|
||||
"type": "function",
|
||||
"z": "tab1",
|
||||
"name": "Process Data",
|
||||
"func": "return msg;",
|
||||
"outputs": 1,
|
||||
"noerr": 0,
|
||||
"x": 300,
|
||||
"y": 100,
|
||||
"wires": [["debug1"]],
|
||||
},
|
||||
{
|
||||
"id": "debug1",
|
||||
"type": "debug",
|
||||
"z": "tab1",
|
||||
"name": "Output",
|
||||
"active": True,
|
||||
"tosidebar": True,
|
||||
"console": False,
|
||||
"tostatus": False,
|
||||
"complete": "payload",
|
||||
"x": 500,
|
||||
"y": 100,
|
||||
"wires": [],
|
||||
},
|
||||
{
|
||||
"id": "inject2",
|
||||
"type": "inject",
|
||||
"z": "tab2",
|
||||
"name": "Another Inject",
|
||||
"payload": "",
|
||||
"payloadType": "date",
|
||||
"repeat": "",
|
||||
"crontab": "",
|
||||
"once": False,
|
||||
"x": 100,
|
||||
"y": 200,
|
||||
"wires": [[]],
|
||||
},
|
||||
# One subflow
|
||||
{
|
||||
"id": "subflow1",
|
||||
"type": "subflow",
|
||||
"name": "Test Subflow",
|
||||
"info": "",
|
||||
"in": [{"x": 50, "y": 50, "wires": [{"id": "sf-node1"}]}],
|
||||
"out": [{"x": 250, "y": 50, "wires": [{"id": "sf-node1", "port": 0}]}],
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_flow_data() -> dict[str, Any]:
|
||||
"""Single flow API response (GET /flow/:id)."""
|
||||
return {
|
||||
"id": "tab1",
|
||||
"label": "Main Flow",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "inject1",
|
||||
"type": "inject",
|
||||
"name": "Test",
|
||||
"x": 100,
|
||||
"y": 100,
|
||||
"wires": [["debug1"]],
|
||||
},
|
||||
{
|
||||
"id": "debug1",
|
||||
"type": "debug",
|
||||
"name": "Output",
|
||||
"x": 300,
|
||||
"y": 100,
|
||||
"wires": [],
|
||||
},
|
||||
],
|
||||
"configs": [],
|
||||
"subflows": [],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_node_module_data() -> dict[str, Any]:
|
||||
"""NodeModule API response (GET /nodes/:module)."""
|
||||
return {
|
||||
"name": "node-red-contrib-test",
|
||||
"version": "1.0.0",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "node-red-contrib-test/test-node",
|
||||
"name": "test-node",
|
||||
"types": ["test-input", "test-output"],
|
||||
"enabled": True,
|
||||
"module": "node-red-contrib-test",
|
||||
"version": "1.0.0",
|
||||
}
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_node_set_data() -> dict[str, Any]:
|
||||
"""NodeSet API response."""
|
||||
return {
|
||||
"id": "node-red/inject",
|
||||
"name": "inject",
|
||||
"types": ["inject"],
|
||||
"enabled": True,
|
||||
"module": "node-red",
|
||||
"version": "3.1.0",
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_settings_data() -> dict[str, Any]:
|
||||
"""Settings API response (GET /settings)."""
|
||||
return {
|
||||
"httpNodeRoot": "/api",
|
||||
"version": "3.1.0",
|
||||
"user": {
|
||||
"username": "admin",
|
||||
"permissions": "*",
|
||||
},
|
||||
"flowFilePretty": True,
|
||||
"editorTheme": {
|
||||
"page": {"title": "Node-RED"},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_diagnostics_data() -> dict[str, Any]:
|
||||
"""Diagnostics API response (GET /diagnostics)."""
|
||||
return {
|
||||
"report": {
|
||||
"versions": {
|
||||
"node-red": "3.1.0",
|
||||
"nodejs": "18.17.0",
|
||||
"npm": "9.6.7",
|
||||
},
|
||||
"platform": {
|
||||
"os": "darwin",
|
||||
"arch": "arm64",
|
||||
"cpus": 8,
|
||||
"mem": 17179869184,
|
||||
},
|
||||
"modules": {
|
||||
"node-red-contrib-test": "1.0.0",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_flows_state_data() -> dict[str, Any]:
|
||||
"""Flow state API response (GET /flows/state)."""
|
||||
return {"state": "start"}
|
||||
|
||||
|
||||
# ── Mock client fixture ───────────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_client(
|
||||
sample_flows_data: list[dict[str, Any]],
|
||||
sample_flow_data: dict[str, Any],
|
||||
sample_node_module_data: dict[str, Any],
|
||||
sample_node_set_data: dict[str, Any],
|
||||
sample_settings_data: dict[str, Any],
|
||||
sample_diagnostics_data: dict[str, Any],
|
||||
sample_flows_state_data: dict[str, Any],
|
||||
) -> NodeRedClient:
|
||||
"""Mock NodeRedClient with all methods stubbed."""
|
||||
client = AsyncMock(spec=NodeRedClient)
|
||||
|
||||
# Flow endpoints
|
||||
client.get_flows.return_value = sample_flows_data
|
||||
client.get_flow.return_value = sample_flow_data
|
||||
client.update_flows.return_value = None
|
||||
client.update_flow.return_value = None
|
||||
client.create_flow.return_value = {"id": "new-flow-id"}
|
||||
client.delete_flow.return_value = None
|
||||
client.get_flows_state.return_value = sample_flows_state_data
|
||||
client.set_flows_state.return_value = sample_flows_state_data
|
||||
|
||||
# Node endpoints
|
||||
client.get_nodes.return_value = [sample_node_set_data]
|
||||
client.get_node_info.return_value = sample_node_module_data
|
||||
client.toggle_node_module.return_value = sample_node_module_data
|
||||
client.inject.return_value = None
|
||||
|
||||
# Settings endpoints
|
||||
client.get_settings.return_value = sample_settings_data
|
||||
client.get_diagnostics.return_value = sample_diagnostics_data
|
||||
|
||||
with (
|
||||
patch("nodered_mcp.tools.flows.get_client", return_value=client),
|
||||
patch("nodered_mcp.tools.nodes.get_client", return_value=client),
|
||||
patch("nodered_mcp.tools.settings.get_client", return_value=client),
|
||||
patch("nodered_mcp.tools.layout.get_client", return_value=client),
|
||||
):
|
||||
yield client
|
||||
422
tests/test_client.py
Normal file
422
tests/test_client.py
Normal file
@@ -0,0 +1,422 @@
|
||||
"""Tests for NodeRedClient."""
|
||||
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import httpx
|
||||
import pytest
|
||||
|
||||
from nodered_mcp.client import NodeRedClient, get_client, reset_client
|
||||
|
||||
|
||||
class TestNodeRedClient:
|
||||
def test_init_without_token(self) -> None:
|
||||
client = NodeRedClient(base_url="http://localhost:1880", api_version="v1")
|
||||
assert client._http.base_url == httpx.URL("http://localhost:1880")
|
||||
assert client._http.headers["Node-RED-API-Version"] == "v1"
|
||||
assert "Authorization" not in client._http.headers
|
||||
|
||||
def test_init_with_token(self) -> None:
|
||||
client = NodeRedClient(
|
||||
base_url="http://localhost:1880",
|
||||
token="test-token",
|
||||
api_version="v1",
|
||||
)
|
||||
assert client._http.headers["Authorization"] == "Bearer test-token"
|
||||
assert client._http.headers["Node-RED-API-Version"] == "v1"
|
||||
|
||||
def test_init_with_custom_api_version(self) -> None:
|
||||
client = NodeRedClient(
|
||||
base_url="http://localhost:1880",
|
||||
api_version="v2",
|
||||
)
|
||||
assert client._http.headers["Node-RED-API-Version"] == "v2"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_success_with_json(self) -> None:
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
client = NodeRedClient(base_url="http://localhost:1880")
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"id": "test"}
|
||||
|
||||
with patch.object(client._http, "request", new_callable=AsyncMock, return_value=mock_response):
|
||||
result = await client._request("GET", "/flows")
|
||||
assert result == {"id": "test"}
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_204_no_content(self) -> None:
|
||||
client = NodeRedClient(base_url="http://localhost:1880")
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 204
|
||||
|
||||
with patch.object(client._http, "request", return_value=mock_response):
|
||||
result = await client._request("DELETE", "/flow/test")
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_400_error(self) -> None:
|
||||
client = NodeRedClient(base_url="http://localhost:1880")
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 400
|
||||
mock_response.text = "Bad Request"
|
||||
|
||||
with patch.object(client._http, "request", return_value=mock_response):
|
||||
with pytest.raises(RuntimeError, match="Node-RED API error 400: Bad Request"):
|
||||
await client._request("GET", "/flows")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_404_error(self) -> None:
|
||||
client = NodeRedClient(base_url="http://localhost:1880")
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 404
|
||||
mock_response.text = "Not Found"
|
||||
|
||||
with patch.object(client._http, "request", return_value=mock_response):
|
||||
with pytest.raises(RuntimeError, match="Node-RED API error 404: Not Found"):
|
||||
await client._request("GET", "/flow/nonexistent")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_500_error(self) -> None:
|
||||
client = NodeRedClient(base_url="http://localhost:1880")
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 500
|
||||
mock_response.text = "Internal Server Error"
|
||||
|
||||
with patch.object(client._http, "request", return_value=mock_response):
|
||||
with pytest.raises(RuntimeError, match="Node-RED API error 500"):
|
||||
await client._request("POST", "/flows")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_with_data(self) -> None:
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
client = NodeRedClient(base_url="http://localhost:1880")
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {"success": True}
|
||||
|
||||
with patch.object(client._http, "request", new_callable=AsyncMock, return_value=mock_response) as mock_req:
|
||||
await client._request("POST", "/flow", data={"label": "Test"})
|
||||
mock_req.assert_called_once_with(
|
||||
"POST", "/flow", json={"label": "Test"}, headers={}
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_request_with_extra_headers(self) -> None:
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
client = NodeRedClient(base_url="http://localhost:1880")
|
||||
mock_response = MagicMock()
|
||||
mock_response.status_code = 200
|
||||
mock_response.json.return_value = {}
|
||||
|
||||
with patch.object(client._http, "request", new_callable=AsyncMock, return_value=mock_response) as mock_req:
|
||||
await client._request(
|
||||
"POST", "/flows", data=[], headers={"Node-RED-Deployment-Type": "full"}
|
||||
)
|
||||
mock_req.assert_called_once_with(
|
||||
"POST",
|
||||
"/flows",
|
||||
json=[],
|
||||
headers={"Node-RED-Deployment-Type": "full"},
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_update_flows_passes_deployment_type_header(self) -> None:
|
||||
client = NodeRedClient(base_url="http://localhost:1880")
|
||||
mock_response = AsyncMock()
|
||||
mock_response.status_code = 204
|
||||
|
||||
with patch.object(client._http, "request", return_value=mock_response) as mock_req:
|
||||
await client.update_flows(flows=[{"id": "test"}], deployment_type="nodes")
|
||||
mock_req.assert_called_once()
|
||||
call_args = mock_req.call_args
|
||||
assert call_args[1]["headers"]["Node-RED-Deployment-Type"] == "nodes"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_close(self) -> None:
|
||||
client = NodeRedClient(base_url="http://localhost:1880")
|
||||
with patch.object(client._http, "aclose", new_callable=AsyncMock) as mock_close:
|
||||
await client.close()
|
||||
mock_close.assert_called_once()
|
||||
|
||||
|
||||
class TestGetClient:
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_client_returns_singleton(self) -> None:
|
||||
# Reset to ensure clean state
|
||||
await reset_client()
|
||||
c1 = get_client()
|
||||
c2 = get_client()
|
||||
assert c1 is c2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_client_returns_noderedclient(self) -> None:
|
||||
await reset_client()
|
||||
client = get_client()
|
||||
assert isinstance(client, NodeRedClient)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_client_uses_config(self) -> None:
|
||||
with patch("nodered_mcp.client.get_config") as mock_get_config:
|
||||
mock_config = AsyncMock()
|
||||
mock_config.url = "http://test:1880"
|
||||
mock_config.token = "test-token"
|
||||
mock_config.api_version = "v1"
|
||||
mock_config.auth_method = "token"
|
||||
mock_config.auth_username = ""
|
||||
mock_config.auth_password = ""
|
||||
mock_config.oauth_token_url = ""
|
||||
mock_config.oauth_client_id = ""
|
||||
mock_get_config.return_value = mock_config
|
||||
|
||||
await reset_client()
|
||||
client = get_client()
|
||||
assert client._http.base_url == httpx.URL("http://test:1880")
|
||||
assert client._http.headers["Authorization"] == "Bearer test-token"
|
||||
|
||||
|
||||
class TestResetClient:
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_client_returns_fresh_instance(self) -> None:
|
||||
c1 = get_client()
|
||||
c2 = await reset_client()
|
||||
assert c1 is not c2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_client_closes_old_client(self) -> None:
|
||||
client = get_client()
|
||||
with patch.object(client, "close", new_callable=AsyncMock) as mock_close:
|
||||
await reset_client()
|
||||
mock_close.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_client_returns_noderedclient(self) -> None:
|
||||
client = await reset_client()
|
||||
assert isinstance(client, NodeRedClient)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reset_client_updates_singleton(self) -> None:
|
||||
await reset_client()
|
||||
c1 = get_client()
|
||||
c2 = await reset_client()
|
||||
c3 = get_client()
|
||||
# After reset, get_client should return the new instance
|
||||
assert c1 is not c3
|
||||
assert c2 is c3
|
||||
|
||||
|
||||
class TestBasicAuth:
|
||||
def test_init_basic_auth(self) -> None:
|
||||
"""Basic auth sets httpx.BasicAuth on the client."""
|
||||
client = NodeRedClient(
|
||||
base_url="http://localhost:1880",
|
||||
auth_method="basic",
|
||||
auth_username="user",
|
||||
auth_password="secret",
|
||||
)
|
||||
assert client._auth_method == "basic"
|
||||
assert client._http._auth is not None
|
||||
# No static Authorization header — BasicAuth sends it per-request
|
||||
assert "Authorization" not in client._http.headers
|
||||
|
||||
def test_init_basic_auth_no_token_header(self) -> None:
|
||||
"""Basic auth mode ignores static token."""
|
||||
client = NodeRedClient(
|
||||
base_url="http://localhost:1880",
|
||||
auth_method="basic",
|
||||
auth_username="user",
|
||||
auth_password="pass",
|
||||
token="should-be-ignored",
|
||||
)
|
||||
assert "Authorization" not in client._http.headers
|
||||
|
||||
|
||||
class TestOAuthAuth:
|
||||
def test_init_oauth_no_initial_auth(self) -> None:
|
||||
"""OAuth mode has no Authorization header at init."""
|
||||
client = NodeRedClient(
|
||||
base_url="http://localhost:1880",
|
||||
auth_method="oauth",
|
||||
auth_username="user",
|
||||
auth_password="pass",
|
||||
oauth_token_url="https://auth.example.com/application/o/token/",
|
||||
oauth_client_id="my-client-id",
|
||||
)
|
||||
assert client._auth_method == "oauth"
|
||||
assert client._oauth_token is None
|
||||
assert "Authorization" not in client._http.headers
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_token_fetch(self) -> None:
|
||||
"""OAuth fetches token on first request."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
client = NodeRedClient(
|
||||
base_url="http://localhost:1880",
|
||||
auth_method="oauth",
|
||||
auth_username="user",
|
||||
auth_password="pass",
|
||||
oauth_token_url="https://auth.example.com/token",
|
||||
oauth_client_id="client-id",
|
||||
)
|
||||
|
||||
# Mock the token endpoint response
|
||||
token_response = MagicMock()
|
||||
token_response.status_code = 200
|
||||
token_response.json.return_value = {
|
||||
"access_token": "jwt-token-123",
|
||||
"expires_in": 600,
|
||||
}
|
||||
|
||||
with patch("nodered_mcp.client.httpx.AsyncClient") as mock_auth_cls:
|
||||
mock_auth_client = AsyncMock()
|
||||
mock_auth_client.post.return_value = token_response
|
||||
mock_auth_client.__aenter__ = AsyncMock(return_value=mock_auth_client)
|
||||
mock_auth_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_auth_cls.return_value = mock_auth_client
|
||||
|
||||
await client._ensure_oauth_token()
|
||||
|
||||
assert client._oauth_token == "jwt-token-123"
|
||||
assert client._oauth_expiry > 0
|
||||
assert client._http.headers["Authorization"] == "Bearer jwt-token-123"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_token_cached(self) -> None:
|
||||
"""OAuth doesn't re-fetch valid token."""
|
||||
import time
|
||||
|
||||
client = NodeRedClient(
|
||||
base_url="http://localhost:1880",
|
||||
auth_method="oauth",
|
||||
auth_username="user",
|
||||
auth_password="pass",
|
||||
oauth_token_url="https://auth.example.com/token",
|
||||
oauth_client_id="client-id",
|
||||
)
|
||||
# Pre-set a valid token
|
||||
client._oauth_token = "cached-token"
|
||||
client._oauth_expiry = time.time() + 300 # 5 min from now
|
||||
|
||||
with patch("nodered_mcp.client.httpx.AsyncClient") as mock_cls:
|
||||
await client._ensure_oauth_token()
|
||||
# Should NOT have created a new client (no fetch needed)
|
||||
mock_cls.assert_not_called()
|
||||
|
||||
assert client._oauth_token == "cached-token"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_token_refresh_when_expired(self) -> None:
|
||||
"""OAuth re-fetches when token is expired."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
client = NodeRedClient(
|
||||
base_url="http://localhost:1880",
|
||||
auth_method="oauth",
|
||||
auth_username="user",
|
||||
auth_password="pass",
|
||||
oauth_token_url="https://auth.example.com/token",
|
||||
oauth_client_id="client-id",
|
||||
)
|
||||
# Set an expired token
|
||||
client._oauth_token = "old-token"
|
||||
client._oauth_expiry = 0.0
|
||||
|
||||
token_response = MagicMock()
|
||||
token_response.status_code = 200
|
||||
token_response.json.return_value = {
|
||||
"access_token": "new-token",
|
||||
"expires_in": 600,
|
||||
}
|
||||
|
||||
with patch("nodered_mcp.client.httpx.AsyncClient") as mock_auth_cls:
|
||||
mock_auth_client = AsyncMock()
|
||||
mock_auth_client.post.return_value = token_response
|
||||
mock_auth_client.__aenter__ = AsyncMock(return_value=mock_auth_client)
|
||||
mock_auth_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_auth_cls.return_value = mock_auth_client
|
||||
|
||||
await client._ensure_oauth_token()
|
||||
|
||||
assert client._oauth_token == "new-token"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_token_error(self) -> None:
|
||||
"""OAuth raises RuntimeError on token endpoint failure."""
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
client = NodeRedClient(
|
||||
base_url="http://localhost:1880",
|
||||
auth_method="oauth",
|
||||
auth_username="user",
|
||||
auth_password="pass",
|
||||
oauth_token_url="https://auth.example.com/token",
|
||||
oauth_client_id="client-id",
|
||||
)
|
||||
|
||||
error_response = MagicMock()
|
||||
error_response.status_code = 401
|
||||
error_response.text = "invalid_client"
|
||||
|
||||
with patch("nodered_mcp.client.httpx.AsyncClient") as mock_auth_cls:
|
||||
mock_auth_client = AsyncMock()
|
||||
mock_auth_client.post.return_value = error_response
|
||||
mock_auth_client.__aenter__ = AsyncMock(return_value=mock_auth_client)
|
||||
mock_auth_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_auth_cls.return_value = mock_auth_client
|
||||
|
||||
with pytest.raises(RuntimeError, match="OAuth token error 401"):
|
||||
await client._ensure_oauth_token()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_oauth_401_retry(self) -> None:
|
||||
"""OAuth retries once on 401 from Node-RED."""
|
||||
from unittest.mock import MagicMock
|
||||
import time
|
||||
|
||||
client = NodeRedClient(
|
||||
base_url="http://localhost:1880",
|
||||
auth_method="oauth",
|
||||
auth_username="user",
|
||||
auth_password="pass",
|
||||
oauth_token_url="https://auth.example.com/token",
|
||||
oauth_client_id="client-id",
|
||||
)
|
||||
# Pre-set a "valid" but actually revoked token
|
||||
client._oauth_token = "revoked-token"
|
||||
client._oauth_expiry = time.time() + 300
|
||||
|
||||
# First response: 401, second response: 200
|
||||
response_401 = MagicMock()
|
||||
response_401.status_code = 401
|
||||
response_401.text = "Unauthorized"
|
||||
|
||||
response_200 = MagicMock()
|
||||
response_200.status_code = 200
|
||||
response_200.json.return_value = {"id": "test"}
|
||||
|
||||
with patch.object(
|
||||
client._http, "request", new_callable=AsyncMock,
|
||||
side_effect=[response_401, response_200],
|
||||
):
|
||||
# Mock the token refresh
|
||||
token_response = MagicMock()
|
||||
token_response.status_code = 200
|
||||
token_response.json.return_value = {
|
||||
"access_token": "new-token",
|
||||
"expires_in": 600,
|
||||
}
|
||||
|
||||
with patch("nodered_mcp.client.httpx.AsyncClient") as mock_auth_cls:
|
||||
mock_auth_client = AsyncMock()
|
||||
mock_auth_client.post.return_value = token_response
|
||||
mock_auth_client.__aenter__ = AsyncMock(return_value=mock_auth_client)
|
||||
mock_auth_client.__aexit__ = AsyncMock(return_value=None)
|
||||
mock_auth_cls.return_value = mock_auth_client
|
||||
|
||||
result = await client._request("GET", "/flows")
|
||||
|
||||
assert result == {"id": "test"}
|
||||
assert client._oauth_token == "new-token"
|
||||
141
tests/test_config.py
Normal file
141
tests/test_config.py
Normal file
@@ -0,0 +1,141 @@
|
||||
"""Tests for configuration management."""
|
||||
|
||||
import os
|
||||
|
||||
from nodered_mcp.config import Config, get_config, reset_config
|
||||
|
||||
|
||||
class TestConfig:
|
||||
def test_defaults(self) -> None:
|
||||
"""Test default values without env vars."""
|
||||
old_url = os.environ.pop("NODE_RED_URL", None)
|
||||
old_token = os.environ.pop("NODE_RED_TOKEN", None)
|
||||
old_version = os.environ.pop("NODE_RED_API_VERSION", None)
|
||||
|
||||
try:
|
||||
cfg = Config()
|
||||
assert cfg.url == "http://localhost:1880"
|
||||
assert cfg.token == ""
|
||||
assert cfg.api_version == "v1"
|
||||
finally:
|
||||
if old_url:
|
||||
os.environ["NODE_RED_URL"] = old_url
|
||||
if old_token:
|
||||
os.environ["NODE_RED_TOKEN"] = old_token
|
||||
if old_version:
|
||||
os.environ["NODE_RED_API_VERSION"] = old_version
|
||||
|
||||
def test_env_var_override_url(self, monkeypatch) -> None:
|
||||
"""Test that Config reads NODE_RED_URL from environment."""
|
||||
monkeypatch.setenv("NODE_RED_URL", "http://example.com:1880")
|
||||
cfg = Config(_env_file=None)
|
||||
assert cfg.url == "http://example.com:1880"
|
||||
|
||||
def test_env_var_override_token(self, monkeypatch) -> None:
|
||||
"""Test that Config reads NODE_RED_TOKEN from environment."""
|
||||
monkeypatch.setenv("NODE_RED_TOKEN", "test-token-123")
|
||||
cfg = Config(_env_file=None)
|
||||
assert cfg.token == "test-token-123"
|
||||
|
||||
def test_env_var_override_api_version(self, monkeypatch) -> None:
|
||||
"""Test that Config reads NODE_RED_API_VERSION from environment."""
|
||||
monkeypatch.setenv("NODE_RED_API_VERSION", "v2")
|
||||
cfg = Config(_env_file=None)
|
||||
assert cfg.api_version == "v2"
|
||||
|
||||
def test_env_var_override_all(self, monkeypatch) -> None:
|
||||
"""Test that Config reads all env vars."""
|
||||
monkeypatch.setenv("NODE_RED_URL", "http://custom:8080")
|
||||
monkeypatch.setenv("NODE_RED_TOKEN", "custom-token")
|
||||
monkeypatch.setenv("NODE_RED_API_VERSION", "v2")
|
||||
cfg = Config(_env_file=None)
|
||||
assert cfg.url == "http://custom:8080"
|
||||
assert cfg.token == "custom-token"
|
||||
assert cfg.api_version == "v2"
|
||||
|
||||
def test_env_prefix_case_insensitive(self, monkeypatch) -> None:
|
||||
"""Verify case_sensitive=False setting works."""
|
||||
monkeypatch.setenv("node_red_url", "http://lowercase:1880")
|
||||
cfg = Config(_env_file=None)
|
||||
assert cfg.url == "http://lowercase:1880"
|
||||
|
||||
|
||||
class TestGetConfig:
|
||||
def test_get_config_returns_singleton(self) -> None:
|
||||
cfg1 = get_config()
|
||||
cfg2 = get_config()
|
||||
assert cfg1 is cfg2
|
||||
|
||||
def test_get_config_returns_config_instance(self) -> None:
|
||||
cfg = get_config()
|
||||
assert isinstance(cfg, Config)
|
||||
|
||||
def test_get_config_with_env_vars(self, monkeypatch) -> None:
|
||||
"""Test get_config returns instance that respects env vars."""
|
||||
monkeypatch.setenv("NODE_RED_URL", "http://singleton-test:1880")
|
||||
reset_config()
|
||||
cfg = get_config()
|
||||
assert cfg.url == "http://singleton-test:1880"
|
||||
|
||||
|
||||
class TestResetConfig:
|
||||
def test_reset_config_returns_fresh_instance(self) -> None:
|
||||
cfg1 = get_config()
|
||||
cfg2 = reset_config()
|
||||
assert cfg1 is not cfg2
|
||||
|
||||
def test_reset_config_returns_config_instance(self) -> None:
|
||||
cfg = reset_config()
|
||||
assert isinstance(cfg, Config)
|
||||
|
||||
def test_reset_config_updates_singleton(self) -> None:
|
||||
reset_config()
|
||||
cfg1 = get_config()
|
||||
reset_config()
|
||||
cfg2 = get_config()
|
||||
# After reset, get_config should return the new instance
|
||||
assert cfg1 is not cfg2
|
||||
|
||||
def test_reset_config_applies_new_env_vars(self, monkeypatch) -> None:
|
||||
"""Test reset_config picks up changed env vars."""
|
||||
monkeypatch.setenv("NODE_RED_URL", "http://first:1880")
|
||||
reset_config()
|
||||
cfg1 = get_config()
|
||||
assert cfg1.url == "http://first:1880"
|
||||
|
||||
monkeypatch.setenv("NODE_RED_URL", "http://second:1880")
|
||||
cfg2 = reset_config()
|
||||
assert cfg2.url == "http://second:1880"
|
||||
|
||||
|
||||
class TestAuthConfig:
|
||||
def test_auth_defaults(self) -> None:
|
||||
"""Auth fields default to token mode with empty credentials."""
|
||||
cfg = Config(_env_file=None)
|
||||
assert cfg.auth_method == "token"
|
||||
assert cfg.auth_username == ""
|
||||
assert cfg.auth_password == ""
|
||||
assert cfg.oauth_token_url == ""
|
||||
assert cfg.oauth_client_id == ""
|
||||
|
||||
def test_auth_method_basic(self, monkeypatch) -> None:
|
||||
monkeypatch.setenv("NODE_RED_AUTH_METHOD", "basic")
|
||||
monkeypatch.setenv("NODE_RED_AUTH_USERNAME", "admin")
|
||||
monkeypatch.setenv("NODE_RED_AUTH_PASSWORD", "app-password-123")
|
||||
cfg = Config(_env_file=None)
|
||||
assert cfg.auth_method == "basic"
|
||||
assert cfg.auth_username == "admin"
|
||||
assert cfg.auth_password == "app-password-123"
|
||||
|
||||
def test_auth_method_oauth(self, monkeypatch) -> None:
|
||||
monkeypatch.setenv("NODE_RED_AUTH_METHOD", "oauth")
|
||||
monkeypatch.setenv("NODE_RED_AUTH_USERNAME", "svc-account")
|
||||
monkeypatch.setenv("NODE_RED_AUTH_PASSWORD", "svc-password")
|
||||
monkeypatch.setenv("NODE_RED_OAUTH_TOKEN_URL", "https://auth.example.com/application/o/token/")
|
||||
monkeypatch.setenv("NODE_RED_OAUTH_CLIENT_ID", "my-client-id")
|
||||
cfg = Config(_env_file=None)
|
||||
assert cfg.auth_method == "oauth"
|
||||
assert cfg.auth_username == "svc-account"
|
||||
assert cfg.auth_password == "svc-password"
|
||||
assert cfg.oauth_token_url == "https://auth.example.com/application/o/token/"
|
||||
assert cfg.oauth_client_id == "my-client-id"
|
||||
379
tests/test_models.py
Normal file
379
tests/test_models.py
Normal file
@@ -0,0 +1,379 @@
|
||||
"""Tests for Pydantic data models and their from_api() methods."""
|
||||
|
||||
from nodered_mcp.models.flow import Flow, FlowState, FlowTab, Node
|
||||
from nodered_mcp.models.node import NodeModule, NodeSet
|
||||
from nodered_mcp.models.responses import (
|
||||
DiagnosticsResult,
|
||||
FlowCreateResult,
|
||||
FlowList,
|
||||
FlowSummary,
|
||||
Settings,
|
||||
)
|
||||
|
||||
|
||||
# ── BaseApiModel tests ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestBaseApiModel:
|
||||
def test_from_api_single_dict(self) -> None:
|
||||
result = Node.from_api({"id": "test1", "type": "inject"})
|
||||
assert isinstance(result, Node)
|
||||
assert result.id == "test1"
|
||||
assert result.type == "inject"
|
||||
|
||||
def test_from_api_list_of_dicts(self) -> None:
|
||||
results = Node.from_api([
|
||||
{"id": "n1", "type": "inject"},
|
||||
{"id": "n2", "type": "debug"},
|
||||
])
|
||||
assert isinstance(results, list)
|
||||
assert len(results) == 2
|
||||
assert all(isinstance(n, Node) for n in results)
|
||||
|
||||
|
||||
# ── Node model tests ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNode:
|
||||
def test_from_api_basic(self) -> None:
|
||||
node = Node.from_api({
|
||||
"id": "node1",
|
||||
"type": "inject",
|
||||
"z": "tab1",
|
||||
"name": "Test Node",
|
||||
"x": 100,
|
||||
"y": 200,
|
||||
"wires": [["node2"]],
|
||||
})
|
||||
assert node.id == "node1"
|
||||
assert node.type == "inject"
|
||||
assert node.z == "tab1"
|
||||
assert node.name == "Test Node"
|
||||
assert node.x == 100
|
||||
assert node.y == 200
|
||||
assert node.wires == [["node2"]]
|
||||
|
||||
def test_from_api_preserves_extra_fields(self) -> None:
|
||||
"""Verify extra='allow' captures type-specific fields."""
|
||||
node = Node.from_api({
|
||||
"id": "inject1",
|
||||
"type": "inject",
|
||||
"payload": "test",
|
||||
"payloadType": "str",
|
||||
"repeat": "",
|
||||
"crontab": "",
|
||||
"once": False,
|
||||
"topic": "test/topic",
|
||||
})
|
||||
assert node.id == "inject1"
|
||||
# Extra fields should be accessible as attributes
|
||||
assert hasattr(node, "payload")
|
||||
assert node.payload == "test" # type: ignore[attr-defined]
|
||||
assert node.payloadType == "str" # type: ignore[attr-defined]
|
||||
assert node.topic == "test/topic" # type: ignore[attr-defined]
|
||||
|
||||
def test_from_api_minimal(self) -> None:
|
||||
node = Node.from_api({"id": "n1"})
|
||||
assert node.id == "n1"
|
||||
assert node.type == ""
|
||||
assert node.z == ""
|
||||
assert node.name == ""
|
||||
assert node.wires == []
|
||||
assert node.x == 0
|
||||
assert node.y == 0
|
||||
|
||||
def test_from_api_list(self) -> None:
|
||||
nodes = Node.from_api([
|
||||
{"id": "n1", "type": "inject"},
|
||||
{"id": "n2", "type": "debug"},
|
||||
])
|
||||
assert isinstance(nodes, list)
|
||||
assert len(nodes) == 2
|
||||
|
||||
|
||||
# ── FlowTab model tests ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFlowTab:
|
||||
def test_from_api_complete(self) -> None:
|
||||
tab = FlowTab.from_api({
|
||||
"id": "tab1",
|
||||
"label": "Main Flow",
|
||||
"disabled": False,
|
||||
"info": "Primary workspace",
|
||||
})
|
||||
assert tab.id == "tab1"
|
||||
assert tab.label == "Main Flow"
|
||||
assert tab.disabled is False
|
||||
assert tab.info == "Primary workspace"
|
||||
|
||||
def test_from_api_minimal(self) -> None:
|
||||
tab = FlowTab.from_api({"id": "tab1"})
|
||||
assert tab.id == "tab1"
|
||||
assert tab.label == ""
|
||||
assert tab.disabled is False
|
||||
assert tab.info == ""
|
||||
|
||||
def test_from_api_list(self) -> None:
|
||||
tabs = FlowTab.from_api([
|
||||
{"id": "tab1", "label": "Flow 1"},
|
||||
{"id": "tab2", "label": "Flow 2"},
|
||||
])
|
||||
assert isinstance(tabs, list)
|
||||
assert len(tabs) == 2
|
||||
|
||||
|
||||
# ── Flow model tests ──────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFlow:
|
||||
def test_from_api_complete(self) -> None:
|
||||
flow = Flow.from_api({
|
||||
"id": "flow1",
|
||||
"label": "Test Flow",
|
||||
"nodes": [
|
||||
{"id": "n1", "type": "inject"},
|
||||
{"id": "n2", "type": "debug"},
|
||||
],
|
||||
"configs": [
|
||||
{"id": "c1", "type": "mqtt-broker"},
|
||||
],
|
||||
"subflows": [],
|
||||
})
|
||||
assert flow.id == "flow1"
|
||||
assert flow.label == "Test Flow"
|
||||
assert len(flow.nodes) == 2
|
||||
assert isinstance(flow.nodes[0], Node)
|
||||
assert len(flow.configs) == 1
|
||||
assert isinstance(flow.configs[0], Node)
|
||||
assert flow.subflows == []
|
||||
|
||||
def test_from_api_minimal(self) -> None:
|
||||
flow = Flow.from_api({"id": "flow1"})
|
||||
assert flow.id == "flow1"
|
||||
assert flow.label == ""
|
||||
assert flow.nodes == []
|
||||
assert flow.configs == []
|
||||
assert flow.subflows == []
|
||||
|
||||
def test_from_api_with_nested_nodes(self) -> None:
|
||||
flow = Flow.from_api({
|
||||
"id": "flow1",
|
||||
"label": "Test",
|
||||
"nodes": [
|
||||
{"id": "n1", "type": "inject", "name": "Input"},
|
||||
],
|
||||
})
|
||||
assert flow.nodes[0].id == "n1"
|
||||
assert flow.nodes[0].name == "Input"
|
||||
|
||||
|
||||
# ── FlowState model tests ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFlowState:
|
||||
def test_from_api_start(self) -> None:
|
||||
state = FlowState.from_api({"state": "start"})
|
||||
assert state.state == "start"
|
||||
|
||||
def test_from_api_stop(self) -> None:
|
||||
state = FlowState.from_api({"state": "stop"})
|
||||
assert state.state == "stop"
|
||||
|
||||
def test_from_api_minimal(self) -> None:
|
||||
state = FlowState.from_api({})
|
||||
assert state.state == ""
|
||||
|
||||
|
||||
# ── NodeSet model tests ───────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNodeSet:
|
||||
def test_from_api_complete(self) -> None:
|
||||
node_set = NodeSet.from_api({
|
||||
"id": "node-red/inject",
|
||||
"name": "inject",
|
||||
"types": ["inject"],
|
||||
"enabled": True,
|
||||
"module": "node-red",
|
||||
"version": "3.1.0",
|
||||
})
|
||||
assert node_set.id == "node-red/inject"
|
||||
assert node_set.name == "inject"
|
||||
assert node_set.types == ["inject"]
|
||||
assert node_set.enabled is True
|
||||
assert node_set.module == "node-red"
|
||||
assert node_set.version == "3.1.0"
|
||||
|
||||
def test_from_api_minimal(self) -> None:
|
||||
node_set = NodeSet.from_api({})
|
||||
assert node_set.id == ""
|
||||
assert node_set.name == ""
|
||||
assert node_set.types == []
|
||||
assert node_set.enabled is True
|
||||
assert node_set.module == ""
|
||||
assert node_set.version == ""
|
||||
|
||||
def test_from_api_list(self) -> None:
|
||||
node_sets = NodeSet.from_api([
|
||||
{"id": "ns1", "name": "test1"},
|
||||
{"id": "ns2", "name": "test2"},
|
||||
])
|
||||
assert isinstance(node_sets, list)
|
||||
assert len(node_sets) == 2
|
||||
|
||||
|
||||
# ── NodeModule model tests ────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNodeModule:
|
||||
def test_from_api_complete(self) -> None:
|
||||
module = NodeModule.from_api({
|
||||
"name": "node-red-contrib-test",
|
||||
"version": "1.0.0",
|
||||
"nodes": [
|
||||
{
|
||||
"id": "test/node1",
|
||||
"name": "node1",
|
||||
"types": ["test-input"],
|
||||
"enabled": True,
|
||||
"module": "node-red-contrib-test",
|
||||
"version": "1.0.0",
|
||||
},
|
||||
],
|
||||
})
|
||||
assert module.name == "node-red-contrib-test"
|
||||
assert module.version == "1.0.0"
|
||||
assert len(module.nodes) == 1
|
||||
assert isinstance(module.nodes[0], NodeSet)
|
||||
assert module.nodes[0].name == "node1"
|
||||
|
||||
def test_from_api_with_nested_node_sets(self) -> None:
|
||||
module = NodeModule.from_api({
|
||||
"name": "test-module",
|
||||
"version": "2.0.0",
|
||||
"nodes": [
|
||||
{"id": "ns1", "name": "set1"},
|
||||
{"id": "ns2", "name": "set2"},
|
||||
],
|
||||
})
|
||||
assert len(module.nodes) == 2
|
||||
assert all(isinstance(ns, NodeSet) for ns in module.nodes)
|
||||
|
||||
def test_from_api_minimal(self) -> None:
|
||||
module = NodeModule.from_api({})
|
||||
assert module.name == ""
|
||||
assert module.version == ""
|
||||
assert module.nodes == []
|
||||
|
||||
|
||||
# ── Settings model tests ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestSettings:
|
||||
def test_from_api_with_validation_alias(self) -> None:
|
||||
"""Verify validation_alias converts httpNodeRoot to http_node_root."""
|
||||
settings = Settings.from_api({
|
||||
"httpNodeRoot": "/api",
|
||||
"version": "3.1.0",
|
||||
"user": {"username": "admin"},
|
||||
})
|
||||
assert settings.http_node_root == "/api"
|
||||
assert settings.version == "3.1.0"
|
||||
assert settings.user == {"username": "admin"}
|
||||
|
||||
def test_from_api_minimal(self) -> None:
|
||||
settings = Settings.from_api({})
|
||||
assert settings.http_node_root == ""
|
||||
assert settings.version == ""
|
||||
assert settings.user is None
|
||||
|
||||
def test_from_api_snake_case_direct(self) -> None:
|
||||
"""Verify snake_case keys also work (populate_by_name=True)."""
|
||||
settings = Settings.from_api({
|
||||
"http_node_root": "/custom",
|
||||
"version": "3.0.0",
|
||||
})
|
||||
assert settings.http_node_root == "/custom"
|
||||
|
||||
|
||||
# ── DiagnosticsResult model tests ─────────────────────────────────
|
||||
|
||||
|
||||
class TestDiagnosticsResult:
|
||||
def test_from_api_with_nested_data(self) -> None:
|
||||
diag = DiagnosticsResult.from_api({
|
||||
"data": {
|
||||
"report": {
|
||||
"versions": {"node-red": "3.1.0"},
|
||||
"platform": {"os": "darwin"},
|
||||
},
|
||||
}
|
||||
})
|
||||
assert "report" in diag.data
|
||||
assert diag.data["report"]["versions"]["node-red"] == "3.1.0"
|
||||
|
||||
def test_from_api_minimal(self) -> None:
|
||||
diag = DiagnosticsResult.from_api({})
|
||||
assert diag.data == {}
|
||||
|
||||
|
||||
# ── FlowCreateResult model tests ──────────────────────────────────
|
||||
|
||||
|
||||
class TestFlowCreateResult:
|
||||
def test_from_api(self) -> None:
|
||||
result = FlowCreateResult.from_api({"id": "new-flow-123"})
|
||||
assert result.id == "new-flow-123"
|
||||
|
||||
def test_from_api_minimal(self) -> None:
|
||||
result = FlowCreateResult.from_api({})
|
||||
assert result.id == ""
|
||||
|
||||
|
||||
# ── FlowList model tests ──────────────────────────────────────────
|
||||
|
||||
|
||||
class TestFlowList:
|
||||
def test_from_api_complete(self) -> None:
|
||||
flow_list = FlowList.from_api({
|
||||
"flows": [{"id": "n1"}, {"id": "n2"}],
|
||||
"tabs": [{"id": "tab1"}],
|
||||
"summary": "2 flows, 1 tab",
|
||||
"statistics": {"total_nodes": 2, "total_tabs": 1},
|
||||
})
|
||||
assert len(flow_list.flows) == 2
|
||||
assert len(flow_list.tabs) == 1
|
||||
assert flow_list.summary == "2 flows, 1 tab"
|
||||
assert flow_list.statistics["total_nodes"] == 2
|
||||
|
||||
def test_from_api_minimal(self) -> None:
|
||||
flow_list = FlowList.from_api({})
|
||||
assert flow_list.flows == []
|
||||
assert flow_list.tabs == []
|
||||
assert flow_list.summary == ""
|
||||
assert flow_list.statistics == {}
|
||||
|
||||
|
||||
# ── FlowSummary model tests ───────────────────────────────────────
|
||||
|
||||
|
||||
class TestFlowSummary:
|
||||
def test_from_api_complete(self) -> None:
|
||||
summary = FlowSummary.from_api({
|
||||
"summary": "Total: 5 nodes across 2 tabs",
|
||||
"statistics": {"nodes": 5, "tabs": 2},
|
||||
"data": {
|
||||
"tabs": [{"id": "tab1"}],
|
||||
"nodes": [{"id": "n1"}],
|
||||
},
|
||||
})
|
||||
assert summary.summary == "Total: 5 nodes across 2 tabs"
|
||||
assert summary.statistics["nodes"] == 5
|
||||
assert "tabs" in summary.data
|
||||
|
||||
def test_from_api_minimal(self) -> None:
|
||||
summary = FlowSummary.from_api({})
|
||||
assert summary.summary == ""
|
||||
assert summary.statistics == {}
|
||||
assert summary.data == {}
|
||||
0
tests/tools/__init__.py
Normal file
0
tests/tools/__init__.py
Normal file
409
tests/tools/test_flows.py
Normal file
409
tests/tools/test_flows.py
Normal file
@@ -0,0 +1,409 @@
|
||||
"""Tests for flow tools."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from nodered_mcp.client import NodeRedClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetFlows:
|
||||
async def test_get_flows(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.flows import get_flows
|
||||
|
||||
result = await get_flows.fn()
|
||||
|
||||
# Should return FlowList with flows and tabs separated
|
||||
from nodered_mcp.models.responses import FlowList
|
||||
assert isinstance(result, FlowList)
|
||||
assert len(result.flows) > 0
|
||||
assert len(result.tabs) > 0
|
||||
assert result.summary != ""
|
||||
assert "statistics" in result.__dict__
|
||||
mock_client.get_flows.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestUpdateFlows:
|
||||
async def test_update_flows_default_deployment(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.flows import update_flows
|
||||
|
||||
flows_json = json.dumps([{"id": "test", "type": "tab"}])
|
||||
result = await update_flows.fn(flows_json)
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "updated" in result.lower() or "success" in result.lower()
|
||||
mock_client.update_flows.assert_called_once()
|
||||
# Verify default deployment_type is "full"
|
||||
call_args = mock_client.update_flows.call_args
|
||||
assert call_args[1]["deployment_type"] == "full"
|
||||
|
||||
async def test_update_flows_custom_deployment(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.flows import update_flows
|
||||
|
||||
flows_json = json.dumps([{"id": "test"}])
|
||||
await update_flows.fn(flows_json, deployment_type="nodes")
|
||||
|
||||
call_args = mock_client.update_flows.call_args
|
||||
assert call_args[1]["deployment_type"] == "nodes"
|
||||
|
||||
async def test_update_flows_parses_json(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.flows import update_flows
|
||||
|
||||
flows_json = json.dumps([{"id": "flow1"}, {"id": "flow2"}])
|
||||
await update_flows.fn(flows_json)
|
||||
|
||||
# Verify JSON was parsed and passed to client
|
||||
call_args = mock_client.update_flows.call_args
|
||||
flows_list = call_args[0][0]
|
||||
assert isinstance(flows_list, list)
|
||||
assert len(flows_list) == 2
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetFlow:
|
||||
async def test_get_flow(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.flows import get_flow
|
||||
|
||||
result = await get_flow.fn("tab1")
|
||||
|
||||
from nodered_mcp.models.flow import Flow
|
||||
assert isinstance(result, Flow)
|
||||
assert result.id != ""
|
||||
mock_client.get_flow.assert_called_once_with("tab1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestUpdateFlow:
|
||||
async def test_update_flow(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.flows import update_flow
|
||||
|
||||
flow_json = json.dumps({"id": "tab1", "label": "Updated"})
|
||||
result = await update_flow.fn("tab1", flow_json)
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "updated" in result.lower() or "success" in result.lower()
|
||||
mock_client.update_flow.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestListTabs:
|
||||
async def test_list_tabs(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.flows import list_tabs
|
||||
|
||||
result = await list_tabs.fn()
|
||||
|
||||
# Should return list of FlowTab (filtered from heterogeneous array)
|
||||
assert isinstance(result, list)
|
||||
from nodered_mcp.models.flow import FlowTab
|
||||
# All items should be FlowTab instances
|
||||
assert all(isinstance(tab, FlowTab) for tab in result)
|
||||
# Should have found the tabs from sample data (2 tabs)
|
||||
assert len(result) == 2
|
||||
mock_client.get_flows.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestCreateFlow:
|
||||
async def test_create_flow(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.flows import create_flow
|
||||
|
||||
flow_json = json.dumps({"label": "New Flow", "type": "tab"})
|
||||
result = await create_flow.fn(flow_json)
|
||||
|
||||
from nodered_mcp.models.responses import FlowCreateResult
|
||||
assert isinstance(result, FlowCreateResult)
|
||||
assert result.id == "new-flow-id"
|
||||
mock_client.create_flow.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestDeleteFlow:
|
||||
async def test_delete_flow(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.flows import delete_flow
|
||||
|
||||
result = await delete_flow.fn("tab1")
|
||||
|
||||
assert isinstance(result, str)
|
||||
assert "deleted" in result.lower() or "success" in result.lower()
|
||||
mock_client.delete_flow.assert_called_once_with("tab1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetFlowsState:
|
||||
async def test_get_flows_state(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.flows import get_flows_state
|
||||
|
||||
result = await get_flows_state.fn()
|
||||
|
||||
from nodered_mcp.models.flow import FlowState
|
||||
assert isinstance(result, FlowState)
|
||||
assert result.state != ""
|
||||
mock_client.get_flows_state.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSetFlowsState:
|
||||
async def test_set_flows_state_start(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.flows import set_flows_state
|
||||
|
||||
result = await set_flows_state.fn("start")
|
||||
|
||||
from nodered_mcp.models.flow import FlowState
|
||||
assert isinstance(result, FlowState)
|
||||
mock_client.set_flows_state.assert_called_once_with("start")
|
||||
|
||||
async def test_set_flows_state_stop(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.flows import set_flows_state
|
||||
|
||||
await set_flows_state.fn("stop")
|
||||
|
||||
mock_client.set_flows_state.assert_called_once_with("stop")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetFlowsFormatted:
|
||||
async def test_get_flows_formatted(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.flows import get_flows_formatted
|
||||
|
||||
result = await get_flows_formatted.fn()
|
||||
|
||||
from nodered_mcp.models.responses import FlowSummary
|
||||
assert isinstance(result, FlowSummary)
|
||||
assert result.summary != ""
|
||||
assert "statistics" in result.__dict__
|
||||
# Should have grouped data
|
||||
assert "data" in result.__dict__
|
||||
mock_client.get_flows.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestVisualizeFlows:
|
||||
async def test_visualize_flows(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.flows import visualize_flows
|
||||
|
||||
result = await visualize_flows.fn()
|
||||
|
||||
# Should return markdown string
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
# Should contain tab names from sample data
|
||||
assert "Main Flow" in result or "tab" in result.lower()
|
||||
mock_client.get_flows.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestPatchFlow:
|
||||
"""Tests for patch_flow tool and _apply_patch_ops helper."""
|
||||
|
||||
def _make_flow(self) -> dict:
|
||||
"""Build a minimal flow dict for patching tests."""
|
||||
return {
|
||||
"id": "flow1",
|
||||
"label": "Test Flow",
|
||||
"info": "",
|
||||
"disabled": False,
|
||||
"nodes": [
|
||||
{"id": "n1", "type": "inject", "z": "flow1", "name": "trigger", "wires": [["n2", "n3"]]},
|
||||
{"id": "n2", "type": "function", "z": "flow1", "name": "process", "wires": [["n3"]]},
|
||||
{"id": "n3", "type": "debug", "z": "flow1", "name": "output", "wires": []},
|
||||
],
|
||||
"configs": [],
|
||||
"subflows": [],
|
||||
}
|
||||
|
||||
async def test_remove_nodes(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.flows import patch_flow
|
||||
|
||||
flow = self._make_flow()
|
||||
mock_client.get_flow.return_value = flow
|
||||
|
||||
ops = json.dumps([{"op": "remove_nodes", "ids": ["n3"]}])
|
||||
result = await patch_flow.fn("flow1", ops)
|
||||
|
||||
assert "removed 1 nodes" in result
|
||||
call_args = mock_client.update_flow.call_args
|
||||
patched = call_args[0][1]
|
||||
node_ids = [n["id"] for n in patched["nodes"]]
|
||||
assert "n3" not in node_ids
|
||||
assert len(patched["nodes"]) == 2
|
||||
|
||||
async def test_remove_nodes_wire_cleanup(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.flows import patch_flow
|
||||
|
||||
flow = self._make_flow()
|
||||
mock_client.get_flow.return_value = flow
|
||||
|
||||
ops = json.dumps([{"op": "remove_nodes", "ids": ["n2"]}])
|
||||
await patch_flow.fn("flow1", ops)
|
||||
|
||||
call_args = mock_client.update_flow.call_args
|
||||
patched = call_args[0][1]
|
||||
# n1 originally wired to ["n2", "n3"] — n2 should be stripped
|
||||
n1 = next(n for n in patched["nodes"] if n["id"] == "n1")
|
||||
assert n1["wires"] == [["n3"]]
|
||||
|
||||
async def test_add_nodes(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.flows import patch_flow
|
||||
|
||||
flow = self._make_flow()
|
||||
mock_client.get_flow.return_value = flow
|
||||
|
||||
new_node = {"id": "n4", "type": "debug", "name": "new debug", "wires": []}
|
||||
ops = json.dumps([{"op": "add_nodes", "nodes": [new_node]}])
|
||||
result = await patch_flow.fn("flow1", ops)
|
||||
|
||||
assert "added 1 nodes" in result
|
||||
call_args = mock_client.update_flow.call_args
|
||||
patched = call_args[0][1]
|
||||
n4 = next(n for n in patched["nodes"] if n["id"] == "n4")
|
||||
assert n4["z"] == "flow1"
|
||||
assert len(patched["nodes"]) == 4
|
||||
|
||||
async def test_update_node(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.flows import patch_flow
|
||||
|
||||
flow = self._make_flow()
|
||||
mock_client.get_flow.return_value = flow
|
||||
|
||||
ops = json.dumps([{"op": "update_node", "id": "n2", "set": {"name": "Renamed", "x": 500}}])
|
||||
result = await patch_flow.fn("flow1", ops)
|
||||
|
||||
assert "updated node n2" in result
|
||||
call_args = mock_client.update_flow.call_args
|
||||
patched = call_args[0][1]
|
||||
n2 = next(n for n in patched["nodes"] if n["id"] == "n2")
|
||||
assert n2["name"] == "Renamed"
|
||||
assert n2["x"] == 500
|
||||
assert n2["type"] == "function" # unchanged
|
||||
|
||||
async def test_update_node_not_found(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.flows import patch_flow
|
||||
|
||||
flow = self._make_flow()
|
||||
mock_client.get_flow.return_value = flow
|
||||
|
||||
ops = json.dumps([{"op": "update_node", "id": "missing", "set": {"name": "X"}}])
|
||||
result = await patch_flow.fn("flow1", ops)
|
||||
|
||||
assert "not found" in result
|
||||
|
||||
async def test_rewire(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.flows import patch_flow
|
||||
|
||||
flow = self._make_flow()
|
||||
mock_client.get_flow.return_value = flow
|
||||
|
||||
ops = json.dumps([{"op": "rewire", "id": "n1", "output": 0, "targets": ["n3"]}])
|
||||
result = await patch_flow.fn("flow1", ops)
|
||||
|
||||
assert "rewired node n1 output 0" in result
|
||||
call_args = mock_client.update_flow.call_args
|
||||
patched = call_args[0][1]
|
||||
n1 = next(n for n in patched["nodes"] if n["id"] == "n1")
|
||||
assert n1["wires"][0] == ["n3"]
|
||||
|
||||
async def test_rewire_append(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.flows import patch_flow
|
||||
|
||||
flow = self._make_flow()
|
||||
mock_client.get_flow.return_value = flow
|
||||
# n1 starts with wires [["n2", "n3"]] — append "n4"
|
||||
ops = json.dumps([{"op": "rewire", "id": "n1", "output": 0, "targets": ["n4"], "mode": "append"}])
|
||||
result = await patch_flow.fn("flow1", ops)
|
||||
|
||||
assert "rewired node n1 output 0" in result
|
||||
call_args = mock_client.update_flow.call_args
|
||||
patched = call_args[0][1]
|
||||
n1 = next(n for n in patched["nodes"] if n["id"] == "n1")
|
||||
assert n1["wires"][0] == ["n2", "n3", "n4"]
|
||||
|
||||
async def test_rewire_append_deduplicates(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.flows import patch_flow
|
||||
|
||||
flow = self._make_flow()
|
||||
mock_client.get_flow.return_value = flow
|
||||
# n1 starts with wires [["n2", "n3"]] — append "n2" (already present)
|
||||
ops = json.dumps([{"op": "rewire", "id": "n1", "output": 0, "targets": ["n2"], "mode": "append"}])
|
||||
await patch_flow.fn("flow1", ops)
|
||||
|
||||
call_args = mock_client.update_flow.call_args
|
||||
patched = call_args[0][1]
|
||||
n1 = next(n for n in patched["nodes"] if n["id"] == "n1")
|
||||
assert n1["wires"][0] == ["n3", "n2"] # n2 moved to end, no duplicate
|
||||
|
||||
async def test_set_label(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.flows import patch_flow
|
||||
|
||||
flow = self._make_flow()
|
||||
mock_client.get_flow.return_value = flow
|
||||
|
||||
ops = json.dumps([{"op": "set_label", "label": "Office Automation"}])
|
||||
result = await patch_flow.fn("flow1", ops)
|
||||
|
||||
assert "Office Automation" in result
|
||||
call_args = mock_client.update_flow.call_args
|
||||
patched = call_args[0][1]
|
||||
assert patched["label"] == "Office Automation"
|
||||
|
||||
async def test_set_info(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.flows import patch_flow
|
||||
|
||||
flow = self._make_flow()
|
||||
mock_client.get_flow.return_value = flow
|
||||
|
||||
ops = json.dumps([{"op": "set_info", "info": "Controls lights and fans"}])
|
||||
result = await patch_flow.fn("flow1", ops)
|
||||
|
||||
assert "info updated" in result
|
||||
call_args = mock_client.update_flow.call_args
|
||||
patched = call_args[0][1]
|
||||
assert patched["info"] == "Controls lights and fans"
|
||||
|
||||
async def test_set_disabled(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.flows import patch_flow
|
||||
|
||||
flow = self._make_flow()
|
||||
mock_client.get_flow.return_value = flow
|
||||
|
||||
ops = json.dumps([{"op": "set_disabled", "disabled": True}])
|
||||
result = await patch_flow.fn("flow1", ops)
|
||||
|
||||
assert "disabled" in result
|
||||
call_args = mock_client.update_flow.call_args
|
||||
patched = call_args[0][1]
|
||||
assert patched["disabled"] is True
|
||||
|
||||
async def test_multiple_ops(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.flows import patch_flow
|
||||
|
||||
flow = self._make_flow()
|
||||
mock_client.get_flow.return_value = flow
|
||||
|
||||
ops = json.dumps([
|
||||
{"op": "set_label", "label": "Renamed"},
|
||||
{"op": "remove_nodes", "ids": ["n3"]},
|
||||
{"op": "update_node", "id": "n1", "set": {"name": "Start"}},
|
||||
])
|
||||
result = await patch_flow.fn("flow1", ops)
|
||||
|
||||
assert "Renamed" in result
|
||||
assert "removed 1" in result
|
||||
assert "updated node n1" in result
|
||||
call_args = mock_client.update_flow.call_args
|
||||
patched = call_args[0][1]
|
||||
assert patched["label"] == "Renamed"
|
||||
assert len(patched["nodes"]) == 2
|
||||
n1 = next(n for n in patched["nodes"] if n["id"] == "n1")
|
||||
assert n1["name"] == "Start"
|
||||
|
||||
async def test_unknown_op(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.flows import patch_flow
|
||||
|
||||
flow = self._make_flow()
|
||||
mock_client.get_flow.return_value = flow
|
||||
|
||||
ops = json.dumps([{"op": "explode"}])
|
||||
with pytest.raises(ValueError, match="Unknown patch op: explode"):
|
||||
await patch_flow.fn("flow1", ops)
|
||||
222
tests/tools/test_layout.py
Normal file
222
tests/tools/test_layout.py
Normal file
@@ -0,0 +1,222 @@
|
||||
"""Tests for layout linting tools."""
|
||||
|
||||
import json
|
||||
|
||||
import pytest
|
||||
|
||||
from nodered_mcp.client import NodeRedClient
|
||||
from nodered_mcp.models.layout import LayoutIssue, LayoutReport
|
||||
from nodered_mcp.tools.layout import _lint_nodes, format_lint_report
|
||||
|
||||
|
||||
class TestLintNodesNoIssues:
|
||||
def test_well_spaced_nodes(self) -> None:
|
||||
nodes = [
|
||||
{"id": "n1", "type": "inject", "name": "A", "x": 100, "y": 100, "wires": [["n2"]]},
|
||||
{"id": "n2", "type": "debug", "name": "B", "x": 400, "y": 100, "wires": []},
|
||||
]
|
||||
report = _lint_nodes(nodes)
|
||||
|
||||
assert isinstance(report, LayoutReport)
|
||||
assert report.issues == []
|
||||
assert report.nodes_checked == 2
|
||||
assert report.summary == "no issues"
|
||||
|
||||
|
||||
class TestOverlapDetection:
|
||||
def test_same_position(self) -> None:
|
||||
nodes = [
|
||||
{"id": "n1", "type": "inject", "name": "A", "x": 300, "y": 100, "wires": []},
|
||||
{"id": "n2", "type": "debug", "name": "B", "x": 300, "y": 100, "wires": []},
|
||||
]
|
||||
report = _lint_nodes(nodes)
|
||||
|
||||
assert len(report.issues) == 1
|
||||
issue = report.issues[0]
|
||||
assert issue.severity == "error"
|
||||
assert issue.rule == "overlap"
|
||||
assert "n1" in issue.node_ids
|
||||
assert "n2" in issue.node_ids
|
||||
assert "1 error" in report.summary
|
||||
|
||||
def test_partial_overlap(self) -> None:
|
||||
# Nodes 50px apart horizontally (< DEFAULT_NODE_WIDTH=120), same y
|
||||
nodes = [
|
||||
{"id": "n1", "type": "inject", "name": "A", "x": 100, "y": 100, "wires": []},
|
||||
{"id": "n2", "type": "debug", "name": "B", "x": 150, "y": 100, "wires": []},
|
||||
]
|
||||
report = _lint_nodes(nodes)
|
||||
|
||||
overlap_issues = [i for i in report.issues if i.rule == "overlap"]
|
||||
assert len(overlap_issues) == 1
|
||||
|
||||
|
||||
class TestSpacingDetection:
|
||||
def test_nodes_too_close(self) -> None:
|
||||
# 130px apart horizontally: > 120 (no overlap) but < 140 (120+20 spacing)
|
||||
# Same y so dy=0 < 30+20=50
|
||||
nodes = [
|
||||
{"id": "n1", "type": "inject", "name": "A", "x": 100, "y": 100, "wires": []},
|
||||
{"id": "n2", "type": "debug", "name": "B", "x": 230, "y": 100, "wires": []},
|
||||
]
|
||||
report = _lint_nodes(nodes)
|
||||
|
||||
spacing_issues = [i for i in report.issues if i.rule == "spacing"]
|
||||
assert len(spacing_issues) == 1
|
||||
assert spacing_issues[0].severity == "warning"
|
||||
assert "n1" in spacing_issues[0].node_ids
|
||||
assert "n2" in spacing_issues[0].node_ids
|
||||
|
||||
def test_no_double_report_with_overlap(self) -> None:
|
||||
# Same position: should only get overlap, not also spacing
|
||||
nodes = [
|
||||
{"id": "n1", "type": "inject", "name": "A", "x": 100, "y": 100, "wires": []},
|
||||
{"id": "n2", "type": "debug", "name": "B", "x": 100, "y": 100, "wires": []},
|
||||
]
|
||||
report = _lint_nodes(nodes)
|
||||
|
||||
spacing_issues = [i for i in report.issues if i.rule == "spacing"]
|
||||
assert len(spacing_issues) == 0
|
||||
|
||||
|
||||
class TestWireCrossing:
|
||||
def test_wire_through_node(self) -> None:
|
||||
# A at x=100 wires to C at x=500; B sits at x=300 in the middle, same y
|
||||
nodes = [
|
||||
{"id": "A", "type": "inject", "name": "A", "x": 100, "y": 100, "wires": [["C"]]},
|
||||
{"id": "B", "type": "function", "name": "B", "x": 300, "y": 100, "wires": []},
|
||||
{"id": "C", "type": "debug", "name": "C", "x": 500, "y": 100, "wires": []},
|
||||
]
|
||||
report = _lint_nodes(nodes)
|
||||
|
||||
wire_issues = [i for i in report.issues if i.rule == "wire_crossing"]
|
||||
assert len(wire_issues) >= 1
|
||||
issue = wire_issues[0]
|
||||
assert "B" in issue.node_ids
|
||||
assert issue.severity == "warning"
|
||||
|
||||
def test_no_crossing_when_wire_avoids_node(self) -> None:
|
||||
# A wires to C; B is far off the path
|
||||
nodes = [
|
||||
{"id": "A", "type": "inject", "name": "A", "x": 100, "y": 100, "wires": [["C"]]},
|
||||
{"id": "B", "type": "function", "name": "B", "x": 300, "y": 500, "wires": []},
|
||||
{"id": "C", "type": "debug", "name": "C", "x": 500, "y": 100, "wires": []},
|
||||
]
|
||||
report = _lint_nodes(nodes)
|
||||
|
||||
wire_issues = [i for i in report.issues if i.rule == "wire_crossing"]
|
||||
assert len(wire_issues) == 0
|
||||
|
||||
|
||||
class TestScopedCheck:
|
||||
def test_node_ids_filter(self) -> None:
|
||||
# n1 and n2 overlap, n3 is well-separated
|
||||
nodes = [
|
||||
{"id": "n1", "type": "inject", "name": "A", "x": 100, "y": 100, "wires": []},
|
||||
{"id": "n2", "type": "debug", "name": "B", "x": 100, "y": 100, "wires": []},
|
||||
{"id": "n3", "type": "function", "name": "C", "x": 500, "y": 500, "wires": []},
|
||||
]
|
||||
# Scope to n3 only — should find no issues since n3 doesn't overlap with anyone
|
||||
report = _lint_nodes(nodes, node_ids=["n3"])
|
||||
assert report.issues == []
|
||||
|
||||
def test_scoped_finds_relevant_issues(self) -> None:
|
||||
nodes = [
|
||||
{"id": "n1", "type": "inject", "name": "A", "x": 100, "y": 100, "wires": []},
|
||||
{"id": "n2", "type": "debug", "name": "B", "x": 100, "y": 100, "wires": []},
|
||||
]
|
||||
# Scope to n1 — should still find the overlap with n2
|
||||
report = _lint_nodes(nodes, node_ids=["n1"])
|
||||
assert len(report.issues) == 1
|
||||
assert report.issues[0].rule == "overlap"
|
||||
|
||||
|
||||
class TestSkipsUnplacedNodes:
|
||||
def test_nodes_at_origin_skipped(self) -> None:
|
||||
nodes = [
|
||||
{"id": "n1", "type": "inject", "name": "A", "x": 0, "y": 0, "wires": []},
|
||||
{"id": "n2", "type": "debug", "name": "B", "x": 0, "y": 0, "wires": []},
|
||||
]
|
||||
report = _lint_nodes(nodes)
|
||||
|
||||
assert report.issues == []
|
||||
assert report.nodes_checked == 0
|
||||
|
||||
def test_config_nodes_without_position_skipped(self) -> None:
|
||||
nodes = [
|
||||
{"id": "n1", "type": "mqtt-broker", "name": "MQTT"},
|
||||
{"id": "n2", "type": "inject", "name": "A", "x": 100, "y": 100, "wires": []},
|
||||
]
|
||||
report = _lint_nodes(nodes)
|
||||
|
||||
assert report.issues == []
|
||||
assert report.nodes_checked == 1
|
||||
|
||||
|
||||
class TestFormatLintReport:
|
||||
def test_empty_report(self) -> None:
|
||||
report = LayoutReport(issues=[], nodes_checked=5, summary="no issues")
|
||||
assert format_lint_report(report) == ""
|
||||
|
||||
def test_report_with_issues(self) -> None:
|
||||
report = LayoutReport(
|
||||
issues=[
|
||||
LayoutIssue(
|
||||
severity="error",
|
||||
rule="overlap",
|
||||
message='"A" and "B" overlap',
|
||||
node_ids=["n1", "n2"],
|
||||
),
|
||||
],
|
||||
nodes_checked=2,
|
||||
summary="1 error",
|
||||
)
|
||||
formatted = format_lint_report(report)
|
||||
assert "Layout (1 error):" in formatted
|
||||
assert "[error] overlap:" in formatted
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestLintFlowTool:
|
||||
async def test_lint_flow_returns_report(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.layout import lint_flow
|
||||
|
||||
result = await lint_flow.fn("tab1")
|
||||
|
||||
assert isinstance(result, LayoutReport)
|
||||
mock_client.get_flow.assert_called_once_with("tab1")
|
||||
|
||||
async def test_lint_flow_with_node_ids(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.layout import lint_flow
|
||||
|
||||
mock_client.get_flow.return_value = {
|
||||
"id": "tab1",
|
||||
"nodes": [
|
||||
{"id": "n1", "type": "inject", "name": "A", "x": 100, "y": 100, "wires": []},
|
||||
{"id": "n2", "type": "debug", "name": "B", "x": 100, "y": 100, "wires": []},
|
||||
],
|
||||
"configs": [],
|
||||
"subflows": [],
|
||||
}
|
||||
|
||||
# Scoped to n1 — should find overlap
|
||||
result = await lint_flow.fn("tab1", node_ids=json.dumps(["n1"]))
|
||||
assert len(result.issues) == 1
|
||||
|
||||
async def test_lint_flow_detects_overlap(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.layout import lint_flow
|
||||
|
||||
mock_client.get_flow.return_value = {
|
||||
"id": "flow1",
|
||||
"nodes": [
|
||||
{"id": "n1", "type": "inject", "name": "A", "x": 200, "y": 200, "wires": []},
|
||||
{"id": "n2", "type": "debug", "name": "B", "x": 200, "y": 200, "wires": []},
|
||||
],
|
||||
"configs": [],
|
||||
"subflows": [],
|
||||
}
|
||||
|
||||
result = await lint_flow.fn("flow1")
|
||||
assert len(result.issues) == 1
|
||||
assert result.issues[0].rule == "overlap"
|
||||
assert result.issues[0].severity == "error"
|
||||
140
tests/tools/test_nodes.py
Normal file
140
tests/tools/test_nodes.py
Normal file
@@ -0,0 +1,140 @@
|
||||
"""Tests for node tools."""
|
||||
|
||||
import pytest
|
||||
|
||||
from nodered_mcp.client import NodeRedClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestInject:
|
||||
async def test_inject(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.nodes import inject
|
||||
|
||||
result = await inject.fn("inject1")
|
||||
|
||||
# Should return confirmation string
|
||||
assert isinstance(result, str)
|
||||
assert "inject" in result.lower() or "triggered" in result.lower()
|
||||
mock_client.inject.assert_called_once_with("inject1")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetNodes:
|
||||
async def test_get_nodes(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.nodes import get_nodes
|
||||
|
||||
result = await get_nodes.fn()
|
||||
|
||||
# Should return list of NodeSet
|
||||
assert isinstance(result, list)
|
||||
from nodered_mcp.models.node import NodeSet
|
||||
assert all(isinstance(ns, NodeSet) for ns in result)
|
||||
assert len(result) > 0
|
||||
mock_client.get_nodes.assert_called_once()
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetNodeInfo:
|
||||
async def test_get_node_info(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.nodes import get_node_info
|
||||
|
||||
result = await get_node_info.fn("node-red-contrib-test")
|
||||
|
||||
# Should return NodeModule
|
||||
from nodered_mcp.models.node import NodeModule
|
||||
assert isinstance(result, NodeModule)
|
||||
assert result.name != ""
|
||||
mock_client.get_node_info.assert_called_once_with("node-red-contrib-test")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestToggleNodeModule:
|
||||
async def test_toggle_node_module_enable(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.nodes import toggle_node_module
|
||||
|
||||
result = await toggle_node_module.fn("node-red-contrib-test", enabled=True)
|
||||
|
||||
# Should return NodeModule (actual API response)
|
||||
from nodered_mcp.models.node import NodeModule
|
||||
assert isinstance(result, NodeModule)
|
||||
mock_client.toggle_node_module.assert_called_once_with(
|
||||
"node-red-contrib-test", True
|
||||
)
|
||||
|
||||
async def test_toggle_node_module_disable(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.nodes import toggle_node_module
|
||||
|
||||
result = await toggle_node_module.fn("node-red-contrib-test", enabled=False)
|
||||
|
||||
from nodered_mcp.models.node import NodeModule
|
||||
assert isinstance(result, NodeModule)
|
||||
mock_client.toggle_node_module.assert_called_once_with(
|
||||
"node-red-contrib-test", False
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestFindNodesByType:
|
||||
async def test_find_nodes_by_type(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.nodes import find_nodes_by_type
|
||||
|
||||
result = await find_nodes_by_type.fn("inject")
|
||||
|
||||
# Should return list of Node (filtered from flow data)
|
||||
assert isinstance(result, list)
|
||||
from nodered_mcp.models.flow import Node
|
||||
assert all(isinstance(n, Node) for n in result)
|
||||
# Sample data has 2 inject nodes
|
||||
assert len(result) == 2
|
||||
assert all(n.type == "inject" for n in result)
|
||||
mock_client.get_flows.assert_called_once()
|
||||
|
||||
async def test_find_nodes_by_type_no_matches(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.nodes import find_nodes_by_type
|
||||
|
||||
result = await find_nodes_by_type.fn("nonexistent-type")
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 0
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestSearchNodes:
|
||||
async def test_search_nodes_by_name(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.nodes import search_nodes
|
||||
|
||||
result = await search_nodes.fn("Test")
|
||||
|
||||
# Should return list of Node matching query
|
||||
assert isinstance(result, list)
|
||||
from nodered_mcp.models.flow import Node
|
||||
assert all(isinstance(n, Node) for n in result)
|
||||
# Should find nodes with "Test" in name
|
||||
assert len(result) > 0
|
||||
mock_client.get_flows.assert_called_once()
|
||||
|
||||
async def test_search_nodes_by_type(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.nodes import search_nodes
|
||||
|
||||
result = await search_nodes.fn("inject")
|
||||
|
||||
# Should match nodes with "inject" in type or name
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
|
||||
async def test_search_nodes_no_matches(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.nodes import search_nodes
|
||||
|
||||
result = await search_nodes.fn("NONEXISTENT_QUERY_STRING")
|
||||
|
||||
assert isinstance(result, list)
|
||||
assert len(result) == 0
|
||||
|
||||
async def test_search_nodes_case_insensitive(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.nodes import search_nodes
|
||||
|
||||
result = await search_nodes.fn("test")
|
||||
|
||||
# Should match regardless of case
|
||||
assert isinstance(result, list)
|
||||
assert len(result) > 0
|
||||
53
tests/tools/test_settings.py
Normal file
53
tests/tools/test_settings.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Tests for settings tools."""
|
||||
|
||||
import pytest
|
||||
|
||||
from nodered_mcp.client import NodeRedClient
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetSettings:
|
||||
async def test_get_settings(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.settings import get_settings
|
||||
|
||||
result = await get_settings.fn()
|
||||
|
||||
# Should return Settings model
|
||||
from nodered_mcp.models.responses import Settings
|
||||
assert isinstance(result, Settings)
|
||||
assert result.http_node_root != ""
|
||||
assert result.version != ""
|
||||
mock_client.get_settings.assert_called_once()
|
||||
|
||||
async def test_get_settings_with_user(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.settings import get_settings
|
||||
|
||||
result = await get_settings.fn()
|
||||
|
||||
# User field should be populated from sample data
|
||||
assert result.user is not None
|
||||
assert "username" in result.user
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestGetDiagnostics:
|
||||
async def test_get_diagnostics(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.settings import get_diagnostics
|
||||
|
||||
result = await get_diagnostics.fn()
|
||||
|
||||
# Should return DiagnosticsResult wrapping data
|
||||
from nodered_mcp.models.responses import DiagnosticsResult
|
||||
assert isinstance(result, DiagnosticsResult)
|
||||
assert isinstance(result.data, dict)
|
||||
assert len(result.data) > 0
|
||||
mock_client.get_diagnostics.assert_called_once()
|
||||
|
||||
async def test_get_diagnostics_preserves_structure(self, mock_client: NodeRedClient) -> None:
|
||||
from nodered_mcp.tools.settings import get_diagnostics
|
||||
|
||||
result = await get_diagnostics.fn()
|
||||
|
||||
# Nested structure from sample_diagnostics_data should be preserved
|
||||
assert "report" in result.data
|
||||
assert "versions" in result.data["report"]
|
||||
46
tests/tools/test_utility.py
Normal file
46
tests/tools/test_utility.py
Normal file
@@ -0,0 +1,46 @@
|
||||
"""Tests for utility tools."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
class TestApiHelp:
|
||||
async def test_api_help_returns_string(self) -> None:
|
||||
from nodered_mcp.tools.utility import api_help
|
||||
|
||||
result = await api_help.fn()
|
||||
|
||||
# Should return markdown/text string
|
||||
assert isinstance(result, str)
|
||||
assert len(result) > 0
|
||||
|
||||
async def test_api_help_contains_endpoints(self) -> None:
|
||||
from nodered_mcp.tools.utility import api_help
|
||||
|
||||
result = await api_help.fn()
|
||||
|
||||
# Should document key API endpoints
|
||||
assert "/flows" in result
|
||||
assert "/flow" in result
|
||||
assert "/nodes" in result
|
||||
assert "/settings" in result
|
||||
|
||||
async def test_api_help_contains_http_methods(self) -> None:
|
||||
from nodered_mcp.tools.utility import api_help
|
||||
|
||||
result = await api_help.fn()
|
||||
|
||||
# Should mention HTTP methods
|
||||
assert "GET" in result
|
||||
assert "POST" in result or "PUT" in result or "DELETE" in result
|
||||
|
||||
async def test_api_help_no_client_call(self, mock_client) -> None:
|
||||
"""Verify api_help doesn't call the client (it's static content)."""
|
||||
from nodered_mcp.tools.utility import api_help
|
||||
|
||||
await api_help.fn()
|
||||
|
||||
# Should not have called any client methods
|
||||
mock_client.get_flows.assert_not_called()
|
||||
mock_client.get_settings.assert_not_called()
|
||||
mock_client.get_nodes.assert_not_called()
|
||||
Reference in New Issue
Block a user