Files
nodered-mcp/tests/test_client.py
Joe Carter 764d123fdb 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>
2026-02-24 09:19:33 +00:00

423 lines
16 KiB
Python

"""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"