mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-07-06 01:42:11 +02:00
Drop frequency of contact sync task, make standard polling opt-in only
This commit is contained in:
@@ -273,6 +273,33 @@ class TestConnectionMonitor:
|
||||
mock_broadcast.assert_any_call(True, "TCP: test:4000")
|
||||
assert rm._setup_complete is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_monitor_does_not_retry_while_setup_already_running(self):
|
||||
"""Monitor leaves an in-progress setup alone instead of queueing another one."""
|
||||
from app.radio import RadioManager
|
||||
|
||||
rm = RadioManager()
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.is_connected = True
|
||||
rm._meshcore = mock_mc
|
||||
rm._connection_info = "TCP: test:4000"
|
||||
rm._last_connected = True
|
||||
rm._setup_complete = False
|
||||
rm._setup_in_progress = True
|
||||
rm.post_connect_setup = AsyncMock()
|
||||
|
||||
async def _sleep(_seconds: float):
|
||||
raise asyncio.CancelledError()
|
||||
|
||||
with patch("app.radio.asyncio.sleep", side_effect=_sleep):
|
||||
await rm.start_connection_monitor()
|
||||
try:
|
||||
await rm._reconnect_task
|
||||
finally:
|
||||
await rm.stop_connection_monitor()
|
||||
|
||||
rm.post_connect_setup.assert_not_called()
|
||||
|
||||
|
||||
class TestReconnectLock:
|
||||
"""Tests for reconnect() lock serialization — no duplicate reconnections."""
|
||||
@@ -722,3 +749,121 @@ class TestPostConnectSetupOrdering:
|
||||
await rm.post_connect_setup()
|
||||
|
||||
mock_mc.commands.set_flood_scope.assert_awaited_once_with("")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_polling_disabled_by_default(self):
|
||||
"""Post-connect setup does not start fallback polling unless explicitly enabled."""
|
||||
from app.models import AppSettings
|
||||
from app.radio import RadioManager
|
||||
|
||||
rm = RadioManager()
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.start_auto_message_fetching = AsyncMock()
|
||||
mock_mc.commands.set_flood_scope = AsyncMock()
|
||||
rm._meshcore = mock_mc
|
||||
|
||||
with (
|
||||
patch("app.event_handlers.register_event_handlers"),
|
||||
patch("app.keystore.export_and_store_private_key", new_callable=AsyncMock),
|
||||
patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock),
|
||||
patch(
|
||||
"app.repository.AppSettingsRepository.get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=AppSettings(),
|
||||
),
|
||||
patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock, return_value={}),
|
||||
patch("app.radio_sync.start_periodic_sync"),
|
||||
patch("app.radio_sync.send_advertisement", new_callable=AsyncMock, return_value=False),
|
||||
patch("app.radio_sync.start_periodic_advert"),
|
||||
patch("app.radio_sync.drain_pending_messages", new_callable=AsyncMock, return_value=0),
|
||||
patch("app.radio_sync.start_message_polling") as mock_start_message_polling,
|
||||
):
|
||||
await rm.post_connect_setup()
|
||||
|
||||
mock_start_message_polling.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_message_polling_starts_when_env_flag_enabled(self):
|
||||
"""Post-connect setup starts fallback polling when the env-backed setting is enabled."""
|
||||
from app.models import AppSettings
|
||||
from app.radio import RadioManager
|
||||
|
||||
rm = RadioManager()
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.start_auto_message_fetching = AsyncMock()
|
||||
mock_mc.commands.set_flood_scope = AsyncMock()
|
||||
rm._meshcore = mock_mc
|
||||
|
||||
with (
|
||||
patch("app.event_handlers.register_event_handlers"),
|
||||
patch("app.keystore.export_and_store_private_key", new_callable=AsyncMock),
|
||||
patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock),
|
||||
patch(
|
||||
"app.repository.AppSettingsRepository.get",
|
||||
new_callable=AsyncMock,
|
||||
return_value=AppSettings(),
|
||||
),
|
||||
patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock, return_value={}),
|
||||
patch("app.radio_sync.start_periodic_sync"),
|
||||
patch("app.radio_sync.send_advertisement", new_callable=AsyncMock, return_value=False),
|
||||
patch("app.radio_sync.start_periodic_advert"),
|
||||
patch("app.radio_sync.drain_pending_messages", new_callable=AsyncMock, return_value=0),
|
||||
patch("app.radio_sync.start_message_polling") as mock_start_message_polling,
|
||||
patch("app.services.radio_lifecycle.settings.enable_message_poll_fallback", True),
|
||||
):
|
||||
await rm.post_connect_setup()
|
||||
|
||||
mock_start_message_polling.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_connected_radio_retries_timeout_once_before_failing(self):
|
||||
"""Hung post-connect setup gets one retry before surfacing an operator error."""
|
||||
from app.radio import RadioManager
|
||||
from app.services.radio_lifecycle import prepare_connected_radio
|
||||
|
||||
rm = RadioManager()
|
||||
rm._connection_info = "Serial: /dev/ttyUSB0"
|
||||
rm.post_connect_setup = AsyncMock(
|
||||
side_effect=[asyncio.TimeoutError(), asyncio.TimeoutError()]
|
||||
)
|
||||
|
||||
with (
|
||||
patch("app.services.radio_lifecycle.logger") as mock_logger,
|
||||
patch("app.websocket.broadcast_error") as mock_broadcast_error,
|
||||
patch("app.websocket.broadcast_health") as mock_broadcast_health,
|
||||
):
|
||||
with pytest.raises(RuntimeError, match="Post-connect setup timed out"):
|
||||
await prepare_connected_radio(rm, broadcast_on_success=True)
|
||||
|
||||
assert rm.post_connect_setup.await_count == 2
|
||||
mock_logger.warning.assert_called_once()
|
||||
mock_logger.error.assert_called_once()
|
||||
mock_broadcast_error.assert_called_once_with(
|
||||
"Radio startup appears stuck",
|
||||
"Initial radio offload took too long. Reboot the radio and restart the server.",
|
||||
)
|
||||
mock_broadcast_health.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_prepare_connected_radio_succeeds_on_retry_after_timeout(self):
|
||||
"""A slow first attempt can time out once without failing the reconnect flow."""
|
||||
from app.radio import RadioManager
|
||||
from app.services.radio_lifecycle import prepare_connected_radio
|
||||
|
||||
rm = RadioManager()
|
||||
rm._connection_info = "Serial: /dev/ttyUSB0"
|
||||
rm.post_connect_setup = AsyncMock(side_effect=[asyncio.TimeoutError(), None])
|
||||
|
||||
with (
|
||||
patch("app.services.radio_lifecycle.logger") as mock_logger,
|
||||
patch("app.websocket.broadcast_error") as mock_broadcast_error,
|
||||
patch("app.websocket.broadcast_health") as mock_broadcast_health,
|
||||
):
|
||||
await prepare_connected_radio(rm, broadcast_on_success=True)
|
||||
|
||||
assert rm.post_connect_setup.await_count == 2
|
||||
assert rm._last_connected is True
|
||||
mock_logger.warning.assert_called_once()
|
||||
mock_logger.error.assert_not_called()
|
||||
mock_broadcast_error.assert_not_called()
|
||||
mock_broadcast_health.assert_called_once_with(True, "Serial: /dev/ttyUSB0")
|
||||
|
||||
+138
-15
@@ -16,6 +16,7 @@ from app.radio_sync import (
|
||||
_message_poll_loop,
|
||||
_periodic_advert_loop,
|
||||
_periodic_sync_loop,
|
||||
ensure_contact_on_radio,
|
||||
is_polling_paused,
|
||||
pause_polling,
|
||||
sync_radio_time,
|
||||
@@ -180,10 +181,16 @@ class TestSyncRecentContactsToRadio:
|
||||
"""Test the sync_recent_contacts_to_radio function."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_loads_contacts_not_on_radio(self, test_db):
|
||||
"""Contacts not on radio are added via add_contact."""
|
||||
async def test_loads_favorite_contacts_not_on_radio(self, test_db):
|
||||
"""Favorite contacts not on radio are added via add_contact."""
|
||||
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
|
||||
await _insert_contact(KEY_B, "Bob", last_contacted=1000)
|
||||
await AppSettingsRepository.update(
|
||||
favorites=[
|
||||
Favorite(type="contact", id=KEY_A),
|
||||
Favorite(type="contact", id=KEY_B),
|
||||
]
|
||||
)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
@@ -202,16 +209,16 @@ class TestSyncRecentContactsToRadio:
|
||||
assert bob.on_radio is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_favorites_loaded_before_recent_contacts(self, test_db):
|
||||
"""Favorite contacts are loaded first, then recents until limit."""
|
||||
async def test_fills_remaining_slots_with_recently_contacted_then_advertised(self, test_db):
|
||||
"""Fill order is favorites, then recent contacts, then recent adverts."""
|
||||
await _insert_contact(KEY_A, "Alice", last_contacted=100)
|
||||
await _insert_contact(KEY_B, "Bob", last_contacted=2000)
|
||||
await _insert_contact("cc" * 32, "Carol", last_contacted=1000)
|
||||
await _insert_contact("dd" * 32, "Dave", last_advert=3000)
|
||||
await _insert_contact("ee" * 32, "Eve", last_advert=2500)
|
||||
|
||||
# Set max_radio_contacts=2 and add KEY_A as favorite
|
||||
await AppSettingsRepository.update(
|
||||
max_radio_contacts=2,
|
||||
favorites=[Favorite(type="contact", id=KEY_A)],
|
||||
max_radio_contacts=4, favorites=[Favorite(type="contact", id=KEY_A)]
|
||||
)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
@@ -223,22 +230,45 @@ class TestSyncRecentContactsToRadio:
|
||||
radio_manager._meshcore = mock_mc
|
||||
result = await sync_recent_contacts_to_radio()
|
||||
|
||||
assert result["loaded"] == 2
|
||||
# KEY_A (favorite) should be loaded first, then KEY_B (most recent)
|
||||
assert result["loaded"] == 4
|
||||
loaded_keys = [
|
||||
call.args[0]["public_key"] for call in mock_mc.commands.add_contact.call_args_list
|
||||
]
|
||||
assert loaded_keys == [KEY_A, KEY_B]
|
||||
assert loaded_keys == [KEY_A, KEY_B, "cc" * 32, "dd" * 32]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_favorite_contact_not_loaded_twice_if_also_recent(self, test_db):
|
||||
"""A favorite contact that is also recent is loaded only once."""
|
||||
async def test_advert_fill_skips_repeaters(self, test_db):
|
||||
"""Recent advert fallback only considers non-repeaters."""
|
||||
await _insert_contact(KEY_A, "Alice", last_advert=3000, contact_type=2)
|
||||
await _insert_contact(KEY_B, "Bob", last_advert=2000, contact_type=1)
|
||||
|
||||
await AppSettingsRepository.update(max_radio_contacts=1, favorites=[])
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
mock_result = MagicMock()
|
||||
mock_result.type = EventType.OK
|
||||
mock_mc.commands.add_contact = AsyncMock(return_value=mock_result)
|
||||
|
||||
radio_manager._meshcore = mock_mc
|
||||
result = await sync_recent_contacts_to_radio()
|
||||
|
||||
assert result["loaded"] == 1
|
||||
payload = mock_mc.commands.add_contact.call_args.args[0]
|
||||
assert payload["public_key"] == KEY_B
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_duplicate_favorite_not_loaded_twice(self, test_db):
|
||||
"""Duplicate favorite entries still load the contact only once."""
|
||||
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
|
||||
await _insert_contact(KEY_B, "Bob", last_contacted=1000)
|
||||
|
||||
await AppSettingsRepository.update(
|
||||
max_radio_contacts=2,
|
||||
favorites=[Favorite(type="contact", id=KEY_A)],
|
||||
favorites=[
|
||||
Favorite(type="contact", id=KEY_A),
|
||||
Favorite(type="contact", id=KEY_A),
|
||||
],
|
||||
)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
@@ -260,6 +290,7 @@ class TestSyncRecentContactsToRadio:
|
||||
async def test_skips_contacts_already_on_radio(self, test_db):
|
||||
"""Contacts already on radio are counted but not re-added."""
|
||||
await _insert_contact(KEY_A, "Alice", on_radio=True)
|
||||
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=MagicMock()) # Found
|
||||
@@ -319,6 +350,7 @@ class TestSyncRecentContactsToRadio:
|
||||
async def test_marks_on_radio_when_found_but_not_flagged(self, test_db):
|
||||
"""Contact found on radio but not flagged gets set_on_radio(True)."""
|
||||
await _insert_contact(KEY_A, "Alice", on_radio=False)
|
||||
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=MagicMock()) # Found
|
||||
@@ -335,6 +367,7 @@ class TestSyncRecentContactsToRadio:
|
||||
async def test_handles_add_failure(self, test_db):
|
||||
"""Failed add_contact increments the failed counter."""
|
||||
await _insert_contact(KEY_A, "Alice")
|
||||
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
@@ -360,6 +393,7 @@ class TestSyncRecentContactsToRadio:
|
||||
last_path_len=2,
|
||||
out_path_hash_mode=1,
|
||||
)
|
||||
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
@@ -388,6 +422,7 @@ class TestSyncRecentContactsToRadio:
|
||||
last_path_len=-125,
|
||||
out_path_hash_mode=2,
|
||||
)
|
||||
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
@@ -412,6 +447,7 @@ class TestSyncRecentContactsToRadio:
|
||||
so it passes mc directly to avoid deadlock (asyncio.Lock is not reentrant).
|
||||
"""
|
||||
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
|
||||
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
@@ -451,6 +487,7 @@ class TestSyncRecentContactsToRadio:
|
||||
"""If _meshcore is swapped between pre-check and lock acquisition,
|
||||
the function uses the new (post-lock) instance, not the stale one."""
|
||||
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
|
||||
await AppSettingsRepository.update(favorites=[Favorite(type="contact", id=KEY_A)])
|
||||
|
||||
old_mc = MagicMock(name="old_mc")
|
||||
new_mc = MagicMock(name="new_mc")
|
||||
@@ -471,6 +508,26 @@ class TestSyncRecentContactsToRadio:
|
||||
new_mc.commands.add_contact.assert_called_once()
|
||||
old_mc.commands.add_contact.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_ensure_contact_on_radio_loads_single_contact_even_when_not_favorited(
|
||||
self, test_db
|
||||
):
|
||||
"""Targeted sync loads one contact without needing it in favorites."""
|
||||
await _insert_contact(KEY_A, "Alice", last_contacted=2000)
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix = MagicMock(return_value=None)
|
||||
mock_result = MagicMock()
|
||||
mock_result.type = EventType.OK
|
||||
mock_mc.commands.add_contact = AsyncMock(return_value=mock_result)
|
||||
|
||||
radio_manager._meshcore = mock_mc
|
||||
result = await ensure_contact_on_radio(KEY_A, force=True)
|
||||
|
||||
assert result["loaded"] == 1
|
||||
payload = mock_mc.commands.add_contact.call_args.args[0]
|
||||
assert payload["public_key"] == KEY_A
|
||||
|
||||
|
||||
class TestSyncAndOffloadContacts:
|
||||
"""Test sync_and_offload_contacts: pull contacts from radio, save to DB, remove from radio."""
|
||||
@@ -511,7 +568,7 @@ class TestSyncAndOffloadContacts:
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_claims_prefix_messages_for_each_contact(self, test_db):
|
||||
"""claim_prefix_messages is called for each synced contact."""
|
||||
"""Prefix message claims still complete via scheduled reconciliation tasks."""
|
||||
from app.radio_sync import sync_and_offload_contacts
|
||||
|
||||
# Pre-insert a message with a prefix key that matches KEY_A
|
||||
@@ -536,13 +593,73 @@ class TestSyncAndOffloadContacts:
|
||||
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result)
|
||||
mock_mc.commands.remove_contact = AsyncMock(return_value=mock_remove_result)
|
||||
|
||||
await sync_and_offload_contacts(mock_mc)
|
||||
created_tasks: list[asyncio.Task] = []
|
||||
real_create_task = asyncio.create_task
|
||||
|
||||
def _capture_task(coro):
|
||||
task = real_create_task(coro)
|
||||
created_tasks.append(task)
|
||||
return task
|
||||
|
||||
with patch("app.radio_sync.asyncio.create_task", side_effect=_capture_task):
|
||||
await sync_and_offload_contacts(mock_mc)
|
||||
|
||||
await asyncio.gather(*created_tasks)
|
||||
|
||||
# Verify the prefix message was claimed (promoted to full key)
|
||||
messages = await MessageRepository.get_all(conversation_key=KEY_A)
|
||||
assert len(messages) == 1
|
||||
assert messages[0].conversation_key == KEY_A.lower()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_reconciliation_does_not_block_contact_removal(self, test_db):
|
||||
"""Slow reconciliation work is scheduled in background, not awaited inline."""
|
||||
from app.radio_sync import sync_and_offload_contacts
|
||||
|
||||
contact_payload = {KEY_A: {"adv_name": "Alice", "type": 1, "flags": 0}}
|
||||
|
||||
mock_get_result = MagicMock()
|
||||
mock_get_result.type = EventType.NEW_CONTACT
|
||||
mock_get_result.payload = contact_payload
|
||||
|
||||
mock_remove_result = MagicMock()
|
||||
mock_remove_result.type = EventType.OK
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.commands.get_contacts = AsyncMock(return_value=mock_get_result)
|
||||
mock_mc.commands.remove_contact = AsyncMock(return_value=mock_remove_result)
|
||||
|
||||
reconcile_started = asyncio.Event()
|
||||
reconcile_release = asyncio.Event()
|
||||
created_tasks: list[asyncio.Task] = []
|
||||
real_create_task = asyncio.create_task
|
||||
|
||||
async def _slow_reconcile(*, public_key: str, contact_name: str | None, log):
|
||||
del public_key, contact_name, log
|
||||
reconcile_started.set()
|
||||
await reconcile_release.wait()
|
||||
|
||||
def _capture_task(coro):
|
||||
task = real_create_task(coro)
|
||||
created_tasks.append(task)
|
||||
return task
|
||||
|
||||
with (
|
||||
patch("app.radio_sync.reconcile_contact_messages", side_effect=_slow_reconcile),
|
||||
patch("app.radio_sync.asyncio.create_task", side_effect=_capture_task),
|
||||
):
|
||||
result = await sync_and_offload_contacts(mock_mc)
|
||||
await asyncio.sleep(0)
|
||||
|
||||
assert result["synced"] == 1
|
||||
assert result["removed"] == 1
|
||||
assert reconcile_started.is_set() is True
|
||||
assert created_tasks and created_tasks[0].done() is False
|
||||
mock_mc.commands.remove_contact.assert_awaited_once()
|
||||
|
||||
reconcile_release.set()
|
||||
await asyncio.gather(*created_tasks)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_handles_remove_failure_gracefully(self, test_db):
|
||||
"""Failed remove_contact logs warning but continues to next contact."""
|
||||
@@ -1181,11 +1298,13 @@ class TestPeriodicSyncLoopRaces:
|
||||
with (
|
||||
patch("app.radio_sync.radio_manager", rm),
|
||||
patch("asyncio.sleep", side_effect=mock_sleep),
|
||||
patch("app.radio_sync.cleanup_expired_acks") as mock_cleanup,
|
||||
patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock) as mock_sync,
|
||||
patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock) as mock_time,
|
||||
):
|
||||
await _periodic_sync_loop()
|
||||
|
||||
mock_cleanup.assert_called_once()
|
||||
mock_sync.assert_not_called()
|
||||
mock_time.assert_not_called()
|
||||
assert len(sleep_calls) == 2
|
||||
@@ -1201,6 +1320,7 @@ class TestPeriodicSyncLoopRaces:
|
||||
with (
|
||||
patch("app.radio_sync.radio_manager", rm),
|
||||
patch("asyncio.sleep", side_effect=mock_sleep),
|
||||
patch("app.radio_sync.cleanup_expired_acks") as mock_cleanup,
|
||||
patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock) as mock_sync,
|
||||
patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock) as mock_time,
|
||||
):
|
||||
@@ -1208,6 +1328,7 @@ class TestPeriodicSyncLoopRaces:
|
||||
finally:
|
||||
lock.release()
|
||||
|
||||
mock_cleanup.assert_called_once()
|
||||
mock_sync.assert_not_called()
|
||||
mock_time.assert_not_called()
|
||||
|
||||
@@ -1222,10 +1343,12 @@ class TestPeriodicSyncLoopRaces:
|
||||
with (
|
||||
patch("app.radio_sync.radio_manager", rm),
|
||||
patch("asyncio.sleep", side_effect=mock_sleep),
|
||||
patch("app.radio_sync.cleanup_expired_acks") as mock_cleanup,
|
||||
patch("app.radio_sync.sync_and_offload_all", new_callable=AsyncMock) as mock_sync,
|
||||
patch("app.radio_sync.sync_radio_time", new_callable=AsyncMock) as mock_time,
|
||||
):
|
||||
await _periodic_sync_loop()
|
||||
|
||||
mock_cleanup.assert_called_once()
|
||||
mock_sync.assert_called_once_with(mock_mc)
|
||||
mock_time.assert_called_once_with(mock_mc)
|
||||
|
||||
@@ -525,6 +525,49 @@ class TestContactRepositoryResolvePrefixes:
|
||||
assert result == {}
|
||||
|
||||
|
||||
class TestContactRepositoryRecentQueries:
|
||||
"""Test recent-contact selection helpers used for radio fill."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_recently_advertised_includes_contacted_contacts(self, test_db):
|
||||
stale_contacted_fresh_advert = "ab" * 32
|
||||
advert_only = "cd" * 32
|
||||
repeater = "ef" * 32
|
||||
|
||||
await ContactRepository.upsert(
|
||||
{
|
||||
"public_key": stale_contacted_fresh_advert,
|
||||
"name": "SeenAgain",
|
||||
"type": 1,
|
||||
"last_contacted": 100,
|
||||
"last_advert": 5000,
|
||||
}
|
||||
)
|
||||
await ContactRepository.upsert(
|
||||
{
|
||||
"public_key": advert_only,
|
||||
"name": "AdvertOnly",
|
||||
"type": 1,
|
||||
"last_advert": 4000,
|
||||
}
|
||||
)
|
||||
await ContactRepository.upsert(
|
||||
{
|
||||
"public_key": repeater,
|
||||
"name": "Repeater",
|
||||
"type": 2,
|
||||
"last_advert": 6000,
|
||||
}
|
||||
)
|
||||
|
||||
contacts = await ContactRepository.get_recently_advertised_non_repeaters()
|
||||
|
||||
assert [contact.public_key for contact in contacts] == [
|
||||
stale_contacted_fresh_advert,
|
||||
advert_only,
|
||||
]
|
||||
|
||||
|
||||
class TestAppSettingsRepository:
|
||||
"""Test AppSettingsRepository parsing and migration edge cases."""
|
||||
|
||||
|
||||
@@ -153,6 +153,9 @@ class TestOutgoingDMBroadcast:
|
||||
assert contact_payload["out_path"] == "aa00bb00"
|
||||
assert contact_payload["out_path_len"] == 2
|
||||
assert contact_payload["out_path_hash_mode"] == 1
|
||||
contact = await ContactRepository.get_by_key(pub_key)
|
||||
assert contact is not None
|
||||
assert contact.on_radio is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_send_dm_prefers_route_override_over_learned_path(self, test_db):
|
||||
|
||||
@@ -133,21 +133,35 @@ class TestToggleFavorite:
|
||||
@pytest.mark.asyncio
|
||||
async def test_adds_when_not_favorited(self, test_db):
|
||||
request = FavoriteRequest(type="contact", id="aa" * 32)
|
||||
result = await toggle_favorite(request)
|
||||
with (
|
||||
patch("app.radio_sync.ensure_contact_on_radio", new_callable=AsyncMock) as mock_sync,
|
||||
patch("app.routers.settings.asyncio.create_task") as mock_create_task,
|
||||
):
|
||||
mock_create_task.side_effect = lambda coro: coro.close()
|
||||
result = await toggle_favorite(request)
|
||||
|
||||
assert len(result.favorites) == 1
|
||||
assert result.favorites[0].type == "contact"
|
||||
assert result.favorites[0].id == "aa" * 32
|
||||
mock_sync.assert_called_once_with("aa" * 32, force=True)
|
||||
mock_create_task.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_removes_when_already_favorited(self, test_db):
|
||||
# Pre-add a favorite
|
||||
await AppSettingsRepository.add_favorite("channel", "ABCD")
|
||||
await AppSettingsRepository.add_favorite("contact", "aa" * 32)
|
||||
|
||||
request = FavoriteRequest(type="channel", id="ABCD")
|
||||
result = await toggle_favorite(request)
|
||||
request = FavoriteRequest(type="contact", id="aa" * 32)
|
||||
with (
|
||||
patch("app.radio_sync.ensure_contact_on_radio", new_callable=AsyncMock) as mock_sync,
|
||||
patch("app.routers.settings.asyncio.create_task") as mock_create_task,
|
||||
):
|
||||
mock_create_task.side_effect = lambda coro: coro.close()
|
||||
result = await toggle_favorite(request)
|
||||
|
||||
assert result.favorites == []
|
||||
mock_sync.assert_not_called()
|
||||
mock_create_task.assert_not_called()
|
||||
|
||||
|
||||
class TestMigratePreferences:
|
||||
|
||||
Reference in New Issue
Block a user