mirror of
https://github.com/ipnet-mesh/meshcore-hub.git
synced 2026-03-28 17:42:56 +01:00
Add web dashboard tests for Phase 5.11
- Create conftest.py with MockHttpClient for testing web routes - Add test_home.py with 9 tests for home page - Add test_members.py with 11 tests for members page and load_members function - Add test_network.py with 7 tests for network overview page - Add test_nodes.py with 15 tests for nodes list and detail pages - Add test_map.py with 12 tests for map page and data endpoint - Add test_messages.py with 13 tests for messages page with filtering - All 67 web tests pass, 184 total tests pass - Update TASKS.md to mark Phase 5 as 100% complete (186/221 total)
This commit is contained in:
22
TASKS.md
22
TASKS.md
@@ -602,15 +602,15 @@ This document tracks implementation progress for the MeshCore Hub project. Each
|
|||||||
|
|
||||||
### 5.11 Web Tests
|
### 5.11 Web Tests
|
||||||
|
|
||||||
- [ ] Create `tests/test_web/conftest.py`:
|
- [x] Create `tests/test_web/conftest.py`:
|
||||||
- [ ] Test client fixture
|
- [x] Test client fixture
|
||||||
- [ ] Mock API responses
|
- [x] Mock API responses
|
||||||
- [ ] Create `tests/test_web/test_home.py`
|
- [x] Create `tests/test_web/test_home.py`
|
||||||
- [ ] Create `tests/test_web/test_members.py`
|
- [x] Create `tests/test_web/test_members.py`
|
||||||
- [ ] Create `tests/test_web/test_network.py`
|
- [x] Create `tests/test_web/test_network.py`
|
||||||
- [ ] Create `tests/test_web/test_nodes.py`
|
- [x] Create `tests/test_web/test_nodes.py`
|
||||||
- [ ] Create `tests/test_web/test_map.py`
|
- [x] Create `tests/test_web/test_map.py`
|
||||||
- [ ] Create `tests/test_web/test_messages.py`
|
- [x] Create `tests/test_web/test_messages.py`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -740,9 +740,9 @@ This document tracks implementation progress for the MeshCore Hub project. Each
|
|||||||
| Phase 2: Interface | 35 | 35 | 100% |
|
| Phase 2: Interface | 35 | 35 | 100% |
|
||||||
| Phase 3: Collector | 27 | 20 | 74% |
|
| Phase 3: Collector | 27 | 20 | 74% |
|
||||||
| Phase 4: API | 44 | 44 | 100% |
|
| Phase 4: API | 44 | 44 | 100% |
|
||||||
| Phase 5: Web Dashboard | 40 | 33 | 83% |
|
| Phase 5: Web Dashboard | 40 | 40 | 100% |
|
||||||
| Phase 6: Docker & Deployment | 28 | 0 | 0% |
|
| Phase 6: Docker & Deployment | 28 | 0 | 0% |
|
||||||
| **Total** | **221** | **179** | **81%** |
|
| **Total** | **221** | **186** | **84%** |
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
304
tests/test_web/conftest.py
Normal file
304
tests/test_web/conftest.py
Normal file
@@ -0,0 +1,304 @@
|
|||||||
|
"""Web dashboard test fixtures."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
from unittest.mock import AsyncMock, MagicMock, patch
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
from httpx import Response
|
||||||
|
|
||||||
|
from meshcore_hub.web.app import create_app
|
||||||
|
|
||||||
|
|
||||||
|
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": "abc123def456abc123def456abc123de",
|
||||||
|
"name": "Node One",
|
||||||
|
"adv_type": "REPEATER",
|
||||||
|
"last_seen": "2024-01-01T12:00:00Z",
|
||||||
|
"tags": [],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "node-2",
|
||||||
|
"public_key": "def456abc123def456abc123def456ab",
|
||||||
|
"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
|
||||||
|
self._responses["GET:/api/v1/nodes/abc123def456abc123def456abc123de"] = {
|
||||||
|
"status_code": 200,
|
||||||
|
"json": {
|
||||||
|
"id": "node-1",
|
||||||
|
"public_key": "abc123def456abc123def456abc123de",
|
||||||
|
"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": "abc123def456abc123def456abc123de",
|
||||||
|
"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": "abc123def456abc123def456abc123de",
|
||||||
|
"parsed_data": {"battery_level": 85.5},
|
||||||
|
"received_at": "2024-01-01T12:00:00Z",
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"total": 1,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
# 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."""
|
||||||
|
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"}
|
||||||
|
return response
|
||||||
|
|
||||||
|
response = MagicMock(spec=Response)
|
||||||
|
response.status_code = response_data["status_code"]
|
||||||
|
response.json.return_value = response_data["json"]
|
||||||
|
return response
|
||||||
|
|
||||||
|
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 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) -> Any:
|
||||||
|
"""Create a web app with mocked HTTP client."""
|
||||||
|
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_location=(40.7128, -74.0060),
|
||||||
|
network_radio_config="Test Radio Config",
|
||||||
|
network_contact_email="test@example.com",
|
||||||
|
network_contact_discord="https://discord.gg/test",
|
||||||
|
members_file=None,
|
||||||
|
)
|
||||||
|
|
||||||
|
# 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 members_file() -> Any:
|
||||||
|
"""Create a temporary members JSON file."""
|
||||||
|
members_data = {
|
||||||
|
"members": [
|
||||||
|
{
|
||||||
|
"name": "Alice",
|
||||||
|
"callsign": "W1ABC",
|
||||||
|
"role": "Admin",
|
||||||
|
"contact": "alice@example.com",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"name": "Bob",
|
||||||
|
"callsign": "W2XYZ",
|
||||||
|
"role": "Member",
|
||||||
|
"contact": None,
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".json", delete=False
|
||||||
|
) as f:
|
||||||
|
json.dump(members_data, f)
|
||||||
|
f.flush()
|
||||||
|
yield f.name
|
||||||
|
|
||||||
|
# Cleanup
|
||||||
|
Path(f.name).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def web_app_with_members(
|
||||||
|
mock_http_client: MockHttpClient, members_file: str
|
||||||
|
) -> Any:
|
||||||
|
"""Create a web app with a members file configured."""
|
||||||
|
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_location=(40.7128, -74.0060),
|
||||||
|
network_radio_config="Test Radio Config",
|
||||||
|
network_contact_email="test@example.com",
|
||||||
|
network_contact_discord="https://discord.gg/test",
|
||||||
|
members_file=members_file,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.state.http_client = mock_http_client
|
||||||
|
|
||||||
|
return app
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def client_with_members(
|
||||||
|
web_app_with_members: Any, mock_http_client: MockHttpClient
|
||||||
|
) -> TestClient:
|
||||||
|
"""Create a test client with members file configured."""
|
||||||
|
web_app_with_members.state.http_client = mock_http_client
|
||||||
|
return TestClient(web_app_with_members, raise_server_exceptions=True)
|
||||||
56
tests/test_web/test_home.py
Normal file
56
tests/test_web/test_home.py
Normal file
@@ -0,0 +1,56 @@
|
|||||||
|
"""Tests for the home page route."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestHomePage:
|
||||||
|
"""Tests for the home page."""
|
||||||
|
|
||||||
|
def test_home_returns_200(self, client: TestClient) -> None:
|
||||||
|
"""Test that home page returns 200 status code."""
|
||||||
|
response = client.get("/")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_home_returns_html(self, client: TestClient) -> None:
|
||||||
|
"""Test that home page returns HTML content."""
|
||||||
|
response = client.get("/")
|
||||||
|
assert "text/html" in response.headers["content-type"]
|
||||||
|
|
||||||
|
def test_home_contains_network_name(self, client: TestClient) -> None:
|
||||||
|
"""Test that home page contains the network name."""
|
||||||
|
response = client.get("/")
|
||||||
|
assert "Test Network" in response.text
|
||||||
|
|
||||||
|
def test_home_contains_network_city(self, client: TestClient) -> None:
|
||||||
|
"""Test that home page contains the network city."""
|
||||||
|
response = client.get("/")
|
||||||
|
assert "Test City" in response.text
|
||||||
|
|
||||||
|
def test_home_contains_network_country(self, client: TestClient) -> None:
|
||||||
|
"""Test that home page contains the network country."""
|
||||||
|
response = client.get("/")
|
||||||
|
assert "Test Country" in response.text
|
||||||
|
|
||||||
|
def test_home_contains_radio_config(self, client: TestClient) -> None:
|
||||||
|
"""Test that home page contains the radio configuration."""
|
||||||
|
response = client.get("/")
|
||||||
|
assert "Test Radio Config" in response.text
|
||||||
|
|
||||||
|
def test_home_contains_contact_email(self, client: TestClient) -> None:
|
||||||
|
"""Test that home page contains the contact email."""
|
||||||
|
response = client.get("/")
|
||||||
|
assert "test@example.com" in response.text
|
||||||
|
|
||||||
|
def test_home_contains_discord_link(self, client: TestClient) -> None:
|
||||||
|
"""Test that home page contains the Discord link."""
|
||||||
|
response = client.get("/")
|
||||||
|
assert "discord.gg/test" in response.text
|
||||||
|
|
||||||
|
def test_home_contains_navigation(self, client: TestClient) -> None:
|
||||||
|
"""Test that home page contains navigation links."""
|
||||||
|
response = client.get("/")
|
||||||
|
# Check for navigation links to other pages
|
||||||
|
assert 'href="/"' in response.text or 'href=""' in response.text
|
||||||
|
assert 'href="/nodes"' in response.text or "/nodes" in response.text
|
||||||
|
assert 'href="/messages"' in response.text or "/messages" in response.text
|
||||||
174
tests/test_web/test_map.py
Normal file
174
tests/test_web/test_map.py
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
"""Tests for the map page routes."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from tests.test_web.conftest import MockHttpClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestMapPage:
|
||||||
|
"""Tests for the map page."""
|
||||||
|
|
||||||
|
def test_map_returns_200(self, client: TestClient) -> None:
|
||||||
|
"""Test that map page returns 200 status code."""
|
||||||
|
response = client.get("/map")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_map_returns_html(self, client: TestClient) -> None:
|
||||||
|
"""Test that map page returns HTML content."""
|
||||||
|
response = client.get("/map")
|
||||||
|
assert "text/html" in response.headers["content-type"]
|
||||||
|
|
||||||
|
def test_map_contains_network_name(self, client: TestClient) -> None:
|
||||||
|
"""Test that map page contains the network name."""
|
||||||
|
response = client.get("/map")
|
||||||
|
assert "Test Network" in response.text
|
||||||
|
|
||||||
|
def test_map_contains_leaflet(self, client: TestClient) -> None:
|
||||||
|
"""Test that map page includes Leaflet library."""
|
||||||
|
response = client.get("/map")
|
||||||
|
# Should include Leaflet JS/CSS
|
||||||
|
assert "leaflet" in response.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestMapDataEndpoint:
|
||||||
|
"""Tests for the map data JSON endpoint."""
|
||||||
|
|
||||||
|
def test_map_data_returns_200(self, client: TestClient) -> None:
|
||||||
|
"""Test that map data endpoint returns 200 status code."""
|
||||||
|
response = client.get("/map/data")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_map_data_returns_json(self, client: TestClient) -> None:
|
||||||
|
"""Test that map data endpoint returns JSON content."""
|
||||||
|
response = client.get("/map/data")
|
||||||
|
assert "application/json" in response.headers["content-type"]
|
||||||
|
|
||||||
|
def test_map_data_contains_nodes(
|
||||||
|
self, client: TestClient, mock_http_client: MockHttpClient
|
||||||
|
) -> None:
|
||||||
|
"""Test that map data contains nodes with location."""
|
||||||
|
response = client.get("/map/data")
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert "nodes" in data
|
||||||
|
# The mock includes a node with lat/lon tags
|
||||||
|
nodes = data["nodes"]
|
||||||
|
# Should have at least one node with location
|
||||||
|
assert len(nodes) == 1
|
||||||
|
assert nodes[0]["name"] == "Node Two"
|
||||||
|
assert nodes[0]["lat"] == 40.7128
|
||||||
|
assert nodes[0]["lon"] == -74.0060
|
||||||
|
|
||||||
|
def test_map_data_contains_center(
|
||||||
|
self, client: TestClient, mock_http_client: MockHttpClient
|
||||||
|
) -> None:
|
||||||
|
"""Test that map data contains network center location."""
|
||||||
|
response = client.get("/map/data")
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
assert "center" in data
|
||||||
|
center = data["center"]
|
||||||
|
assert center["lat"] == 40.7128
|
||||||
|
assert center["lon"] == -74.0060
|
||||||
|
|
||||||
|
def test_map_data_excludes_nodes_without_location(
|
||||||
|
self, client: TestClient, mock_http_client: MockHttpClient
|
||||||
|
) -> None:
|
||||||
|
"""Test that map data excludes nodes without location tags."""
|
||||||
|
response = client.get("/map/data")
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
nodes = data["nodes"]
|
||||||
|
# Node One has no location tags, so should not appear
|
||||||
|
node_names = [n["name"] for n in nodes]
|
||||||
|
assert "Node One" not in node_names
|
||||||
|
|
||||||
|
|
||||||
|
class TestMapDataAPIErrors:
|
||||||
|
"""Tests for map data handling API errors."""
|
||||||
|
|
||||||
|
def test_map_data_handles_api_error(
|
||||||
|
self, web_app: any, mock_http_client: MockHttpClient
|
||||||
|
) -> None:
|
||||||
|
"""Test that map data handles API errors gracefully."""
|
||||||
|
mock_http_client.set_response(
|
||||||
|
"GET", "/api/v1/nodes", status_code=500, json_data=None
|
||||||
|
)
|
||||||
|
web_app.state.http_client = mock_http_client
|
||||||
|
|
||||||
|
client = TestClient(web_app, raise_server_exceptions=True)
|
||||||
|
response = client.get("/map/data")
|
||||||
|
|
||||||
|
# Should still return 200 with empty nodes
|
||||||
|
assert response.status_code == 200
|
||||||
|
data = response.json()
|
||||||
|
assert data["nodes"] == []
|
||||||
|
assert "center" in data
|
||||||
|
|
||||||
|
|
||||||
|
class TestMapDataFiltering:
|
||||||
|
"""Tests for map data location filtering."""
|
||||||
|
|
||||||
|
def test_map_data_filters_invalid_lat(
|
||||||
|
self, web_app: any, mock_http_client: MockHttpClient
|
||||||
|
) -> None:
|
||||||
|
"""Test that map data filters nodes with invalid latitude."""
|
||||||
|
mock_http_client.set_response(
|
||||||
|
"GET",
|
||||||
|
"/api/v1/nodes",
|
||||||
|
status_code=200,
|
||||||
|
json_data={
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "node-1",
|
||||||
|
"public_key": "abc123",
|
||||||
|
"name": "Bad Lat Node",
|
||||||
|
"tags": [
|
||||||
|
{"key": "lat", "value": "not-a-number"},
|
||||||
|
{"key": "lon", "value": "-74.0060"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"total": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
web_app.state.http_client = mock_http_client
|
||||||
|
|
||||||
|
client = TestClient(web_app, raise_server_exceptions=True)
|
||||||
|
response = client.get("/map/data")
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Node with invalid lat should be excluded
|
||||||
|
assert len(data["nodes"]) == 0
|
||||||
|
|
||||||
|
def test_map_data_filters_missing_lon(
|
||||||
|
self, web_app: any, mock_http_client: MockHttpClient
|
||||||
|
) -> None:
|
||||||
|
"""Test that map data filters nodes with missing longitude."""
|
||||||
|
mock_http_client.set_response(
|
||||||
|
"GET",
|
||||||
|
"/api/v1/nodes",
|
||||||
|
status_code=200,
|
||||||
|
json_data={
|
||||||
|
"items": [
|
||||||
|
{
|
||||||
|
"id": "node-1",
|
||||||
|
"public_key": "abc123",
|
||||||
|
"name": "No Lon Node",
|
||||||
|
"tags": [
|
||||||
|
{"key": "lat", "value": "40.7128"},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
"total": 1,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
web_app.state.http_client = mock_http_client
|
||||||
|
|
||||||
|
client = TestClient(web_app, raise_server_exceptions=True)
|
||||||
|
response = client.get("/map/data")
|
||||||
|
data = response.json()
|
||||||
|
|
||||||
|
# Node with only lat should be excluded
|
||||||
|
assert len(data["nodes"]) == 0
|
||||||
140
tests/test_web/test_members.py
Normal file
140
tests/test_web/test_members.py
Normal file
@@ -0,0 +1,140 @@
|
|||||||
|
"""Tests for the members page route."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import tempfile
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from meshcore_hub.web.routes.members import load_members
|
||||||
|
|
||||||
|
|
||||||
|
class TestMembersPage:
|
||||||
|
"""Tests for the members page."""
|
||||||
|
|
||||||
|
def test_members_returns_200(self, client: TestClient) -> None:
|
||||||
|
"""Test that members page returns 200 status code."""
|
||||||
|
response = client.get("/members")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_members_returns_html(self, client: TestClient) -> None:
|
||||||
|
"""Test that members page returns HTML content."""
|
||||||
|
response = client.get("/members")
|
||||||
|
assert "text/html" in response.headers["content-type"]
|
||||||
|
|
||||||
|
def test_members_contains_network_name(self, client: TestClient) -> None:
|
||||||
|
"""Test that members page contains the network name."""
|
||||||
|
response = client.get("/members")
|
||||||
|
assert "Test Network" in response.text
|
||||||
|
|
||||||
|
def test_members_without_file_shows_empty(self, client: TestClient) -> None:
|
||||||
|
"""Test that members page with no file shows no members."""
|
||||||
|
response = client.get("/members")
|
||||||
|
# Should still render successfully
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_members_with_file_shows_members(
|
||||||
|
self, client_with_members: TestClient
|
||||||
|
) -> None:
|
||||||
|
"""Test that members page with file shows member data."""
|
||||||
|
response = client_with_members.get("/members")
|
||||||
|
assert response.status_code == 200
|
||||||
|
# Check for member data
|
||||||
|
assert "Alice" in response.text
|
||||||
|
assert "Bob" in response.text
|
||||||
|
assert "W1ABC" in response.text
|
||||||
|
assert "W2XYZ" in response.text
|
||||||
|
assert "Admin" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestLoadMembers:
|
||||||
|
"""Tests for the load_members function."""
|
||||||
|
|
||||||
|
def test_load_members_none_path(self) -> None:
|
||||||
|
"""Test load_members with None path returns empty list."""
|
||||||
|
result = load_members(None)
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_load_members_nonexistent_file(self) -> None:
|
||||||
|
"""Test load_members with nonexistent file returns empty list."""
|
||||||
|
result = load_members("/nonexistent/path/members.json")
|
||||||
|
assert result == []
|
||||||
|
|
||||||
|
def test_load_members_list_format(self) -> None:
|
||||||
|
"""Test load_members with list format JSON."""
|
||||||
|
members_data = [
|
||||||
|
{"name": "Alice", "callsign": "W1ABC"},
|
||||||
|
{"name": "Bob", "callsign": "W2XYZ"},
|
||||||
|
]
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".json", delete=False
|
||||||
|
) as f:
|
||||||
|
json.dump(members_data, f)
|
||||||
|
f.flush()
|
||||||
|
path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = load_members(path)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0]["name"] == "Alice"
|
||||||
|
assert result[1]["name"] == "Bob"
|
||||||
|
finally:
|
||||||
|
Path(path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def test_load_members_dict_format(self) -> None:
|
||||||
|
"""Test load_members with dict format JSON (members key)."""
|
||||||
|
members_data = {
|
||||||
|
"members": [
|
||||||
|
{"name": "Alice", "callsign": "W1ABC"},
|
||||||
|
{"name": "Bob", "callsign": "W2XYZ"},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".json", delete=False
|
||||||
|
) as f:
|
||||||
|
json.dump(members_data, f)
|
||||||
|
f.flush()
|
||||||
|
path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = load_members(path)
|
||||||
|
assert len(result) == 2
|
||||||
|
assert result[0]["name"] == "Alice"
|
||||||
|
assert result[1]["name"] == "Bob"
|
||||||
|
finally:
|
||||||
|
Path(path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def test_load_members_invalid_json(self) -> None:
|
||||||
|
"""Test load_members with invalid JSON returns empty list."""
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".json", delete=False
|
||||||
|
) as f:
|
||||||
|
f.write("not valid json {")
|
||||||
|
f.flush()
|
||||||
|
path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = load_members(path)
|
||||||
|
assert result == []
|
||||||
|
finally:
|
||||||
|
Path(path).unlink(missing_ok=True)
|
||||||
|
|
||||||
|
def test_load_members_dict_without_members_key(self) -> None:
|
||||||
|
"""Test load_members with dict but no members key returns empty list."""
|
||||||
|
data = {"other_key": "value"}
|
||||||
|
|
||||||
|
with tempfile.NamedTemporaryFile(
|
||||||
|
mode="w", suffix=".json", delete=False
|
||||||
|
) as f:
|
||||||
|
json.dump(data, f)
|
||||||
|
f.flush()
|
||||||
|
path = f.name
|
||||||
|
|
||||||
|
try:
|
||||||
|
result = load_members(path)
|
||||||
|
assert result == []
|
||||||
|
finally:
|
||||||
|
Path(path).unlink(missing_ok=True)
|
||||||
117
tests/test_web/test_messages.py
Normal file
117
tests/test_web/test_messages.py
Normal file
@@ -0,0 +1,117 @@
|
|||||||
|
"""Tests for the messages page route."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from tests.test_web.conftest import MockHttpClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestMessagesPage:
|
||||||
|
"""Tests for the messages page."""
|
||||||
|
|
||||||
|
def test_messages_returns_200(self, client: TestClient) -> None:
|
||||||
|
"""Test that messages page returns 200 status code."""
|
||||||
|
response = client.get("/messages")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_messages_returns_html(self, client: TestClient) -> None:
|
||||||
|
"""Test that messages page returns HTML content."""
|
||||||
|
response = client.get("/messages")
|
||||||
|
assert "text/html" in response.headers["content-type"]
|
||||||
|
|
||||||
|
def test_messages_contains_network_name(self, client: TestClient) -> None:
|
||||||
|
"""Test that messages page contains the network name."""
|
||||||
|
response = client.get("/messages")
|
||||||
|
assert "Test Network" in response.text
|
||||||
|
|
||||||
|
def test_messages_displays_message_list(
|
||||||
|
self, client: TestClient, mock_http_client: MockHttpClient
|
||||||
|
) -> None:
|
||||||
|
"""Test that messages page displays messages from API."""
|
||||||
|
response = client.get("/messages")
|
||||||
|
assert response.status_code == 200
|
||||||
|
# Check for message data from mock
|
||||||
|
assert "Hello World" in response.text
|
||||||
|
assert "Channel message" in response.text
|
||||||
|
|
||||||
|
def test_messages_displays_message_types(
|
||||||
|
self, client: TestClient, mock_http_client: MockHttpClient
|
||||||
|
) -> None:
|
||||||
|
"""Test that messages page displays message types."""
|
||||||
|
response = client.get("/messages")
|
||||||
|
# Should show message types
|
||||||
|
assert "direct" in response.text.lower() or "contact" in response.text.lower()
|
||||||
|
assert "channel" in response.text.lower()
|
||||||
|
|
||||||
|
|
||||||
|
class TestMessagesPageFilters:
|
||||||
|
"""Tests for messages page filtering."""
|
||||||
|
|
||||||
|
def test_messages_with_type_filter(self, client: TestClient) -> None:
|
||||||
|
"""Test messages page with message type filter."""
|
||||||
|
response = client.get("/messages?message_type=direct")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_messages_with_channel_filter(self, client: TestClient) -> None:
|
||||||
|
"""Test messages page with channel filter."""
|
||||||
|
response = client.get("/messages?channel_idx=0")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_messages_with_search(self, client: TestClient) -> None:
|
||||||
|
"""Test messages page with search parameter."""
|
||||||
|
response = client.get("/messages?search=hello")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_messages_with_pagination(self, client: TestClient) -> None:
|
||||||
|
"""Test messages page with pagination parameters."""
|
||||||
|
response = client.get("/messages?page=1&limit=25")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_messages_page_2(self, client: TestClient) -> None:
|
||||||
|
"""Test messages page 2."""
|
||||||
|
response = client.get("/messages?page=2")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_messages_with_all_filters(self, client: TestClient) -> None:
|
||||||
|
"""Test messages page with multiple filters."""
|
||||||
|
response = client.get(
|
||||||
|
"/messages?message_type=channel&channel_idx=1&page=1&limit=10"
|
||||||
|
)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
class TestMessagesPageAPIErrors:
|
||||||
|
"""Tests for messages page handling API errors."""
|
||||||
|
|
||||||
|
def test_messages_handles_api_error(
|
||||||
|
self, web_app: any, mock_http_client: MockHttpClient
|
||||||
|
) -> None:
|
||||||
|
"""Test that messages page handles API errors gracefully."""
|
||||||
|
mock_http_client.set_response(
|
||||||
|
"GET", "/api/v1/messages", status_code=500, json_data=None
|
||||||
|
)
|
||||||
|
web_app.state.http_client = mock_http_client
|
||||||
|
|
||||||
|
client = TestClient(web_app, raise_server_exceptions=True)
|
||||||
|
response = client.get("/messages")
|
||||||
|
|
||||||
|
# Should still return 200 (page renders with empty list)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_messages_handles_api_not_found(
|
||||||
|
self, web_app: any, mock_http_client: MockHttpClient
|
||||||
|
) -> None:
|
||||||
|
"""Test that messages page handles API 404 gracefully."""
|
||||||
|
mock_http_client.set_response(
|
||||||
|
"GET",
|
||||||
|
"/api/v1/messages",
|
||||||
|
status_code=404,
|
||||||
|
json_data={"detail": "Not found"},
|
||||||
|
)
|
||||||
|
web_app.state.http_client = mock_http_client
|
||||||
|
|
||||||
|
client = TestClient(web_app, raise_server_exceptions=True)
|
||||||
|
response = client.get("/messages")
|
||||||
|
|
||||||
|
# Should still return 200 (page renders with empty list)
|
||||||
|
assert response.status_code == 200
|
||||||
85
tests/test_web/test_network.py
Normal file
85
tests/test_web/test_network.py
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
"""Tests for the network overview page route."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from tests.test_web.conftest import MockHttpClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestNetworkPage:
|
||||||
|
"""Tests for the network overview page."""
|
||||||
|
|
||||||
|
def test_network_returns_200(self, client: TestClient) -> None:
|
||||||
|
"""Test that network page returns 200 status code."""
|
||||||
|
response = client.get("/network")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_network_returns_html(self, client: TestClient) -> None:
|
||||||
|
"""Test that network page returns HTML content."""
|
||||||
|
response = client.get("/network")
|
||||||
|
assert "text/html" in response.headers["content-type"]
|
||||||
|
|
||||||
|
def test_network_contains_network_name(self, client: TestClient) -> None:
|
||||||
|
"""Test that network page contains the network name."""
|
||||||
|
response = client.get("/network")
|
||||||
|
assert "Test Network" in response.text
|
||||||
|
|
||||||
|
def test_network_displays_stats(
|
||||||
|
self, client: TestClient, mock_http_client: MockHttpClient
|
||||||
|
) -> None:
|
||||||
|
"""Test that network page displays statistics."""
|
||||||
|
response = client.get("/network")
|
||||||
|
# Check for stats from mock response
|
||||||
|
assert response.status_code == 200
|
||||||
|
# The mock returns total_nodes: 10, active_nodes: 5, etc.
|
||||||
|
# These should be displayed in the page
|
||||||
|
assert "10" in response.text # total_nodes
|
||||||
|
assert "5" in response.text # active_nodes
|
||||||
|
|
||||||
|
def test_network_displays_message_counts(
|
||||||
|
self, client: TestClient, mock_http_client: MockHttpClient
|
||||||
|
) -> None:
|
||||||
|
"""Test that network page displays message counts."""
|
||||||
|
response = client.get("/network")
|
||||||
|
assert response.status_code == 200
|
||||||
|
# Mock returns total_messages: 100, messages_today: 15
|
||||||
|
assert "100" in response.text
|
||||||
|
assert "15" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestNetworkPageAPIErrors:
|
||||||
|
"""Tests for network page handling API errors."""
|
||||||
|
|
||||||
|
def test_network_handles_api_error(
|
||||||
|
self, web_app: any, mock_http_client: MockHttpClient
|
||||||
|
) -> None:
|
||||||
|
"""Test that network page handles API errors gracefully."""
|
||||||
|
# Set error response for stats endpoint
|
||||||
|
mock_http_client.set_response(
|
||||||
|
"GET", "/api/v1/dashboard/stats", status_code=500, json_data=None
|
||||||
|
)
|
||||||
|
web_app.state.http_client = mock_http_client
|
||||||
|
|
||||||
|
client = TestClient(web_app, raise_server_exceptions=True)
|
||||||
|
response = client.get("/network")
|
||||||
|
|
||||||
|
# Should still return 200 (page renders with defaults)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_network_handles_api_not_found(
|
||||||
|
self, web_app: any, mock_http_client: MockHttpClient
|
||||||
|
) -> None:
|
||||||
|
"""Test that network page handles API 404 gracefully."""
|
||||||
|
mock_http_client.set_response(
|
||||||
|
"GET",
|
||||||
|
"/api/v1/dashboard/stats",
|
||||||
|
status_code=404,
|
||||||
|
json_data={"detail": "Not found"},
|
||||||
|
)
|
||||||
|
web_app.state.http_client = mock_http_client
|
||||||
|
|
||||||
|
client = TestClient(web_app, raise_server_exceptions=True)
|
||||||
|
response = client.get("/network")
|
||||||
|
|
||||||
|
# Should still return 200 (page renders with defaults)
|
||||||
|
assert response.status_code == 200
|
||||||
137
tests/test_web/test_nodes.py
Normal file
137
tests/test_web/test_nodes.py
Normal file
@@ -0,0 +1,137 @@
|
|||||||
|
"""Tests for the nodes page routes."""
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from fastapi.testclient import TestClient
|
||||||
|
|
||||||
|
from tests.test_web.conftest import MockHttpClient
|
||||||
|
|
||||||
|
|
||||||
|
class TestNodesListPage:
|
||||||
|
"""Tests for the nodes list page."""
|
||||||
|
|
||||||
|
def test_nodes_returns_200(self, client: TestClient) -> None:
|
||||||
|
"""Test that nodes page returns 200 status code."""
|
||||||
|
response = client.get("/nodes")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_nodes_returns_html(self, client: TestClient) -> None:
|
||||||
|
"""Test that nodes page returns HTML content."""
|
||||||
|
response = client.get("/nodes")
|
||||||
|
assert "text/html" in response.headers["content-type"]
|
||||||
|
|
||||||
|
def test_nodes_contains_network_name(self, client: TestClient) -> None:
|
||||||
|
"""Test that nodes page contains the network name."""
|
||||||
|
response = client.get("/nodes")
|
||||||
|
assert "Test Network" in response.text
|
||||||
|
|
||||||
|
def test_nodes_displays_node_list(
|
||||||
|
self, client: TestClient, mock_http_client: MockHttpClient
|
||||||
|
) -> None:
|
||||||
|
"""Test that nodes page displays node data from API."""
|
||||||
|
response = client.get("/nodes")
|
||||||
|
assert response.status_code == 200
|
||||||
|
# Check for node data from mock
|
||||||
|
assert "Node One" in response.text
|
||||||
|
assert "Node Two" in response.text
|
||||||
|
assert "REPEATER" in response.text
|
||||||
|
assert "CLIENT" in response.text
|
||||||
|
|
||||||
|
def test_nodes_displays_public_keys(
|
||||||
|
self, client: TestClient, mock_http_client: MockHttpClient
|
||||||
|
) -> None:
|
||||||
|
"""Test that nodes page displays public keys."""
|
||||||
|
response = client.get("/nodes")
|
||||||
|
# Should show truncated or full public keys
|
||||||
|
assert "abc123" in response.text or "def456" in response.text
|
||||||
|
|
||||||
|
def test_nodes_with_search_param(self, client: TestClient) -> None:
|
||||||
|
"""Test nodes page with search parameter."""
|
||||||
|
response = client.get("/nodes?search=test")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_nodes_with_adv_type_filter(self, client: TestClient) -> None:
|
||||||
|
"""Test nodes page with adv_type filter."""
|
||||||
|
response = client.get("/nodes?adv_type=REPEATER")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_nodes_with_pagination(self, client: TestClient) -> None:
|
||||||
|
"""Test nodes page with pagination parameters."""
|
||||||
|
response = client.get("/nodes?page=1&limit=10")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_nodes_page_2(self, client: TestClient) -> None:
|
||||||
|
"""Test nodes page 2."""
|
||||||
|
response = client.get("/nodes?page=2")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
|
||||||
|
class TestNodeDetailPage:
|
||||||
|
"""Tests for the node detail page."""
|
||||||
|
|
||||||
|
def test_node_detail_returns_200(
|
||||||
|
self, client: TestClient, mock_http_client: MockHttpClient
|
||||||
|
) -> None:
|
||||||
|
"""Test that node detail page returns 200 status code."""
|
||||||
|
response = client.get("/nodes/abc123def456abc123def456abc123de")
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_node_detail_returns_html(
|
||||||
|
self, client: TestClient, mock_http_client: MockHttpClient
|
||||||
|
) -> None:
|
||||||
|
"""Test that node detail page returns HTML content."""
|
||||||
|
response = client.get("/nodes/abc123def456abc123def456abc123de")
|
||||||
|
assert "text/html" in response.headers["content-type"]
|
||||||
|
|
||||||
|
def test_node_detail_displays_node_info(
|
||||||
|
self, client: TestClient, mock_http_client: MockHttpClient
|
||||||
|
) -> None:
|
||||||
|
"""Test that node detail page displays node information."""
|
||||||
|
response = client.get("/nodes/abc123def456abc123def456abc123de")
|
||||||
|
assert response.status_code == 200
|
||||||
|
# Should display node details
|
||||||
|
assert "Node One" in response.text
|
||||||
|
assert "REPEATER" in response.text
|
||||||
|
|
||||||
|
def test_node_detail_displays_public_key(
|
||||||
|
self, client: TestClient, mock_http_client: MockHttpClient
|
||||||
|
) -> None:
|
||||||
|
"""Test that node detail page displays the full public key."""
|
||||||
|
response = client.get("/nodes/abc123def456abc123def456abc123de")
|
||||||
|
assert "abc123def456abc123def456abc123de" in response.text
|
||||||
|
|
||||||
|
|
||||||
|
class TestNodesPageAPIErrors:
|
||||||
|
"""Tests for nodes pages handling API errors."""
|
||||||
|
|
||||||
|
def test_nodes_handles_api_error(
|
||||||
|
self, web_app: any, mock_http_client: MockHttpClient
|
||||||
|
) -> None:
|
||||||
|
"""Test that nodes page handles API errors gracefully."""
|
||||||
|
mock_http_client.set_response(
|
||||||
|
"GET", "/api/v1/nodes", status_code=500, json_data=None
|
||||||
|
)
|
||||||
|
web_app.state.http_client = mock_http_client
|
||||||
|
|
||||||
|
client = TestClient(web_app, raise_server_exceptions=True)
|
||||||
|
response = client.get("/nodes")
|
||||||
|
|
||||||
|
# Should still return 200 (page renders with empty list)
|
||||||
|
assert response.status_code == 200
|
||||||
|
|
||||||
|
def test_node_detail_handles_not_found(
|
||||||
|
self, web_app: any, mock_http_client: MockHttpClient
|
||||||
|
) -> None:
|
||||||
|
"""Test that node detail page handles 404 from API."""
|
||||||
|
mock_http_client.set_response(
|
||||||
|
"GET",
|
||||||
|
"/api/v1/nodes/nonexistent",
|
||||||
|
status_code=404,
|
||||||
|
json_data={"detail": "Node not found"},
|
||||||
|
)
|
||||||
|
web_app.state.http_client = mock_http_client
|
||||||
|
|
||||||
|
client = TestClient(web_app, raise_server_exceptions=True)
|
||||||
|
response = client.get("/nodes/nonexistent")
|
||||||
|
|
||||||
|
# Should still return 200 (page renders but shows no node)
|
||||||
|
assert response.status_code == 200
|
||||||
Reference in New Issue
Block a user