"""Web dashboard test fixtures.""" import json from typing import Any, Generator, cast 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, } def get_app_config(html: str) -> dict[str, Any]: """Extract the embedded ``window.__APP_CONFIG__`` object from SPA shell HTML. The navbar, banners, and feature-gated nav are rendered client-side by React from this config, so web tests assert on the config rather than on server-rendered nav HTML. """ marker = "window.__APP_CONFIG__ = " start = html.index(marker) + len(marker) script_end = html.index("", start) # Use the last ";" before so semicolons inside JSON string values # (e.g. announcement HTML) don't truncate the object. end = html.rindex(";", start, script_end) return cast(dict[str, Any], json.loads(html[start:end])) 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]] = {} # Param-aware handlers for paginated endpoints. Key: "METHOD:path", # value: callable(params) -> {"status_code": int, "json": Any}. # Checked before _responses so pagination tests can override defaults. self._paged_handlers: dict[str, Any] = {} # Records the params forwarded by the most recent request() call so # tests can assert how the proxy forwards query parameters. self.last_request_params: Any = None # Records the headers forwarded by the most recent request() call. self.last_request_headers: dict[str, Any] | None = None # Records the headers forwarded by the most recent get() call. self.last_get_headers: dict[str, Any] | None = None # Records the URL of the most recent request() call. self.last_request_url: str | None = None 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, exc: Exception | None = None, text: str | None = None, headers: dict[str, str] | None = 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 exc: When set, the client raises this instead of responding (simulates an unreachable backend) text: Raw non-JSON body (e.g. an XML feed document); takes precedence over json_data for the response content headers: Extra response headers (e.g. Cache-Control/ETag for proxied feed responses) """ key = f"{method}:{path}" self._responses[key] = { "status_code": status_code, "json": json_data, "exc": exc, "text": text, "headers": headers or {}, } def set_paged_response(self, method: str, path: str, handler: Any) -> None: """Register a param-aware handler for a paginated endpoint. The handler receives the request params dict (including ``limit`` and ``offset``) and returns the response payload dict (``{"status_code": int, "json": Any}``), enabling different pages per offset. Takes precedence over seeded/static responses for that path. """ self._paged_handlers[f"{method}:{path}"] = handler def _response_from_data(self, response_data: dict[str, Any]) -> Response: """Create a mock response from a {"status_code", "json"} payload.""" import json as _json response = MagicMock(spec=Response) response.status_code = response_data["status_code"] response.json.return_value = response_data["json"] if response_data.get("text") is not None: response.content = response_data["text"].encode() else: response.content = _json.dumps(response_data["json"]).encode() headers = {"content-type": "application/json"} headers.update(response_data.get("headers") or {}) response.headers = headers return response def _create_response(self, key: str) -> Response: """Create a mock response for a given key.""" response_data = self._responses.get(key) if response_data is None: # Return 404 for unknown endpoints return self._response_from_data( {"status_code": 404, "json": {"detail": "Not found"}} ) if response_data.get("exc") is not None: raise response_data["exc"] return self._response_from_data(response_data) 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).""" self.last_request_params = params self.last_request_headers = headers self.last_request_url = url 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, headers: dict | None = None, ) -> Response: """Mock GET request.""" self.last_get_headers = headers # Param-aware paged handlers take precedence over static responses # (including seeded defaults) so pagination tests control every page. handler = self._paged_handlers.get(f"GET:{path}") if handler is not None: return self._response_from_data(handler(params)) # 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", "") monkeypatch.setenv("SYSTEM_ANNOUNCEMENT", "") monkeypatch.setenv("SYSTEM_MAINTENANCE", "false") 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)