mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-09-25 09:03:36 +00:00
- Enhanced the coroutine execution logic in the MessageScheduler to ensure proper closure of coroutines when the main event loop is not running, preventing potential resource leaks and runtime warnings. - Updated tests to utilize a dedicated method for retrieving scheduled message jobs, improving clarity and maintainability of the test suite.
1861 lines
78 KiB
Python
1861 lines
78 KiB
Python
"""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
|
||
from zoneinfo import ZoneInfo
|
||
|
||
import pytest
|
||
|
||
from modules.scheduled_message_cron import parse_schedule_key
|
||
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")
|
||
bot.config.set("Bot", "scheduled_message_max_stagger_seconds", "0")
|
||
return MessageScheduler(bot)
|
||
|
||
|
||
class TestParseScheduleKey:
|
||
"""Tests for scheduled message cron / preset / legacy parsing."""
|
||
|
||
def test_five_field_cron(self):
|
||
tz = ZoneInfo("UTC")
|
||
r = parse_schedule_key("30 14 * * *", tz)
|
||
assert r.trigger is not None
|
||
assert r.display_label == "30 14 * * *"
|
||
assert r.is_deprecated_hhmm is False
|
||
|
||
def test_at_daily_preset(self):
|
||
tz = ZoneInfo("UTC")
|
||
r = parse_schedule_key("@daily", tz)
|
||
assert r.trigger is not None
|
||
assert r.display_label == "@daily"
|
||
assert r.is_deprecated_hhmm is False
|
||
|
||
def test_legacy_hhmm(self):
|
||
tz = ZoneInfo("UTC")
|
||
r = parse_schedule_key("0900", tz)
|
||
assert r.trigger is not None
|
||
assert r.display_label == "09:00"
|
||
assert r.is_deprecated_hhmm is True
|
||
|
||
def test_invalid_expression(self):
|
||
tz = ZoneInfo("UTC")
|
||
r = parse_schedule_key("not-a-valid-cron", tz)
|
||
assert r.trigger is None
|
||
|
||
|
||
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 _message_jobs(self, scheduler):
|
||
"""Jobs registered for [Scheduled_Messages] entries.
|
||
|
||
setup_scheduled_messages() also registers device-mode housekeeping jobs
|
||
because auto_manage_contacts defaults to 'device', so a raw get_jobs()
|
||
count conflates the two.
|
||
"""
|
||
return [
|
||
j for j in scheduler._apscheduler.get_jobs() if j.id.startswith("schedmsg_")
|
||
]
|
||
|
||
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, label, scope = scheduler.scheduled_messages["0900"]
|
||
assert scope is None
|
||
assert channel == "general"
|
||
assert "Good morning!" in message
|
||
assert label == "09:00"
|
||
assert len(self._message_jobs(scheduler)) == 1
|
||
self._teardown(scheduler)
|
||
|
||
def test_deprecated_hhmm_logs_migration_warning(self, scheduler):
|
||
scheduler.bot.config.add_section("Scheduled_Messages")
|
||
scheduler.bot.config.set("Scheduled_Messages", "0900", "general: Hi")
|
||
self._setup_and_call(scheduler)
|
||
warns = [str(c) for c in scheduler.bot.logger.warning.call_args_list]
|
||
assert any("deprecated" in w.lower() for w in warns)
|
||
assert any("HHMM" in w for w in warns)
|
||
self._teardown(scheduler)
|
||
|
||
def test_five_field_cron_registered(self, scheduler):
|
||
scheduler.bot.config.add_section("Scheduled_Messages")
|
||
scheduler.bot.config.set(
|
||
"Scheduled_Messages", "0 9 * * *", "general: Morning cron"
|
||
)
|
||
self._setup_and_call(scheduler)
|
||
assert "0 9 * * *" in scheduler.scheduled_messages
|
||
ch, msg, label, _scope = scheduler.scheduled_messages["0 9 * * *"]
|
||
assert _scope is None
|
||
assert ch == "general"
|
||
assert "Morning cron" in msg
|
||
assert label == "0 9 * * *"
|
||
msg_jobs = self._message_jobs(scheduler)
|
||
assert len(msg_jobs) == 1
|
||
assert msg_jobs[0].kwargs == {"schedule_key": "0 9 * * *", "scope": None}
|
||
self._teardown(scheduler)
|
||
|
||
def test_scoped_flood_message_stores_scope_and_job_kwargs(self, scheduler):
|
||
scheduler.bot.config.add_section("Scheduled_Messages")
|
||
scheduler.bot.config.set(
|
||
"Scheduled_Messages",
|
||
"0 18 * * *",
|
||
"Public:#sea:Hello! Come send test messages.",
|
||
)
|
||
self._setup_and_call(scheduler)
|
||
assert "0 18 * * *" in scheduler.scheduled_messages
|
||
ch, msg, label, scope = scheduler.scheduled_messages["0 18 * * *"]
|
||
assert ch == "Public"
|
||
assert scope == "#sea"
|
||
assert msg.startswith("Hello!")
|
||
msg_jobs = self._message_jobs(scheduler)
|
||
assert len(msg_jobs) == 1
|
||
assert msg_jobs[0].kwargs == {"schedule_key": "0 18 * * *", "scope": "#sea"}
|
||
self._teardown(scheduler)
|
||
|
||
def test_at_weekly_preset_registered(self, scheduler):
|
||
scheduler.bot.config.add_section("Scheduled_Messages")
|
||
scheduler.bot.config.set(
|
||
"Scheduled_Messages", "@weekly", "alerts: Weekly digest"
|
||
)
|
||
self._setup_and_call(scheduler)
|
||
assert "@weekly" in scheduler.scheduled_messages
|
||
assert scheduler.scheduled_messages["@weekly"][2] == "@weekly"
|
||
assert len(self._message_jobs(scheduler)) == 1
|
||
self._teardown(scheduler)
|
||
|
||
def test_invalid_cron_skipped(self, scheduler):
|
||
scheduler.bot.config.add_section("Scheduled_Messages")
|
||
scheduler.bot.config.set(
|
||
"Scheduled_Messages", "not-a-cron", "general: should not run"
|
||
)
|
||
self._setup_and_call(scheduler)
|
||
assert "not-a-cron" not in scheduler.scheduled_messages
|
||
assert len(self._message_jobs(scheduler)) == 0
|
||
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(self._message_jobs(scheduler)) == 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, label, _sc = scheduler.scheduled_messages["1000"]
|
||
assert _sc is None
|
||
assert "\n" in message
|
||
assert label == "10:00"
|
||
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(self._message_jobs(scheduler)) == 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_legacy_hhmm(self, scheduler):
|
||
"""Legacy HHMM keys produce 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()
|
||
msg_jobs = [j for j in scheduler._apscheduler.get_jobs() if j.id.startswith("schedmsg_")]
|
||
assert len(msg_jobs) == 1
|
||
trigger = msg_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)
|
||
|
||
def test_cron_trigger_from_five_field_expression(self, scheduler):
|
||
"""5-field crontab keys produce the expected hour/minute fields."""
|
||
scheduler.bot.config.add_section("Scheduled_Messages")
|
||
scheduler.bot.config.set("Scheduled_Messages", "15 10 * * *", "ch: ping")
|
||
scheduler.setup_scheduled_messages()
|
||
msg_jobs = [j for j in scheduler._apscheduler.get_jobs() if j.id.startswith("schedmsg_")]
|
||
assert len(msg_jobs) == 1
|
||
trigger = msg_jobs[0].trigger
|
||
field_map = {f.name: f for f in trigger.fields}
|
||
assert str(field_map["hour"]) == "10"
|
||
assert str(field_map["minute"]) == "15"
|
||
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")
|
||
config.set("Bot", "scheduled_message_max_stagger_seconds", "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 152–293)."""
|
||
|
||
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 308–328)."""
|
||
|
||
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", skip_user_rate_limit=True, scope=None
|
||
)
|
||
|
||
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", skip_user_rate_limit=True, scope=None
|
||
)
|
||
|
||
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}", skip_user_rate_limit=True, scope=None
|
||
)
|
||
|
||
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}", skip_user_rate_limit=True, scope=None
|
||
)
|
||
|
||
def test_passes_scope_to_send_channel_message(self):
|
||
scheduler = _make_scheduler()
|
||
scheduler.bot.command_manager.send_channel_message = AsyncMock()
|
||
asyncio.run(
|
||
scheduler._send_scheduled_message_async(
|
||
"Public", "Hi", scope="#sea"
|
||
)
|
||
)
|
||
scheduler.bot.command_manager.send_channel_message.assert_called_once_with(
|
||
"Public", "Hi", skip_user_rate_limit=True, scope="#sea"
|
||
)
|
||
|
||
def test_stagger_invokes_sleep_when_configured(self):
|
||
"""Nonzero scheduled_message_max_stagger_seconds yields await sleep in [0, max)."""
|
||
scheduler = _make_scheduler()
|
||
scheduler.bot.config.set("Bot", "scheduled_message_max_stagger_seconds", "100")
|
||
scheduler.bot.command_manager.send_channel_message = AsyncMock()
|
||
with patch("modules.scheduler.asyncio.sleep", new_callable=AsyncMock) as mock_sleep:
|
||
asyncio.run(
|
||
scheduler._send_scheduled_message_async("g", "m", schedule_key="0 8 * * *")
|
||
)
|
||
mock_sleep.assert_called_once()
|
||
delay = mock_sleep.call_args[0][0]
|
||
assert 0 <= delay < 100
|
||
|
||
def test_stagger_seconds_deterministic_for_same_key(self):
|
||
scheduler = _make_scheduler()
|
||
scheduler.bot.config.set("Bot", "scheduled_message_max_stagger_seconds", "10")
|
||
a = scheduler._scheduled_message_stagger_seconds("0 8 * * 2")
|
||
b = scheduler._scheduled_message_stagger_seconds("0 8 * * 2")
|
||
assert a == b
|
||
assert 0 <= a < 10
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# TestSendScheduledMessageWrapper
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
class TestSendScheduledMessageWrapper:
|
||
"""Tests for the sync send_scheduled_message() wrapper (lines 121–150)."""
|
||
|
||
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,
|
||
*,
|
||
schedule_key: str = "",
|
||
scope: str | None = None,
|
||
) -> 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", schedule_key="", scope=None
|
||
)
|
||
|
||
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 501–581)."""
|
||
|
||
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 583–607)."""
|
||
|
||
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 927–1014)."""
|
||
|
||
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.config.set("Bot", "scheduled_message_max_stagger_seconds", "0")
|
||
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_timeout_increments_radio_fail_count_and_reraises(self, mock_logger):
|
||
sched = _make_sched_with_logger(mock_logger)
|
||
sched.bot._radio_fail_count = 2
|
||
|
||
async def run():
|
||
async def fake_wait_for(coro, timeout):
|
||
coro.close()
|
||
raise asyncio.TimeoutError()
|
||
|
||
with patch("asyncio.wait_for", side_effect=fake_wait_for):
|
||
await sched._send_interval_advert_async()
|
||
|
||
with pytest.raises(asyncio.TimeoutError):
|
||
asyncio.run(run())
|
||
|
||
assert sched.bot._radio_fail_count == 3
|
||
sched.bot.logger.warning.assert_called_with(
|
||
"send_interval_advert timed out after 30s; _radio_fail_count=%d",
|
||
3,
|
||
)
|
||
|
||
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", skip_user_rate_limit=True, scope=None
|
||
)
|
||
|
||
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()
|
||
|
||
|
||
class TestDeviceModeSchedulerJobs:
|
||
"""_setup_device_mode_scheduler_jobs registers one-shot jobs when auto_manage_contacts=device."""
|
||
|
||
def test_registers_three_jobs_in_device_mode(self, scheduler):
|
||
scheduler.bot.config.set("Bot", "auto_manage_contacts", "device")
|
||
mock_ap = MagicMock()
|
||
scheduler._apscheduler = mock_ap
|
||
scheduler._setup_device_mode_scheduler_jobs()
|
||
assert mock_ap.add_job.call_count == 3
|
||
ids = {call.kwargs["id"] for call in mock_ap.add_job.call_args_list}
|
||
assert ids == {
|
||
"device_mode_firmware_autoadd",
|
||
"device_mode_favourite_pass1",
|
||
"device_mode_favourite_pass2",
|
||
}
|
||
for call in mock_ap.add_job.call_args_list:
|
||
assert call.kwargs.get("replace_existing") is True
|
||
|
||
def test_no_jobs_when_not_device_mode(self, scheduler):
|
||
scheduler.bot.config.set("Bot", "auto_manage_contacts", "bot")
|
||
mock_ap = MagicMock()
|
||
scheduler._apscheduler = mock_ap
|
||
scheduler._setup_device_mode_scheduler_jobs()
|
||
mock_ap.add_job.assert_not_called()
|
||
|
||
def test_firmware_job_sync_skips_when_not_device(self, scheduler):
|
||
scheduler.bot.config.set("Bot", "auto_manage_contacts", "bot")
|
||
with patch.object(scheduler, "_run_async_on_main_loop") as mock_run:
|
||
scheduler._device_mode_firmware_job_sync()
|
||
mock_run.assert_not_called()
|
||
|
||
def test_run_async_warns_without_running_loop(self, scheduler, mock_logger):
|
||
scheduler.bot.logger = mock_logger
|
||
scheduler.bot.main_event_loop = None
|
||
|
||
async def trivial():
|
||
pass
|
||
|
||
coro = trivial()
|
||
try:
|
||
scheduler._run_async_on_main_loop(coro, timeout=1.0)
|
||
finally:
|
||
coro.close()
|
||
mock_logger.warning.assert_called()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_device_mode_firmware_coro_calls_repeater_manager(self, scheduler):
|
||
scheduler.bot.config.set("Bot", "auto_manage_contacts", "device")
|
||
scheduler.bot.repeater_manager = Mock()
|
||
scheduler.bot.repeater_manager.apply_device_mode_firmware_preferences = AsyncMock(return_value=True)
|
||
|
||
await scheduler._device_mode_firmware_coro()
|
||
|
||
scheduler.bot.repeater_manager.apply_device_mode_firmware_preferences.assert_awaited_once()
|
||
|
||
@pytest.mark.asyncio
|
||
async def test_device_mode_favourite_coros_delegate(self, scheduler):
|
||
scheduler.bot.config.set("Bot", "auto_manage_contacts", "device")
|
||
scheduler.bot.repeater_manager = Mock()
|
||
scheduler.bot.repeater_manager.sync_device_mode_favourites_pass1 = AsyncMock()
|
||
scheduler.bot.repeater_manager.sync_device_mode_favourites_pass2 = AsyncMock()
|
||
|
||
await scheduler._device_mode_favourite_pass1_coro()
|
||
await scheduler._device_mode_favourite_pass2_coro()
|
||
|
||
scheduler.bot.repeater_manager.sync_device_mode_favourites_pass1.assert_awaited_once()
|
||
scheduler.bot.repeater_manager.sync_device_mode_favourites_pass2.assert_awaited_once()
|