Add experimental double send

This commit is contained in:
Jack Kingsman
2026-02-10 20:33:14 -08:00
parent 6b5e9457a1
commit a157390fb7
18 changed files with 300 additions and 45 deletions
+1
View File
@@ -133,6 +133,7 @@ export interface BotConfig {
export interface AppSettings {
max_radio_contacts: number;
experimental_channel_double_send: boolean;
favorites: { type: string; id: string }[];
auto_decrypt_dm_on_advert: boolean;
sidebar_sort_order: string;
+2 -1
View File
@@ -169,7 +169,7 @@ class TestMessagesEndpoint:
@pytest.mark.asyncio
async def test_send_channel_message_duplicate_returns_500(self):
"""If MessageRepository.create returns None (duplicate), returns 500."""
from app.models import SendChannelMessageRequest
from app.models import AppSettings, SendChannelMessageRequest
from app.routers.messages import send_channel_message
mock_mc = MagicMock()
@@ -187,6 +187,7 @@ class TestMessagesEndpoint:
with (
patch("app.dependencies.radio_manager") as mock_rm,
patch("app.repository.ChannelRepository") as mock_chan_repo,
patch("app.repository.AppSettingsRepository.get", new=AsyncMock(return_value=AppSettings())),
patch("app.routers.messages.MessageRepository") as mock_msg_repo,
):
mock_rm.is_connected = True
+10 -10
View File
@@ -100,8 +100,8 @@ class TestMigration001:
# Run migrations
applied = await run_migrations(conn)
assert applied == 15 # All 15 migrations run
assert await get_version(conn) == 15
assert applied == 16 # All 16 migrations run
assert await get_version(conn) == 16
# Verify columns exist by inserting and selecting
await conn.execute(
@@ -183,9 +183,9 @@ class TestMigration001:
applied1 = await run_migrations(conn)
applied2 = await run_migrations(conn)
assert applied1 == 15 # All 15 migrations run
assert applied1 == 16 # All 16 migrations run
assert applied2 == 0 # No migrations on second run
assert await get_version(conn) == 15
assert await get_version(conn) == 16
finally:
await conn.close()
@@ -245,9 +245,9 @@ class TestMigration001:
# Run migrations - should not fail
applied = await run_migrations(conn)
# All 15 migrations applied (version incremented) but no error
assert applied == 15
assert await get_version(conn) == 15
# All 16 migrations applied (version incremented) but no error
assert applied == 16
assert await get_version(conn) == 16
finally:
await conn.close()
@@ -374,10 +374,10 @@ class TestMigration013:
)
await conn.commit()
# Run migration 13 (plus 14+15 which also run)
# Run migration 13 (plus 14+15+16 which also run)
applied = await run_migrations(conn)
assert applied == 3
assert await get_version(conn) == 15
assert applied == 4
assert await get_version(conn) == 16
# Verify bots array was created with migrated data
cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1")
+2
View File
@@ -428,6 +428,7 @@ class TestAppSettingsRepository:
mock_cursor.fetchone = AsyncMock(
return_value={
"max_radio_contacts": 250,
"experimental_channel_double_send": 1,
"favorites": "{not-json",
"auto_decrypt_dm_on_advert": 1,
"sidebar_sort_order": "invalid",
@@ -448,6 +449,7 @@ class TestAppSettingsRepository:
settings = await AppSettingsRepository.get()
assert settings.max_radio_contacts == 250
assert settings.experimental_channel_double_send is True
assert settings.favorites == []
assert settings.last_message_times == {}
assert settings.sidebar_sort_order == "recent"
+106 -1
View File
@@ -6,7 +6,13 @@ from unittest.mock import AsyncMock, MagicMock, patch
import pytest
from meshcore import EventType
from app.models import Channel, Contact, SendChannelMessageRequest, SendDirectMessageRequest
from app.models import (
AppSettings,
Channel,
Contact,
SendChannelMessageRequest,
SendDirectMessageRequest,
)
from app.routers.messages import send_channel_message, send_direct_message
@@ -133,7 +139,12 @@ class TestOutgoingChannelBotTrigger:
"app.repository.ChannelRepository.get_by_key",
new=AsyncMock(return_value=db_channel),
),
patch(
"app.repository.AppSettingsRepository.get",
new=AsyncMock(return_value=AppSettings()),
),
patch("app.repository.MessageRepository.create", new=AsyncMock(return_value=1)),
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()) as mock_bot,
):
@@ -165,7 +176,12 @@ class TestOutgoingChannelBotTrigger:
"app.repository.ChannelRepository.get_by_key",
new=AsyncMock(return_value=db_channel),
),
patch(
"app.repository.AppSettingsRepository.get",
new=AsyncMock(return_value=AppSettings()),
),
patch("app.repository.MessageRepository.create", new=AsyncMock(return_value=1)),
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()) as mock_bot,
):
@@ -193,10 +209,99 @@ class TestOutgoingChannelBotTrigger:
"app.repository.ChannelRepository.get_by_key",
new=AsyncMock(return_value=db_channel),
),
patch(
"app.repository.AppSettingsRepository.get",
new=AsyncMock(return_value=AppSettings()),
),
patch("app.repository.MessageRepository.create", new=AsyncMock(return_value=1)),
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=slow_bot),
):
request = SendChannelMessageRequest(channel_key=db_channel.key, text="test")
message = await send_channel_message(request)
assert message.outgoing is True
@pytest.mark.asyncio
async def test_send_channel_msg_double_send_when_experimental_enabled(self):
"""Experimental setting triggers an immediate byte-perfect duplicate send."""
mc = _make_mc(name="MyNode")
db_channel = Channel(key="dd" * 16, name="#double")
settings = AppSettings(experimental_channel_double_send=True)
with (
patch("app.routers.messages.require_connected", return_value=mc),
patch(
"app.repository.ChannelRepository.get_by_key",
new=AsyncMock(return_value=db_channel),
),
patch("app.repository.AppSettingsRepository.get", new=AsyncMock(return_value=settings)),
patch("app.repository.MessageRepository.create", new=AsyncMock(return_value=1)),
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.sleep", new=AsyncMock()) as mock_sleep,
):
request = SendChannelMessageRequest(channel_key=db_channel.key, text="same bytes")
await send_channel_message(request)
assert mc.commands.send_chan_msg.await_count == 2
mock_sleep.assert_awaited_once_with(3)
first_call = mc.commands.send_chan_msg.await_args_list[0].kwargs
second_call = mc.commands.send_chan_msg.await_args_list[1].kwargs
assert first_call["chan"] == second_call["chan"]
assert first_call["msg"] == second_call["msg"]
assert first_call["timestamp"] == second_call["timestamp"]
@pytest.mark.asyncio
async def test_send_channel_msg_single_send_when_experimental_disabled(self):
"""Default setting keeps channel sends to a single radio command."""
mc = _make_mc(name="MyNode")
db_channel = Channel(key="ee" * 16, name="#single")
with (
patch("app.routers.messages.require_connected", return_value=mc),
patch(
"app.repository.ChannelRepository.get_by_key",
new=AsyncMock(return_value=db_channel),
),
patch(
"app.repository.AppSettingsRepository.get",
new=AsyncMock(return_value=AppSettings()),
),
patch("app.repository.MessageRepository.create", new=AsyncMock(return_value=1)),
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()),
):
request = SendChannelMessageRequest(channel_key=db_channel.key, text="single send")
await send_channel_message(request)
assert mc.commands.send_chan_msg.await_count == 1
@pytest.mark.asyncio
async def test_send_channel_msg_response_includes_current_ack_count(self):
"""Send response reflects latest DB ack count at response time."""
mc = _make_mc(name="MyNode")
db_channel = Channel(key="ff" * 16, name="#acked")
with (
patch("app.routers.messages.require_connected", return_value=mc),
patch(
"app.repository.ChannelRepository.get_by_key",
new=AsyncMock(return_value=db_channel),
),
patch(
"app.repository.AppSettingsRepository.get",
new=AsyncMock(return_value=AppSettings()),
),
patch("app.repository.MessageRepository.create", new=AsyncMock(return_value=123)),
patch("app.repository.MessageRepository.get_ack_count", new=AsyncMock(return_value=2)),
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
patch("app.bot.run_bot_for_message", new=AsyncMock()),
):
request = SendChannelMessageRequest(channel_key=db_channel.key, text="acked now")
message = await send_channel_message(request)
assert message.id == 123
assert message.acked == 2
+8 -1
View File
@@ -21,9 +21,11 @@ def _settings(
favorites: list[Favorite] | None = None,
migrated: bool = False,
max_radio_contacts: int = 200,
experimental_channel_double_send: bool = False,
) -> AppSettings:
return AppSettings(
max_radio_contacts=max_radio_contacts,
experimental_channel_double_send=experimental_channel_double_send,
favorites=favorites or [],
auto_decrypt_dm_on_advert=False,
sidebar_sort_order="recent",
@@ -45,7 +47,11 @@ class TestUpdateSettings:
return_value=updated,
) as mock_update:
result = await update_settings(
AppSettingsUpdate(max_radio_contacts=321, advert_interval=3600)
AppSettingsUpdate(
max_radio_contacts=321,
advert_interval=3600,
experimental_channel_double_send=True,
)
)
assert result.max_radio_contacts == 321
@@ -53,6 +59,7 @@ class TestUpdateSettings:
assert mock_update.call_args.kwargs == {
"max_radio_contacts": 321,
"advert_interval": 3600,
"experimental_channel_double_send": True,
}
@pytest.mark.asyncio