"""Tests for Jana backend API client.
Requirements covered:
- AU-001: Authenticate with Jana backend using token-based authentication
- EH-001: Return meaningful error messages for failed API calls
- EH-002: Handle network errors and timeouts gracefully
- EH-003: Handle authentication failures with clear error messages
- NF-002: Delegate data operations to Jana backend
- IN-004: Connect to Jana backend at configurable URL
"""
from unittest.mock import AsyncMock, MagicMock, patch
import httpx
import pytest
from pydantic import SecretStr
from jana_mcp.client.jana_client import (
APIError,
AuthenticationError,
JanaClient,
JanaClientError,
)
from jana_mcp.config import Settings
@pytest.fixture
def settings_with_credentials():
"""Create settings with username/password."""
return Settings(
jana_backend_url="http://test-backend:8000",
jana_username="testuser",
jana_password=SecretStr("testpass"),
jana_timeout=10,
)
@pytest.fixture
def settings_with_token():
"""Create settings with pre-configured token (AU-003)."""
return Settings(
jana_backend_url="http://test-backend:8000",
jana_token=SecretStr("pre-configured-token"),
jana_timeout=10,
)
@pytest.fixture
def settings_no_credentials(monkeypatch):
"""Create settings without credentials."""
# Clear env vars that might override settings
monkeypatch.delenv("JANA_USERNAME", raising=False)
monkeypatch.delenv("JANA_PASSWORD", raising=False)
monkeypatch.delenv("JANA_TOKEN", raising=False)
return Settings(
jana_backend_url="http://test-backend:8000",
jana_timeout=10,
)
class TestJanaClientInitialization:
"""Test client initialization."""
def test_init_with_settings(self, settings_with_credentials):
"""Test client initializes with provided settings."""
client = JanaClient(settings_with_credentials)
assert client.settings == settings_with_credentials
assert client.base_url == "http://test-backend:8000"
def test_init_with_token_sets_token(self, settings_with_token):
"""Test client initializes with pre-configured token (AU-003)."""
client = JanaClient(settings_with_token)
assert client._token == "pre-configured-token"
def test_init_without_token_sets_none(self, settings_with_credentials):
"""Test client initializes with no token when using username/password."""
client = JanaClient(settings_with_credentials)
assert client._token is None
def test_base_url_strips_trailing_slash(self):
"""Test base_url removes trailing slash (IN-004)."""
settings = Settings(jana_backend_url="http://test:8000/")
client = JanaClient(settings)
assert client.base_url == "http://test:8000"
class TestJanaClientAuthentication:
"""Test authentication functionality (AU-001)."""
@pytest.mark.asyncio
async def test_authenticate_success(self, settings_with_credentials):
"""Test successful authentication returns token (AU-001)."""
client = JanaClient(settings_with_credentials)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"token": "new-auth-token"}
with patch.object(client, "_get_client") as mock_get_client:
mock_http_client = AsyncMock()
mock_http_client.post.return_value = mock_response
mock_get_client.return_value = mock_http_client
token = await client.authenticate()
assert token == "new-auth-token"
assert client._token == "new-auth-token"
mock_http_client.post.assert_called_once_with(
"/api/auth/login/",
json={"username": "testuser", "password": "testpass"},
)
@pytest.mark.asyncio
async def test_authenticate_returns_existing_token(self, settings_with_token):
"""Test authenticate returns existing token without API call (AU-003)."""
client = JanaClient(settings_with_token)
token = await client.authenticate()
assert token == "pre-configured-token"
@pytest.mark.asyncio
async def test_authenticate_no_credentials_raises_error(self, settings_no_credentials):
"""Test authentication fails without credentials (EH-003)."""
client = JanaClient(settings_no_credentials)
with pytest.raises(AuthenticationError) as exc_info:
await client.authenticate()
assert "No authentication credentials configured" in str(exc_info.value)
@pytest.mark.asyncio
async def test_authenticate_invalid_credentials(self, settings_with_credentials):
"""Test authentication with invalid credentials (EH-003)."""
client = JanaClient(settings_with_credentials)
mock_response = MagicMock()
mock_response.status_code = 401
mock_response.text = "Invalid credentials"
with patch.object(client, "_get_client") as mock_get_client:
mock_http_client = AsyncMock()
mock_http_client.post.return_value = mock_response
mock_get_client.return_value = mock_http_client
with pytest.raises(AuthenticationError) as exc_info:
await client.authenticate()
assert "Invalid username or password" in str(exc_info.value)
@pytest.mark.asyncio
async def test_authenticate_server_error(self, settings_with_credentials):
"""Test authentication handles server errors (EH-003)."""
client = JanaClient(settings_with_credentials)
mock_response = MagicMock()
mock_response.status_code = 500
mock_response.text = "Internal Server Error"
with patch.object(client, "_get_client") as mock_get_client:
mock_http_client = AsyncMock()
mock_http_client.post.return_value = mock_response
mock_get_client.return_value = mock_http_client
with pytest.raises(AuthenticationError) as exc_info:
await client.authenticate()
assert "500" in str(exc_info.value)
@pytest.mark.asyncio
async def test_authenticate_network_error(self, settings_with_credentials):
"""Test authentication handles network errors (EH-002)."""
client = JanaClient(settings_with_credentials)
with patch.object(client, "_get_client") as mock_get_client:
mock_http_client = AsyncMock()
mock_http_client.post.side_effect = httpx.RequestError("Connection refused")
mock_get_client.return_value = mock_http_client
with pytest.raises(AuthenticationError) as exc_info:
await client.authenticate()
assert "Failed to connect" in str(exc_info.value)
@pytest.mark.asyncio
async def test_authenticate_no_token_in_response(self, settings_with_credentials):
"""Test authentication fails when response has no token (EH-003)."""
client = JanaClient(settings_with_credentials)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"message": "success"} # No token
with patch.object(client, "_get_client") as mock_get_client:
mock_http_client = AsyncMock()
mock_http_client.post.return_value = mock_response
mock_get_client.return_value = mock_http_client
with pytest.raises(AuthenticationError) as exc_info:
await client.authenticate()
assert "No token in authentication response" in str(exc_info.value)
class TestJanaClientRequest:
"""Test general request functionality (NF-002)."""
@pytest.mark.asyncio
async def test_request_adds_auth_header(self, settings_with_token):
"""Test requests include authorization header (AU-001)."""
client = JanaClient(settings_with_token)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"data": "test"}
with patch.object(client, "_get_client") as mock_get_client:
mock_http_client = AsyncMock()
mock_http_client.request.return_value = mock_response
mock_get_client.return_value = mock_http_client
await client.request("GET", "/api/test/")
mock_http_client.request.assert_called_once()
call_kwargs = mock_http_client.request.call_args[1]
assert "Authorization" in call_kwargs["headers"]
assert call_kwargs["headers"]["Authorization"] == "Token pre-configured-token"
@pytest.mark.asyncio
async def test_request_cleans_none_params(self, settings_with_token):
"""Test request removes None values from params."""
client = JanaClient(settings_with_token)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {}
with patch.object(client, "_get_client") as mock_get_client:
mock_http_client = AsyncMock()
mock_http_client.request.return_value = mock_response
mock_get_client.return_value = mock_http_client
await client.request("GET", "/api/test/", params={"a": 1, "b": None, "c": "test"})
call_kwargs = mock_http_client.request.call_args[1]
assert call_kwargs["params"] == {"a": 1, "c": "test"}
@pytest.mark.asyncio
async def test_request_handles_401_reauthenticates(self, settings_with_credentials):
"""Test request re-authenticates on 401 response."""
client = JanaClient(settings_with_credentials)
client._token = "expired-token"
# First response is 401, second is success
mock_401_response = MagicMock()
mock_401_response.status_code = 401
mock_success_response = MagicMock()
mock_success_response.status_code = 200
mock_success_response.json.return_value = {"data": "success"}
mock_auth_response = MagicMock()
mock_auth_response.status_code = 200
mock_auth_response.json.return_value = {"token": "new-token"}
with patch.object(client, "_get_client") as mock_get_client:
mock_http_client = AsyncMock()
# First request returns 401, then auth, then retry succeeds
mock_http_client.request.side_effect = [mock_401_response, mock_success_response]
mock_http_client.post.return_value = mock_auth_response
mock_get_client.return_value = mock_http_client
result = await client.request("GET", "/api/test/")
assert result == {"data": "success"}
assert client._token == "new-token"
@pytest.mark.asyncio
async def test_request_api_error_on_4xx(self, settings_with_token):
"""Test request raises APIError on 4xx response (EH-001)."""
client = JanaClient(settings_with_token)
mock_response = MagicMock()
mock_response.status_code = 400
mock_response.text = "Bad Request"
with patch.object(client, "_get_client") as mock_get_client:
mock_http_client = AsyncMock()
mock_http_client.request.return_value = mock_response
mock_get_client.return_value = mock_http_client
with pytest.raises(APIError) as exc_info:
await client.request("GET", "/api/test/")
assert exc_info.value.status_code == 400
@pytest.mark.asyncio
async def test_request_api_error_on_5xx(self, settings_with_token):
"""Test request raises APIError on 5xx response (EH-001)."""
client = JanaClient(settings_with_token)
mock_response = MagicMock()
mock_response.status_code = 500
mock_response.text = "Internal Server Error"
with patch.object(client, "_get_client") as mock_get_client:
mock_http_client = AsyncMock()
mock_http_client.request.return_value = mock_response
mock_get_client.return_value = mock_http_client
with pytest.raises(APIError) as exc_info:
await client.request("GET", "/api/test/")
assert exc_info.value.status_code == 500
@pytest.mark.asyncio
async def test_request_network_error(self, settings_with_token):
"""Test request handles network errors (EH-002)."""
client = JanaClient(settings_with_token)
with patch.object(client, "_get_client") as mock_get_client:
mock_http_client = AsyncMock()
mock_http_client.request.side_effect = httpx.RequestError("Timeout")
mock_get_client.return_value = mock_http_client
with pytest.raises(APIError) as exc_info:
await client.request("GET", "/api/test/")
assert "Request failed" in str(exc_info.value)
class TestJanaClientAirQuality:
"""Test air quality endpoint wrapper (DA-001 to DA-003)."""
@pytest.mark.asyncio
async def test_get_air_quality_with_bbox(self, settings_with_token):
"""Test air quality query with bounding box (DA-008)."""
client = JanaClient(settings_with_token)
with patch.object(client, "request", new_callable=AsyncMock) as mock_request:
mock_request.return_value = {"results": []}
await client.get_air_quality(bbox=[-122.5, 37.5, -122.0, 38.0])
mock_request.assert_called_once()
call_kwargs = mock_request.call_args[1]
assert "bbox" in call_kwargs["params"]
assert call_kwargs["params"]["bbox"] == "-122.5,37.5,-122.0,38.0"
@pytest.mark.asyncio
async def test_get_air_quality_with_point_radius(self, settings_with_token):
"""Test air quality query with point and radius (DA-009)."""
client = JanaClient(settings_with_token)
with patch.object(client, "request", new_callable=AsyncMock) as mock_request:
mock_request.return_value = {"results": []}
await client.get_air_quality(point=[-122.4, 37.7], radius_km=10)
call_kwargs = mock_request.call_args[1]
assert call_kwargs["params"]["coordinates"] == "-122.4,37.7"
assert call_kwargs["params"]["radius"] == 10
@pytest.mark.asyncio
async def test_get_air_quality_with_country_codes(self, settings_with_token):
"""Test air quality query with country codes (DA-010)."""
client = JanaClient(settings_with_token)
with patch.object(client, "request", new_callable=AsyncMock) as mock_request:
mock_request.return_value = {"results": []}
await client.get_air_quality(country_codes=["USA", "GBR"])
call_kwargs = mock_request.call_args[1]
assert call_kwargs["params"]["countries_id"] == "USA,GBR"
@pytest.mark.asyncio
async def test_get_air_quality_with_parameters(self, settings_with_token):
"""Test air quality query with pollutant parameters (DA-002)."""
client = JanaClient(settings_with_token)
with patch.object(client, "request", new_callable=AsyncMock) as mock_request:
mock_request.return_value = {"results": []}
await client.get_air_quality(
country_codes=["USA"],
parameters=["pm25", "o3"],
)
call_kwargs = mock_request.call_args[1]
assert call_kwargs["params"]["parameters_id"] == "pm25,o3"
@pytest.mark.asyncio
async def test_get_air_quality_with_date_range(self, settings_with_token):
"""Test air quality query with date range (DA-011)."""
client = JanaClient(settings_with_token)
with patch.object(client, "request", new_callable=AsyncMock) as mock_request:
mock_request.return_value = {"results": []}
await client.get_air_quality(
country_codes=["USA"],
date_from="2024-01-01",
date_to="2024-12-31",
)
call_kwargs = mock_request.call_args[1]
assert call_kwargs["params"]["date_from"] == "2024-01-01"
assert call_kwargs["params"]["date_to"] == "2024-12-31"
@pytest.mark.asyncio
async def test_get_air_quality_with_limit(self, settings_with_token):
"""Test air quality query with result limit."""
client = JanaClient(settings_with_token)
with patch.object(client, "request", new_callable=AsyncMock) as mock_request:
mock_request.return_value = {"results": []}
await client.get_air_quality(country_codes=["USA"], limit=50)
call_kwargs = mock_request.call_args[1]
assert call_kwargs["params"]["limit"] == 50
@pytest.mark.asyncio
async def test_get_air_quality_calls_correct_endpoint(self, settings_with_token):
"""Test air quality uses correct API endpoint (NF-002)."""
client = JanaClient(settings_with_token)
with patch.object(client, "request", new_callable=AsyncMock) as mock_request:
mock_request.return_value = {"results": []}
await client.get_air_quality(country_codes=["USA"])
mock_request.assert_called_once()
assert mock_request.call_args[0] == ("GET", "/api/v1/data-sources/openaq/measurements/")
class TestJanaClientEmissions:
"""Test emissions endpoint wrapper (DA-004 to DA-007)."""
@pytest.mark.asyncio
async def test_get_emissions_with_sources(self, settings_with_token):
"""Test emissions query with data sources (DA-004, DA-005).
Note: sources parameter is reserved for future multi-source support.
Currently defaults to Climate Trace.
"""
client = JanaClient(settings_with_token)
with patch.object(client, "request", new_callable=AsyncMock) as mock_request:
mock_request.return_value = {"results": []}
await client.get_emissions(
sources=["climatetrace", "edgar"],
country_codes=["USA"],
)
# Currently uses Climate Trace endpoint, sources param reserved for future
call_kwargs = mock_request.call_args[1]
assert "country_iso3" in call_kwargs["params"]
@pytest.mark.asyncio
async def test_get_emissions_with_sectors(self, settings_with_token):
"""Test emissions query with sector filter (DA-006)."""
client = JanaClient(settings_with_token)
with patch.object(client, "request", new_callable=AsyncMock) as mock_request:
mock_request.return_value = {"results": []}
await client.get_emissions(
country_codes=["USA"],
sectors=["power", "steel"],
)
call_kwargs = mock_request.call_args[1]
# Climate Trace uses sector_name field
assert call_kwargs["params"]["sector_name"] == "power,steel"
@pytest.mark.asyncio
async def test_get_emissions_with_gases(self, settings_with_token):
"""Test emissions query with gas type filter (DA-007)."""
client = JanaClient(settings_with_token)
with patch.object(client, "request", new_callable=AsyncMock) as mock_request:
mock_request.return_value = {"results": []}
await client.get_emissions(
country_codes=["USA"],
gases=["co2", "ch4"],
)
call_kwargs = mock_request.call_args[1]
# Climate Trace uses gas field
assert call_kwargs["params"]["gas"] == "co2,ch4"
@pytest.mark.asyncio
async def test_get_emissions_calls_correct_endpoint(self, settings_with_token):
"""Test emissions uses Climate Trace endpoint (DA-013)."""
client = JanaClient(settings_with_token)
with patch.object(client, "request", new_callable=AsyncMock) as mock_request:
mock_request.return_value = {"results": []}
await client.get_emissions(country_codes=["USA"])
mock_request.assert_called_once()
assert mock_request.call_args[0] == ("GET", "/api/v1/data-sources/climatetrace/emissions/")
class TestJanaClientNearby:
"""Test nearby search endpoint wrapper (DA-009)."""
@pytest.mark.asyncio
async def test_find_nearby_with_point_radius(self, settings_with_token):
"""Test nearby search with point and radius."""
client = JanaClient(settings_with_token)
with patch.object(client, "request", new_callable=AsyncMock) as mock_request:
mock_request.return_value = {"results": []}
await client.find_nearby(point=[-122.4, 37.7], radius_km=25)
call_kwargs = mock_request.call_args[1]
assert call_kwargs["params"]["coordinates"] == "-122.4,37.7"
assert call_kwargs["params"]["radius"] == 25
@pytest.mark.asyncio
async def test_find_nearby_with_sources(self, settings_with_token):
"""Test nearby search with source filter.
Note: sources parameter is reserved for future multi-source support.
Currently defaults to OpenAQ locations.
"""
client = JanaClient(settings_with_token)
with patch.object(client, "request", new_callable=AsyncMock) as mock_request:
mock_request.return_value = {"results": []}
await client.find_nearby(
point=[-122.4, 37.7],
radius_km=25,
sources=["openaq", "climatetrace"],
)
call_kwargs = mock_request.call_args[1]
# Sources param reserved for future; currently uses OpenAQ locations
assert call_kwargs["params"]["coordinates"] == "-122.4,37.7"
assert call_kwargs["params"]["radius"] == 25
@pytest.mark.asyncio
async def test_find_nearby_calls_correct_endpoint(self, settings_with_token):
"""Test nearby uses OpenAQ locations endpoint."""
client = JanaClient(settings_with_token)
with patch.object(client, "request", new_callable=AsyncMock) as mock_request:
mock_request.return_value = {"results": []}
await client.find_nearby(point=[-122.4, 37.7], radius_km=25)
assert mock_request.call_args[0] == ("GET", "/api/v1/data-sources/openaq/locations/")
class TestJanaClientHealth:
"""Test health check functionality (AD-002)."""
@pytest.mark.asyncio
async def test_check_health_success(self, settings_with_token):
"""Test successful health check."""
client = JanaClient(settings_with_token)
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {"status": "ok"}
with patch.object(client, "_get_client") as mock_get_client:
mock_http_client = AsyncMock()
mock_http_client.get.return_value = mock_response
mock_get_client.return_value = mock_http_client
result = await client.check_health()
assert result["status"] == "healthy"
assert "backend" in result
@pytest.mark.asyncio
async def test_check_health_unhealthy(self, settings_with_token):
"""Test health check when backend unhealthy."""
client = JanaClient(settings_with_token)
mock_response = MagicMock()
mock_response.status_code = 503
with patch.object(client, "_get_client") as mock_get_client:
mock_http_client = AsyncMock()
mock_http_client.get.return_value = mock_response
mock_get_client.return_value = mock_http_client
result = await client.check_health()
assert result["status"] == "unhealthy"
assert result["status_code"] == 503
@pytest.mark.asyncio
async def test_check_health_unreachable(self, settings_with_token):
"""Test health check when backend unreachable (EH-002)."""
client = JanaClient(settings_with_token)
with patch.object(client, "_get_client") as mock_get_client:
mock_http_client = AsyncMock()
mock_http_client.get.side_effect = httpx.RequestError("Connection refused")
mock_get_client.return_value = mock_http_client
result = await client.check_health()
assert result["status"] == "unreachable"
assert "error" in result
class TestJanaClientSummary:
"""Test data summary functionality (AD-001)."""
@pytest.mark.asyncio
async def test_get_summary(self, settings_with_token):
"""Test data summary retrieval (platform overview)."""
client = JanaClient(settings_with_token)
with patch.object(client, "request", new_callable=AsyncMock) as mock_request:
mock_request.return_value = {
"total_records": 1000000,
"sources": {
"openaq": {"records": 500000},
"climatetrace": {"records": 300000},
"edgar": {"records": 200000},
},
}
result = await client.get_summary()
mock_request.assert_called_once()
assert mock_request.call_args[0] == ("GET", "/api/v1/esg/summary/")
assert result["total_records"] == 1000000
class TestJanaClientCleanup:
"""Test client cleanup functionality."""
@pytest.mark.asyncio
async def test_close_client(self, settings_with_token):
"""Test client closes HTTP connection."""
client = JanaClient(settings_with_token)
mock_http_client = AsyncMock()
mock_http_client.is_closed = False
client._client = mock_http_client
await client.close()
mock_http_client.aclose.assert_called_once()
assert client._client is None
@pytest.mark.asyncio
async def test_close_already_closed(self, settings_with_token):
"""Test close handles already closed client."""
client = JanaClient(settings_with_token)
mock_http_client = AsyncMock()
mock_http_client.is_closed = True
client._client = mock_http_client
await client.close()
mock_http_client.aclose.assert_not_called()
@pytest.mark.asyncio
async def test_close_no_client(self, settings_with_token):
"""Test close handles no client created."""
client = JanaClient(settings_with_token)
client._client = None
# Should not raise
await client.close()
class TestJanaClientErrorTypes:
"""Test error class hierarchy."""
def test_jana_client_error_is_base(self):
"""Test JanaClientError is base exception."""
error = JanaClientError("test")
assert isinstance(error, Exception)
def test_authentication_error_inherits(self):
"""Test AuthenticationError inherits from JanaClientError."""
error = AuthenticationError("test")
assert isinstance(error, JanaClientError)
def test_api_error_inherits(self):
"""Test APIError inherits from JanaClientError."""
error = APIError("test")
assert isinstance(error, JanaClientError)
def test_api_error_stores_status_code(self):
"""Test APIError stores status code."""
error = APIError("test", status_code=404)
assert error.status_code == 404
def test_api_error_stores_response(self):
"""Test APIError stores response data."""
error = APIError("test", response={"detail": "Not found"})
assert error.response == {"detail": "Not found"}