Files
meshcore-bot/TESTING.md
T
Stacy Olivas ce7adc55f8 ci: add log injection regression check to CI pipeline
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.
2026-04-14 10:02:36 -07:00

44 KiB
Raw Blame History

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_limiterRateLimiter 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_managerCommandManager 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_managerDBManager.

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 187600 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_loaderPluginLoader.

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.schedulerMessageScheduler 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_managerChannelManager 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.i18nTranslator 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_commandAnnouncementsCommand.

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_commandAuroraCommand.

Class What it covers
TestProbIndicator 0/50/100 bar chars, all values 0100
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_commandHelpCommand.

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_commandMoonCommand.

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_commandTraceCommand.

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_commandStatsCommand. 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_managerFeedManager 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_filtercensor() 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_validationvalidate_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, SeattlePortland 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_servicechannel_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 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
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_loaderServicePluginLoader. 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.modelsMeshMessage 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_trackerTransmissionRecord 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_handlerMessageHandler 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_managerRepeaterManager 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 1100, 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 = auto is set — write async 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: MagicMock for sync, AsyncMock for async methods.
  • Marks: Tag with @pytest.mark.unit or @pytest.mark.integration for 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

  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. 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).