mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-07-27 03:52:54 +02:00
527c860bf8
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).
352 lines
13 KiB
Python
352 lines
13 KiB
Python
"""Tests for feature flags functionality."""
|
|
|
|
import pytest
|
|
from fastapi.testclient import TestClient
|
|
|
|
from meshcore_hub.web.app import create_app
|
|
from tests.test_web.conftest import (
|
|
ALL_FEATURES_ENABLED,
|
|
MockHttpClient,
|
|
get_app_config,
|
|
)
|
|
|
|
|
|
class TestFeatureFlagsConfig:
|
|
"""Test feature flags in config."""
|
|
|
|
def test_all_features_enabled_by_default(self, client: TestClient) -> None:
|
|
"""All non-OIDC features should be enabled by default in config JSON."""
|
|
response = client.get("/")
|
|
assert response.status_code == 200
|
|
config = get_app_config(response.text)
|
|
features = config["features"]
|
|
non_oidc_features = {k: v for k, v in features.items() if k != "members"}
|
|
assert all(
|
|
non_oidc_features.values()
|
|
), "All non-OIDC features should be enabled by default"
|
|
|
|
def test_features_dict_has_all_keys(self, client: TestClient) -> None:
|
|
"""Features dict should have all 7 expected keys."""
|
|
response = client.get("/")
|
|
config = get_app_config(response.text)
|
|
features = config["features"]
|
|
expected_keys = {
|
|
"dashboard",
|
|
"nodes",
|
|
"advertisements",
|
|
"messages",
|
|
"map",
|
|
"members",
|
|
"pages",
|
|
}
|
|
assert set(features.keys()) == expected_keys
|
|
|
|
def test_disabled_features_in_config(self, client_no_features: TestClient) -> None:
|
|
"""Disabled features should be false in config JSON."""
|
|
response = client_no_features.get("/")
|
|
config = get_app_config(response.text)
|
|
features = config["features"]
|
|
assert all(not v for v in features.values()), "All features should be disabled"
|
|
|
|
|
|
class TestFeatureFlagsNav:
|
|
"""Test feature flags affect navigation (via the SPA config the nav reads)."""
|
|
|
|
def test_enabled_features_show_nav_links(self, client: TestClient) -> None:
|
|
"""Enabled features should be true in config so the React nav shows them."""
|
|
config = get_app_config(client.get("/").text)
|
|
features = config["features"]
|
|
for key in ("dashboard", "nodes", "advertisements", "messages", "map"):
|
|
assert features[key] is True
|
|
|
|
def test_disabled_features_hide_nav_links(
|
|
self, client_no_features: TestClient
|
|
) -> None:
|
|
"""Disabled features should be false in config so the React nav hides them."""
|
|
config = get_app_config(client_no_features.get("/").text)
|
|
features = config["features"]
|
|
for key in (
|
|
"dashboard",
|
|
"nodes",
|
|
"advertisements",
|
|
"messages",
|
|
"map",
|
|
"members",
|
|
):
|
|
assert features[key] is False
|
|
|
|
def test_home_link_always_present(self, client_no_features: TestClient) -> None:
|
|
"""The SPA mount (where the always-present Home nav renders) is in the shell."""
|
|
response = client_no_features.get("/")
|
|
assert 'id="app"' in response.text
|
|
|
|
|
|
class TestFeatureFlagsEndpoints:
|
|
"""Test feature flags affect endpoints."""
|
|
|
|
def test_map_data_returns_404_when_disabled(
|
|
self, client_no_features: TestClient
|
|
) -> None:
|
|
"""/map/data should return 404 when map feature is disabled."""
|
|
response = client_no_features.get("/map/data")
|
|
assert response.status_code == 404
|
|
assert response.json()["detail"] == "Map feature is disabled"
|
|
|
|
def test_map_data_returns_200_when_enabled(self, client: TestClient) -> None:
|
|
"""/map/data should return 200 when map feature is enabled."""
|
|
response = client.get("/map/data")
|
|
assert response.status_code == 200
|
|
|
|
def test_custom_page_returns_404_when_disabled(
|
|
self, client_no_features: TestClient
|
|
) -> None:
|
|
"""/spa/pages/{slug} should return 404 when pages feature is disabled."""
|
|
response = client_no_features.get("/spa/pages/about")
|
|
assert response.status_code == 404
|
|
assert response.json()["detail"] == "Pages feature is disabled"
|
|
|
|
def test_custom_pages_empty_when_disabled(
|
|
self, client_no_features: TestClient
|
|
) -> None:
|
|
"""Custom pages should be empty in config when pages feature is disabled."""
|
|
config = get_app_config(client_no_features.get("/").text)
|
|
assert config["custom_pages"] == []
|
|
|
|
|
|
class TestFeatureFlagsSEO:
|
|
"""Test feature flags affect SEO endpoints."""
|
|
|
|
def test_sitemap_includes_all_when_enabled(self, client: TestClient) -> None:
|
|
"""Sitemap should include all feature-enabled pages."""
|
|
response = client.get("/sitemap.xml")
|
|
assert response.status_code == 200
|
|
content = response.text
|
|
assert "/dashboard" in content
|
|
assert "/nodes" in content
|
|
assert "/advertisements" in content
|
|
assert "/map" in content
|
|
|
|
def test_sitemap_excludes_disabled_features(
|
|
self, client_no_features: TestClient
|
|
) -> None:
|
|
"""Sitemap should exclude disabled features."""
|
|
response = client_no_features.get("/sitemap.xml")
|
|
assert response.status_code == 200
|
|
content = response.text
|
|
assert "/dashboard" not in content
|
|
assert "/nodes" not in content
|
|
assert "/advertisements" not in content
|
|
assert "/map" not in content
|
|
assert "/members" not in content
|
|
|
|
def test_sitemap_always_includes_home(self, client_no_features: TestClient) -> None:
|
|
"""Sitemap should always include the home page."""
|
|
response = client_no_features.get("/sitemap.xml")
|
|
assert response.status_code == 200
|
|
content = response.text
|
|
# Home page has an empty path, so check for base URL loc
|
|
assert "<loc>" in content
|
|
|
|
def test_robots_txt_adds_disallow_for_disabled(
|
|
self, client_no_features: TestClient
|
|
) -> None:
|
|
"""Robots.txt should add Disallow for disabled features."""
|
|
response = client_no_features.get("/robots.txt")
|
|
assert response.status_code == 200
|
|
content = response.text
|
|
assert "Disallow: /dashboard" in content
|
|
assert "Disallow: /nodes" in content
|
|
assert "Disallow: /advertisements" in content
|
|
assert "Disallow: /map" in content
|
|
assert "Disallow: /members" in content
|
|
assert "Disallow: /pages" in content
|
|
|
|
def test_robots_txt_default_disallows_when_enabled(
|
|
self, client: TestClient
|
|
) -> None:
|
|
"""Robots.txt should only disallow messages and nodes/ when all enabled."""
|
|
response = client.get("/robots.txt")
|
|
assert response.status_code == 200
|
|
content = response.text
|
|
assert "Disallow: /messages" in content
|
|
assert "Disallow: /nodes/" in content
|
|
# Should not disallow the full /nodes path (only /nodes/ for detail pages)
|
|
lines = content.strip().split("\n")
|
|
disallow_lines = [
|
|
line.strip() for line in lines if line.startswith("Disallow:")
|
|
]
|
|
assert "Disallow: /nodes" not in disallow_lines or any(
|
|
line == "Disallow: /nodes/" for line in disallow_lines
|
|
)
|
|
|
|
|
|
class TestPacketsFeatureFlag:
|
|
"""Test the packets feature flag (off by default)."""
|
|
|
|
def _make_app(self, mock_http_client: MockHttpClient, packets: bool):
|
|
features = dict(ALL_FEATURES_ENABLED)
|
|
features["packets"] = packets
|
|
app = create_app(
|
|
api_url="http://localhost:8000",
|
|
api_key="test-api-key",
|
|
network_name="Test Network",
|
|
features=features,
|
|
)
|
|
app.state.http_client = mock_http_client
|
|
return TestClient(app, raise_server_exceptions=True)
|
|
|
|
def test_packets_nav_hidden_when_disabled(
|
|
self, mock_http_client: MockHttpClient
|
|
) -> None:
|
|
"""The packets nav link is absent when the feature is off."""
|
|
client = self._make_app(mock_http_client, packets=False)
|
|
config = get_app_config(client.get("/").text)
|
|
assert config["features"]["packets"] is False
|
|
# Messages still shows (ordering sanity)
|
|
assert config["features"]["messages"] is True
|
|
|
|
def test_packets_nav_shown_when_enabled(
|
|
self, mock_http_client: MockHttpClient
|
|
) -> None:
|
|
"""The packets nav link appears when the feature is on."""
|
|
client = self._make_app(mock_http_client, packets=True)
|
|
config = get_app_config(client.get("/").text)
|
|
assert config["features"]["packets"] is True
|
|
|
|
def test_packets_enabled_by_default_in_settings(self) -> None:
|
|
"""The declared default for feature_packets is True (env-independent)."""
|
|
from meshcore_hub.common.config import WebSettings
|
|
|
|
# Check the field default directly so a local .env cannot mask it.
|
|
assert WebSettings.model_fields["feature_packets"].default is True
|
|
|
|
|
|
class TestFeatureFlagsIndividual:
|
|
"""Test individual feature flags."""
|
|
|
|
@pytest.fixture
|
|
def _make_client(self, mock_http_client: MockHttpClient):
|
|
"""Factory to create a client with specific features disabled."""
|
|
|
|
def _create(disabled_feature: str) -> TestClient:
|
|
features = {
|
|
"dashboard": True,
|
|
"nodes": True,
|
|
"advertisements": True,
|
|
"messages": True,
|
|
"map": True,
|
|
"members": True,
|
|
"pages": True,
|
|
}
|
|
features[disabled_feature] = False
|
|
app = create_app(
|
|
api_url="http://localhost:8000",
|
|
api_key="test-api-key",
|
|
network_name="Test Network",
|
|
features=features,
|
|
)
|
|
app.state.http_client = mock_http_client
|
|
return TestClient(app, raise_server_exceptions=True)
|
|
|
|
return _create
|
|
|
|
def test_disable_map_only(self, _make_client) -> None:
|
|
"""Disabling only map should hide map but show others."""
|
|
client = _make_client("map")
|
|
config = get_app_config(client.get("/").text)
|
|
assert config["features"]["map"] is False
|
|
assert config["features"]["dashboard"] is True
|
|
assert config["features"]["nodes"] is True
|
|
|
|
# Map data endpoint should 404
|
|
response = client.get("/map/data")
|
|
assert response.status_code == 404
|
|
|
|
def test_disable_dashboard_only(self, _make_client) -> None:
|
|
"""Disabling only dashboard should hide dashboard but show others."""
|
|
client = _make_client("dashboard")
|
|
config = get_app_config(client.get("/").text)
|
|
assert config["features"]["dashboard"] is False
|
|
assert config["features"]["nodes"] is True
|
|
assert config["features"]["map"] is True
|
|
|
|
|
|
class TestDashboardAutoDisable:
|
|
"""Test that dashboard is automatically disabled when it has no content."""
|
|
|
|
def test_dashboard_auto_disabled_when_all_stats_off(
|
|
self, mock_http_client: MockHttpClient
|
|
) -> None:
|
|
"""Dashboard should auto-disable when nodes, adverts, messages all off."""
|
|
app = create_app(
|
|
api_url="http://localhost:8000",
|
|
api_key="test-api-key",
|
|
network_name="Test Network",
|
|
features={
|
|
"dashboard": True,
|
|
"nodes": False,
|
|
"advertisements": False,
|
|
"messages": False,
|
|
"map": True,
|
|
"members": True,
|
|
"pages": True,
|
|
},
|
|
)
|
|
app.state.http_client = mock_http_client
|
|
client = TestClient(app, raise_server_exceptions=True)
|
|
|
|
config = get_app_config(client.get("/").text)
|
|
assert config["features"]["dashboard"] is False
|
|
|
|
def test_map_auto_disabled_when_nodes_off(
|
|
self, mock_http_client: MockHttpClient
|
|
) -> None:
|
|
"""Map should auto-disable when nodes is off (map depends on nodes)."""
|
|
app = create_app(
|
|
api_url="http://localhost:8000",
|
|
api_key="test-api-key",
|
|
network_name="Test Network",
|
|
features={
|
|
"dashboard": True,
|
|
"nodes": False,
|
|
"advertisements": True,
|
|
"messages": True,
|
|
"map": True,
|
|
"members": True,
|
|
"pages": True,
|
|
},
|
|
)
|
|
app.state.http_client = mock_http_client
|
|
client = TestClient(app, raise_server_exceptions=True)
|
|
|
|
config = get_app_config(client.get("/").text)
|
|
assert config["features"]["map"] is False
|
|
|
|
# Map data endpoint should 404
|
|
response = client.get("/map/data")
|
|
assert response.status_code == 404
|
|
|
|
def test_dashboard_stays_enabled_with_one_stat(
|
|
self, mock_http_client: MockHttpClient
|
|
) -> None:
|
|
"""Dashboard should stay enabled when at least one stat feature is on."""
|
|
app = create_app(
|
|
api_url="http://localhost:8000",
|
|
api_key="test-api-key",
|
|
network_name="Test Network",
|
|
features={
|
|
"dashboard": True,
|
|
"nodes": True,
|
|
"advertisements": False,
|
|
"messages": False,
|
|
"map": True,
|
|
"members": True,
|
|
"pages": True,
|
|
},
|
|
)
|
|
app.state.http_client = mock_http_client
|
|
client = TestClient(app, raise_server_exceptions=True)
|
|
|
|
config = get_app_config(client.get("/").text)
|
|
assert config["features"]["dashboard"] is True
|