Files
meshcore-bot/tests/test_scheduler_logic.py
T
agessaman 313dfccf75 fix: Align send suppression and security compatibility behavior
Make outbound send suppression consistent by honoring both radio-offline and zombie states across command-manager and scheduler paths. Preserve strict SSRF defaults while adding explicit private-feed URL opt-ins, persist allow_local_smtp from notifications config writes, reconcile zombie alert setting precedence, and replace deprecated UTC timestamp calls with timezone-aware UTC usage.
2026-04-14 12:18:02 -07:00

1602 lines
66 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""Tests for MessageScheduler pure logic (no threading, no asyncio)."""
import datetime
import time
from configparser import ConfigParser
from unittest.mock import AsyncMock, MagicMock, Mock, patch
import pytest
from modules.scheduler import MessageScheduler
@pytest.fixture
def scheduler(mock_logger):
"""MessageScheduler with mock bot for pure logic tests."""
bot = Mock()
bot.logger = mock_logger
bot.config = ConfigParser()
bot.config.add_section("Bot")
return MessageScheduler(bot)
class TestIsValidTimeFormat:
"""Tests for _is_valid_time_format()."""
def test_valid_time_0000(self, scheduler):
assert scheduler._is_valid_time_format("0000") is True
def test_valid_time_2359(self, scheduler):
assert scheduler._is_valid_time_format("2359") is True
def test_valid_time_1200(self, scheduler):
assert scheduler._is_valid_time_format("1200") is True
def test_invalid_time_2400(self, scheduler):
assert scheduler._is_valid_time_format("2400") is False
def test_invalid_time_0060(self, scheduler):
assert scheduler._is_valid_time_format("0060") is False
def test_invalid_time_short(self, scheduler):
assert scheduler._is_valid_time_format("123") is False
def test_invalid_time_letters(self, scheduler):
assert scheduler._is_valid_time_format("abcd") is False
def test_invalid_time_empty(self, scheduler):
assert scheduler._is_valid_time_format("") is False
class TestGetCurrentTime:
"""Tests for timezone-aware time retrieval."""
def test_valid_timezone(self, scheduler):
scheduler.bot.config.set("Bot", "timezone", "US/Pacific")
result = scheduler.get_current_time()
assert result.tzinfo is not None
def test_invalid_timezone_falls_back(self, scheduler):
scheduler.bot.config.set("Bot", "timezone", "Invalid/Zone")
result = scheduler.get_current_time()
# Should still return a datetime (system time fallback)
assert result is not None
scheduler.bot.logger.warning.assert_called()
def test_empty_timezone_uses_system(self, scheduler):
scheduler.bot.config.set("Bot", "timezone", "")
result = scheduler.get_current_time()
assert result is not None
class TestHasMeshInfoPlaceholders:
"""Tests for _has_mesh_info_placeholders()."""
def test_detects_placeholder(self, scheduler):
assert scheduler._has_mesh_info_placeholders("Contacts: {total_contacts}") is True
def test_no_placeholder_returns_false(self, scheduler):
assert scheduler._has_mesh_info_placeholders("Hello world") is False
def test_detects_legacy_placeholder(self, scheduler):
assert scheduler._has_mesh_info_placeholders("Repeaters: {repeaters}") is True
# ---------------------------------------------------------------------------
# TestSetupScheduledMessages
# ---------------------------------------------------------------------------
class TestSetupScheduledMessages:
"""Tests for setup_scheduled_messages() — config parsing and APScheduler job registration."""
def _setup_and_call(self, scheduler):
"""Run setup_scheduled_messages() with a real (but isolated) APScheduler."""
scheduler.setup_scheduled_messages()
def _teardown(self, scheduler):
if scheduler._apscheduler is not None:
try:
scheduler._apscheduler.shutdown(wait=False)
except Exception:
pass
def test_valid_entry_is_registered_and_stored(self, scheduler):
scheduler.bot.config.add_section("Scheduled_Messages")
scheduler.bot.config.set("Scheduled_Messages", "0900", "general: Good morning!")
self._setup_and_call(scheduler)
assert "0900" in scheduler.scheduled_messages
channel, message = scheduler.scheduled_messages["0900"]
assert channel == "general"
assert "Good morning!" in message
assert len(scheduler._apscheduler.get_jobs()) == 1
self._teardown(scheduler)
def test_invalid_time_format_is_skipped(self, scheduler):
scheduler.bot.config.add_section("Scheduled_Messages")
scheduler.bot.config.set("Scheduled_Messages", "9999", "general: Bad time")
self._setup_and_call(scheduler)
assert "9999" not in scheduler.scheduled_messages
self._teardown(scheduler)
def test_missing_colon_separator_is_skipped(self, scheduler):
scheduler.bot.config.add_section("Scheduled_Messages")
scheduler.bot.config.set("Scheduled_Messages", "0800", "no colon here")
self._setup_and_call(scheduler)
assert "0800" not in scheduler.scheduled_messages
self._teardown(scheduler)
def test_no_scheduled_messages_section_does_not_raise(self, scheduler):
# No [Scheduled_Messages] section in config
self._setup_and_call(scheduler)
assert scheduler.scheduled_messages == {}
self._teardown(scheduler)
def test_multiple_entries_all_registered(self, scheduler):
scheduler.bot.config.add_section("Scheduled_Messages")
scheduler.bot.config.set("Scheduled_Messages", "0700", "general: Morning")
scheduler.bot.config.set("Scheduled_Messages", "1200", "general: Noon")
scheduler.bot.config.set("Scheduled_Messages", "1800", "general: Evening")
self._setup_and_call(scheduler)
assert len(scheduler.scheduled_messages) == 3
assert len(scheduler._apscheduler.get_jobs()) == 3
self._teardown(scheduler)
def test_message_escape_sequences_decoded(self, scheduler):
"""\\n in config value should be decoded to a real newline in the stored message."""
scheduler.bot.config.add_section("Scheduled_Messages")
scheduler.bot.config.set("Scheduled_Messages", "1000", r"general: Line1\nLine2")
self._setup_and_call(scheduler)
_, message = scheduler.scheduled_messages["1000"]
assert "\n" in message
self._teardown(scheduler)
def test_reload_replaces_existing_jobs(self, scheduler):
"""Calling setup_scheduled_messages() twice should not duplicate jobs."""
scheduler.bot.config.add_section("Scheduled_Messages")
scheduler.bot.config.set("Scheduled_Messages", "0700", "general: Morning")
self._setup_and_call(scheduler)
self._setup_and_call(scheduler) # second call — should replace, not add
assert len(scheduler._apscheduler.get_jobs()) == 1
self._teardown(scheduler)
# ---------------------------------------------------------------------------
# TestSetupIntervalAdvertising
# ---------------------------------------------------------------------------
class TestSetupIntervalAdvertising:
"""Tests for setup_interval_advertising()."""
def test_positive_interval_initialises_last_advert_time(self, scheduler):
scheduler.bot.config.set("Bot", "advert_interval_hours", "6")
del scheduler.bot.last_advert_time # ensure hasattr returns False
scheduler.bot.last_advert_time = None
scheduler.setup_interval_advertising()
assert scheduler.bot.last_advert_time is not None
def test_last_advert_time_not_overwritten_when_already_set(self, scheduler):
scheduler.bot.config.set("Bot", "advert_interval_hours", "6")
scheduler.bot.last_advert_time = 12345.0
scheduler.setup_interval_advertising()
assert scheduler.bot.last_advert_time == 12345.0
def test_zero_interval_logs_disabled(self, scheduler):
scheduler.bot.config.set("Bot", "advert_interval_hours", "0")
scheduler.setup_interval_advertising()
scheduler.bot.logger.info.assert_called()
def test_default_interval_zero_when_unset(self, scheduler):
# advert_interval_hours not in config → fallback 0 → disabled
scheduler.setup_interval_advertising()
scheduler.bot.logger.info.assert_called()
# ---------------------------------------------------------------------------
# check_interval_advertising
# ---------------------------------------------------------------------------
class TestCheckIntervalAdvertising:
def test_disabled_when_interval_zero(self, scheduler):
scheduler.bot.config.set("Bot", "advert_interval_hours", "0")
scheduler.bot.last_advert_time = time.time() - 99999
with patch.object(scheduler, "send_interval_advert") as mock_send:
scheduler.check_interval_advertising()
mock_send.assert_not_called()
def test_first_call_sets_last_advert_time(self, scheduler):
scheduler.bot.config.set("Bot", "advert_interval_hours", "1")
scheduler.bot.last_advert_time = None
scheduler.check_interval_advertising()
assert scheduler.bot.last_advert_time is not None
def test_not_enough_time_passed_no_advert(self, scheduler):
scheduler.bot.config.set("Bot", "advert_interval_hours", "1")
scheduler.bot.last_advert_time = time.time() - 1800 # 30 min ago, need 60 min
with patch.object(scheduler, "send_interval_advert") as mock_send:
scheduler.check_interval_advertising()
mock_send.assert_not_called()
def test_enough_time_passed_sends_advert(self, scheduler):
scheduler.bot.config.set("Bot", "advert_interval_hours", "1")
scheduler.bot.last_advert_time = time.time() - 3700 # > 1 hour ago
with patch.object(scheduler, "send_interval_advert") as mock_send:
scheduler.check_interval_advertising()
mock_send.assert_called_once()
def test_last_advert_time_updated_after_send(self, scheduler):
scheduler.bot.config.set("Bot", "advert_interval_hours", "1")
old_time = time.time() - 3700
scheduler.bot.last_advert_time = old_time
with patch.object(scheduler, "send_interval_advert"):
scheduler.check_interval_advertising()
assert scheduler.bot.last_advert_time > old_time
# ---------------------------------------------------------------------------
# _get_notif / _get_maint
# ---------------------------------------------------------------------------
class TestGetNotifAndMaint:
def test_get_notif_returns_value(self, scheduler):
scheduler.bot.db_manager = Mock()
scheduler.bot.db_manager.get_metadata = Mock(return_value="smtp.example.com")
assert scheduler._get_notif("smtp_host") == "smtp.example.com"
scheduler.bot.db_manager.get_metadata.assert_called_with("notif.smtp_host")
def test_get_notif_returns_empty_on_none(self, scheduler):
scheduler.bot.db_manager = Mock()
scheduler.bot.db_manager.get_metadata = Mock(return_value=None)
assert scheduler._get_notif("smtp_host") == ""
def test_get_notif_returns_empty_on_exception(self, scheduler):
scheduler.bot.db_manager = Mock()
scheduler.bot.db_manager.get_metadata = Mock(side_effect=Exception("db error"))
assert scheduler._get_notif("smtp_host") == ""
def test_get_maint_returns_value(self, scheduler):
scheduler.bot.db_manager = Mock()
scheduler.bot.db_manager.get_metadata = Mock(return_value="daily")
assert scheduler._get_maint("db_backup_schedule") == "daily"
scheduler.bot.db_manager.get_metadata.assert_called_with("maint.db_backup_schedule")
def test_get_maint_returns_empty_on_none(self, scheduler):
scheduler.bot.db_manager = Mock()
scheduler.bot.db_manager.get_metadata = Mock(return_value=None)
assert scheduler._get_maint("db_backup_enabled") == ""
def test_get_maint_returns_empty_on_exception(self, scheduler):
scheduler.bot.db_manager = Mock()
scheduler.bot.db_manager.get_metadata = Mock(side_effect=RuntimeError("fail"))
assert scheduler._get_maint("any") == ""
# ---------------------------------------------------------------------------
# _format_email_body
# ---------------------------------------------------------------------------
# ---------------------------------------------------------------------------
# _maybe_run_db_backup
# ---------------------------------------------------------------------------
class TestMaybeRunDbBackup:
def _setup(self, scheduler, enabled="true", schedule="daily",
time_str="02:00", last_ran=""):
def maint(key):
return {
"db_backup_enabled": enabled,
"db_backup_schedule": schedule,
"db_backup_time": time_str,
"db_backup_retention_count": "7",
"db_backup_dir": "/tmp/backup",
}.get(key, "")
scheduler.maintenance.get_maint = Mock(side_effect=maint)
scheduler._last_db_backup_stats = {"ran_at": last_ran}
def test_disabled_does_not_run(self, scheduler):
self._setup(scheduler, enabled="false")
with patch.object(scheduler.maintenance, "run_db_backup") as mock_run:
scheduler._maybe_run_db_backup()
mock_run.assert_not_called()
def test_manual_schedule_does_not_run(self, scheduler):
self._setup(scheduler, schedule="manual")
with patch.object(scheduler.maintenance, "run_db_backup") as mock_run:
scheduler._maybe_run_db_backup()
mock_run.assert_not_called()
def test_already_ran_today_does_not_run(self, scheduler):
now = datetime.datetime.now()
today = now.strftime("%Y-%m-%d")
# Schedule 1 minute ago (inside window), but mark as already run today
sched_time = now - datetime.timedelta(minutes=1)
time_str = sched_time.strftime("%H:%M")
self._setup(scheduler, time_str=time_str, last_ran=f"{today}T00:01:00")
with patch.object(scheduler.maintenance, "run_db_backup") as mock_run:
scheduler._maybe_run_db_backup()
mock_run.assert_not_called()
def test_runs_within_fire_window(self, scheduler):
"""Backup fires when now is within 2 minutes of the scheduled time."""
now = datetime.datetime.now()
# Set scheduled time to 1 minute ago so we're inside the 2-min window
sched_time = now - datetime.timedelta(minutes=1)
time_str = sched_time.strftime("%H:%M")
yesterday = (now - datetime.timedelta(days=1)).strftime("%Y-%m-%d")
self._setup(scheduler, time_str=time_str, last_ran=f"{yesterday}T00:01:00")
with patch.object(scheduler.maintenance, "run_db_backup") as mock_run:
scheduler._maybe_run_db_backup()
mock_run.assert_called_once()
def test_does_not_run_outside_fire_window(self, scheduler):
"""Backup does NOT fire when the scheduled time passed more than 2 minutes ago."""
now = datetime.datetime.now()
# Set scheduled time to 5 minutes ago — outside the 2-min window
sched_time = now - datetime.timedelta(minutes=5)
time_str = sched_time.strftime("%H:%M")
yesterday = (now - datetime.timedelta(days=1)).strftime("%Y-%m-%d")
self._setup(scheduler, time_str=time_str, last_ran=f"{yesterday}T00:01:00")
with patch.object(scheduler.maintenance, "run_db_backup") as mock_run:
scheduler._maybe_run_db_backup()
mock_run.assert_not_called()
def test_does_not_run_before_scheduled_time(self, scheduler):
"""Backup does NOT fire when the scheduled time is in the future."""
now = datetime.datetime.now()
sched_time = now + datetime.timedelta(minutes=30)
time_str = sched_time.strftime("%H:%M")
self._setup(scheduler, time_str=time_str, last_ran="")
with patch.object(scheduler.maintenance, "run_db_backup") as mock_run:
scheduler._maybe_run_db_backup()
mock_run.assert_not_called()
def test_weekly_on_wrong_day_does_not_run(self, scheduler):
# Use a time 1 min ago (inside the 2-min fire window) on a Tuesday
now = datetime.datetime.now()
sched_time = now - datetime.timedelta(minutes=1)
time_str = sched_time.strftime("%H:%M")
self._setup(scheduler, schedule="weekly", time_str=time_str, last_ran="")
fake_now = Mock()
fake_now.weekday.return_value = 1 # Tuesday — not Monday
scheduled_dt = now.replace(
hour=sched_time.hour, minute=sched_time.minute, second=0, microsecond=0
)
fake_now.replace.return_value = scheduled_dt
fake_now.__gt__ = lambda s, o: False # inside window
fake_now.__lt__ = lambda s, o: False
fake_now.__sub__ = lambda s, o: now - o # for timedelta comparison
fake_now.strftime = now.strftime
fake_now.isocalendar.return_value = (2026, 11, 2)
with patch.object(scheduler, "get_current_time", return_value=fake_now):
with patch.object(scheduler.maintenance, "_get_current_time", return_value=fake_now):
with patch.object(scheduler.maintenance, "run_db_backup") as mock_run:
scheduler._maybe_run_db_backup()
mock_run.assert_not_called()
# ---------------------------------------------------------------------------
# _apply_log_rotation_config
# ---------------------------------------------------------------------------
class TestApplyLogRotationConfig:
def test_no_maint_settings_returns_early(self, scheduler):
scheduler.bot.db_manager = Mock()
scheduler.bot.db_manager.get_metadata = Mock(return_value=None)
# No logger handlers to worry about
scheduler._apply_log_rotation_config() # Should not raise
def test_same_settings_not_reapplied(self, scheduler):
scheduler.bot.db_manager = Mock()
scheduler.bot.db_manager.get_metadata = Mock(side_effect=lambda k: {
"maint.log_max_bytes": "5242880",
"maint.log_backup_count": "3",
}.get(k))
scheduler._last_log_rotation_applied = {
"max_bytes": "5242880",
"backup_count": "3",
}
# No RotatingFileHandler in mock logger
scheduler.bot.logger.handlers = []
scheduler._apply_log_rotation_config() # Should not raise or modify
def test_invalid_value_logs_warning(self, scheduler):
scheduler.bot.db_manager = Mock()
scheduler.bot.db_manager.get_metadata = Mock(side_effect=lambda k: {
"maint.log_max_bytes": "not-a-number",
"maint.log_backup_count": "3",
}.get(k))
scheduler._last_log_rotation_applied = {}
scheduler.bot.logger.handlers = []
scheduler._apply_log_rotation_config()
scheduler.bot.logger.warning.assert_called()
def test_rotating_handler_replaced(self, scheduler):
import os
import tempfile
from logging.handlers import RotatingFileHandler
scheduler.bot.db_manager = Mock()
scheduler.bot.db_manager.get_metadata = Mock(side_effect=lambda k: {
"maint.log_max_bytes": "10485760",
"maint.log_backup_count": "5",
}.get(k))
scheduler._last_log_rotation_applied = {}
# Create a real RotatingFileHandler pointed at a temp file
with tempfile.NamedTemporaryFile(delete=False, suffix=".log") as tf:
tmp_path = tf.name
try:
handler = RotatingFileHandler(tmp_path, maxBytes=1024, backupCount=1)
scheduler.bot.logger.handlers = [handler]
scheduler._apply_log_rotation_config()
new_handler = scheduler.bot.logger.handlers[0]
assert new_handler.maxBytes == 10485760
assert new_handler.backupCount == 5
new_handler.close()
finally:
os.unlink(tmp_path)
# ---------------------------------------------------------------------------
# TestAPSchedulerLifecycle
# ---------------------------------------------------------------------------
class TestAPSchedulerLifecycle:
"""Tests for APScheduler start/shutdown lifecycle in MessageScheduler."""
def test_apscheduler_created_on_setup(self, scheduler):
scheduler.setup_scheduled_messages()
assert scheduler._apscheduler is not None
assert scheduler._apscheduler.running
scheduler.join(timeout=1)
def test_join_shuts_down_apscheduler(self, scheduler):
scheduler.setup_scheduled_messages()
assert scheduler._apscheduler.running
scheduler.join(timeout=1)
assert not scheduler._apscheduler.running
def test_join_with_no_apscheduler_does_not_raise(self, scheduler):
assert scheduler._apscheduler is None
scheduler.join(timeout=0.1) # must not raise
def test_cron_trigger_hour_minute(self, scheduler):
"""Registered jobs get a CronTrigger with the correct hour/minute."""
scheduler.bot.config.add_section("Scheduled_Messages")
scheduler.bot.config.set("Scheduled_Messages", "1430", "ch: hello")
scheduler.setup_scheduled_messages()
jobs = scheduler._apscheduler.get_jobs()
assert len(jobs) == 1
trigger = jobs[0].trigger
# CronTrigger fields: hour=14, minute=30
field_map = {f.name: f for f in trigger.fields}
assert str(field_map["hour"]) == "14"
assert str(field_map["minute"]) == "30"
scheduler.join(timeout=1)
# ---------------------------------------------------------------------------
# TASK-05 / BUG-024: last_db_backup_run updated after _maybe_run_db_backup
# ---------------------------------------------------------------------------
class TestDbBackupIntervalGuard:
"""Verify last_db_backup_run is updated so the 300s guard works correctly."""
def test_last_db_backup_run_updated_after_call(self, scheduler):
"""last_db_backup_run is set to ~now immediately after _maybe_run_db_backup."""
scheduler.last_db_backup_run = 0 # force guard to fire
with patch.object(scheduler, '_maybe_run_db_backup') as mock_backup:
before = time.time()
# Simulate the scheduler loop body: guard fires, backup runs, timestamp updated
if time.time() - scheduler.last_db_backup_run >= 300:
scheduler._maybe_run_db_backup()
scheduler.last_db_backup_run = time.time()
after = time.time()
mock_backup.assert_called_once()
assert scheduler.last_db_backup_run >= before
assert scheduler.last_db_backup_run <= after
def test_guard_prevents_second_call_within_300s(self, scheduler):
"""After last_db_backup_run is updated, a second loop iteration does not call backup."""
scheduler.last_db_backup_run = 0
call_count = 0
def fake_backup():
nonlocal call_count
call_count += 1
scheduler.last_db_backup_run = time.time() # mirrors fixed scheduler code
with patch.object(scheduler, '_maybe_run_db_backup', side_effect=fake_backup):
# First iteration — guard fires
if time.time() - scheduler.last_db_backup_run >= 300:
scheduler._maybe_run_db_backup()
scheduler.last_db_backup_run = time.time()
# Second iteration immediately after — guard must NOT fire
if time.time() - scheduler.last_db_backup_run >= 300:
scheduler._maybe_run_db_backup()
scheduler.last_db_backup_run = time.time()
assert call_count == 1, "Backup should only run once; guard failed to prevent second call"
def test_initial_last_db_backup_run_is_zero(self, scheduler):
"""last_db_backup_run starts at 0 so first check fires after 300s uptime."""
assert scheduler.last_db_backup_run == 0
def test_guard_fires_after_300s(self, scheduler):
"""Guard fires when last_db_backup_run is more than 300s in the past."""
scheduler.last_db_backup_run = time.time() - 301
assert time.time() - scheduler.last_db_backup_run >= 300
def test_guard_does_not_fire_before_300s(self, scheduler):
"""Guard does not fire when last run was less than 300s ago."""
scheduler.last_db_backup_run = time.time() - 10
assert not (time.time() - scheduler.last_db_backup_run >= 300)
def test_restart_seeds_last_ran_from_db(self, scheduler):
"""On first _maybe_run_db_backup call after restart, ran_at is loaded from DB metadata."""
now = datetime.datetime.now()
today = now.strftime("%Y-%m-%d")
# DB says backup ran today
scheduler.bot.db_manager.get_metadata.return_value = f"{today}T01:00:00"
scheduler._last_db_backup_stats = {}
# Schedule 1 min ago (inside 2-min window) to ensure we'd run if not for dedup
sched_time = now - datetime.timedelta(minutes=1)
time_str = sched_time.strftime("%H:%M")
def maint(key):
return {
"db_backup_enabled": "true",
"db_backup_schedule": "daily",
"db_backup_time": time_str,
"db_backup_retention_count": "7",
"db_backup_dir": "/tmp",
}.get(key, "")
scheduler.maintenance.get_maint = Mock(side_effect=maint)
with patch.object(scheduler.maintenance, "run_db_backup") as mock_run:
scheduler._maybe_run_db_backup()
# Should NOT run because DB says it already ran today
mock_run.assert_not_called()
# And ran_at should be seeded from DB
assert scheduler._last_db_backup_stats.get("ran_at", "").startswith(today)
# ---------------------------------------------------------------------------
# _format_email_body — pure logic, no external calls
# ---------------------------------------------------------------------------
class TestFormatEmailBodyPure:
"""Tests for _format_email_body — pure string builder."""
def setup_method(self):
bot = Mock()
bot.logger = Mock()
bot.config = ConfigParser()
bot.config.add_section("Bot")
bot.connected = True
self.scheduler = MessageScheduler(bot)
def _basic_stats(self):
return {
"uptime": "2d 3h",
"contacts_24h": 5,
"contacts_new_24h": 1,
"contacts_total": 42,
"db_size_mb": "12.3",
"errors_24h": 0,
"criticals_24h": 0,
}
def test_returns_string(self):
result = self.scheduler._format_email_body(self._basic_stats(), "2026-01-01 00:00", "2026-01-02 00:00")
assert isinstance(result, str)
def test_contains_period(self):
result = self.scheduler._format_email_body(self._basic_stats(), "start", "end")
assert "start" in result
assert "end" in result
def test_contains_uptime(self):
result = self.scheduler._format_email_body(self._basic_stats(), "s", "e")
assert "2d 3h" in result
def test_contains_db_section(self):
result = self.scheduler._format_email_body(self._basic_stats(), "s", "e")
assert "DATABASE" in result
assert "12.3" in result
def test_contains_error_section(self):
result = self.scheduler._format_email_body(self._basic_stats(), "s", "e")
assert "ERRORS" in result
def test_bot_connected_yes(self):
self.scheduler.bot.connected = True
result = self.scheduler._format_email_body(self._basic_stats(), "s", "e")
assert "yes" in result
def test_bot_connected_no(self):
self.scheduler.bot.connected = False
result = self.scheduler._format_email_body(self._basic_stats(), "s", "e")
assert "no" in result
def test_retention_ran_at_included(self):
self.scheduler._last_retention_stats = {"ran_at": "2026-01-01T03:00:00"}
result = self.scheduler._format_email_body(self._basic_stats(), "s", "e")
assert "2026-01-01T03:00:00" in result
def test_retention_error_included(self):
self.scheduler._last_retention_stats = {"error": "DB locked"}
result = self.scheduler._format_email_body(self._basic_stats(), "s", "e")
assert "DB locked" in result
def test_log_file_section_included(self):
stats = self._basic_stats()
stats["log_file"] = "/var/log/bot.log"
stats["log_size_mb"] = "5.0"
result = self.scheduler._format_email_body(stats, "s", "e")
assert "/var/log/bot.log" in result
assert "5.0" in result
def test_log_rotated_yes(self):
stats = self._basic_stats()
stats["log_file"] = "/var/log/bot.log"
stats["log_size_mb"] = "5.0"
stats["log_rotated_24h"] = True
stats["log_backup_size_mb"] = "4.9"
result = self.scheduler._format_email_body(stats, "s", "e")
assert "yes" in result
assert "4.9" in result
def test_log_rotated_no(self):
stats = self._basic_stats()
stats["log_file"] = "/var/log/bot.log"
stats["log_size_mb"] = "5.0"
stats["log_rotated_24h"] = False
result = self.scheduler._format_email_body(stats, "s", "e")
assert "Rotated : no" in result
def test_missing_optional_stats_use_nap(self):
result = self.scheduler._format_email_body({}, "s", "e")
assert "n/a" in result or "unknown" in result
def test_no_log_file_no_log_section(self):
stats = self._basic_stats()
result = self.scheduler._format_email_body(stats, "s", "e")
assert "LOG FILES" not in result
def test_ends_with_config_hint(self):
result = self.scheduler._format_email_body(self._basic_stats(), "s", "e")
assert "/config" in result
# ---------------------------------------------------------------------------
# _send_nightly_email disabled path (no smtplib)
# ---------------------------------------------------------------------------
class TestSendNightlyEmailDisabled:
def test_disabled_returns_immediately(self):
bot = Mock()
bot.logger = Mock()
bot.config = ConfigParser()
bot.config.add_section("Bot")
scheduler = MessageScheduler(bot)
def _get_notif(key):
return {"nightly_enabled": "false"}.get(key, "")
scheduler.maintenance.get_notif = Mock(side_effect=_get_notif)
# Should not raise and should not call smtplib
scheduler.maintenance.send_nightly_email()
# No assertion needed — if it reaches here without smtplib, it returned early
# ---------------------------------------------------------------------------
# Helper — shared bot + scheduler factory used by several new test classes
# ---------------------------------------------------------------------------
import asyncio
import configparser as _configparser
def _make_scheduler():
"""Return a MessageScheduler with a fully-mocked bot, skipping setup_scheduled_messages."""
bot = MagicMock()
bot.connected = True
bot.logger = Mock()
config = _configparser.ConfigParser()
config.add_section("Bot")
config.set("Bot", "advert_interval_hours", "0")
bot.config = config
bot.main_event_loop = None
bot.is_radio_zombie = False
bot.is_radio_offline = False
# db_manager.connection() context manager
conn_mock = MagicMock()
conn_mock.__enter__ = Mock(return_value=conn_mock)
conn_mock.__exit__ = Mock(return_value=False)
cursor_mock = MagicMock()
cursor_mock.fetchone.return_value = None
cursor_mock.fetchall.return_value = []
conn_mock.cursor.return_value = cursor_mock
bot.db_manager.connection.return_value = conn_mock
with patch.object(MessageScheduler, "setup_scheduled_messages"):
scheduler = MessageScheduler(bot)
return scheduler
# ---------------------------------------------------------------------------
# TestGetMeshInfo
# ---------------------------------------------------------------------------
class TestGetMeshInfo:
"""Tests for _get_mesh_info() async method (lines 152293)."""
def test_returns_dict_with_required_keys(self):
scheduler = _make_scheduler()
# Remove repeater_manager so we fall to the fallback path
del scheduler.bot.repeater_manager
result = asyncio.run(scheduler._get_mesh_info())
required = [
"total_contacts",
"total_repeaters",
"total_companions",
"total_roomservers",
"total_sensors",
"recent_activity_24h",
"new_companions_7d",
"new_repeaters_7d",
]
for key in required:
assert key in result
def test_uses_repeater_manager_stats_when_available(self):
scheduler = _make_scheduler()
stats_payload = {
"total_heard": 15,
"by_role": {
"repeater": 3,
"companion": 10,
"roomserver": 1,
"sensor": 1,
},
"recent_activity": 7,
}
scheduler.bot.repeater_manager.get_contact_statistics = AsyncMock(
return_value=stats_payload
)
result = asyncio.run(scheduler._get_mesh_info())
assert result["total_contacts"] == 15
assert result["total_repeaters"] == 3
assert result["total_companions"] == 10
assert result["recent_activity_24h"] == 7
def test_fallback_to_meshcore_contacts_when_repeater_manager_absent(self):
scheduler = _make_scheduler()
del scheduler.bot.repeater_manager
scheduler.bot.meshcore.contacts = {"a": {}, "b": {}, "c": {}}
result = asyncio.run(scheduler._get_mesh_info())
assert result["total_contacts"] == 3
def test_fallback_counts_repeaters_and_companions_when_repeater_manager_present(self):
"""When repeater_manager returns 0 total_heard, falls back to meshcore.contacts
and uses repeater_manager._is_repeater_device to classify."""
scheduler = _make_scheduler()
scheduler.bot.repeater_manager.get_contact_statistics = AsyncMock(
return_value={"total_heard": 0, "by_role": {}, "recent_activity": 0}
)
scheduler.bot.meshcore.contacts = {
"key1": {"type": "repeater"},
"key2": {"type": "companion"},
"key3": {"type": "companion"},
}
def _is_repeater(contact_data):
return contact_data.get("type") == "repeater"
scheduler.bot.repeater_manager._is_repeater_device = Mock(side_effect=_is_repeater)
result = asyncio.run(scheduler._get_mesh_info())
assert result["total_contacts"] == 3
assert result["total_repeaters"] == 1
assert result["total_companions"] == 2
def test_db_complete_contact_tracking_populates_7d_new_counts(self):
"""When complete_contact_tracking table exists, role rows are mapped to new_*_7d keys."""
scheduler = _make_scheduler()
del scheduler.bot.repeater_manager
del scheduler.bot.meshcore
# Simulate DB: first fetchone for message_stats table → None (no table)
# then inner block: fetchone for complete_contact_tracking → row
# fetchall for 7d roles → companion + repeater rows
# fetchone for 30d total → 5
# fetchall for 30d roles → empty
conn_mock = MagicMock()
conn_mock.__enter__ = Mock(return_value=conn_mock)
conn_mock.__exit__ = Mock(return_value=False)
# Track cursor().fetchone() calls — first returns None (no message_stats),
# second returns a row (complete_contact_tracking exists), third returns (5,) for 30d total
fetchone_seq = iter([None, ("complete_contact_tracking",), (5,)])
cursor_mock = MagicMock()
cursor_mock.fetchone.side_effect = lambda: next(fetchone_seq)
cursor_mock.fetchall.side_effect = [
# 7d new devices by role
[("companion", 4), ("repeater", 2), ("roomserver", 1), ("sensor", 0)],
# 30d active by role
[],
]
conn_mock.cursor.return_value = cursor_mock
scheduler.bot.db_manager.connection.return_value = conn_mock
result = asyncio.run(scheduler._get_mesh_info())
assert result["new_companions_7d"] == 4
assert result["new_repeaters_7d"] == 2
assert result["new_roomservers_7d"] == 1
assert result["total_contacts_30d"] == 5
def test_db_exception_returns_zeroed_dict_gracefully(self):
"""When db_manager.connection() raises, method still returns a dict without crashing."""
scheduler = _make_scheduler()
del scheduler.bot.repeater_manager
del scheduler.bot.meshcore
scheduler.bot.db_manager.connection.side_effect = Exception("DB unavailable")
result = asyncio.run(scheduler._get_mesh_info())
assert isinstance(result, dict)
assert result["total_contacts"] == 0
def test_repeater_manager_exception_falls_through(self):
"""Exception in get_contact_statistics is caught; method returns partial dict."""
scheduler = _make_scheduler()
scheduler.bot.repeater_manager.get_contact_statistics = AsyncMock(
side_effect=RuntimeError("timeout")
)
del scheduler.bot.meshcore
result = asyncio.run(scheduler._get_mesh_info())
assert isinstance(result, dict)
# ---------------------------------------------------------------------------
# TestSendScheduledMessageAsync
# ---------------------------------------------------------------------------
class TestSendScheduledMessageAsync:
"""Tests for _send_scheduled_message_async() (lines 308328)."""
def test_no_placeholders_calls_send_channel_message_directly(self):
scheduler = _make_scheduler()
scheduler.bot.command_manager.send_channel_message = AsyncMock()
asyncio.run(scheduler._send_scheduled_message_async("general", "Hello world"))
scheduler.bot.command_manager.send_channel_message.assert_called_once_with(
"general", "Hello world"
)
def test_with_placeholder_calls_get_mesh_info_and_formats(self):
scheduler = _make_scheduler()
scheduler.bot.command_manager.send_channel_message = AsyncMock()
mesh_data = {
"total_contacts": 42,
"total_repeaters": 5,
"total_companions": 37,
"total_roomservers": 0,
"total_sensors": 0,
"recent_activity_24h": 10,
"new_companions_7d": 1,
"new_repeaters_7d": 0,
"new_roomservers_7d": 0,
"new_sensors_7d": 0,
"total_contacts_30d": 40,
"total_repeaters_30d": 4,
"total_companions_30d": 36,
"total_roomservers_30d": 0,
"total_sensors_30d": 0,
}
with patch.object(
scheduler, "_get_mesh_info", new=AsyncMock(return_value=mesh_data)
):
with patch(
"modules.scheduler.format_keyword_response_with_placeholders",
return_value="Contacts: 42",
) as mock_fmt:
asyncio.run(
scheduler._send_scheduled_message_async(
"general", "Contacts: {total_contacts}"
)
)
mock_fmt.assert_called_once()
scheduler.bot.command_manager.send_channel_message.assert_called_once_with(
"general", "Contacts: 42"
)
def test_get_mesh_info_exception_sends_message_as_is(self):
"""When _get_mesh_info raises, the original message is still sent."""
scheduler = _make_scheduler()
scheduler.bot.command_manager.send_channel_message = AsyncMock()
with patch.object(
scheduler,
"_get_mesh_info",
new=AsyncMock(side_effect=Exception("mesh unavailable")),
):
asyncio.run(
scheduler._send_scheduled_message_async(
"general", "Active: {total_contacts}"
)
)
scheduler.bot.command_manager.send_channel_message.assert_called_once_with(
"general", "Active: {total_contacts}"
)
def test_format_placeholder_exception_sends_message_as_is(self):
"""When format_keyword_response_with_placeholders raises KeyError, original message is sent."""
scheduler = _make_scheduler()
scheduler.bot.command_manager.send_channel_message = AsyncMock()
with patch.object(
scheduler,
"_get_mesh_info",
new=AsyncMock(return_value={}),
):
with patch(
"modules.scheduler.format_keyword_response_with_placeholders",
side_effect=KeyError("missing_key"),
):
asyncio.run(
scheduler._send_scheduled_message_async(
"alerts", "Count: {total_contacts}"
)
)
scheduler.bot.command_manager.send_channel_message.assert_called_once_with(
"alerts", "Count: {total_contacts}"
)
# ---------------------------------------------------------------------------
# TestSendScheduledMessageWrapper
# ---------------------------------------------------------------------------
class TestSendScheduledMessageWrapper:
"""Tests for the sync send_scheduled_message() wrapper (lines 121150)."""
def test_uses_run_coroutine_threadsafe_when_main_loop_running(self):
scheduler = _make_scheduler()
mock_loop = Mock()
mock_loop.is_running.return_value = True
scheduler.bot.main_event_loop = mock_loop
fake_future = Mock()
fake_future.result.return_value = None
def _run_coro_threadsafe(coro, loop):
coro.close()
return fake_future
with patch("asyncio.run_coroutine_threadsafe", side_effect=_run_coro_threadsafe) as mock_rct:
scheduler.send_scheduled_message("general", "hi")
mock_rct.assert_called_once()
fake_future.result.assert_called_once_with(timeout=60)
def test_logs_error_when_future_result_raises(self):
scheduler = _make_scheduler()
mock_loop = Mock()
mock_loop.is_running.return_value = True
scheduler.bot.main_event_loop = mock_loop
fake_future = Mock()
fake_future.result.side_effect = Exception("timeout")
def _run_coro_threadsafe(coro, loop):
coro.close()
return fake_future
with patch("asyncio.run_coroutine_threadsafe", side_effect=_run_coro_threadsafe):
scheduler.send_scheduled_message("general", "hi")
scheduler.bot.logger.error.assert_called()
def test_fallback_to_event_loop_when_no_main_loop(self):
scheduler = _make_scheduler()
scheduler.bot.main_event_loop = None
mock_loop = Mock()
async def _fake_send(channel: str, message: str) -> None:
return None
def _run_until_complete(coro):
if asyncio.iscoroutine(coro):
asyncio.run(coro)
mock_loop.run_until_complete = Mock(side_effect=_run_until_complete)
mock_loop.close = Mock()
with patch("asyncio.new_event_loop", return_value=mock_loop):
with patch.object(scheduler, "_send_scheduled_message_async", side_effect=_fake_send) as mock_send:
scheduler.send_scheduled_message("general", "test message")
mock_loop.run_until_complete.assert_called_once()
mock_loop.close.assert_called_once()
mock_send.assert_called_once_with("general", "test message")
def test_suppressed_when_radio_zombie(self):
scheduler = _make_scheduler()
scheduler.bot.is_radio_zombie = True
with patch("asyncio.run_coroutine_threadsafe") as mock_rct:
scheduler.send_scheduled_message("general", "hi")
mock_rct.assert_not_called()
def test_suppressed_when_radio_offline(self):
scheduler = _make_scheduler()
scheduler.bot.is_radio_offline = True
with patch("asyncio.run_coroutine_threadsafe") as mock_rct:
scheduler.send_scheduled_message("general", "hi")
mock_rct.assert_not_called()
def test_records_success_on_successful_send(self):
scheduler = _make_scheduler()
mock_loop = Mock()
mock_loop.is_running.return_value = True
scheduler.bot.main_event_loop = mock_loop
fake_future = Mock()
fake_future.result.return_value = None
def _run_coro_threadsafe(coro, loop):
coro.close()
return fake_future
with patch("asyncio.run_coroutine_threadsafe", side_effect=_run_coro_threadsafe):
scheduler.send_scheduled_message("general", "hi")
scheduler.bot._record_send_success.assert_called_once()
def test_records_failure_on_exception(self):
scheduler = _make_scheduler()
mock_loop = Mock()
mock_loop.is_running.return_value = True
scheduler.bot.main_event_loop = mock_loop
fake_future = Mock()
fake_future.result.side_effect = Exception("bang")
def _run_coro_threadsafe(coro, loop):
coro.close()
return fake_future
with patch("asyncio.run_coroutine_threadsafe", side_effect=_run_coro_threadsafe):
scheduler.send_scheduled_message("general", "hi")
scheduler.bot._record_send_failure.assert_called_once()
# ---------------------------------------------------------------------------
# TestSendIntervalAdvertOfflineGuard
# ---------------------------------------------------------------------------
class TestSendIntervalAdvertOfflineGuard:
"""Tests for send_interval_advert() radio state suppression guards."""
def test_suppressed_when_radio_zombie(self):
scheduler = _make_scheduler()
scheduler.bot.is_radio_zombie = True
with patch("asyncio.run_coroutine_threadsafe") as mock_rct:
scheduler.send_interval_advert()
mock_rct.assert_not_called()
def test_suppressed_when_radio_offline(self):
scheduler = _make_scheduler()
scheduler.bot.is_radio_offline = True
with patch("asyncio.run_coroutine_threadsafe") as mock_rct:
scheduler.send_interval_advert()
mock_rct.assert_not_called()
def test_records_success_on_successful_send(self):
scheduler = _make_scheduler()
mock_loop = Mock()
mock_loop.is_running.return_value = True
scheduler.bot.main_event_loop = mock_loop
fake_future = Mock()
fake_future.result.return_value = None
def _run_coro_threadsafe(coro, loop):
coro.close()
return fake_future
with patch("asyncio.run_coroutine_threadsafe", side_effect=_run_coro_threadsafe):
scheduler.send_interval_advert()
scheduler.bot._record_send_success.assert_called_once()
def test_records_failure_on_exception(self):
from concurrent.futures import TimeoutError as FuturesTimeoutError
scheduler = _make_scheduler()
mock_loop = Mock()
mock_loop.is_running.return_value = True
scheduler.bot.main_event_loop = mock_loop
fake_future = Mock()
fake_future.result.side_effect = FuturesTimeoutError()
def _run_coro_threadsafe(coro, loop):
coro.close()
return fake_future
with patch("asyncio.run_coroutine_threadsafe", side_effect=_run_coro_threadsafe):
scheduler.send_interval_advert()
scheduler.bot._record_send_failure.assert_called_once()
# ---------------------------------------------------------------------------
# TestRunDataRetention
# ---------------------------------------------------------------------------
class TestRunDataRetention:
"""Tests for _run_data_retention() (lines 501581)."""
def _make(self):
scheduler = _make_scheduler()
# Remove all optional attributes so hasattr returns False by default
for attr in [
"web_viewer_integration",
"repeater_manager",
"command_manager",
"mesh_graph",
]:
if hasattr(scheduler.bot, attr):
delattr(scheduler.bot, attr)
return scheduler
def test_calls_web_viewer_cleanup_when_present(self):
scheduler = self._make()
bi_mock = Mock()
bi_mock.cleanup_old_data = Mock()
wvi_mock = Mock()
wvi_mock.bot_integration = bi_mock
scheduler.bot.web_viewer_integration = wvi_mock
scheduler._run_data_retention()
bi_mock.cleanup_old_data.assert_called_once()
def test_does_not_call_web_viewer_cleanup_when_absent(self):
scheduler = self._make()
# web_viewer_integration not set — should not raise
scheduler._run_data_retention() # must not raise
def test_calls_repeater_manager_cleanup_database_without_main_loop(self):
scheduler = self._make()
rm_mock = AsyncMock()
scheduler.bot.main_event_loop = None
scheduler.bot.repeater_manager = rm_mock
scheduler.bot.repeater_manager.cleanup_database = AsyncMock()
scheduler.bot.repeater_manager.cleanup_repeater_retention = Mock()
scheduler._run_data_retention()
scheduler.bot.repeater_manager.cleanup_repeater_retention.assert_called_once()
def test_calls_cleanup_expired_cache_when_present(self):
scheduler = self._make()
scheduler.bot.db_manager.cleanup_expired_cache = Mock()
scheduler._run_data_retention()
scheduler.bot.db_manager.cleanup_expired_cache.assert_called_once()
def test_calls_mesh_graph_delete_expired_edges_when_present(self):
scheduler = self._make()
mg_mock = Mock()
mg_mock.delete_expired_edges_from_db = Mock()
scheduler.bot.mesh_graph = mg_mock
scheduler._run_data_retention()
mg_mock.delete_expired_edges_from_db.assert_called_once()
def test_sets_last_retention_stats_ran_at_on_success(self):
scheduler = self._make()
scheduler._run_data_retention()
assert "ran_at" in scheduler._last_retention_stats
def test_sets_last_retention_stats_error_on_exception(self):
scheduler = self._make()
# Force an exception by making db_manager.set_metadata raise immediately
# inside the try block (cleanup_expired_cache doesn't exist, so no early raise;
# we inject via web_viewer_integration instead)
wvi_mock = Mock()
wvi_mock.bot_integration.cleanup_old_data = Mock(side_effect=RuntimeError("disk full"))
scheduler.bot.web_viewer_integration = wvi_mock
scheduler._run_data_retention()
assert "error" in scheduler._last_retention_stats
def test_no_error_when_db_manager_set_metadata_raises(self):
"""set_metadata failures after ran_at assignment should be silently swallowed."""
scheduler = self._make()
scheduler.bot.db_manager.set_metadata = Mock(side_effect=Exception("locked"))
# Should not propagate
scheduler._run_data_retention()
assert "ran_at" in scheduler._last_retention_stats
# ---------------------------------------------------------------------------
# TestCheckIntervalAdvertisingExtended
# ---------------------------------------------------------------------------
class TestCheckIntervalAdvertisingExtended:
"""Additional coverage for check_interval_advertising() (lines 583607)."""
def test_exception_logs_error(self):
scheduler = _make_scheduler()
# Force getint to raise
scheduler.bot.config.getint = Mock(side_effect=Exception("bad config"))
scheduler.check_interval_advertising()
scheduler.bot.logger.error.assert_called()
def test_last_advert_time_none_sets_timer_and_returns(self):
"""When last_advert_time is None, timer is set but no advert is sent."""
scheduler = _make_scheduler()
scheduler.bot.config.set("Bot", "advert_interval_hours", "2")
scheduler.bot.last_advert_time = None
with patch.object(scheduler, "send_interval_advert") as mock_send:
scheduler.check_interval_advertising()
mock_send.assert_not_called()
assert scheduler.bot.last_advert_time is not None
def test_missing_last_advert_time_attr_sets_timer(self):
"""When bot has no last_advert_time attribute, it gets initialised."""
scheduler = _make_scheduler()
scheduler.bot.config.set("Bot", "advert_interval_hours", "1")
# Delete the attribute so hasattr returns False
del scheduler.bot.last_advert_time
with patch.object(scheduler, "send_interval_advert") as mock_send:
scheduler.check_interval_advertising()
mock_send.assert_not_called()
# ---------------------------------------------------------------------------
# TestCollectEmailStats
# ---------------------------------------------------------------------------
class TestCollectEmailStats:
"""Tests for _collect_email_stats() (lines 9271014)."""
def _scheduler_with_db(self):
scheduler = _make_scheduler()
return scheduler
def test_returns_dict_type(self):
scheduler = self._scheduler_with_db()
result = scheduler._collect_email_stats()
assert isinstance(result, dict)
def test_uptime_unknown_when_no_connection_time(self):
scheduler = self._scheduler_with_db()
# MagicMock returns truthy by default for getattr; force None
scheduler.bot.connection_time = None
result = scheduler._collect_email_stats()
assert result.get("uptime") == "unknown"
def test_uptime_computed_when_connection_time_set(self):
import time as _time
scheduler = self._scheduler_with_db()
scheduler.bot.connection_time = _time.time() - 7200 # 2 hours ago
result = scheduler._collect_email_stats()
assert "2h" in result.get("uptime", "")
def test_contacts_error_key_set_when_db_raises(self):
"""When db_manager.connection() raises, contacts_error is recorded."""
scheduler = self._scheduler_with_db()
scheduler.bot.db_manager.connection.side_effect = Exception("no DB")
result = scheduler._collect_email_stats()
assert "contacts_error" in result
def test_db_size_unknown_when_db_path_missing(self):
"""When db_path attribute is missing or stat fails, db_size_mb is 'unknown'."""
scheduler = self._scheduler_with_db()
scheduler.bot.db_manager.db_path = "/nonexistent/path/test.db"
result = scheduler._collect_email_stats()
assert result.get("db_size_mb") == "unknown"
def test_retention_key_always_present(self):
scheduler = self._scheduler_with_db()
result = scheduler._collect_email_stats()
assert "retention" in result
def test_no_log_file_in_config_skips_log_stats(self):
scheduler = self._scheduler_with_db()
# No 'Logging' section → fallback empty string → log_file not set
result = scheduler._collect_email_stats()
assert "log_file" not in result
def test_contacts_totals_from_mock_cursor(self):
"""When cursor returns plausible rows, values are mapped to contacts_* keys."""
scheduler = self._scheduler_with_db()
conn_mock = MagicMock()
conn_mock.__enter__ = Mock(return_value=conn_mock)
conn_mock.__exit__ = Mock(return_value=False)
cursor_mock = MagicMock()
cursor_mock.fetchone.side_effect = [{"n": 50}, {"n": 10}, {"n": 3}]
conn_mock.cursor.return_value = cursor_mock
scheduler.bot.db_manager.connection.return_value = conn_mock
result = scheduler._collect_email_stats()
assert result.get("contacts_total") == 50
assert result.get("contacts_24h") == 10
assert result.get("contacts_new_24h") == 3
# ---------------------------------------------------------------------------
# _send_interval_advert_async (PR2 fix — Event-based error detection)
# ---------------------------------------------------------------------------
def _make_sched_with_logger(mock_logger):
"""Return a MessageScheduler backed by a mock bot with the given logger."""
bot = Mock()
bot.logger = mock_logger
bot.config = ConfigParser()
bot.config.add_section("Bot")
bot.is_radio_zombie = False # ensure zombie guard does not suppress sends
bot.is_radio_offline = False # ensure offline guard does not suppress sends
return MessageScheduler(bot)
class TestSendIntervalAdvertAsyncFixed:
"""Tests for MessageScheduler._send_interval_advert_async() (PR2 fix)."""
def test_error_event_raises_runtime_error(self, mock_logger):
from meshcore.events import EventType
sched = _make_sched_with_logger(mock_logger)
error_event = MagicMock()
error_event.type = EventType.ERROR
error_event.payload = {"reason": "no_event_received"}
sched.bot.meshcore.commands.send_advert = AsyncMock(return_value=error_event)
with pytest.raises(RuntimeError, match="send_advert failed"):
asyncio.run(sched._send_interval_advert_async())
def test_error_event_includes_reason_in_message(self, mock_logger):
from meshcore.events import EventType
sched = _make_sched_with_logger(mock_logger)
error_event = MagicMock()
error_event.type = EventType.ERROR
error_event.payload = {"reason": "no_event_received"}
sched.bot.meshcore.commands.send_advert = AsyncMock(return_value=error_event)
with pytest.raises(RuntimeError, match="no_event_received"):
asyncio.run(sched._send_interval_advert_async())
def test_ok_event_logs_success(self, mock_logger):
from meshcore.events import EventType
sched = _make_sched_with_logger(mock_logger)
ok_event = MagicMock()
ok_event.type = EventType.OK
sched.bot.meshcore.commands.send_advert = AsyncMock(return_value=ok_event)
asyncio.run(sched._send_interval_advert_async())
sched.bot.logger.info.assert_called_with(
"Interval-based flood advert sent successfully"
)
def test_send_interval_advert_logs_exception_type_name(self, mock_logger):
"""Error log must include type(e).__name__ so blank TimeoutError is visible."""
from concurrent.futures import TimeoutError as FuturesTimeoutError
sched = _make_sched_with_logger(mock_logger)
future_mock = MagicMock()
future_mock.result = MagicMock(side_effect=FuturesTimeoutError())
loop_mock = MagicMock()
loop_mock.is_running.return_value = True
sched.bot.main_event_loop = loop_mock
def _run_coro_threadsafe(coro, loop):
coro.close()
return future_mock
with patch("asyncio.run_coroutine_threadsafe", side_effect=_run_coro_threadsafe):
sched.send_interval_advert()
# The error log must include the class name, not just str(e) which
# would be empty for concurrent.futures.TimeoutError
call_args_list = mock_logger.error.call_args_list
assert call_args_list, "logger.error was never called"
logged = str(call_args_list[0])
assert "TimeoutError" in logged
# ---------------------------------------------------------------------------
# _send_scheduled_message_async (PR2 fix — asyncio.wait_for wrapping)
# ---------------------------------------------------------------------------
class TestSendScheduledMessageAsyncTimeout:
"""Tests for _send_scheduled_message_async() asyncio.wait_for wrapping (PR2)."""
def test_success_calls_send_channel_message(self, mock_logger):
sched = _make_sched_with_logger(mock_logger)
sched.bot.command_manager.send_channel_message = AsyncMock(return_value=None)
asyncio.run(sched._send_scheduled_message_async("#general", "hello"))
sched.bot.command_manager.send_channel_message.assert_awaited_once_with(
"#general", "hello"
)
def test_timeout_raises_asyncio_timeout_error(self, mock_logger):
sched = _make_sched_with_logger(mock_logger)
async def run():
async def fake_wait_for(coro, timeout):
raise asyncio.TimeoutError()
with patch("asyncio.wait_for", side_effect=fake_wait_for):
await sched._send_scheduled_message_async("#general", "hello")
with pytest.raises(asyncio.TimeoutError):
asyncio.run(run())
def test_send_timeout_seconds_config_used(self, mock_logger):
"""send_timeout_seconds from config is passed to wait_for."""
sched = _make_sched_with_logger(mock_logger)
sched.bot.config.set("Bot", "send_timeout_seconds", "45")
sched.bot.command_manager.send_channel_message = AsyncMock(return_value=None)
captured_timeout = []
async def spy_wait_for(coro, timeout):
captured_timeout.append(timeout)
return await coro
async def run():
with patch("asyncio.wait_for", side_effect=spy_wait_for):
await sched._send_scheduled_message_async("#general", "hello")
asyncio.run(run())
assert captured_timeout == [45]
def test_send_scheduled_message_logs_exception_type_name(self, mock_logger):
"""Error log must include type(e).__name__."""
from concurrent.futures import TimeoutError as FuturesTimeoutError
sched = _make_sched_with_logger(mock_logger)
future_mock = MagicMock()
future_mock.result = MagicMock(side_effect=FuturesTimeoutError())
loop_mock = MagicMock()
loop_mock.is_running.return_value = True
sched.bot.main_event_loop = loop_mock
def _run_coro_threadsafe(coro, loop):
coro.close()
return future_mock
with patch("asyncio.run_coroutine_threadsafe", side_effect=_run_coro_threadsafe):
sched.send_scheduled_message("#general", "hello")
call_args_list = mock_logger.error.call_args_list
assert call_args_list, "logger.error was never called"
logged = str(call_args_list[0])
assert "TimeoutError" in logged
# ---------------------------------------------------------------------------
# SSRF guard — SMTP host validation in email-sending methods
# ---------------------------------------------------------------------------
def _make_smtp_scheduler(
notif_overrides: dict,
meta_overrides: dict | None = None,
) -> "MessageScheduler":
"""Return a scheduler whose maintenance.get_notif returns values from notif_overrides."""
bot = Mock()
bot.logger = Mock()
bot.config = ConfigParser()
bot.config.add_section("Bot")
bot.config.set("Bot", "radio_zombie_alert_enabled", "true")
bot.config.set("Bot", "radio_zombie_alert_email", "")
bot.config.add_section("Connection")
bot.config.set("Connection", "connection_type", "serial")
metadata = dict(meta_overrides or {})
bot.db_manager = Mock()
bot.db_manager.get_metadata = Mock(side_effect=lambda key: metadata.get(key, ""))
sched = MessageScheduler(bot)
defaults = {
"nightly_enabled": "true",
"smtp_host": "smtp.example.com",
"smtp_port": "587",
"smtp_security": "starttls",
"smtp_user": "",
"smtp_password": "",
"from_name": "Bot",
"from_email": "bot@example.com",
"recipients": "admin@example.com",
}
defaults.update(notif_overrides)
sched.maintenance.get_notif = Mock(side_effect=lambda k: defaults.get(k, ""))
sched._get_notif = Mock(side_effect=lambda k: defaults.get(k, "")) # zombie alert still uses _get_notif
return sched
class TestNightlyEmailSsrfGuard:
"""send_nightly_email must abort on private IP unless allow_local_smtp=true."""
@pytest.mark.parametrize("bad_host", [
"10.0.0.1", # RFC 1918 10.0.0.0/8
"172.16.0.1", # RFC 1918 172.16.0.0/12
"192.168.1.1", # RFC 1918 192.168.0.0/16
"127.0.0.1", # RFC 1122 127.0.0.0/8 loopback
"169.254.0.1", # RFC 3927 169.254.0.0/16 link-local
"100.64.0.1", # RFC 6598 100.64.0.0/10 shared/CGN
"::1", # RFC 4291 ::1/128 IPv6 loopback
"fd00::1", # RFC 4193 fc00::/7 IPv6 ULA
"fe80::1", # RFC 4291 fe80::/10 IPv6 link-local
])
def test_private_smtp_host_aborts_nightly_email(self, bad_host):
sched = _make_smtp_scheduler({"smtp_host": bad_host})
sched.maintenance.send_nightly_email()
sched.bot.logger.error.assert_called()
logged = str(sched.bot.logger.error.call_args_list)
assert "private" in logged.lower() or "reserved" in logged.lower()
@pytest.mark.parametrize("local_host", ["127.0.0.1", "192.168.1.1"])
def test_allow_local_smtp_bypasses_ssrf_guard(self, local_host):
"""allow_local_smtp=true permits private-IP SMTP (e.g. local Postfix)."""
sched = _make_smtp_scheduler({"smtp_host": local_host, "allow_local_smtp": "true"})
# Should not abort at the SSRF guard — will fail at smtplib (connection refused)
# so error log must NOT contain the SSRF rejection message
with patch("smtplib.SMTP", side_effect=ConnectionRefusedError("no server")):
with patch("smtplib.SMTP_SSL", side_effect=ConnectionRefusedError("no server")):
sched.maintenance.send_nightly_email()
logged = str(sched.bot.logger.error.call_args_list)
assert "private" not in logged.lower() and "reserved" not in logged.lower()
class TestZombieAlertEmailSsrfGuard:
"""send_zombie_alert_email must abort on private IP unless allow_local_smtp=true."""
@pytest.mark.parametrize("bad_host", [
"10.0.0.1", # RFC 1918 10.0.0.0/8
"172.16.0.1", # RFC 1918 172.16.0.0/12
"192.168.1.1", # RFC 1918 192.168.0.0/16
"127.0.0.1", # RFC 1122 127.0.0.0/8 loopback
"169.254.0.1", # RFC 3927 169.254.0.0/16 link-local
"100.64.0.1", # RFC 6598 100.64.0.0/10 shared/CGN
"::1", # RFC 4291 ::1/128 IPv6 loopback
"fd00::1", # RFC 4193 fc00::/7 IPv6 ULA
"fe80::1", # RFC 4291 fe80::/10 IPv6 link-local
])
def test_private_smtp_host_aborts_zombie_alert(self, bad_host):
sched = _make_smtp_scheduler({"smtp_host": bad_host})
sched.send_zombie_alert_email(fail_count=5, threshold=3, interval=60)
sched.bot.logger.error.assert_called()
logged = str(sched.bot.logger.error.call_args_list)
assert "private" in logged.lower() or "reserved" in logged.lower()
def test_allow_local_smtp_bypasses_ssrf_guard_zombie_alert(self):
"""allow_local_smtp=true permits private-IP SMTP for zombie alert."""
sched = _make_smtp_scheduler({"smtp_host": "127.0.0.1", "allow_local_smtp": "true"})
with patch("smtplib.SMTP", side_effect=ConnectionRefusedError("no server")):
with patch("smtplib.SMTP_SSL", side_effect=ConnectionRefusedError("no server")):
sched.send_zombie_alert_email(fail_count=5, threshold=3, interval=60)
logged = str(sched.bot.logger.error.call_args_list)
assert "private" not in logged.lower() and "reserved" not in logged.lower()
def test_metadata_alert_enabled_false_suppresses_zombie_alert(self):
"""zombie.alert_enabled metadata should suppress alert sending immediately."""
sched = _make_smtp_scheduler(
{"smtp_host": "smtp.example.com", "from_email": "bot@example.com"},
{"zombie.alert_enabled": "false"},
)
with patch("smtplib.SMTP") as mock_smtp:
with patch("smtplib.SMTP_SSL") as mock_smtp_ssl:
sched.send_zombie_alert_email(fail_count=5, threshold=3, interval=60)
mock_smtp.assert_not_called()
mock_smtp_ssl.assert_not_called()