Add scripts/check_log_injection.py to scan for unsanitized variables in log calls and fail CI if new violations are introduced. Baseline is committed at zero violations after fixing all 26 pre-existing ones. Update TESTING.md with instructions for running the check locally.
44 KiB
TESTING
Complete reference for the meshcore-bot test suite.
Quick Start
# First-time setup
make dev
# Full suite with coverage
make test
# Without coverage (faster)
make test-no-cov
# Specific file
.venv/bin/pytest tests/test_enums.py -v
# Specific class or function
.venv/bin/pytest tests/test_message_handler.py::TestShouldProcessMessage -v
.venv/bin/pytest tests/test_enums.py::TestPayloadType::test_lookup_by_value -v
# Stop on first failure
.venv/bin/pytest -x
Configuration
pytest.ini — controls pytest behaviour:
| Setting | Value |
|---|---|
testpaths |
tests |
asyncio_mode |
auto — async tests run without @pytest.mark.asyncio |
addopts |
-v --tb=short --strict-markers --cov=modules --cov-report=term-missing |
| Registered markers | unit, integration, slow, mqtt |
pyproject.toml — coverage settings ([tool.coverage.*]):
| Setting | Value |
|---|---|
source |
modules/ |
omit |
tests/, .venv/ |
fail_under |
35 — raised 2026-03-16; currently 36.72%; target 40% |
| (hardware-dependent modules cap realistic ceiling at ~40-42%) |
Running Subsets of Tests
pytest -m unit # unit tests only (fast, no real DB)
pytest -m integration # integration tests (real SQLite via tmp_path)
pytest -m "not slow" # skip slow tests
pytest tests/unit/ # tests in a subdirectory
pytest tests/commands/
pytest tests/integration/
pytest tests/regression/
pytest -x # stop on first failure
pytest --tb=long # full traceback
pytest --collect-only # list collected tests without running
Coverage
# Terminal report (default via pytest.ini)
pytest
# HTML report — open htmlcov/index.html in a browser
pytest --cov=modules --cov-report=html
# Coverage for a single module
pytest tests/test_message_handler.py \
--cov=modules.message_handler --cov-report=term-missing
Linting
All lint and type-check commands are available via the Makefile (preferred):
make lint # ruff check + mypy
make fix # auto-fix safe ruff issues
Or run directly:
.venv/bin/ruff check modules/ tests/ # style/lint check
.venv/bin/ruff check --fix modules/ tests/ # auto-fix safe issues
.venv/bin/mypy modules/ # type checking
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 core sections pre-populated |
command_mock_bot |
function | Lightweight mock bot; no DB or mesh graph |
command_mock_bot_with_db |
function | Same as above with mock db_manager |
test_config |
function | Full [Path_Command] + [Bot] config; Seattle coords |
test_db |
function | File-based DBManager at tmp_path with test tables |
mock_bot |
function | Mock bot with logger, config, DB, and prefix helpers |
mesh_graph |
function | MeshGraph (immediate write, no background thread) |
populated_mesh_graph |
function | MeshGraph pre-loaded with 7 test edges |
tests/helpers.py — Data factories
| Function | Returns |
|---|---|
create_test_repeater(prefix, ...) |
Dict matching complete_contact_tracking schema |
create_test_edge(from, to, ...) |
Dict matching MeshGraph edge structure |
create_test_path(node_ids, ...) |
Normalized list of node IDs |
populate_test_graph(graph, edges) |
Populates a MeshGraph with edge dicts |
mock_message() (helper in conftest)
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, timestamps |
TestPerUserRateLimiter |
Per-key tracking, LRU eviction, 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 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, scope, DM routing |
TestGetHelpForCommand |
Help text lookup for known/unknown commands |
TestInternetStatusCache |
Freshness check, stale detection, lock lazy-creation |
TestSendChannelMessageListeners |
Listener registration, invoke on success/skip on fail |
TestSendChannelMessagesChunked |
Empty chunks, single/multi-chunk timing, failure prop |
test_db_manager.py
Tests modules.db_manager — DBManager.
| Class | What it covers |
|---|---|
TestGeocoding |
Cache/retrieve geocoding, overwrite, invalid hours |
TestGenericCache |
JSON round-trip, miss returns default, key isolation |
TestCacheCleanup |
Expired rows deleted, valid rows preserved |
TestTableManagement |
Allowed/disallowed 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. Covers ., !, multi-char, whitespace, case sensitivity, and empty-prefix
edge cases — 14 test cases total.
test_plugin_loader.py
Tests modules.plugin_loader — PluginLoader.
| Class | What it covers |
|---|---|
TestDiscover |
Finds command files, excludes base and __init__ |
TestValidatePlugin |
Rejects missing/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, load from path, name-collision skip |
test_checkin_service.py
Tests local.service_plugins.checkin_service.CheckInService (auto-skipped if 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/chars |
TestGetCurrentTime |
Valid timezone, invalid fallback, empty timezone |
TestHasMeshInfoPlaceholders |
Detects {total_contacts}, {repeaters}; false case |
test_channel_manager_logic.py
Tests modules.channel_manager — ChannelManager pure logic.
| Class | What it covers |
|---|---|
TestGenerateHashtagKey |
Deterministic 16-byte key, # prefix, known SHA-256 |
TestChannelNameLookup |
Cache hit, fallback to "channel N" on miss |
TestChannelNumberLookup |
Found by name (case-insensitive), miss |
TestCacheManagement |
invalidate_cache() sets _cache_valid = False |
test_channel_manager.py
Expanded coverage of modules.channel_manager — 47 tests.
| Class | What it covers |
|---|---|
TestGenerateHashtagKey |
Prefix normalisation, case-folding, SHA-256 identity |
TestGetChannelName |
Cache hit, fallback label, missing field, ch-0 edge |
TestGetChannelNumber |
Index lookup, case-insensitive, not-found None |
TestGetChannelKey |
Returns hex, missing channel, missing key field |
TestGetChannelInfo |
Full dict shape, missing fallback, full cache entry |
TestGetChannelByName |
Found, case-insensitive, not found, empty cache |
TestGetConfiguredChannels |
Filters empty/whitespace names, missing field |
TestInvalidateCache |
Sets _cache_valid = False; does not clear data |
TestGetCachedChannels |
Sorted by index, empty cache, single-item |
TestAddChannelValidation |
Not connected, falsy meshcore, negative index, |
| index at/beyond max, missing key, invalid hex, | |
| wrong byte length, index-0 boundary |
test_i18n.py
Tests modules.i18n — Translator class. All tests use tmp_path-based JSON files.
| Class | What it covers |
|---|---|
TestExtractBaseLanguage |
Simple/hyphen/underscore locale parsing |
TestMergeTranslations |
Empty primary, override, recursive nested merge |
TestTranslatorWithRealFiles |
English fallback, missing key, kwargs, format error, |
| locale chain (en→es→es-MX), reload, invalid JSON, | |
| non-string, PermissionError, nested key miss, | |
| format KeyError, get_value fallback break |
test_announcements_command.py
Tests modules.commands.announcements_command — AnnouncementsCommand.
| Class | What it covers |
|---|---|
TestParseCommand |
No args, trigger-only, trigger+channel, all three |
TestRecordTrigger |
Sets cooldown, fresh not locked, old unlocked |
TestExecute |
No trigger, list, unknown trigger, cooldown/override, |
| successful/failed send, custom channel, exception |
test_aurora_command.py
Tests modules.commands.aurora_command — AuroraCommand.
| Class | What it covers |
|---|---|
TestProbIndicator |
0/50/100 bar chars, all values 0–100 |
TestFormatKpTime |
Empty/whitespace → dash, ISO formats, invalid → dash |
TestGetBotLocation |
Returns lat/lon from config, None when missing |
TestResolveLocation |
Coord parse, invalid lat/lon, bot fallback, error |
TestAuroraCanExecute |
Enabled/disabled |
TestResolveLocationExtended |
Companion DB location, default coords, ValueError |
TestAuroraExecute |
No location, bot location, KP G3/G2/G1/unsettled, |
| fetch exception, coords arg, response truncation |
test_help_command.py
Tests modules.commands.help_command — HelpCommand.
| Class | What it covers |
|---|---|
TestFormatCommandsListToLength |
No max, zero max, empty, truncation, suffix, negative |
TestIsCommandValidForChannel |
No message, allow/block, no attr, trigger checks |
TestGetSpecificHelp |
Known command, TypeError fallback, alias, no get_help |
TestCanExecute |
Enabled true/false |
TestGetHelpText |
Returns string |
TestGetGeneralHelp |
Returns string, includes commands.help key |
TestGetAvailableCommandsListFiltered |
Channel filter excludes invalid; no keyword_mappings |
TestFormatCommandsListSuffix |
Suffix fits within max, some fit |
TestExecute |
Returns True |
TestGetAvailableCommandsList |
Empty, with commands, max_length, message filter, |
| stats table present, DB exception fallback |
test_moon_command.py
Tests modules.commands.moon_command — MoonCommand.
| Class | What it covers |
|---|---|
TestTranslatePhaseName |
No translation, strips emoji, unknown, all 8 phases |
TestFormatMoonResponse |
Valid parsed, partial/empty/malformed fallback |
TestMoonCommandEnabled |
can_execute enabled/disabled |
TestGetHelpTextMoon |
Returns description |
TestFormatMoonPhaseNoAt |
Phase without @: sign, exception falls back |
TestTranslatePhaseNameFound |
Translation returned when key not found |
TestMoonExecute |
Success (mocked get_moon), error returns False |
test_trace_command.py
Tests modules.commands.trace_command — TraceCommand.
| Class | What it covers |
|---|---|
TestExtractPathFromMessage |
No path, Direct, zero hops, single/multi hop, |
| route type stripped, parentheses, invalid hex | |
TestParsePathArg |
No arg, comma-sep, contiguous hex, invalid, odd length |
TestFormatTraceInline |
Basic inline, no SNR |
TestFormatTraceVertical |
Basic two nodes, single node |
TestBuildReciprocalPath |
Empty, single, two-node, three-node |
TestMatchesKeyword |
trace/tracer/trace+path, !trace/!tracer, no-match |
TestCanExecuteTrace |
Enabled/disabled |
TestGetHelpTextTrace |
Returns string containing "trace" |
TestExtractPathEdgeCases |
3-char invalid length, 2-char non-hex |
TestParseBangPrefix |
!trace stripped |
TestFormatTraceResult |
Failed shows error, success inline/vertical |
TestFormatTraceVerticalThreeNodes |
Middle hop present, no SNR shows — |
TestTraceExecute |
No path sends error, not connected, no commands |
test_stats_command.py
Tests modules.commands.stats_command — StatsCommand. 66 tests.
| Class | What it covers |
|---|---|
TestIsValidPathFormat |
None/empty, hex+commas, continuous hex, single node |
TestFormatPathForDisplay |
None/empty → Direct, commas unchanged, chunked |
TestStatsCommandEnabled |
Enabled/disabled |
TestRecordMessage |
Inserts row, disabled, no track_all, anonymize |
TestRecordCommand |
Inserts row, disabled, no track_details, anonymize |
TestRecordPathStats |
Valid path, no/None hops, descriptive path skipped |
TestExecuteStats |
Disabled/enabled; all subcommands; !-prefix strip; |
| exception returns False | |
TestGetHelpText |
Returns string |
TestFormatPathEdgeCases |
hex_chars=0 uses default, legacy fallback |
TestRecordExceptionPaths |
record_message/command/path_stats exceptions |
TestGetBasicStatsWithData |
top_command/top_user format lines with real data |
TestGetUserLeaderboardWithData |
Long name truncation, exception returns error key |
TestGetChannelLeaderboardWithData |
Channel data, exception |
TestGetPathLeaderboardWithData |
Path data, exception |
TestGetAdvertsLeaderboard |
No table, empty, fallback daily_stats variants, |
| singular count, name/hash truncation, exception | |
TestCleanupOldStats |
Runs without error, exception handled |
TestGetStatsSummary |
Returns dict with 4 keys, exception returns empty |
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; hate symbols |
TestProfanityFilterWithLibrary |
(skipped if absent) censoring, homoglyph detect |
TestProfanityFilterFallbackWhenLibrary |
Graceful degradation, hate symbols, one warning |
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, typo detection |
TestPathValidation |
Non-existent parent, relative resolved, non-writable |
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, Seattle–Portland known distance |
TestFormatElapsedDisplay |
None/unknown/invalid, recent, future, translator |
TestDecodePathLenByte |
1/2/3 bytes-per-hop, size code, fallback |
TestParsePathString |
Comma/space/continuous hex, hop suffix, legacy |
TestCalculatePacketHashPathLength |
Single/multi-byte hashes, different sizes |
TestMultiBytePathDisplayContract |
Format contract for 1-byte and 2-byte nodes |
TestIsValidTimezone |
Valid IANA zones, invalid, empty, whitespace |
TestGetConfigTimezone |
Valid returned, invalid→UTC, empty→UTC, warning |
TestFormatLocationForDisplay |
None/empty, city-only, city+state, max_length |
TestGetMajorCityQueries |
Known city, unknown city, case-insensitive |
TestResolvePath |
Absolute unchanged, relative to base_dir, "." |
TestCheckInternetConnectivity |
True on socket, False all fail, HTTP fallback |
TestCalculatePathDistances |
Empty/direct, no db_manager, single/two nodes |
TestFormatKeywordResponseWithPlaceholders |
{sender}, {hops_label}, {connection_info}, |
{total_contacts}, defaults, bad placeholder |
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 whenbridge_bot_responses = truestop()unregisters the listenerstart()does NOT register whenbridge_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 |
TestValidateSafePath |
Relative resolution, path traversal rejection |
TestValidateExternalUrl |
file:// rejected, http(s) allowed, localhost |
TestSanitizeInput |
Max-length truncation, control char stripping |
TestValidateApiKeyFormat |
Valid key, too-short, 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 key), and
name-collision skipping.
test_enums.py
Tests modules.enums — all enum and flag types.
| Class | What it covers |
|---|---|
TestAdvertFlags |
Type/feature flag values, legacy aliases, | combo |
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 None |
TestMeshMessageConstruction |
Channel msg, DM, routing_info dict, 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, pending dict, multiple, command_id |
TestMatchPacketHash |
Null/zero→None, matches pending, confirmed, timeout |
TestRecordRepeat |
Null hash, increment, _unknown key, multiple |
TestGetRepeatInfo |
Unknown hash, by packet_hash, by command_id |
TestExtractRepeaterPrefixes |
Path last hop, path_nodes, own-prefix filter, via |
TestCleanupOldRecords |
Removes old pending, keeps recent/confirmed+repeats |
test_message_handler.py
Tests modules.message_handler — MessageHandler pure logic.
| Class | What it covers |
|---|---|
TestIsOldCachedMessage |
No connection time, None/unknown/0/negative/future, |
| old vs. recent, invalid string | |
TestPathBytesToNodes |
1/2-byte-per-hop, remainder fallback, empty, zero |
TestPathHexToNodes |
2/4-char chunks, empty/short, remainder fallback |
TestFormatPathString |
Empty→Direct, legacy, bytes_per_hop 1/2, None, invalid |
TestGetRouteTypeName |
All 4 known types, unknown type |
TestGetPayloadTypeName |
Known types, unknown type |
TestShouldProcessMessage |
Bot disabled, banned, monitored/unmonitored, DM on/off |
TestCleanupStaleCacheEntries |
Removes old timestamp/pubkey/rf entries, skip interval |
test_repeater_manager.py
Tests modules.repeater_manager — RepeaterManager pure logic. Uses a real test DB.
| Class | What it covers |
|---|---|
TestDetermineContactRole |
mode priority, device type fallback, name patterns |
| (rpt, roomserver, sensor, bot, gateway) | |
TestDetermineDeviceType |
advert_data.mode priority, numeric codes, name-based |
TestIsRepeaterDevice |
Type 2/3, role fields, name patterns, companion→False |
TestIsCompanionDevice |
Type 1→True, type 2→False, empty data→True |
TestIsInAcl |
No section, key present/absent, empty list, exact-only |
test_core.py
Tests modules.core.MeshCoreBot — config, radio settings, reload, key helpers.
Instantiates a real MeshCoreBot from temp config files.
| Class | What it covers |
|---|---|
TestBotRoot |
bot_root returns config file directory |
TestGetRadioSettings |
Returns dict with all keys, reads from config |
TestReloadConfig |
Success same settings, fail changed port, missing file |
TestKeyPrefixHelpers |
key_prefix() truncates, is_valid_prefix() length |
test_web_viewer.py
Tests modules.web_viewer.app — Flask routes and SocketIO handlers. 224 tests total.
| Class | What it covers |
|---|---|
TestWebViewerAuth |
Password-protected and open endpoints, sessions |
TestApiRoutes |
/api/contacts, /api/stats, maintenance, radio |
TestChannelRoutes |
GET/POST /api/channels, add/update/delete ops |
TestUpdateChannelRoute |
PUT /api/channels/<n> — name/key/number; 400 on bad |
TestCreateChannelValidation |
Index bounds, hex key length, missing fields |
TestChannelValidateRoute |
POST /api/channels/validate — valid/invalid combos |
TestStreamDataTypes |
SocketIO type-filter; data-type on entries |
TestMaintenanceStatusFields |
/api/maintenance/status schema — all fields present |
TestDbPathResolutionFromConfigDir |
BUG-029: db_path resolves relative to config dir; |
absolute unchanged; startup log via _setup_logging |
test_mqtt_live.py
Tests MeshCore MQTT packet parsing — schema validation against live and fixture packets.
Config: tests/mqtt_test_config.ini. Fixtures: tests/fixtures/mqtt_packets.json.
| Class | Marker | What it covers |
|---|---|---|
TestPacketSchemaValidation |
(always) | Required keys, valid direction/route/type values, |
| rx-only SNR/RSSI/hash, tx allowed without RF fields | ||
TestFixturePackets |
(always) | Loads mqtt_packets.json (skip if absent); |
| validates schema, timestamp/type/route ranges | ||
TestLiveMqttPackets |
mqtt | Connects to LAN broker; validates schema, |
| SNR/RSSI ranges, plausibility; auto-saves fixtures |
Run live tests: pytest tests/test_mqtt_live.py -v -m mqtt
Collect fixtures offline: python tests/test_mqtt_live.py --collect-fixtures
Command tests (tests/commands/)
| File | Command | Key scenarios |
|---|---|---|
test_base_command.py |
BaseCommand |
config_section_name, channel_allowed, |
get_config_value legacy migration, 7 command types |
||
test_help_command.py |
HelpCommand |
Enabled/disabled, async execute |
test_cmd_command.py |
CmdCommand |
Command list building, truncation with (N more) |
test_ping_command.py |
PingCommand |
Keyword response, enabled/disabled |
test_dice_command.py |
DiceCommand |
d20, 2d6, decade, mixed notation, default d6 |
test_hello_command.py |
HelloCommand |
Emoji-only detection, time-seeded greeting, execute |
test_magic8_command.py |
Magic8Command |
Valid 🎱 response, sender mention in channel |
test_roll_command.py |
RollCommand |
Parse notation, keyword match, default 1–100, max |
Unit tests (tests/unit/)
test_mesh_graph.py and test_mesh_graph_*.py
Six files providing comprehensive unit coverage of modules.mesh_graph:
| File | Focus |
|---|---|
test_mesh_graph.py |
Edge management, prefix, path validation, scoring, |
| multi-hop, persistence | |
test_mesh_graph_scoring.py |
get_candidate_score(): prev/next edge, bidirectional, |
| hop-position match, tolerance, disable flags | |
test_mesh_graph_edges.py |
Add/update/get/has, key merging, 1→2→3 byte promotion |
test_mesh_graph_multihop.py |
find_intermediate_nodes(): 2/3-hop, no path, |
| min observations, bidirectional, multi-candidate | |
test_mesh_graph_validation.py |
validate_path_segment/path(): confidence, recency, |
| bidirectional, empty/single path edge cases | |
test_mesh_graph_optimizations.py |
Adjacency indexes, key interning, edge expiration, |
pruning, notification throttle, capture_enabled |
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.
Each scenario uses mock_bot, mesh_graph, and helper factories.
All methods use @pytest.mark.integration.
| File | Scenarios |
|---|---|
test_path_graph_integration.py |
Graph resolution, prefix disambiguation, starred/ |
| stored-key priority, 2-hop inference, persistence, | |
| 5-node real-world scenario | |
test_path_resolution.py |
Same scenarios plus sync graph validation, |
| geographic vs. graph selection, direct SQLite inserts |
Regression tests (tests/regression/)
test_keyword_escapes.py
Regression guard for modules.utils.decode_escape_sequences. Verifies \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 = autois set — writeasync def test_...without the mark. - Fixtures: Prefer conftest fixtures (
mock_logger,mock_bot,test_db,minimal_config). - Factories: Use
create_test_repeater(),create_test_edge(),mock_message(). - Database: Use
tmp_path(file-based SQLite) to avoid cross-connection isolation issues. - Mocking:
MagicMockfor sync,AsyncMockfor async methods. - Marks: Tag with
@pytest.mark.unitor@pytest.mark.integrationfor filtering.
Example skeleton
"""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
- Create
tests/test_<module_name>.py. - Add a local fixture that constructs the class under test with mocked dependencies.
- Start with pure-logic methods (no network, no DB) — these are fastest to write and run.
- Add integration tests (with
test_db) for database-touching methods. - Check coverage gaps:
pytest tests/test_<module_name>.py \ --cov=modules.<module_name> --cov-report=term-missing
MQTT Test Framework
Live and offline tests for MeshCore packet parsing using real broker data.
Architecture
tests/
mqtt_test_config.ini # broker / topic / timeout settings
test_mqtt_live.py # schema + fixture + live test classes
fixtures/
mqtt_packets.json # pre-collected packets (auto-refreshed on live run)
Running
# Offline schema + fixture tests (no network required)
pytest tests/test_mqtt_live.py -v -m "not mqtt"
# Live integration tests (requires LAN broker at 10.0.2.123:1883)
pytest tests/test_mqtt_live.py -v -m mqtt
# Collect fresh fixtures and exit
python tests/test_mqtt_live.py --collect-fixtures
Broker configuration (tests/mqtt_test_config.ini)
| Key | Default | Notes |
|---|---|---|
broker |
10.0.2.123 |
LAN MQTT broker (plain TCP, no auth) |
port |
1883 |
— |
transport |
tcp |
Use websockets for letsmesh TLS broker |
topic_subscribe |
meshcore/SEA/+/packets |
+ wildcard matches any station |
timeout_seconds |
15 |
Seconds to wait for packets |
max_packets |
10 |
Collection limit |
letsmesh alternative (commented out): mqtt-us-v1.letsmesh.net:443 WebSocket/TLS — requires
JWT auth; not suitable for anonymous CI runs.
Packet schema
| Field | rx | tx | Notes |
|---|---|---|---|
origin |
✓ | ✓ | Sender display name |
origin_id |
✓ | ✓ | 64-char hex pubkey |
timestamp |
✓ | ✓ | Unix epoch (int or float) |
type |
✓ | ✓ | Always "PACKET" |
direction |
✓ | ✓ | "rx" or "tx" |
packet_type |
✓ | ✓ | "0"–"15" string |
route |
✓ | ✓ | "F" flood / "D" direct / "T" tunnel / "U" unknown |
SNR |
✓ | ✗ | String float, −200…+30 dB |
RSSI |
✓ | ✗ | String int, −200…0 dBm |
hash |
✓ | ✗ | 16-char uppercase hex |
Fixture auto-refresh
test_received_at_least_one_packet (live test) calls _save_fixture_packets() after each
successful run — so mqtt_packets.json is updated automatically whenever live tests pass.
CI Integration
Tests run automatically on push/PR via GitHub Actions.
| Job | Command |
|---|---|
lint |
ruff check modules/ tests/ |
typecheck |
mypy modules/ |
lint-frontend |
ESLint + HTMLHint on modules/web_viewer/templates/ |
lint-shell |
ShellCheck --severity=warning on all .sh files |
test |
pytest tests/ -v --tb=short with coverage (no mqtt) |
To keep TODO.md in sync locally:
python scripts/update_todos.py
Or wire it up as a pre-commit hook (see TODO.md → Auto-Update section).