From d699ea1cf1e63bff3bac5fad374ab10f0accab52 Mon Sep 17 00:00:00 2001 From: agessaman Date: Thu, 12 Feb 2026 19:23:35 -0800 Subject: [PATCH] Update configuration handling and validation for bot sections - Enhanced .gitignore to allow test files in the tests/ directory and committed pytest.ini for test discovery. - Added checks for missing sections in configuration files, specifically for Admin_ACL and Banned_Users, to prevent errors during bot startup. - Updated generate_website.py and command_manager.py to handle cases where required sections are absent, returning empty lists instead of raising exceptions. - Introduced optional dependencies for testing in pyproject.toml, ensuring a smoother development experience. - Improved localization handling in core.py to default to English when the Localization section is missing, enhancing user experience. --- .github/workflows/test.yml | 33 ++ .gitignore | 8 +- generate_website.py | 2 + modules/command_manager.py | 2 + modules/commands/announcements_command.py | 2 + modules/commands/base_command.py | 4 +- modules/commands/path_command.py | 242 ++++---- modules/config_validation.py | 140 ++++- modules/core.py | 16 +- modules/mesh_graph.py | 30 +- modules/repeater_manager.py | 2 +- pyproject.toml | 1 + pytest.ini | 29 + tests/README.md | 200 +++++++ tests/__init__.py | 1 + tests/commands/__init__.py | 1 + tests/commands/test_base_command.py | 105 ++++ tests/commands/test_cmd_command.py | 41 ++ tests/commands/test_dice_command.py | 124 ++++ tests/commands/test_hello_command.py | 82 +++ tests/commands/test_help_command.py | 33 ++ tests/commands/test_magic8_command.py | 49 ++ tests/commands/test_ping_command.py | 38 ++ tests/commands/test_roll_command.py | 77 +++ tests/conftest.py | 253 ++++++++ tests/helpers.py | 155 +++++ tests/integration/__init__.py | 1 + .../test_path_graph_integration.py | 338 +++++++++++ tests/integration/test_path_resolution.py | 177 ++++++ tests/regression/__init__.py | 1 + tests/regression/test_keyword_escapes.py | 23 + tests/test_command_prefix.py | 289 ++++++++++ tests/test_config_validation.py | 339 +++++++++++ tests/test_rate_limiter.py | 78 +++ tests/test_security_utils.py | 135 +++++ tests/test_utils.py | 187 ++++++ tests/unit/__init__.py | 1 + tests/unit/test_mesh_graph.py | 539 ++++++++++++++++++ tests/unit/test_mesh_graph_edges.py | 173 ++++++ tests/unit/test_mesh_graph_multihop.py | 158 +++++ tests/unit/test_mesh_graph_scoring.py | 122 ++++ tests/unit/test_mesh_graph_validation.py | 154 +++++ tests/unit/test_path_command_graph.py | 258 +++++++++ .../unit/test_path_command_graph_selection.py | 155 +++++ 44 files changed, 4674 insertions(+), 124 deletions(-) create mode 100644 .github/workflows/test.yml create mode 100644 pytest.ini create mode 100644 tests/README.md create mode 100644 tests/__init__.py create mode 100644 tests/commands/__init__.py create mode 100644 tests/commands/test_base_command.py create mode 100644 tests/commands/test_cmd_command.py create mode 100644 tests/commands/test_dice_command.py create mode 100644 tests/commands/test_hello_command.py create mode 100644 tests/commands/test_help_command.py create mode 100644 tests/commands/test_magic8_command.py create mode 100644 tests/commands/test_ping_command.py create mode 100644 tests/commands/test_roll_command.py create mode 100644 tests/conftest.py create mode 100644 tests/helpers.py create mode 100644 tests/integration/__init__.py create mode 100644 tests/integration/test_path_graph_integration.py create mode 100644 tests/integration/test_path_resolution.py create mode 100644 tests/regression/__init__.py create mode 100644 tests/regression/test_keyword_escapes.py create mode 100644 tests/test_command_prefix.py create mode 100644 tests/test_config_validation.py create mode 100644 tests/test_rate_limiter.py create mode 100644 tests/test_security_utils.py create mode 100644 tests/test_utils.py create mode 100644 tests/unit/__init__.py create mode 100644 tests/unit/test_mesh_graph.py create mode 100644 tests/unit/test_mesh_graph_edges.py create mode 100644 tests/unit/test_mesh_graph_multihop.py create mode 100644 tests/unit/test_mesh_graph_scoring.py create mode 100644 tests/unit/test_mesh_graph_validation.py create mode 100644 tests/unit/test_path_command_graph.py create mode 100644 tests/unit/test_path_command_graph_selection.py diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml new file mode 100644 index 0000000..467393d --- /dev/null +++ b/.github/workflows/test.yml @@ -0,0 +1,33 @@ +name: Tests + +on: + push: + branches: + - main + - master + - dev + pull_request: + branches: + - main + - master + - dev + workflow_dispatch: + +jobs: + test: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install -e ".[test]" + + - name: Run tests + run: pytest tests/ -v --tb=short diff --git a/.gitignore b/.gitignore index 5587e17..934a282 100644 --- a/.gitignore +++ b/.gitignore @@ -129,20 +129,22 @@ mctomqtt.py bot_cli.py bot_start_time.txt -# Test files and development artifacts +# Test files and development artifacts (root level) test_*.py test_*.db test_*.log *_test.py *_test.db +# Allow tests/ directory (pytest convention) +!tests/ +!tests/**/*.py # Local documentation and development files docs/local/ -tests/ test_scripts/ dev/ website/ -pytest.ini +# pytest.ini committed for asyncio_mode and test discovery pymc-test/* config-pymc.ini *.key diff --git a/generate_website.py b/generate_website.py index 5dc1c00..52ea432 100755 --- a/generate_website.py +++ b/generate_website.py @@ -933,6 +933,8 @@ def get_bot_name(config: configparser.ConfigParser) -> str: def get_admin_commands(config: configparser.ConfigParser) -> List[str]: """Extract admin commands from config""" + if not config.has_section('Admin_ACL'): + return [] admin_commands_str = config.get('Admin_ACL', 'admin_commands', fallback='') if not admin_commands_str: return [] diff --git a/modules/command_manager.py b/modules/command_manager.py index 5414f21..6ab97d5 100644 --- a/modules/command_manager.py +++ b/modules/command_manager.py @@ -408,6 +408,8 @@ class CommandManager: def load_banned_users(self) -> List[str]: """Load banned users from config""" + if not self.bot.config.has_section('Banned_Users'): + return [] banned = self.bot.config.get('Banned_Users', 'banned_users', fallback='') return [user.strip() for user in banned.split(',') if user.strip()] diff --git a/modules/commands/announcements_command.py b/modules/commands/announcements_command.py index 17e170f..9b4b9f5 100644 --- a/modules/commands/announcements_command.py +++ b/modules/commands/announcements_command.py @@ -88,6 +88,8 @@ class AnnouncementsCommand(BaseCommand): # Always include admin ACL members (inheritance) try: + if not self.bot.config.has_section('Admin_ACL'): + return acl_list admin_pubkeys = self.bot.config.get('Admin_ACL', 'admin_pubkeys', fallback='') if admin_pubkeys and admin_pubkeys.strip(): for key in admin_pubkeys.split(','): diff --git a/modules/commands/base_command.py b/modules/commands/base_command.py index 566557d..1a7b3d8 100644 --- a/modules/commands/base_command.py +++ b/modules/commands/base_command.py @@ -858,7 +858,7 @@ class BaseCommand(ABC): def requires_admin_access(self) -> bool: """Check if this command requires admin access""" - if not hasattr(self.bot, 'config'): + if not hasattr(self.bot, 'config') or not self.bot.config.has_section('Admin_ACL'): return False try: @@ -886,7 +886,7 @@ class BaseCommand(ABC): - Uses centralized validate_pubkey_format() function """ import re # This import is needed for re.match - if not hasattr(self.bot, 'config'): + if not hasattr(self.bot, 'config') or not self.bot.config.has_section('Admin_ACL'): return False try: diff --git a/modules/commands/path_command.py b/modules/commands/path_command.py index d942cd4..9819610 100644 --- a/modules/commands/path_command.py +++ b/modules/commands/path_command.py @@ -7,7 +7,7 @@ Decodes hex path data to show which repeaters were involved in message routing import re import time import asyncio -from typing import List, Optional, Dict, Any, Tuple +from typing import List, Optional, Dict, Any, Tuple, Callable from .base_command import BaseCommand from ..models import MeshMessage from ..utils import calculate_distance @@ -246,109 +246,148 @@ class PathCommand(BaseCommand): self.logger.error(f"Error decoding path: {e}") return self.translate('commands.path.error_decoding', error=str(e)) - async def _lookup_repeater_names(self, node_ids: List[str]) -> Dict[str, Dict[str, Any]]: - """Look up repeater names for given node IDs""" + async def _lookup_repeater_names( + self, + node_ids: List[str], + lookup_func: Optional[Callable[[str], List[Dict[str, Any]]]] = None, + ) -> Dict[str, Dict[str, Any]]: + """Look up repeater names for given node IDs. + + Args: + node_ids: List of node prefixes to look up. + lookup_func: Optional test hook. When provided, used instead of + repeater_manager/db_manager. Callable(node_id) -> list of repeater dicts. + """ repeater_info = {} - + try: # Skip API cache for path decoding - use database with improved proximity logic # API cache doesn't have recency-based proximity selection needed for path decoding api_data = None - + # Query the database for repeaters with matching prefixes # Node IDs are typically the first 2 characters of the public key for node_id in node_ids: - # First try complete tracking database (all heard contacts, filtered by role) - if hasattr(self.bot, 'repeater_manager'): - try: - # Get repeater devices from complete database (repeaters and roomservers) - complete_db = await self.bot.repeater_manager.get_repeater_devices(include_historical=True) - - results = [] - for row in complete_db: - if row['public_key'].startswith(node_id): - results.append({ - 'name': row['name'], - 'public_key': row['public_key'], - 'device_type': row['device_type'], - 'last_seen': row['last_heard'], - 'last_heard': row['last_heard'], # Include last_heard for recency calculation - 'last_advert_timestamp': row.get('last_advert_timestamp'), # Include last_advert_timestamp for recency calculation - 'is_active': row['is_currently_tracked'], - 'latitude': row['latitude'], - 'longitude': row['longitude'], - 'city': row['city'], - 'state': row['state'], - 'country': row['country'], - 'advert_count': row['advert_count'], - 'signal_strength': row['signal_strength'], - 'snr': row.get('snr'), # Include SNR for zero-hop bonus - 'hop_count': row['hop_count'], - 'role': row['role'], - 'is_starred': bool(row.get('is_starred', 0)) # Include star status for bias - }) - except Exception as e: - self.logger.debug(f"Error getting complete database: {e}") - results = [] - - # If complete tracking database failed, try direct query to complete_contact_tracking - if not results: - try: - # Build query with age filtering if configured - # Use last_advert_timestamp if available, otherwise fall back to last_heard - if self.max_repeater_age_days > 0: - query = ''' - SELECT name, public_key, device_type, last_heard, last_heard as last_seen, - last_advert_timestamp, latitude, longitude, city, state, country, - advert_count, signal_strength, snr, hop_count, role, is_starred - FROM complete_contact_tracking - WHERE public_key LIKE ? AND role IN ('repeater', 'roomserver') - AND ( - (last_advert_timestamp IS NOT NULL AND last_advert_timestamp >= datetime('now', '-{} days')) - OR (last_advert_timestamp IS NULL AND last_heard >= datetime('now', '-{} days')) - ) - ORDER BY COALESCE(last_advert_timestamp, last_heard) DESC - '''.format(self.max_repeater_age_days, self.max_repeater_age_days) - else: - query = ''' - SELECT name, public_key, device_type, last_heard, last_heard as last_seen, - last_advert_timestamp, latitude, longitude, city, state, country, - advert_count, signal_strength, snr, hop_count, role, is_starred - FROM complete_contact_tracking - WHERE public_key LIKE ? AND role IN ('repeater', 'roomserver') - ORDER BY COALESCE(last_advert_timestamp, last_heard) DESC - ''' - - prefix_pattern = f"{node_id}%" - results = self.bot.db_manager.execute_query(query, (prefix_pattern,)) - - # Convert results to expected format - if results: - results = [ - { - 'name': row['name'], - 'public_key': row['public_key'], - 'device_type': row['device_type'], - 'last_seen': row['last_seen'], - 'last_heard': row.get('last_heard', row['last_seen']), # Include last_heard for recency calculation - 'last_advert_timestamp': row.get('last_advert_timestamp'), # Include last_advert_timestamp for recency calculation - 'is_active': True, # Assume active for path purposes - 'latitude': row['latitude'], - 'longitude': row['longitude'], - 'city': row['city'], - 'state': row['state'], - 'country': row['country'], - 'advert_count': row.get('advert_count', 0), - 'signal_strength': row.get('signal_strength'), - 'snr': row.get('snr'), # Include SNR for zero-hop bonus - 'hop_count': row.get('hop_count'), - 'role': row.get('role'), - 'is_starred': bool(row.get('is_starred', 0)) # Include star status for bias - } for row in results - ] - except Exception as e: - self.logger.debug(f"Error querying complete_contact_tracking directly: {e}") - results = [] + # Test dependency injection: use provided lookup when available + if lookup_func is not None: + results = lookup_func(node_id) + # Normalize to expected format (create_test_repeater already matches) + if results: + results = [ + { + 'name': r['name'], + 'public_key': r['public_key'], + 'device_type': r.get('device_type', 'repeater'), + 'last_seen': r.get('last_seen', r.get('last_heard')), + 'last_heard': r.get('last_heard', r.get('last_seen')), + 'last_advert_timestamp': r.get('last_advert_timestamp'), + 'is_active': r.get('is_active', True), + 'latitude': r.get('latitude'), + 'longitude': r.get('longitude'), + 'city': r.get('city'), + 'state': r.get('state'), + 'country': r.get('country'), + 'advert_count': r.get('advert_count', 1), + 'signal_strength': r.get('signal_strength'), + 'snr': r.get('snr'), + 'hop_count': r.get('hop_count'), + 'role': r.get('role', 'repeater'), + 'is_starred': bool(r.get('is_starred', False)), + } + for r in results + ] + else: + # First try complete tracking database (all heard contacts, filtered by role) + results = [] + if hasattr(self.bot, 'repeater_manager'): + try: + # Get repeater devices from complete database (repeaters and roomservers) + complete_db = await self.bot.repeater_manager.get_repeater_devices(include_historical=True) + + for row in complete_db: + if row['public_key'].startswith(node_id): + results.append({ + 'name': row['name'], + 'public_key': row['public_key'], + 'device_type': row['device_type'], + 'last_seen': row['last_heard'], + 'last_heard': row['last_heard'], # Include last_heard for recency calculation + 'last_advert_timestamp': row.get('last_advert_timestamp'), # Include last_advert_timestamp for recency calculation + 'is_active': row['is_currently_tracked'], + 'latitude': row['latitude'], + 'longitude': row['longitude'], + 'city': row['city'], + 'state': row['state'], + 'country': row['country'], + 'advert_count': row['advert_count'], + 'signal_strength': row['signal_strength'], + 'snr': row.get('snr'), # Include SNR for zero-hop bonus + 'hop_count': row['hop_count'], + 'role': row['role'], + 'is_starred': bool(row.get('is_starred', 0)) # Include star status for bias + }) + except Exception as e: + self.logger.debug(f"Error getting complete database: {e}") + results = [] + + # If complete tracking database failed, try direct query to complete_contact_tracking + if not results: + try: + # Build query with age filtering if configured + # Use last_advert_timestamp if available, otherwise fall back to last_heard + if self.max_repeater_age_days > 0: + query = ''' + SELECT name, public_key, device_type, last_heard, last_heard as last_seen, + last_advert_timestamp, latitude, longitude, city, state, country, + advert_count, signal_strength, snr, hop_count, role, is_starred + FROM complete_contact_tracking + WHERE public_key LIKE ? AND role IN ('repeater', 'roomserver') + AND ( + (last_advert_timestamp IS NOT NULL AND last_advert_timestamp >= datetime('now', '-{} days')) + OR (last_advert_timestamp IS NULL AND last_heard >= datetime('now', '-{} days')) + ) + ORDER BY COALESCE(last_advert_timestamp, last_heard) DESC + '''.format(self.max_repeater_age_days, self.max_repeater_age_days) + else: + query = ''' + SELECT name, public_key, device_type, last_heard, last_heard as last_seen, + last_advert_timestamp, latitude, longitude, city, state, country, + advert_count, signal_strength, snr, hop_count, role, is_starred + FROM complete_contact_tracking + WHERE public_key LIKE ? AND role IN ('repeater', 'roomserver') + ORDER BY COALESCE(last_advert_timestamp, last_heard) DESC + ''' + + prefix_pattern = f"{node_id}%" + results = self.bot.db_manager.execute_query(query, (prefix_pattern,)) + + # Convert results to expected format + if results: + results = [ + { + 'name': row['name'], + 'public_key': row['public_key'], + 'device_type': row['device_type'], + 'last_seen': row['last_seen'], + 'last_heard': row.get('last_heard', row['last_seen']), + 'last_advert_timestamp': row.get('last_advert_timestamp'), + 'is_active': True, + 'latitude': row['latitude'], + 'longitude': row['longitude'], + 'city': row['city'], + 'state': row['state'], + 'country': row['country'], + 'advert_count': row.get('advert_count', 0), + 'signal_strength': row.get('signal_strength'), + 'snr': row.get('snr'), + 'hop_count': row.get('hop_count'), + 'role': row.get('role'), + 'is_starred': bool(row.get('is_starred', 0)) + } for row in results + ] + except Exception as e: + self.logger.debug(f"Error querying complete_contact_tracking directly: {e}") + results = [] if results: # Build repeaters_data with all necessary fields @@ -1310,19 +1349,22 @@ class PathCommand(BaseCommand): self.logger.debug(f"Found stored public key match for {repeater.get('name', 'unknown')} in edge {candidate_prefix}->{next_node_id}") # Zero-hop bonus: If this repeater has been heard directly by the bot (zero-hop advert), - # it's strong evidence it's close and should be preferred, even for intermediate hops + # it's strong evidence it's close and should be preferred, even for intermediate hops. + # Only apply when graph_score > 0 (we have graph evidence); otherwise zero-hop alone + # would select candidates with no graph edges. zero_hop_bonus = 0.0 hop_count = repeater.get('hop_count') - if hop_count is not None and hop_count == 0: + if hop_count is not None and hop_count == 0 and graph_score > 0: # This repeater has been heard directly - strong evidence it's close to bot zero_hop_bonus = self.graph_zero_hop_bonus self.logger.debug(f"Zero-hop bonus for {repeater.get('name', 'unknown')}: {zero_hop_bonus:.2%} (heard directly by bot)") # SNR bonus: If this repeater has SNR data, it's a zero-hop repeater (direct neighbor) - # This is even stronger evidence than just hop_count == 0, as it means we have actual signal quality data + # This is even stronger evidence than just hop_count == 0, as it means we have actual signal quality data. + # Only apply when graph_score > 0 (same rationale as zero_hop_bonus). snr_bonus = 0.0 snr = repeater.get('snr') - if snr is not None: + if snr is not None and graph_score > 0: # SNR presence indicates zero-hop connection with signal quality data # Use same bonus as zero-hop, but this is more definitive snr_bonus = self.graph_zero_hop_bonus * 1.2 # 20% stronger than zero-hop bonus alone diff --git a/modules/config_validation.py b/modules/config_validation.py index 8f48feb..7601e3d 100644 --- a/modules/config_validation.py +++ b/modules/config_validation.py @@ -8,8 +8,9 @@ standalone via validate_config.py or at bot startup with --validate-config. """ import configparser +import os from pathlib import Path -from typing import List, Tuple +from typing import Dict, List, Optional, Tuple # Severity levels for validation results SEVERITY_ERROR = "error" @@ -42,6 +43,19 @@ CANONICAL_NON_COMMAND_SECTIONS = frozenset({ "DiscordBridge", }) +# Sections required for the bot to start (accessed without has_section guards) +# Missing any of these causes ConfigParser.NoSectionError during startup +REQUIRED_SECTIONS = frozenset({ + "Connection", # Serial/BLE/TCP connection params + "Bot", # db_path, rate limits, bot_name, etc. + "Channels", # monitor_channels, respond_to_dms +}) + +# Optional sections: when absent, use defaults or treat as empty/disabled +ADMIN_ACL_SECTION = "Admin_ACL" # admin commands disabled +BANNED_USERS_SECTION = "Banned_Users" # empty banned list +LOCALIZATION_SECTION = "Localization" # language=en, translation_path=translations/ + # Non-standard section name -> suggested canonical name (exact match) SECTION_TYPO_MAP = { "WebViewer": "Web_Viewer", @@ -51,6 +65,68 @@ SECTION_TYPO_MAP = { } +def _get_command_prefix_to_section() -> Dict[str, str]: + """Build map from command prefix (lowercase) to canonical section for similarity suggestions. + + Discovers command sections from config.ini.example in the project. Returns a dict + mapping prefix.lower() -> "Prefix_Command" (e.g. "stats" -> "Stats_Command"). + """ + result: Dict[str, str] = {} + base = Path(__file__).resolve().parent.parent # project root + example_paths = [base / "config.ini.example", base / "config.ini.minimal-example"] + for path in example_paths: + if not path.exists(): + continue + try: + parser = configparser.ConfigParser() + parser.read(path) + for section in parser.sections(): + if section.endswith("_Command"): + prefix = section[:-8] # remove "_Command" + result[prefix.lower()] = section + except configparser.Error: + pass + return result + + +def _suggest_similar_command(section: str, prefix_to_section: Dict[str, str]) -> Optional[str]: + """If section looks like a command name (e.g. Stats, Hacker), suggest the canonical section.""" + return prefix_to_section.get(section.strip().lower()) + + +def _resolve_path(file_path: str, base_dir: Path) -> Path: + """Resolve a path relative to base_dir (or as absolute).""" + p = Path(file_path) + if p.is_absolute(): + return p.resolve() + return (base_dir.resolve() / p).resolve() + + +def _check_path_writable( + file_path: str, base_dir: Path, description: str +) -> Optional[str]: + """Check if a file path can be written. Returns warning message if not.""" + if not file_path or not file_path.strip(): + return None + try: + resolved = _resolve_path(file_path.strip(), base_dir) + except (OSError, RuntimeError): + return f"{description}: cannot resolve path '{file_path}'" + parent = resolved.parent + # Find first existing ancestor to check writability + check_dir = parent + while not check_dir.exists(): + check_dir = check_dir.parent + if check_dir == check_dir.parent: # reached root + return f"{description} '{resolved}': parent directory does not exist" + if not os.access(str(check_dir), os.W_OK): + return f"{description} '{resolved}': directory {check_dir} is not writable" + # If file exists, verify it's writable + if resolved.exists() and not os.access(str(resolved), os.W_OK): + return f"{description} '{resolved}': file exists but is not writable" + return None + + def validate_config(config_path: str) -> List[Tuple[str, str]]: """ Validate config file section names. Returns a list of (severity, message). @@ -73,6 +149,55 @@ def validate_config(config_path: str) -> List[Tuple[str, str]]: results: List[Tuple[str, str]] = [] + # Check required sections (bot fails to start without these) + sections_present = frozenset(s.strip() for s in config.sections() if s.strip()) + missing_required = REQUIRED_SECTIONS - sections_present + for section in sorted(missing_required): + results.append(( + SEVERITY_ERROR, + f"Missing required section [{section}]; bot will not start without it.", + )) + + # Note when optional sections are absent + if ADMIN_ACL_SECTION not in sections_present: + results.append(( + SEVERITY_INFO, + f"Section [{ADMIN_ACL_SECTION}] absent; admin commands (repeater, webviewer, reload) disabled.", + )) + if BANNED_USERS_SECTION not in sections_present: + results.append(( + SEVERITY_INFO, + f"Section [{BANNED_USERS_SECTION}] absent; no users banned.", + )) + if LOCALIZATION_SECTION not in sections_present: + results.append(( + SEVERITY_INFO, + f"Section [{LOCALIZATION_SECTION}] absent; using defaults (language=en, translation_path=translations/).", + )) + + # Check writable paths (database, log file) + bot_root = Path(config_path).resolve().parent + if config.has_section("Bot"): + db_path = config.get("Bot", "db_path", fallback="").strip() + if db_path: + msg = _check_path_writable(db_path, bot_root, "Database path") + if msg: + results.append((SEVERITY_WARNING, msg)) + if config.has_section("Logging"): + log_file = config.get("Logging", "log_file", fallback="").strip() + if log_file: + msg = _check_path_writable(log_file, bot_root, "Log file path") + if msg: + results.append((SEVERITY_WARNING, msg)) + if config.has_section("Web_Viewer"): + web_db = config.get("Web_Viewer", "db_path", fallback="").strip() + if web_db: + msg = _check_path_writable(web_db, bot_root, "Web viewer db_path") + if msg: + results.append((SEVERITY_WARNING, msg)) + + prefix_to_section: Optional[Dict[str, str]] = None + for section in config.sections(): section_stripped = section.strip() if not section_stripped: @@ -93,9 +218,14 @@ def validate_config(config_path: str) -> List[Tuple[str, str]]: f"Non-standard section [{section_stripped}]; did you mean [{suggestion}]?", )) else: - results.append(( - SEVERITY_INFO, - f"Unknown section [{section_stripped}] (not in canonical list and not a *_Command section).", - )) + # Check if section looks like a command name (e.g. [Stats] -> [Stats_Command]) + if prefix_to_section is None: + prefix_to_section = _get_command_prefix_to_section() + similar = _suggest_similar_command(section_stripped, prefix_to_section) + if similar: + msg = f"Unknown section [{section_stripped}]; did you mean [{similar}]?" + else: + msg = f"Unknown section [{section_stripped}] (not in canonical list and not a *_Command section)." + results.append((SEVERITY_INFO, msg)) return results diff --git a/modules/core.py b/modules/core.py index 2b1717e..47a49c8 100644 --- a/modules/core.py +++ b/modules/core.py @@ -135,8 +135,12 @@ class MeshCoreBot: # Initialize translator for localization BEFORE CommandManager # This ensures translated keywords are available when commands are loaded try: - language = self.config.get('Localization', 'language', fallback='en') - translation_path = self.config.get('Localization', 'translation_path', fallback='translations/') + if self.config.has_section('Localization'): + language = self.config.get('Localization', 'language', fallback='en') + translation_path = self.config.get('Localization', 'translation_path', fallback='translations/') + else: + language = 'en' + translation_path = 'translations/' self.translator = Translator(language, translation_path) self.logger.info(f"Localization initialized: {language}") except (OSError, ValueError, FileNotFoundError, json.JSONDecodeError) as e: @@ -341,8 +345,12 @@ class MeshCoreBot: # Update translator if language changed try: - new_language = self.config.get('Localization', 'language', fallback='en') - new_translation_path = self.config.get('Localization', 'translation_path', fallback='translations/') + if self.config.has_section('Localization'): + new_language = self.config.get('Localization', 'language', fallback='en') + new_translation_path = self.config.get('Localization', 'translation_path', fallback='translations/') + else: + new_language = 'en' + new_translation_path = 'translations/' if (not hasattr(self, 'translator') or getattr(self.translator, 'language', None) != new_language or getattr(self.translator, 'translation_path', None) != new_translation_path): diff --git a/modules/mesh_graph.py b/modules/mesh_graph.py index fddb3eb..9ca7a51 100644 --- a/modules/mesh_graph.py +++ b/modules/mesh_graph.py @@ -599,26 +599,38 @@ class MeshGraph: """Flush all pending edge updates to database (synchronous version). Uses a single connection for the entire batch to avoid 'unable to open database file' when many edges are written in quick succession. + Handles both new edges (INSERT) and existing edges (UPDATE). """ with self.pending_lock: if not self.pending_updates: return - + updates = list(self.pending_updates) self.pending_updates.clear() - + conn = None location_cache: Dict[str, Tuple[float, float]] = {} try: conn = self.db_manager.get_connection() - params_list: List[Tuple] = [] + cursor = conn.cursor() for edge_key in updates: - params_tuple = self._build_update_params_for_edge(edge_key, conn, location_cache) - if params_tuple is not None: - params_list.append(params_tuple) - if params_list: - cursor = conn.cursor() - cursor.executemany(self._MESH_EDGE_UPDATE_QUERY, params_list) + if edge_key not in self.edges: + continue + edge = self.edges[edge_key] + # Recalculate distance if we have public keys + if edge.get('from_public_key') or edge.get('to_public_key'): + recalculated = self._recalculate_distance_if_needed( + edge, conn=conn, location_cache=location_cache + ) + if recalculated is not None: + edge['geographic_distance'] = recalculated + # Check if edge exists in DB + cursor.execute( + 'SELECT 1 FROM mesh_connections WHERE from_prefix = ? AND to_prefix = ?', + (edge_key[0], edge_key[1]), + ) + is_new = cursor.fetchone() is None + self._write_edge_to_db(edge_key, is_new, conn=conn, location_cache=location_cache) if conn: conn.commit() except Exception as e: diff --git a/modules/repeater_manager.py b/modules/repeater_manager.py index 18db43d..a8b4f62 100644 --- a/modules/repeater_manager.py +++ b/modules/repeater_manager.py @@ -1774,7 +1774,7 @@ class RepeaterManager: def _is_in_acl(self, public_key: str) -> bool: """Check if a public key is in the bot's admin ACL (should never be purged)""" try: - if not hasattr(self.bot, 'config'): + if not hasattr(self.bot, 'config') or not self.bot.config.has_section('Admin_ACL'): return False # Get admin pubkeys from config diff --git a/pyproject.toml b/pyproject.toml index cb011c9..81a7521 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -39,6 +39,7 @@ dependencies = [ [project.optional-dependencies] docs = ["mkdocs-material>=9.0.0"] +test = ["pytest>=7.0", "pytest-asyncio>=0.21"] [project.scripts] meshcore-bot = "meshcore_bot:main" diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..3f1d7d4 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,29 @@ +[pytest] +# Pytest configuration for meshcore-bot tests + +# Test discovery patterns +python_files = test_*.py *_test.py +python_classes = Test* +python_functions = test_* + +# Test paths +testpaths = tests + +# Async test support +asyncio_mode = auto + +# Coverage configuration (optional) +# [tool:pytest] +# addopts = --cov=modules --cov-report=html --cov-report=term-missing + +# Output options +addopts = + -v + --tb=short + --strict-markers + +# Markers for test categorization +markers = + unit: Unit tests (isolated, with mocks) + integration: Integration tests (with real database) + slow: Slow running tests diff --git a/tests/README.md b/tests/README.md new file mode 100644 index 0000000..0203fdf --- /dev/null +++ b/tests/README.md @@ -0,0 +1,200 @@ +# MeshCore Bot Test Suite + +This directory contains the test suite for the MeshCore Bot, focusing on graph-based path guessing functionality. + +## Structure + +``` +tests/ +├── README.md # This file +├── conftest.py # Pytest fixtures and configuration +├── helpers.py # Test data factories and helper functions +├── unit/ # Unit tests (isolated, with mocks) +│ ├── test_mesh_graph_edges.py +│ ├── test_mesh_graph_validation.py +│ ├── test_mesh_graph_scoring.py +│ ├── test_mesh_graph_multihop.py +│ └── test_path_command_graph_selection.py +└── integration/ # Integration tests (with real database) + └── test_path_resolution.py +``` + +## Running Tests + +### Run all tests +```bash +pytest +``` + +### Run only unit tests +```bash +pytest tests/unit/ +``` + +### Run only integration tests +```bash +pytest tests/integration/ +``` + +### Run specific test file +```bash +pytest tests/unit/test_mesh_graph_edges.py +``` + +### Run specific test +```bash +pytest tests/unit/test_mesh_graph_edges.py::TestMeshGraphEdges::test_add_new_edge +``` + +### Run with coverage +```bash +pytest --cov=modules --cov-report=html --cov-report=term-missing +``` + +### Run with verbose output +```bash +pytest -v +``` + +### Run with markers +```bash +pytest -m unit # Run only unit tests +pytest -m integration # Run only integration tests +pytest -m slow # Run slow tests +``` + +## Test Coverage + +### Unit Tests + +#### `test_mesh_graph_edges.py` (15 tests) +Tests for `MeshGraph` edge management: +- Adding new edges +- Updating existing edges +- Public key handling +- Hop position tracking +- Geographic distance +- Edge queries (get, has, outgoing, incoming) +- Prefix normalization + +#### `test_mesh_graph_validation.py` (12 tests) +Tests for path validation: +- Path segment validation +- Confidence calculation +- Recency checks +- Bidirectional edge validation +- Full path validation +- Minimum observations filtering + +#### `test_mesh_graph_scoring.py` (11 tests) +Tests for candidate scoring: +- Score calculation with various edge combinations +- Bidirectional bonuses +- Hop position matching +- Geographic distance bonuses +- Minimum observations filtering + +#### `test_mesh_graph_multihop.py` (12 tests) +Tests for multi-hop path inference: +- 2-hop and 3-hop path finding +- Intermediate node discovery +- Minimum observations filtering +- Bidirectional path bonuses +- Score reduction for longer paths + +#### `test_path_command_graph_selection.py` (8 tests) +Tests for `PathCommand._select_repeater_by_graph`: +- Direct edge selection +- Stored public key bonus +- Star bias multiplier +- Multi-hop inference +- Confidence conversion + +### Integration Tests + +#### `test_path_resolution.py` (5 tests) +End-to-end tests for full path resolution: +- Path resolution with graph edges from database +- Prefix collision resolution using graph data +- Edge persistence across graph restarts +- Graph vs geographic selection +- Real-world multi-hop scenarios + +## Test Fixtures + +Fixtures are defined in `conftest.py`: + +- `mock_logger`: Mock logger for testing +- `test_config`: Test configuration with Path_Command settings +- `test_db`: In-memory SQLite database for testing +- `mock_bot`: Mock bot instance with all necessary attributes +- `mesh_graph`: Clean `MeshGraph` instance for testing +- `populated_mesh_graph`: `MeshGraph` instance with sample edges + +## Test Helpers + +Helper functions in `helpers.py`: + +- `create_test_repeater()`: Factory for creating test repeater data +- `create_test_edge()`: Factory for creating test edge data +- `create_test_path()`: Factory for creating test path data +- `populate_test_graph()`: Helper to populate a graph with test edges + +## Writing New Tests + +### Unit Test Example + +```python +import pytest +from tests.helpers import create_test_edge + +@pytest.mark.unit +class TestMyFeature: + def test_my_feature(self, mesh_graph): + """Test description.""" + mesh_graph.add_edge('01', '7e') + assert mesh_graph.has_edge('01', '7e') +``` + +### Integration Test Example + +```python +import pytest + +@pytest.mark.integration +class TestMyIntegration: + def test_my_integration(self, mock_bot, test_db): + """Test description.""" + # Use real database and bot components + pass +``` + +## Test Markers + +Tests are marked with: +- `@pytest.mark.unit`: Unit tests (isolated, with mocks) +- `@pytest.mark.integration`: Integration tests (with real database) +- `@pytest.mark.slow`: Slow-running tests + +## Dependencies + +Test dependencies are in `requirements.txt`: +- `pytest>=7.0.0` +- `pytest-asyncio>=0.21.0` +- `pytest-mock>=3.10.0` +- `pytest-cov>=4.0.0` + +## Configuration + +Pytest configuration is in `pytest.ini`: +- Test discovery patterns +- Async test support +- Output options +- Test markers + +## Notes + +- Unit tests use in-memory SQLite databases for speed +- Integration tests may use real database connections +- All tests should be deterministic and not depend on external services +- Tests should clean up after themselves (fixtures handle this automatically) diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..29763da --- /dev/null +++ b/tests/__init__.py @@ -0,0 +1 @@ +# Tests for meshcore-bot diff --git a/tests/commands/__init__.py b/tests/commands/__init__.py new file mode 100644 index 0000000..749fa03 --- /dev/null +++ b/tests/commands/__init__.py @@ -0,0 +1 @@ +# Command tests diff --git a/tests/commands/test_base_command.py b/tests/commands/test_base_command.py new file mode 100644 index 0000000..19241d2 --- /dev/null +++ b/tests/commands/test_base_command.py @@ -0,0 +1,105 @@ +"""Tests for modules.commands.base_command.""" + +import pytest +from unittest.mock import MagicMock + +from modules.commands.base_command import BaseCommand +from modules.commands.ping_command import PingCommand +from modules.commands.dadjoke_command import DadJokeCommand +from modules.models import MeshMessage +from tests.conftest import mock_message + + +class _TestCommand(BaseCommand): + """Minimal concrete command for testing BaseCommand behavior.""" + name = "testcmd" + keywords = ["testcmd"] + description = "Test" + short_description = "Test" + usage = "testcmd" + examples = ["testcmd"] + + def can_execute(self, message: MeshMessage) -> bool: + return super().can_execute(message) + + def get_help_text(self) -> str: + return "Help" + + async def execute(self, message: MeshMessage) -> bool: + return await self.send_response(message, "ok") + + +class TestDeriveConfigSectionName: + """Tests for _derive_config_section_name().""" + + def test_regular_name(self, command_mock_bot): + cmd = _TestCommand(command_mock_bot) + cmd.name = "dice" + assert cmd._derive_config_section_name() == "Dice_Command" + + def test_camel_case_dadjoke(self, command_mock_bot): + cmd = DadJokeCommand(command_mock_bot) + assert cmd._derive_config_section_name() == "DadJoke_Command" + + def test_camel_case_webviewer(self, command_mock_bot): + cmd = _TestCommand(command_mock_bot) + cmd.name = "webviewer" + assert cmd._derive_config_section_name() == "WebViewer_Command" + + +class TestIsChannelAllowed: + """Tests for is_channel_allowed().""" + + def test_dm_always_allowed(self, command_mock_bot): + cmd = PingCommand(command_mock_bot) + msg = mock_message(channel=None, is_dm=True) + assert cmd.is_channel_allowed(msg) is True + + def test_channel_in_monitor_list_allowed(self, command_mock_bot): + cmd = PingCommand(command_mock_bot) + msg = mock_message(channel="general", is_dm=False) + assert cmd.is_channel_allowed(msg) is True + + def test_channel_not_in_monitor_list_rejected(self, command_mock_bot): + cmd = PingCommand(command_mock_bot) + msg = mock_message(channel="unknown_channel", is_dm=False) + assert cmd.is_channel_allowed(msg) is False + + def test_no_channel_rejected(self, command_mock_bot): + cmd = PingCommand(command_mock_bot) + msg = mock_message(channel=None, is_dm=False) + assert cmd.is_channel_allowed(msg) is False + + +class TestGetConfigValue: + """Tests for get_config_value() section migration.""" + + def test_new_section_used_first(self, command_mock_bot): + command_mock_bot.config.add_section("Ping_Command") + command_mock_bot.config.set("Ping_Command", "enabled", "true") + cmd = PingCommand(command_mock_bot) + assert cmd.get_config_value("Ping_Command", "enabled", fallback=False, value_type="bool") is True + + def test_legacy_section_migration(self, command_mock_bot): + # Old [Hacker] section used when [Hacker_Command] not present + command_mock_bot.config.add_section("Hacker") + command_mock_bot.config.set("Hacker", "enabled", "true") + cmd = _TestCommand(command_mock_bot) + val = cmd.get_config_value("Hacker_Command", "enabled", fallback=False, value_type="bool") + assert val is True + + +class TestCanExecute: + """Tests for can_execute().""" + + def test_channel_check_blocks_unknown_channel(self, command_mock_bot): + cmd = PingCommand(command_mock_bot) + msg = mock_message(content="ping", channel="other", is_dm=False) + assert cmd.can_execute(msg) is False + + def test_dm_allowed(self, command_mock_bot): + command_mock_bot.config.add_section("Ping_Command") + command_mock_bot.config.set("Ping_Command", "enabled", "true") + cmd = PingCommand(command_mock_bot) + msg = mock_message(content="ping", is_dm=True) + assert cmd.can_execute(msg) is True diff --git a/tests/commands/test_cmd_command.py b/tests/commands/test_cmd_command.py new file mode 100644 index 0000000..28565c2 --- /dev/null +++ b/tests/commands/test_cmd_command.py @@ -0,0 +1,41 @@ +"""Tests for modules.commands.cmd_command.""" + +import pytest + +from modules.commands.cmd_command import CmdCommand +from tests.conftest import command_mock_bot, mock_message + + +class TestCmdCommand: + """Tests for CmdCommand.""" + + def test_can_execute_when_enabled(self, command_mock_bot): + command_mock_bot.config.add_section("Cmd_Command") + command_mock_bot.config.set("Cmd_Command", "enabled", "true") + command_mock_bot.command_manager.commands = {"ping": object(), "help": object()} + cmd = CmdCommand(command_mock_bot) + msg = mock_message(content="cmd", is_dm=True) + assert cmd.can_execute(msg) is True + + def test_can_execute_when_disabled(self, command_mock_bot): + command_mock_bot.config.add_section("Cmd_Command") + command_mock_bot.config.set("Cmd_Command", "enabled", "false") + cmd = CmdCommand(command_mock_bot) + msg = mock_message(content="cmd", is_dm=True) + assert cmd.can_execute(msg) is False + + @pytest.mark.asyncio + async def test_execute_returns_command_list(self, command_mock_bot): + command_mock_bot.config.add_section("Cmd_Command") + command_mock_bot.config.set("Cmd_Command", "enabled", "true") + command_mock_bot.command_manager.keywords = {} # No custom cmd keyword -> use dynamic list + mock_ping = type("MockCmd", (), {"keywords": ["ping"]})() + mock_help = type("MockCmd", (), {"keywords": ["help"]})() + command_mock_bot.command_manager.commands = {"ping": mock_ping, "help": mock_help} + cmd = CmdCommand(command_mock_bot) + msg = mock_message(content="cmd", is_dm=True) + result = await cmd.execute(msg) + assert result is True + call_args = command_mock_bot.command_manager.send_response.call_args + response = call_args[0][1] + assert "ping" in response or "help" in response or "cmd" in response diff --git a/tests/commands/test_dice_command.py b/tests/commands/test_dice_command.py new file mode 100644 index 0000000..26a74cb --- /dev/null +++ b/tests/commands/test_dice_command.py @@ -0,0 +1,124 @@ +"""Tests for modules.commands.dice_command.""" + +import pytest +from unittest.mock import patch + +from modules.commands.dice_command import DiceCommand +from tests.conftest import command_mock_bot, mock_message + + +class TestParseDiceNotation: + """Tests for parse_dice_notation().""" + + def test_d20(self, command_mock_bot): + cmd = DiceCommand(command_mock_bot) + sides, count, is_decade = cmd.parse_dice_notation("d20") + assert sides == 20 + assert count == 1 + assert is_decade is False + + def test_2d6(self, command_mock_bot): + cmd = DiceCommand(command_mock_bot) + sides, count, is_decade = cmd.parse_dice_notation("2d6") + assert sides == 6 + assert count == 2 + assert is_decade is False + + def test_decade(self, command_mock_bot): + cmd = DiceCommand(command_mock_bot) + sides, count, is_decade = cmd.parse_dice_notation("decade") + assert sides == 10 + assert count == 1 + assert is_decade is True + + def test_direct_number_20(self, command_mock_bot): + cmd = DiceCommand(command_mock_bot) + sides, count, is_decade = cmd.parse_dice_notation("20") + assert sides == 20 + assert count == 1 + + def test_invalid_d7(self, command_mock_bot): + cmd = DiceCommand(command_mock_bot) + sides, count, is_decade = cmd.parse_dice_notation("d7") + assert sides is None + assert count is None + + +class TestParseMixedDice: + """Tests for parse_mixed_dice().""" + + def test_d10_d6(self, command_mock_bot): + cmd = DiceCommand(command_mock_bot) + result = cmd.parse_mixed_dice("d10 d6") + assert len(result) == 2 + assert result[0] == (10, 1, False) + assert result[1] == (6, 1, False) + + def test_2d6_d20(self, command_mock_bot): + cmd = DiceCommand(command_mock_bot) + result = cmd.parse_mixed_dice("2d6 d20") + assert len(result) == 2 + assert result[0] == (6, 2, False) + assert result[1] == (20, 1, False) + + +class TestRollDice: + """Tests for roll_dice().""" + + def test_decade_values_in_range(self, command_mock_bot): + cmd = DiceCommand(command_mock_bot) + for _ in range(20): + results = cmd.roll_dice(10, 1, is_decade=True) + assert len(results) == 1 + assert results[0] in (0, 10, 20, 30, 40, 50, 60, 70, 80, 90) + + def test_standard_dice_range(self, command_mock_bot): + cmd = DiceCommand(command_mock_bot) + results = cmd.roll_dice(6, 10) + assert len(results) == 10 + for r in results: + assert 1 <= r <= 6 + + +class TestFormatDiceResult: + """Tests for format_dice_result().""" + + def test_single_die(self, command_mock_bot): + cmd = DiceCommand(command_mock_bot) + result = cmd.format_dice_result(6, 1, [4]) + assert "4" in result and "6" in result + + def test_decade_format(self, command_mock_bot): + cmd = DiceCommand(command_mock_bot) + result = cmd.format_dice_result(10, 1, [50], is_decade=True) + assert "50" in result + assert "decade" in result.lower() + + +class TestDiceExecute: + """Tests for execute().""" + + @pytest.mark.asyncio + async def test_dice_defaults_to_d6(self, command_mock_bot): + command_mock_bot.config.add_section("Dice_Command") + command_mock_bot.config.set("Dice_Command", "enabled", "true") + cmd = DiceCommand(command_mock_bot) + msg = mock_message(content="dice", is_dm=True) + result = await cmd.execute(msg) + assert result is True + call_args = command_mock_bot.command_manager.send_response.call_args + assert call_args is not None + response = call_args[0][1] + assert "6" in response or "d6" in response or "🎲" in response + + @pytest.mark.asyncio + async def test_dice_invalid_returns_error(self, command_mock_bot): + command_mock_bot.config.add_section("Dice_Command") + command_mock_bot.config.set("Dice_Command", "enabled", "true") + cmd = DiceCommand(command_mock_bot) + msg = mock_message(content="dice invalid", is_dm=True) + result = await cmd.execute(msg) + assert result is True + call_args = command_mock_bot.command_manager.send_response.call_args + response = call_args[0][1] + assert "invalid" in response.lower() or "d4" in response or "d6" in response diff --git a/tests/commands/test_hello_command.py b/tests/commands/test_hello_command.py new file mode 100644 index 0000000..a0cb1ac --- /dev/null +++ b/tests/commands/test_hello_command.py @@ -0,0 +1,82 @@ +"""Tests for modules.commands.hello_command.""" + +import pytest +from unittest.mock import patch + +from modules.commands.hello_command import HelloCommand +from tests.conftest import command_mock_bot, mock_message + + +class TestHelloCommand: + """Tests for HelloCommand.""" + + def test_is_emoji_only_message_vulcan_salute(self, command_mock_bot): + command_mock_bot.config.add_section("Hello_Command") + command_mock_bot.config.set("Hello_Command", "enabled", "true") + cmd = HelloCommand(command_mock_bot) + assert cmd.is_emoji_only_message("🖖") is True + + def test_is_emoji_only_message_with_whitespace(self, command_mock_bot): + command_mock_bot.config.add_section("Hello_Command") + command_mock_bot.config.set("Hello_Command", "enabled", "true") + cmd = HelloCommand(command_mock_bot) + assert cmd.is_emoji_only_message(" 🖖 ") is True + + def test_is_emoji_only_message_text_returns_false(self, command_mock_bot): + command_mock_bot.config.add_section("Hello_Command") + command_mock_bot.config.set("Hello_Command", "enabled", "true") + cmd = HelloCommand(command_mock_bot) + assert cmd.is_emoji_only_message("hello") is False + + def test_is_emoji_only_message_empty_returns_false(self, command_mock_bot): + command_mock_bot.config.add_section("Hello_Command") + command_mock_bot.config.set("Hello_Command", "enabled", "true") + cmd = HelloCommand(command_mock_bot) + assert cmd.is_emoji_only_message("") is False + + @patch("datetime.datetime") + def test_get_random_greeting_deterministic_with_mocked_time(self, mock_datetime, command_mock_bot): + command_mock_bot.config.add_section("Hello_Command") + command_mock_bot.config.set("Hello_Command", "enabled", "true") + mock_now = mock_datetime.now.return_value + mock_now.hour = 10 # morning + cmd = HelloCommand(command_mock_bot) + with patch("modules.commands.hello_command.random.choice", side_effect=lambda x: x[0]): + result = cmd.get_random_greeting() + assert result + assert isinstance(result, str) + + def test_get_emoji_response_vulcan(self, command_mock_bot): + command_mock_bot.config.add_section("Hello_Command") + command_mock_bot.config.set("Hello_Command", "enabled", "true") + cmd = HelloCommand(command_mock_bot) + with patch("modules.commands.hello_command.random.choice", side_effect=lambda x: x[0]): + result = cmd.get_emoji_response("🖖", "TestBot") + assert "🖖" in result or "TestBot" in result + + def test_can_execute_when_enabled(self, command_mock_bot): + command_mock_bot.config.add_section("Hello_Command") + command_mock_bot.config.set("Hello_Command", "enabled", "true") + cmd = HelloCommand(command_mock_bot) + msg = mock_message(content="hello", is_dm=True) + assert cmd.can_execute(msg) is True + + def test_can_execute_when_disabled(self, command_mock_bot): + command_mock_bot.config.add_section("Hello_Command") + command_mock_bot.config.set("Hello_Command", "enabled", "false") + cmd = HelloCommand(command_mock_bot) + msg = mock_message(content="hello", is_dm=True) + assert cmd.can_execute(msg) is False + + @pytest.mark.asyncio + async def test_execute_text_greeting_sends_response(self, command_mock_bot): + command_mock_bot.config.add_section("Hello_Command") + command_mock_bot.config.set("Hello_Command", "enabled", "true") + cmd = HelloCommand(command_mock_bot) + msg = mock_message(content="hello", is_dm=True) + with patch("modules.commands.hello_command.random.choice", side_effect=lambda x: x[0]): + result = await cmd.execute(msg) + assert result is True + call_args = command_mock_bot.command_manager.send_response.call_args + response = call_args[0][1] + assert response diff --git a/tests/commands/test_help_command.py b/tests/commands/test_help_command.py new file mode 100644 index 0000000..3611cff --- /dev/null +++ b/tests/commands/test_help_command.py @@ -0,0 +1,33 @@ +"""Tests for modules.commands.help_command.""" + +import pytest + +from modules.commands.help_command import HelpCommand +from tests.conftest import command_mock_bot, mock_message + + +class TestHelpCommand: + """Tests for HelpCommand.""" + + def test_can_execute_when_enabled(self, command_mock_bot): + command_mock_bot.config.add_section("Help_Command") + command_mock_bot.config.set("Help_Command", "enabled", "true") + cmd = HelpCommand(command_mock_bot) + msg = mock_message(content="help", is_dm=True) + assert cmd.can_execute(msg) is True + + def test_can_execute_when_disabled(self, command_mock_bot): + command_mock_bot.config.add_section("Help_Command") + command_mock_bot.config.set("Help_Command", "enabled", "false") + cmd = HelpCommand(command_mock_bot) + msg = mock_message(content="help", is_dm=True) + assert cmd.can_execute(msg) is False + + @pytest.mark.asyncio + async def test_execute_returns_true(self, command_mock_bot): + command_mock_bot.config.add_section("Help_Command") + command_mock_bot.config.set("Help_Command", "enabled", "true") + cmd = HelpCommand(command_mock_bot) + msg = mock_message(content="help", is_dm=True) + result = await cmd.execute(msg) + assert result is True diff --git a/tests/commands/test_magic8_command.py b/tests/commands/test_magic8_command.py new file mode 100644 index 0000000..4470272 --- /dev/null +++ b/tests/commands/test_magic8_command.py @@ -0,0 +1,49 @@ +"""Tests for modules.commands.magic8_command.""" + +import pytest + +from modules.commands.magic8_command import Magic8Command, magic8_responses +from tests.conftest import command_mock_bot, mock_message + + +class TestMagic8Command: + """Tests for Magic8Command.""" + + def test_can_execute_when_enabled(self, command_mock_bot): + command_mock_bot.config.add_section("Magic8_Command") + command_mock_bot.config.set("Magic8_Command", "enabled", "true") + cmd = Magic8Command(command_mock_bot) + msg = mock_message(content="magic8 will it rain?", is_dm=True) + assert cmd.can_execute(msg) is True + + def test_can_execute_when_disabled(self, command_mock_bot): + command_mock_bot.config.add_section("Magic8_Command") + command_mock_bot.config.set("Magic8_Command", "enabled", "false") + cmd = Magic8Command(command_mock_bot) + msg = mock_message(content="magic8 will it rain?", is_dm=True) + assert cmd.can_execute(msg) is False + + @pytest.mark.asyncio + async def test_execute_returns_valid_response(self, command_mock_bot): + command_mock_bot.config.add_section("Magic8_Command") + command_mock_bot.config.set("Magic8_Command", "enabled", "true") + cmd = Magic8Command(command_mock_bot) + msg = mock_message(content="magic8 will it rain?", is_dm=True) + result = await cmd.execute(msg) + assert result is True + call_args = command_mock_bot.command_manager.send_response.call_args + response = call_args[0][1] + assert "🎱" in response + assert any(r in response for r in magic8_responses) + + @pytest.mark.asyncio + async def test_execute_channel_includes_sender_mention(self, command_mock_bot): + command_mock_bot.config.add_section("Magic8_Command") + command_mock_bot.config.set("Magic8_Command", "enabled", "true") + cmd = Magic8Command(command_mock_bot) + msg = mock_message(content="magic8 test?", channel="general", is_dm=False, sender_id="Alice") + result = await cmd.execute(msg) + assert result is True + call_args = command_mock_bot.command_manager.send_response.call_args + response = call_args[0][1] + assert "Alice" in response or "@[" in response diff --git a/tests/commands/test_ping_command.py b/tests/commands/test_ping_command.py new file mode 100644 index 0000000..04707d5 --- /dev/null +++ b/tests/commands/test_ping_command.py @@ -0,0 +1,38 @@ +"""Tests for modules.commands.ping_command.""" + +import pytest + +from modules.commands.ping_command import PingCommand +from tests.conftest import command_mock_bot, mock_message + + +class TestPingCommand: + """Tests for PingCommand.""" + + def test_can_execute_when_enabled(self, command_mock_bot): + command_mock_bot.config.add_section("Ping_Command") + command_mock_bot.config.set("Ping_Command", "enabled", "true") + cmd = PingCommand(command_mock_bot) + msg = mock_message(content="ping", is_dm=True) + assert cmd.can_execute(msg) is True + + def test_can_execute_when_disabled(self, command_mock_bot): + command_mock_bot.config.add_section("Ping_Command") + command_mock_bot.config.set("Ping_Command", "enabled", "false") + cmd = PingCommand(command_mock_bot) + msg = mock_message(content="ping", is_dm=True) + assert cmd.can_execute(msg) is False + + @pytest.mark.asyncio + async def test_execute_returns_keyword_response(self, command_mock_bot): + command_mock_bot.config.add_section("Ping_Command") + command_mock_bot.config.set("Ping_Command", "enabled", "true") + command_mock_bot.config.set("Keywords", "ping", "Pong!") + cmd = PingCommand(command_mock_bot) + msg = mock_message(content="ping", is_dm=True) + result = await cmd.execute(msg) + assert result is True + call_args = command_mock_bot.command_manager.send_response.call_args + assert call_args is not None + response = call_args[0][1] + assert "Pong" in response or "pong" in response.lower() diff --git a/tests/commands/test_roll_command.py b/tests/commands/test_roll_command.py new file mode 100644 index 0000000..057285f --- /dev/null +++ b/tests/commands/test_roll_command.py @@ -0,0 +1,77 @@ +"""Tests for modules.commands.roll_command.""" + +import re +import pytest + +from modules.commands.roll_command import RollCommand +from tests.conftest import command_mock_bot, mock_message + + +class TestParseRollNotation: + """Tests for parse_roll_notation().""" + + def test_valid_number(self, command_mock_bot): + cmd = RollCommand(command_mock_bot) + assert cmd.parse_roll_notation("50") == 50 + assert cmd.parse_roll_notation("100") == 100 + assert cmd.parse_roll_notation("1000") == 1000 + + def test_out_of_range(self, command_mock_bot): + cmd = RollCommand(command_mock_bot) + assert cmd.parse_roll_notation("0") is None + assert cmd.parse_roll_notation("10001") is None + + def test_invalid_input(self, command_mock_bot): + cmd = RollCommand(command_mock_bot) + assert cmd.parse_roll_notation("abc") is None + + +class TestRollCommandMatches: + """Tests for matches_keyword().""" + + def test_exact_roll_match(self, command_mock_bot): + cmd = RollCommand(command_mock_bot) + assert cmd.matches_keyword(mock_message(content="roll")) is True + + def test_roll_with_number_match(self, command_mock_bot): + cmd = RollCommand(command_mock_bot) + assert cmd.matches_keyword(mock_message(content="roll 50")) is True + + def test_roll_invalid_no_match(self, command_mock_bot): + cmd = RollCommand(command_mock_bot) + assert cmd.matches_keyword(mock_message(content="roll abc")) is False + + +class TestRollCommandExecute: + """Tests for execute().""" + + @pytest.mark.asyncio + async def test_roll_default_1_to_100(self, command_mock_bot): + command_mock_bot.config.add_section("Roll_Command") + command_mock_bot.config.set("Roll_Command", "enabled", "true") + cmd = RollCommand(command_mock_bot) + msg = mock_message(content="roll", is_dm=True) + result = await cmd.execute(msg) + assert result is True + call_args = command_mock_bot.command_manager.send_response.call_args + response = call_args[0][1] + # Should be a number between 1 and 100 + nums = re.findall(r'\d+', response) + assert len(nums) >= 1 + val = int(nums[0]) + assert 1 <= val <= 100 + + @pytest.mark.asyncio + async def test_roll_50(self, command_mock_bot): + command_mock_bot.config.add_section("Roll_Command") + command_mock_bot.config.set("Roll_Command", "enabled", "true") + cmd = RollCommand(command_mock_bot) + msg = mock_message(content="roll 50", is_dm=True) + result = await cmd.execute(msg) + assert result is True + call_args = command_mock_bot.command_manager.send_response.call_args + response = call_args[0][1] + nums = re.findall(r'\d+', response) + assert len(nums) >= 1 + val = int(nums[0]) + assert 1 <= val <= 50 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..1d73582 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,253 @@ +#!/usr/bin/env python3 +""" +Pytest fixtures for meshcore-bot tests +""" + +import pytest +import sqlite3 +import configparser +from unittest.mock import Mock, MagicMock, AsyncMock +from datetime import datetime, timedelta +from typing import Any, Optional + +from modules.db_manager import DBManager +from modules.mesh_graph import MeshGraph +from modules.models import MeshMessage +from tests.helpers import create_test_repeater, create_test_edge, populate_test_graph + + +def mock_message( + content: str = "ping", + channel: Optional[str] = "general", + is_dm: bool = False, + sender_id: Optional[str] = "TestUser", + sender_pubkey: Optional[str] = None, + **kwargs: Any, +) -> MeshMessage: + """Factory for creating MeshMessage instances in tests.""" + return MeshMessage( + content=content, + channel=channel if not is_dm else None, + is_dm=is_dm, + sender_id=sender_id, + sender_pubkey=sender_pubkey, + **kwargs, + ) + + +@pytest.fixture +def minimal_config(): + """Minimal ConfigParser for command tests (Connection, Bot, Channels, Keywords).""" + config = configparser.ConfigParser() + config.add_section("Connection") + config.set("Connection", "connection_type", "serial") + config.set("Connection", "serial_port", "/dev/ttyUSB0") + config.add_section("Bot") + config.set("Bot", "bot_name", "TestBot") + config.set("Bot", "db_path", "meshcore_bot.db") + config.add_section("Channels") + config.set("Channels", "monitor_channels", "general,test,emergency") + config.set("Channels", "respond_to_dms", "true") + config.add_section("Keywords") + config.set("Keywords", "ping", "Pong!") + config.set("Keywords", "test", "ack") + return config + + +@pytest.fixture +def command_mock_bot(mock_logger, minimal_config): + """Lightweight mock bot for command tests. No DB, no mesh_graph.""" + bot = MagicMock() + bot.logger = mock_logger + bot.config = minimal_config + bot.translator = MagicMock() + + def _mock_translate(key, **kwargs): + if kwargs: + return key + " " + " ".join(f"{k}={v}" for k, v in sorted(kwargs.items())) + return key + + bot.translator.translate = Mock(side_effect=_mock_translate) + bot.translator.get_value = Mock(return_value=None) + bot.command_manager = MagicMock() + bot.command_manager.monitor_channels = ["general", "test", "emergency"] + bot.command_manager.send_response = AsyncMock(return_value=True) + return bot + + +@pytest.fixture +def mock_logger(): + """Create a mock logger for testing.""" + logger = Mock() + logger.info = Mock() + logger.debug = Mock() + logger.warning = Mock() + logger.error = Mock() + return logger + + +@pytest.fixture +def test_config(): + """Create a test configuration with Path_Command settings.""" + config = configparser.ConfigParser() + + # Add Path_Command section with graph-related settings + config.add_section('Path_Command') + config.set('Path_Command', 'enabled', 'true') + config.set('Path_Command', 'graph_based_validation', 'true') + config.set('Path_Command', 'min_edge_observations', '3') + config.set('Path_Command', 'graph_write_strategy', 'immediate') # For faster tests + config.set('Path_Command', 'graph_batch_interval_seconds', '30') + config.set('Path_Command', 'graph_batch_max_pending', '100') + config.set('Path_Command', 'graph_startup_load_days', '0') # Don't load old data in tests + config.set('Path_Command', 'graph_edge_expiration_days', '7') + config.set('Path_Command', 'graph_use_bidirectional', 'true') + config.set('Path_Command', 'graph_use_hop_position', 'true') + config.set('Path_Command', 'graph_multi_hop_enabled', 'true') + config.set('Path_Command', 'graph_multi_hop_max_hops', '2') + config.set('Path_Command', 'graph_geographic_combined', 'false') + config.set('Path_Command', 'graph_geographic_weight', '0.7') + config.set('Path_Command', 'graph_prefer_stored_keys', 'true') + config.set('Path_Command', 'star_bias_multiplier', '2.5') + + # Add Bot section (for location if needed) + config.add_section('Bot') + config.set('Bot', 'bot_latitude', '47.6062') + config.set('Bot', 'bot_longitude', '-122.3321') + + return config + + +@pytest.fixture +def test_db(mock_logger, tmp_path): + """Create a file-based SQLite database for testing. + + Uses tmp_path (not :memory:) so all connections share the same database. + SQLite :memory: creates a new empty DB per connection, causing isolation issues. + """ + db_path = str(tmp_path / "test.db") + + # Create a minimal bot mock for DBManager + mock_bot = Mock() + mock_bot.logger = mock_logger + + # Create DBManager with file-based database + db_manager = DBManager(mock_bot, db_path) + + # Initialize mesh_connections table schema + db_manager.create_table('mesh_connections', ''' + id INTEGER PRIMARY KEY AUTOINCREMENT, + from_prefix TEXT NOT NULL, + to_prefix TEXT NOT NULL, + from_public_key TEXT, + to_public_key TEXT, + observation_count INTEGER DEFAULT 1, + first_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_seen TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + avg_hop_position REAL, + geographic_distance REAL, + UNIQUE(from_prefix, to_prefix) + ''') + + # Initialize complete_contact_tracking table schema (for repeater lookups) + db_manager.create_table('complete_contact_tracking', ''' + id INTEGER PRIMARY KEY AUTOINCREMENT, + public_key TEXT NOT NULL, + name TEXT NOT NULL, + role TEXT NOT NULL, + device_type TEXT, + first_heard TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + last_heard TIMESTAMP DEFAULT CURRENT_TIMESTAMP, + advert_count INTEGER DEFAULT 1, + latitude REAL, + longitude REAL, + city TEXT, + state TEXT, + country TEXT, + raw_advert_data TEXT, + signal_strength REAL, + snr REAL, + hop_count INTEGER, + is_currently_tracked BOOLEAN DEFAULT 0, + last_advert_timestamp TIMESTAMP, + location_accuracy REAL, + contact_source TEXT DEFAULT 'advertisement', + out_path TEXT, + out_path_len INTEGER, + is_starred INTEGER DEFAULT 0 + ''') + + # Create indexes (after tables are created) + # Create indexes (db_manager created tables in same db_path) + try: + with sqlite3.connect(db_path) as conn: + cursor = conn.cursor() + # Check if table exists before creating indexes + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='mesh_connections'") + if cursor.fetchone(): + cursor.execute('CREATE INDEX IF NOT EXISTS idx_from_prefix ON mesh_connections(from_prefix)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_to_prefix ON mesh_connections(to_prefix)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_last_seen ON mesh_connections(last_seen)') + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='complete_contact_tracking'") + if cursor.fetchone(): + cursor.execute('CREATE INDEX IF NOT EXISTS idx_complete_public_key ON complete_contact_tracking(public_key)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_complete_role ON complete_contact_tracking(role)') + cursor.execute('CREATE INDEX IF NOT EXISTS idx_complete_last_heard ON complete_contact_tracking(last_heard)') + conn.commit() + except Exception: + # Indexes are optional, continue if they fail + pass + + yield db_manager + + # Cleanup (tmp_path is automatically cleaned up by pytest) + + +@pytest.fixture +def mock_bot(mock_logger, test_config, test_db): + """Create a mock bot instance with all necessary attributes.""" + bot = Mock() + bot.logger = mock_logger + bot.config = test_config + bot.db_manager = test_db + bot.bot_root = '/tmp' # Dummy path for testing + + # Mock repeater_manager if needed + bot.repeater_manager = Mock() + bot.repeater_manager.get_repeater_devices = Mock(return_value=[]) + + # Mock web_viewer_integration (optional, for edge notifications) + bot.web_viewer_integration = None + + return bot + + +@pytest.fixture +def mesh_graph(mock_bot): + """Create a MeshGraph instance for testing.""" + # Ensure config is set to immediate strategy for tests + mock_bot.config.set('Path_Command', 'graph_write_strategy', 'immediate') + graph = MeshGraph(mock_bot) + # Ensure batch writer doesn't interfere with tests + if hasattr(graph, '_batch_thread') and graph._batch_thread: + graph._shutdown_event.set() + return graph + + +@pytest.fixture +def populated_mesh_graph(mesh_graph): + """Create a MeshGraph instance with sample edges for testing.""" + # Add some test edges + edges = [ + create_test_edge('01', '7e', observation_count=5, last_seen=datetime.now()), + create_test_edge('7e', '86', observation_count=3, last_seen=datetime.now()), + create_test_edge('86', 'e0', observation_count=10, last_seen=datetime.now()), + create_test_edge('e0', '09', observation_count=2, last_seen=datetime.now()), + # Bidirectional edge + create_test_edge('01', '7a', observation_count=4, last_seen=datetime.now()), + create_test_edge('7a', '01', observation_count=4, last_seen=datetime.now()), + # Stale edge (old) + create_test_edge('7a', 'cf', observation_count=1, last_seen=datetime.now() - timedelta(days=30)), + ] + populate_test_graph(mesh_graph, edges) + return mesh_graph diff --git a/tests/helpers.py b/tests/helpers.py new file mode 100644 index 0000000..a5379ce --- /dev/null +++ b/tests/helpers.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +""" +Test helper functions and factories for creating test data +""" + +from datetime import datetime, timedelta +from typing import Dict, Any, List, Optional, Tuple + + +def create_test_repeater( + prefix: str = "01", + name: str = "Test Repeater", + public_key: Optional[str] = None, + latitude: float = 47.6062, + longitude: float = -122.3321, + is_starred: bool = False, + last_heard: Optional[datetime] = None, + last_advert_timestamp: Optional[datetime] = None, + role: str = "repeater" +) -> Dict[str, Any]: + """Factory function to create test repeater data. + + Args: + prefix: Two-character hex prefix (default: "01") + name: Repeater name + public_key: Full public key (default: prefix repeated 32 times) + latitude: Latitude coordinate + longitude: Longitude coordinate + is_starred: Whether repeater is starred + last_heard: Last heard timestamp (default: now) + last_advert_timestamp: Last advert timestamp (default: now) + role: Device role (default: "repeater") + + Returns: + Dictionary with repeater data matching database schema + """ + if public_key is None: + # Generate a realistic-looking public key from prefix + public_key = (prefix.lower() * 16)[:64] # 64 hex chars = 32 bytes + + now = datetime.now() + if last_heard is None: + last_heard = now + if last_advert_timestamp is None: + last_advert_timestamp = now + + return { + 'name': name, + 'public_key': public_key, + 'device_type': 'repeater', + 'last_seen': last_heard, + 'last_heard': last_heard, + 'last_advert_timestamp': last_advert_timestamp, + 'is_active': True, + 'latitude': latitude, + 'longitude': longitude, + 'city': 'Seattle', + 'state': 'WA', + 'country': 'USA', + 'advert_count': 1, + 'signal_strength': None, + 'hop_count': 0, + 'role': role, + 'is_starred': is_starred + } + + +def create_test_edge( + from_prefix: str, + to_prefix: str, + from_public_key: Optional[str] = None, + to_public_key: Optional[str] = None, + observation_count: int = 1, + first_seen: Optional[datetime] = None, + last_seen: Optional[datetime] = None, + avg_hop_position: Optional[float] = None, + geographic_distance: Optional[float] = None +) -> Dict[str, Any]: + """Factory function to create test edge data. + + Args: + from_prefix: Source node prefix + to_prefix: Destination node prefix + from_public_key: Source public key (default: generated from prefix) + to_public_key: Destination public key (default: generated from prefix) + observation_count: Number of times edge observed + first_seen: First observation time (default: now) + last_seen: Last observation time (default: now) + avg_hop_position: Average hop position in paths + geographic_distance: Distance in km + + Returns: + Dictionary with edge data matching MeshGraph edge structure + """ + now = datetime.now() + if first_seen is None: + first_seen = now + if last_seen is None: + last_seen = now + + if from_public_key is None: + from_public_key = (from_prefix.lower() * 16)[:64] + if to_public_key is None: + to_public_key = (to_prefix.lower() * 16)[:64] + + return { + 'from_prefix': from_prefix.lower()[:2], + 'to_prefix': to_prefix.lower()[:2], + 'from_public_key': from_public_key, + 'to_public_key': to_public_key, + 'observation_count': observation_count, + 'first_seen': first_seen, + 'last_seen': last_seen, + 'avg_hop_position': avg_hop_position, + 'geographic_distance': geographic_distance + } + + +def create_test_path(node_ids: List[str]) -> List[str]: + """Factory function to create test path data. + + Args: + node_ids: List of node prefixes in path order + + Returns: + List of node IDs (normalized to lowercase) + """ + return [node_id.lower()[:2] for node_id in node_ids] + + +def populate_test_graph(mesh_graph, edges: List[Dict[str, Any]]): + """Helper to populate a MeshGraph instance with test edges. + + Args: + mesh_graph: MeshGraph instance to populate + edges: List of edge dictionaries (from create_test_edge) + """ + for edge in edges: + mesh_graph.add_edge( + edge['from_prefix'], + edge['to_prefix'], + from_public_key=edge.get('from_public_key'), + to_public_key=edge.get('to_public_key'), + hop_position=edge.get('avg_hop_position'), + geographic_distance=edge.get('geographic_distance') + ) + # Manually set observation_count and timestamps if needed + edge_key = (edge['from_prefix'].lower()[:2], edge['to_prefix'].lower()[:2]) + if edge_key in mesh_graph.edges: + if edge.get('observation_count', 1) > 1: + mesh_graph.edges[edge_key]['observation_count'] = edge['observation_count'] + if edge.get('first_seen'): + mesh_graph.edges[edge_key]['first_seen'] = edge['first_seen'] + if edge.get('last_seen'): + mesh_graph.edges[edge_key]['last_seen'] = edge['last_seen'] diff --git a/tests/integration/__init__.py b/tests/integration/__init__.py new file mode 100644 index 0000000..a265048 --- /dev/null +++ b/tests/integration/__init__.py @@ -0,0 +1 @@ +# Integration tests package diff --git a/tests/integration/test_path_graph_integration.py b/tests/integration/test_path_graph_integration.py new file mode 100644 index 0000000..78c8f2e --- /dev/null +++ b/tests/integration/test_path_graph_integration.py @@ -0,0 +1,338 @@ +#!/usr/bin/env python3 +""" +Integration tests for path resolution with graph-based validation +""" + +import pytest +from datetime import datetime, timedelta +from unittest.mock import Mock +from modules.commands.path_command import PathCommand +from modules.mesh_graph import MeshGraph +from tests.helpers import create_test_repeater, create_test_edge, populate_test_graph + + +@pytest.mark.integration +class TestPathResolutionIntegration: + """Integration tests for full path resolution.""" + + @pytest.mark.asyncio + async def test_path_resolution_with_graph_data(self, mock_bot, test_db, mesh_graph): + """Test complete path resolution using real database.""" + mock_bot.mesh_graph = mesh_graph + + # Populate database with repeater data + test_db.execute_update(''' + INSERT INTO complete_contact_tracking + (public_key, name, role, last_heard, latitude, longitude, is_starred) + VALUES (?, ?, ?, ?, ?, ?, ?) + ''', ('0101010101010101010101010101010101010101010101010101010101010101', + 'Repeater 01', 'repeater', datetime.now().isoformat(), 47.6062, -122.3321, 0)) + + test_db.execute_update(''' + INSERT INTO complete_contact_tracking + (public_key, name, role, last_heard, latitude, longitude, is_starred) + VALUES (?, ?, ?, ?, ?, ?, ?) + ''', ('7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e', + 'Repeater 7e', 'repeater', datetime.now().isoformat(), 47.5, -122.3, 0)) + + # Create graph edge + mesh_graph.add_edge('01', '7e') + for _ in range(5): + mesh_graph.add_edge('01', '7e') + + path_cmd = PathCommand(mock_bot) + + # Mock the lookup function to return our test data + def mock_lookup(node_id): + if node_id == '01': + return [create_test_repeater('01', 'Repeater 01', + public_key='0101010101010101010101010101010101010101010101010101010101010101')] + elif node_id == '7e': + return [create_test_repeater('7e', 'Repeater 7e', + public_key='7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e')] + return [] + + # Test path resolution + path = ['01', '7e'] + result = await path_cmd._lookup_repeater_names(path, lookup_func=mock_lookup) + + assert len(result) > 0 + + @pytest.mark.asyncio + async def test_path_resolution_prefix_collision(self, mock_bot, test_db, mesh_graph): + """Test path with prefix collisions using graph-based disambiguation.""" + mock_bot.mesh_graph = mesh_graph + + key1 = '7e1111111111111111111111111111111111111111111111111111111111111111' + key2 = '7e2222222222222222222222222222222222222222222222222222222222222222' + + # Mock repeater_manager to return two repeaters with same prefix + async def mock_get_repeater_devices(include_historical=True): + return [ + { + 'public_key': '0101010101010101010101010101010101010101010101010101010101010101', + 'name': 'Repeater 01', + 'role': 'repeater', + 'device_type': 'repeater', + 'last_heard': datetime.now(), + 'last_advert_timestamp': datetime.now(), + 'is_currently_tracked': True, + 'latitude': 47.6062, + 'longitude': -122.3321, + 'city': 'Seattle', + 'state': 'WA', + 'country': 'USA', + 'advert_count': 1, + 'signal_strength': None, + 'hop_count': 0, + 'is_starred': 0 + }, + { + 'public_key': key1, + 'name': 'Local 7e', + 'role': 'repeater', + 'device_type': 'repeater', + 'last_heard': datetime.now(), + 'last_advert_timestamp': datetime.now(), + 'is_currently_tracked': True, + 'latitude': 47.6, + 'longitude': -122.3, + 'city': 'Seattle', + 'state': 'WA', + 'country': 'USA', + 'advert_count': 1, + 'signal_strength': None, + 'hop_count': 0, + 'is_starred': 1 # Starred + }, + { + 'public_key': key2, + 'name': 'Distant 7e', + 'role': 'repeater', + 'device_type': 'repeater', + 'last_heard': datetime.now(), + 'last_advert_timestamp': datetime.now(), + 'is_currently_tracked': True, + 'latitude': 49.0, + 'longitude': -123.0, + 'city': 'Vancouver', + 'state': 'BC', + 'country': 'Canada', + 'advert_count': 1, + 'signal_strength': None, + 'hop_count': 0, + 'is_starred': 0 + } + ] + + mock_bot.repeater_manager = Mock() + mock_bot.repeater_manager.get_repeater_devices = mock_get_repeater_devices + + # Create graph edge to local repeater + mesh_graph.add_edge('01', '7e', to_public_key=key1) + for _ in range(10): + mesh_graph.add_edge('01', '7e') + + path_cmd = PathCommand(mock_bot) + + path = ['01', '7e'] + result = await path_cmd._lookup_repeater_names(path) + + # Should select local starred repeater with graph edge + assert len(result) > 0 + if '7e' in result: + # Verify it selected the correct one (should be Local 7e) + assert result['7e']['name'] == 'Local 7e' + + @pytest.mark.asyncio + async def test_path_resolution_starred_preference(self, mock_bot, test_db, mesh_graph): + """Test starred repeater preference in collisions.""" + mock_bot.mesh_graph = mesh_graph + + # Create edges for both repeaters + mesh_graph.add_edge('01', '7e') + mesh_graph.add_edge('01', '7a') + + path_cmd = PathCommand(mock_bot) + + def mock_lookup(node_id): + if node_id == '01': + return [create_test_repeater('01', 'Repeater 01')] + elif node_id == '7e': + return [ + create_test_repeater('7e', 'Starred 7e', is_starred=True), + create_test_repeater('7e', 'Regular 7e', is_starred=False) + ] + return [] + + path = ['01', '7e'] + result = await path_cmd._lookup_repeater_names(path, lookup_func=mock_lookup) + + # Should prefer starred repeater + assert len(result) > 0 + + @pytest.mark.asyncio + async def test_path_resolution_stored_keys_priority(self, mock_bot, test_db, mesh_graph): + """Test stored public key priority.""" + mock_bot.mesh_graph = mesh_graph + + # Create edge with stored public key + stored_key = '7e1111111111111111111111111111111111111111111111111111111111111111' + other_key = '7e2222222222222222222222222222222222222222222222222222222222222222' + mesh_graph.add_edge('01', '7e', to_public_key=stored_key) + for _ in range(5): + mesh_graph.add_edge('01', '7e') + + async def mock_get_repeater_devices(include_historical=True): + return [ + { + 'public_key': '0101010101010101010101010101010101010101010101010101010101010101', + 'name': 'Repeater 01', + 'role': 'repeater', + 'device_type': 'repeater', + 'last_heard': datetime.now(), + 'last_advert_timestamp': datetime.now(), + 'is_currently_tracked': True, + 'latitude': 47.6062, + 'longitude': -122.3321, + 'city': 'Seattle', + 'state': 'WA', + 'country': 'USA', + 'advert_count': 1, + 'signal_strength': None, + 'hop_count': 0, + 'is_starred': 0 + }, + { + 'public_key': stored_key, + 'name': 'Matching Key', + 'role': 'repeater', + 'device_type': 'repeater', + 'last_heard': datetime.now(), + 'last_advert_timestamp': datetime.now(), + 'is_currently_tracked': True, + 'latitude': 47.6, + 'longitude': -122.3, + 'city': 'Seattle', + 'state': 'WA', + 'country': 'USA', + 'advert_count': 1, + 'signal_strength': None, + 'hop_count': 0, + 'is_starred': 0 + }, + { + 'public_key': other_key, + 'name': 'Other Key', + 'role': 'repeater', + 'device_type': 'repeater', + 'last_heard': datetime.now(), + 'last_advert_timestamp': datetime.now(), + 'is_currently_tracked': True, + 'latitude': 47.5, + 'longitude': -122.2, + 'city': 'Seattle', + 'state': 'WA', + 'country': 'USA', + 'advert_count': 1, + 'signal_strength': None, + 'hop_count': 0, + 'is_starred': 0 + } + ] + + mock_bot.repeater_manager = Mock() + mock_bot.repeater_manager.get_repeater_devices = mock_get_repeater_devices + + path_cmd = PathCommand(mock_bot) + + path = ['01', '7e'] + result = await path_cmd._lookup_repeater_names(path) + + # Should select repeater with matching stored key + assert len(result) > 0 + if '7e' in result: + assert result['7e']['name'] == 'Matching Key' + + @pytest.mark.asyncio + async def test_path_resolution_multi_hop_inference(self, mock_bot, test_db, mesh_graph): + """Test multi-hop path inference in real scenario.""" + mock_bot.mesh_graph = mesh_graph + + # Create 2-hop path: 01 -> 7e -> 86 + mesh_graph.add_edge('01', '7e') + mesh_graph.add_edge('7e', '86') + + path_cmd = PathCommand(mock_bot) + path_cmd.graph_multi_hop_enabled = True + + def mock_lookup(node_id): + if node_id == '01': + return [create_test_repeater('01', 'Repeater 01')] + elif node_id == '7e': + return [create_test_repeater('7e', 'Intermediate 7e')] + elif node_id == '86': + return [create_test_repeater('86', 'Repeater 86')] + return [] + + path = ['01', '7e', '86'] + result = await path_cmd._lookup_repeater_names(path, lookup_func=mock_lookup) + + assert len(result) > 0 + + def test_path_resolution_edge_persistence(self, mock_bot, test_db): + """Test edge persistence across operations.""" + # Create graph and add edge + graph1 = MeshGraph(mock_bot) + graph1.add_edge('01', '7e') + for _ in range(5): + graph1.add_edge('01', '7e') + + # Verify in database + results = test_db.execute_query('SELECT * FROM mesh_connections WHERE from_prefix = ? AND to_prefix = ?', + ('01', '7e')) + assert len(results) == 1 + assert results[0]['observation_count'] == 6 + + # Create new graph instance (simulates restart) + graph2 = MeshGraph(mock_bot) + + # Edge should be loaded from database + edge = graph2.get_edge('01', '7e') + assert edge is not None + assert edge['observation_count'] == 6 + + @pytest.mark.asyncio + async def test_path_resolution_real_world_scenario(self, mock_bot, test_db, mesh_graph): + """Test with realistic path data.""" + mock_bot.mesh_graph = mesh_graph + + # Create realistic path: 01 -> 7e -> 86 -> e0 -> 09 + path_nodes = ['01', '7e', '86', 'e0', '09'] + + # Add edges with varying strengths + mesh_graph.add_edge('01', '7e') + for _ in range(10): + mesh_graph.add_edge('01', '7e') # Strong + + mesh_graph.add_edge('7e', '86') + for _ in range(5): + mesh_graph.add_edge('7e', '86') # Medium + + mesh_graph.add_edge('86', 'e0') + for _ in range(3): + mesh_graph.add_edge('86', 'e0') # Weak + + mesh_graph.add_edge('e0', '09') + for _ in range(8): + mesh_graph.add_edge('e0', '09') # Strong + + path_cmd = PathCommand(mock_bot) + + def mock_lookup(node_id): + return [create_test_repeater(node_id, f'Repeater {node_id}')] + + result = await path_cmd._lookup_repeater_names(path_nodes, lookup_func=mock_lookup) + + # Should resolve all nodes + assert len(result) == len(path_nodes) diff --git a/tests/integration/test_path_resolution.py b/tests/integration/test_path_resolution.py new file mode 100644 index 0000000..1ac95b2 --- /dev/null +++ b/tests/integration/test_path_resolution.py @@ -0,0 +1,177 @@ +#!/usr/bin/env python3 +""" +Integration tests for full path resolution +""" + +import pytest +import sqlite3 +from datetime import datetime, timedelta +from modules.commands.path_command import PathCommand +from modules.mesh_graph import MeshGraph +from tests.helpers import create_test_repeater, create_test_edge + + +@pytest.mark.integration +class TestPathResolutionIntegration: + """Integration tests for full path resolution with real database.""" + + def test_path_resolution_with_graph_edges(self, mock_bot, test_db, populated_mesh_graph): + """Test path resolution using graph edges from database.""" + # Add repeaters to database + repeater1 = create_test_repeater('01', 'Repeater 01', latitude=47.6062, longitude=-122.3321) + repeater2 = create_test_repeater('7e', 'Repeater 7e', latitude=47.6100, longitude=-122.3400) + repeater3 = create_test_repeater('86', 'Repeater 86', latitude=47.6200, longitude=-122.3500) + + # Insert into database + with sqlite3.connect(test_db.db_path) as conn: + cursor = conn.cursor() + for r in [repeater1, repeater2, repeater3]: + cursor.execute(''' + INSERT INTO complete_contact_tracking + (public_key, name, role, latitude, longitude, last_heard, is_starred) + VALUES (?, ?, ?, ?, ?, ?, ?) + ''', ( + r['public_key'], r['name'], r['role'], + r['latitude'], r['longitude'], r['last_heard'].isoformat(), + 1 if r['is_starred'] else 0 + )) + conn.commit() + + # Create path command + command = PathCommand(mock_bot) + command.graph_based_validation = True + + # Test path resolution + path_hex = "017e86" + # This would normally be called via handle_command, but we test the core logic + # For integration, we verify the graph edges are used correctly + + # Verify edges exist in graph + assert populated_mesh_graph.has_edge('01', '7e') + assert populated_mesh_graph.has_edge('7e', '86') + + def test_path_resolution_prefix_collision(self, mock_bot, test_db, mesh_graph): + """Test path resolution with prefix collisions resolved by graph.""" + # Create two repeaters with same prefix but different public keys + key1 = '7e' * 32 + key2 = '7f' * 32 + + repeater1 = create_test_repeater('7e', 'Repeater 7e A', public_key=key1, latitude=47.6062, longitude=-122.3321) + repeater2 = create_test_repeater('7e', 'Repeater 7e B', public_key=key2, latitude=47.7000, longitude=-122.4000) + + # Add edge with stored public key matching repeater1 + mesh_graph.add_edge('01', '7e', to_public_key=key1) + + # Insert into database + with sqlite3.connect(test_db.db_path) as conn: + cursor = conn.cursor() + for r in [repeater1, repeater2]: + cursor.execute(''' + INSERT INTO complete_contact_tracking + (public_key, name, role, latitude, longitude, last_heard, is_starred) + VALUES (?, ?, ?, ?, ?, ?, ?) + ''', ( + r['public_key'], r['name'], r['role'], + r['latitude'], r['longitude'], r['last_heard'].isoformat(), + 1 if r['is_starred'] else 0 + )) + conn.commit() + + # Graph should prefer repeater1 due to stored key match + edge = mesh_graph.get_edge('01', '7e') + assert edge is not None + assert edge.get('to_public_key') == key1 + + def test_edge_persistence_across_restarts(self, mock_bot, test_db, populated_mesh_graph): + """Test that edges persist in database across graph restarts.""" + # Add edge to existing graph + graph1 = populated_mesh_graph + graph1.add_edge('01', '7e', from_public_key='01' * 32, to_public_key='7e' * 32) + + # Verify edge in database + with sqlite3.connect(test_db.db_path) as conn: + cursor = conn.cursor() + cursor.execute('SELECT * FROM mesh_connections WHERE from_prefix = ? AND to_prefix = ?', ('01', '7e')) + row = cursor.fetchone() + assert row is not None + + # Create new graph instance (simulating restart) - loads from same db via mock_bot.db_manager + graph2 = MeshGraph(mock_bot) + + # Edge should be loaded from database + assert graph2.has_edge('01', '7e') + edge = graph2.get_edge('01', '7e') + assert edge['from_public_key'] == '01' * 32 + assert edge['to_public_key'] == '7e' * 32 + + def test_graph_vs_geographic_selection(self, mock_bot, test_db, populated_mesh_graph): + """Test that graph selection can override geographic when graph evidence is strong.""" + # Create two repeaters with same prefix + # Repeater1: closer geographically + # Repeater2: has strong graph evidence + + repeater1 = create_test_repeater('7e', 'Close Repeater', latitude=47.6062, longitude=-122.3321) + repeater2 = create_test_repeater('7e', 'Graph Repeater', latitude=47.7000, longitude=-122.4000) + + # Add strong graph edge for repeater2 + key2 = repeater2['public_key'] + populated_mesh_graph.add_edge('01', '7e', to_public_key=key2) + populated_mesh_graph.add_edge('01', '7e', to_public_key=key2) # Multiple observations + populated_mesh_graph.add_edge('01', '7e', to_public_key=key2) + + # Insert into database + with sqlite3.connect(test_db.db_path) as conn: + cursor = conn.cursor() + for r in [repeater1, repeater2]: + cursor.execute(''' + INSERT INTO complete_contact_tracking + (public_key, name, role, latitude, longitude, last_heard, is_starred) + VALUES (?, ?, ?, ?, ?, ?, ?) + ''', ( + r['public_key'], r['name'], r['role'], + r['latitude'], r['longitude'], r['last_heard'].isoformat(), + 1 if r['is_starred'] else 0 + )) + conn.commit() + + # Graph should prefer repeater2 due to stored key and multiple observations + edge = populated_mesh_graph.get_edge('01', '7e') + assert edge is not None + assert edge.get('to_public_key') == key2 + assert edge['observation_count'] >= 3 + + def test_real_world_path_scenario(self, mock_bot, test_db, populated_mesh_graph): + """Test a realistic multi-hop path scenario.""" + # Create a realistic path: 01 -> 7e -> 86 -> e0 -> 09 + path_nodes = ['01', '7e', '86', 'e0', '09'] + + # Add repeaters + repeaters = [] + for i, node_id in enumerate(path_nodes): + lat = 47.6062 + (i * 0.01) + lon = -122.3321 - (i * 0.01) + repeater = create_test_repeater( + node_id, f'Repeater {node_id}', + latitude=lat, longitude=lon + ) + repeaters.append(repeater) + + # Insert into database + with sqlite3.connect(test_db.db_path) as conn: + cursor = conn.cursor() + for r in repeaters: + cursor.execute(''' + INSERT INTO complete_contact_tracking + (public_key, name, role, latitude, longitude, last_heard, is_starred) + VALUES (?, ?, ?, ?, ?, ?, ?) + ''', ( + r['public_key'], r['name'], r['role'], + r['latitude'], r['longitude'], r['last_heard'].isoformat(), + 1 if r['is_starred'] else 0 + )) + conn.commit() + + # Verify path is valid in graph + is_valid, confidence = populated_mesh_graph.validate_path(path_nodes) + assert is_valid is True + assert confidence > 0.0 diff --git a/tests/regression/__init__.py b/tests/regression/__init__.py new file mode 100644 index 0000000..69ddf4e --- /dev/null +++ b/tests/regression/__init__.py @@ -0,0 +1 @@ +# Regression tests diff --git a/tests/regression/test_keyword_escapes.py b/tests/regression/test_keyword_escapes.py new file mode 100644 index 0000000..aa396d4 --- /dev/null +++ b/tests/regression/test_keyword_escapes.py @@ -0,0 +1,23 @@ +"""Regression tests for keyword escape sequences.""" + +import pytest + +from modules.utils import decode_escape_sequences + + +class TestKeywordEscapes: + """Regression: decode_escape_sequences handles config escape sequences.""" + + def test_newline_in_config(self): + """Config strings with \\n should produce actual newlines.""" + result = decode_escape_sequences(r"Line 1\nLine 2") + assert result == "Line 1\nLine 2" + + def test_literal_backslash_n_preserved(self): + """\\\\n in config should produce literal \\n.""" + result = decode_escape_sequences(r"Literal \\n here") + assert result == "Literal \\n here" + + def test_tab_escape(self): + result = decode_escape_sequences(r"Col1\tCol2") + assert result == "Col1\tCol2" diff --git a/tests/test_command_prefix.py b/tests/test_command_prefix.py new file mode 100644 index 0000000..3e161e9 --- /dev/null +++ b/tests/test_command_prefix.py @@ -0,0 +1,289 @@ +#!/usr/bin/env python3 +""" +Unit tests for command prefix functionality +Tests that all commands properly handle command prefixes when enabled +""" + +import pytest +from unittest.mock import Mock, MagicMock, patch +from configparser import ConfigParser + +from modules.models import MeshMessage +from modules.commands.base_command import BaseCommand +from modules.commands.hello_command import HelloCommand +from modules.commands.ping_command import PingCommand +from modules.commands.help_command import HelpCommand +from modules.command_manager import CommandManager + + +class MockTestCommand(BaseCommand): + """Mock command for testing prefix functionality""" + name = "test" + keywords = ['test', 't'] + description = "Test command" + category = "test" + + async def execute(self, message: MeshMessage) -> bool: + """Execute the test command (required by abstract base class)""" + return True + + +@pytest.fixture +def mock_bot(): + """Create a mock bot instance""" + bot = Mock() + bot.logger = Mock() + bot.logger.debug = Mock() + bot.logger.info = Mock() + bot.logger.warning = Mock() + bot.logger.error = Mock() + bot.config = ConfigParser() + bot.config.add_section('Bot') + bot.config.add_section('Channels') + bot.config.set('Channels', 'monitor_channels', 'general') + bot.config.set('Channels', 'respond_to_dms', 'true') + bot.meshcore = None + bot.translator = None + bot.rate_limiter = Mock() + bot.rate_limiter.can_send = Mock(return_value=True) + bot.bot_tx_rate_limiter = Mock() + bot.bot_tx_rate_limiter.wait_for_tx = Mock() + bot.tx_delay_ms = 0 + return bot + + +@pytest.fixture +def mock_message(): + """Create a mock message""" + return MeshMessage( + content="test", + sender_id="TestUser", + channel="general", + is_dm=False + ) + + +class TestCommandPrefix: + """Test command prefix functionality""" + + def test_no_prefix_allows_commands(self, mock_bot, mock_message): + """Test that without prefix configured, commands work normally""" + mock_bot.config.set('Bot', 'command_prefix', '') + command = MockTestCommand(mock_bot) + + # Should match without prefix + assert command.matches_keyword(mock_message) is True + + # Should also match with legacy ! prefix + mock_message.content = "!test" + assert command.matches_keyword(mock_message) is True + + def test_prefix_required_when_configured(self, mock_bot, mock_message): + """Test that when prefix is configured, it's required""" + mock_bot.config.set('Bot', 'command_prefix', '!') + command = MockTestCommand(mock_bot) + + # Should match with prefix + mock_message.content = "!test" + assert command.matches_keyword(mock_message) is True + + # Should NOT match without prefix + mock_message.content = "test" + assert command.matches_keyword(mock_message) is False + + def test_dot_prefix(self, mock_bot, mock_message): + """Test dot prefix (e.g., .ping)""" + mock_bot.config.set('Bot', 'command_prefix', '.') + command = MockTestCommand(mock_bot) + + # Should match with dot prefix + mock_message.content = ".test" + assert command.matches_keyword(mock_message) is True + + # Should NOT match without prefix + mock_message.content = "test" + assert command.matches_keyword(mock_message) is False + + def test_single_char_prefix(self, mock_bot, mock_message): + """Test single character prefix (e.g., bping)""" + mock_bot.config.set('Bot', 'command_prefix', 'b') + command = MockTestCommand(mock_bot) + + # Should match with 'b' prefix + mock_message.content = "btest" + assert command.matches_keyword(mock_message) is True + + # Should NOT match without prefix + mock_message.content = "test" + assert command.matches_keyword(mock_message) is False + + def test_multi_char_prefix(self, mock_bot, mock_message): + """Test multi-character prefix (e.g., abcping)""" + mock_bot.config.set('Bot', 'command_prefix', 'abc') + command = MockTestCommand(mock_bot) + + # Should match with 'abc' prefix + mock_message.content = "abctest" + assert command.matches_keyword(mock_message) is True + + # Should NOT match without prefix + mock_message.content = "test" + assert command.matches_keyword(mock_message) is False + + # Should NOT match with partial prefix + mock_message.content = "abtest" + assert command.matches_keyword(mock_message) is False + + def test_prefix_with_whitespace(self, mock_bot, mock_message): + """Test that prefix works with whitespace after it""" + mock_bot.config.set('Bot', 'command_prefix', '!') + command = MockTestCommand(mock_bot) + + # Should match with prefix and space + mock_message.content = "! test" + assert command.matches_keyword(mock_message) is True + + # Should match with prefix and no space + mock_message.content = "!test" + assert command.matches_keyword(mock_message) is True + + def test_prefix_with_keyword_variations(self, mock_bot, mock_message): + """Test prefix with different keyword variations""" + mock_bot.config.set('Bot', 'command_prefix', '!') + command = MockTestCommand(mock_bot) + + # Test first keyword + mock_message.content = "!test" + assert command.matches_keyword(mock_message) is True + + # Test second keyword + mock_message.content = "!t" + assert command.matches_keyword(mock_message) is True + + # Test keyword with arguments + mock_message.content = "!test arg1 arg2" + assert command.matches_keyword(mock_message) is True + + def test_hello_command_with_prefix(self, mock_bot, mock_message): + """Test hello command specifically with prefix""" + mock_bot.config.set('Bot', 'command_prefix', '!') + mock_bot.config.set('Bot', 'bot_name', 'TestBot') + mock_bot.config.add_section('Hello_Command') + mock_bot.config.set('Hello_Command', 'enabled', 'true') + command = HelloCommand(mock_bot) + + # Should match with prefix + mock_message.content = "!hello" + assert command.matches_keyword(mock_message) is True + + # Should NOT match without prefix + mock_message.content = "hello" + assert command.matches_keyword(mock_message) is False + + def test_ping_command_with_prefix(self, mock_bot, mock_message): + """Test ping command with prefix""" + mock_bot.config.set('Bot', 'command_prefix', '.') + mock_bot.config.add_section('Ping_Command') + mock_bot.config.set('Ping_Command', 'enabled', 'true') + command = PingCommand(mock_bot) + + # Should match with dot prefix + mock_message.content = ".ping" + assert command.matches_keyword(mock_message) is True + + # Should NOT match without prefix + mock_message.content = "ping" + assert command.matches_keyword(mock_message) is False + + def test_command_manager_with_prefix(self, mock_bot, mock_message): + """Test CommandManager handles prefix correctly""" + mock_bot.config.set('Bot', 'command_prefix', '!') + mock_bot.config.add_section('Keywords') + mock_bot.config.set('Keywords', 'keywords', '') + mock_bot.config.add_section('Custom_Syntax') + mock_bot.config.set('Custom_Syntax', 'custom_syntax', '') + + # Mock plugin loader to return empty commands for simplicity + with patch('modules.command_manager.PluginLoader') as mock_loader_class: + mock_loader = Mock() + mock_loader.load_all_plugins = Mock(return_value={}) + mock_loader_class.return_value = mock_loader + + manager = CommandManager(mock_bot) + + # Should return empty matches for message without prefix + mock_message.content = "test" + matches = manager.check_keywords(mock_message) + assert matches == [] + + # Should process message with prefix + mock_message.content = "!test" + matches = manager.check_keywords(mock_message) + # Will be empty because no commands loaded, but should process without error + assert isinstance(matches, list) + + def test_prefix_with_mentions(self, mock_bot, mock_message): + """Test that prefix works correctly with @[username] mentions""" + mock_bot.config.set('Bot', 'command_prefix', '!') + mock_bot.config.set('Bot', 'bot_name', 'TestBot') + command = MockTestCommand(mock_bot) + + # Mock self_info to return bot name + mock_bot.meshcore = Mock() + mock_bot.meshcore.self_info = {'name': 'TestBot'} + + # Should match with prefix and bot mention + mock_message.content = "!@[TestBot] test" + assert command.matches_keyword(mock_message) is True + + # Should NOT match with prefix but other user mention + mock_message.content = "!@[OtherUser] test" + assert command.matches_keyword(mock_message) is False + + def test_different_prefixes_dont_match(self, mock_bot, mock_message): + """Test that wrong prefix doesn't match""" + mock_bot.config.set('Bot', 'command_prefix', '!') + command = MockTestCommand(mock_bot) + + # Should NOT match with wrong prefix + mock_message.content = ".test" + assert command.matches_keyword(mock_message) is False + + mock_message.content = "btest" + assert command.matches_keyword(mock_message) is False + + mock_message.content = "abctest" + assert command.matches_keyword(mock_message) is False + + def test_prefix_case_sensitive(self, mock_bot, mock_message): + """Test that prefix matching is case-sensitive""" + mock_bot.config.set('Bot', 'command_prefix', '!') + command = MockTestCommand(mock_bot) + + # Should match with exact prefix + mock_message.content = "!test" + assert command.matches_keyword(mock_message) is True + + # Prefix matching is case-sensitive, so different case shouldn't match + # (This tests the actual behavior - prefixes are case-sensitive) + mock_message.content = "!TEST" # Prefix is still '!', so this should match + assert command.matches_keyword(mock_message) is True # '!' is same case + + # But if prefix is lowercase, uppercase shouldn't match + mock_bot.config.set('Bot', 'command_prefix', 'b') + command = MockTestCommand(mock_bot) + mock_message.content = "Btest" # Uppercase B + assert command.matches_keyword(mock_message) is False # Should not match lowercase 'b' + + def test_empty_prefix_string(self, mock_bot, mock_message): + """Test that empty string prefix means no prefix required""" + mock_bot.config.set('Bot', 'command_prefix', '') + command = MockTestCommand(mock_bot) + + # Should match without prefix + mock_message.content = "test" + assert command.matches_keyword(mock_message) is True + + # Should also match with legacy ! prefix + mock_message.content = "!test" + assert command.matches_keyword(mock_message) is True diff --git a/tests/test_config_validation.py b/tests/test_config_validation.py new file mode 100644 index 0000000..2896830 --- /dev/null +++ b/tests/test_config_validation.py @@ -0,0 +1,339 @@ +"""Tests for modules.config_validation.""" + +import pytest +from pathlib import Path + +from modules.config_validation import ( + SEVERITY_ERROR, + SEVERITY_INFO, + SEVERITY_WARNING, + validate_config, + _resolve_path, + _check_path_writable, + _suggest_similar_command, + _get_command_prefix_to_section, +) + + +class TestValidateConfig: + """Tests for validate_config().""" + + def test_config_file_not_found(self): + results = validate_config("/nonexistent/path/config.ini") + assert len(results) == 1 + assert results[0][0] == SEVERITY_ERROR + assert "not found" in results[0][1] + + def test_missing_required_sections(self, tmp_path): + config = tmp_path / "config.ini" + config.write_text("[Bot]\nbot_name = Test\n") + results = validate_config(str(config)) + errors = [r for r in results if r[0] == SEVERITY_ERROR] + assert any("Connection" in r[1] for r in errors) + assert any("Channels" in r[1] for r in errors) + + def test_valid_minimal_config(self, tmp_path): + config = tmp_path / "config.ini" + config.write_text("""[Connection] +connection_type = serial +serial_port = /dev/ttyUSB0 + +[Bot] +bot_name = TestBot +db_path = {db_path} + +[Channels] +monitor_channels = general +respond_to_dms = true + +[Keywords] +test = ack +ping = Pong +""".format(db_path=str(tmp_path / "meshcore_bot.db"))) + results = validate_config(str(config)) + errors = [r for r in results if r[0] == SEVERITY_ERROR] + assert len(errors) == 0 + + def test_optional_sections_absent_info(self, tmp_path): + config = tmp_path / "config.ini" + config.write_text("""[Connection] +connection_type = serial +serial_port = /dev/ttyUSB0 + +[Bot] +bot_name = TestBot +db_path = {db_path} + +[Channels] +monitor_channels = general +respond_to_dms = true + +[Keywords] +test = ack +""".format(db_path=str(tmp_path / "meshcore_bot.db"))) + results = validate_config(str(config)) + infos = [r for r in results if r[0] == SEVERITY_INFO] + assert any("Admin_ACL" in r[1] for r in infos) + assert any("Banned_Users" in r[1] for r in infos) + assert any("Localization" in r[1] for r in infos) + + def test_non_standard_section_typo(self, tmp_path): + config = tmp_path / "config.ini" + config.write_text("""[Connection] +connection_type = serial +serial_port = /dev/ttyUSB0 + +[Bot] +bot_name = TestBot +db_path = {db_path} + +[Channels] +monitor_channels = general +respond_to_dms = true + +[WebViewer] +debug = false +""".format(db_path=str(tmp_path / "meshcore_bot.db"))) + results = validate_config(str(config)) + warnings = [r for r in results if r[0] == SEVERITY_WARNING] + assert any("WebViewer" in r[1] and "Web_Viewer" in r[1] for r in warnings) + + def test_unknown_section_similar_command(self, tmp_path): + config = tmp_path / "config.ini" + config.write_text("""[Connection] +connection_type = serial +serial_port = /dev/ttyUSB0 + +[Bot] +bot_name = TestBot +db_path = {db_path} + +[Channels] +monitor_channels = general +respond_to_dms = true + +[Stats] +enabled = true +""".format(db_path=str(tmp_path / "meshcore_bot.db"))) + results = validate_config(str(config)) + infos = [r for r in results if r[0] == SEVERITY_INFO] + assert any("Stats" in r[1] and "Stats_Command" in r[1] for r in infos) + + +class TestPathValidation: + """Tests for path writability validation.""" + + def test_db_path_nonexistent_parent_warns(self, tmp_path): + config = tmp_path / "config.ini" + config.write_text("""[Connection] +connection_type = serial +serial_port = /dev/ttyUSB0 + +[Bot] +bot_name = TestBot +db_path = /nonexistent/path/12345/meshcore_bot.db + +[Channels] +monitor_channels = general +respond_to_dms = true + +[Keywords] +test = ack +""") + results = validate_config(str(config)) + warnings = [r for r in results if r[0] == SEVERITY_WARNING] + assert any("Database path" in r[1] for r in warnings) + assert any("parent directory does not exist" in r[1] for r in warnings) + + def test_log_path_nonexistent_parent_warns(self, tmp_path): + config = tmp_path / "config.ini" + config.write_text("""[Connection] +connection_type = serial +serial_port = /dev/ttyUSB0 + +[Bot] +bot_name = TestBot +db_path = {db_path} + +[Channels] +monitor_channels = general +respond_to_dms = true + +[Logging] +log_file = /nonexistent/logs/12345/meshcore_bot.log + +[Keywords] +test = ack +""".format(db_path=str(tmp_path / "meshcore_bot.db"))) + results = validate_config(str(config)) + warnings = [r for r in results if r[0] == SEVERITY_WARNING] + assert any("Log file path" in r[1] for r in warnings) + + def test_writable_paths_pass(self, tmp_path): + log_dir = tmp_path / "logs" + log_dir.mkdir() + config = tmp_path / "config.ini" + config.write_text("""[Connection] +connection_type = serial +serial_port = /dev/ttyUSB0 + +[Bot] +bot_name = TestBot +db_path = {db_path} + +[Channels] +monitor_channels = general +respond_to_dms = true + +[Logging] +log_file = {log_file} + +[Keywords] +test = ack +""".format( + db_path=str(tmp_path / "meshcore_bot.db"), + log_file=str(log_dir / "meshcore_bot.log"), + )) + results = validate_config(str(config)) + path_warnings = [r for r in results if r[0] == SEVERITY_WARNING + and ("Database path" in r[1] or "Log file path" in r[1])] + assert len(path_warnings) == 0 + + def test_relative_db_path_resolved_from_config_dir(self, tmp_path): + config = tmp_path / "config.ini" + config.write_text("""[Connection] +connection_type = serial +serial_port = /dev/ttyUSB0 + +[Bot] +bot_name = TestBot +db_path = meshcore_bot.db + +[Channels] +monitor_channels = general +respond_to_dms = true + +[Keywords] +test = ack +""") + results = validate_config(str(config)) + path_warnings = [r for r in results if r[0] == SEVERITY_WARNING + and "Database path" in r[1]] + assert len(path_warnings) == 0 + + def test_web_viewer_db_path_validation(self, tmp_path): + config = tmp_path / "config.ini" + config.write_text("""[Connection] +connection_type = serial +serial_port = /dev/ttyUSB0 + +[Bot] +bot_name = TestBot +db_path = {db_path} + +[Channels] +monitor_channels = general +respond_to_dms = true + +[Web_Viewer] +db_path = /nonexistent/webviewer/12345/bot_data.db + +[Keywords] +test = ack +""".format(db_path=str(tmp_path / "meshcore_bot.db"))) + results = validate_config(str(config)) + warnings = [r for r in results if r[0] == SEVERITY_WARNING] + assert any("Web viewer db_path" in r[1] for r in warnings) + + def test_directory_not_writable_warns(self, tmp_path): + read_only_dir = tmp_path / "readonly" + read_only_dir.mkdir() + read_only_dir.chmod(0o444) + try: + config = tmp_path / "config.ini" + config.write_text("""[Connection] +connection_type = serial +serial_port = /dev/ttyUSB0 + +[Bot] +bot_name = TestBot +db_path = {db_path} + +[Channels] +monitor_channels = general +respond_to_dms = true + +[Keywords] +test = ack +""".format(db_path=str(read_only_dir / "meshcore_bot.db"))) + results = validate_config(str(config)) + warnings = [r for r in results if r[0] == SEVERITY_WARNING] + assert any("Database path" in r[1] for r in warnings) + assert any("not writable" in r[1] for r in warnings) + finally: + read_only_dir.chmod(0o755) + + +class TestResolvePath: + """Tests for _resolve_path().""" + + def test_absolute_path_returns_resolved(self): + result = _resolve_path("/foo/bar/baz", Path("/other")) + assert result == Path("/foo/bar/baz").resolve() + + def test_relative_path_resolved_from_base(self): + base = Path("/base/dir") + result = _resolve_path("subdir/file.db", base) + assert result == (base / "subdir" / "file.db").resolve() + + +class TestCheckPathWritable: + """Tests for _check_path_writable().""" + + def test_empty_path_returns_none(self): + assert _check_path_writable("", Path("/tmp"), "Test") is None + assert _check_path_writable(" ", Path("/tmp"), "Test") is None + + def test_nonexistent_parent_returns_warning(self): + msg = _check_path_writable( + "/nonexistent/path/xyz/file.log", + Path("/tmp"), + "Test path", + ) + assert msg is not None + assert "parent directory does not exist" in msg + + def test_writable_dir_returns_none(self, tmp_path): + target = tmp_path / "subdir" / "file.log" + assert _check_path_writable( + str(target), + tmp_path, + "Test path", + ) is None + + +class TestSuggestSimilarCommand: + """Tests for _suggest_similar_command().""" + + def test_exact_match(self): + prefix_map = {"stats": "Stats_Command", "hacker": "Hacker_Command"} + assert _suggest_similar_command("stats", prefix_map) == "Stats_Command" + assert _suggest_similar_command("Stats", prefix_map) == "Stats_Command" + + def test_no_match(self): + prefix_map = {"stats": "Stats_Command"} + assert _suggest_similar_command("unknown", prefix_map) is None + + +class TestGetCommandPrefixToSection: + """Tests for _get_command_prefix_to_section().""" + + def test_returns_dict(self): + result = _get_command_prefix_to_section() + assert isinstance(result, dict) + + def test_contains_known_commands(self): + result = _get_command_prefix_to_section() + assert "stats" in result or "ping" in result + for k, v in result.items(): + assert v.endswith("_Command") diff --git a/tests/test_rate_limiter.py b/tests/test_rate_limiter.py new file mode 100644 index 0000000..4193fb5 --- /dev/null +++ b/tests/test_rate_limiter.py @@ -0,0 +1,78 @@ +"""Tests for modules.rate_limiter.""" + +import time +from unittest.mock import patch + +import pytest + +from modules.rate_limiter import RateLimiter, PerUserRateLimiter + + +class TestRateLimiter: + """Tests for RateLimiter.""" + + def test_can_send_initially_true(self): + limiter = RateLimiter(seconds=5) + assert limiter.can_send() is True + + def test_can_send_after_record_send_false_within_interval(self): + limiter = RateLimiter(seconds=5) + limiter.record_send() + assert limiter.can_send() is False + + def test_can_send_after_interval_elapsed(self): + limiter = RateLimiter(seconds=1) + limiter.record_send() + time.sleep(1.1) + assert limiter.can_send() is True + + def test_time_until_next(self): + limiter = RateLimiter(seconds=5) + limiter.record_send() + t = limiter.time_until_next() + assert 0 < t <= 5 + + def test_record_send_updates_last_send(self): + limiter = RateLimiter(seconds=10) + before = time.time() + limiter.record_send() + after = time.time() + assert before <= limiter.last_send <= after + + +class TestPerUserRateLimiter: + """Tests for PerUserRateLimiter.""" + + def test_empty_key_always_allowed(self): + limiter = PerUserRateLimiter(seconds=5) + assert limiter.can_send("") is True + limiter.record_send("") + assert limiter.can_send("") is True + + def test_per_key_tracking(self): + limiter = PerUserRateLimiter(seconds=5) + limiter.record_send("user1") + assert limiter.can_send("user1") is False + assert limiter.can_send("user2") is True + + def test_record_send_then_wait_allows_send(self): + limiter = PerUserRateLimiter(seconds=1) + limiter.record_send("user1") + time.sleep(1.1) + assert limiter.can_send("user1") is True + + def test_time_until_next(self): + limiter = PerUserRateLimiter(seconds=5) + limiter.record_send("user1") + t = limiter.time_until_next("user1") + assert 0 < t <= 5 + assert limiter.time_until_next("") == 0.0 + + def test_eviction_at_max_entries(self): + limiter = PerUserRateLimiter(seconds=10, max_entries=2) + limiter.record_send("user1") + limiter.record_send("user2") + # Add third user - should evict user1 + limiter.record_send("user3") + assert "user1" not in limiter._last_send or len(limiter._last_send) <= 2 + assert "user3" in limiter._last_send diff --git a/tests/test_security_utils.py b/tests/test_security_utils.py new file mode 100644 index 0000000..7c20d07 --- /dev/null +++ b/tests/test_security_utils.py @@ -0,0 +1,135 @@ +"""Tests for modules.security_utils.""" + +import pytest +from pathlib import Path +from unittest.mock import patch + +from modules.security_utils import ( + validate_pubkey_format, + validate_safe_path, + validate_external_url, + sanitize_input, + validate_api_key_format, + validate_port_number, +) + + +class TestValidatePubkeyFormat: + """Tests for validate_pubkey_format().""" + + def test_valid_hex_64_chars(self): + valid_key = "a" * 64 + assert validate_pubkey_format(valid_key) is True + assert validate_pubkey_format("0123456789abcdef" * 4) is True + + def test_invalid_length(self): + assert validate_pubkey_format("a" * 63) is False + assert validate_pubkey_format("a" * 65) is False + assert validate_pubkey_format("") is False + + def test_invalid_chars(self): + assert validate_pubkey_format("g" + "a" * 63) is False + assert validate_pubkey_format("a" * 63 + "Z") is False # Actually Z might be valid in hex - no, hex is 0-9a-fA-F. Z is invalid. + assert validate_pubkey_format("a" * 63 + "-") is False + + def test_not_string(self): + assert validate_pubkey_format(None) is False + assert validate_pubkey_format(12345) is False + + +class TestValidateSafePath: + """Tests for validate_safe_path().""" + + @patch("modules.security_utils._is_nix_environment", return_value=True) + def test_relative_path_resolution(self, mock_nix, tmp_path): + # Patch Nix check so tmp_path (under /private on macOS) doesn't trigger dangerous path + result = validate_safe_path("subdir/file.db", base_dir=str(tmp_path), allow_absolute=False) + assert result == (tmp_path / "subdir" / "file.db").resolve() + + def test_path_traversal_rejected(self, tmp_path): + with pytest.raises(ValueError, match="Path traversal"): + validate_safe_path("../../../etc/passwd", base_dir=str(tmp_path), allow_absolute=False) + + def test_absolute_path_when_not_allowed_raises(self, tmp_path): + with pytest.raises(ValueError, match="Path traversal"): + validate_safe_path("/etc/passwd", base_dir=str(tmp_path), allow_absolute=False) + + @patch("modules.security_utils._is_nix_environment", return_value=True) + def test_absolute_path_when_allowed(self, mock_nix, tmp_path): + target = tmp_path / "data" / "file.db" + target.parent.mkdir(parents=True, exist_ok=True) + result = validate_safe_path(str(target), base_dir="/other", allow_absolute=True) + assert result == target.resolve() + + +class TestValidateExternalUrl: + """Tests for validate_external_url().""" + + def test_file_scheme_rejected(self): + assert validate_external_url("file:///etc/passwd") is False + + def test_http_https_scheme_allowed(self): + with patch("socket.gethostbyname", return_value="93.184.216.34"): + assert validate_external_url("https://example.com/") is True + assert validate_external_url("http://example.com/") is True + + def test_localhost_rejected_by_default(self): + with patch("socket.gethostbyname", return_value="127.0.0.1"): + assert validate_external_url("http://localhost/") is False + assert validate_external_url("http://example.com/") is False + + def test_localhost_allowed_when_requested(self): + with patch("socket.gethostbyname", return_value="127.0.0.1"): + assert validate_external_url("http://localhost/", allow_localhost=True) is True + + def test_missing_netloc_rejected(self): + assert validate_external_url("http://") is False + + +class TestSanitizeInput: + """Tests for sanitize_input().""" + + def test_truncates_to_max_length(self): + assert len(sanitize_input("a" * 1000, max_length=100)) == 100 + + def test_strips_control_chars(self): + result = sanitize_input("hello\x01world\x02", strip_controls=True) + assert "\x01" not in result + assert "\x02" not in result + + def test_keeps_newline_tab(self): + result = sanitize_input("hello\nworld\tthere") + assert "\n" in result + assert "\t" in result + + +class TestValidateApiKeyFormat: + """Tests for validate_api_key_format().""" + + def test_valid_key(self): + assert validate_api_key_format("a1b2c3d4e5f6g7h8i9j0") is True + + def test_too_short(self): + assert validate_api_key_format("short") is False + + def test_placeholder_rejected(self): + assert validate_api_key_format("your_api_key_here" + "x" * 10) is False + + +class TestValidatePortNumber: + """Tests for validate_port_number().""" + + def test_valid_port(self): + assert validate_port_number(8080) is True + assert validate_port_number(65535) is True + + def test_privileged_port_rejected_by_default(self): + assert validate_port_number(80) is False + assert validate_port_number(443) is False + + def test_privileged_port_allowed_when_requested(self): + assert validate_port_number(80, allow_privileged=True) is True + + def test_invalid_port(self): + assert validate_port_number(0) is False + assert validate_port_number(70000) is False diff --git a/tests/test_utils.py b/tests/test_utils.py new file mode 100644 index 0000000..91b19e7 --- /dev/null +++ b/tests/test_utils.py @@ -0,0 +1,187 @@ +"""Tests for modules.utils.""" + +import pytest +from unittest.mock import MagicMock + +from modules.utils import ( + abbreviate_location, + truncate_string, + decode_escape_sequences, + parse_location_string, + calculate_distance, + format_elapsed_display, + parse_path_string, +) + + +class TestAbbreviateLocation: + """Tests for abbreviate_location().""" + + def test_empty_returns_empty(self): + assert abbreviate_location("") == "" + assert abbreviate_location(None) is None + + def test_under_max_length_unchanged(self): + assert abbreviate_location("Seattle", max_length=20) == "Seattle" + assert abbreviate_location("Portland, OR", max_length=20) == "Portland, OR" + + def test_united_states_abbreviated(self): + assert "USA" in abbreviate_location("United States of America", max_length=50) + assert abbreviate_location("United States", max_length=50) == "USA" + + def test_british_columbia_abbreviated(self): + assert abbreviate_location("Vancouver, British Columbia", max_length=50) == "Vancouver, BC" + + def test_over_max_truncates_with_ellipsis(self): + result = abbreviate_location("Very Long City Name That Exceeds Limit", max_length=20) + assert len(result) <= 20 + assert result.endswith("...") + + def test_comma_separated_keeps_first_part_when_truncating(self): + result = abbreviate_location("Seattle, Washington, USA", max_length=10) + assert "Seattle" in result or result.startswith("Seattle") + + +class TestTruncateString: + """Tests for truncate_string().""" + + def test_empty_returns_empty(self): + assert truncate_string("", 10) == "" + assert truncate_string(None, 10) is None + + def test_under_max_unchanged(self): + assert truncate_string("hello", 10) == "hello" + + def test_over_max_truncates_with_ellipsis(self): + assert truncate_string("hello world", 8) == "hello..." + assert truncate_string("hello world", 11) == "hello world" + + def test_custom_ellipsis(self): + # max_length=8 with ellipsis=".." (2 chars) -> 6 chars + ".." + assert truncate_string("hello world", 8, ellipsis="..") == "hello .." + + +class TestDecodeEscapeSequences: + """Tests for decode_escape_sequences().""" + + def test_empty_returns_empty(self): + assert decode_escape_sequences("") == "" + assert decode_escape_sequences(None) is None + + def test_newline(self): + assert decode_escape_sequences(r"Line 1\nLine 2") == "Line 1\nLine 2" + + def test_tab(self): + assert decode_escape_sequences(r"Col1\tCol2") == "Col1\tCol2" + + def test_literal_backslash_n(self): + assert decode_escape_sequences(r"Literal \\n here") == "Literal \\n here" + + def test_mixed(self): + result = decode_escape_sequences(r"Line 1\nLine 2\tTab") + assert "Line 1" in result + assert "\n" in result + assert "\t" in result + + def test_carriage_return(self): + assert decode_escape_sequences(r"Line1\r\nLine2") == "Line1\r\nLine2" + + +class TestParseLocationString: + """Tests for parse_location_string().""" + + def test_no_comma_returns_city_only(self): + city, second, kind = parse_location_string("Seattle") + assert city == "Seattle" + assert second is None + assert kind is None + + def test_zipcode_only(self): + city, second, kind = parse_location_string("98101") + assert city == "98101" + assert second is None + assert kind is None + + def test_city_state_format(self): + city, second, kind = parse_location_string("Seattle, WA") + assert city == "Seattle" + assert second is not None + assert kind in ("state", None) + + def test_city_country_format(self): + city, second, kind = parse_location_string("Stockholm, Sweden") + assert city == "Stockholm" + assert second is not None + + +class TestCalculateDistance: + """Tests for calculate_distance() (Haversine).""" + + def test_same_point_zero_distance(self): + assert calculate_distance(47.6062, -122.3321, 47.6062, -122.3321) == 0.0 + + def test_known_distance_seattle_portland(self): + # Seattle to Portland ~233 km + dist = calculate_distance(47.6062, -122.3321, 45.5152, -122.6784) + assert 220 < dist < 250 + + def test_known_distance_short(self): + # ~1 degree lat at equator ~111 km + dist = calculate_distance(0, 0, 1, 0) + assert 110 < dist < 112 + + +class TestFormatElapsedDisplay: + """Tests for format_elapsed_display().""" + + def test_none_returns_sync_message(self): + assert "Sync" in format_elapsed_display(None) + assert "Clock" in format_elapsed_display(None) + + def test_unknown_returns_sync_message(self): + assert "Sync" in format_elapsed_display("unknown") + + def test_invalid_type_returns_sync_message(self): + assert "Sync" in format_elapsed_display("not_a_number") + + def test_valid_recent_timestamp_returns_ms(self): + import time + ts = time.time() - 1.5 # 1.5 seconds ago + result = format_elapsed_display(ts) + assert "ms" in result + assert "Sync" not in result + + def test_future_timestamp_returns_sync_message(self): + import time + ts = time.time() + 3600 # 1 hour in future + assert "Sync" in format_elapsed_display(ts) + + def test_translator_used_when_provided(self): + translator = MagicMock() + translator.translate = MagicMock(return_value="Custom Sync Message") + result = format_elapsed_display(None, translator=translator) + assert result == "Custom Sync Message" + + +class TestParsePathString: + """Tests for parse_path_string().""" + + def test_empty_returns_empty_list(self): + assert parse_path_string("") == [] + assert parse_path_string(None) == [] + + def test_comma_separated(self): + assert parse_path_string("01,5f,ab") == ["01", "5F", "AB"] + + def test_space_separated(self): + assert parse_path_string("01 5f ab") == ["01", "5F", "AB"] + + def test_continuous_hex(self): + assert parse_path_string("015fab") == ["01", "5F", "AB"] + + def test_with_hop_count_suffix(self): + result = parse_path_string("01,5f (2 hops)") + assert result == ["01", "5F"] + + def test_mixed_case_normalized_uppercase(self): + assert parse_path_string("01,5f,aB") == ["01", "5F", "AB"] diff --git a/tests/unit/__init__.py b/tests/unit/__init__.py new file mode 100644 index 0000000..4a5d263 --- /dev/null +++ b/tests/unit/__init__.py @@ -0,0 +1 @@ +# Unit tests package diff --git a/tests/unit/test_mesh_graph.py b/tests/unit/test_mesh_graph.py new file mode 100644 index 0000000..8d1db68 --- /dev/null +++ b/tests/unit/test_mesh_graph.py @@ -0,0 +1,539 @@ +#!/usr/bin/env python3 +""" +Unit tests for MeshGraph class +""" + +import pytest +from datetime import datetime, timedelta +from modules.mesh_graph import MeshGraph +from tests.helpers import create_test_edge, populate_test_graph + + +@pytest.mark.unit +class TestMeshGraphEdgeManagement: + """Test edge creation, updates, and retrieval.""" + + def test_add_edge_new(self, mesh_graph): + """Test creating a new edge.""" + from_prefix = "01" + to_prefix = "7e" + + mesh_graph.add_edge(from_prefix, to_prefix) + + edge = mesh_graph.get_edge(from_prefix, to_prefix) + assert edge is not None + assert edge['from_prefix'] == from_prefix.lower() + assert edge['to_prefix'] == to_prefix.lower() + assert edge['observation_count'] == 1 + assert isinstance(edge['first_seen'], datetime) + assert isinstance(edge['last_seen'], datetime) + assert edge['first_seen'] == edge['last_seen'] + + def test_add_edge_update_existing(self, mesh_graph): + """Test updating an existing edge.""" + from_prefix = "01" + to_prefix = "7e" + + # Create initial edge + mesh_graph.add_edge(from_prefix, to_prefix, hop_position=0) + first_seen = mesh_graph.edges[(from_prefix.lower(), to_prefix.lower())]['first_seen'] + + # Wait a tiny bit to ensure timestamps differ + import time + time.sleep(0.01) + + # Update edge + mesh_graph.add_edge(from_prefix, to_prefix, hop_position=1) + + edge = mesh_graph.get_edge(from_prefix, to_prefix) + assert edge['observation_count'] == 2 + assert edge['first_seen'] == first_seen # First seen should not change + assert edge['last_seen'] > first_seen # Last seen should update + assert edge['avg_hop_position'] == 0.5 # (0 + 1) / 2 + + def test_add_edge_public_keys(self, mesh_graph): + """Test edge creation and updates with public keys.""" + from_prefix = "01" + to_prefix = "7e" + from_key = "0101010101010101010101010101010101010101010101010101010101010101" + to_key = "7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e" + + # Create edge with public keys + mesh_graph.add_edge(from_prefix, to_prefix, from_public_key=from_key, to_public_key=to_key) + + edge = mesh_graph.get_edge(from_prefix, to_prefix) + assert edge['from_public_key'] == from_key + assert edge['to_public_key'] == to_key + + # Update with new public key + new_from_key = "0202020202020202020202020202020202020202020202020202020202020202" + mesh_graph.add_edge(from_prefix, to_prefix, from_public_key=new_from_key) + + edge = mesh_graph.get_edge(from_prefix, to_prefix) + assert edge['from_public_key'] == new_from_key + assert edge['to_public_key'] == to_key # Should remain unchanged + + def test_add_edge_hop_position(self, mesh_graph): + """Test hop position tracking and weighted average calculation.""" + from_prefix = "01" + to_prefix = "7e" + + # Add edge multiple times with different hop positions + mesh_graph.add_edge(from_prefix, to_prefix, hop_position=0) + mesh_graph.add_edge(from_prefix, to_prefix, hop_position=1) + mesh_graph.add_edge(from_prefix, to_prefix, hop_position=2) + + edge = mesh_graph.get_edge(from_prefix, to_prefix) + assert edge['observation_count'] == 3 + # Weighted average: (0*0 + 1*1 + 2*2) / 3 = 5/3 ≈ 1.667 + # Actually: ((0*0 + 1) + (1*1 + 2)) / 3 = (0 + 3) / 3 = 1.0 + # Formula: ((current_avg * (count-1)) + new_pos) / count + # After 1st: 0 + # After 2nd: ((0 * 1) + 1) / 2 = 0.5 + # After 3rd: ((0.5 * 2) + 2) / 3 = (1 + 2) / 3 = 1.0 + assert abs(edge['avg_hop_position'] - 1.0) < 0.01 + + def test_add_edge_geographic_distance(self, mesh_graph): + """Test geographic distance storage and updates.""" + from_prefix = "01" + to_prefix = "7e" + + # Create edge with distance + mesh_graph.add_edge(from_prefix, to_prefix, geographic_distance=10.5) + + edge = mesh_graph.get_edge(from_prefix, to_prefix) + assert edge['geographic_distance'] == 10.5 + + # Update distance + mesh_graph.add_edge(from_prefix, to_prefix, geographic_distance=12.3) + + edge = mesh_graph.get_edge(from_prefix, to_prefix) + assert edge['geographic_distance'] == 12.3 + + def test_get_edge_existing(self, mesh_graph): + """Test retrieving an existing edge.""" + from_prefix = "01" + to_prefix = "7e" + + mesh_graph.add_edge(from_prefix, to_prefix) + edge = mesh_graph.get_edge(from_prefix, to_prefix) + + assert edge is not None + assert edge['from_prefix'] == from_prefix.lower() + assert edge['to_prefix'] == to_prefix.lower() + + def test_get_edge_nonexistent(self, mesh_graph): + """Test retrieving a non-existent edge returns None.""" + edge = mesh_graph.get_edge("99", "aa") + assert edge is None + + def test_get_edge_case_insensitive(self, mesh_graph): + """Test that get_edge is case-insensitive.""" + mesh_graph.add_edge("01", "7E") + + # Try various case combinations + assert mesh_graph.get_edge("01", "7e") is not None + assert mesh_graph.get_edge("01", "7E") is not None + assert mesh_graph.get_edge("01", "7e") is not None + assert mesh_graph.get_edge("01", "7E") is not None + + +@pytest.mark.unit +class TestMeshGraphPathValidation: + """Test path segment and full path validation.""" + + def test_validate_path_segment_valid(self, mesh_graph): + """Test validation with sufficient observations.""" + from_prefix = "01" + to_prefix = "7e" + + # Create edge with recent timestamp and sufficient observations + mesh_graph.add_edge(from_prefix, to_prefix) + # Add more observations + for _ in range(4): + mesh_graph.add_edge(from_prefix, to_prefix) + + is_valid, confidence = mesh_graph.validate_path_segment(from_prefix, to_prefix, min_observations=3) + + assert is_valid is True + assert 0.0 <= confidence <= 1.0 + assert confidence > 0.5 # Should have high confidence with recent data and 5 observations + + def test_validate_path_segment_insufficient_observations(self, mesh_graph): + """Test validation fails when observations < min_observations.""" + from_prefix = "01" + to_prefix = "7e" + + mesh_graph.add_edge(from_prefix, to_prefix) # Only 1 observation + + is_valid, confidence = mesh_graph.validate_path_segment(from_prefix, to_prefix, min_observations=3) + + assert is_valid is False + assert confidence == 0.0 + + def test_validate_path_segment_stale(self, mesh_graph): + """Test confidence decay with old last_seen.""" + from_prefix = "01" + to_prefix = "7e" + + # Create edge with old timestamp + old_time = datetime.now() - timedelta(days=10) # 10 days ago + edge_key = (from_prefix.lower(), to_prefix.lower()) + mesh_graph.edges[edge_key] = { + 'from_prefix': from_prefix.lower(), + 'to_prefix': to_prefix.lower(), + 'observation_count': 1, # Low obs so recency dominates; 10 obs would keep confidence ~0.4 + 'first_seen': old_time, + 'last_seen': old_time, + 'avg_hop_position': None, + 'geographic_distance': None + } + + is_valid, confidence = mesh_graph.validate_path_segment(from_prefix, to_prefix, min_observations=1) + + assert is_valid is True # Still valid (has observations) + assert confidence < 0.3 # But low confidence due to staleness (10 days = ~240 hours, well past 48h half-life) + + def test_validate_path_segment_bidirectional(self, mesh_graph): + """Test bidirectional edge bonus.""" + from_prefix = "01" + to_prefix = "7e" + + # Create forward edge + mesh_graph.add_edge(from_prefix, to_prefix) + for _ in range(4): + mesh_graph.add_edge(from_prefix, to_prefix) + + # Validate without bidirectional check + is_valid, confidence_forward = mesh_graph.validate_path_segment( + from_prefix, to_prefix, min_observations=1, check_bidirectional=False + ) + + # Create reverse edge + mesh_graph.add_edge(to_prefix, from_prefix) + for _ in range(4): + mesh_graph.add_edge(to_prefix, from_prefix) + + # Validate with bidirectional check + is_valid_bidir, confidence_bidir = mesh_graph.validate_path_segment( + from_prefix, to_prefix, min_observations=1, check_bidirectional=True + ) + + assert is_valid is True + assert is_valid_bidir is True + assert confidence_bidir > confidence_forward # Bidirectional should have higher confidence + assert confidence_bidir <= min(1.0, confidence_forward + 0.15) # Bonus is +0.15 max + + def test_validate_path_segment_confidence_calculation(self, mesh_graph): + """Test confidence calculation components.""" + from_prefix = "01" + to_prefix = "7e" + + # Create edge with known values + now = datetime.now() + edge_key = (from_prefix.lower(), to_prefix.lower()) + mesh_graph.edges[edge_key] = { + 'from_prefix': from_prefix.lower(), + 'to_prefix': to_prefix.lower(), + 'observation_count': 20, # High observation count + 'first_seen': now - timedelta(hours=1), + 'last_seen': now - timedelta(hours=0.5), # Very recent + 'avg_hop_position': None, + 'geographic_distance': None + } + + is_valid, confidence = mesh_graph.validate_path_segment(from_prefix, to_prefix, min_observations=1) + + assert is_valid is True + # With 20 observations and very recent timestamp, confidence should be very high + assert confidence > 0.8 + + def test_validate_path_complete(self, mesh_graph): + """Test full path validation.""" + # Create a path: 01 -> 7e -> 86 -> e0 + path_nodes = ['01', '7e', '86', 'e0'] + + # Add edges for the path + for i in range(len(path_nodes) - 1): + mesh_graph.add_edge(path_nodes[i], path_nodes[i + 1]) + # Add extra observations for some edges + if i < 2: + mesh_graph.add_edge(path_nodes[i], path_nodes[i + 1]) + + is_valid, avg_confidence = mesh_graph.validate_path(path_nodes, min_observations=1) + + assert is_valid is True + assert 0.0 <= avg_confidence <= 1.0 + + def test_validate_path_invalid_segment(self, mesh_graph): + """Test path validation fails when one segment is invalid.""" + path_nodes = ['01', '7e', '86', 'e0'] + + # Add edges for most of the path + mesh_graph.add_edge('01', '7e') + mesh_graph.add_edge('7e', '86') + # Missing edge: 86 -> e0 + + is_valid, confidence = mesh_graph.validate_path(path_nodes, min_observations=1) + + assert is_valid is False + assert confidence == 0.0 + + def test_validate_path_single_node(self, mesh_graph): + """Test edge case: single node path is always valid.""" + is_valid, confidence = mesh_graph.validate_path(['01'], min_observations=1) + + assert is_valid is True + assert confidence == 1.0 + + +@pytest.mark.unit +class TestMeshGraphCandidateScoring: + """Test candidate scoring with various edge combinations.""" + + def test_get_candidate_score_no_edges(self, mesh_graph): + """Test candidate with no graph edges returns 0.0.""" + score = mesh_graph.get_candidate_score('99', '01', '7e', min_observations=1) + assert score == 0.0 + + def test_get_candidate_score_prev_edge_only(self, mesh_graph): + """Test scoring with only previous edge.""" + # Create edge from previous node to candidate + mesh_graph.add_edge('01', '7e') + for _ in range(4): + mesh_graph.add_edge('01', '7e') # 5 total observations + + score = mesh_graph.get_candidate_score('7e', '01', None, min_observations=1) + + assert score > 0.0 + assert score <= 1.0 + + def test_get_candidate_score_next_edge_only(self, mesh_graph): + """Test scoring with only next edge.""" + # Create edge from candidate to next node + mesh_graph.add_edge('7e', '86') + for _ in range(4): + mesh_graph.add_edge('7e', '86') # 5 total observations + + score = mesh_graph.get_candidate_score('7e', None, '86', min_observations=1) + + assert score > 0.0 + assert score <= 1.0 + + def test_get_candidate_score_both_edges(self, mesh_graph): + """Test scoring with both prev and next edges.""" + # Create both edges + mesh_graph.add_edge('01', '7e') + mesh_graph.add_edge('7e', '86') + + score = mesh_graph.get_candidate_score('7e', '01', '86', min_observations=1) + + assert score > 0.0 + assert score <= 1.0 + # Should be average of both edge confidences + + def test_get_candidate_score_bidirectional(self, mesh_graph): + """Test bidirectional edge checking increases score.""" + # Create forward edge only + mesh_graph.add_edge('01', '7e') + for _ in range(4): + mesh_graph.add_edge('01', '7e') + + score_forward = mesh_graph.get_candidate_score('7e', '01', None, min_observations=1, use_bidirectional=False) + + # Create reverse edge (bidirectional) + mesh_graph.add_edge('7e', '01') + for _ in range(4): + mesh_graph.add_edge('7e', '01') + + score_bidir = mesh_graph.get_candidate_score('7e', '01', None, min_observations=1, use_bidirectional=True) + + assert score_bidir > score_forward # Bidirectional should score higher + + def test_get_candidate_score_hop_position_match(self, mesh_graph): + """Test hop position validation bonus.""" + # Create edge with avg_hop_position = 1.0 + mesh_graph.add_edge('01', '7e', hop_position=1) + mesh_graph.add_edge('01', '7e', hop_position=1) # Average stays at 1.0 + + # Score with matching hop position + score_match = mesh_graph.get_candidate_score('7e', '01', None, min_observations=1, hop_position=1, use_hop_position=True) + + # Score with non-matching hop position + score_no_match = mesh_graph.get_candidate_score('7e', '01', None, min_observations=1, hop_position=5, use_hop_position=True) + + assert score_match > score_no_match # Matching position should have bonus + + def test_get_candidate_score_geographic_bonus(self, mesh_graph): + """Test geographic distance data bonus.""" + # Create edge without geographic distance + mesh_graph.add_edge('01', '7e') + score_no_geo = mesh_graph.get_candidate_score('7e', '01', None, min_observations=1) + + # Update edge with geographic distance + mesh_graph.add_edge('01', '7e', geographic_distance=10.5) + score_with_geo = mesh_graph.get_candidate_score('7e', '01', None, min_observations=1) + + assert score_with_geo > score_no_geo # Geographic data adds +0.05 bonus + + def test_get_candidate_score_combined_features(self, mesh_graph): + """Test scoring with all features enabled.""" + # Create bidirectional edge with hop position and geographic distance + mesh_graph.add_edge('01', '7e', hop_position=1, geographic_distance=10.5) + mesh_graph.add_edge('7e', '01', hop_position=0, geographic_distance=10.5) + + score = mesh_graph.get_candidate_score( + '7e', '01', '86', min_observations=1, + use_bidirectional=True, use_hop_position=True, hop_position=1 + ) + + assert 0.0 <= score <= 1.0 + # Should be high with all bonuses + + +@pytest.mark.unit +class TestMeshGraphMultiHop: + """Test multi-hop path inference.""" + + def test_find_intermediate_nodes_2hop(self, mesh_graph): + """Test finding intermediate node in 2-hop path.""" + # Create 2-hop path: 01 -> 7e -> 86 (no direct 01 -> 86) + mesh_graph.add_edge('01', '7e') + for _ in range(4): + mesh_graph.add_edge('01', '7e') + + mesh_graph.add_edge('7e', '86') + for _ in range(4): + mesh_graph.add_edge('7e', '86') + + candidates = mesh_graph.find_intermediate_nodes('01', '86', min_observations=1, max_hops=2) + + assert len(candidates) > 0 + # Should find '7e' as intermediate + intermediate_prefixes = [c[0] for c in candidates] + assert '7e' in intermediate_prefixes + + def test_find_intermediate_nodes_direct_edge(self, mesh_graph): + """Test when direct edge exists, intermediate search still works.""" + # Create direct edge + mesh_graph.add_edge('01', '86') + for _ in range(4): + mesh_graph.add_edge('01', '86') + + # Also create 2-hop path + mesh_graph.add_edge('01', '7e') + mesh_graph.add_edge('7e', '86') + + candidates = mesh_graph.find_intermediate_nodes('01', '86', min_observations=1, max_hops=2) + + # Should still find intermediate nodes even though direct edge exists + # (the function skips direct edges but continues searching) + intermediate_prefixes = [c[0] for c in candidates] + # May or may not include '7e' depending on implementation, but should not error + assert isinstance(candidates, list) + + def test_find_intermediate_nodes_3hop(self, mesh_graph): + """Test 3-hop path inference.""" + # Create 3-hop path: 01 -> 7e -> 86 -> e0 + mesh_graph.add_edge('01', '7e') + mesh_graph.add_edge('7e', '86') + mesh_graph.add_edge('86', 'e0') + + candidates = mesh_graph.find_intermediate_nodes('01', 'e0', min_observations=1, max_hops=3) + + # Should find intermediate nodes in 3-hop path + intermediate_prefixes = [c[0] for c in candidates] + # Should find either '7e' or '86' or both + assert len(candidates) > 0 + + def test_find_intermediate_nodes_no_path(self, mesh_graph): + """Test when no path exists returns empty list.""" + # Create isolated edges that don't connect + mesh_graph.add_edge('01', '7e') + mesh_graph.add_edge('99', 'aa') + + candidates = mesh_graph.find_intermediate_nodes('01', 'aa', min_observations=1, max_hops=2) + + assert candidates == [] + + def test_find_intermediate_nodes_min_observations(self, mesh_graph): + """Test filtering by min_observations.""" + # Create path with one edge below threshold + mesh_graph.add_edge('01', '7e') # Only 1 observation + mesh_graph.add_edge('7e', '86') + for _ in range(4): + mesh_graph.add_edge('7e', '86') # 5 observations + + # With min_observations=3, should not find path through 7e + candidates = mesh_graph.find_intermediate_nodes('01', '86', min_observations=3, max_hops=2) + + assert len(candidates) == 0 # 01->7e has only 1 observation, below threshold + + +@pytest.mark.unit +class TestMeshGraphPersistence: + """Test edge persistence and write strategies.""" + + def test_write_strategy_immediate(self, mock_bot, test_db): + """Test immediate write strategy.""" + mock_bot.config.set('Path_Command', 'graph_write_strategy', 'immediate') + graph = MeshGraph(mock_bot) + + graph.add_edge('01', '7e') + + # Check database directly + results = test_db.execute_query('SELECT * FROM mesh_connections WHERE from_prefix = ? AND to_prefix = ?', ('01', '7e')) + assert len(results) == 1 + assert results[0]['observation_count'] == 1 + + def test_write_strategy_batched(self, mock_bot, test_db): + """Test batched write strategy.""" + mock_bot.config.set('Path_Command', 'graph_write_strategy', 'batched') + mock_bot.config.set('Path_Command', 'graph_batch_max_pending', '5') + graph = MeshGraph(mock_bot) + + # Add edges (should be batched) + for i in range(3): + graph.add_edge(f'0{i}', '7e') + + # Edges should be in pending_updates, not yet in DB (unless max_pending reached) + # With 3 edges and max_pending=5, they should still be pending + assert len(graph.pending_updates) >= 0 # May have been flushed if max_pending reached + + # Force flush + graph._flush_pending_updates_sync() + + # Now should be in database + results = test_db.execute_query('SELECT * FROM mesh_connections') + assert len(results) == 3 + + def test_write_strategy_hybrid(self, mock_bot, test_db): + """Test hybrid strategy (immediate for new, batched for updates).""" + mock_bot.config.set('Path_Command', 'graph_write_strategy', 'hybrid') + graph = MeshGraph(mock_bot) + + # New edge should be written immediately + graph.add_edge('01', '7e') + results = test_db.execute_query('SELECT * FROM mesh_connections WHERE from_prefix = ? AND to_prefix = ?', ('01', '7e')) + assert len(results) == 1 + + # Update should be batched + graph.add_edge('01', '7e') + assert ('01', '7e') in graph.pending_updates + + def test_load_from_database(self, mock_bot, test_db): + """Test loading edges on initialization.""" + # Pre-populate database with edges + test_db.execute_update(''' + INSERT INTO mesh_connections + (from_prefix, to_prefix, observation_count, first_seen, last_seen) + VALUES (?, ?, ?, ?, ?) + ''', ('01', '7e', 5, datetime.now().isoformat(), datetime.now().isoformat())) + + # Create new graph instance (should load from DB) + graph = MeshGraph(mock_bot) + + # Check that edge was loaded + edge = graph.get_edge('01', '7e') + assert edge is not None + assert edge['observation_count'] == 5 diff --git a/tests/unit/test_mesh_graph_edges.py b/tests/unit/test_mesh_graph_edges.py new file mode 100644 index 0000000..4f51e1c --- /dev/null +++ b/tests/unit/test_mesh_graph_edges.py @@ -0,0 +1,173 @@ +#!/usr/bin/env python3 +""" +Unit tests for MeshGraph edge management +""" + +import pytest +from datetime import datetime, timedelta +from tests.helpers import create_test_edge + + +@pytest.mark.unit +class TestMeshGraphEdges: + """Test MeshGraph edge management functionality.""" + + def test_add_new_edge(self, mesh_graph): + """Test adding a new edge to the graph.""" + mesh_graph.add_edge('01', '7e') + + edge = mesh_graph.get_edge('01', '7e') + assert edge is not None + assert edge['from_prefix'] == '01' + assert edge['to_prefix'] == '7e' + assert edge['observation_count'] == 1 + assert edge['first_seen'] is not None + assert edge['last_seen'] is not None + + def test_add_edge_with_public_keys(self, mesh_graph): + """Test adding edge with public keys.""" + from_key = '0101010101010101010101010101010101010101010101010101010101010101' + to_key = '7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e' + + mesh_graph.add_edge('01', '7e', from_public_key=from_key, to_public_key=to_key) + + edge = mesh_graph.get_edge('01', '7e') + assert edge['from_public_key'] == from_key + assert edge['to_public_key'] == to_key + + def test_add_edge_with_hop_position(self, mesh_graph): + """Test adding edge with hop position.""" + mesh_graph.add_edge('01', '7e', hop_position=2) + + edge = mesh_graph.get_edge('01', '7e') + assert edge['avg_hop_position'] == 2.0 + + def test_add_edge_with_geographic_distance(self, mesh_graph): + """Test adding edge with geographic distance.""" + mesh_graph.add_edge('01', '7e', geographic_distance=15.5) + + edge = mesh_graph.get_edge('01', '7e') + assert edge['geographic_distance'] == 15.5 + + def test_update_existing_edge(self, mesh_graph): + """Test updating an existing edge increments observation count.""" + mesh_graph.add_edge('01', '7e') + mesh_graph.add_edge('01', '7e') + + edge = mesh_graph.get_edge('01', '7e') + assert edge['observation_count'] == 2 + + def test_update_edge_hop_position_average(self, mesh_graph): + """Test that updating edge recalculates average hop position.""" + mesh_graph.add_edge('01', '7e', hop_position=1) + mesh_graph.add_edge('01', '7e', hop_position=3) + + edge = mesh_graph.get_edge('01', '7e') + # Average should be (1 + 3) / 2 = 2.0 + assert edge['avg_hop_position'] == 2.0 + + # Add another observation + mesh_graph.add_edge('01', '7e', hop_position=2) + edge = mesh_graph.get_edge('01', '7e') + # Average should be (2.0 * 2 + 2) / 3 = 2.0 + assert edge['avg_hop_position'] == 2.0 + + def test_update_edge_public_keys(self, mesh_graph): + """Test that updating edge can add missing public keys.""" + # Add edge without keys + mesh_graph.add_edge('01', '7e') + edge = mesh_graph.get_edge('01', '7e') + assert edge['from_public_key'] is None + assert edge['to_public_key'] is None + + # Update with keys + from_key = '0101010101010101010101010101010101010101010101010101010101010101' + to_key = '7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e7e' + mesh_graph.add_edge('01', '7e', from_public_key=from_key, to_public_key=to_key) + + edge = mesh_graph.get_edge('01', '7e') + assert edge['from_public_key'] == from_key + assert edge['to_public_key'] == to_key + + def test_update_edge_geographic_distance(self, mesh_graph): + """Test that updating edge can update geographic distance.""" + mesh_graph.add_edge('01', '7e', geographic_distance=10.0) + mesh_graph.add_edge('01', '7e', geographic_distance=15.5) + + edge = mesh_graph.get_edge('01', '7e') + assert edge['geographic_distance'] == 15.5 + + def test_get_edge_nonexistent(self, mesh_graph): + """Test getting a non-existent edge returns None.""" + edge = mesh_graph.get_edge('01', '99') + assert edge is None + + def test_has_edge(self, mesh_graph): + """Test has_edge method.""" + assert mesh_graph.has_edge('01', '7e') is False + + mesh_graph.add_edge('01', '7e') + assert mesh_graph.has_edge('01', '7e') is True + assert mesh_graph.has_edge('7e', '01') is False # Direction matters + + def test_prefix_normalization(self, mesh_graph): + """Test that prefixes are normalized to lowercase and truncated.""" + mesh_graph.add_edge('01AB', '7EFF') + + # Should normalize to '01' and '7e' + assert mesh_graph.has_edge('01', '7e') is True + assert mesh_graph.has_edge('01ab', '7eff') is True + assert mesh_graph.has_edge('01AB', '7EFF') is True + + def test_get_outgoing_edges(self, mesh_graph): + """Test getting all outgoing edges from a node.""" + mesh_graph.add_edge('01', '7e') + mesh_graph.add_edge('01', '86') + mesh_graph.add_edge('7e', '01') # Reverse direction + + outgoing = mesh_graph.get_outgoing_edges('01') + assert len(outgoing) == 2 + prefixes = {edge['to_prefix'] for edge in outgoing} + assert '7e' in prefixes + assert '86' in prefixes + + def test_get_incoming_edges(self, mesh_graph): + """Test getting all incoming edges to a node.""" + mesh_graph.add_edge('01', '7e') + mesh_graph.add_edge('86', '7e') + mesh_graph.add_edge('7e', '01') # Reverse direction + + incoming = mesh_graph.get_incoming_edges('7e') + assert len(incoming) == 2 + prefixes = {edge['from_prefix'] for edge in incoming} + assert '01' in prefixes + assert '86' in prefixes + + def test_empty_prefix_handling(self, mesh_graph): + """Test that empty prefixes are ignored.""" + initial_count = len(mesh_graph.edges) + + mesh_graph.add_edge('', '7e') + mesh_graph.add_edge('01', '') + mesh_graph.add_edge('', '') + + assert len(mesh_graph.edges) == initial_count + + def test_edge_last_seen_updates(self, mesh_graph): + """Test that last_seen timestamp updates on edge updates.""" + first_time = datetime.now() - timedelta(seconds=5) + + # Manually set first_seen to past time + mesh_graph.add_edge('01', '7e') + edge = mesh_graph.get_edge('01', '7e') + edge['first_seen'] = first_time + edge['last_seen'] = first_time + + # Wait a moment and update + import time + time.sleep(0.1) + mesh_graph.add_edge('01', '7e') + + edge = mesh_graph.get_edge('01', '7e') + assert edge['last_seen'] > first_time + assert edge['first_seen'] == first_time # First seen shouldn't change diff --git a/tests/unit/test_mesh_graph_multihop.py b/tests/unit/test_mesh_graph_multihop.py new file mode 100644 index 0000000..05187ab --- /dev/null +++ b/tests/unit/test_mesh_graph_multihop.py @@ -0,0 +1,158 @@ +#!/usr/bin/env python3 +""" +Unit tests for MeshGraph multi-hop inference +""" + +import pytest +from tests.helpers import create_test_edge + + +@pytest.mark.unit +class TestMeshGraphMultiHop: + """Test MeshGraph multi-hop inference functionality.""" + + def test_find_intermediate_nodes_direct_edge(self, mesh_graph): + """Test that direct edges are not returned as intermediate nodes.""" + mesh_graph.add_edge('01', '7e') + + candidates = mesh_graph.find_intermediate_nodes('01', '7e') + # Direct edge exists, so no intermediate nodes should be returned + assert len(candidates) == 0 + + def test_find_intermediate_nodes_2hop_path(self, mesh_graph): + """Test finding intermediate nodes in a 2-hop path.""" + mesh_graph.add_edge('01', '7e') + mesh_graph.add_edge('7e', '86') + + candidates = mesh_graph.find_intermediate_nodes('01', '86') + assert len(candidates) > 0 + assert candidates[0][0] == '7e' # Intermediate node + assert candidates[0][1] > 0.0 # Has score + + def test_find_intermediate_nodes_3hop_path(self, mesh_graph): + """Test finding intermediate nodes in a 3-hop path.""" + mesh_graph.add_edge('01', '7e') + mesh_graph.add_edge('7e', '86') + mesh_graph.add_edge('86', 'e0') + + candidates = mesh_graph.find_intermediate_nodes('01', 'e0', max_hops=3) + assert len(candidates) > 0 + # Should return '86' (the node before destination in 3-hop path) + assert candidates[0][0] == '86' + assert candidates[0][1] > 0.0 + + def test_find_intermediate_nodes_no_path(self, mesh_graph): + """Test that no candidates are returned when no path exists.""" + mesh_graph.add_edge('01', '7e') + mesh_graph.add_edge('86', 'e0') # Disconnected components + + candidates = mesh_graph.find_intermediate_nodes('01', 'e0') + assert len(candidates) == 0 + + def test_find_intermediate_nodes_min_observations(self, mesh_graph): + """Test that min_observations filter is applied.""" + mesh_graph.add_edge('01', '7e') # Only 1 observation + mesh_graph.add_edge('7e', '86') + mesh_graph.add_edge('7e', '86') # Ensure 7e->86 has 2 observations + + # With min_observations=3, should find no candidates (both edges need 3+) + candidates = mesh_graph.find_intermediate_nodes('01', '86', min_observations=3) + assert len(candidates) == 0 + + # Add more observations to both edges + mesh_graph.add_edge('01', '7e') + mesh_graph.add_edge('01', '7e') # Now 01->7e has 3 observations + mesh_graph.add_edge('7e', '86') # Now 7e->86 has 3 observations + + candidates = mesh_graph.find_intermediate_nodes('01', '86', min_observations=3) + assert len(candidates) > 0 + + def test_find_intermediate_nodes_bidirectional_bonus(self, mesh_graph): + """Test that bidirectional paths get higher scores.""" + # Unidirectional path + mesh_graph.add_edge('01', '7e') + mesh_graph.add_edge('7e', '86') + candidates_uni = mesh_graph.find_intermediate_nodes('01', '86') + + # Add reverse edges (bidirectional) + mesh_graph.add_edge('7e', '01') + mesh_graph.add_edge('86', '7e') + candidates_bi = mesh_graph.find_intermediate_nodes('01', '86') + + assert len(candidates_bi) > 0 + assert candidates_bi[0][1] > candidates_uni[0][1] + + def test_find_intermediate_nodes_multiple_candidates(self, mesh_graph): + """Test finding multiple intermediate node candidates.""" + # Path 1: 01 -> 7e -> 86 + mesh_graph.add_edge('01', '7e') + mesh_graph.add_edge('7e', '86') + + # Path 2: 01 -> 7a -> 86 + mesh_graph.add_edge('01', '7a') + mesh_graph.add_edge('7a', '86') + + candidates = mesh_graph.find_intermediate_nodes('01', '86') + assert len(candidates) >= 2 + + # Should be sorted by score (highest first) + scores = [c[1] for c in candidates] + assert scores == sorted(scores, reverse=True) + + def test_find_intermediate_nodes_3hop_score_reduction(self, mesh_graph): + """Test that 3-hop paths have reduced scores.""" + # 2-hop path + mesh_graph.add_edge('01', '7e') + mesh_graph.add_edge('7e', '86') + candidates_2hop = mesh_graph.find_intermediate_nodes('01', '86', max_hops=2) + + # 3-hop path + mesh_graph.add_edge('01', '7e') + mesh_graph.add_edge('7e', '86') + mesh_graph.add_edge('86', 'e0') + candidates_3hop = mesh_graph.find_intermediate_nodes('01', 'e0', max_hops=3) + + # 3-hop should have lower score due to 0.8 multiplier + if len(candidates_2hop) > 0 and len(candidates_3hop) > 0: + # Both paths exist, 3-hop should be lower + assert candidates_3hop[0][1] < candidates_2hop[0][1] + + def test_find_intermediate_nodes_max_hops_limit(self, mesh_graph): + """Test that max_hops parameter limits search depth.""" + mesh_graph.add_edge('01', '7e') + mesh_graph.add_edge('7e', '86') + mesh_graph.add_edge('86', 'e0') + + # With max_hops=2, should not find 3-hop path + candidates = mesh_graph.find_intermediate_nodes('01', 'e0', max_hops=2) + assert len(candidates) == 0 + + # With max_hops=3, should find it + candidates = mesh_graph.find_intermediate_nodes('01', 'e0', max_hops=3) + assert len(candidates) > 0 + + def test_find_intermediate_nodes_weakest_link_scoring(self, mesh_graph): + """Test that path score uses weakest link (minimum confidence).""" + # Create path where one edge has low confidence + mesh_graph.add_edge('01', '7e') # 1 observation + mesh_graph.add_edge('7e', '86') + mesh_graph.add_edge('7e', '86') + mesh_graph.add_edge('7e', '86') # 3 observations + + candidates = mesh_graph.find_intermediate_nodes('01', '86') + assert len(candidates) > 0 + + # Score should be limited by the weaker edge (01->7e) + score = candidates[0][1] + assert score < 1.0 # Should be less than perfect due to weak link + + def test_find_intermediate_nodes_self_loop_prevention(self, mesh_graph): + """Test that paths don't loop back to source.""" + mesh_graph.add_edge('01', '7e') + mesh_graph.add_edge('7e', '01') # Loop back + mesh_graph.add_edge('7e', '86') + + candidates = mesh_graph.find_intermediate_nodes('01', '86') + # Should still find valid path through 7e + assert len(candidates) > 0 + assert candidates[0][0] == '7e' diff --git a/tests/unit/test_mesh_graph_scoring.py b/tests/unit/test_mesh_graph_scoring.py new file mode 100644 index 0000000..a3899a5 --- /dev/null +++ b/tests/unit/test_mesh_graph_scoring.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +""" +Unit tests for MeshGraph candidate scoring +""" + +import pytest +from tests.helpers import create_test_edge + + +@pytest.mark.unit +class TestMeshGraphScoring: + """Test MeshGraph candidate scoring functionality.""" + + def test_get_candidate_score_no_edges(self, mesh_graph): + """Test scoring candidate with no graph edges.""" + score = mesh_graph.get_candidate_score('01', None, None) + assert score == 0.0 + + def test_get_candidate_score_prev_edge_only(self, mesh_graph): + """Test scoring candidate with only previous edge.""" + mesh_graph.add_edge('7e', '01') + + score = mesh_graph.get_candidate_score('01', '7e', None) + assert score > 0.0 + assert score <= 1.0 + + def test_get_candidate_score_next_edge_only(self, mesh_graph): + """Test scoring candidate with only next edge.""" + mesh_graph.add_edge('01', '86') + + score = mesh_graph.get_candidate_score('01', None, '86') + assert score > 0.0 + assert score <= 1.0 + + def test_get_candidate_score_both_edges(self, mesh_graph): + """Test scoring candidate with both previous and next edges.""" + mesh_graph.add_edge('7e', '01') + mesh_graph.add_edge('01', '86') + + score = mesh_graph.get_candidate_score('01', '7e', '86') + assert score > 0.0 + assert score <= 1.0 + + def test_get_candidate_score_bidirectional_bonus(self, mesh_graph): + """Test that bidirectional edges increase score.""" + # Unidirectional + mesh_graph.add_edge('7e', '01') + score_uni = mesh_graph.get_candidate_score('01', '7e', None, use_bidirectional=True) + + # Add reverse edge + mesh_graph.add_edge('01', '7e') + score_bi = mesh_graph.get_candidate_score('01', '7e', None, use_bidirectional=True) + + assert score_bi > score_uni + + def test_get_candidate_score_hop_position_match(self, mesh_graph): + """Test hop position validation bonus.""" + # Add edge with avg_hop_position = 2.0 + mesh_graph.add_edge('7e', '01', hop_position=2) + mesh_graph.add_edge('7e', '01', hop_position=2) # Average stays at 2.0 + + # Score at position 2 (should match) + score_match = mesh_graph.get_candidate_score('01', '7e', None, hop_position=2, use_hop_position=True) + + # Score at position 5 (shouldn't match) + score_mismatch = mesh_graph.get_candidate_score('01', '7e', None, hop_position=5, use_hop_position=True) + + assert score_match > score_mismatch + + def test_get_candidate_score_hop_position_tolerance(self, mesh_graph): + """Test that hop position allows small tolerance.""" + mesh_graph.add_edge('7e', '01', hop_position=2) + mesh_graph.add_edge('7e', '01', hop_position=2) + + # Position 2.3 should still match (within 0.5 tolerance) + score = mesh_graph.get_candidate_score('01', '7e', None, hop_position=2, use_hop_position=True) + assert score > 0.0 + + def test_get_candidate_score_min_observations(self, mesh_graph): + """Test that scoring respects min_observations.""" + mesh_graph.add_edge('7e', '01') # Only 1 observation + + score = mesh_graph.get_candidate_score('01', '7e', None, min_observations=3) + assert score == 0.0 + + # Add more observations + mesh_graph.add_edge('7e', '01') + mesh_graph.add_edge('7e', '01') + + score = mesh_graph.get_candidate_score('01', '7e', None, min_observations=3) + assert score > 0.0 + + def test_get_candidate_score_hop_position_disabled(self, mesh_graph): + """Test that hop position validation can be disabled.""" + mesh_graph.add_edge('7e', '01', hop_position=2) + mesh_graph.add_edge('7e', '01', hop_position=2) + + # With hop position disabled, should still score + score = mesh_graph.get_candidate_score('01', '7e', None, hop_position=5, use_hop_position=False) + assert score > 0.0 + + def test_get_candidate_score_bidirectional_disabled(self, mesh_graph): + """Test that bidirectional check can be disabled.""" + mesh_graph.add_edge('7e', '01') + mesh_graph.add_edge('01', '7e') # Reverse edge + + score_bi_enabled = mesh_graph.get_candidate_score('01', '7e', None, use_bidirectional=True) + score_bi_disabled = mesh_graph.get_candidate_score('01', '7e', None, use_bidirectional=False) + + assert score_bi_enabled > score_bi_disabled + + def test_get_candidate_score_average_of_edges(self, mesh_graph): + """Test that score averages confidence from multiple edges.""" + # Create edges with different observation counts + mesh_graph.add_edge('7e', '01') # 1 observation + mesh_graph.add_edge('01', '86') + mesh_graph.add_edge('01', '86') + mesh_graph.add_edge('01', '86') # 3 observations + + # Score should be average of both edges + score = mesh_graph.get_candidate_score('01', '7e', '86') + assert 0.0 < score < 1.0 diff --git a/tests/unit/test_mesh_graph_validation.py b/tests/unit/test_mesh_graph_validation.py new file mode 100644 index 0000000..4cd8719 --- /dev/null +++ b/tests/unit/test_mesh_graph_validation.py @@ -0,0 +1,154 @@ +#!/usr/bin/env python3 +""" +Unit tests for MeshGraph path validation +""" + +import pytest +from datetime import datetime, timedelta +from tests.helpers import create_test_edge, create_test_path + + +@pytest.mark.unit +class TestMeshGraphValidation: + """Test MeshGraph path validation functionality.""" + + def test_validate_path_segment_exists(self, mesh_graph): + """Test validating an existing path segment.""" + mesh_graph.add_edge('01', '7e', hop_position=1) + + is_valid, confidence = mesh_graph.validate_path_segment('01', '7e') + assert is_valid is True + assert 0.0 <= confidence <= 1.0 + + def test_validate_path_segment_nonexistent(self, mesh_graph): + """Test validating a non-existent path segment.""" + is_valid, confidence = mesh_graph.validate_path_segment('01', '99') + assert is_valid is False + assert confidence == 0.0 + + def test_validate_path_segment_min_observations(self, mesh_graph): + """Test that validation respects min_observations threshold.""" + mesh_graph.add_edge('01', '7e') + # Edge has observation_count=1 + + is_valid, confidence = mesh_graph.validate_path_segment('01', '7e', min_observations=3) + assert is_valid is False + assert confidence == 0.0 + + # Add more observations + mesh_graph.add_edge('01', '7e') + mesh_graph.add_edge('01', '7e') + mesh_graph.add_edge('01', '7e') + + is_valid, confidence = mesh_graph.validate_path_segment('01', '7e', min_observations=3) + assert is_valid is True + + def test_validate_path_segment_recency(self, mesh_graph): + """Test that validation considers recency of edge.""" + # Add edge with recent timestamp + mesh_graph.add_edge('01', '7e') + recent_is_valid, recent_confidence = mesh_graph.validate_path_segment('01', '7e') + + # Manually set old timestamp + edge = mesh_graph.get_edge('01', '7e') + edge['last_seen'] = datetime.now() - timedelta(days=30) + + stale_is_valid, stale_confidence = mesh_graph.validate_path_segment('01', '7e') + + # Recent edge should have higher confidence + assert recent_confidence > stale_confidence + + def test_validate_path_segment_bidirectional(self, mesh_graph): + """Test bidirectional edge bonus.""" + # Add unidirectional edge + mesh_graph.add_edge('01', '7e') + is_valid, unidirectional_confidence = mesh_graph.validate_path_segment( + '01', '7e', check_bidirectional=True + ) + + # Add reverse edge + mesh_graph.add_edge('7e', '01') + is_valid, bidirectional_confidence = mesh_graph.validate_path_segment( + '01', '7e', check_bidirectional=True + ) + + # Bidirectional should have higher confidence + assert bidirectional_confidence > unidirectional_confidence + + def test_validate_path_segment_bidirectional_min_observations(self, mesh_graph): + """Test that bidirectional check respects min_observations.""" + # Add forward edge with enough observations + mesh_graph.add_edge('01', '7e', hop_position=1) + mesh_graph.add_edge('01', '7e', hop_position=1) + mesh_graph.add_edge('01', '7e', hop_position=1) # 3 observations + + mesh_graph.add_edge('7e', '01') # Reverse edge with only 1 observation + + # With min_observations=3, forward edge should be valid + is_valid, confidence = mesh_graph.validate_path_segment( + '01', '7e', min_observations=3, check_bidirectional=True + ) + # Should still be valid from forward edge, but no bidirectional bonus + assert is_valid is True + + # Add more observations to reverse edge + mesh_graph.add_edge('7e', '01') + mesh_graph.add_edge('7e', '01') + + is_valid, confidence_with_bonus = mesh_graph.validate_path_segment( + '01', '7e', min_observations=3, check_bidirectional=True + ) + assert confidence_with_bonus > confidence + + def test_validate_path_single_node(self, mesh_graph): + """Test that single-node path is always valid.""" + is_valid, confidence = mesh_graph.validate_path(['01']) + assert is_valid is True + assert confidence == 1.0 + + def test_validate_path_empty(self, mesh_graph): + """Test that empty path is always valid.""" + is_valid, confidence = mesh_graph.validate_path([]) + assert is_valid is True + assert confidence == 1.0 + + def test_validate_path_valid(self, populated_mesh_graph): + """Test validating a valid path.""" + path = create_test_path(['01', '7e', '86']) + is_valid, confidence = populated_mesh_graph.validate_path(path) + assert is_valid is True + assert confidence > 0.0 + + def test_validate_path_invalid_missing_edge(self, populated_mesh_graph): + """Test validating a path with missing edge.""" + path = create_test_path(['01', '99', '86']) # 01->99 doesn't exist + is_valid, confidence = populated_mesh_graph.validate_path(path) + assert is_valid is False + assert confidence == 0.0 + + def test_validate_path_invalid_insufficient_observations(self, mesh_graph): + """Test validating a path with edge below min_observations.""" + mesh_graph.add_edge('01', '7e') # Only 1 observation + + path = create_test_path(['01', '7e']) + is_valid, confidence = mesh_graph.validate_path(path, min_observations=3) + assert is_valid is False + assert confidence == 0.0 + + def test_validate_path_average_confidence(self, mesh_graph): + """Test that path validation returns average confidence.""" + # Create path with edges of different observation counts + mesh_graph.add_edge('01', '7e') + mesh_graph.add_edge('01', '7e') # 2 observations + mesh_graph.add_edge('7e', '86') + mesh_graph.add_edge('7e', '86') + mesh_graph.add_edge('7e', '86') + mesh_graph.add_edge('7e', '86') + mesh_graph.add_edge('7e', '86') # 5 observations + + path = create_test_path(['01', '7e', '86']) + is_valid, confidence = mesh_graph.validate_path(path) + + assert is_valid is True + # Confidence should be average of both segments + assert 0.0 < confidence < 1.0 diff --git a/tests/unit/test_path_command_graph.py b/tests/unit/test_path_command_graph.py new file mode 100644 index 0000000..2c58d04 --- /dev/null +++ b/tests/unit/test_path_command_graph.py @@ -0,0 +1,258 @@ +#!/usr/bin/env python3 +""" +Unit tests for PathCommand graph-based selection logic +""" + +import pytest +from unittest.mock import Mock, patch +from datetime import datetime +from modules.commands.path_command import PathCommand +from tests.helpers import create_test_repeater, create_test_edge, populate_test_graph + + +@pytest.mark.unit +class TestPathCommandGraphSelection: + """Test PathCommand._select_repeater_by_graph method.""" + + def test_select_repeater_by_graph_no_graph(self, mock_bot): + """Test when graph_based_validation is False.""" + # Disable graph validation + mock_bot.config.set('Path_Command', 'graph_based_validation', 'false') + path_cmd = PathCommand(mock_bot) + + repeaters = [create_test_repeater('01', 'Test Repeater')] + result = path_cmd._select_repeater_by_graph(repeaters, '01', ['01']) + + assert result == (None, 0.0, None) + + def test_select_repeater_by_graph_no_mesh_graph(self, mock_bot): + """Test when mesh_graph is None.""" + mock_bot.mesh_graph = None + path_cmd = PathCommand(mock_bot) + + repeaters = [create_test_repeater('01', 'Test Repeater')] + result = path_cmd._select_repeater_by_graph(repeaters, '01', ['01']) + + assert result == (None, 0.0, None) + + def test_select_repeater_by_graph_no_context(self, mock_bot, mesh_graph): + """Test when node_id not in path_context.""" + mock_bot.mesh_graph = mesh_graph + path_cmd = PathCommand(mock_bot) + + repeaters = [create_test_repeater('99', 'Test Repeater')] + result = path_cmd._select_repeater_by_graph(repeaters, '99', ['01', '7e', '86']) + + assert result == (None, 0.0, None) + + def test_select_repeater_by_graph_direct_edge(self, mock_bot, mesh_graph): + """Test selection with strong direct edge.""" + mock_bot.mesh_graph = mesh_graph + + # Create strong edge: 01 -> 7e + mesh_graph.add_edge('01', '7e') + for _ in range(10): + mesh_graph.add_edge('01', '7e') # 11 total observations + + path_cmd = PathCommand(mock_bot) + + # Create repeaters with matching prefix + repeaters = [ + create_test_repeater('7e', 'Test Repeater 7e', public_key='7e' * 32) + ] + + result = path_cmd._select_repeater_by_graph(repeaters, '7e', ['01', '7e', '86']) + + assert result[0] is not None # Should select a repeater + assert result[1] > 0.7 # High confidence + assert result[2] == 'graph' # Direct edge method + + def test_select_repeater_by_graph_stored_public_key_bonus(self, mock_bot, mesh_graph): + """Test stored public key bonus.""" + mock_bot.mesh_graph = mesh_graph + + # Create edge with stored public key + public_key = '7e' * 32 # 64 hex chars + mesh_graph.add_edge('01', '7e', from_public_key='01' * 32, to_public_key=public_key) + for _ in range(5): + mesh_graph.add_edge('01', '7e') + + path_cmd = PathCommand(mock_bot) + + # Create repeater with matching public key + repeaters = [ + create_test_repeater('7e', 'Test Repeater', public_key=public_key), + create_test_repeater('7e', 'Other Repeater', public_key='aa' * 32) # Different key + ] + + result = path_cmd._select_repeater_by_graph(repeaters, '7e', ['01', '7e', '86']) + + assert result[0] is not None + # Should select the repeater with matching public key + assert result[0]['public_key'] == public_key + assert result[1] > 0.5 # Should have good confidence with stored key bonus + + def test_select_repeater_by_graph_star_bias(self, mock_bot, mesh_graph): + """Test star bias multiplier application.""" + mock_bot.mesh_graph = mesh_graph + + # Create edges for both repeaters + mesh_graph.add_edge('01', '7e') + mesh_graph.add_edge('01', '7a') + + path_cmd = PathCommand(mock_bot) + + # Create one starred and one non-starred repeater + repeaters = [ + create_test_repeater('7e', 'Starred Repeater', is_starred=True), + create_test_repeater('7a', 'Regular Repeater', is_starred=False) + ] + + result = path_cmd._select_repeater_by_graph(repeaters, '7e', ['01', '7e']) + + assert result[0] is not None + # Starred repeater should be selected even if both have similar graph scores + assert result[0]['is_starred'] is True + + def test_select_repeater_by_graph_multi_hop(self, mock_bot, mesh_graph): + """Test multi-hop inference when direct edge has low confidence.""" + mock_bot.mesh_graph = mesh_graph + + # Create 2-hop path: 01 -> 7e -> 86 (no direct 01 -> 86) + # Need at least 3 observations per edge for min_edge_observations + for _ in range(3): + mesh_graph.add_edge('01', '7e') + mesh_graph.add_edge('7e', '86') + + path_cmd = PathCommand(mock_bot) + + # Create repeater that's the intermediate node + repeaters = [ + create_test_repeater('7e', 'Intermediate Repeater', public_key='7e' * 32) + ] + + # Try to select 7e when path is 01 -> 7e -> 86 + result = path_cmd._select_repeater_by_graph(repeaters, '7e', ['01', '7e', '86']) + + assert result[0] is not None + # Should use graph method (direct edge exists) + assert result[2] in ('graph', 'graph_multihop') + + def test_select_repeater_by_graph_hop_position(self, mock_bot, mesh_graph): + """Test hop position validation.""" + mock_bot.mesh_graph = mesh_graph + + # Create edge with avg_hop_position = 1.0 + # Need at least 3 observations for min_edge_observations + for _ in range(3): + mesh_graph.add_edge('01', '7e', hop_position=1) + + path_cmd = PathCommand(mock_bot) + path_cmd.graph_use_hop_position = True + + repeaters = [create_test_repeater('7e', 'Test Repeater')] + + # Path where 7e is at position 1 + result = path_cmd._select_repeater_by_graph(repeaters, '7e', ['01', '7e', '86']) + + assert result[0] is not None + assert result[1] > 0.0 + + def test_select_repeater_by_graph_multiple_candidates(self, mock_bot, mesh_graph): + """Test selection from multiple candidates.""" + mock_bot.mesh_graph = mesh_graph + + # Create edges with different strengths + mesh_graph.add_edge('01', '7e') + for _ in range(10): + mesh_graph.add_edge('01', '7e') # Strong edge + + mesh_graph.add_edge('01', '7a') + for _ in range(2): + mesh_graph.add_edge('01', '7a') # Weaker edge + + path_cmd = PathCommand(mock_bot) + + repeaters = [ + create_test_repeater('7e', 'Strong Edge Repeater'), + create_test_repeater('7a', 'Weak Edge Repeater') + ] + + result = path_cmd._select_repeater_by_graph(repeaters, '7e', ['01', '7e']) + + assert result[0] is not None + # Should select the one with stronger edge (7e) + assert result[0]['name'] == 'Strong Edge Repeater' + assert result[1] > 0.5 + + def test_select_repeater_by_graph_confidence_conversion(self, mock_bot, mesh_graph): + """Test graph score to confidence conversion.""" + mock_bot.mesh_graph = mesh_graph + + # Create very strong edge + mesh_graph.add_edge('01', '7e') + for _ in range(20): + mesh_graph.add_edge('01', '7e') + + path_cmd = PathCommand(mock_bot) + + repeaters = [create_test_repeater('7e', 'Test Repeater')] + result = path_cmd._select_repeater_by_graph(repeaters, '7e', ['01', '7e']) + + assert result[0] is not None + # Confidence should be capped at 1.0 + assert 0.0 <= result[1] <= 1.0 + + def test_select_repeater_by_graph_star_bias_exceeds_one(self, mock_bot, mesh_graph): + """Test star bias can exceed 1.0 but confidence is normalized.""" + mock_bot.mesh_graph = mesh_graph + + mesh_graph.add_edge('01', '7e') + for _ in range(5): + mesh_graph.add_edge('01', '7e') + + path_cmd = PathCommand(mock_bot) + path_cmd.star_bias_multiplier = 2.5 # High multiplier + + # Create starred repeater + repeaters = [create_test_repeater('7e', 'Starred Repeater', is_starred=True)] + + result = path_cmd._select_repeater_by_graph(repeaters, '7e', ['01', '7e']) + + assert result[0] is not None + # Confidence should still be capped appropriately + assert 0.0 <= result[1] <= 1.0 + + def test_select_repeater_by_graph_prefix_extraction(self, mock_bot, mesh_graph): + """Test prefix extraction from public_key.""" + mock_bot.mesh_graph = mesh_graph + + # Create edge + mesh_graph.add_edge('01', '7e') + + path_cmd = PathCommand(mock_bot) + + # Repeater with public key starting with '7e' + public_key = '7e' + '00' * 31 # 7e prefix + repeaters = [create_test_repeater('7e', 'Test Repeater', public_key=public_key)] + + result = path_cmd._select_repeater_by_graph(repeaters, '7e', ['01', '7e']) + + assert result[0] is not None + + def test_select_repeater_by_graph_missing_public_key(self, mock_bot, mesh_graph): + """Test handling when public_key is missing.""" + mock_bot.mesh_graph = mesh_graph + + mesh_graph.add_edge('01', '7e') + + path_cmd = PathCommand(mock_bot) + + # Repeater without public_key + repeater = create_test_repeater('7e', 'Test Repeater') + del repeater['public_key'] # Remove public key + + result = path_cmd._select_repeater_by_graph([repeater], '7e', ['01', '7e']) + + # Should skip this repeater (no prefix to match) + assert result == (None, 0.0, None) or result[0] is None diff --git a/tests/unit/test_path_command_graph_selection.py b/tests/unit/test_path_command_graph_selection.py new file mode 100644 index 0000000..f4c9709 --- /dev/null +++ b/tests/unit/test_path_command_graph_selection.py @@ -0,0 +1,155 @@ +#!/usr/bin/env python3 +""" +Unit tests for PathCommand graph-based selection +""" + +import pytest +from unittest.mock import Mock +from modules.commands.path_command import PathCommand +from tests.helpers import create_test_repeater + + +@pytest.mark.unit +class TestPathCommandGraphSelection: + """Test PathCommand._select_repeater_by_graph functionality.""" + + @pytest.fixture + def path_command(self, mock_bot, populated_mesh_graph): + """Create a PathCommand instance with graph enabled.""" + mock_bot.mesh_graph = populated_mesh_graph + command = PathCommand(mock_bot) + command.graph_based_validation = True + command.min_edge_observations = 1 + command.graph_use_bidirectional = True + command.graph_use_hop_position = True + command.graph_multi_hop_enabled = True + command.graph_multi_hop_max_hops = 2 + command.graph_prefer_stored_keys = True + command.star_bias_multiplier = 2.5 + return command + + def test_select_repeater_direct_edge(self, path_command, populated_mesh_graph): + """Test selecting repeater with direct graph edge.""" + repeaters = [ + create_test_repeater('7e', 'Repeater 7e', public_key='7e' * 32) + ] + path_context = ['01', '7e', '86'] + + repeater, confidence, method = path_command._select_repeater_by_graph( + repeaters, '7e', path_context + ) + + assert repeater is not None + assert confidence > 0.0 + assert method == 'graph' + + def test_select_repeater_stored_key_bonus(self, path_command, populated_mesh_graph): + """Test that stored public keys provide bonus.""" + # Add edge with stored public key + stored_key = '7e' * 32 + populated_mesh_graph.add_edge('01', '7e', to_public_key=stored_key) + + repeaters = [ + create_test_repeater('7e', 'Repeater 7e', public_key=stored_key), + create_test_repeater('7e', 'Other 7e', public_key='7f' * 32) + ] + path_context = ['01', '7e', '86'] + + repeater, confidence, method = path_command._select_repeater_by_graph( + repeaters, '7e', path_context + ) + + assert repeater is not None + assert repeater['name'] == 'Repeater 7e' # Should prefer stored key match + + def test_select_repeater_star_bias(self, path_command, populated_mesh_graph): + """Test that starred repeaters get score boost.""" + repeaters = [ + create_test_repeater('7e', 'Starred Repeater', is_starred=True), + create_test_repeater('7e', 'Regular Repeater', is_starred=False) + ] + path_context = ['01', '7e', '86'] + + repeater, confidence, method = path_command._select_repeater_by_graph( + repeaters, '7e', path_context + ) + + assert repeater is not None + assert repeater['is_starred'] is True + + def test_select_repeater_multihop(self, path_command, populated_mesh_graph): + """Test multi-hop inference when direct edge has low confidence.""" + # Create 2-hop path: 01 -> 7e -> 86 -> e0 + populated_mesh_graph.add_edge('01', '7e') + populated_mesh_graph.add_edge('7e', '86') + populated_mesh_graph.add_edge('86', 'e0') + + repeaters = [ + create_test_repeater('86', 'Intermediate Repeater') + ] + path_context = ['01', '86', 'e0'] + + repeater, confidence, method = path_command._select_repeater_by_graph( + repeaters, '86', path_context + ) + + assert repeater is not None + assert method == 'graph_multihop' or method == 'graph' + + def test_select_repeater_no_graph(self, path_command): + """Test that selection returns None when graph is disabled.""" + path_command.graph_based_validation = False + + repeaters = [create_test_repeater('7e', 'Repeater')] + path_context = ['01', '7e', '86'] + + repeater, confidence, method = path_command._select_repeater_by_graph( + repeaters, '7e', path_context + ) + + assert repeater is None + assert confidence == 0.0 + assert method is None + + def test_select_repeater_no_candidates(self, path_command, populated_mesh_graph): + """Test that selection returns None when no valid candidates.""" + repeaters = [ + create_test_repeater('99', 'Unknown Repeater') # No graph edges + ] + path_context = ['01', '99', '86'] + + repeater, confidence, method = path_command._select_repeater_by_graph( + repeaters, '99', path_context + ) + + assert repeater is None or confidence == 0.0 + + def test_select_repeater_confidence_conversion(self, path_command, populated_mesh_graph): + """Test that graph scores are converted to confidence properly.""" + repeaters = [ + create_test_repeater('7e', 'Repeater 7e') + ] + path_context = ['01', '7e', '86'] + + repeater, confidence, method = path_command._select_repeater_by_graph( + repeaters, '7e', path_context + ) + + assert 0.0 <= confidence <= 1.0 + + def test_select_repeater_hop_position(self, path_command, mesh_graph): + """Test that hop position is used when enabled.""" + path_command.graph_use_hop_position = True + + # Add edge with specific hop position (add multiple times to establish avg) + mesh_graph.add_edge('01', '7e', hop_position=1) + mesh_graph.add_edge('01', '7e', hop_position=1) # Keep avg at 1.0 + + repeaters = [create_test_repeater('7e', 'Repeater')] + path_context = ['01', '7e', '86'] # 7e is at position 1 + + repeater, confidence, method = path_command._select_repeater_by_graph( + repeaters, '7e', path_context + ) + + assert repeater is not None