Files
meshcore-hub/tests/test_web/conftest.py
Louis King 527c860bf8 feat(web): frontend CI, vitest suite, navbar→React SPA shell — Phase 5
Frontend CI (closes the no-coverage gap for the TSX):
- ci.yml: new 'frontend' job — npm ci, tsc --noEmit, test:frontend, build.
- package.json: engines.node>=20, test:frontend/typecheck scripts.

vitest unit + component tests (jsdom, @testing-library/react):
- utils/charts.test.ts — tier math + every chart builder.
- utils/format.test.ts — parseAppDate, formatNumber, truncateKey, emoji helpers,
  formatRelativeTime.
- components/Navbar.test.tsx — feature-gated nav, custom pages, OIDC/maintenance
  auth gating.
- components/Announcements.test.tsx — banner rendering, ordering, dismiss +
  sessionStorage (covers behaviour moved out of the Python suite).

Navbar → React (full SPA shell):
- New Navbar/ThemeToggle/Announcements components + useNavItems hook (shared
  feature-gated nav for desktop + mobile); nav uses react-router NavLink
  (client-side nav + auto active) — drops the imperative data-nav-link bridge.
- main.tsx single root; App.tsx renders Navbar + Announcements above routed <main>.
- spa.html slimmed to SEO/config/footer shell (Jinja2 navbar/banners/theme script
  removed; #app is a plain div React fills).
- Backend: _build_config_json exposes system_announcement/network_announcement.

Tests (server-rendered nav/banner assertions → config):
- conftest.py: get_app_config() helper (robust __APP_CONFIG__ extraction).
- test_features/app/home/pages/dashboard rewritten to assert __APP_CONFIG__;
  dashboard client-rendered-stats tests replaced with a shell assertion.

Docs: REACT_MIGRATION.md Phase 5 (incl. deliberately-skipped react-query/
Storybook/Playwright), AGENTS.md Frontend section.

Verified: tsc clean, npm run build, vitest (49 passed), pytest tests/test_web
(251 passed), full suite (1459 passed), pre-commit (passed).
2026-07-21 20:06:52 +01:00

523 lines
18 KiB
Python

"""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("</script>", start)
# Use the last ";" before </script> 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]] = {}
# 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
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)."""
self.last_request_params = params
self.last_request_headers = headers
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
# 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)