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:
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"
|
||||
Reference in New Issue
Block a user