From 65c77afbe073ff7b6424b07b5338a7799e584d49 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 3 Dec 2025 15:06:40 +0000 Subject: [PATCH] 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) --- CLAUDE.md | 2 +- TASKS.md | 22 +-- tests/test_web/conftest.py | 304 ++++++++++++++++++++++++++++++++ tests/test_web/test_home.py | 56 ++++++ tests/test_web/test_map.py | 174 ++++++++++++++++++ tests/test_web/test_members.py | 140 +++++++++++++++ tests/test_web/test_messages.py | 117 ++++++++++++ tests/test_web/test_network.py | 85 +++++++++ tests/test_web/test_nodes.py | 137 ++++++++++++++ 9 files changed, 1025 insertions(+), 12 deletions(-) create mode 100644 tests/test_web/conftest.py create mode 100644 tests/test_web/test_home.py create mode 100644 tests/test_web/test_map.py create mode 100644 tests/test_web/test_members.py create mode 100644 tests/test_web/test_messages.py create mode 100644 tests/test_web/test_network.py create mode 100644 tests/test_web/test_nodes.py diff --git a/CLAUDE.md b/CLAUDE.md index 2f024d4..48b2af0 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -2,4 +2,4 @@ Refer to @AGENTS.md for coding instructions. -DO NOT MODIFY "CLAUDE.md", UPDATE "AGENTS.md". \ No newline at end of file +DO NOT MODIFY "CLAUDE.md", UPDATE "AGENTS.md". diff --git a/TASKS.md b/TASKS.md index 1f2d93d..9db608b 100644 --- a/TASKS.md +++ b/TASKS.md @@ -602,15 +602,15 @@ This document tracks implementation progress for the MeshCore Hub project. Each ### 5.11 Web Tests -- [ ] Create `tests/test_web/conftest.py`: - - [ ] Test client fixture - - [ ] Mock API responses -- [ ] Create `tests/test_web/test_home.py` -- [ ] Create `tests/test_web/test_members.py` -- [ ] Create `tests/test_web/test_network.py` -- [ ] Create `tests/test_web/test_nodes.py` -- [ ] Create `tests/test_web/test_map.py` -- [ ] Create `tests/test_web/test_messages.py` +- [x] Create `tests/test_web/conftest.py`: + - [x] Test client fixture + - [x] Mock API responses +- [x] Create `tests/test_web/test_home.py` +- [x] Create `tests/test_web/test_members.py` +- [x] Create `tests/test_web/test_network.py` +- [x] Create `tests/test_web/test_nodes.py` +- [x] Create `tests/test_web/test_map.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 3: Collector | 27 | 20 | 74% | | 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% | -| **Total** | **221** | **179** | **81%** | +| **Total** | **221** | **186** | **84%** | --- diff --git a/tests/test_web/conftest.py b/tests/test_web/conftest.py new file mode 100644 index 0000000..8d696c4 --- /dev/null +++ b/tests/test_web/conftest.py @@ -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) diff --git a/tests/test_web/test_home.py b/tests/test_web/test_home.py new file mode 100644 index 0000000..205c536 --- /dev/null +++ b/tests/test_web/test_home.py @@ -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 diff --git a/tests/test_web/test_map.py b/tests/test_web/test_map.py new file mode 100644 index 0000000..28f316c --- /dev/null +++ b/tests/test_web/test_map.py @@ -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 diff --git a/tests/test_web/test_members.py b/tests/test_web/test_members.py new file mode 100644 index 0000000..24cc797 --- /dev/null +++ b/tests/test_web/test_members.py @@ -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) diff --git a/tests/test_web/test_messages.py b/tests/test_web/test_messages.py new file mode 100644 index 0000000..589afe6 --- /dev/null +++ b/tests/test_web/test_messages.py @@ -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 diff --git a/tests/test_web/test_network.py b/tests/test_web/test_network.py new file mode 100644 index 0000000..e64225b --- /dev/null +++ b/tests/test_web/test_network.py @@ -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 diff --git a/tests/test_web/test_nodes.py b/tests/test_web/test_nodes.py new file mode 100644 index 0000000..d309153 --- /dev/null +++ b/tests/test_web/test_nodes.py @@ -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