Files
meshcore-bot/tests/commands/test_neighbors_command.py
agessaman 34bfe807f7 refactor(neighbors): improve IATA handling and cycle management
Enhance the handling of the global IATA configuration to ensure that blank values are treated correctly, preventing unintended namespace pollution. Introduce methods for claiming and releasing the neighbors discovery cycle, ensuring that overlapping requests are managed effectively. Update the NeighborsCommand to utilize these new methods, improving the accuracy of cooldown management for users. Additionally, add tests to validate the new behavior and ensure that cooldowns are respected during busy and disabled states.
2026-08-06 11:04:15 -07:00

385 lines
15 KiB
Python

"""Tests for the neighbors command (modules/commands/neighbors_command.py)."""
from __future__ import annotations
import asyncio
import time
import types
import pytest
from modules.commands.neighbors_command import NeighborsCommand
from modules.neighbors_discovery import MIN_CYCLE_GAP_SECONDS
from modules.service_plugins.packet_capture_service import PacketCaptureService
from tests.conftest import mock_message
def make_service(*, neighbors_enabled=True, summary=None, hang=False,
raises=None, cycle_budget=5.0, last_publish=0.0,
last_attempt=0.0, cycle_active=False):
"""A stand-in for PacketCaptureService's neighbors surface."""
service = types.SimpleNamespace()
service.neighbors_enabled = neighbors_enabled
service.neighbors_config = types.SimpleNamespace(
discover_window=60.0, cycle_budget=cycle_budget
)
# 0.0 == "no cycle has ever run", so nothing is on cooldown.
service.last_neighbors_publish = last_publish
service.last_neighbors_attempt = last_attempt
service.neighbors_cycle_active = cycle_active
# The node-wide cooldown is the service's rule; mirror its arithmetic here so
# the command is tested against the same contract the real service offers.
service.neighbors_cooldown_remaining = lambda: PacketCaptureService.\
neighbors_cooldown_remaining(service)
service.calls = 0
def claim_neighbors_cycle():
if service.neighbors_cycle_active:
return "a discovery cycle is already running"
remaining = service.neighbors_cooldown_remaining()
if remaining > 0:
return f"another cycle may run in {remaining:.0f}s"
service.neighbors_cycle_active = True
return None
def release_neighbors_cycle():
service.neighbors_cycle_active = False
async def run_cycle(*, already_claimed=False):
if not already_claimed:
reason = claim_neighbors_cycle()
if reason is not None:
return {
"ok": False, "reason": reason, "discovered": 0, "queried": 0,
"best_snr": None, "attempted": 0, "succeeded": 0, "recorded": 0,
}
service.calls += 1
try:
if hang:
await asyncio.sleep(30)
if raises is not None:
raise raises
return summary or {"ok": True, "queried": 0, "recorded": 0, "attempted": 0}
finally:
release_neighbors_cycle()
service.claim_neighbors_cycle = claim_neighbors_cycle
service.release_neighbors_cycle = release_neighbors_cycle
service.run_neighbors_cycle = run_cycle
return service
def make_command(command_mock_bot, service, *, enabled=True):
"""Build the command without __init__ (which reads config and the ACL)."""
command_mock_bot.packet_capture_service = service
command = object.__new__(NeighborsCommand)
command.bot = command_mock_bot
command.logger = command_mock_bot.logger
command.command_enabled = enabled
command._cycle_task = None
# Normally set up by BaseCommand.__init__, which make_command skips.
command._user_cooldowns = {}
command.cooldown_seconds = NeighborsCommand.cooldown_seconds
sent: list[str] = []
async def send_response(message, content, **kwargs):
sent.append(content)
return True
command.send_response = send_response
command.translate = lambda key, **kwargs: (
key.split(".")[-1] + (" " + " ".join(f"{k}={v}" for k, v in sorted(kwargs.items()))
if kwargs else "")
)
return command, sent
@pytest.fixture
def message():
return mock_message(content="neighbors", is_dm=True, sender_id="TestUser")
def test_command_metadata():
# A cycle spends airtime and takes a minute, so it is DM-only with a cooldown.
assert NeighborsCommand.requires_dm is True
assert NeighborsCommand.cooldown_seconds == 900
assert "neighbours" in NeighborsCommand.keywords
async def test_reports_when_discovery_is_disabled(command_mock_bot, message):
command, sent = make_command(command_mock_bot, make_service(neighbors_enabled=False))
assert await command.execute(message) is True
assert sent == ["disabled"]
async def test_reports_when_the_service_is_absent(command_mock_bot, message):
command, sent = make_command(command_mock_bot, None)
command_mock_bot.services = {}
assert await command.execute(message) is True
assert sent == ["disabled"]
async def test_disabled_refusal_does_not_burn_the_senders_own_cooldown(
command_mock_bot, message
):
"""Enabling the feature a minute later must not still be blocked for 14 more."""
command, sent = make_command(
command_mock_bot, make_service(neighbors_enabled=False)
)
command.record_execution(message.sender_id)
await command.execute(message)
assert sent == ["disabled"]
can_execute, remaining = command.check_cooldown(message.sender_id)
assert can_execute is False
assert remaining == pytest.approx(60, abs=5)
async def test_acknowledges_before_running_the_cycle(command_mock_bot, message):
"""The listen window is ~60s, far too long to hold the reply open."""
service = make_service(summary={"ok": True, "queried": 2, "best_snr": 8.0,
"recorded": 2, "attempted": 0})
command, sent = make_command(command_mock_bot, service)
assert await command.execute(message) is True
assert sent == ["started seconds=60"]
assert service.calls == 0 # not awaited inline
# Claimed before the ack so the scheduler cannot sneak in during send_response.
assert service.neighbors_cycle_active is True
await command._cycle_task
assert len(sent) == 2
assert sent[1].startswith("success")
assert service.neighbors_cycle_active is False
async def test_claim_before_ack_survives_a_scheduler_race(command_mock_bot, message):
"""If another trigger starts during send_response, we already hold the lock."""
service = make_service(summary={"ok": True, "queried": 0, "recorded": 0, "attempted": 0})
command, sent = make_command(command_mock_bot, service)
raced = {}
async def send_and_race(message, content, **kwargs):
sent.append(content)
if content.startswith("started"):
# Stands in for the scheduler waking during the ack await.
raced["summary"] = await service.run_neighbors_cycle()
return True
command.send_response = send_and_race
await command.execute(message)
await command._cycle_task
assert sent[0].startswith("started")
assert raced["summary"]["ok"] is False
assert "already running" in raced["summary"]["reason"]
assert service.calls == 1
async def test_summary_reports_count_snr_and_records(command_mock_bot, message):
service = make_service(summary={"ok": True, "queried": 3, "best_snr": 8.25,
"recorded": 3, "attempted": 0})
command, sent = make_command(command_mock_bot, service)
await command.execute(message)
await command._cycle_task
assert "count=3" in sent[1]
assert "recorded=3" in sent[1]
assert "best_snr=8.2dB" in sent[1]
async def test_summary_mentions_brokers_only_when_one_was_tried(command_mock_bot, message):
"""A operator with no MQTT should not be shown a confusing 0/0."""
without = make_service(summary={"ok": True, "queried": 1, "best_snr": 1.0,
"recorded": 1, "attempted": 0})
command, sent = make_command(command_mock_bot, without)
await command.execute(message)
await command._cycle_task
assert "published" not in sent[1]
with_broker = make_service(summary={"ok": True, "queried": 1, "best_snr": 1.0,
"recorded": 1, "attempted": 2, "succeeded": 1})
command2, sent2 = make_command(command_mock_bot, with_broker)
await command2.execute(message)
await command2._cycle_task
assert "published" in sent2[1]
assert "succeeded=1" in sent2[1]
assert "attempted=2" in sent2[1]
async def test_reports_when_nothing_answered(command_mock_bot, message):
service = make_service(summary={"ok": True, "queried": 0, "recorded": 0, "attempted": 0})
command, sent = make_command(command_mock_bot, service)
await command.execute(message)
await command._cycle_task
assert sent[1] == "none"
async def test_reports_the_reason_a_cycle_did_not_run(command_mock_bot, message):
service = make_service(summary={"ok": False, "reason": "radio not connected"})
command, sent = make_command(command_mock_bot, service)
await command.execute(message)
await command._cycle_task
assert sent[1] == "failed reason=radio not connected"
async def test_missing_snr_does_not_break_the_summary(command_mock_bot, message):
service = make_service(summary={"ok": True, "queried": 1, "best_snr": None,
"recorded": 1, "attempted": 0})
command, sent = make_command(command_mock_bot, service)
await command.execute(message)
await command._cycle_task
assert "best_snr=n/a" in sent[1]
async def test_cycle_errors_are_reported(command_mock_bot, message):
service = make_service(raises=RuntimeError("radio exploded"))
command, sent = make_command(command_mock_bot, service)
await command.execute(message)
await command._cycle_task
assert sent[1].startswith("error")
assert "radio exploded" in sent[1]
async def test_a_stalled_cycle_is_abandoned(command_mock_bot, message):
service = make_service(hang=True, cycle_budget=0.05)
command, sent = make_command(command_mock_bot, service)
await command.execute(message)
await command._cycle_task
assert sent[1].startswith("error")
assert "timed out" in sent[1]
async def test_a_second_request_will_not_overlap_the_first(command_mock_bot, message):
"""Two concurrent discover rounds would collect into each other's window."""
service = make_service(hang=True, cycle_budget=5.0)
command, sent = make_command(command_mock_bot, service)
await command.execute(message)
await command.execute(message)
assert sent == ["started seconds=60", "busy"]
command._cycle_task.cancel()
with pytest.raises(asyncio.CancelledError):
await command._cycle_task
async def test_busy_refusal_does_not_burn_the_senders_own_cooldown(
command_mock_bot, message
):
"""A mid-cycle 'busy' clears with the discover window, not after 15 minutes."""
service = make_service(cycle_active=True)
command, sent = make_command(command_mock_bot, service)
command.record_execution(message.sender_id)
await command.execute(message)
assert sent == ["busy"]
can_execute, remaining = command.check_cooldown(message.sender_id)
assert can_execute is False
assert remaining == pytest.approx(60, abs=5)
async def test_a_finished_cycle_does_not_block_the_next_request(command_mock_bot, message):
service = make_service(summary={"ok": True, "queried": 0, "recorded": 0, "attempted": 0})
command, sent = make_command(command_mock_bot, service)
await command.execute(message)
await command._cycle_task
await command.execute(message)
await command._cycle_task
assert service.calls == 2
assert "busy" not in sent
async def test_a_cycle_the_service_is_running_reads_as_busy(command_mock_bot, message):
"""The scheduler's cycle is invisible to this command's own task guard."""
service = make_service(cycle_active=True)
command, sent = make_command(command_mock_bot, service)
assert await command.execute(message) is True
assert sent == ["busy"]
assert service.calls == 0
async def test_the_cooldown_applies_across_senders(command_mock_bot):
"""The base class rations per user; airtime has to be rationed per node.
Otherwise N users can take turns and keep the radio discovering nonstop.
"""
service = make_service(last_publish=time.time() - 60)
command, sent = make_command(command_mock_bot, service)
for sender in ("UserOne", "UserTwo"):
result = await command.execute(
mock_message(content="neighbors", is_dm=True, sender_id=sender)
)
assert result is True
assert sent == ["cooldown_active minutes=14"] * 2
assert service.calls == 0
assert command._cycle_task is None
async def test_a_failed_cycle_still_starts_the_cooldown(command_mock_bot):
"""The discover broadcast may have gone out even when the cycle failed, so a
second sender must not be able to transmit another round immediately."""
service = make_service(last_publish=0.0, last_attempt=time.time() - 60)
command, sent = make_command(command_mock_bot, service)
result = await command.execute(
mock_message(content="neighbors", is_dm=True, sender_id="SomeoneElse")
)
assert result is True
assert sent == ["cooldown_active minutes=14"]
assert service.calls == 0
async def test_a_refusal_does_not_burn_the_senders_own_cooldown(command_mock_bot, message):
"""The command manager records the execution before calling execute().
So a request refused for the shared cooldown has already spent this sender's
15 minutes. Telling them to wait one minute and then refusing for fourteen
more — on their personal cooldown this time — would make the reply a lie.
"""
service = make_service(last_attempt=time.time() - 840) # 60s left
command, sent = make_command(command_mock_bot, service)
command.record_execution(message.sender_id) # what the manager does first
await command.execute(message)
assert sent == ["cooldown_active minutes=1"]
can_execute, remaining = command.check_cooldown(message.sender_id)
assert can_execute is False
# Expires with the shared cooldown, not 15 minutes from the refusal.
assert remaining == pytest.approx(60, abs=5)
async def test_a_refusal_still_blocks_an_immediate_retry(command_mock_bot, message):
"""Rewound, not cleared: a refusal reply is airtime too."""
service = make_service(last_attempt=time.time() - 60)
command, _ = make_command(command_mock_bot, service)
command.record_execution(message.sender_id)
await command.execute(message)
assert command.check_cooldown(message.sender_id)[0] is False
async def test_the_cooldown_expires(command_mock_bot, message):
service = make_service(last_publish=time.time() - (MIN_CYCLE_GAP_SECONDS + 1),
summary={"ok": True, "queried": 0, "recorded": 0, "attempted": 0})
command, sent = make_command(command_mock_bot, service)
await command.execute(message)
assert sent[0] == "started seconds=60"
await command._cycle_task
assert service.calls == 1
def test_disabled_command_cannot_execute(command_mock_bot, message):
command, _ = make_command(command_mock_bot, make_service(), enabled=False)
assert command.can_execute(message) is False