mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-08 17:53:10 +02:00
Outgoing WS now echoes, websock reclamation after unmount cleanup, hash fix for empty contacts, no double bot broadcast, AGENTS.md + test fixes (this should have been more than one commit lol)
This commit is contained in:
@@ -92,6 +92,112 @@ class TestMessagesEndpoint:
|
||||
|
||||
assert response.status_code == 503
|
||||
|
||||
def test_send_direct_message_emits_websocket_message_event(self):
|
||||
"""POST /messages/direct should emit a WS message event for other clients."""
|
||||
from fastapi.testclient import TestClient
|
||||
from meshcore import EventType
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix.return_value = {"public_key": "ab" * 32}
|
||||
mock_mc.commands.add_contact = AsyncMock(
|
||||
return_value=MagicMock(type=EventType.OK, payload={})
|
||||
)
|
||||
mock_mc.commands.send_msg = AsyncMock(
|
||||
return_value=MagicMock(type=EventType.MSG_SENT, payload={})
|
||||
)
|
||||
|
||||
mock_contact = MagicMock()
|
||||
mock_contact.public_key = "ab" * 32
|
||||
mock_contact.to_radio_dict.return_value = {"public_key": "ab" * 32}
|
||||
|
||||
def _capture_task(coro):
|
||||
coro.close()
|
||||
return MagicMock()
|
||||
|
||||
with (
|
||||
patch("app.dependencies.radio_manager") as mock_rm,
|
||||
patch(
|
||||
"app.repository.ContactRepository.get_by_key_or_prefix",
|
||||
new=AsyncMock(return_value=mock_contact),
|
||||
),
|
||||
patch("app.repository.ContactRepository.update_last_contacted", new=AsyncMock()),
|
||||
patch("app.repository.MessageRepository.create", new=AsyncMock(return_value=123)),
|
||||
patch("app.bot.run_bot_for_message", new=AsyncMock()),
|
||||
patch("app.routers.messages.asyncio.create_task", side_effect=_capture_task),
|
||||
patch("app.routers.messages.broadcast_event", create=True) as mock_broadcast,
|
||||
):
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.meshcore = mock_mc
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/messages/direct",
|
||||
json={"destination": mock_contact.public_key, "text": "Hello"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_broadcast.assert_called_once()
|
||||
event_type, payload = mock_broadcast.call_args.args
|
||||
assert event_type == "message"
|
||||
assert payload["id"] == 123
|
||||
assert payload["type"] == "PRIV"
|
||||
|
||||
def test_send_channel_message_emits_websocket_message_event(self):
|
||||
"""POST /messages/channel should emit a WS message event for other clients."""
|
||||
from fastapi.testclient import TestClient
|
||||
from meshcore import EventType
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.self_info = {"name": "TestNode"}
|
||||
ok_result = MagicMock(type=EventType.MSG_SENT, payload={})
|
||||
mock_mc.commands.set_channel = AsyncMock(return_value=ok_result)
|
||||
mock_mc.commands.send_chan_msg = AsyncMock(return_value=ok_result)
|
||||
|
||||
mock_channel = MagicMock()
|
||||
mock_channel.name = "Public"
|
||||
mock_channel.key = "AA" * 16
|
||||
|
||||
def _capture_task(coro):
|
||||
coro.close()
|
||||
return MagicMock()
|
||||
|
||||
with (
|
||||
patch("app.dependencies.radio_manager") as mock_rm,
|
||||
patch(
|
||||
"app.repository.ChannelRepository.get_by_key",
|
||||
new=AsyncMock(return_value=mock_channel),
|
||||
),
|
||||
patch(
|
||||
"app.repository.AppSettingsRepository.get",
|
||||
new=AsyncMock(return_value=MagicMock(experimental_channel_double_send=False)),
|
||||
),
|
||||
patch("app.repository.MessageRepository.create", new=AsyncMock(return_value=456)),
|
||||
patch("app.repository.MessageRepository.get_ack_count", new=AsyncMock(return_value=0)),
|
||||
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
|
||||
patch("app.bot.run_bot_for_message", new=AsyncMock()),
|
||||
patch("app.routers.messages.asyncio.create_task", side_effect=_capture_task),
|
||||
patch("app.routers.messages.broadcast_event", create=True) as mock_broadcast,
|
||||
):
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.meshcore = mock_mc
|
||||
|
||||
from app.main import app
|
||||
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/messages/channel",
|
||||
json={"channel_key": mock_channel.key, "text": "Hello room"},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
mock_broadcast.assert_called_once()
|
||||
event_type, payload = mock_broadcast.call_args.args
|
||||
assert event_type == "message"
|
||||
assert payload["id"] == 456
|
||||
assert payload["type"] == "CHAN"
|
||||
|
||||
def test_send_direct_message_contact_not_found(self):
|
||||
"""Sending to unknown contact returns 404."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
@@ -16,6 +16,7 @@ from app.event_handlers import (
|
||||
register_event_handlers,
|
||||
track_pending_ack,
|
||||
)
|
||||
from app.repository import AmbiguousPublicKeyPrefixError
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
@@ -306,6 +307,45 @@ class TestContactMessageCLIFiltering:
|
||||
# SHOULD still be processed (defaults to txt_type=0)
|
||||
mock_repo.create.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ambiguous_prefix_stores_dm_under_prefix(self):
|
||||
"""Ambiguous sender prefixes should still be stored under the prefix key."""
|
||||
from app.event_handlers import on_contact_message
|
||||
|
||||
with (
|
||||
patch("app.event_handlers.MessageRepository") as mock_repo,
|
||||
patch("app.event_handlers.ContactRepository") as mock_contact_repo,
|
||||
patch("app.event_handlers.broadcast_event") as mock_broadcast,
|
||||
patch("app.bot.run_bot_for_message", new_callable=AsyncMock),
|
||||
):
|
||||
mock_repo.create = AsyncMock(return_value=77)
|
||||
mock_contact_repo.get_by_key_or_prefix = AsyncMock(
|
||||
side_effect=AmbiguousPublicKeyPrefixError(
|
||||
"abc123",
|
||||
[
|
||||
"abc1230000000000000000000000000000000000000000000000000000000000",
|
||||
"abc123ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff",
|
||||
],
|
||||
)
|
||||
)
|
||||
|
||||
class MockEvent:
|
||||
payload = {
|
||||
"pubkey_prefix": "abc123",
|
||||
"text": "hello from ambiguous prefix",
|
||||
"txt_type": 0,
|
||||
"sender_timestamp": 1700000000,
|
||||
}
|
||||
|
||||
await on_contact_message(MockEvent())
|
||||
|
||||
mock_repo.create.assert_called_once()
|
||||
assert mock_repo.create.await_args.kwargs["conversation_key"] == "abc123"
|
||||
|
||||
mock_broadcast.assert_called_once()
|
||||
_, payload = mock_broadcast.call_args.args
|
||||
assert payload["conversation_key"] == "abc123"
|
||||
|
||||
|
||||
class TestEventHandlerRegistration:
|
||||
"""Test event handler registration and cleanup."""
|
||||
|
||||
@@ -4,6 +4,7 @@ These tests verify that connect() routes to the correct transport method
|
||||
based on settings.connection_type, and that connection_info is set correctly.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -168,3 +169,58 @@ class TestRadioManagerConnect:
|
||||
|
||||
old_mc.disconnect.assert_awaited_once()
|
||||
assert rm.meshcore is new_mc
|
||||
|
||||
|
||||
class TestConnectionMonitor:
|
||||
"""Tests for the background connection monitor loop."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_monitor_does_not_mark_connected_when_setup_fails(self):
|
||||
"""A reconnect with failing post-connect setup should not broadcast healthy status."""
|
||||
from app.radio import RadioManager
|
||||
|
||||
rm = RadioManager()
|
||||
rm._connection_info = "Serial: /dev/ttyUSB0"
|
||||
rm._last_connected = True
|
||||
rm._meshcore = MagicMock()
|
||||
rm._meshcore.is_connected = False
|
||||
|
||||
reconnect_calls = 0
|
||||
|
||||
async def _reconnect(*args, **kwargs):
|
||||
nonlocal reconnect_calls
|
||||
reconnect_calls += 1
|
||||
if reconnect_calls == 1:
|
||||
rm._meshcore = MagicMock()
|
||||
rm._meshcore.is_connected = True
|
||||
return True
|
||||
return False
|
||||
|
||||
sleep_calls = 0
|
||||
|
||||
async def _sleep(_seconds: float):
|
||||
nonlocal sleep_calls
|
||||
sleep_calls += 1
|
||||
if sleep_calls >= 3:
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
rm.reconnect = AsyncMock(side_effect=_reconnect)
|
||||
rm.post_connect_setup = AsyncMock(side_effect=RuntimeError("setup failed"))
|
||||
|
||||
with (
|
||||
patch("app.radio.asyncio.sleep", side_effect=_sleep),
|
||||
patch("app.websocket.broadcast_health") as mock_broadcast_health,
|
||||
):
|
||||
await rm.start_connection_monitor()
|
||||
try:
|
||||
await rm._reconnect_task
|
||||
finally:
|
||||
await rm.stop_connection_monitor()
|
||||
|
||||
# Should report connection lost, but not report healthy until setup succeeds.
|
||||
mock_broadcast_health.assert_any_call(False, "Serial: /dev/ttyUSB0")
|
||||
healthy_calls = [
|
||||
call for call in mock_broadcast_health.call_args_list if call.args[0] is True
|
||||
]
|
||||
assert healthy_calls == []
|
||||
assert rm._last_connected is False
|
||||
|
||||
Reference in New Issue
Block a user