mirror of
https://github.com/agessaman/meshcore-bot.git
synced 2026-08-27 21:10:13 +00:00
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.
385 lines
15 KiB
Python
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
|