Files
meshcore-bot/tests/test_web_viewer_integration.py
Stacy Olivas e392468d39 test: coverage expansion — commands, web viewer, and infrastructure
New test modules:
- test_announcements_command: parse, record_trigger, execute paths
- test_aurora_command: KP index parsing, alert levels, execute paths
- test_channel_manager: generate_hashtag_key, cache lookups, validation
- test_channels_command: remaining channel info display paths
- test_dadjoke_command: format, split, length, execute
- test_graph_trace_helper: geo-location helper and graph algorithm paths
- test_hacker_command: text transform logic
- test_help_command: format list, channel filter, general/specific help
- test_i18n: fallback loops, format failure, PermissionError, get_value
- test_joke_command: seasonal, format, split, dark, execute
- test_moon_command: phase calc, execute success/error
- test_multitest_command: multi-channel test sequences
- test_stats_command: adverts leaderboard, get_stats_summary, cleanup
- test_trace_command: path extract, parse, format inline/vertical
- test_web_viewer_integration: circuit breaker, JSON serializer,
  packet capture, channel message
- test_webviewer_command: 100% coverage

Extended existing: test_command_manager, test_feed_manager,
test_message_handler, test_rate_limiter, test_repeater_manager,
test_scheduler_logic, test_security_utils, test_transmission_tracker,
test_utils, test_web_viewer
2026-03-17 17:46:44 -07:00

439 lines
16 KiB
Python

"""Tests for modules.web_viewer.integration — BotIntegration pure logic."""
import json
import queue
import time
from configparser import ConfigParser
from contextlib import suppress
from unittest.mock import MagicMock, Mock, patch
import pytest
# ---------------------------------------------------------------------------
# Factory helpers
# ---------------------------------------------------------------------------
def _make_bot():
"""Create minimal mock bot for BotIntegration tests."""
bot = MagicMock()
bot.logger = Mock()
config = ConfigParser()
config.add_section("Bot")
config.add_section("Web_Viewer")
config.set("Web_Viewer", "host", "127.0.0.1")
config.set("Web_Viewer", "port", "8080")
config.set("Web_Viewer", "enabled", "false")
config.set("Web_Viewer", "auto_start", "false")
config.set("Web_Viewer", "debug", "false")
bot.config = config
bot.bot_root = "/tmp"
bot.db_manager = MagicMock()
bot.db_manager.db_path = ":memory:"
bot.transmission_tracker = None
return bot
def _make_bot_integration(bot=None):
"""Create BotIntegration with all I/O patched out."""
if bot is None:
bot = _make_bot()
from modules.web_viewer.integration import BotIntegration
with patch.object(BotIntegration, "_init_http_session"), \
patch.object(BotIntegration, "_init_packet_stream_table"), \
patch.object(BotIntegration, "_start_drain_thread"):
obj = BotIntegration(bot)
# Give it a real write queue for testing
obj._write_queue = queue.Queue()
obj.http_session = None
return obj
# ---------------------------------------------------------------------------
# reset_circuit_breaker
# ---------------------------------------------------------------------------
class TestResetCircuitBreaker:
def test_clears_open_flag(self):
bi = _make_bot_integration()
bi.circuit_breaker_open = True
bi.circuit_breaker_failures = 5
bi.reset_circuit_breaker()
assert bi.circuit_breaker_open is False
assert bi.circuit_breaker_failures == 0
# ---------------------------------------------------------------------------
# _should_skip_web_viewer_send
# ---------------------------------------------------------------------------
class TestShouldSkipWebViewerSend:
def test_not_open_returns_false(self):
bi = _make_bot_integration()
bi.circuit_breaker_open = False
assert bi._should_skip_web_viewer_send() is False
def test_open_within_cooldown_returns_true(self):
bi = _make_bot_integration()
bi.circuit_breaker_open = True
bi.circuit_breaker_last_failure_time = time.time() # just now
assert bi._should_skip_web_viewer_send() is True
def test_open_beyond_cooldown_resets_and_returns_false(self):
bi = _make_bot_integration()
bi.circuit_breaker_open = True
bi.circuit_breaker_failures = 3
# Set last failure time far in the past
bi.circuit_breaker_last_failure_time = time.time() - bi.CIRCUIT_BREAKER_COOLDOWN_SEC - 1
assert bi._should_skip_web_viewer_send() is False
assert bi.circuit_breaker_open is False
# ---------------------------------------------------------------------------
# _record_web_viewer_result
# ---------------------------------------------------------------------------
class TestRecordWebViewerResult:
def test_success_resets_circuit(self):
bi = _make_bot_integration()
bi.circuit_breaker_failures = 2
bi.circuit_breaker_open = True
bi._record_web_viewer_result(True)
assert bi.circuit_breaker_failures == 0
assert bi.circuit_breaker_open is False
def test_failure_increments_counter(self):
bi = _make_bot_integration()
bi._record_web_viewer_result(False)
assert bi.circuit_breaker_failures == 1
def test_failure_opens_circuit_at_threshold(self):
bi = _make_bot_integration()
for _ in range(bi.CIRCUIT_BREAKER_THRESHOLD):
bi._record_web_viewer_result(False)
assert bi.circuit_breaker_open is True
def test_failure_below_threshold_does_not_open(self):
bi = _make_bot_integration()
for _ in range(bi.CIRCUIT_BREAKER_THRESHOLD - 1):
bi._record_web_viewer_result(False)
assert bi.circuit_breaker_open is False
# ---------------------------------------------------------------------------
# _make_json_serializable
# ---------------------------------------------------------------------------
class TestMakeJsonSerializable:
def setup_method(self):
self.bi = _make_bot_integration()
def test_none_passes_through(self):
assert self.bi._make_json_serializable(None) is None
def test_string_passes_through(self):
assert self.bi._make_json_serializable("hello") == "hello"
def test_int_passes_through(self):
assert self.bi._make_json_serializable(42) == 42
def test_float_passes_through(self):
assert self.bi._make_json_serializable(3.14) == 3.14
def test_bool_passes_through(self):
assert self.bi._make_json_serializable(True) is True
def test_list_recurses(self):
result = self.bi._make_json_serializable([1, "two", None])
assert result == [1, "two", None]
def test_tuple_becomes_list(self):
result = self.bi._make_json_serializable((1, 2))
assert result == [1, 2]
def test_dict_recurses(self):
result = self.bi._make_json_serializable({"a": 1, "b": [2, 3]})
assert result == {"a": 1, "b": [2, 3]}
def test_enum_like_uses_name(self):
obj = Mock(spec=["name"])
obj.name = "MY_ENUM"
result = self.bi._make_json_serializable(obj)
assert result == "MY_ENUM"
def test_value_attr_used_when_no_name(self):
obj = Mock(spec=["value"])
obj.value = 99
result = self.bi._make_json_serializable(obj)
assert result == 99
def test_object_with_dict_converted(self):
class Dummy:
def __init__(self):
self.x = 1
self.y = "a"
result = self.bi._make_json_serializable(Dummy())
assert result == {"x": 1, "y": "a"}
def test_unknown_object_stringified(self):
# An object with no __dict__, no name, no value
result = self.bi._make_json_serializable(object())
assert isinstance(result, str)
def test_max_depth_stringifies(self):
# At max_depth, return str
result = self.bi._make_json_serializable({"nested": [1]}, depth=4, max_depth=3)
assert isinstance(result, str)
def test_deeply_nested_dict(self):
d = {"a": {"b": {"c": "deep"}}}
result = self.bi._make_json_serializable(d)
assert result["a"]["b"]["c"] == "deep"
# ---------------------------------------------------------------------------
# _insert_packet_stream_row
# ---------------------------------------------------------------------------
class TestInsertPacketStreamRow:
def test_enqueues_tuple(self):
bi = _make_bot_integration()
bi._insert_packet_stream_row('{"x": 1}', "packet")
assert not bi._write_queue.empty()
ts, data, row_type = bi._write_queue.get_nowait()
assert data == '{"x": 1}'
assert row_type == "packet"
def test_queue_exception_logged(self):
bi = _make_bot_integration()
bi._write_queue = Mock()
bi._write_queue.put_nowait.side_effect = Exception("full")
# Should not raise
bi._insert_packet_stream_row("{}", "packet")
bi.bot.logger.warning.assert_called_once()
# ---------------------------------------------------------------------------
# capture_full_packet_data
# ---------------------------------------------------------------------------
class TestCaptureFullPacketData:
def test_dict_packet_data_queued(self):
bi = _make_bot_integration()
bi.capture_full_packet_data({"snr": -5.0, "path_len": 2})
assert not bi._write_queue.empty()
ts, data, row_type = bi._write_queue.get_nowait()
parsed = json.loads(data)
assert row_type == "packet"
assert parsed["hops"] == 2
def test_no_path_len_defaults_hops_to_0(self):
bi = _make_bot_integration()
bi.capture_full_packet_data({"snr": -5.0})
ts, data, row_type = bi._write_queue.get_nowait()
parsed = json.loads(data)
assert parsed["hops"] == 0
def test_existing_hops_not_overwritten(self):
bi = _make_bot_integration()
bi.capture_full_packet_data({"hops": 3, "path_len": 2})
ts, data, row_type = bi._write_queue.get_nowait()
parsed = json.loads(data)
assert parsed["hops"] == 3
def test_datetime_added(self):
bi = _make_bot_integration()
bi.capture_full_packet_data({"snr": 0})
ts, data, _ = bi._write_queue.get_nowait()
parsed = json.loads(data)
assert "datetime" in parsed
def test_non_dict_wrapped(self):
bi = _make_bot_integration()
# Pass a non-dict (e.g. a Mock with __dict__)
bi.capture_full_packet_data("not a dict")
# Should not raise; may enqueue something
assert True # reached here without exception
# ---------------------------------------------------------------------------
# capture_command
# ---------------------------------------------------------------------------
class TestCaptureCommand:
def test_basic_capture_queued(self):
bi = _make_bot_integration()
msg = Mock()
msg.sender_id = "aa:bb"
msg.channel = "general"
msg.content = "ping"
bi.capture_command(msg, "ping", "Pong!", True)
assert not bi._write_queue.empty()
ts, data, row_type = bi._write_queue.get_nowait()
assert row_type == "command"
parsed = json.loads(data)
assert parsed["command"] == "ping"
assert parsed["success"] is True
def test_no_transmission_tracker(self):
bi = _make_bot_integration()
bi.bot.transmission_tracker = None
msg = Mock()
msg.sender_id = "u1"
msg.channel = "ch"
msg.content = "cmd"
bi.capture_command(msg, "cmd", "resp", True)
ts, data, _ = bi._write_queue.get_nowait()
parsed = json.loads(data)
assert parsed["repeat_count"] == 0
# ---------------------------------------------------------------------------
# capture_channel_message
# ---------------------------------------------------------------------------
class TestCaptureChannelMessage:
def test_message_queued_with_type_message(self):
bi = _make_bot_integration()
msg = Mock()
msg.sender_id = "aa"
msg.channel = "general"
msg.content = "hello"
msg.snr = -5.0
msg.hops = 1
msg.path = "bb,cc"
msg.is_dm = False
bi.capture_channel_message(msg)
assert not bi._write_queue.empty()
ts, data, row_type = bi._write_queue.get_nowait()
assert row_type == "message"
parsed = json.loads(data)
assert parsed["type"] == "message"
assert parsed["content"] == "hello"
def test_dm_message_captured(self):
bi = _make_bot_integration()
msg = Mock()
msg.sender_id = "aa"
msg.channel = ""
msg.content = "private"
msg.snr = -3.0
msg.hops = 0
msg.path = ""
msg.is_dm = True
bi.capture_channel_message(msg)
ts, data, _ = bi._write_queue.get_nowait()
parsed = json.loads(data)
assert parsed["is_dm"] is True
# ---------------------------------------------------------------------------
# capture_packet_routing
# ---------------------------------------------------------------------------
class TestCapturePacketRouting:
def test_routing_data_queued(self):
bi = _make_bot_integration()
bi.capture_packet_routing({"path_nodes": ["aa", "bb"]})
assert not bi._write_queue.empty()
ts, data, row_type = bi._write_queue.get_nowait()
assert row_type == "routing"
# ---------------------------------------------------------------------------
# _get_web_viewer_db_path
# ---------------------------------------------------------------------------
class TestGetWebViewerDbPath:
def test_uses_bot_db_path_when_no_section(self):
bi = _make_bot_integration()
bi.bot.config.remove_section("Web_Viewer")
bi.bot.db_manager.db_path = "/tmp/test.db"
result = bi._get_web_viewer_db_path()
assert "test.db" in result
def test_uses_web_viewer_db_path_when_set(self):
bi = _make_bot_integration()
bi.bot.config.set("Web_Viewer", "db_path", "/tmp/viewer.db")
result = bi._get_web_viewer_db_path()
assert "viewer.db" in result
def test_falls_back_when_web_viewer_db_path_empty(self):
bi = _make_bot_integration()
bi.bot.config.set("Web_Viewer", "db_path", "")
bi.bot.db_manager.db_path = "/tmp/bot.db"
result = bi._get_web_viewer_db_path()
assert "bot.db" in result
# ---------------------------------------------------------------------------
# WebViewerIntegration validation
# ---------------------------------------------------------------------------
class TestWebViewerIntegrationValidation:
def test_invalid_host_raises(self):
from modules.web_viewer.integration import WebViewerIntegration
bot = _make_bot()
bot.config.set("Web_Viewer", "host", "evil.host")
bot.config.set("Web_Viewer", "enabled", "false")
with patch.object(WebViewerIntegration, "start_viewer"):
with patch("modules.web_viewer.integration.BotIntegration._init_http_session"), \
patch("modules.web_viewer.integration.BotIntegration._init_packet_stream_table"), \
patch("modules.web_viewer.integration.BotIntegration._start_drain_thread"):
with pytest.raises(ValueError, match="Invalid host"):
WebViewerIntegration(bot)
def test_invalid_port_raises(self):
from modules.web_viewer.integration import WebViewerIntegration
bot = _make_bot()
bot.config.set("Web_Viewer", "port", "80") # privileged port
with patch("modules.web_viewer.integration.BotIntegration._init_http_session"), \
patch("modules.web_viewer.integration.BotIntegration._init_packet_stream_table"), \
patch("modules.web_viewer.integration.BotIntegration._start_drain_thread"):
with pytest.raises(ValueError, match="Port must be"):
WebViewerIntegration(bot)
def test_valid_config_no_error(self):
from modules.web_viewer.integration import WebViewerIntegration
bot = _make_bot()
with patch("modules.web_viewer.integration.BotIntegration._init_http_session"), \
patch("modules.web_viewer.integration.BotIntegration._init_packet_stream_table"), \
patch("modules.web_viewer.integration.BotIntegration._start_drain_thread"):
wvi = WebViewerIntegration(bot)
assert wvi.host == "127.0.0.1"
assert wvi.port == 8080
# ---------------------------------------------------------------------------
# shutdown
# ---------------------------------------------------------------------------
class TestShutdown:
def test_shutdown_sets_flag(self):
bi = _make_bot_integration()
bi._drain_thread = Mock()
bi._drain_thread.is_alive.return_value = False
bi.shutdown()
assert bi.is_shutting_down is True
def test_shutdown_stops_drain_thread(self):
bi = _make_bot_integration()
bi._drain_thread = Mock()
bi._drain_thread.is_alive.return_value = True
bi.shutdown()
bi._drain_thread.join.assert_called_once()