Files
meshcore-bot/tests/test_web_viewer.py
agessaman 07a2db4ede Slim scheduler, add maintenance module
- Introduced a new `maintenance` module to handle data retention, log rotation, and nightly email tasks.
- Updated the `scheduler` to utilize the `MaintenanceRunner` for executing maintenance tasks, improving code organization and clarity.
- Enhanced documentation to reflect changes in logging configuration and data retention processes.
- Adjusted tests to accommodate the refactored scheduler methods and ensure proper functionality.
2026-03-19 19:40:33 -07:00

2838 lines
106 KiB
Python

#!/usr/bin/env python3
"""Tests for modules/web_viewer/app.py — BotDataViewer Flask routes and API endpoints.
Uses Flask's built-in test client. Background threads (database polling, log
tailing, cleanup scheduler) are patched to no-ops so the fixture is fast and
side-effect free.
"""
import configparser
import json
import logging
import sqlite3
from contextlib import closing
from pathlib import Path
from unittest.mock import patch
import pytest
from modules.web_viewer.app import BotDataViewer
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _write_config(path: Path, db_path: str) -> None:
cfg = configparser.ConfigParser()
cfg["Connection"] = {"connection_type": "serial", "serial_port": "/dev/ttyUSB0"}
cfg["Bot"] = {"bot_name": "TestBot", "db_path": db_path, "prefix_bytes": "1"}
cfg["Channels"] = {"monitor_channels": "general"}
cfg["Path_Command"] = {
"graph_capture_enabled": "false",
"graph_write_strategy": "immediate",
}
with open(path, "w") as f:
cfg.write(f)
def _fake_setup_logging(self: BotDataViewer) -> None:
"""Replace file-based logging with an in-memory logger for tests."""
self.logger = logging.getLogger("test_web_viewer")
self.logger.setLevel(logging.DEBUG)
if not self.logger.handlers:
self.logger.addHandler(logging.NullHandler())
self.logger.propagate = False
# ---------------------------------------------------------------------------
# Fixtures
# ---------------------------------------------------------------------------
@pytest.fixture(scope="module")
def viewer(tmp_path_factory):
"""Create a BotDataViewer with a real temp SQLite DB and Flask test client.
Background threads are suppressed. The fixture is module-scoped so the
expensive DB initialisation only runs once per test module.
"""
tmp = tmp_path_factory.mktemp("web_viewer")
db_path = str(tmp / "test.db")
config_path = str(tmp / "config.ini")
_write_config(Path(config_path), db_path)
with (
patch.object(BotDataViewer, "_setup_logging", _fake_setup_logging),
patch.object(BotDataViewer, "_start_database_polling", lambda self: None),
patch.object(BotDataViewer, "_start_log_tailing", lambda self: None),
patch.object(BotDataViewer, "_start_cleanup_scheduler", lambda self: None),
):
v = BotDataViewer(db_path=db_path, config_path=config_path)
v.app.config["TESTING"] = True
v.app.config["WTF_CSRF_ENABLED"] = False
yield v
@pytest.fixture
def client(viewer):
"""Flask test client with an application context."""
with viewer.app.test_client() as c:
yield c
@pytest.fixture
def auth_viewer(tmp_path_factory):
"""BotDataViewer with password authentication enabled."""
tmp = tmp_path_factory.mktemp("web_viewer_auth")
db_path = str(tmp / "test.db")
config_path = str(tmp / "config.ini")
cfg = configparser.ConfigParser()
cfg["Connection"] = {"connection_type": "serial", "serial_port": "/dev/ttyUSB0"}
cfg["Bot"] = {"bot_name": "TestBot", "db_path": db_path, "prefix_bytes": "1"}
cfg["Web_Viewer"] = {"web_viewer_password": "secret123"}
cfg["Path_Command"] = {
"graph_capture_enabled": "false",
"graph_write_strategy": "immediate",
}
with open(config_path, "w") as f:
cfg.write(f)
with (
patch.object(BotDataViewer, "_setup_logging", _fake_setup_logging),
patch.object(BotDataViewer, "_start_database_polling", lambda self: None),
patch.object(BotDataViewer, "_start_log_tailing", lambda self: None),
patch.object(BotDataViewer, "_start_cleanup_scheduler", lambda self: None),
):
v = BotDataViewer(db_path=db_path, config_path=config_path)
v.app.config["TESTING"] = True
yield v
@pytest.fixture
def auth_client(auth_viewer):
with auth_viewer.app.test_client() as c:
yield c
# ---------------------------------------------------------------------------
# Helper: insert a contact row so contact-related routes have data
# ---------------------------------------------------------------------------
def _insert_contact(viewer: BotDataViewer, public_key: str = "aabbccdd" * 8,
name: str = "TestNode") -> str:
with closing(sqlite3.connect(viewer.db_path)) as conn:
conn.execute(
"""INSERT OR IGNORE INTO complete_contact_tracking
(public_key, name, role, device_type, is_starred, is_currently_tracked)
VALUES (?, ?, 'companion', 'device', 0, 1)""",
(public_key, name),
)
conn.commit()
return public_key
# ===========================================================================
# Page routes (HTML)
# ===========================================================================
class TestPageRoutes:
def test_index(self, client):
resp = client.get("/")
assert resp.status_code == 200
def test_index_live_activity_controls(self, client):
"""Dashboard index page contains scroll buttons and type-filter checkboxes."""
resp = client.get("/")
assert resp.status_code == 200
html = resp.data.decode()
# Scroll buttons
assert 'id="live-scroll-top"' in html
assert 'id="live-scroll-bottom"' in html
assert 'scrollLiveFeed' in html
# Filter checkboxes with data-type attributes
assert 'data-type="packet"' in html
assert 'data-type="command"' in html
assert 'data-type="message"' in html
assert 'live-filter-cb' in html
# [#channel] prefix logic present in JS
assert 'applyFilters' in html
def test_realtime(self, client):
resp = client.get("/realtime")
assert resp.status_code == 200
def test_realtime_scroll_controls(self, client):
"""Realtime page has scroll buttons, type filters, and channel labels in messages."""
resp = client.get("/realtime")
assert resp.status_code == 200
html = resp.data.decode()
# Scroll buttons present for all three streams
assert 'id="cmd-scroll-top"' in html
assert 'id="cmd-scroll-bottom"' in html
assert 'id="pkt-scroll-top"' in html
assert 'id="pkt-scroll-bottom"' in html
assert 'id="msg-scroll-top"' in html
assert 'id="msg-scroll-bottom"' in html
# scrollStream JS function present
assert 'scrollStream' in html
# Type filter checkboxes for each stream panel
assert 'rt-filter-cb' in html
assert 'id="rt-filter-command"' in html
assert 'id="rt-filter-packet"' in html
assert 'id="rt-filter-message"' in html
assert 'id="command-card"' in html
assert 'id="packet-card"' in html
assert 'id="message-card"' in html
# Channel label placeholder in message entry template
assert 'channelLabel' in html
assert '[#' in html
def test_logs(self, client):
resp = client.get("/logs")
assert resp.status_code == 200
def test_contacts(self, client):
resp = client.get("/contacts")
assert resp.status_code == 200
def test_cache(self, client):
resp = client.get("/cache")
assert resp.status_code == 200
def test_stats(self, client):
resp = client.get("/stats")
assert resp.status_code == 200
def test_greeter(self, client):
resp = client.get("/greeter")
assert resp.status_code == 200
def test_feeds(self, client):
resp = client.get("/feeds")
assert resp.status_code == 200
def test_radio(self, client):
resp = client.get("/radio")
assert resp.status_code == 200
def test_config(self, client):
resp = client.get("/config")
assert resp.status_code == 200
def test_mesh(self, client):
resp = client.get("/mesh")
assert resp.status_code == 200
# ===========================================================================
# Health routes
# ===========================================================================
class TestHealthRoutes:
def test_api_health_status(self, client):
resp = client.get("/api/health")
assert resp.status_code == 200
data = resp.get_json()
assert data["status"] == "healthy"
assert "connected_clients" in data
assert "timestamp" in data
assert data["version"] == "modern_2.0"
def test_api_health_client_count(self, client):
resp = client.get("/api/health")
data = resp.get_json()
assert isinstance(data["connected_clients"], int)
assert data["connected_clients"] >= 0
def test_api_system_health_returns_json(self, client):
resp = client.get("/api/system-health")
assert resp.status_code == 200
data = resp.get_json()
assert "status" in data or "error" in data
# ===========================================================================
# Radio routes
# ===========================================================================
class TestRadioRoutes:
def test_radio_status_returns_json(self, client):
resp = client.get("/api/radio/status")
assert resp.status_code == 200
data = resp.get_json()
assert "status_known" in data
def test_radio_status_unknown_when_no_metadata(self, client, viewer):
# Ensure key is absent
viewer.db_manager.set_metadata("radio_connected", None) if hasattr(
viewer.db_manager, "set_metadata"
) else None
resp = client.get("/api/radio/status")
assert resp.status_code == 200
def test_radio_reboot_queues_operation(self, client):
resp = client.post("/api/radio/reboot")
assert resp.status_code == 200
data = resp.get_json()
assert data["success"] is True
assert "operation_id" in data
def test_radio_connect_action(self, client):
resp = client.post(
"/api/radio/connect",
json={"action": "connect"},
content_type="application/json",
)
assert resp.status_code == 200
data = resp.get_json()
assert data["success"] is True
def test_radio_disconnect_action(self, client):
resp = client.post(
"/api/radio/connect",
json={"action": "disconnect"},
content_type="application/json",
)
assert resp.status_code == 200
data = resp.get_json()
assert data["success"] is True
def test_radio_connect_invalid_action(self, client):
resp = client.post(
"/api/radio/connect",
json={"action": "explode"},
content_type="application/json",
)
assert resp.status_code == 400
data = resp.get_json()
assert "error" in data
def test_radio_connect_missing_action(self, client):
resp = client.post(
"/api/radio/connect",
json={},
content_type="application/json",
)
assert resp.status_code == 400
# ===========================================================================
# Contact routes
# ===========================================================================
class TestContactRoutes:
def test_api_contacts_default(self, client):
resp = client.get("/api/contacts")
assert resp.status_code == 200
data = resp.get_json()
assert isinstance(data, dict)
def test_api_contacts_since_7d(self, client):
resp = client.get("/api/contacts?since=7d")
assert resp.status_code == 200
def test_api_contacts_since_all(self, client):
resp = client.get("/api/contacts?since=all")
assert resp.status_code == 200
def test_api_contacts_invalid_since_uses_default(self, client):
resp = client.get("/api/contacts?since=forever")
assert resp.status_code == 200
def test_toggle_star_missing_public_key(self, client):
resp = client.post(
"/api/toggle-star-contact",
json={},
content_type="application/json",
)
assert resp.status_code == 400
data = resp.get_json()
assert "error" in data
def test_toggle_star_unknown_contact(self, client):
resp = client.post(
"/api/toggle-star-contact",
json={"public_key": "0" * 64},
content_type="application/json",
)
assert resp.status_code == 404
def test_toggle_star_known_contact(self, client, viewer):
pk = _insert_contact(viewer, "1122334455667788" * 4, "StarNode")
resp = client.post(
"/api/toggle-star-contact",
json={"public_key": pk},
content_type="application/json",
)
assert resp.status_code == 200
data = resp.get_json()
assert data["success"] is True
assert "is_starred" in data
def test_toggle_star_toggles_value(self, client, viewer):
pk = _insert_contact(viewer, "aabbccdd11223344" * 4, "ToggleNode")
# First call: star
r1 = client.post("/api/toggle-star-contact", json={"public_key": pk},
content_type="application/json")
starred = r1.get_json()["is_starred"]
# Second call: unstar
r2 = client.post("/api/toggle-star-contact", json={"public_key": pk},
content_type="application/json")
unstarred = r2.get_json()["is_starred"]
assert starred != unstarred
def test_purge_preview_returns_json(self, client):
resp = client.get("/api/contacts/purge-preview?days=30")
assert resp.status_code == 200
data = resp.get_json()
assert isinstance(data, (dict, list))
def test_purge_contacts_post(self, client):
resp = client.post(
"/api/contacts/purge",
json={"days": 365},
content_type="application/json",
)
# Should return 200 (even if no contacts to purge)
assert resp.status_code == 200
# ===========================================================================
# Export routes
# ===========================================================================
class TestExportRoutes:
def test_export_contacts_json(self, client):
resp = client.get("/api/export/contacts?format=json")
assert resp.status_code == 200
assert resp.content_type.startswith("application/json")
def test_export_contacts_csv(self, client):
resp = client.get("/api/export/contacts?format=csv")
assert resp.status_code == 200
assert "text/csv" in resp.content_type
assert b"user_id" in resp.data # CSV header
def test_export_contacts_since_7d(self, client):
resp = client.get("/api/export/contacts?format=json&since=7d")
assert resp.status_code == 200
def test_export_contacts_default_format_is_json(self, client):
resp = client.get("/api/export/contacts")
assert resp.status_code == 200
def test_export_paths_json(self, client):
resp = client.get("/api/export/paths?format=json")
assert resp.status_code == 200
def test_export_paths_csv(self, client):
resp = client.get("/api/export/paths?format=csv")
assert resp.status_code == 200
assert "text/csv" in resp.content_type
def test_export_paths_invalid_since_uses_default(self, client):
resp = client.get("/api/export/paths?since=bogus")
assert resp.status_code == 200
# ===========================================================================
# Decode path
# ===========================================================================
class TestDecodePathRoute:
def test_missing_path_hex_returns_400(self, client):
resp = client.post(
"/api/decode-path",
json={},
content_type="application/json",
)
assert resp.status_code == 400
def test_empty_path_hex_returns_400(self, client):
resp = client.post(
"/api/decode-path",
json={"path_hex": ""},
content_type="application/json",
)
assert resp.status_code == 400
def test_valid_path_hex_returns_200(self, client):
resp = client.post(
"/api/decode-path",
json={"path_hex": "7e,01"},
content_type="application/json",
)
assert resp.status_code == 200
data = resp.get_json()
assert data["success"] is True
assert "path" in data
def test_path_hex_with_bytes_per_hop(self, client):
resp = client.post(
"/api/decode-path",
json={"path_hex": "7e01", "bytes_per_hop": 1},
content_type="application/json",
)
assert resp.status_code == 200
def test_invalid_bytes_per_hop_ignored(self, client):
resp = client.post(
"/api/decode-path",
json={"path_hex": "7e", "bytes_per_hop": 99},
content_type="application/json",
)
assert resp.status_code == 200
# ===========================================================================
# Database / cache / stats routes
# ===========================================================================
class TestDatabaseRoutes:
def test_api_database_returns_json(self, client):
resp = client.get("/api/database")
assert resp.status_code == 200
data = resp.get_json()
assert isinstance(data, dict)
def test_api_optimize_database(self, client):
resp = client.post("/api/optimize-database")
assert resp.status_code == 200
def test_api_cache_returns_json(self, client):
resp = client.get("/api/cache")
assert resp.status_code == 200
data = resp.get_json()
assert isinstance(data, dict)
def test_api_stats_returns_json(self, client):
resp = client.get("/api/stats")
assert resp.status_code == 200
data = resp.get_json()
assert isinstance(data, dict)
def test_api_stats_with_window_params(self, client):
resp = client.get("/api/stats?top_users_window=7d&top_commands_window=30d")
assert resp.status_code == 200
# ===========================================================================
# Mesh routes
# ===========================================================================
class TestMeshRoutes:
def test_api_mesh_nodes_returns_json(self, client):
resp = client.get("/api/mesh/nodes")
assert resp.status_code == 200
data = resp.get_json()
assert "nodes" in data or isinstance(data, (list, dict))
def test_api_mesh_edges_returns_json(self, client):
resp = client.get("/api/mesh/edges")
assert resp.status_code == 200
def test_api_mesh_stats_returns_json(self, client):
resp = client.get("/api/mesh/stats")
assert resp.status_code == 200
def test_api_mesh_resolve_path_missing_body(self, client):
resp = client.post(
"/api/mesh/resolve-path",
json={},
content_type="application/json",
)
# Should return 400 or 200 with error key — not a 500
assert resp.status_code in (200, 400)
def test_api_mesh_resolve_path_valid(self, client):
resp = client.post(
"/api/mesh/resolve-path",
json={"path": "7e,01"},
content_type="application/json",
)
assert resp.status_code == 200
# ===========================================================================
# Config / notification routes
# ===========================================================================
class TestConfigRoutes:
def test_api_config_notifications_get(self, client):
resp = client.get("/api/config/notifications")
assert resp.status_code == 200
data = resp.get_json()
assert "smtp_port" in data
assert "smtp_security" in data
def test_api_config_notifications_post(self, client):
payload = {
"smtp_host": "smtp.example.com",
"smtp_port": "587",
"smtp_security": "starttls",
"smtp_user": "user@example.com",
"smtp_password": "pass",
"from_name": "Bot",
"from_email": "bot@example.com",
"recipients": "admin@example.com",
"nightly_enabled": "true",
}
resp = client.post(
"/api/config/notifications",
json=payload,
content_type="application/json",
)
assert resp.status_code == 200
data = resp.get_json()
assert data.get("success") is True
def test_api_config_logging_get(self, client):
resp = client.get("/api/config/logging")
assert resp.status_code == 200
data = resp.get_json()
assert isinstance(data, dict)
def test_api_config_maintenance_get(self, client):
resp = client.get("/api/config/maintenance")
assert resp.status_code == 200
def test_api_maintenance_status(self, client):
resp = client.get("/api/maintenance/status")
assert resp.status_code == 200
data = resp.get_json()
assert isinstance(data, dict)
# ===========================================================================
# Channel operations
# ===========================================================================
class TestChannelRoutes:
def test_api_channels_get(self, client):
resp = client.get("/api/channels")
assert resp.status_code == 200
def test_api_channel_stats(self, client):
resp = client.get("/api/channels/stats")
assert resp.status_code == 200
def test_api_channels_validate_missing_name(self, client):
resp = client.post(
"/api/channels/validate",
json={},
content_type="application/json",
)
assert resp.status_code in (200, 400)
def test_api_channel_operation_status_not_found(self, client):
resp = client.get("/api/channel-operations/99999")
assert resp.status_code in (200, 404)
# ===========================================================================
# Feeds routes
# ===========================================================================
class TestFeedRoutes:
def test_api_feeds_get(self, client):
resp = client.get("/api/feeds")
assert resp.status_code == 200
data = resp.get_json()
# Returns {'feeds': [...], 'total': N} or a plain list
assert isinstance(data, (dict, list))
def test_api_feeds_stats(self, client):
resp = client.get("/api/feeds/stats")
assert resp.status_code == 200
def test_api_feeds_default_format(self, client):
resp = client.get("/api/feeds/default-format")
assert resp.status_code == 200
def test_api_feed_not_found(self, client):
resp = client.get("/api/feeds/99999")
assert resp.status_code in (200, 404)
def test_api_feed_delete_not_found(self, client):
resp = client.delete("/api/feeds/99999")
assert resp.status_code in (200, 404)
# ===========================================================================
# Authentication (password-protected viewer)
# ===========================================================================
class TestAuthRoutes:
def test_login_page_get(self, auth_client):
resp = auth_client.get("/login")
assert resp.status_code == 200
def test_unauthenticated_index_redirects_to_login(self, auth_client):
resp = auth_client.get("/")
assert resp.status_code == 302
assert "login" in resp.headers["Location"]
def test_unauthenticated_api_returns_401(self, auth_client):
resp = auth_client.get("/api/health")
assert resp.status_code == 401
def test_login_wrong_password(self, auth_client):
resp = auth_client.post(
"/login",
data={"password": "wrongpass"},
follow_redirects=False,
)
assert resp.status_code == 200
assert b"Invalid" in resp.data
def test_login_correct_password_redirects(self, auth_client):
resp = auth_client.post(
"/login",
data={"password": "secret123"},
follow_redirects=False,
)
assert resp.status_code == 302
def test_authenticated_can_access_index(self, auth_client):
auth_client.post("/login", data={"password": "secret123"})
resp = auth_client.get("/")
assert resp.status_code == 200
def test_logout_clears_session(self, auth_client):
auth_client.post("/login", data={"password": "secret123"})
resp = auth_client.get("/logout", follow_redirects=False)
assert resp.status_code == 302
# After logout, index should redirect to login again
resp2 = auth_client.get("/")
assert resp2.status_code == 302
def test_login_no_password_configured_redirects_to_index(self, client):
"""When no password is set, /login should redirect to /."""
resp = client.get("/login", follow_redirects=False)
assert resp.status_code == 302
assert resp.headers["Location"].endswith("/")
# ===========================================================================
# Open-access routes (no auth required even with password enabled)
# ===========================================================================
class TestOpenRoutes:
def test_favicon_ico(self, client):
resp = client.get("/favicon.ico")
assert resp.status_code in (200, 404) # 404 if static file absent
def test_favicon_32(self, client):
resp = client.get("/favicon-32x32.png")
assert resp.status_code in (200, 404)
def test_favicon_16(self, client):
resp = client.get("/favicon-16x16.png")
assert resp.status_code in (200, 404)
def test_apple_touch_icon(self, client):
resp = client.get("/apple-touch-icon.png")
assert resp.status_code in (200, 404)
def test_site_webmanifest(self, client):
resp = client.get("/site.webmanifest")
assert resp.status_code in (200, 404)
def test_favicon_not_blocked_by_auth(self, auth_client):
resp = auth_client.get("/favicon.ico")
# Auth exempt — should NOT be 302/401
assert resp.status_code in (200, 404)
# ===========================================================================
# Recent commands / stream
# ===========================================================================
class TestStreamRoutes:
def test_api_recent_commands(self, client):
resp = client.get("/api/recent_commands")
assert resp.status_code == 200
def test_api_stream_data_post(self, client):
payload = {"type": "command", "data": {"cmd": "ping"}}
resp = client.post(
"/api/stream_data",
json=payload,
content_type="application/json",
)
assert resp.status_code == 200
# ===========================================================================
# Greeter routes
# ===========================================================================
class TestGreeterRoutes:
def test_api_greeter_get(self, client):
resp = client.get("/api/greeter")
assert resp.status_code == 200
def test_api_greeter_end_rollout(self, client):
resp = client.post(
"/api/greeter/end-rollout",
json={"public_key": "a" * 64},
content_type="application/json",
)
assert resp.status_code in (200, 400, 404, 500)
def test_api_greeter_ungreet(self, client):
resp = client.post(
"/api/greeter/ungreet",
json={"public_key": "a" * 64},
content_type="application/json",
)
assert resp.status_code in (200, 400, 404, 500)
# ===========================================================================
# Delete contact
# ===========================================================================
class TestDeleteContact:
def test_delete_contact_missing_key(self, client):
resp = client.post(
"/api/delete-contact",
json={},
content_type="application/json",
)
assert resp.status_code == 400
def test_delete_contact_unknown_key(self, client):
resp = client.post(
"/api/delete-contact",
json={"public_key": "0" * 64},
content_type="application/json",
)
assert resp.status_code in (200, 404)
def test_delete_contact_existing(self, client, viewer):
pk = _insert_contact(viewer, "deadbeef" * 8, "DeleteMe")
resp = client.post(
"/api/delete-contact",
json={"public_key": pk},
content_type="application/json",
)
assert resp.status_code == 200
data = resp.get_json()
assert data.get("success") is True
# ===========================================================================
# Geocode contact
# ===========================================================================
class TestGeocodeContact:
def test_geocode_missing_public_key(self, client):
resp = client.post(
"/api/geocode-contact",
json={},
content_type="application/json",
)
assert resp.status_code in (200, 400)
def test_geocode_unknown_contact(self, client):
resp = client.post(
"/api/geocode-contact",
json={"public_key": "0" * 64},
content_type="application/json",
)
assert resp.status_code in (200, 404)
# ===========================================================================
# Version info helper (unit test, no HTTP)
# ===========================================================================
class TestVersionInfo:
def test_version_info_structure(self, viewer):
info = viewer._version_info
assert isinstance(info, dict)
assert set(info.keys()) >= {"tag", "branch", "commit", "date"}
def test_version_info_returns_something(self, viewer):
# At least one field is populated in a git repo
info = viewer._version_info
assert any(v is not None for v in info.values())
# ===========================================================================
# Config loading helper (unit test)
# ===========================================================================
class TestConfigLoading:
def test_load_config_nonexistent_returns_empty(self, viewer):
cfg = viewer._load_config("/nonexistent/config.ini")
assert isinstance(cfg, configparser.ConfigParser)
def test_load_config_reads_values(self, viewer, tmp_path_factory):
tmp = tmp_path_factory.mktemp("cfg_load")
p = tmp / "cfg.ini"
db = str(tmp / "db.db")
_write_config(p, db)
cfg = viewer._load_config(str(p))
assert cfg.get("Bot", "bot_name") == "TestBot"
# ===========================================================================
# Config logging API
# ===========================================================================
class TestConfigLoggingRoutes:
def test_get_logging_returns_defaults(self, client):
resp = client.get("/api/config/logging")
assert resp.status_code == 200
data = json.loads(resp.data)
assert "log_max_bytes" in data
assert "log_backup_count" in data
def test_get_logging_default_values_populated(self, client):
resp = client.get("/api/config/logging")
data = json.loads(resp.data)
# Defaults should be non-empty strings
assert data["log_max_bytes"] != ""
assert data["log_backup_count"] != ""
def test_post_logging_saves_fields(self, client):
resp = client.post(
"/api/config/logging",
data=json.dumps({"log_max_bytes": "10485760", "log_backup_count": "5"}),
content_type="application/json",
)
assert resp.status_code == 200
data = json.loads(resp.data)
assert data["success"] is True
assert set(data["saved"]) == {"log_max_bytes", "log_backup_count"}
def test_post_logging_ignores_unknown_fields(self, client):
resp = client.post(
"/api/config/logging",
data=json.dumps({"unknown_field": "value"}),
content_type="application/json",
)
assert resp.status_code == 200
data = json.loads(resp.data)
assert data["saved"] == []
def test_post_logging_empty_body(self, client):
resp = client.post("/api/config/logging", content_type="application/json")
assert resp.status_code == 200
# ===========================================================================
# Config maintenance API
# ===========================================================================
class TestConfigMaintenanceRoutes:
def test_get_maintenance_returns_defaults(self, client):
resp = client.get("/api/config/maintenance")
assert resp.status_code == 200
data = json.loads(resp.data)
assert "db_backup_enabled" in data
assert "db_backup_schedule" in data
assert "db_backup_time" in data
def test_post_maintenance_saves_backup_settings(self, client):
payload = {
"db_backup_enabled": "true",
"db_backup_schedule": "weekly",
"db_backup_time": "03:00",
"db_backup_retention_count": "14",
"db_backup_dir": "/tmp", # /tmp always exists
"email_attach_log": "true",
}
resp = client.post(
"/api/config/maintenance",
data=json.dumps(payload),
content_type="application/json",
)
assert resp.status_code == 200
data = json.loads(resp.data)
assert data["success"] is True
assert len(data["saved"]) == 6
def test_post_maintenance_empty_body(self, client):
resp = client.post("/api/config/maintenance", content_type="application/json")
assert resp.status_code == 200
def test_get_maintenance_status(self, client):
resp = client.get("/api/maintenance/status")
assert resp.status_code == 200
data = json.loads(resp.data)
assert "data_retention_ran_at" in data
assert "nightly_email_ran_at" in data
assert "db_backup_ran_at" in data
# ===========================================================================
# Config notifications API
# ===========================================================================
class TestConfigNotificationsRoutes:
def test_get_notifications_returns_200(self, client):
resp = client.get("/api/config/notifications")
assert resp.status_code == 200
def test_post_notifications_test_returns_result(self, client):
resp = client.post(
"/api/config/notifications/test",
data=json.dumps({"type": "email"}),
content_type="application/json",
)
assert resp.status_code in (200, 400, 500)
# ===========================================================================
# Feed management API
# ===========================================================================
class TestFeedManagementRoutes:
def test_get_feeds_returns_200(self, client):
resp = client.get("/api/feeds")
assert resp.status_code == 200
def test_get_feed_stats(self, client):
resp = client.get("/api/feeds/stats")
assert resp.status_code == 200
def test_get_feed_detail_not_found(self, client):
resp = client.get("/api/feeds/99999")
assert resp.status_code in (404, 200)
def test_post_feeds_missing_data(self, client):
resp = client.post(
"/api/feeds",
data=json.dumps({}),
content_type="application/json",
)
# Should return 400 or 500 — invalid/empty feed
assert resp.status_code in (200, 400, 500)
def test_delete_feed_not_found(self, client):
resp = client.delete("/api/feeds/99999")
assert resp.status_code in (200, 404, 500)
def test_get_default_format(self, client):
resp = client.get("/api/feeds/default-format")
assert resp.status_code == 200
data = json.loads(resp.data)
assert "default_format" in data
def test_post_feeds_preview_missing_url(self, client):
resp = client.post(
"/api/feeds/preview",
data=json.dumps({}),
content_type="application/json",
)
assert resp.status_code == 400
def test_post_feeds_test_missing_url(self, client):
resp = client.post(
"/api/feeds/test",
data=json.dumps({}),
content_type="application/json",
)
assert resp.status_code == 400
def test_post_feeds_test_invalid_url(self, client):
resp = client.post(
"/api/feeds/test",
data=json.dumps({"url": "not-a-url"}),
content_type="application/json",
)
assert resp.status_code == 400
def test_post_feeds_test_valid_url_accepted(self, client):
resp = client.post(
"/api/feeds/test",
data=json.dumps({"url": "https://example.com/feed.rss"}),
content_type="application/json",
)
assert resp.status_code == 200
def test_get_feed_activity(self, client):
resp = client.get("/api/feeds/1/activity")
assert resp.status_code in (200, 404, 500)
def test_get_feed_errors(self, client):
resp = client.get("/api/feeds/1/errors")
assert resp.status_code in (200, 404, 500)
def test_post_feed_refresh(self, client):
resp = client.post("/api/feeds/1/refresh")
assert resp.status_code in (200, 404, 500)
# ===========================================================================
# Channel management API
# ===========================================================================
class TestChannelManagementRoutes:
def test_get_channels_returns_dict(self, client):
resp = client.get("/api/channels")
assert resp.status_code == 200
data = json.loads(resp.data)
assert "channels" in data
def test_post_channel_missing_name(self, client):
resp = client.post(
"/api/channels",
data=json.dumps({}),
content_type="application/json",
)
assert resp.status_code == 400
def test_delete_channel_not_found(self, client):
resp = client.delete("/api/channels/99")
assert resp.status_code in (200, 400, 404, 500)
def test_get_channel_operation_not_found(self, client):
resp = client.get("/api/channel-operations/99999")
assert resp.status_code in (200, 404, 500)
def test_post_channel_validate_missing_data(self, client):
resp = client.post(
"/api/channels/validate",
data=json.dumps({}),
content_type="application/json",
)
assert resp.status_code in (200, 400, 500)
def test_get_channel_stats(self, client):
resp = client.get("/api/channels/stats")
assert resp.status_code == 200
def test_get_channel_feeds(self, client):
resp = client.get("/api/channels/0/feeds")
assert resp.status_code in (200, 404, 500)
# ===========================================================================
# Optimize database API
# ===========================================================================
class TestDatabaseOptimizeRoute:
def test_post_optimize_database(self, client):
resp = client.post("/api/optimize-database")
assert resp.status_code in (200, 500)
# ===========================================================================
# Purge contacts API
# ===========================================================================
class TestPurgeContactsRoutes:
def test_get_purge_preview(self, client):
resp = client.get("/api/contacts/purge-preview")
assert resp.status_code in (200, 500)
def test_post_purge_empty_body(self, client):
resp = client.post(
"/api/contacts/purge",
data=json.dumps({}),
content_type="application/json",
)
assert resp.status_code in (200, 400, 500)
# ===========================================================================
# Mesh graph API routes
# ===========================================================================
class TestMeshGraphRoutes:
def test_get_mesh_nodes_returns_200(self, client):
resp = client.get("/api/mesh/nodes")
assert resp.status_code == 200
data = json.loads(resp.data)
assert "nodes" in data
def test_get_mesh_nodes_with_prefix_param(self, client):
resp = client.get("/api/mesh/nodes?prefix_hex_chars=4")
assert resp.status_code == 200
def test_get_mesh_edges_returns_200(self, client):
resp = client.get("/api/mesh/edges")
assert resp.status_code == 200
data = json.loads(resp.data)
assert "edges" in data
def test_get_mesh_edges_with_filter_params(self, client):
resp = client.get("/api/mesh/edges?min_observations=2&days=7")
assert resp.status_code == 200
def test_get_mesh_stats_returns_200(self, client):
resp = client.get("/api/mesh/stats")
assert resp.status_code == 200
data = json.loads(resp.data)
assert "node_count" in data
assert "total_edges" in data
def test_post_resolve_path_missing_body(self, client):
resp = client.post("/api/mesh/resolve-path", content_type="application/json")
assert resp.status_code in (400, 500)
def test_post_resolve_path_missing_path_field(self, client):
resp = client.post(
"/api/mesh/resolve-path",
data=json.dumps({}),
content_type="application/json",
)
assert resp.status_code == 400
def test_post_resolve_path_with_hex(self, client):
resp = client.post(
"/api/mesh/resolve-path",
data=json.dumps({"path": "aabbccdd", "prefix_hex_chars": 2}),
content_type="application/json",
)
assert resp.status_code in (200, 400, 500)
# ===========================================================================
# Radio API routes
# ===========================================================================
class TestRadioApiRoutes:
def test_get_radio_status_returns_200(self, client):
resp = client.get("/api/radio/status")
assert resp.status_code == 200
data = json.loads(resp.data)
assert "connected" in data or "error" in data
def test_post_radio_reboot_queues_operation(self, client):
resp = client.post("/api/radio/reboot")
assert resp.status_code in (200, 500)
if resp.status_code == 200:
data = json.loads(resp.data)
assert data.get("success") is True
def test_post_radio_connect_missing_action(self, client):
resp = client.post(
"/api/radio/connect",
data=json.dumps({}),
content_type="application/json",
)
assert resp.status_code == 400
def test_post_radio_connect_invalid_action(self, client):
resp = client.post(
"/api/radio/connect",
data=json.dumps({"action": "restart"}),
content_type="application/json",
)
assert resp.status_code == 400
def test_post_radio_connect_valid(self, client):
resp = client.post(
"/api/radio/connect",
data=json.dumps({"action": "connect"}),
content_type="application/json",
)
assert resp.status_code in (200, 500)
def test_post_radio_disconnect_valid(self, client):
resp = client.post(
"/api/radio/connect",
data=json.dumps({"action": "disconnect"}),
content_type="application/json",
)
assert resp.status_code in (200, 500)
# ===========================================================================
# Greeter API route
# ===========================================================================
class TestGreeterRoute:
def test_get_greeter_returns_200(self, client):
resp = client.get("/api/greeter")
assert resp.status_code == 200
def test_post_greeter_end_rollout(self, client):
resp = client.post("/api/greeter/end-rollout", content_type="application/json")
assert resp.status_code in (200, 400, 404, 500)
def test_post_greeter_ungreet(self, client):
resp = client.post(
"/api/greeter/ungreet",
data=json.dumps({"sender_id": "TestUser"}),
content_type="application/json",
)
assert resp.status_code in (200, 400, 404, 500)
# ===========================================================================
# Stream data and recent commands
# ===========================================================================
class TestStreamAndCommandRoutes:
def test_get_recent_commands_returns_200(self, client):
resp = client.get("/api/recent_commands")
assert resp.status_code == 200
def test_post_stream_data_empty_body(self, client):
resp = client.post("/api/stream_data", content_type="application/json")
assert resp.status_code in (200, 400, 500)
# ===========================================================================
# Rate limiter stats API
# ===========================================================================
class TestRateLimiterStatsRoute:
def test_get_rate_limiter_stats_returns_200(self, client):
resp = client.get("/api/stats/rate_limiters")
assert resp.status_code == 200
def test_rate_limiter_stats_returns_dict(self, client):
resp = client.get("/api/stats/rate_limiters")
assert resp.status_code == 200
data = resp.get_json()
assert isinstance(data, dict)
def test_rate_limiter_stats_no_bot(self, viewer):
"""When viewer has no bot attribute the endpoint returns an empty dict."""
# Standalone viewer has no .bot — just use the test client directly
with viewer.app.test_client() as c:
resp = c.get("/api/stats/rate_limiters")
assert resp.status_code == 200
assert resp.get_json() == {}
# ---------------------------------------------------------------------------
# Werkzeug WebSocket compatibility patch
# ---------------------------------------------------------------------------
class TestWerkzeugWebSocketFix:
"""_apply_werkzeug_websocket_fix patches SimpleWebSocketWSGI.__call__ so
that Werkzeug's write() before start_response assertion is never raised
when a WebSocket session ends normally."""
def test_patch_is_applied_at_module_import(self):
"""SimpleWebSocketWSGI.__call__ should be our patched wrapper after
importing app.py (which calls _apply_werkzeug_websocket_fix at import
time)."""
from engineio.async_drivers import _websocket_wsgi
# The patch wraps __call__; the closure name reflects the patch.
assert _websocket_wsgi.SimpleWebSocketWSGI.__call__.__name__ == '_patched_call'
def test_patch_calls_start_response_after_handler(self):
"""After the underlying __call__ returns, the patch must invoke
start_response so that status_set is not None when Werkzeug's
write(b'') runs."""
from engineio.async_drivers import _websocket_wsgi
sr_calls = []
def fake_start_response(status, headers, exc_info=None):
sr_calls.append((status, headers))
return lambda data: None
# Build a minimal mock SimpleWebSocketWSGI instance where __call__
# returns [] (as _websocket_handler does on teardown).
from unittest.mock import MagicMock
from unittest.mock import patch as mock_patch
ws_instance = MagicMock(spec=_websocket_wsgi.SimpleWebSocketWSGI)
# Temporarily restore a fake "original" __call__ that returns []
with mock_patch.object(
_websocket_wsgi.SimpleWebSocketWSGI,
'__call__',
new=_websocket_wsgi.SimpleWebSocketWSGI.__call__,
):
# The real patched __call__ is already in place; call it with a
# mock "inner" that returns [] without calling start_response.
from modules.web_viewer.app import _apply_werkzeug_websocket_fix
captured = {}
def mock_orig(self, environ, start_response):
captured['sr_called'] = False
return []
orig = _websocket_wsgi.SimpleWebSocketWSGI.__call__
_websocket_wsgi.SimpleWebSocketWSGI.__call__ = mock_orig
try:
_apply_werkzeug_websocket_fix()
result = _websocket_wsgi.SimpleWebSocketWSGI.__call__(
ws_instance, {}, fake_start_response
)
finally:
_websocket_wsgi.SimpleWebSocketWSGI.__call__ = orig
assert result == []
# start_response must have been called by the patch
assert len(sr_calls) == 1
assert sr_calls[0][0] == '200 OK'
def test_patch_tolerates_start_response_already_called(self):
"""If start_response was already called (e.g. error path), the patch
must not propagate the 'Headers already set' AssertionError."""
from unittest.mock import MagicMock
from engineio.async_drivers import _websocket_wsgi
from modules.web_viewer.app import _apply_werkzeug_websocket_fix
call_count = [0]
def raises_on_second(status, headers, exc_info=None):
call_count[0] += 1
if call_count[0] > 1:
raise AssertionError("Headers already set")
return lambda data: None
ws_instance = MagicMock(spec=_websocket_wsgi.SimpleWebSocketWSGI)
def mock_orig_already_called(self, environ, start_response):
start_response('500 INTERNAL SERVER ERROR', [])
return []
orig = _websocket_wsgi.SimpleWebSocketWSGI.__call__
_websocket_wsgi.SimpleWebSocketWSGI.__call__ = mock_orig_already_called
try:
_apply_werkzeug_websocket_fix()
# Must not raise even though start_response throws on second call
result = _websocket_wsgi.SimpleWebSocketWSGI.__call__(
ws_instance, {}, raises_on_second
)
finally:
_websocket_wsgi.SimpleWebSocketWSGI.__call__ = orig
assert result == []
def test_patch_is_idempotent(self):
"""Calling _apply_werkzeug_websocket_fix() twice must not double-wrap
and must leave the patched callable working correctly."""
from engineio.async_drivers import _websocket_wsgi
from modules.web_viewer.app import _apply_werkzeug_websocket_fix
sr_calls = []
def fake_sr(status, headers, exc_info=None):
sr_calls.append(status)
return lambda data: None
# Apply a second time
_apply_werkzeug_websocket_fix()
from unittest.mock import MagicMock
ws_instance = MagicMock(spec=_websocket_wsgi.SimpleWebSocketWSGI)
# Temporarily replace the inner with a simple stub
def stub_orig(self, environ, start_response):
return []
orig = _websocket_wsgi.SimpleWebSocketWSGI.__call__
_websocket_wsgi.SimpleWebSocketWSGI.__call__ = stub_orig
try:
_apply_werkzeug_websocket_fix()
_websocket_wsgi.SimpleWebSocketWSGI.__call__(
ws_instance, {}, fake_sr
)
finally:
_websocket_wsgi.SimpleWebSocketWSGI.__call__ = orig
# Exactly one start_response call regardless of how many times patch applied
assert len(sr_calls) == 1
def test_patch_handles_missing_engineio(self):
"""_apply_werkzeug_websocket_fix must not raise if engineio is absent."""
import sys
from unittest.mock import patch as mock_patch
from modules.web_viewer.app import _apply_werkzeug_websocket_fix
with mock_patch.dict(sys.modules, {'engineio.async_drivers._websocket_wsgi': None}):
# Should be a no-op, not raise
_apply_werkzeug_websocket_fix()
# ===========================================================================
# TASK-01: Radio page — firmware config + reboot UI removed
# ===========================================================================
class TestRadioPageFirmwareRemoval:
"""Assert that firmware config and reboot UI are absent from /radio (TASK-01)."""
def test_radio_page_loads(self, client):
resp = client.get("/radio")
assert resp.status_code == 200
def test_firmware_config_card_absent(self, client):
resp = client.get("/radio")
html = resp.data.decode()
assert 'id="firmware-config"' not in html
assert "readFirmwareConfig" not in html
assert "writeFirmwareConfig" not in html
assert "readFirmwareBtn" not in html
assert "writeFirmwareBtn" not in html
assert "firmwareStatusAlert" not in html
assert "firmwareLastRead" not in html
assert "Firmware Configuration" not in html
def test_reboot_ui_absent(self, client):
resp = client.get("/radio")
html = resp.data.decode()
assert "rebootRadioBtn" not in html
assert "rebootConfirmModal" not in html
assert "confirmRebootBtn" not in html
assert "confirmReboot" not in html
assert "rebootRadio" not in html
assert "handleReboot" not in html
def test_connect_section_present(self, client):
"""Connect/disconnect button must still be present after removal."""
resp = client.get("/radio")
html = resp.data.decode()
assert "connectToggleBtn" in html
assert "handleConnectToggle" in html
# ===========================================================================
# TASK-02: subscribe_commands history replay (BUG-023)
# ===========================================================================
def _insert_packet_stream_rows(db_path: str, rows: list) -> None:
"""Insert rows into packet_stream for testing. Each row: (timestamp, data_json, type)."""
with closing(sqlite3.connect(db_path)) as conn:
conn.execute(
"CREATE TABLE IF NOT EXISTS packet_stream"
" (id INTEGER PRIMARY KEY, timestamp REAL, data TEXT, type TEXT)"
)
for i, (ts, data_json, row_type) in enumerate(rows):
conn.execute(
"INSERT INTO packet_stream (timestamp, data, type) VALUES (?, ?, ?)",
(ts, data_json, row_type),
)
conn.commit()
@pytest.fixture
def socketio_viewer(tmp_path_factory):
"""Isolated viewer fixture for SocketIO event tests."""
from unittest.mock import patch as _patch
tmp = tmp_path_factory.mktemp("sio_viewer")
db_path = str(tmp / "sio_test.db")
config_path = str(tmp / "config.ini")
_write_config(Path(config_path), db_path)
with (
_patch.object(BotDataViewer, "_setup_logging", _fake_setup_logging),
_patch.object(BotDataViewer, "_start_database_polling", lambda self: None),
_patch.object(BotDataViewer, "_start_log_tailing", lambda self: None),
_patch.object(BotDataViewer, "_start_cleanup_scheduler", lambda self: None),
):
v = BotDataViewer(db_path=db_path, config_path=config_path)
v.app.config["TESTING"] = True
v.app.config["SECRET_KEY"] = "test-secret"
return v
class TestSubscribeCommandsHistoryReplay:
"""subscribe_commands must replay last 50 command rows on connect (TASK-02 / BUG-023)."""
def test_subscribe_commands_replays_history(self, socketio_viewer):
"""History rows are emitted as command_data events on subscribe."""
import json as _json
import time as _time
from flask_socketio import SocketIOTestClient
now = _time.time()
# Give each row a distinct timestamp so ORDER BY is deterministic
rows = [
(now - 50 + i, _json.dumps({"cmd": "ping", "seq": i}), "command")
for i in range(5)
]
_insert_packet_stream_rows(socketio_viewer.db_path, rows)
sio_client = SocketIOTestClient(
socketio_viewer.app, socketio_viewer.socketio
)
sio_client.emit("subscribe_commands")
received = sio_client.get_received()
command_events = [e for e in received if e["name"] == "command_data"]
assert len(command_events) == 5
seq_values = [e["args"][0]["seq"] for e in command_events]
assert seq_values == list(range(5)) # replayed in chronological order
def test_subscribe_commands_sets_subscription_flag(self, socketio_viewer):
"""subscribed_commands flag is set to True after subscribe event."""
from flask_socketio import SocketIOTestClient
sio_client = SocketIOTestClient(
socketio_viewer.app, socketio_viewer.socketio
)
sio_client.emit("subscribe_commands")
with socketio_viewer._clients_lock:
flags = [
info.get("subscribed_commands", False)
for info in socketio_viewer.connected_clients.values()
]
assert any(flags), "At least one client should have subscribed_commands=True"
def test_subscribe_commands_empty_history(self, socketio_viewer):
"""subscribe_commands with no history emits only status event, no command_data."""
from flask_socketio import SocketIOTestClient
sio_client = SocketIOTestClient(
socketio_viewer.app, socketio_viewer.socketio
)
sio_client.emit("subscribe_commands")
received = sio_client.get_received()
command_events = [e for e in received if e["name"] == "command_data"]
assert len(command_events) == 0
def test_subscribe_commands_only_replays_command_type(self, socketio_viewer):
"""Only rows with type='command' are replayed — not packets or messages."""
import json as _json
import time as _time
from flask_socketio import SocketIOTestClient
now = _time.time()
rows = [
(now - 5, _json.dumps({"t": "cmd"}), "command"),
(now - 4, _json.dumps({"t": "pkt"}), "packet"),
(now - 3, _json.dumps({"t": "msg"}), "message"),
]
_insert_packet_stream_rows(socketio_viewer.db_path, rows)
sio_client = SocketIOTestClient(
socketio_viewer.app, socketio_viewer.socketio
)
sio_client.emit("subscribe_commands")
received = sio_client.get_received()
command_events = [e for e in received if e["name"] == "command_data"]
assert len(command_events) == 1
assert command_events[0]["args"][0]["t"] == "cmd"
def test_polling_thread_last_timestamp_is_recent(self, socketio_viewer):
"""_start_database_polling initializes last_timestamp ~5 min back, not epoch 0."""
import inspect
# Extract poll_database source from _start_database_polling closure
src = inspect.getsource(socketio_viewer._start_database_polling)
# The source should reference time.time() - 300, not "= 0"
assert "time() - 300" in src or "_time.time() - 300" in src, (
"last_timestamp must be initialized to time.time()-300, not 0"
)
# ---------------------------------------------------------------------------
# TASK-03: GET /api/connected_clients
# ---------------------------------------------------------------------------
class TestConnectedClientsApi:
"""Tests for GET /api/connected_clients endpoint."""
def test_empty_when_no_clients(self, viewer):
"""Returns empty list when no clients connected."""
viewer.connected_clients.clear()
with viewer.app.test_client() as c:
resp = c.get("/api/connected_clients")
assert resp.status_code == 200
data = resp.get_json()
assert data == []
def test_returns_client_list(self, viewer):
"""Returns list with client_id, connected_at, last_activity fields."""
import time
now = time.time()
viewer.connected_clients["abcdef1234567890"] = {
"connected_at": now - 60,
"last_activity": now - 5,
"subscribed_commands": False,
}
try:
with viewer.app.test_client() as c:
resp = c.get("/api/connected_clients")
assert resp.status_code == 200
data = resp.get_json()
assert isinstance(data, list)
assert len(data) == 1
entry = data[0]
assert "client_id" in entry
assert "connected_at" in entry
assert "last_activity" in entry
# ID is truncated to first 8 chars + ellipsis
assert entry["client_id"] == "abcdef12\u2026"
assert abs(entry["connected_at"] - (now - 60)) < 1
assert abs(entry["last_activity"] - (now - 5)) < 1
finally:
viewer.connected_clients.pop("abcdef1234567890", None)
def test_multiple_clients(self, viewer):
"""Returns all connected clients."""
import time
now = time.time()
viewer.connected_clients["aaa"] = {"connected_at": now, "last_activity": now}
viewer.connected_clients["bbb"] = {"connected_at": now, "last_activity": now}
try:
with viewer.app.test_client() as c:
resp = c.get("/api/connected_clients")
assert resp.status_code == 200
data = resp.get_json()
ids = [entry["client_id"] for entry in data]
assert "aaa" in ids
assert "bbb" in ids
finally:
viewer.connected_clients.pop("aaa", None)
viewer.connected_clients.pop("bbb", None)
def test_short_id_not_truncated(self, viewer):
"""Client IDs of 8 chars or fewer are returned as-is."""
import time
now = time.time()
viewer.connected_clients["short"] = {"connected_at": now, "last_activity": now}
try:
with viewer.app.test_client() as c:
resp = c.get("/api/connected_clients")
assert resp.status_code == 200
data = resp.get_json()
match = [entry for entry in data if entry["client_id"] == "short"]
assert len(match) == 1
finally:
viewer.connected_clients.pop("short", None)
def test_dashboard_contains_modal_and_link(self, viewer):
"""Dashboard page includes the connected-clients modal and clickable link."""
with viewer.app.test_client() as c:
resp = c.get("/")
html = resp.data.decode()
assert "connectedClientsModal" in html
assert "connected-clients-table" in html
assert "loadConnectedClients" in html
# ---------------------------------------------------------------------------
# TASK-04: DB backup dir validation on POST /api/config/maintenance
# ---------------------------------------------------------------------------
class TestDbBackupDirValidation:
"""Tests for backup directory validation in POST /api/config/maintenance."""
def test_nonexistent_dir_returns_400(self, viewer):
"""Returns 400 with error message when backup_dir does not exist."""
with viewer.app.test_client() as c:
resp = c.post(
"/api/config/maintenance",
json={"db_backup_dir": "/nonexistent/path/that/does/not/exist"},
content_type="application/json",
)
assert resp.status_code == 400
data = resp.get_json()
assert "error" in data
assert "/nonexistent/path/that/does/not/exist" in data["error"]
def test_existing_dir_returns_200(self, viewer, tmp_path):
"""Returns 200 when backup_dir exists."""
with viewer.app.test_client() as c:
resp = c.post(
"/api/config/maintenance",
json={"db_backup_dir": str(tmp_path)},
content_type="application/json",
)
assert resp.status_code == 200
data = resp.get_json()
assert data["success"] is True
assert "db_backup_dir" in data["saved"]
def test_empty_dir_skips_validation(self, viewer):
"""Empty string for db_backup_dir skips the isdir check and saves."""
with viewer.app.test_client() as c:
resp = c.post(
"/api/config/maintenance",
json={"db_backup_dir": ""},
content_type="application/json",
)
assert resp.status_code == 200
def test_other_fields_save_when_no_dir(self, viewer):
"""Other maintenance fields save normally when db_backup_dir is absent."""
with viewer.app.test_client() as c:
resp = c.post(
"/api/config/maintenance",
json={"db_backup_enabled": "true", "db_backup_schedule": "daily"},
content_type="application/json",
)
assert resp.status_code == 200
data = resp.get_json()
assert "db_backup_enabled" in data["saved"]
assert "db_backup_schedule" in data["saved"]
def test_error_message_contains_path(self, viewer):
"""Error message specifically mentions the invalid path."""
bad_path = "/absolutely/does/not/exist/xyz"
with viewer.app.test_client() as c:
resp = c.post(
"/api/config/maintenance",
json={"db_backup_dir": bad_path},
content_type="application/json",
)
assert resp.status_code == 400
assert bad_path in resp.get_json()["error"]
# ---------------------------------------------------------------------------
# TASK-06: POST /api/maintenance/backup_now
# ---------------------------------------------------------------------------
class TestBackupNowRoute:
"""Tests for POST /api/maintenance/backup_now endpoint."""
def test_returns_503_when_no_scheduler(self, viewer):
"""Returns 503 when bot/scheduler is not attached."""
# viewer fixture has no bot attached
with viewer.app.test_client() as c:
resp = c.post("/api/maintenance/backup_now")
assert resp.status_code == 503
data = resp.get_json()
assert data["success"] is False
assert "Scheduler not available" in data["error"]
def test_returns_200_on_successful_backup(self, viewer, tmp_path):
"""Returns 200 with success=True and path when backup succeeds."""
from unittest.mock import MagicMock, patch
mock_scheduler = MagicMock()
def fake_run_db_backup():
# Simulate what run_db_backup writes to metadata
viewer.db_manager.set_metadata(
'maint.status.db_backup_path', str(tmp_path / "test.db")
)
viewer.db_manager.set_metadata('maint.status.db_backup_outcome', 'ok')
mock_scheduler.run_db_backup = fake_run_db_backup
mock_bot = MagicMock()
mock_bot.scheduler = mock_scheduler
with patch.object(viewer, 'bot', mock_bot, create=True):
with viewer.app.test_client() as c:
resp = c.post("/api/maintenance/backup_now")
assert resp.status_code == 200
data = resp.get_json()
assert data["success"] is True
assert "test.db" in data["path"]
def test_returns_500_on_backup_error(self, viewer):
"""Returns 500 with success=False when backup writes an error outcome."""
from unittest.mock import MagicMock, patch
mock_scheduler = MagicMock()
def fake_run_db_backup():
viewer.db_manager.set_metadata('maint.status.db_backup_path', '')
viewer.db_manager.set_metadata(
'maint.status.db_backup_outcome', 'error: cannot create dir'
)
mock_scheduler.run_db_backup = fake_run_db_backup
mock_bot = MagicMock()
mock_bot.scheduler = mock_scheduler
with patch.object(viewer, 'bot', mock_bot, create=True):
with viewer.app.test_client() as c:
resp = c.post("/api/maintenance/backup_now")
assert resp.status_code == 500
data = resp.get_json()
assert data["success"] is False
def test_config_page_contains_backup_now_button(self, viewer):
"""Config page HTML includes the Backup Now button."""
with viewer.app.test_client() as c:
resp = c.get("/config")
html = resp.data.decode()
assert "backup-now-btn" in html
assert "backup_now" in html
# ---------------------------------------------------------------------------
# TASK-07: POST /api/maintenance/restore + GET /api/maintenance/list_backups
# ---------------------------------------------------------------------------
class TestRestoreRoute:
"""Tests for the DB restore endpoint."""
def test_missing_db_file_returns_400(self, viewer):
"""Returns 400 when db_file is absent."""
with viewer.app.test_client() as c:
resp = c.post("/api/maintenance/restore", json={},
content_type="application/json")
assert resp.status_code == 400
assert "db_file" in resp.get_json()["error"]
def test_nonexistent_file_returns_400(self, viewer):
"""Returns 400 when db_file path does not exist."""
with viewer.app.test_client() as c:
resp = c.post("/api/maintenance/restore",
json={"db_file": "/no/such/file.db"},
content_type="application/json")
assert resp.status_code == 400
assert "not found" in resp.get_json()["error"].lower()
def test_non_sqlite_file_returns_400(self, viewer, tmp_path):
"""Returns 400 when the file is not a valid SQLite database."""
bad = tmp_path / "bad.db"
bad.write_bytes(b"not a sqlite file!!")
with viewer.app.test_client() as c:
resp = c.post("/api/maintenance/restore",
json={"db_file": str(bad)},
content_type="application/json")
assert resp.status_code == 400
assert "valid SQLite" in resp.get_json()["error"]
def test_valid_sqlite_restore_returns_200(self, viewer, tmp_path):
"""Returns 200 with warning when a valid SQLite backup is restored."""
import sqlite3 as _sql
backup = tmp_path / "backup.db"
conn = _sql.connect(str(backup))
conn.execute("CREATE TABLE t (id INTEGER)")
conn.commit()
conn.close()
# Patch db_path to a temp destination so the real test DB is not overwritten
dest = str(tmp_path / "restored.db")
with patch.object(viewer, "db_path", dest):
with viewer.app.test_client() as c:
resp = c.post("/api/maintenance/restore",
json={"db_file": str(backup)},
content_type="application/json")
assert resp.status_code == 200
data = resp.get_json()
assert data["success"] is True
assert "warning" in data
assert "Restart" in data["warning"]
def test_list_backups_empty_when_no_dir(self, viewer):
"""list_backups returns empty list when backup dir not configured."""
viewer.db_manager.set_metadata('maint.db_backup_dir', '')
with viewer.app.test_client() as c:
resp = c.get("/api/maintenance/list_backups")
assert resp.status_code == 200
assert resp.get_json()["backups"] == []
def test_list_backups_returns_files(self, viewer, tmp_path):
"""list_backups returns matching .db files from the backup directory."""
import sqlite3 as _sql
db_stem = Path(viewer.db_path).stem
for i in range(2):
f = tmp_path / f"{db_stem}_2026010{i}T000000.db"
conn = _sql.connect(str(f))
conn.execute("CREATE TABLE t (id INTEGER)")
conn.commit()
conn.close()
viewer.db_manager.set_metadata('maint.db_backup_dir', str(tmp_path))
with viewer.app.test_client() as c:
resp = c.get("/api/maintenance/list_backups")
assert resp.status_code == 200
data = resp.get_json()
assert len(data["backups"]) == 2
assert all("path" in b and "size_mb" in b for b in data["backups"])
def test_config_page_contains_restore_modal(self, viewer):
"""Config page HTML includes the restore modal and button."""
with viewer.app.test_client() as c:
resp = c.get("/config")
html = resp.data.decode()
assert "restoreModal" in html
assert "restore-btn" in html
assert "restore-db-path" in html
# ---------------------------------------------------------------------------
# TASK-08: Database purge by age
# ---------------------------------------------------------------------------
class TestPurgeRoute:
"""Tests for POST /api/maintenance/purge."""
def test_keep_all_returns_empty_deleted(self, viewer):
"""keep_days='all' returns 200 with empty deleted dict."""
with viewer.app.test_client() as c:
resp = c.post("/api/maintenance/purge",
json={"keep_days": "all"},
content_type="application/json")
assert resp.status_code == 200
data = resp.get_json()
assert data["deleted"] == {}
def test_invalid_keep_days_returns_400(self, viewer):
"""keep_days=3 (not in valid set) returns 400."""
with viewer.app.test_client() as c:
resp = c.post("/api/maintenance/purge",
json={"keep_days": 3},
content_type="application/json")
assert resp.status_code == 400
assert "keep_days" in resp.get_json()["error"]
def test_non_integer_keep_days_returns_400(self, viewer):
"""keep_days='invalid' returns 400."""
with viewer.app.test_client() as c:
resp = c.post("/api/maintenance/purge",
json={"keep_days": "invalid"},
content_type="application/json")
assert resp.status_code == 400
def test_valid_keep_days_returns_deleted_counts(self, viewer):
"""keep_days=30 returns 200 with per-table deleted counts."""
with viewer.app.test_client() as c:
resp = c.post("/api/maintenance/purge",
json={"keep_days": 30},
content_type="application/json")
assert resp.status_code == 200
data = resp.get_json()
assert "deleted" in data
# All six purgeable tables should be present in the response
expected_tables = {
"packet_stream", "message_stats", "complete_contact_tracking",
"purging_log", "mesh_connections", "daily_stats",
}
assert expected_tables == set(data["deleted"].keys())
def test_all_valid_keep_days_values_accepted(self, viewer):
"""All documented valid keep_days values (1,7,14,30,60,90) return 200."""
for days in [1, 7, 14, 30, 60, 90]:
with viewer.app.test_client() as c:
resp = c.post("/api/maintenance/purge",
json={"keep_days": days},
content_type="application/json")
assert resp.status_code == 200, f"Failed for keep_days={days}"
def test_old_rows_deleted_recent_rows_kept(self, viewer):
"""Rows older than cutoff are deleted; recent rows are kept."""
import sqlite3 as _sql
import time as _time
old_ts = _time.time() - 40 * 86400 # 40 days ago
new_ts = _time.time() - 1 * 86400 # 1 day ago
# Insert one old and one new row into packet_stream
with _sql.connect(viewer.db_path) as conn:
conn.execute(
"INSERT INTO packet_stream (timestamp, data, type) VALUES (?, ?, ?)",
(old_ts, '{"test": "old"}', "test"),
)
conn.execute(
"INSERT INTO packet_stream (timestamp, data, type) VALUES (?, ?, ?)",
(new_ts, '{"test": "new"}', "test"),
)
conn.commit()
with viewer.app.test_client() as c:
resp = c.post("/api/maintenance/purge",
json={"keep_days": 30},
content_type="application/json")
assert resp.status_code == 200
data = resp.get_json()
# At least the one old packet_stream row should be deleted
assert data["deleted"]["packet_stream"] >= 1
# Recent row must still be present
with _sql.connect(viewer.db_path) as conn:
row = conn.execute(
"SELECT COUNT(*) FROM packet_stream WHERE data = ?",
('{"test": "new"}',),
).fetchone()
assert row[0] == 1
def test_config_page_contains_purge_card(self, viewer):
"""Config page HTML includes the purge card and confirmation modal."""
with viewer.app.test_client() as c:
resp = c.get("/config")
html = resp.data.decode()
assert "purgeModal" in html
assert "purge-keep-days" in html
assert "purge-confirm-btn" in html
# ---------------------------------------------------------------------------
# TASK-09: BotIntegration write queue (batch packet_stream inserts)
# ---------------------------------------------------------------------------
class TestBotIntegrationQueue:
"""Tests for BotIntegration's batched packet_stream write queue."""
@pytest.fixture()
def bot_integration(self, tmp_path):
"""Create a BotIntegration with a real temp SQLite DB and a fake bot."""
import configparser as _cp
from unittest.mock import MagicMock
db_path = str(tmp_path / "test.db")
cfg = _cp.ConfigParser()
cfg["Connection"] = {"connection_type": "serial", "serial_port": "/dev/null"}
cfg["Bot"] = {"bot_name": "Test", "db_path": db_path, "prefix_bytes": "1"}
bot = MagicMock()
bot.logger = logging.getLogger("test_integration")
bot.logger.addHandler(logging.NullHandler())
bot.config = cfg
bot.bot_root = str(tmp_path)
# Ensure schema exists (packet_stream is migration-owned).
from modules.db_manager import DBManager
class MinimalBot:
def __init__(self, logger, config):
self.logger = logger
self.config = config
bot.db_manager = DBManager(MinimalBot(bot.logger, cfg), db_path)
from modules.web_viewer.integration import BotIntegration
bi = BotIntegration(bot)
yield bi
bi.shutdown()
def test_insert_queues_row_without_db_open(self, bot_integration):
"""_insert_packet_stream_row puts item in queue, does not open DB immediately."""
# Queue should start empty
assert bot_integration._write_queue.empty()
bot_integration._insert_packet_stream_row('{"x":1}', 'packet')
assert bot_integration._write_queue.qsize() == 1
def test_flush_writes_row_to_db(self, bot_integration, tmp_path):
"""_flush_write_queue inserts queued rows into packet_stream."""
bot_integration._insert_packet_stream_row('{"test":"flush"}', 'packet')
bot_integration._flush_write_queue()
db_path = bot_integration._get_web_viewer_db_path()
with sqlite3.connect(db_path) as conn:
rows = conn.execute(
"SELECT data, type FROM packet_stream WHERE type='packet'"
).fetchall()
assert any(r[0] == '{"test":"flush"}' for r in rows)
def test_flush_batches_multiple_rows(self, bot_integration):
"""_flush_write_queue inserts multiple queued rows in one transaction."""
for i in range(5):
bot_integration._insert_packet_stream_row(f'{{"n":{i}}}', 'command')
assert bot_integration._write_queue.qsize() == 5
bot_integration._flush_write_queue()
assert bot_integration._write_queue.empty()
db_path = bot_integration._get_web_viewer_db_path()
with sqlite3.connect(db_path) as conn:
count = conn.execute(
"SELECT COUNT(*) FROM packet_stream WHERE type='command'"
).fetchone()[0]
assert count == 5
def test_flush_empty_queue_is_noop(self, bot_integration):
"""_flush_write_queue on empty queue does not raise."""
assert bot_integration._write_queue.empty()
bot_integration._flush_write_queue() # should not raise
def test_shutdown_flushes_remaining_rows(self, bot_integration):
"""shutdown() flushes rows that were queued but not yet drained."""
# Stop the drain thread early so rows stay in queue
bot_integration._drain_stop.set()
if bot_integration._drain_thread:
bot_integration._drain_thread.join(timeout=2.0)
bot_integration._insert_packet_stream_row('{"shutdown":"test"}', 'message')
assert not bot_integration._write_queue.empty()
# shutdown() must flush the remaining row
bot_integration.shutdown()
db_path = bot_integration._get_web_viewer_db_path()
with sqlite3.connect(db_path) as conn:
rows = conn.execute(
"SELECT data FROM packet_stream WHERE type='message'"
).fetchall()
assert any(r[0] == '{"shutdown":"test"}' for r in rows)
def test_drain_thread_is_running(self, bot_integration):
"""Drain thread is alive after construction."""
assert bot_integration._drain_thread is not None
assert bot_integration._drain_thread.is_alive()
# ===========================================================================
# PUT /api/channels/<idx> — update channel endpoint
# ===========================================================================
class TestUpdateChannelRoute:
"""Tests for PUT /api/channels/<channel_idx>."""
def test_update_channel_no_body_returns_400(self, viewer):
with viewer.app.test_client() as c:
resp = c.put(
"/api/channels/0",
json={},
content_type="application/json",
)
assert resp.status_code == 400
data = resp.get_json()
assert "error" in data
def test_update_channel_with_body_returns_200(self, viewer):
with viewer.app.test_client() as c:
resp = c.put(
"/api/channels/0",
json={"name": "#newname"},
content_type="application/json",
)
assert resp.status_code == 200
data = resp.get_json()
assert data["success"] is True
def test_update_channel_index_5_returns_200(self, viewer):
with viewer.app.test_client() as c:
resp = c.put(
"/api/channels/5",
json={"name": "#updated"},
content_type="application/json",
)
assert resp.status_code == 200
# ===========================================================================
# POST /api/channels — create channel validation branches
# ===========================================================================
class TestCreateChannelValidation:
"""Additional POST /api/channels validation paths not covered elsewhere."""
def test_empty_name_returns_400(self, viewer):
with viewer.app.test_client() as c:
resp = c.post(
"/api/channels",
json={"name": " "},
content_type="application/json",
)
assert resp.status_code == 400
data = resp.get_json()
assert "error" in data
def test_custom_channel_without_key_returns_400(self, viewer):
"""Non-hashtag channel name without a key must be rejected."""
with viewer.app.test_client() as c:
resp = c.post(
"/api/channels",
json={"name": "mychannel"},
content_type="application/json",
)
assert resp.status_code == 400
data = resp.get_json()
assert "key" in data["error"].lower()
def test_channel_key_wrong_length_returns_400(self, viewer):
"""A key shorter than 32 hex chars must be rejected."""
with viewer.app.test_client() as c:
resp = c.post(
"/api/channels",
json={"name": "mychan", "channel_key": "deadbeef"},
content_type="application/json",
)
assert resp.status_code == 400
data = resp.get_json()
assert "32" in data["error"]
def test_channel_key_non_hex_returns_400(self, viewer):
"""A 32-char key containing non-hex characters must be rejected."""
with viewer.app.test_client() as c:
resp = c.post(
"/api/channels",
json={"name": "mychan", "channel_key": "Z" * 32},
content_type="application/json",
)
assert resp.status_code == 400
data = resp.get_json()
assert "hexadecimal" in data["error"].lower()
# ===========================================================================
# POST /api/channels/validate — with a valid name
# ===========================================================================
class TestChannelValidateRoute:
"""Tests for POST /api/channels/validate."""
def test_validate_missing_name_returns_400(self, viewer):
with viewer.app.test_client() as c:
resp = c.post(
"/api/channels/validate",
json={},
content_type="application/json",
)
assert resp.status_code == 400
data = resp.get_json()
assert "error" in data
def test_validate_known_channel_name(self, viewer):
with viewer.app.test_client() as c:
resp = c.post(
"/api/channels/validate",
json={"name": "#general"},
content_type="application/json",
)
assert resp.status_code == 200
data = resp.get_json()
assert "exists" in data
def test_validate_nonexistent_channel_exists_false(self, viewer):
with viewer.app.test_client() as c:
resp = c.post(
"/api/channels/validate",
json={"name": "#channel_that_does_not_exist_xyz"},
content_type="application/json",
)
assert resp.status_code == 200
data = resp.get_json()
assert data["exists"] is False
assert data["channel_num"] is None
# ===========================================================================
# POST /api/stream_data — additional type branches
# ===========================================================================
class TestStreamDataTypes:
"""POST /api/stream_data with all supported type values."""
def test_packet_type_returns_success(self, viewer):
with viewer.app.test_client() as c:
resp = c.post(
"/api/stream_data",
json={"type": "packet", "data": {"raw": "aabbcc"}},
content_type="application/json",
)
assert resp.status_code == 200
data = resp.get_json()
assert data["status"] == "success"
def test_mesh_edge_type_returns_success(self, viewer):
with viewer.app.test_client() as c:
resp = c.post(
"/api/stream_data",
json={"type": "mesh_edge", "data": {"from": "aa", "to": "bb"}},
content_type="application/json",
)
assert resp.status_code == 200
data = resp.get_json()
assert data["status"] == "success"
def test_mesh_node_type_returns_success(self, viewer):
with viewer.app.test_client() as c:
resp = c.post(
"/api/stream_data",
json={"type": "mesh_node", "data": {"node_id": "cc"}},
content_type="application/json",
)
assert resp.status_code == 200
data = resp.get_json()
assert data["status"] == "success"
def test_unknown_type_returns_400(self, viewer):
with viewer.app.test_client() as c:
resp = c.post(
"/api/stream_data",
json={"type": "unknown_type", "data": {}},
content_type="application/json",
)
assert resp.status_code == 400
data = resp.get_json()
assert "error" in data
def test_missing_body_returns_400(self, viewer):
with viewer.app.test_client() as c:
resp = c.post(
"/api/stream_data",
json={},
content_type="application/json",
)
assert resp.status_code == 400
data = resp.get_json()
assert "error" in data
def test_command_type_returns_success(self, viewer):
"""Verify the already-exercised command type still returns success."""
with viewer.app.test_client() as c:
resp = c.post(
"/api/stream_data",
json={"type": "command", "data": {"cmd": "ping"}},
content_type="application/json",
)
assert resp.status_code == 200
data = resp.get_json()
assert data["status"] == "success"
# ===========================================================================
# GET /api/maintenance/status — detailed field verification
# ===========================================================================
class TestMaintenanceStatusFields:
"""Verify all expected keys are present in GET /api/maintenance/status."""
def test_status_contains_all_keys(self, viewer):
with viewer.app.test_client() as c:
resp = c.get("/api/maintenance/status")
assert resp.status_code == 200
data = resp.get_json()
expected = {
"data_retention_ran_at",
"data_retention_outcome",
"nightly_email_ran_at",
"nightly_email_outcome",
"db_backup_ran_at",
"db_backup_outcome",
"db_backup_path",
"log_rotation_applied_at",
}
assert expected == set(data.keys())
def test_status_values_are_strings(self, viewer):
"""All values in the status response must be strings (empty or populated)."""
with viewer.app.test_client() as c:
resp = c.get("/api/maintenance/status")
data = resp.get_json()
for key, value in data.items():
assert isinstance(value, str), f"Key {key!r} has non-string value: {value!r}"
def test_status_written_metadata_is_reflected(self, viewer):
"""Metadata written via set_metadata appears in status response."""
viewer.db_manager.set_metadata(
"maint.status.db_backup_ran_at", "2026-01-01T02:00:00"
)
with viewer.app.test_client() as c:
resp = c.get("/api/maintenance/status")
data = resp.get_json()
assert data["db_backup_ran_at"] == "2026-01-01T02:00:00"
# ===========================================================================
# T1-A: subscribe_packets and subscribe_messages history replay
# ===========================================================================
class TestSubscribePacketsHistoryReplay:
"""subscribe_packets must replay last 50 packet/command/routing rows on connect (T1-A)."""
def test_subscribe_packets_replays_packet_history(self, socketio_viewer):
"""Packet-type history rows are emitted as packet_data events on subscribe."""
import json as _json
import time as _time
from flask_socketio import SocketIOTestClient
now = _time.time()
rows = [
(now - 10 + i, _json.dumps({"seq": i, "type": "rf_data"}), "packet")
for i in range(3)
]
_insert_packet_stream_rows(socketio_viewer.db_path, rows)
sio_client = SocketIOTestClient(socketio_viewer.app, socketio_viewer.socketio)
sio_client.emit("subscribe_packets")
received = sio_client.get_received()
packet_events = [e for e in received if e["name"] == "packet_data"]
assert len(packet_events) == 3
seq_values = [e["args"][0]["seq"] for e in packet_events]
assert seq_values == [0, 1, 2]
def test_subscribe_packets_replays_command_as_command_data(self, socketio_viewer):
"""Command-type rows in packet_stream are replayed as command_data events."""
import json as _json
import time as _time
from flask_socketio import SocketIOTestClient
now = _time.time()
rows = [
(now - 5, _json.dumps({"command": "ping", "seq": 0}), "command"),
]
_insert_packet_stream_rows(socketio_viewer.db_path, rows)
sio_client = SocketIOTestClient(socketio_viewer.app, socketio_viewer.socketio)
sio_client.emit("subscribe_packets")
received = sio_client.get_received()
command_events = [e for e in received if e["name"] == "command_data"]
assert len(command_events) == 1
def test_subscribe_packets_excludes_message_type(self, socketio_viewer):
"""Message-type rows are NOT included in subscribe_packets replay."""
import json as _json
import time as _time
from flask_socketio import SocketIOTestClient
now = _time.time()
rows = [
(now - 5, _json.dumps({"content": "hello", "seq": 0}), "message"),
]
_insert_packet_stream_rows(socketio_viewer.db_path, rows)
sio_client = SocketIOTestClient(socketio_viewer.app, socketio_viewer.socketio)
sio_client.emit("subscribe_packets")
received = sio_client.get_received()
packet_events = [e for e in received if e["name"] == "packet_data"]
message_events = [e for e in received if e["name"] == "message_data"]
assert len(packet_events) == 0
assert len(message_events) == 0
def test_subscribe_packets_empty_history(self, socketio_viewer):
"""subscribe_packets with no history emits only status, no packet_data."""
from flask_socketio import SocketIOTestClient
sio_client = SocketIOTestClient(socketio_viewer.app, socketio_viewer.socketio)
sio_client.emit("subscribe_packets")
received = sio_client.get_received()
packet_events = [e for e in received if e["name"] == "packet_data"]
assert len(packet_events) == 0
def test_subscribe_packets_sets_subscription_flag(self, socketio_viewer):
"""subscribed_packets flag is set to True after subscribe event."""
from flask_socketio import SocketIOTestClient
sio_client = SocketIOTestClient(socketio_viewer.app, socketio_viewer.socketio)
sio_client.emit("subscribe_packets")
with socketio_viewer._clients_lock:
flags = [
v.get("subscribed_packets", False)
for v in socketio_viewer.connected_clients.values()
]
assert any(flags), "At least one client should have subscribed_packets=True"
def test_subscribe_packets_emits_status_ack(self, socketio_viewer):
"""subscribe_packets always emits a status acknowledgement."""
from flask_socketio import SocketIOTestClient
sio_client = SocketIOTestClient(socketio_viewer.app, socketio_viewer.socketio)
sio_client.emit("subscribe_packets")
received = sio_client.get_received()
status_events = [e for e in received if e["name"] == "status"]
assert len(status_events) >= 1
def test_subscribe_packets_routing_type_replayed(self, socketio_viewer):
"""Routing-type rows are replayed as packet_data events."""
import json as _json
import time as _time
from flask_socketio import SocketIOTestClient
now = _time.time()
rows = [
(now - 5, _json.dumps({"route": "aa->bb", "seq": 0}), "routing"),
]
_insert_packet_stream_rows(socketio_viewer.db_path, rows)
sio_client = SocketIOTestClient(socketio_viewer.app, socketio_viewer.socketio)
sio_client.emit("subscribe_packets")
received = sio_client.get_received()
packet_events = [e for e in received if e["name"] == "packet_data"]
assert len(packet_events) == 1
def test_subscribe_packets_chronological_order(self, socketio_viewer):
"""Replayed rows are in chronological order (oldest first)."""
import json as _json
import time as _time
from flask_socketio import SocketIOTestClient
now = _time.time()
rows = [
(now - 20, _json.dumps({"seq": 0}), "packet"),
(now - 10, _json.dumps({"seq": 1}), "packet"),
(now - 5, _json.dumps({"seq": 2}), "packet"),
]
_insert_packet_stream_rows(socketio_viewer.db_path, rows)
sio_client = SocketIOTestClient(socketio_viewer.app, socketio_viewer.socketio)
sio_client.emit("subscribe_packets")
received = sio_client.get_received()
packet_events = [e for e in received if e["name"] == "packet_data"]
seq_values = [e["args"][0]["seq"] for e in packet_events]
assert seq_values == [0, 1, 2]
class TestSubscribeMessagesHistoryReplay:
"""subscribe_messages must replay last 50 message rows on connect (T1-A)."""
def test_subscribe_messages_replays_history(self, socketio_viewer):
"""Message-type history rows are emitted as message_data events on subscribe."""
import json as _json
import time as _time
from flask_socketio import SocketIOTestClient
now = _time.time()
rows = [
(now - 10 + i, _json.dumps({"content": f"hello{i}", "seq": i}), "message")
for i in range(4)
]
_insert_packet_stream_rows(socketio_viewer.db_path, rows)
sio_client = SocketIOTestClient(socketio_viewer.app, socketio_viewer.socketio)
sio_client.emit("subscribe_messages")
received = sio_client.get_received()
message_events = [e for e in received if e["name"] == "message_data"]
assert len(message_events) == 4
seq_values = [e["args"][0]["seq"] for e in message_events]
assert seq_values == [0, 1, 2, 3]
def test_subscribe_messages_excludes_packet_type(self, socketio_viewer):
"""Packet-type rows are NOT replayed on subscribe_messages."""
import json as _json
import time as _time
from flask_socketio import SocketIOTestClient
now = _time.time()
rows = [
(now - 5, _json.dumps({"seq": 0}), "packet"),
]
_insert_packet_stream_rows(socketio_viewer.db_path, rows)
sio_client = SocketIOTestClient(socketio_viewer.app, socketio_viewer.socketio)
sio_client.emit("subscribe_messages")
received = sio_client.get_received()
message_events = [e for e in received if e["name"] == "message_data"]
assert len(message_events) == 0
def test_subscribe_messages_excludes_command_type(self, socketio_viewer):
"""Command-type rows are NOT replayed on subscribe_messages."""
import json as _json
import time as _time
from flask_socketio import SocketIOTestClient
now = _time.time()
rows = [
(now - 5, _json.dumps({"command": "ping"}), "command"),
]
_insert_packet_stream_rows(socketio_viewer.db_path, rows)
sio_client = SocketIOTestClient(socketio_viewer.app, socketio_viewer.socketio)
sio_client.emit("subscribe_messages")
received = sio_client.get_received()
message_events = [e for e in received if e["name"] == "message_data"]
assert len(message_events) == 0
def test_subscribe_messages_empty_history(self, socketio_viewer):
"""subscribe_messages with no history emits only status, no message_data."""
from flask_socketio import SocketIOTestClient
sio_client = SocketIOTestClient(socketio_viewer.app, socketio_viewer.socketio)
sio_client.emit("subscribe_messages")
received = sio_client.get_received()
message_events = [e for e in received if e["name"] == "message_data"]
assert len(message_events) == 0
def test_subscribe_messages_sets_subscription_flag(self, socketio_viewer):
"""subscribed_messages flag is set to True after subscribe event."""
from flask_socketio import SocketIOTestClient
sio_client = SocketIOTestClient(socketio_viewer.app, socketio_viewer.socketio)
sio_client.emit("subscribe_messages")
with socketio_viewer._clients_lock:
flags = [
v.get("subscribed_messages", False)
for v in socketio_viewer.connected_clients.values()
]
assert any(flags), "At least one client should have subscribed_messages=True"
def test_subscribe_messages_emits_status_ack(self, socketio_viewer):
"""subscribe_messages always emits a status acknowledgement."""
from flask_socketio import SocketIOTestClient
sio_client = SocketIOTestClient(socketio_viewer.app, socketio_viewer.socketio)
sio_client.emit("subscribe_messages")
received = sio_client.get_received()
status_events = [e for e in received if e["name"] == "status"]
assert len(status_events) >= 1
def test_subscribe_messages_chronological_order(self, socketio_viewer):
"""Replayed message rows are in chronological order."""
import json as _json
import time as _time
from flask_socketio import SocketIOTestClient
now = _time.time()
rows = [
(now - 30, _json.dumps({"seq": 0}), "message"),
(now - 15, _json.dumps({"seq": 1}), "message"),
(now - 5, _json.dumps({"seq": 2}), "message"),
]
_insert_packet_stream_rows(socketio_viewer.db_path, rows)
sio_client = SocketIOTestClient(socketio_viewer.app, socketio_viewer.socketio)
sio_client.emit("subscribe_messages")
received = sio_client.get_received()
message_events = [e for e in received if e["name"] == "message_data"]
seq_values = [e["args"][0]["seq"] for e in message_events]
assert seq_values == [0, 1, 2]
def test_subscribe_messages_limit_50(self, socketio_viewer):
"""Only up to 50 message rows are replayed."""
import json as _json
import time as _time
from flask_socketio import SocketIOTestClient
now = _time.time()
rows = [
(now - (100 - i), _json.dumps({"seq": i}), "message")
for i in range(60)
]
_insert_packet_stream_rows(socketio_viewer.db_path, rows)
sio_client = SocketIOTestClient(socketio_viewer.app, socketio_viewer.socketio)
sio_client.emit("subscribe_messages")
received = sio_client.get_received()
message_events = [e for e in received if e["name"] == "message_data"]
assert len(message_events) <= 50
# ===========================================================================
# TASK-16: db_path resolved relative to config file directory (BUG-023 regression)
# ===========================================================================
class TestDbPathResolutionFromConfigDir:
"""BotDataViewer must resolve db_path relative to the config file's parent directory,
matching core.py's bot_root = Path(config_file).parent.resolve(), not relative to
the hardcoded code root (2 dirs above app.py). This was the root cause of the blank
realtime monitor when config.ini lived outside the project tree."""
def _make_viewer(self, tmp_path: Path, db_rel: str = "meshcore_bot.db") -> BotDataViewer:
"""Create a BotDataViewer whose config lives in tmp_path with a relative db_path."""
config_dir = tmp_path / "deployment"
config_dir.mkdir()
cfg = configparser.ConfigParser()
cfg["Connection"] = {"connection_type": "serial", "serial_port": "/dev/ttyUSB0"}
cfg["Bot"] = {"bot_name": "TestBot", "db_path": db_rel, "prefix_bytes": "1"}
cfg["Channels"] = {"monitor_channels": "general"}
cfg["Path_Command"] = {"max_hops": "5", "timeout": "30"}
config_path = str(config_dir / "config.ini")
with open(config_path, "w") as fh:
cfg.write(fh)
with (
patch.object(BotDataViewer, "_start_database_polling", lambda self: None),
patch.object(BotDataViewer, "_start_log_tailing", lambda self: None),
patch.object(BotDataViewer, "_start_cleanup_scheduler", lambda self: None),
):
return BotDataViewer(config_path=config_path)
def test_relative_db_path_resolved_to_config_dir(self, tmp_path: Path) -> None:
"""Relative [Bot] db_path is joined to the config file's parent, not the code root."""
v = self._make_viewer(tmp_path, db_rel="meshcore_bot.db")
expected = str((tmp_path / "deployment" / "meshcore_bot.db").resolve())
assert v.db_path == expected
def test_absolute_db_path_unchanged(self, tmp_path: Path) -> None:
"""Absolute db_path is kept as-is regardless of config location."""
abs_dir = tmp_path / "absolute"
abs_dir.mkdir()
abs_db = str(abs_dir / "bot.db")
v = self._make_viewer(tmp_path, db_rel=abs_db)
assert v.db_path == abs_db
def test_db_path_differs_from_code_root_when_config_elsewhere(self, tmp_path: Path) -> None:
"""When config.ini is not in the code root, the resolved db_path must NOT point
inside the code root (the old, broken behaviour)."""
v = self._make_viewer(tmp_path, db_rel="bot.db")
code_root = Path(
__file__ # tests/test_web_viewer.py
).parent.parent.resolve() # project root
# The resolved db_path must be under tmp_path/deployment, not under the code root
assert not v.db_path.startswith(str(code_root)), (
f"db_path {v.db_path!r} still resolves inside the code root {code_root}"
"the config_base fix is not working"
)
def test_startup_logs_db_path_at_info(self, tmp_path: Path) -> None:
"""BotDataViewer logs the resolved database path at INFO level on startup.
_setup_logging() calls handlers.clear() before adding its own handlers, so
any handler attached before __init__ is removed. We patch _setup_logging to
add a capture handler immediately after the original runs so we see log records
emitted during the rest of __init__.
"""
import logging as _logging
logged: list[str] = []
class _Capture(_logging.Handler):
def emit(self, record: _logging.LogRecord) -> None:
logged.append(record.getMessage())
original_setup_logging = BotDataViewer._setup_logging
capture_handler = _Capture(level=_logging.INFO)
def patched_setup_logging(viewer_self: BotDataViewer) -> None: # type: ignore[type-arg]
original_setup_logging(viewer_self)
viewer_self.logger.addHandler(capture_handler)
with patch.object(BotDataViewer, "_setup_logging", patched_setup_logging):
self._make_viewer(tmp_path)
assert any("Using database:" in msg for msg in logged), (
f"Expected 'Using database:' INFO log at startup; got: {logged}"
)