infra: initial test suite and project tracking files

Test modules:
- test_enums: enum values and flag combinations
- test_models: MeshMessage dataclass field and type validation
- test_transmission_tracker: full TransmissionTracker coverage
- test_message_handler: path parsing, RF correlation, message routing
- test_repeater_manager: role detection, ACL, device type classification
- test_core: config loading, radio settings, reload paths

Tracking files:
- BUGS.md: known bugs and fix history log
- TESTING.md: test strategy, coverage targets, and how-to guide
- TODO.md: feature and task backlog with completion status
- scripts/update_todos.py: scans source for # TODO/FIXME/HACK markers
  and regenerates the Inline TODOs section in TODO.md
This commit is contained in:
Stacy Olivas committed 2026-03-17 18:07:18 -07:00
1 parent ba32accc44
commit 9de9230c2b
10 files changed
+2080

No files matched your search

+70
View File
@@ -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
+550
View File
@@ -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_<module_name>.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_<module_name>.py --cov=modules.<module_name> --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).
+123
View File
@@ -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._
+152
View File
@@ -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:
<!-- inline-todos-start -->
<!-- inline-todos-end -->
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()
+187
View File
@@ -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
+108
View File
@@ -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
+329
View File
@@ -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
+93
View File
@@ -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
+217
View File
@@ -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
+251
View File
@@ -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