diff --git a/BUGS.md b/BUGS.md new file mode 100644 index 0000000..d0c2c02 --- /dev/null +++ b/BUGS.md @@ -0,0 +1,70 @@ +# BUGS + +Tracking of known bugs, fixed issues, and outstanding defects in meshcore-bot. + +--- + +## Fixed Bugs + +### v0.8.3 and recent history + +| Commit | Summary | +|--------|---------| +| `1264f49` | Fixed repeater manager auto-purge ignoring `auto_manage_contacts` config — purge ran unconditionally regardless of setting (issue #50) | +| `1264f49` | Fixed web viewer responses returning stale or incorrect repeater data | +| `5c8ee35` | Fixed timezone handling in `format_elapsed_display` — elapsed times displayed incorrectly in non-UTC timezones (issue #75) | +| `1cc41bc` | Fixed repeater usage and web viewer response formatting (PR #67) | +| `1474174` | Fixed `TraceCommand` path truncation — return paths were being cut short | +| `1a576e8` | Fixed reversed path nodes in `TraceCommand` — trace direction was backwards | +| `5a96dec` | Fixed incorrect hop labeling logic in `TraceCommand` | +| `2178a80` | Fixed log spam during shutdown — cleanup methods logged errors after streams were closed | +| `e9f17ec` | Fixed incomplete shutdown — scheduler thread and meshcore disconnect were not always joined cleanly | +| `217d2a4` | Fixed database connection handling across modules — connections were not always properly closed | +| `d084c6b` | Fixed `PrefixCommand` not supporting multi-byte hex prefix lookups | +| `6c81513` | Fixed `MeshGraph` edge promotion logic — edges were not promoted correctly under some conditions | +| `36a8a67` | Fixed prefix handling incompatibility when transitioning from 1-byte to 2-byte prefixes | +| `0c060a5` | Fixed chunked message sending race with rate limiter — second chunk could be blocked | +| `58deb12` | Fixed `RepeaterManager` ignoring `auto_manage_contacts = false` | + +--- + +## Outstanding Known Issues + +### High Priority + +| ID | Module | Description | Workaround | +|----|--------|-------------|------------| +| BUG-001 | `web_viewer` | Web viewer has **no authentication** — exposes all contact data and packet history on the configured host/port | Set `host = 127.0.0.1` in `[Web_Viewer]` config; use firewall rules or SSH tunnel for remote access | +| BUG-002 | `db_manager` | Moving a database from an older install can cause `"no such column"` errors at startup if schema migrations haven't run yet | Start bot once to trigger migrations; if errors persist, clear stale `feed_message_queue` and `channel_operations` rows | +| BUG-003 | `repeater_manager` | Geocoding runs at most once per packet hash within a 60-second window, but concurrent adverts from the same node within that window skip geocoding silently | None; by design for rate limiting, but location may lag for rapid-fire adverts | + +### Medium Priority + +| ID | Module | Description | Workaround | +|----|--------|-------------|------------| +| BUG-004 | `message_handler` | RF data correlation (SNR/RSSI) can miss messages if the RF log event arrives more than `rf_data_timeout` (default 15s) after the message | Increase `rf_data_timeout` in `[Bot]` config | +| BUG-005 | `scheduler` | On Raspberry Pi Zero 2 W, bot + web viewer together use ~300 MB RAM, leaving little headroom under load | Disable web viewer (`[Web_Viewer] enabled = false`) or tune mesh graph settings (`graph_startup_load_days = 7`) | +| BUG-006 | `feed_manager` | Stale rows in `feed_message_queue` from an old install can cause repeated scheduler errors after a database migration | Clear pending queue: `DELETE FROM feed_message_queue WHERE sent_at IS NULL` | +| BUG-007 | `discord_bridge_service` | Discord webhook rate limit is 30 requests/minute; bot warns at 20% exhaustion but does not queue excess messages — they are dropped | Keep bridged channels low-traffic; consider rate-limiting at mesh level | +| BUG-008 | `telegram_bridge_service` | Telegram `message_thread_id` (forum/topic support) is not implemented — messages go to the main group channel only | Manual: add thread ID mapping in a future plugin iteration | + +### Low Priority / By Design + +| ID | Module | Description | Notes | +|----|--------|-------------|-------| +| BUG-009 | `discord_bridge_service` | DMs are never bridged to Discord or Telegram — hardcoded exclusion | By design; DMs contain private communications | +| BUG-010 | `wx_command` | Weather alerts and NOAA data are US-only | Use `wx_international.py` alternative in `modules/commands/alternatives/` for non-US deployments | +| BUG-011 | `repeater_manager` | MeshCore device hard-limits contacts to 300; auto-purge threshold is 280 — purging 20 contacts at a time may not be enough on very busy meshes | Tune `auto_purge_threshold` and ensure `auto_manage_contacts` is enabled | +| BUG-012 | `plugin_loader` | Local plugins with the same name as a built-in plugin are skipped — no override-by-name is possible | Rename your local plugin to a unique name | +| BUG-013 | `core.py` | Some older MeshCore firmware versions do not support `get_time` or `set_name` commands — bot logs a warning and continues without those features | Upgrade firmware; no functional impact on message processing | +| BUG-014 | `packet_capture_service` | Packet hash calculation silently uses a default hash value on failure (`pass # Use default hash if calculation fails`) | Low impact; affects deduplication accuracy only | + +--- + +## Reporting New Bugs + +Open an issue at the project repository. Include: +- Bot version (`git describe --tags`) +- Relevant section of `config.ini` (redact keys/tokens) +- Log output (`logs/meshcore_bot.log`) around the time of the issue +- Steps to reproduce diff --git a/TESTING.md b/TESTING.md new file mode 100644 index 0000000..2f7985d --- /dev/null +++ b/TESTING.md @@ -0,0 +1,550 @@ +# Testing + +Complete reference for the meshcore-bot test suite: how to run tests, what each file covers, and how to extend coverage. + +--- + +## Quick Start + +```bash +# Create and activate a virtual environment (first time only) +python3 -m venv .venv +source .venv/bin/activate + +# Install the package with test dependencies +pip install -e ".[test]" + +# Run the full test suite +pytest + +# Run without coverage (faster) +pytest --no-cov + +# Run a specific file +pytest tests/test_enums.py -v + +# Run a specific test class or function +pytest tests/test_message_handler.py::TestShouldProcessMessage -v +pytest tests/test_enums.py::TestPayloadType::test_lookup_by_value -v +``` + +--- + +## Configuration + +Test configuration lives in two files: + +**`pytest.ini`** — controls pytest behaviour: + +| Setting | Value | +|---------|-------| +| `testpaths` | `tests` | +| `asyncio_mode` | `auto` — all async tests run automatically, no `@pytest.mark.asyncio` required on each | +| `addopts` | `-v --tb=short --strict-markers --cov=modules --cov-report=term-missing` | +| Registered markers | `unit`, `integration`, `slow` | + +**`pyproject.toml`** — coverage settings (`[tool.coverage.*]`): + +| Setting | Value | +|---------|-------| +| `source` | `modules/` | +| `omit` | `tests/`, `.venv/` | +| `fail_under` | 70% | + +--- + +## Running Subsets of Tests + +```bash +# Unit tests only (fast, no real DB) +pytest -m unit + +# Integration tests only (real SQLite database via tmp_path) +pytest -m integration + +# Skip slow tests +pytest -m "not slow" + +# Tests in a subdirectory +pytest tests/unit/ +pytest tests/commands/ +pytest tests/integration/ +pytest tests/regression/ + +# Run and stop on first failure +pytest -x + +# Run with full traceback +pytest --tb=long + +# Show which tests were collected without running them +pytest --collect-only +``` + +--- + +## Coverage + +```bash +# Terminal report (default, configured in pytest.ini) +pytest + +# HTML report — open htmlcov/index.html in a browser +pytest --cov=modules --cov-report=html + +# Check that coverage meets the 70% threshold +pytest --cov=modules --cov-fail-under=70 + +# Coverage for a single module +pytest tests/test_message_handler.py --cov=modules.message_handler --cov-report=term-missing +``` + +--- + +## Linting (configured alongside tests) + +```bash +pip install ruff mypy # one-time, or use pip install -e ".[test]" + pip install ruff mypy + +# Check for style/lint issues +ruff check . + +# Auto-fix safe issues +ruff check . --fix + +# Type checking +mypy modules/ --ignore-missing-imports +``` + +--- + +## Shared Infrastructure + +### `tests/conftest.py` — Fixtures available to all tests + +| Fixture | Scope | Description | +|---------|-------|-------------| +| `mock_logger` | function | Mock with `.info`, `.debug`, `.warning`, `.error` methods | +| `minimal_config` | function | `ConfigParser` with Connection / Bot / Channels / Keywords sections | +| `command_mock_bot` | function | Lightweight mock bot (no DB, no mesh graph); includes translator mock that echoes keys | +| `command_mock_bot_with_db` | function | Same as above but with a mock `db_manager` | +| `test_config` | function | Full `[Path_Command]` + `[Bot]` config (graph settings, Seattle lat/lon) | +| `test_db` | function | File-based `DBManager` at `tmp_path` with `mesh_connections` + `complete_contact_tracking` tables | +| `mock_bot` | function | Mock bot with logger, config, `test_db`, `bot_root`, `prefix_hex_chars=2`, `key_prefix` lambda | +| `mesh_graph` | function | `MeshGraph` instance (immediate write, no background thread) | +| `populated_mesh_graph` | function | `MeshGraph` pre-populated with 7 test edges of varying observations and ages | + +### `tests/helpers.py` — Data factories + +| Function | Returns | +|----------|---------| +| `create_test_repeater(prefix, name, ...)` | Dict matching `complete_contact_tracking` schema | +| `create_test_edge(from_prefix, to_prefix, ...)` | Dict matching `MeshGraph` edge structure | +| `create_test_path(node_ids, prefix_hex_chars)` | Normalized list of node IDs | +| `populate_test_graph(mesh_graph, edges)` | Populates a `MeshGraph` with edge dicts and sets observation counts/timestamps | + +### `mock_message()` (helper in conftest) + +```python +from tests.conftest import mock_message + +msg = mock_message(content="ping", channel="general") +msg = mock_message(content="hello", is_dm=True, sender_id="Alice") +``` + +--- + +## Test File Reference + +### Root-level tests (`tests/`) + +#### `test_rate_limiter.py` +Tests `modules.rate_limiter` — `RateLimiter` and `PerUserRateLimiter`. + +| Class | What it covers | +|-------|---------------| +| `TestRateLimiter` | Allow/block by interval, time-until-next, timestamp recording | +| `TestPerUserRateLimiter` | Per-key tracking, LRU eviction at `max_entries`, empty-key bypass | + +--- + +#### `test_command_manager.py` +Tests `modules.command_manager` — `CommandManager` and `InternetStatusCache`. + +| Class | What it covers | +|-------|---------------| +| `TestLoadKeywords` | Config parsing, quote stripping, escape decoding | +| `TestLoadBannedUsers` | Banned list parsing and whitespace handling | +| `TestIsUserBanned` | Exact match, prefix (starts-with) match, `None` sender | +| `TestChannelTriggerAllowed` | DM bypass, whitelist allow/block logic | +| `TestLoadMonitorChannels` | Channel list parsing and quote handling | +| `TestLoadChannelKeywords` | Per-channel keyword loading | +| `TestCheckKeywords` | Keyword matching, prefix-gating, channel scope, DM routing | +| `TestGetHelpForCommand` | Help text lookup for known/unknown commands | +| `TestInternetStatusCache` | Freshness check, stale detection, lock lazy-creation | +| `TestSendChannelMessageListeners` | Listener registration, invocation on success, skip on failure | +| `TestSendChannelMessagesChunked` | Empty chunks, single/multi-chunk timing, failure propagation | + +--- + +#### `test_db_manager.py` +Tests `modules.db_manager` — `DBManager`. + +| Class | What it covers | +|-------|---------------| +| `TestGeocoding` | Cache/retrieve geocoding, overwrite existing, invalid hours logged | +| `TestGenericCache` | JSON round-trip, miss returns default, independent keys | +| `TestCacheCleanup` | Expired rows deleted, valid rows preserved | +| `TestTableManagement` | Allowed/disallowed table names, SQL injection prevention | +| `TestExecuteQuery` | Returns list of dicts, update returns row count | +| `TestMetadata` | `set_metadata`/`get_metadata`, miss, bot start time | +| `TestCacheHoursValidation` | Boundary values 1–87600 valid; 0 and 87601 invalid | + +--- + +#### `test_command_prefix.py` +Tests prefix-gating across `BaseCommand.matches_keyword`, `HelloCommand`, `PingCommand`, and `CommandManager`. Verifies that commands with a configured prefix require it, with 14 test cases covering `.`, `!`, multi-char, whitespace, case sensitivity, and empty-prefix edge cases. + +--- + +#### `test_plugin_loader.py` +Tests `modules.plugin_loader` — `PluginLoader`. + +| Class | What it covers | +|-------|---------------| +| `TestDiscover` | Finds command files, excludes `base_command.py` and `__init__.py` | +| `TestValidatePlugin` | Rejects missing `execute`, rejects sync `execute`, accepts valid class | +| `TestLoadPlugin` | Loads `ping_command`, returns `None` for nonexistent | +| `TestKeywordLookup` | By keyword, by name, miss | +| `TestCategoryAndFailed` | Category filter, failed-plugins copy | +| `TestLocalPlugins` | Discovery (empty / missing dir / found), load from path, collision skip | + +--- + +#### `test_checkin_service.py` +Tests `local.service_plugins.checkin_service.CheckInService` (optional; auto-skipped if plugin not installed). Covers channel filtering, phrase matching, `any_message_counts`, and day-of-week filtering. + +--- + +#### `test_scheduler_logic.py` +Tests `modules.scheduler` — `MessageScheduler` pure logic (no threading/asyncio). + +| Class | What it covers | +|-------|---------------| +| `TestIsValidTimeFormat` | Valid HHMM times, invalid hours/minutes/length/non-numeric | +| `TestGetCurrentTime` | Valid timezone, invalid timezone fallback, empty timezone | +| `TestHasMeshInfoPlaceholders` | Detects `{total_contacts}`, `{repeaters}`, returns false for plain text | + +--- + +#### `test_channel_manager_logic.py` +Tests `modules.channel_manager` — `ChannelManager` pure logic. + +| Class | What it covers | +|-------|---------------| +| `TestGenerateHashtagKey` | Deterministic 16-byte key, `#` prepended if missing, known SHA-256 value | +| `TestChannelNameLookup` | Cache hit, fallback to `"channel N"` on miss | +| `TestChannelNumberLookup` | Found by name (case-insensitive), miss | +| `TestCacheManagement` | `invalidate_cache()` sets `_cache_valid = False` | + +--- + +#### `test_feed_manager_formatting.py` +Tests `modules.feed_manager` — `FeedManager` pure formatting (networking disabled). + +| Class | What it covers | +|-------|---------------| +| `TestApplyShortening` | `truncate:N`, `word_wrap:N`, `first_words:N`, `regex:`, `if_regex:`, empty input | +| `TestGetNestedValue` | Simple field, dotted path, missing field default | +| `TestShouldSendItem` | No filter, `equals`, `in`, `and` logic | +| `TestFormatTimestamp` | Recent timestamp string, `None` returns empty | + +--- + +#### `test_profanity_filter.py` +Tests `modules.profanity_filter` — `censor()` and `contains_profanity()`. + +| Class | What it covers | +|-------|---------------| +| `TestProfanityFilterEdgeCases` | `None`, empty, whitespace, non-string input; hate symbol detection | +| `TestProfanityFilterWithLibrary` | (skipped if `better_profanity` absent) — full censoring, `unidecode` homoglyph detection | +| `TestProfanityFilterFallbackWhenLibraryUnavailable` | Graceful degradation, hate symbols still filtered, one-time warning log | + +--- + +#### `test_config_validation.py` +Tests `modules.config_validation` — `validate_config` and helpers. + +| Class | What it covers | +|-------|---------------| +| `TestStripOptionalQuotes` | Single/double quote stripping, mismatch handling | +| `TestValidateConfig` | Missing sections, minimal valid, optional absent, typo detection | +| `TestPathValidation` | Non-existent parent warns, relative paths resolved, non-writable directory warns | +| `TestResolvePath` | Absolute/relative path resolution | +| `TestCheckPathWritable` | Empty path, non-existent parent, writable dir | +| `TestSuggestSimilarCommand` | Fuzzy match hit/miss | +| `TestGetCommandPrefixToSection` | Returns expected dict | + +--- + +#### `test_utils.py` +Tests `modules.utils` — utility functions. + +| Class | What it covers | +|-------|---------------| +| `TestAbbreviateLocation` | US/CA abbreviations, truncation with ellipsis | +| `TestTruncateString` | Under/over max, custom ellipsis | +| `TestDecodeEscapeSequences` | `\n`, `\t`, `\r`, literal backslash-n, mixed | +| `TestParseLocationString` | No comma, zip-only, city/state, city/country | +| `TestCalculateDistance` | Same point = 0, known distances (Seattle–Portland) | +| `TestFormatElapsedDisplay` | `None`/`unknown`/invalid input, recent timestamp, future timestamp, translator integration | +| `TestDecodePathLenByte` | 1/2/3 bytes-per-hop encoding, size code, path length fallback | +| `TestParsePathString` | Comma/space/continuous hex, hop-count suffix, 4-char nodes, legacy fallback | +| `TestCalculatePacketHashPathLength` | Single/multi-byte path hashes, different sizes produce different hashes | +| `TestMultiBytePathDisplayContract` | Format contract for 1-byte and 2-byte node paths | + +--- + +#### `test_bridge_bot_responses.py` +Tests `modules.service_plugins.discord_bridge_service` and `telegram_bridge_service` — `channel_sent_listeners` lifecycle. + +Both `TestDiscordBridgeBotResponses` and `TestTelegramBridgeBotResponses` verify: +- `start()` registers a listener when `bridge_bot_responses = true` +- `stop()` unregisters the listener +- `start()` does NOT register a listener when `bridge_bot_responses = false` + +--- + +#### `test_config_merge.py` +Tests `modules.core.MeshCoreBot` — local config merging. Verifies that `local/config.ini` is merged on `load_config()` and `reload_config()`, and that absent local configs are handled gracefully. + +--- + +#### `test_randomline.py` +Tests `modules.command_manager.CommandManager.match_randomline` — `[RandomLine]` trigger matching. Covers case/whitespace normalisation, extra-word rejection, channel filtering, and channel-override allowing non-monitored channels. + +--- + +#### `test_security_utils.py` +Tests `modules.security_utils`. + +| Class | What it covers | +|-------|---------------| +| `TestValidatePubkeyFormat` | Valid 64-char hex, wrong length, invalid chars, non-string | +| `TestValidateSafePath` | Relative resolution, path traversal rejection, absolute path policy | +| `TestValidateExternalUrl` | `file://` rejected, `http(s)` allowed, localhost policy, missing netloc | +| `TestSanitizeInput` | Max-length truncation, control character stripping, newline/tab preserved | +| `TestValidateApiKeyFormat` | Valid key, too-short key, placeholder strings rejected | +| `TestValidatePortNumber` | Valid port, privileged port policy, out-of-range | + +--- + +#### `test_service_plugin_loader.py` +Tests `modules.service_plugin_loader` — `ServicePluginLoader`. Covers local service discovery (empty/missing dir, finds `.py` files), loading (enabled/disabled/invalid/missing-enabled-key), and name-collision skipping. + +--- + +#### `test_enums.py` +Tests `modules.enums` — all enum and flag types. + +| Class | What it covers | +|-------|---------------| +| `TestAdvertFlags` | Type flag values, feature flag values, legacy aliases, `\|` combination, membership test | +| `TestPayloadType` | All 16 values, lookup by value, uniqueness | +| `TestPayloadVersion` | Four version values, lookup | +| `TestRouteType` | Four route type values, lookup | +| `TestDeviceRole` | String values, lookup, member count | + +--- + +#### `test_models.py` +Tests `modules.models` — `MeshMessage` dataclass. + +| Class | What it covers | +|-------|---------------| +| `TestMeshMessageDefaults` | Required `content`, all optional fields default to `None`, `is_dm=False` | +| `TestMeshMessageConstruction` | Channel msg, DM, routing_info dict, numeric fields, path, elapsed | +| `TestMeshMessageEquality` | Equal messages, different content/channel | + +--- + +#### `test_transmission_tracker.py` +Tests `modules.transmission_tracker` — `TransmissionRecord` and `TransmissionTracker`. + +| Class | What it covers | +|-------|---------------| +| `TestTransmissionRecord` | Default fields, custom fields | +| `TestRecordTransmission` | Returns record, stores in pending dict, multiple per second, command_id | +| `TestMatchPacketHash` | Null/zero hash → None, matches pending, confirmed cached, outside window → None | +| `TestRecordRepeat` | Null hash, increment count, no-prefix `_unknown` key, multiple repeats same repeater, unmatched → False | +| `TestGetRepeatInfo` | Unknown hash, by packet_hash, by command_id | +| `TestExtractRepeaterPrefixes` | Path string last hop, path_nodes last node, own-prefix filter, empty path, `via` annotation, single node | +| `TestCleanupOldRecords` | Removes old pending, keeps recent, removes confirmed without repeats, keeps confirmed with repeats | + +--- + +#### `test_message_handler.py` +Tests `modules.message_handler` — `MessageHandler` pure logic. + +| Class | What it covers | +|-------|---------------| +| `TestIsOldCachedMessage` | No connection time, `None`/`"unknown"`/0/negative/far-future timestamps, old vs. recent, invalid string | +| `TestPathBytesToNodes` | 1/2-byte-per-hop, remainder fallback, empty bytes, zero prefix_hex_chars | +| `TestPathHexToNodes` | 2/4-char chunks, empty/short string, remainder fallback | +| `TestFormatPathString` | Empty → `"Direct"`, legacy 1-byte, bytes_per_hop 1/2, remainder fallback, `None` input, invalid hex | +| `TestGetRouteTypeName` | All 4 known types, unknown type | +| `TestGetPayloadTypeName` | Known types, unknown type | +| `TestShouldProcessMessage` | Bot disabled, banned user, monitored channel, unmonitored, DM on/off, command-override channel | +| `TestCleanupStaleCacheEntries` | Removes old timestamp/pubkey/recent_rf entries, skips full cleanup within interval | + +--- + +#### `test_repeater_manager.py` +Tests `modules.repeater_manager` — `RepeaterManager` pure logic. Uses a real test DB for construction. + +| Class | What it covers | +|-------|---------------| +| `TestDetermineContactRole` | `mode` field priority, device type fallback, name-based detection (rpt, roomserver, sensor, bot, gateway), default companion | +| `TestDetermineDeviceType` | `advert_data.mode` priority, numeric type codes, name-based detection | +| `TestIsRepeaterDevice` | Type 2/3, role fields, name patterns (repeater, gateway), companion → False | +| `TestIsCompanionDevice` | Type 1 → True, type 2 → False, empty data → True | +| `TestIsInAcl` | No section, key present, key absent, empty list, exact-match-only, auto_purge_enabled config | + +--- + +#### `test_core.py` +Tests `modules.core.MeshCoreBot` — config, radio settings, reload, and key helpers. Instantiates a real `MeshCoreBot` from temp config files. + +| Class | What it covers | +|-------|---------------| +| `TestBotRoot` | `bot_root` property returns config file directory | +| `TestGetRadioSettings` | Returns dict with all keys, reads connection_type/serial_port/timeout, defaults for missing fields | +| `TestReloadConfig` | Success on same settings, failure on changed serial_port, missing file → False | +| `TestKeyPrefixHelpers` | `key_prefix()` truncates to `prefix_hex_chars`, `is_valid_prefix()` length check, config-driven `prefix_bytes` | + +--- + +### Command tests (`tests/commands/`) + +| File | Command tested | Key scenarios | +|------|---------------|---------------| +| `test_base_command.py` | `BaseCommand` (via concrete commands) | `derive_config_section_name()`, `is_channel_allowed()`, `get_config_value()` legacy migration for 7 command types | +| `test_help_command.py` | `HelpCommand` | Enabled/disabled, async execute | +| `test_cmd_command.py` | `CmdCommand` | Command list building, truncation with `(N more)` suffix | +| `test_ping_command.py` | `PingCommand` | Keyword response, enabled/disabled | +| `test_dice_command.py` | `DiceCommand` | `d20`, `2d6`, decade (`d10`), mixed notation, value ranges, formatting, default d6, invalid input | +| `test_hello_command.py` | `HelloCommand` | Emoji-only detection, time-seeded greeting, emoji response, async execute | +| `test_magic8_command.py` | `Magic8Command` | Valid 🎱 response, sender mention in channel | +| `test_roll_command.py` | `RollCommand` | Parse notation, keyword matching, default 1–100, specific max | + +--- + +### Unit tests (`tests/unit/`) + +#### `test_mesh_graph.py` and `test_mesh_graph_*.py` +These six files provide comprehensive unit coverage of `modules.mesh_graph`: + +| File | Focus | +|------|-------| +| `test_mesh_graph.py` | Edge management, multi-resolution prefix, path validation, candidate scoring, multi-hop, persistence | +| `test_mesh_graph_scoring.py` | `get_candidate_score()` — prev/next edge, bidirectional bonus, hop-position match and tolerance, disable flags | +| `test_mesh_graph_edges.py` | Add/update/get/has, public key merging, 1→2→3 byte edge promotion | +| `test_mesh_graph_multihop.py` | `find_intermediate_nodes()` — 2/3-hop paths, no path, min observations, bidirectional bonus, multiple candidates, self-loop prevention | +| `test_mesh_graph_validation.py` | `validate_path_segment()` and `validate_path()` — confidence, recency, bidirectional, empty/single paths | +| `test_mesh_graph_optimizations.py` | Adjacency indexes, public-key interning, edge expiration/pruning, notification throttle, capture_enabled flag | + +#### `test_path_command_graph.py` and `test_path_command_graph_selection.py` +Both test `PathCommand._select_repeater_by_graph()`: no-graph fallback, direct edge selection, stored-key bonus, star bias, multi-hop, hop-position weighting, confidence conversion, missing key handling. + +#### `test_path_command_multibyte.py` +Tests `PathCommand._decode_path()` and `_extract_path_from_recent_messages()` for multi-byte prefix support: 2-byte comma-separated, 1-byte, continuous hex, hop-count suffix stripping, routing_info.path_nodes priority. + +--- + +### Integration tests (`tests/integration/`) + +Both files test `PathCommand` + `MeshGraph` end-to-end with a real SQLite database (`test_db` fixture). Each scenario uses `mock_bot`, `mesh_graph`, and helper factories. + +| File | Scenarios | +|------|-----------| +| `test_path_graph_integration.py` | Graph data resolution, prefix collision + disambiguation, starred preference, stored-key priority, 2-hop inference, edge persistence across restart, 5-node real-world scenario | +| `test_path_resolution.py` | Same scenarios plus sync graph validation, geographic vs. graph selection, direct SQLite inserts for setup | + +All test methods use `@pytest.mark.integration` and `@pytest.mark.asyncio` where async. + +--- + +### Regression tests (`tests/regression/`) + +#### `test_keyword_escapes.py` +Regression guard for `modules.utils.decode_escape_sequences` — verifies that `\n` in config values produces a real newline, `\\n` produces a literal backslash-n, and `\t` produces a real tab. Prevents regressions in escape handling after any utils refactor. + +--- + +## Writing New Tests + +### Conventions + +- **Class-based:** Use `class TestFeatureName:` grouping. +- **Async:** `asyncio_mode = auto` is set — just write `async def test_...` without the mark. +- **Fixtures:** Prefer conftest fixtures (`mock_logger`, `mock_bot`, `test_db`, `minimal_config`). Define local fixtures at the top of the file for module-specific setup. +- **Factories:** Use `create_test_repeater()`, `create_test_edge()`, `mock_message()` for consistent test data. +- **Database:** Use `tmp_path` (file-based SQLite, not `:memory:`) to avoid cross-connection isolation issues. +- **Mocking:** `MagicMock` for sync, `AsyncMock` for async methods. +- **Marks:** Tag with `@pytest.mark.unit` or `@pytest.mark.integration` for filtering. + +### Example skeleton + +```python +"""Tests for modules/my_module.py — MyClass.""" + +import pytest +from unittest.mock import Mock, AsyncMock +from modules.my_module import MyClass + + +@pytest.fixture +def my_obj(mock_logger): + bot = Mock() + bot.logger = mock_logger + return MyClass(bot) + + +class TestMyFeature: + + def test_pure_logic(self, my_obj): + result = my_obj.some_method("input") + assert result == "expected" + + async def test_async_method(self, my_obj): + my_obj.bot.send = AsyncMock(return_value=True) + result = await my_obj.async_method("msg") + assert result is True +``` + +### Adding coverage for a new module + +1. Create `tests/test_.py`. +2. Add a local fixture that constructs the class under test with mocked dependencies. +3. Start with pure-logic methods (no network, no DB) — these are fastest to write and run. +4. Add integration tests (with `test_db`) for database-touching methods. +5. Run `pytest tests/test_.py --cov=modules. --cov-report=term-missing` to see gaps. + +--- + +## CI Integration + +Tests run automatically on push/PR to `main`, `master`, and `dev` via `.github/workflows/test.yml`: + +1. **Install deps** — `pip install -e ".[test]"` + `pip install ruff mypy` +2. **Lint** — `ruff check .` +3. **Type check** — `mypy modules/ --ignore-missing-imports` +4. **Tests** — `pytest tests/ -v --tb=short` + +To keep `TODO.md` in sync locally, run: + +```bash +python scripts/update_todos.py +``` + +Or wire it up as a pre-commit hook (see `TODO.md` → Auto-Update section). diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..83ea7f5 --- /dev/null +++ b/TODO.md @@ -0,0 +1,123 @@ +# TODO + +Task list for meshcore-bot development. Auto-updated sections are regenerated +by running `python scripts/update_todos.py` (see [Auto-Update](#auto-update)). + +**Last updated:** 2026-03-14 + +--- + +## In Progress + +- [ ] Expand test coverage to ≥70% across all modules (Phase 5 of dev setup plan) + - [x] `tests/test_enums.py` — enum values and flag combinations + - [x] `tests/test_models.py` — MeshMessage dataclass + - [x] `tests/test_transmission_tracker.py` — full TransmissionTracker + - [x] `tests/test_message_handler.py` — path parsing, cache, message routing + - [x] `tests/test_repeater_manager.py` — role detection, ACL, device type + - [x] `tests/test_core.py` — config loading, radio settings, reload + - [ ] `tests/test_feed_manager.py` — feed polling, deduplication, queue logic + - [ ] `tests/test_scheduler.py` — scheduled message dispatch, interval advertising + - [ ] `tests/test_command_manager.py` — full command dispatch, keyword matching + - [ ] `tests/test_channel_manager.py` — channel fetch, cache lifecycle + +--- + +## Planned Features + +### Bridges + +- [ ] **Two-way Discord bridge** — receive messages from Discord and relay to MeshCore +- [ ] **Two-way Telegram bridge** — relay Telegram messages back into MeshCore channels +- [ ] **Telegram `message_thread_id` support** — route bridged messages to forum topics +- [ ] **Bridge DM support** — optional, opt-in bridging of DMs (requires consent mechanism) + +### Web Viewer + +- [ ] **Authentication** — add token or basic-auth to protect the web dashboard (see BUG-001) +- [ ] **Live packet streaming** — SSE/WebSocket stream of incoming packets on dashboard +- [ ] **Real-time message monitoring** — live channel message feed in browser +- [ ] **Interactive contact management** — star/purge contacts from the UI +- [ ] **Export functionality** — CSV/JSON export of contact and path data +- [ ] **Mobile-responsive improvements** — optimize layout for small screens + +### Commands and Features + +- [ ] **Inbound webhook command** — trigger bot responses from external HTTP POST +- [ ] **Per-channel rate limiting** — different rate limits for different channels +- [ ] **Command aliases** — allow users to define shorthand aliases for commands +- [ ] **Scheduled message preview** — `!schedule list` command to show upcoming scheduled messages +- [ ] **`!wx` non-US improvement** — promote `wx_international.py` to default with US fallback +- [ ] **`!path` geographic scoring toggle** — runtime config flag without restart + +### Infrastructure + +- [ ] **Virtual environment setup automation** — `make install` / `make dev` targets +- [ ] **`ruff check` CI gate** — block merges when linting fails (test.yml updated; needs clean pass) +- [ ] **`mypy` strict mode** — incrementally add type annotations and raise strictness +- [ ] **Coverage threshold enforcement** — currently set to 70%; raise to 80% once baseline is met +- [ ] **Database migration versioning** — replace ad-hoc `ALTER TABLE IF NOT EXISTS` with numbered migrations +- [ ] **Docker multi-arch build** — publish `linux/arm64` image for Raspberry Pi natively + +--- + +## Backlog + +- [ ] Investigate replacing `schedule` library with `APScheduler` for more flexible recurring jobs +- [ ] Evaluate moving web viewer to a separate installable package +- [ ] Add structured logging (JSON) mode for log aggregation pipelines +- [ ] Rate-limiter observability — expose current limiter state via web viewer API +- [ ] Consider async-safe SQLite wrapper (e.g. `aiosqlite`) for `db_manager` +- [ ] Repeater auto-purge dry-run mode — log what would be purged without acting +- [ ] Feed manager: add support for JSON API feeds (not just RSS/Atom) +- [ ] Map uploader: configurable upload interval (currently hardcoded) +- [ ] Support custom greeting messages per-channel (greeter command) + +--- + +## Recently Completed + +- [x] Added `ruff` and `mypy` linting configuration to `pyproject.toml` +- [x] Added lint steps to `.github/workflows/test.yml` +- [x] Fixed `meshcore>=2.2.14` → `meshcore>=2.2.31` version pin in `pyproject.toml` +- [x] Added `profanity`, `geo`, `pytest-mock`, `pytest-cov` optional dependency groups +- [x] Added coverage reporting to `pytest.ini` +- [x] Created 6 new test modules (enums, models, transmission_tracker, message_handler, repeater_manager, core) +- [x] Fixed `RepeaterManager` ignoring `auto_manage_contacts = false` (commit `58deb12`) +- [x] Fixed timezone handling in `format_elapsed_display` — issue #75 (commit `5c8ee35`) +- [x] Fixed `TraceCommand` reversed path nodes and truncated return paths +- [x] Fixed shutdown log spam after streams closed (commit `2178a80`) +- [x] Added Discord and Telegram one-way bridges +- [x] Added chunked message sending for rate-limit-aware large responses +- [x] Multi-byte prefix (2-byte) support throughout codebase + +--- + +## Auto-Update + +The **Inline TODOs** section below is auto-generated by scanning source files for +`# TODO`, `# FIXME`, and `# HACK` markers. Regenerate it with: + +```bash +python scripts/update_todos.py +``` + +Or run it as part of a pre-commit hook by adding to `.pre-commit-config.yaml`: + +```yaml +- repo: local + hooks: + - id: update-todos + name: Update TODO.md inline scan + language: python + entry: python scripts/update_todos.py + pass_filenames: false +``` + +--- + +## Inline TODOs (auto-generated) + +> _Last scanned: 2026-03-14. No `# TODO`, `# FIXME`, or `# HACK` markers +> found in `modules/` or `tests/`. Run `python scripts/update_todos.py` to refresh._ + diff --git a/scripts/update_todos.py b/scripts/update_todos.py new file mode 100644 index 0000000..b824865 --- /dev/null +++ b/scripts/update_todos.py @@ -0,0 +1,152 @@ +#!/usr/bin/env python3 +""" +update_todos.py — Scans source files for TODO/FIXME/HACK markers and rewrites +the "Inline TODOs" section of TODO.md. + +Usage: + python scripts/update_todos.py # from project root + python scripts/update_todos.py --check # exit 1 if TODO.md would change (CI use) + +The script only touches the content between the two sentinel comments: + + +Everything else in TODO.md is left exactly as-is. +""" + +import argparse +import datetime +import os +import re +import sys +from pathlib import Path + +# --------------------------------------------------------------------------- +# Configuration +# --------------------------------------------------------------------------- + +PROJECT_ROOT = Path(__file__).parent.parent +TODO_FILE = PROJECT_ROOT / "TODO.md" +SCAN_DIRS = ["modules", "tests"] +SCAN_EXTENSIONS = {".py"} +MARKERS = re.compile(r"#\s*(TODO|FIXME|HACK)\b[:\s]*(.*)", re.IGNORECASE) + +SECTION_START = "## Inline TODOs (auto-generated)" +SENTINEL_LINE = "> _Last scanned:" + + +def scan_todos(): + """Walk source directories and collect all TODO/FIXME/HACK comments.""" + results = [] + for scan_dir in SCAN_DIRS: + root = PROJECT_ROOT / scan_dir + if not root.exists(): + continue + for dirpath, _dirs, files in os.walk(root): + for fname in sorted(files): + if Path(fname).suffix not in SCAN_EXTENSIONS: + continue + fpath = Path(dirpath) / fname + rel = fpath.relative_to(PROJECT_ROOT) + try: + with open(fpath, encoding="utf-8", errors="replace") as fh: + for lineno, line in enumerate(fh, 1): + m = MARKERS.search(line) + if m: + marker = m.group(1).upper() + text = m.group(2).strip().rstrip(".") + results.append((str(rel), lineno, marker, text)) + except OSError: + pass + return results + + +def build_section(todos, today: str) -> str: + """Render the Inline TODOs section as a markdown string.""" + lines = [f"{SECTION_START}\n"] + if not todos: + lines.append( + f"> _Last scanned: {today}. No `# TODO`, `# FIXME`, or `# HACK` markers" + ) + lines.append( + "> found in `modules/` or `tests/`. Run `python scripts/update_todos.py` to refresh._\n" + ) + return "\n".join(lines) + + lines.append( + f"> _Last scanned: {today}. {len(todos)} item(s) found._\n" + ) + + # Group by marker type + by_marker: dict[str, list] = {} + for rel, lineno, marker, text in todos: + by_marker.setdefault(marker, []).append((rel, lineno, text)) + + emoji = {"TODO": "📋", "FIXME": "🔧", "HACK": "⚠️"} + for marker in ("FIXME", "TODO", "HACK"): + if marker not in by_marker: + continue + lines.append(f"### {emoji.get(marker, '')} {marker}\n") + for rel, lineno, text in sorted(by_marker[marker]): + label = text if text else f"(no description — see file)" + lines.append(f"- [ ] **`{rel}:{lineno}`** — {label}") + lines.append("") + + return "\n".join(lines) + + +def rewrite_todo_md(new_section: str, check_only: bool = False) -> bool: + """ + Replace the Inline TODOs section in TODO.md. + + Returns True if the file was (or would be) changed. + """ + if not TODO_FILE.exists(): + print(f"ERROR: {TODO_FILE} not found.", file=sys.stderr) + sys.exit(1) + + content = TODO_FILE.read_text(encoding="utf-8") + + # Find the section heading and replace everything from it to EOF + idx = content.find(f"\n{SECTION_START}") + if idx == -1: + # Section missing — append it + new_content = content.rstrip() + "\n\n---\n\n" + new_section + "\n" + else: + new_content = content[: idx + 1] + new_section + "\n" + + changed = new_content != content + + if check_only: + if changed: + print("TODO.md is out of date. Run `python scripts/update_todos.py` to refresh.") + return changed + + if changed: + TODO_FILE.write_text(new_content, encoding="utf-8") + print(f"TODO.md updated ({len(new_section.splitlines())} lines in Inline TODOs section).") + else: + print("TODO.md is already up to date.") + + return changed + + +def main(): + parser = argparse.ArgumentParser(description="Update Inline TODOs section in TODO.md") + parser.add_argument( + "--check", + action="store_true", + help="Exit with code 1 if TODO.md would change (for CI gates)", + ) + args = parser.parse_args() + + today = datetime.date.today().isoformat() + todos = scan_todos() + section = build_section(todos, today) + changed = rewrite_todo_md(section, check_only=args.check) + + if args.check and changed: + sys.exit(1) + + +if __name__ == "__main__": + main() diff --git a/tests/test_core.py b/tests/test_core.py new file mode 100644 index 0000000..9191739 --- /dev/null +++ b/tests/test_core.py @@ -0,0 +1,187 @@ +"""Tests for MeshCoreBot logic (config loading, radio settings, helpers).""" + +import pytest +from pathlib import Path + +from modules.core import MeshCoreBot + + +# --------------------------------------------------------------------------- +# Helpers +# --------------------------------------------------------------------------- + +def _write_config(path: Path, db_path: Path, extra: str = "") -> None: + path.write_text( + f"""[Connection] +connection_type = serial +serial_port = /dev/ttyUSB0 +timeout = 30 + +[Bot] +db_path = {db_path.as_posix()} +prefix_bytes = 1 + +[Channels] +monitor_channels = #general +{extra} +""", + encoding="utf-8", + ) + + +@pytest.fixture +def bot(tmp_path): + """Minimal MeshCoreBot from a temporary config file.""" + config_file = tmp_path / "config.ini" + db_path = tmp_path / "bot.db" + _write_config(config_file, db_path) + return MeshCoreBot(config_file=str(config_file)) + + +# --------------------------------------------------------------------------- +# bot_root property +# --------------------------------------------------------------------------- + +class TestBotRoot: + def test_returns_config_directory(self, tmp_path): + config_file = tmp_path / "config.ini" + db_path = tmp_path / "bot.db" + _write_config(config_file, db_path) + bot = MeshCoreBot(config_file=str(config_file)) + assert bot.bot_root == tmp_path.resolve() + + +# --------------------------------------------------------------------------- +# _get_radio_settings +# --------------------------------------------------------------------------- + +class TestGetRadioSettings: + def test_returns_dict_with_expected_keys(self, bot): + settings = bot._get_radio_settings() + assert "connection_type" in settings + assert "serial_port" in settings + assert "ble_device_name" in settings + assert "hostname" in settings + assert "tcp_port" in settings + assert "timeout" in settings + + def test_reads_connection_type(self, bot): + assert bot._get_radio_settings()["connection_type"] == "serial" + + def test_reads_serial_port(self, bot): + assert bot._get_radio_settings()["serial_port"] == "/dev/ttyUSB0" + + def test_reads_timeout(self, bot): + assert bot._get_radio_settings()["timeout"] == 30 + + def test_defaults_when_missing(self, tmp_path): + config_file = tmp_path / "config.ini" + db_path = tmp_path / "bot.db" + # Write config without optional fields + config_file.write_text( + f"""[Connection] +connection_type = ble + +[Bot] +db_path = {db_path.as_posix()} + +[Channels] +monitor_channels = #general +""", + encoding="utf-8", + ) + b = MeshCoreBot(config_file=str(config_file)) + settings = b._get_radio_settings() + assert settings["ble_device_name"] == "" + assert settings["hostname"] == "" + assert settings["tcp_port"] == 5000 + + +# --------------------------------------------------------------------------- +# reload_config +# --------------------------------------------------------------------------- + +class TestReloadConfig: + def test_reload_succeeds_with_same_radio_settings(self, tmp_path): + config_file = tmp_path / "config.ini" + db_path = tmp_path / "bot.db" + _write_config(config_file, db_path) + b = MeshCoreBot(config_file=str(config_file)) + success, msg = b.reload_config() + assert success is True + + def test_reload_fails_when_radio_settings_changed(self, tmp_path): + config_file = tmp_path / "config.ini" + db_path = tmp_path / "bot.db" + _write_config(config_file, db_path) + b = MeshCoreBot(config_file=str(config_file)) + # Change serial port in config file + _write_config(config_file, db_path, extra="") + config_file.write_text( + f"""[Connection] +connection_type = serial +serial_port = /dev/ttyUSB1 +timeout = 30 + +[Bot] +db_path = {db_path.as_posix()} + +[Channels] +monitor_channels = #general +""", + encoding="utf-8", + ) + success, msg = b.reload_config() + assert success is False + assert "serial_port" in msg.lower() or "restart" in msg.lower() + + def test_reload_config_not_found_returns_false(self, tmp_path): + config_file = tmp_path / "config.ini" + db_path = tmp_path / "bot.db" + _write_config(config_file, db_path) + b = MeshCoreBot(config_file=str(config_file)) + # Delete the config file + config_file.unlink() + success, msg = b.reload_config() + assert success is False + + +# --------------------------------------------------------------------------- +# key_prefix / is_valid_prefix +# --------------------------------------------------------------------------- + +class TestKeyPrefixHelpers: + def test_key_prefix_returns_first_n_chars(self, bot): + assert bot.key_prefix("deadbeef1234") == "de" + + def test_key_prefix_uses_prefix_hex_chars(self, bot): + bot.prefix_hex_chars = 4 + assert bot.key_prefix("deadbeef1234") == "dead" + + def test_is_valid_prefix_correct_length(self, bot): + assert bot.is_valid_prefix("de") is True + + def test_is_valid_prefix_wrong_length(self, bot): + assert bot.is_valid_prefix("d") is False + assert bot.is_valid_prefix("dead") is False + + def test_prefix_hex_chars_from_config(self, tmp_path): + config_file = tmp_path / "config2.ini" + db_path = tmp_path / "bot2.db" + config_file.write_text( + f"""[Connection] +connection_type = ble + +[Bot] +db_path = {db_path.as_posix()} +prefix_bytes = 2 + +[Channels] +monitor_channels = #general +""", + encoding="utf-8", + ) + b = MeshCoreBot(config_file=str(config_file)) + assert b.prefix_hex_chars == 4 + assert b.is_valid_prefix("dead") is True + assert b.is_valid_prefix("de") is False diff --git a/tests/test_enums.py b/tests/test_enums.py new file mode 100644 index 0000000..479cfd7 --- /dev/null +++ b/tests/test_enums.py @@ -0,0 +1,108 @@ +"""Tests for modules/enums.py — enum values and flag combinations.""" + +import pytest + +from modules.enums import AdvertFlags, DeviceRole, PayloadType, PayloadVersion, RouteType + + +class TestAdvertFlags: + """Tests for AdvertFlags Flag enum.""" + + def test_type_flags_values(self): + assert AdvertFlags.ADV_TYPE_NONE.value == 0x00 + assert AdvertFlags.ADV_TYPE_CHAT.value == 0x01 + assert AdvertFlags.ADV_TYPE_REPEATER.value == 0x02 + assert AdvertFlags.ADV_TYPE_ROOM.value == 0x03 + assert AdvertFlags.ADV_TYPE_SENSOR.value == 0x04 + + def test_feature_flag_values(self): + assert AdvertFlags.ADV_LATLON_MASK.value == 0x10 + assert AdvertFlags.ADV_FEAT1_MASK.value == 0x20 + assert AdvertFlags.ADV_FEAT2_MASK.value == 0x40 + assert AdvertFlags.ADV_NAME_MASK.value == 0x80 + + def test_legacy_aliases_equal_originals(self): + assert AdvertFlags.IsCompanion == AdvertFlags.ADV_TYPE_CHAT + assert AdvertFlags.IsRepeater == AdvertFlags.ADV_TYPE_REPEATER + assert AdvertFlags.IsRoomServer == AdvertFlags.ADV_TYPE_ROOM + assert AdvertFlags.HasLocation == AdvertFlags.ADV_LATLON_MASK + assert AdvertFlags.HasName == AdvertFlags.ADV_NAME_MASK + assert AdvertFlags.HasNameBit7 == AdvertFlags.ADV_NAME_MASK + + def test_flag_combination(self): + """Flags can be combined with |.""" + combined = AdvertFlags.ADV_TYPE_REPEATER | AdvertFlags.ADV_LATLON_MASK + assert AdvertFlags.ADV_TYPE_REPEATER in combined + assert AdvertFlags.ADV_LATLON_MASK in combined + + def test_flag_membership(self): + combined = AdvertFlags.ADV_TYPE_CHAT | AdvertFlags.ADV_NAME_MASK + assert AdvertFlags.ADV_NAME_MASK in combined + assert AdvertFlags.ADV_LATLON_MASK not in combined + + +class TestPayloadType: + """Tests for PayloadType Enum.""" + + def test_known_values(self): + assert PayloadType.REQ.value == 0x00 + assert PayloadType.RESPONSE.value == 0x01 + assert PayloadType.TXT_MSG.value == 0x02 + assert PayloadType.ACK.value == 0x03 + assert PayloadType.ADVERT.value == 0x04 + assert PayloadType.GRP_TXT.value == 0x05 + assert PayloadType.GRP_DATA.value == 0x06 + assert PayloadType.ANON_REQ.value == 0x07 + assert PayloadType.PATH.value == 0x08 + assert PayloadType.TRACE.value == 0x09 + assert PayloadType.MULTIPART.value == 0x0A + assert PayloadType.RAW_CUSTOM.value == 0x0F + + def test_lookup_by_value(self): + assert PayloadType(0x02) == PayloadType.TXT_MSG + assert PayloadType(0x04) == PayloadType.ADVERT + + def test_all_members_unique(self): + values = [m.value for m in PayloadType] + assert len(values) == len(set(values)) + + +class TestPayloadVersion: + """Tests for PayloadVersion Enum.""" + + def test_versions(self): + assert PayloadVersion.VER_1.value == 0x00 + assert PayloadVersion.VER_2.value == 0x01 + assert PayloadVersion.VER_3.value == 0x02 + assert PayloadVersion.VER_4.value == 0x03 + + def test_lookup(self): + assert PayloadVersion(0x00) == PayloadVersion.VER_1 + + +class TestRouteType: + """Tests for RouteType Enum.""" + + def test_values(self): + assert RouteType.TRANSPORT_FLOOD.value == 0x00 + assert RouteType.FLOOD.value == 0x01 + assert RouteType.DIRECT.value == 0x02 + assert RouteType.TRANSPORT_DIRECT.value == 0x03 + + def test_lookup_by_value(self): + assert RouteType(0x01) == RouteType.FLOOD + + +class TestDeviceRole: + """Tests for DeviceRole Enum.""" + + def test_values(self): + assert DeviceRole.Companion.value == "Companion" + assert DeviceRole.Repeater.value == "Repeater" + assert DeviceRole.RoomServer.value == "RoomServer" + + def test_lookup_by_value(self): + assert DeviceRole("Repeater") == DeviceRole.Repeater + + def test_all_members(self): + assert len(list(DeviceRole)) == 3 diff --git a/tests/test_message_handler.py b/tests/test_message_handler.py new file mode 100644 index 0000000..2fb3ed3 --- /dev/null +++ b/tests/test_message_handler.py @@ -0,0 +1,329 @@ +"""Tests for MessageHandler pure logic (no network, no meshcore device).""" + +import time +import pytest +import configparser +from unittest.mock import Mock, MagicMock + +from modules.message_handler import MessageHandler +from modules.models import MeshMessage + + +@pytest.fixture +def bot(mock_logger): + """Minimal bot mock for MessageHandler instantiation.""" + bot = Mock() + bot.logger = mock_logger + bot.config = configparser.ConfigParser() + bot.config.add_section("Bot") + bot.config.set("Bot", "enabled", "true") + bot.config.set("Bot", "rf_data_timeout", "15.0") + bot.config.set("Bot", "message_correlation_timeout", "10.0") + bot.config.set("Bot", "enable_enhanced_correlation", "true") + bot.config.add_section("Channels") + bot.config.set("Channels", "respond_to_dms", "true") + bot.connection_time = None + bot.prefix_hex_chars = 2 + bot.command_manager = Mock() + bot.command_manager.monitor_channels = ["general", "test"] + bot.command_manager.is_user_banned = Mock(return_value=False) + bot.command_manager.commands = {} + return bot + + +@pytest.fixture +def handler(bot): + return MessageHandler(bot) + + +# --------------------------------------------------------------------------- +# _is_old_cached_message +# --------------------------------------------------------------------------- + +class TestIsOldCachedMessage: + """Tests for MessageHandler._is_old_cached_message().""" + + def test_no_connection_time_returns_false(self, handler): + handler.bot.connection_time = None + assert handler._is_old_cached_message(12345) is False + + def test_none_timestamp_returns_false(self, handler): + handler.bot.connection_time = time.time() + assert handler._is_old_cached_message(None) is False + + def test_unknown_timestamp_returns_false(self, handler): + handler.bot.connection_time = time.time() + assert handler._is_old_cached_message("unknown") is False + + def test_zero_timestamp_returns_false(self, handler): + handler.bot.connection_time = time.time() + assert handler._is_old_cached_message(0) is False + + def test_negative_timestamp_returns_false(self, handler): + handler.bot.connection_time = time.time() + assert handler._is_old_cached_message(-1) is False + + def test_old_timestamp_returns_true(self, handler): + now = time.time() + handler.bot.connection_time = now + old = now - 100 # 100 seconds before connection + assert handler._is_old_cached_message(old) is True + + def test_recent_timestamp_returns_false(self, handler): + now = time.time() + handler.bot.connection_time = now + recent = now + 1 # after connection + assert handler._is_old_cached_message(recent) is False + + def test_far_future_timestamp_returns_false(self, handler): + handler.bot.connection_time = time.time() + future = time.time() + 7200 # 2 hours in future + assert handler._is_old_cached_message(future) is False + + def test_invalid_string_returns_false(self, handler): + handler.bot.connection_time = time.time() + assert handler._is_old_cached_message("not_a_number") is False + + +# --------------------------------------------------------------------------- +# _path_bytes_to_nodes +# --------------------------------------------------------------------------- + +class TestPathBytesToNodes: + """Tests for MessageHandler._path_bytes_to_nodes().""" + + def test_single_byte_per_hop(self, handler): + # 3 bytes -> 3 nodes of 2 hex chars each + path_hex, nodes = handler._path_bytes_to_nodes(bytes.fromhex("017e86"), prefix_hex_chars=2) + assert path_hex == "017e86" + assert nodes == ["01", "7E", "86"] + + def test_two_bytes_per_hop(self, handler): + path_hex, nodes = handler._path_bytes_to_nodes(bytes.fromhex("01027e86"), prefix_hex_chars=4) + assert nodes == ["0102", "7E86"] + + def test_remainder_falls_back_to_1byte(self, handler): + # 3 bytes with prefix_hex_chars=4 → remainder, fallback to 1 byte + path_hex, nodes = handler._path_bytes_to_nodes(bytes.fromhex("017e86"), prefix_hex_chars=4) + assert nodes == ["01", "7E", "86"] + + def test_empty_bytes(self, handler): + path_hex, nodes = handler._path_bytes_to_nodes(b"", prefix_hex_chars=2) + assert path_hex == "" + # Empty or fallback nodes — no crash expected + assert isinstance(nodes, list) + + def test_zero_prefix_hex_chars_defaults_to_2(self, handler): + path_hex, nodes = handler._path_bytes_to_nodes(bytes.fromhex("017e"), prefix_hex_chars=0) + assert nodes == ["01", "7E"] + + +# --------------------------------------------------------------------------- +# _path_hex_to_nodes +# --------------------------------------------------------------------------- + +class TestPathHexToNodes: + """Tests for MessageHandler._path_hex_to_nodes().""" + + def test_splits_into_2char_nodes(self, handler): + handler.bot.prefix_hex_chars = 2 + nodes = handler._path_hex_to_nodes("017e86") + assert nodes == ["01", "7e", "86"] + + def test_empty_string_returns_empty(self, handler): + nodes = handler._path_hex_to_nodes("") + assert nodes == [] + + def test_short_string_returns_empty(self, handler): + nodes = handler._path_hex_to_nodes("0") + assert nodes == [] + + def test_4char_prefix_hex_chars(self, handler): + handler.bot.prefix_hex_chars = 4 + nodes = handler._path_hex_to_nodes("01027e86") + assert nodes == ["0102", "7e86"] + + def test_remainder_falls_back_to_2chars(self, handler): + handler.bot.prefix_hex_chars = 4 + # 6 hex chars (3 bytes) with 4-char chunks → remainder → fallback to 2-char + nodes = handler._path_hex_to_nodes("017e86") + assert nodes == ["01", "7e", "86"] + + +# --------------------------------------------------------------------------- +# _format_path_string +# --------------------------------------------------------------------------- + +class TestFormatPathString: + """Tests for MessageHandler._format_path_string().""" + + def test_empty_path_returns_direct(self, handler): + assert handler._format_path_string("") == "Direct" + + def test_legacy_single_byte_per_hop(self, handler): + result = handler._format_path_string("017e86") + assert result == "01,7e,86" + + def test_with_bytes_per_hop_1(self, handler): + result = handler._format_path_string("017e86", bytes_per_hop=1) + assert result == "01,7e,86" + + def test_with_bytes_per_hop_2(self, handler): + result = handler._format_path_string("01027e86", bytes_per_hop=2) + assert result == "0102,7e86" + + def test_remainder_with_bytes_per_hop_falls_back(self, handler): + # 3 bytes (6 hex) with bytes_per_hop=2 → remainder → fallback to 1 byte + result = handler._format_path_string("017e86", bytes_per_hop=2) + assert result == "01,7e,86" + + def test_none_path_returns_direct(self, handler): + assert handler._format_path_string(None) == "Direct" + + def test_invalid_hex_returns_raw(self, handler): + result = handler._format_path_string("ZZZZ", bytes_per_hop=None) + # Should not crash; returns "Raw: ..." fallback + assert "Raw" in result or "ZZ" in result.upper() or result == "Direct" + + +# --------------------------------------------------------------------------- +# _get_route_type_name +# --------------------------------------------------------------------------- + +class TestGetRouteTypeName: + """Tests for MessageHandler._get_route_type_name().""" + + def test_known_types(self, handler): + assert handler._get_route_type_name(0x00) == "ROUTE_TYPE_TRANSPORT_FLOOD" + assert handler._get_route_type_name(0x01) == "ROUTE_TYPE_FLOOD" + assert handler._get_route_type_name(0x02) == "ROUTE_TYPE_DIRECT" + assert handler._get_route_type_name(0x03) == "ROUTE_TYPE_TRANSPORT_DIRECT" + + def test_unknown_type(self, handler): + result = handler._get_route_type_name(0xFF) + assert "UNKNOWN" in result + assert "ff" in result + + +# --------------------------------------------------------------------------- +# get_payload_type_name +# --------------------------------------------------------------------------- + +class TestGetPayloadTypeName: + """Tests for MessageHandler.get_payload_type_name().""" + + def test_known_types(self, handler): + assert handler.get_payload_type_name(0x00) == "REQ" + assert handler.get_payload_type_name(0x02) == "TXT_MSG" + assert handler.get_payload_type_name(0x04) == "ADVERT" + assert handler.get_payload_type_name(0x05) == "GRP_TXT" + assert handler.get_payload_type_name(0x08) == "PATH" + assert handler.get_payload_type_name(0x0F) == "RAW_CUSTOM" + + def test_unknown_type(self, handler): + result = handler.get_payload_type_name(0xAB) + assert "UNKNOWN" in result + + +# --------------------------------------------------------------------------- +# should_process_message +# --------------------------------------------------------------------------- + +class TestShouldProcessMessage: + """Tests for MessageHandler.should_process_message().""" + + def _make_msg(self, channel=None, is_dm=False, sender_id="Alice"): + return MeshMessage( + content="hello", + channel=channel, + is_dm=is_dm, + sender_id=sender_id, + ) + + def test_bot_disabled_returns_false(self, handler): + handler.bot.config.set("Bot", "enabled", "false") + msg = self._make_msg(channel="general") + assert handler.should_process_message(msg) is False + + def test_banned_user_returns_false(self, handler): + handler.bot.command_manager.is_user_banned.return_value = True + msg = self._make_msg(channel="general") + assert handler.should_process_message(msg) is False + + def test_monitored_channel_returns_true(self, handler): + msg = self._make_msg(channel="general") + assert handler.should_process_message(msg) is True + + def test_unmonitored_channel_returns_false(self, handler): + msg = self._make_msg(channel="unmonitored") + assert handler.should_process_message(msg) is False + + def test_dm_enabled_returns_true(self, handler): + handler.bot.config.set("Channels", "respond_to_dms", "true") + msg = self._make_msg(is_dm=True) + assert handler.should_process_message(msg) is True + + def test_dm_disabled_returns_false(self, handler): + handler.bot.config.set("Channels", "respond_to_dms", "false") + msg = self._make_msg(is_dm=True) + assert handler.should_process_message(msg) is False + + def test_command_override_allows_unmonitored_channel(self, handler): + cmd = Mock() + cmd.is_channel_allowed = Mock(return_value=True) + handler.bot.command_manager.commands = {"special": cmd} + msg = self._make_msg(channel="unmonitored") + assert handler.should_process_message(msg) is True + + +# --------------------------------------------------------------------------- +# _cleanup_stale_cache_entries +# --------------------------------------------------------------------------- + +class TestCleanupStaleCacheEntries: + """Tests for MessageHandler._cleanup_stale_cache_entries().""" + + def test_removes_old_timestamp_cache_entries(self, handler): + now = time.time() + # Old entry: older than rf_data_timeout + handler.rf_data_by_timestamp[now - 100] = {"timestamp": now - 100, "data": "old"} + # Recent entry + handler.rf_data_by_timestamp[now] = {"timestamp": now, "data": "new"} + # Force full cleanup + handler._last_cache_cleanup = 0 + handler._cleanup_stale_cache_entries(current_time=now + handler._cache_cleanup_interval + 1) + # Old entry should be gone, recent kept + assert (now - 100) not in handler.rf_data_by_timestamp + assert now in handler.rf_data_by_timestamp + + def test_removes_stale_pubkey_cache_entries(self, handler): + now = time.time() + handler.rf_data_by_pubkey["deadbeef"] = [ + {"timestamp": now - 100, "data": "old"}, # stale + {"timestamp": now, "data": "new"}, # fresh + ] + handler._last_cache_cleanup = 0 + handler._cleanup_stale_cache_entries(current_time=now + handler._cache_cleanup_interval + 1) + entries = handler.rf_data_by_pubkey.get("deadbeef", []) + assert all(now - e["timestamp"] < handler.rf_data_timeout for e in entries) + + def test_removes_stale_recent_rf_data(self, handler): + now = time.time() + handler.recent_rf_data = [ + {"timestamp": now - 100}, + {"timestamp": now}, + ] + handler._last_cache_cleanup = 0 + handler._cleanup_stale_cache_entries(current_time=now + handler._cache_cleanup_interval + 1) + assert all(now - e["timestamp"] < handler.rf_data_timeout for e in handler.recent_rf_data) + + def test_skips_full_cleanup_within_interval(self, handler): + now = time.time() + handler._last_cache_cleanup = now # just cleaned + # Stale entry in timestamp cache + stale_ts = now - 100 + handler.rf_data_by_timestamp[stale_ts] = {"timestamp": stale_ts} + # Call with time just slightly after (within cleanup interval) + handler._cleanup_stale_cache_entries(current_time=now + 1) + # Still cleaned (timeout-only cleanup still runs) + assert stale_ts not in handler.rf_data_by_timestamp diff --git a/tests/test_models.py b/tests/test_models.py new file mode 100644 index 0000000..d21c150 --- /dev/null +++ b/tests/test_models.py @@ -0,0 +1,93 @@ +"""Tests for modules/models.py — MeshMessage dataclass.""" + +import pytest + +from modules.models import MeshMessage + + +class TestMeshMessageDefaults: + """MeshMessage default values.""" + + def test_content_is_required(self): + msg = MeshMessage(content="hello") + assert msg.content == "hello" + + def test_optional_fields_default_to_none(self): + msg = MeshMessage(content="test") + assert msg.sender_id is None + assert msg.sender_pubkey is None + assert msg.channel is None + assert msg.hops is None + assert msg.path is None + assert msg.timestamp is None + assert msg.snr is None + assert msg.rssi is None + assert msg.elapsed is None + assert msg.routing_info is None + + def test_is_dm_defaults_false(self): + msg = MeshMessage(content="test") + assert msg.is_dm is False + + +class TestMeshMessageConstruction: + """MeshMessage construction with various field combinations.""" + + def test_channel_message(self): + msg = MeshMessage(content="ping", channel="general", sender_id="Alice") + assert msg.content == "ping" + assert msg.channel == "general" + assert msg.sender_id == "Alice" + assert msg.is_dm is False + + def test_dm_message(self): + msg = MeshMessage(content="hello", is_dm=True, sender_pubkey="deadbeef") + assert msg.is_dm is True + assert msg.channel is None + assert msg.sender_pubkey == "deadbeef" + + def test_routing_info_dict(self): + routing = {"path_nodes": ["01", "7e"], "route_type": "FLOOD"} + msg = MeshMessage(content="data", routing_info=routing) + assert msg.routing_info == routing + assert msg.routing_info["route_type"] == "FLOOD" + + def test_numeric_fields(self): + msg = MeshMessage( + content="test", + hops=3, + timestamp=1700000000, + snr=5.5, + rssi=-80, + ) + assert msg.hops == 3 + assert msg.timestamp == 1700000000 + assert msg.snr == 5.5 + assert msg.rssi == -80 + + def test_path_field(self): + msg = MeshMessage(content="test", path="01,7e,86") + assert msg.path == "01,7e,86" + + def test_elapsed_field(self): + msg = MeshMessage(content="test", elapsed="1.23s") + assert msg.elapsed == "1.23s" + + +class TestMeshMessageEquality: + """MeshMessage dataclass equality.""" + + def test_equal_messages(self): + msg1 = MeshMessage(content="ping", channel="general", sender_id="Alice") + msg2 = MeshMessage(content="ping", channel="general", sender_id="Alice") + assert msg1 == msg2 + + def test_different_content_not_equal(self): + msg1 = MeshMessage(content="ping") + msg2 = MeshMessage(content="pong") + assert msg1 != msg2 + + def test_different_channel_not_equal(self): + msg1 = MeshMessage(content="ping", channel="general") + msg2 = MeshMessage(content="ping", channel="test") + assert msg1 != msg2 diff --git a/tests/test_repeater_manager.py b/tests/test_repeater_manager.py new file mode 100644 index 0000000..7fbd515 --- /dev/null +++ b/tests/test_repeater_manager.py @@ -0,0 +1,217 @@ +"""Tests for RepeaterManager pure logic (no network, no geocoding).""" + +import pytest +import configparser +from unittest.mock import Mock, MagicMock, patch + +from modules.repeater_manager import RepeaterManager + + +@pytest.fixture +def bot(mock_logger, test_db): + """Minimal bot mock for RepeaterManager — uses a real test DB.""" + bot = Mock() + bot.logger = mock_logger + bot.db_manager = test_db + bot.config = configparser.ConfigParser() + bot.config.add_section("Bot") + bot.config.set("Bot", "auto_manage_contacts", "false") + bot.config.add_section("Companion_Purge") + bot.config.set("Companion_Purge", "companion_purge_enabled", "false") + bot.config.set("Companion_Purge", "companion_dm_threshold_days", "30") + bot.config.set("Companion_Purge", "companion_advert_threshold_days", "30") + bot.config.set("Companion_Purge", "companion_min_inactive_days", "30") + bot.meshcore = None + return bot + + +@pytest.fixture +def rm(bot): + """RepeaterManager instance for pure logic tests.""" + return RepeaterManager(bot) + + +# --------------------------------------------------------------------------- +# _determine_contact_role +# --------------------------------------------------------------------------- + +class TestDetermineContactRole: + """Tests for RepeaterManager._determine_contact_role().""" + + def test_mode_repeater(self, rm): + assert rm._determine_contact_role({"mode": "Repeater"}) == "repeater" + + def test_mode_roomserver(self, rm): + assert rm._determine_contact_role({"mode": "RoomServer"}) == "roomserver" + + def test_mode_companion(self, rm): + assert rm._determine_contact_role({"mode": "Companion"}) == "companion" + + def test_mode_sensor(self, rm): + assert rm._determine_contact_role({"mode": "Sensor"}) == "sensor" + + def test_mode_unknown_lowercased(self, rm): + result = rm._determine_contact_role({"mode": "CustomMode"}) + assert result == "custommode" + + def test_device_type_2_returns_repeater(self, rm): + assert rm._determine_contact_role({"type": 2}) == "repeater" + + def test_device_type_3_returns_roomserver(self, rm): + assert rm._determine_contact_role({"type": 3}) == "roomserver" + + def test_name_rpt_returns_repeater(self, rm): + assert rm._determine_contact_role({"name": "My-RPT-01"}) == "repeater" + + def test_name_roomserver_returns_roomserver(self, rm): + assert rm._determine_contact_role({"name": "Room Server"}) == "roomserver" + + def test_name_sensor_returns_sensor(self, rm): + assert rm._determine_contact_role({"name": "Weather Sensor"}) == "sensor" + + def test_name_bot_returns_bot(self, rm): + assert rm._determine_contact_role({"name": "AutomatedBot"}) == "bot" + + def test_name_gateway_returns_gateway(self, rm): + assert rm._determine_contact_role({"name": "GW-01"}) == "gateway" + + def test_unknown_defaults_to_companion(self, rm): + assert rm._determine_contact_role({"name": "Alice"}) == "companion" + + def test_empty_contact_defaults_to_companion(self, rm): + assert rm._determine_contact_role({}) == "companion" + + +# --------------------------------------------------------------------------- +# _determine_device_type +# --------------------------------------------------------------------------- + +class TestDetermineDeviceType: + """Tests for RepeaterManager._determine_device_type().""" + + def test_advert_data_mode_repeater(self, rm): + result = rm._determine_device_type(0, "Test", advert_data={"mode": "Repeater"}) + assert result == "Repeater" + + def test_advert_data_mode_roomserver(self, rm): + result = rm._determine_device_type(0, "Test", advert_data={"mode": "RoomServer"}) + assert result == "RoomServer" + + def test_device_type_1(self, rm): + assert rm._determine_device_type(1, "Alice") == "Companion" + + def test_device_type_2(self, rm): + assert rm._determine_device_type(2, "Node") == "Repeater" + + def test_device_type_3(self, rm): + assert rm._determine_device_type(3, "Node") == "RoomServer" + + def test_name_roomserver(self, rm): + assert rm._determine_device_type(0, "RoomServer Node") == "RoomServer" + + def test_name_repeater(self, rm): + assert rm._determine_device_type(0, "RPT-01 repeater") == "Repeater" + + def test_name_sensor(self, rm): + assert rm._determine_device_type(0, "Weather sens") == "Sensor" + + def test_name_gateway(self, rm): + assert rm._determine_device_type(0, "MQTT-GW bridge") == "Gateway" + + def test_name_bot(self, rm): + assert rm._determine_device_type(0, "Automated assistant") == "Bot" + + def test_unknown_defaults_to_companion(self, rm): + assert rm._determine_device_type(0, "Alice Johnson") == "Companion" + + +# --------------------------------------------------------------------------- +# _is_repeater_device +# --------------------------------------------------------------------------- + +class TestIsRepeaterDevice: + """Tests for RepeaterManager._is_repeater_device().""" + + def test_type_2_is_repeater(self, rm): + assert rm._is_repeater_device({"type": 2}) is True + + def test_type_3_is_repeater(self, rm): + assert rm._is_repeater_device({"type": 3}) is True + + def test_type_1_not_repeater(self, rm): + assert rm._is_repeater_device({"type": 1}) is False + + def test_role_repeater_field(self, rm): + assert rm._is_repeater_device({"role": "repeater"}) is True + + def test_role_roomserver_field(self, rm): + assert rm._is_repeater_device({"device_role": "RoomServer"}) is True + + def test_name_repeater(self, rm): + assert rm._is_repeater_device({"adv_name": "My Repeater Node"}) is True + + def test_name_gateway(self, rm): + assert rm._is_repeater_device({"name": "MQTT Gateway"}) is True + + def test_companion_not_repeater(self, rm): + assert rm._is_repeater_device({"type": 1, "name": "Alice"}) is False + + def test_empty_data_not_repeater(self, rm): + assert rm._is_repeater_device({}) is False + + +# --------------------------------------------------------------------------- +# _is_companion_device +# --------------------------------------------------------------------------- + +class TestIsCompanionDevice: + """Tests for RepeaterManager._is_companion_device().""" + + def test_companion_type_1(self, rm): + assert rm._is_companion_device({"type": 1}) is True + + def test_repeater_type_2_not_companion(self, rm): + assert rm._is_companion_device({"type": 2}) is False + + def test_empty_is_companion(self, rm): + assert rm._is_companion_device({}) is True + + +# --------------------------------------------------------------------------- +# _is_in_acl +# --------------------------------------------------------------------------- + +class TestIsInAcl: + """Tests for RepeaterManager._is_in_acl().""" + + def test_no_acl_section_returns_false(self, rm): + assert rm._is_in_acl("deadbeef") is False + + def test_key_in_acl(self, rm): + rm.bot.config.add_section("Admin_ACL") + rm.bot.config.set("Admin_ACL", "admin_pubkeys", "deadbeef,cafebabe") + assert rm._is_in_acl("deadbeef") is True + + def test_key_not_in_acl(self, rm): + rm.bot.config.add_section("Admin_ACL") + rm.bot.config.set("Admin_ACL", "admin_pubkeys", "deadbeef") + assert rm._is_in_acl("cafebabe") is False + + def test_empty_acl_list_returns_false(self, rm): + rm.bot.config.add_section("Admin_ACL") + rm.bot.config.set("Admin_ACL", "admin_pubkeys", "") + assert rm._is_in_acl("deadbeef") is False + + def test_exact_match_required(self, rm): + """Partial key match should not succeed.""" + rm.bot.config.add_section("Admin_ACL") + rm.bot.config.set("Admin_ACL", "admin_pubkeys", "deadbeef00112233") + assert rm._is_in_acl("deadbeef") is False + + def test_auto_purge_disabled_by_default(self, rm): + assert rm.auto_purge_enabled is False + + def test_auto_purge_enabled_when_set(self, bot): + bot.config.set("Bot", "auto_manage_contacts", "device") + rm2 = RepeaterManager(bot) + assert rm2.auto_purge_enabled is True diff --git a/tests/test_transmission_tracker.py b/tests/test_transmission_tracker.py new file mode 100644 index 0000000..d305d67 --- /dev/null +++ b/tests/test_transmission_tracker.py @@ -0,0 +1,251 @@ +"""Tests for modules/transmission_tracker.py.""" + +import time +import pytest +from unittest.mock import Mock, MagicMock + +from modules.transmission_tracker import TransmissionRecord, TransmissionTracker + + +@pytest.fixture +def mock_bot(mock_logger): + """Minimal bot mock for TransmissionTracker.""" + bot = Mock() + bot.logger = mock_logger + bot.meshcore = None # No device connected + bot.prefix_hex_chars = 2 + return bot + + +@pytest.fixture +def tracker(mock_bot): + """TransmissionTracker instance with a mock bot.""" + return TransmissionTracker(mock_bot) + + +class TestTransmissionRecord: + """Tests for TransmissionRecord dataclass.""" + + def test_default_fields(self): + rec = TransmissionRecord( + timestamp=1234.0, + content="hello", + target="general", + message_type="channel", + ) + assert rec.repeat_count == 0 + assert rec.packet_hash is None + assert rec.command_id is None + assert rec.repeater_prefixes == set() + assert rec.repeater_counts == {} + + def test_custom_fields(self): + rec = TransmissionRecord( + timestamp=5678.0, + content="dm text", + target="Alice", + message_type="dm", + packet_hash="abcd1234", + command_id="cmd-001", + ) + assert rec.packet_hash == "abcd1234" + assert rec.command_id == "cmd-001" + + +class TestRecordTransmission: + """Tests for TransmissionTracker.record_transmission().""" + + def test_returns_transmission_record(self, tracker): + rec = tracker.record_transmission("hello", "general", "channel") + assert isinstance(rec, TransmissionRecord) + assert rec.content == "hello" + assert rec.target == "general" + assert rec.message_type == "channel" + + def test_stores_in_pending(self, tracker): + rec = tracker.record_transmission("msg", "ch", "channel") + key = int(rec.timestamp) + assert key in tracker.pending_transmissions + assert rec in tracker.pending_transmissions[key] + + def test_multiple_records_same_second(self, tracker): + rec1 = tracker.record_transmission("a", "ch", "channel") + rec2 = tracker.record_transmission("b", "ch", "channel") + key = int(rec1.timestamp) + # Both records should be in the same (or nearby) bucket + assert rec1 in tracker.pending_transmissions.get(int(rec1.timestamp), []) + assert rec2 in tracker.pending_transmissions.get(int(rec2.timestamp), []) + + def test_with_command_id(self, tracker): + rec = tracker.record_transmission("msg", "ch", "channel", command_id="cmd-42") + assert rec.command_id == "cmd-42" + + +class TestMatchPacketHash: + """Tests for TransmissionTracker.match_packet_hash().""" + + def test_null_hash_returns_none(self, tracker): + assert tracker.match_packet_hash("", time.time()) is None + assert tracker.match_packet_hash("0000000000000000", time.time()) is None + + def test_matches_pending_transmission(self, tracker): + rec = tracker.record_transmission("msg", "general", "channel") + result = tracker.match_packet_hash("deadbeef", rec.timestamp + 1) + assert result is not None + assert result.packet_hash == "deadbeef" + + def test_already_confirmed_returned_immediately(self, tracker): + rec = tracker.record_transmission("msg", "ch", "channel") + # First match confirms it + tracker.match_packet_hash("abc123", rec.timestamp) + # Second call returns same confirmed record + result2 = tracker.match_packet_hash("abc123", time.time()) + assert result2 is not None + assert result2.packet_hash == "abc123" + + def test_no_match_outside_window(self, tracker): + rec = tracker.record_transmission("msg", "ch", "channel") + # RF timestamp far in the future + result = tracker.match_packet_hash("deadbeef", rec.timestamp + 9999) + assert result is None + + +class TestRecordRepeat: + """Tests for TransmissionTracker.record_repeat().""" + + def test_null_hash_returns_false(self, tracker): + assert tracker.record_repeat("") is False + assert tracker.record_repeat("0000000000000000") is False + + def test_repeat_increments_count(self, tracker): + rec = tracker.record_transmission("msg", "ch", "channel") + # First confirm the hash + tracker.match_packet_hash("hash01", rec.timestamp) + # Now record a repeat + result = tracker.record_repeat("hash01", repeater_prefix="7e") + assert result is True + assert rec.repeat_count == 1 + assert "7e" in rec.repeater_prefixes + + def test_repeat_without_prefix(self, tracker): + rec = tracker.record_transmission("msg", "ch", "channel") + tracker.match_packet_hash("hash02", rec.timestamp) + result = tracker.record_repeat("hash02") + assert result is True + assert rec.repeat_count == 1 + assert rec.repeater_counts.get("_unknown") == 1 + + def test_multiple_repeats_same_repeater(self, tracker): + rec = tracker.record_transmission("msg", "ch", "channel") + tracker.match_packet_hash("hash03", rec.timestamp) + tracker.record_repeat("hash03", repeater_prefix="01") + tracker.record_repeat("hash03", repeater_prefix="01") + assert rec.repeat_count == 2 + assert rec.repeater_counts["01"] == 2 + + def test_unmatched_hash_returns_false(self, tracker): + result = tracker.record_repeat("nonexistent_hash") + assert result is False + + +class TestGetRepeatInfo: + """Tests for TransmissionTracker.get_repeat_info().""" + + def test_unknown_hash_returns_zeros(self, tracker): + info = tracker.get_repeat_info(packet_hash="unknown") + assert info["repeat_count"] == 0 + assert info["repeater_prefixes"] == [] + assert info["repeater_counts"] == {} + + def test_lookup_by_packet_hash(self, tracker): + rec = tracker.record_transmission("msg", "ch", "channel") + tracker.match_packet_hash("hashXX", rec.timestamp) + tracker.record_repeat("hashXX", repeater_prefix="7e") + info = tracker.get_repeat_info(packet_hash="hashXX") + assert info["repeat_count"] == 1 + assert "7e" in info["repeater_prefixes"] + + def test_lookup_by_command_id(self, tracker): + rec = tracker.record_transmission("msg", "ch", "channel", command_id="cmd-99") + tracker.match_packet_hash("hashYY", rec.timestamp) + tracker.record_repeat("hashYY", repeater_prefix="ab") + info = tracker.get_repeat_info(command_id="cmd-99") + assert info["repeat_count"] == 1 + assert "ab" in info["repeater_prefixes"] + + +class TestExtractRepeaterPrefixes: + """Tests for TransmissionTracker.extract_repeater_prefixes_from_path().""" + + def test_extracts_last_hop_from_path_string(self, tracker): + result = tracker.extract_repeater_prefixes_from_path("01,7e,86") + assert result == ["86"] + + def test_extracts_from_path_nodes(self, tracker): + result = tracker.extract_repeater_prefixes_from_path(None, path_nodes=["01", "7e", "86"]) + assert result == ["86"] + + def test_filters_own_prefix(self, tracker): + tracker.bot_prefix = "86" + result = tracker.extract_repeater_prefixes_from_path("01,7e,86") + assert result == [] + + def test_empty_path_returns_empty(self, tracker): + result = tracker.extract_repeater_prefixes_from_path(None) + assert result == [] + + def test_path_with_route_type_annotation(self, tracker): + result = tracker.extract_repeater_prefixes_from_path("01,7e,55 via ROUTE_TYPE_FLOOD") + assert result == ["55"] + + def test_single_node_path(self, tracker): + result = tracker.extract_repeater_prefixes_from_path("7e") + assert result == ["7e"] + + +class TestCleanupOldRecords: + """Tests for TransmissionTracker.cleanup_old_records().""" + + def test_removes_old_pending(self, tracker): + # Inject a record with an old timestamp + old_rec = TransmissionRecord( + timestamp=time.time() - 600, # 10 minutes ago (beyond cleanup_after=300) + content="old msg", + target="ch", + message_type="channel", + ) + old_key = int(old_rec.timestamp) + tracker.pending_transmissions[old_key] = [old_rec] + tracker.cleanup_old_records() + assert old_key not in tracker.pending_transmissions + + def test_keeps_recent_pending(self, tracker): + rec = tracker.record_transmission("recent", "ch", "channel") + key = int(rec.timestamp) + tracker.cleanup_old_records() + assert key in tracker.pending_transmissions + + def test_removes_old_confirmed_without_repeats(self, tracker): + old_rec = TransmissionRecord( + timestamp=time.time() - 600, + content="old", + target="ch", + message_type="channel", + packet_hash="stale_hash", + ) + tracker.confirmed_transmissions["stale_hash"] = old_rec + tracker.cleanup_old_records() + assert "stale_hash" not in tracker.confirmed_transmissions + + def test_keeps_old_confirmed_with_repeats(self, tracker): + old_rec = TransmissionRecord( + timestamp=time.time() - 600, + content="old", + target="ch", + message_type="channel", + packet_hash="repeat_hash", + repeat_count=3, + ) + tracker.confirmed_transmissions["repeat_hash"] = old_rec + tracker.cleanup_old_records() + assert "repeat_hash" in tracker.confirmed_transmissions