mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-06-11 08:44:53 +02:00
f7d9901c9b
- Replace single NETWORK_RADIO_CONFIG comma-delimited string with six individual environment variables: NETWORK_RADIO_PROFILE, _FREQUENCY, _BANDWIDTH, _SPREADING_FACTOR, _CODING_RATE, _TX_POWER - Radio config fields now use raw numeric types (float/int) with units applied dynamically via RadioConfig.format_for_display() - Add FEATURE_RADIO_CONFIG feature flag to control radio config panel visibility on the home page (default: enabled) - Remove from_config_string class method (no backwards compatibility) - Update Click CLI options, create_app() signature, and _build_config_json() - Update docker-compose.yml, .env.example, README.md, AGENTS.md - Add upgrading.md v0.12.0 section with migration instructions - Add test coverage for schema, config, and feature flag
489 lines
16 KiB
Python
489 lines
16 KiB
Python
"""Web dashboard test fixtures."""
|
|
|
|
from typing import Any, Generator
|
|
from unittest.mock import MagicMock, patch
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
from httpx import Response
|
|
|
|
from meshcore_hub.web.app import create_app
|
|
|
|
# Explicit all-enabled features dict so tests are not affected by the user's
|
|
# local .env file (pydantic-settings loads .env by default).
|
|
ALL_FEATURES_ENABLED = {
|
|
"dashboard": True,
|
|
"nodes": True,
|
|
"advertisements": True,
|
|
"messages": True,
|
|
"map": True,
|
|
"members": True,
|
|
"pages": True,
|
|
}
|
|
|
|
|
|
class MockHttpClient:
|
|
"""Mock HTTP client for testing web routes."""
|
|
|
|
def __init__(self) -> None:
|
|
"""Initialize mock client with default responses."""
|
|
self._responses: dict[str, dict[str, Any]] = {}
|
|
self._default_responses()
|
|
|
|
def _default_responses(self) -> None:
|
|
"""Set up default mock API responses."""
|
|
# Default stats response
|
|
self._responses["GET:/api/v1/dashboard/stats"] = {
|
|
"status_code": 200,
|
|
"json": {
|
|
"total_nodes": 10,
|
|
"active_nodes": 5,
|
|
"total_messages": 100,
|
|
"messages_today": 15,
|
|
"total_advertisements": 50,
|
|
"channel_message_counts": {"0": 30, "1": 20},
|
|
},
|
|
}
|
|
|
|
# Default nodes list response
|
|
self._responses["GET:/api/v1/nodes"] = {
|
|
"status_code": 200,
|
|
"json": {
|
|
"items": [
|
|
{
|
|
"id": "node-1",
|
|
"public_key": "abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
|
|
"name": "Node One",
|
|
"adv_type": "REPEATER",
|
|
"last_seen": "2024-01-01T12:00:00Z",
|
|
"tags": [],
|
|
},
|
|
{
|
|
"id": "node-2",
|
|
"public_key": "def456abc123def456abc123def456abc123def456abc123def456abc123def4",
|
|
"name": "Node Two",
|
|
"adv_type": "CLIENT",
|
|
"last_seen": "2024-01-01T11:00:00Z",
|
|
"tags": [
|
|
{"key": "lat", "value": "40.7128"},
|
|
{"key": "lon", "value": "-74.0060"},
|
|
],
|
|
},
|
|
],
|
|
"total": 2,
|
|
},
|
|
}
|
|
|
|
# Default single node response (exact match)
|
|
self._responses[
|
|
"GET:/api/v1/nodes/abc123def456abc123def456abc123def456abc123def456abc123def456abc1"
|
|
] = {
|
|
"status_code": 200,
|
|
"json": {
|
|
"id": "node-1",
|
|
"public_key": "abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
|
|
"name": "Node One",
|
|
"adv_type": "REPEATER",
|
|
"last_seen": "2024-01-01T12:00:00Z",
|
|
"tags": [],
|
|
},
|
|
}
|
|
|
|
# Default messages response
|
|
self._responses["GET:/api/v1/messages"] = {
|
|
"status_code": 200,
|
|
"json": {
|
|
"items": [
|
|
{
|
|
"id": "msg-1",
|
|
"message_type": "direct",
|
|
"pubkey_prefix": "abc123",
|
|
"text": "Hello World",
|
|
"received_at": "2024-01-01T12:00:00Z",
|
|
"snr": -5.5,
|
|
"hops": 2,
|
|
},
|
|
{
|
|
"id": "msg-2",
|
|
"message_type": "channel",
|
|
"channel_idx": 0,
|
|
"text": "Channel message",
|
|
"received_at": "2024-01-01T11:00:00Z",
|
|
"snr": None,
|
|
"hops": None,
|
|
},
|
|
],
|
|
"total": 2,
|
|
},
|
|
}
|
|
|
|
# Default advertisements response
|
|
self._responses["GET:/api/v1/advertisements"] = {
|
|
"status_code": 200,
|
|
"json": {
|
|
"items": [
|
|
{
|
|
"id": "adv-1",
|
|
"public_key": "abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
|
|
"name": "Node One",
|
|
"adv_type": "REPEATER",
|
|
"received_at": "2024-01-01T12:00:00Z",
|
|
},
|
|
],
|
|
"total": 1,
|
|
},
|
|
}
|
|
|
|
# Default telemetry response
|
|
self._responses["GET:/api/v1/telemetry"] = {
|
|
"status_code": 200,
|
|
"json": {
|
|
"items": [
|
|
{
|
|
"id": "tel-1",
|
|
"node_public_key": "abc123def456abc123def456abc123def456abc123def456abc123def456abc1",
|
|
"parsed_data": {"battery_level": 85.5},
|
|
"received_at": "2024-01-01T12:00:00Z",
|
|
},
|
|
],
|
|
"total": 1,
|
|
},
|
|
}
|
|
|
|
# Default members response (empty)
|
|
self._responses["GET:/api/v1/members"] = {
|
|
"status_code": 200,
|
|
"json": {
|
|
"items": [],
|
|
"total": 0,
|
|
"limit": 100,
|
|
"offset": 0,
|
|
},
|
|
}
|
|
|
|
# Default activity response (for home page chart)
|
|
self._responses["GET:/api/v1/dashboard/activity"] = {
|
|
"status_code": 200,
|
|
"json": {
|
|
"days": 7,
|
|
"data": [
|
|
{"date": "2024-01-01", "count": 10},
|
|
{"date": "2024-01-02", "count": 15},
|
|
{"date": "2024-01-03", "count": 8},
|
|
{"date": "2024-01-04", "count": 12},
|
|
{"date": "2024-01-05", "count": 20},
|
|
{"date": "2024-01-06", "count": 5},
|
|
{"date": "2024-01-07", "count": 18},
|
|
],
|
|
},
|
|
}
|
|
|
|
# Default message activity response (for network page chart)
|
|
self._responses["GET:/api/v1/dashboard/message-activity"] = {
|
|
"status_code": 200,
|
|
"json": {
|
|
"days": 7,
|
|
"data": [
|
|
{"date": "2024-01-01", "count": 5},
|
|
{"date": "2024-01-02", "count": 8},
|
|
{"date": "2024-01-03", "count": 3},
|
|
{"date": "2024-01-04", "count": 10},
|
|
{"date": "2024-01-05", "count": 7},
|
|
{"date": "2024-01-06", "count": 2},
|
|
{"date": "2024-01-07", "count": 9},
|
|
],
|
|
},
|
|
}
|
|
|
|
# Default node count response (for network page chart)
|
|
self._responses["GET:/api/v1/dashboard/node-count"] = {
|
|
"status_code": 200,
|
|
"json": {
|
|
"days": 7,
|
|
"data": [
|
|
{"date": "2024-01-01", "count": 5},
|
|
{"date": "2024-01-02", "count": 6},
|
|
{"date": "2024-01-03", "count": 7},
|
|
{"date": "2024-01-04", "count": 8},
|
|
{"date": "2024-01-05", "count": 9},
|
|
{"date": "2024-01-06", "count": 9},
|
|
{"date": "2024-01-07", "count": 10},
|
|
],
|
|
},
|
|
}
|
|
|
|
# Health check response
|
|
self._responses["GET:/health"] = {
|
|
"status_code": 200,
|
|
"json": {"status": "healthy"},
|
|
}
|
|
|
|
def set_response(
|
|
self, method: str, path: str, status_code: int = 200, json_data: Any = None
|
|
) -> None:
|
|
"""Set a custom response for a specific endpoint.
|
|
|
|
Args:
|
|
method: HTTP method (GET, POST, etc.)
|
|
path: URL path
|
|
status_code: Response status code
|
|
json_data: JSON response body
|
|
"""
|
|
key = f"{method}:{path}"
|
|
self._responses[key] = {
|
|
"status_code": status_code,
|
|
"json": json_data,
|
|
}
|
|
|
|
def _create_response(self, key: str) -> Response:
|
|
"""Create a mock response for a given key."""
|
|
import json as _json
|
|
|
|
response_data = self._responses.get(key)
|
|
if response_data is None:
|
|
# Return 404 for unknown endpoints
|
|
response = MagicMock(spec=Response)
|
|
response.status_code = 404
|
|
response.json.return_value = {"detail": "Not found"}
|
|
response.content = b'{"detail": "Not found"}'
|
|
response.headers = {"content-type": "application/json"}
|
|
return response
|
|
|
|
response = MagicMock(spec=Response)
|
|
response.status_code = response_data["status_code"]
|
|
response.json.return_value = response_data["json"]
|
|
response.content = _json.dumps(response_data["json"]).encode()
|
|
response.headers = {"content-type": "application/json"}
|
|
return response
|
|
|
|
async def request(
|
|
self,
|
|
method: str,
|
|
url: str,
|
|
params: dict | None = None,
|
|
content: bytes | None = None,
|
|
headers: dict | None = None,
|
|
) -> Response:
|
|
"""Mock generic request (used by API proxy)."""
|
|
key = f"{method.upper()}:{url}"
|
|
if key in self._responses:
|
|
return self._create_response(key)
|
|
# Try base path without query params
|
|
base_path = url.split("?")[0]
|
|
key = f"{method.upper()}:{base_path}"
|
|
return self._create_response(key)
|
|
|
|
async def get(self, path: str, params: dict | None = None) -> Response:
|
|
"""Mock GET request."""
|
|
# Try exact match first
|
|
key = f"GET:{path}"
|
|
if key in self._responses:
|
|
return self._create_response(key)
|
|
|
|
# Try without query params for list endpoints
|
|
base_path = path.split("?")[0]
|
|
key = f"GET:{base_path}"
|
|
return self._create_response(key)
|
|
|
|
async def post(
|
|
self, path: str, json: dict | None = None, params: dict | None = None
|
|
) -> Response:
|
|
"""Mock POST request."""
|
|
key = f"POST:{path}"
|
|
return self._create_response(key)
|
|
|
|
async def put(
|
|
self, path: str, json: dict | None = None, params: dict | None = None
|
|
) -> Response:
|
|
"""Mock PUT request."""
|
|
key = f"PUT:{path}"
|
|
return self._create_response(key)
|
|
|
|
async def delete(self, path: str, params: dict | None = None) -> Response:
|
|
"""Mock DELETE request."""
|
|
key = f"DELETE:{path}"
|
|
return self._create_response(key)
|
|
|
|
async def aclose(self) -> None:
|
|
"""Mock close method."""
|
|
pass
|
|
|
|
|
|
@pytest.fixture
|
|
def mock_http_client() -> MockHttpClient:
|
|
"""Create a mock HTTP client."""
|
|
return MockHttpClient()
|
|
|
|
|
|
@pytest.fixture
|
|
def web_app(mock_http_client: MockHttpClient, monkeypatch: pytest.MonkeyPatch) -> Any:
|
|
"""Create a web app with mocked HTTP client."""
|
|
# Ensure tests use a consistent locale regardless of local .env
|
|
monkeypatch.setenv("WEB_DATETIME_LOCALE", "en-US")
|
|
monkeypatch.setenv("OIDC_ENABLED", "false")
|
|
monkeypatch.setenv("NETWORK_ANNOUNCEMENT", "")
|
|
app = create_app(
|
|
api_url="http://localhost:8000",
|
|
api_key="test-api-key",
|
|
network_name="Test Network",
|
|
network_city="Test City",
|
|
network_country="Test Country",
|
|
network_radio_profile="Test Profile",
|
|
network_radio_frequency=868.0,
|
|
network_radio_bandwidth=125.0,
|
|
network_radio_spreading_factor=7,
|
|
network_radio_coding_rate=5,
|
|
network_radio_tx_power=20.0,
|
|
network_contact_email="test@example.com",
|
|
network_contact_discord="https://discord.gg/test",
|
|
features=ALL_FEATURES_ENABLED,
|
|
)
|
|
|
|
# Override the lifespan to use our mock client
|
|
app.state.http_client = mock_http_client
|
|
|
|
return app
|
|
|
|
|
|
@pytest.fixture
|
|
def client(web_app: Any, mock_http_client: MockHttpClient) -> TestClient:
|
|
"""Create a test client for the web app.
|
|
|
|
Note: We don't use the context manager to skip lifespan events
|
|
since we've already set up the mock client.
|
|
"""
|
|
# Ensure the mock client is attached
|
|
web_app.state.http_client = mock_http_client
|
|
return TestClient(web_app, raise_server_exceptions=True)
|
|
|
|
|
|
@pytest.fixture
|
|
def web_app_with_oidc(
|
|
mock_http_client: MockHttpClient, monkeypatch: pytest.MonkeyPatch
|
|
) -> Any:
|
|
"""Create a web app with OIDC enabled and session middleware."""
|
|
monkeypatch.setenv("OIDC_ENABLED", "true")
|
|
monkeypatch.setenv("OIDC_CLIENT_ID", "test-client-id")
|
|
monkeypatch.setenv("OIDC_CLIENT_SECRET", "test-client-secret")
|
|
monkeypatch.setenv(
|
|
"OIDC_DISCOVERY_URL", "https://idp.example.com/.well-known/openid-configuration"
|
|
)
|
|
monkeypatch.setenv("OIDC_SESSION_SECRET", "test-session-secret")
|
|
monkeypatch.setenv("WEB_DATETIME_LOCALE", "en-US")
|
|
monkeypatch.setenv("NETWORK_ANNOUNCEMENT", "")
|
|
|
|
app = create_app(
|
|
api_url="http://localhost:8000",
|
|
api_key="test-api-key",
|
|
network_name="Test Network",
|
|
features=ALL_FEATURES_ENABLED,
|
|
)
|
|
|
|
app.state.http_client = mock_http_client
|
|
return app
|
|
|
|
|
|
ADMIN_USER = {
|
|
"sub": "admin-1",
|
|
"name": "Admin User",
|
|
"email": "admin@example.com",
|
|
"picture": None,
|
|
"roles": ["admin", "member"],
|
|
}
|
|
|
|
MEMBER_USER = {
|
|
"sub": "member-1",
|
|
"name": "Member User",
|
|
"email": "member@example.com",
|
|
"picture": None,
|
|
"roles": ["member"],
|
|
}
|
|
|
|
NO_ROLES_USER: dict[str, Any] = {
|
|
"sub": "noroles-1",
|
|
"name": "No Roles User",
|
|
"email": "noroles@example.com",
|
|
"picture": None,
|
|
"roles": [],
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def client_with_oidc(
|
|
web_app_with_oidc: Any, mock_http_client: MockHttpClient
|
|
) -> TestClient:
|
|
"""Create a test client with OIDC enabled (no session)."""
|
|
web_app_with_oidc.state.http_client = mock_http_client
|
|
return TestClient(web_app_with_oidc, raise_server_exceptions=True)
|
|
|
|
|
|
@pytest.fixture
|
|
def client_with_oidc_admin_session(
|
|
web_app_with_oidc: Any, mock_http_client: MockHttpClient
|
|
) -> Generator[TestClient, None, None]:
|
|
"""Create a test client with OIDC enabled and admin session via mock."""
|
|
web_app_with_oidc.state.http_client = mock_http_client
|
|
with (
|
|
patch("meshcore_hub.web.app.get_session_user", return_value=ADMIN_USER),
|
|
patch("meshcore_hub.web.oidc.get_session_user", return_value=ADMIN_USER),
|
|
):
|
|
yield TestClient(web_app_with_oidc, raise_server_exceptions=True)
|
|
|
|
|
|
@pytest.fixture
|
|
def client_with_oidc_member_session(
|
|
web_app_with_oidc: Any, mock_http_client: MockHttpClient
|
|
) -> Generator[TestClient, None, None]:
|
|
"""Create a test client with OIDC enabled and member session via mock."""
|
|
web_app_with_oidc.state.http_client = mock_http_client
|
|
with (
|
|
patch("meshcore_hub.web.app.get_session_user", return_value=MEMBER_USER),
|
|
patch("meshcore_hub.web.oidc.get_session_user", return_value=MEMBER_USER),
|
|
):
|
|
yield TestClient(web_app_with_oidc, raise_server_exceptions=True)
|
|
|
|
|
|
@pytest.fixture
|
|
def client_with_oidc_no_roles_session(
|
|
web_app_with_oidc: Any, mock_http_client: MockHttpClient
|
|
) -> Generator[TestClient, None, None]:
|
|
"""Create a test client with OIDC enabled and a session with NO roles."""
|
|
web_app_with_oidc.state.http_client = mock_http_client
|
|
with (
|
|
patch("meshcore_hub.web.app.get_session_user", return_value=NO_ROLES_USER),
|
|
patch("meshcore_hub.web.oidc.get_session_user", return_value=NO_ROLES_USER),
|
|
):
|
|
yield TestClient(web_app_with_oidc, raise_server_exceptions=True)
|
|
|
|
|
|
@pytest.fixture
|
|
def web_app_no_features(mock_http_client: MockHttpClient) -> Any:
|
|
"""Create a web app with all features disabled."""
|
|
app = create_app(
|
|
api_url="http://localhost:8000",
|
|
api_key="test-api-key",
|
|
network_name="Test Network",
|
|
network_city="Test City",
|
|
network_country="Test Country",
|
|
features={
|
|
"dashboard": False,
|
|
"nodes": False,
|
|
"advertisements": False,
|
|
"messages": False,
|
|
"map": False,
|
|
"members": False,
|
|
"pages": False,
|
|
},
|
|
)
|
|
app.state.http_client = mock_http_client
|
|
return app
|
|
|
|
|
|
@pytest.fixture
|
|
def client_no_features(
|
|
web_app_no_features: Any, mock_http_client: MockHttpClient
|
|
) -> TestClient:
|
|
"""Create a test client with all features disabled."""
|
|
web_app_no_features.state.http_client = mock_http_client
|
|
return TestClient(web_app_no_features, raise_server_exceptions=True)
|