Add resend button for 30s

This commit is contained in:
Jack Kingsman
2026-02-14 17:37:51 -08:00
parent 7b2d5b817e
commit 5a82d469b4
23 changed files with 570 additions and 171 deletions
-1
View File
@@ -174,7 +174,6 @@ 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;
+36
View File
@@ -46,4 +46,40 @@ test.describe('Channel messaging in #flightless', () => {
const messageContainer = messageEl.locator('..');
await expect(messageContainer.getByText(/[?✓]/)).toBeVisible();
});
test('resend outgoing channel message from message row', async ({ page }) => {
await page.goto('/');
await page.getByText('#flightless', { exact: true }).first().click();
await expect(page.getByPlaceholder(/message #flightless/i)).toBeVisible();
const testMessage = `resend-test-${Date.now()}`;
const input = page.getByPlaceholder(/type a message|message #flightless/i);
await input.fill(testMessage);
await page.getByRole('button', { name: 'Send' }).click();
const messageEl = page.getByText(testMessage).first();
await expect(messageEl).toBeVisible({ timeout: 15_000 });
const messageContainer = messageEl.locator(
'xpath=ancestor::div[contains(@class,"break-words")][1]'
);
const resendButton = messageContainer.getByTitle('Resend message');
await expect(resendButton).toBeVisible({ timeout: 15_000 });
const resendResponsePromise = page.waitForResponse(
(response) =>
response.request().method() === 'POST' &&
/\/api\/messages\/channel\/\d+\/resend$/.test(response.url())
);
await resendButton.click();
const resendResponse = await resendResponsePromise;
expect(resendResponse.ok()).toBeTruthy();
await expect(page.getByText('Message resent')).toBeVisible({ timeout: 10_000 });
// Byte-perfect resend should not create a second visible row in this conversation.
await expect(page.getByText(testMessage)).toHaveCount(1);
});
});
+112
View File
@@ -315,6 +315,118 @@ class TestMessagesEndpoint:
assert exc_info.value.status_code == 500
assert "unexpected duplicate" in exc_info.value.detail.lower()
@pytest.mark.asyncio
async def test_resend_channel_message_requires_connection(self, test_db, client):
"""Resend endpoint returns 503 when radio is disconnected."""
with patch("app.dependencies.radio_manager") as mock_rm:
mock_rm.is_connected = False
mock_rm.meshcore = None
response = await client.post("/api/messages/channel/1/resend")
assert response.status_code == 503
assert "not connected" in response.json()["detail"].lower()
@pytest.mark.asyncio
async def test_resend_channel_message_success(self, test_db, client):
"""Resend endpoint reuses timestamp bytes and strips sender prefix."""
from meshcore import EventType
chan_key = "AB" * 16
await ChannelRepository.upsert(key=chan_key, name="#resend")
sent_at = int(time.time()) - 5
msg_id = await MessageRepository.create(
msg_type="CHAN",
text="TestNode: hello world",
conversation_key=chan_key,
sender_timestamp=sent_at,
received_at=sent_at,
outgoing=True,
)
assert msg_id is not None
mock_mc = MagicMock()
mock_mc.self_info = {"name": "TestNode"}
mock_mc.commands = MagicMock()
mock_mc.commands.set_channel = AsyncMock(
return_value=MagicMock(type=EventType.OK, payload={})
)
mock_mc.commands.send_chan_msg = AsyncMock(
return_value=MagicMock(type=EventType.MSG_SENT, payload={})
)
with patch("app.dependencies.radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
response = await client.post(f"/api/messages/channel/{msg_id}/resend")
assert response.status_code == 200
assert response.json() == {"status": "ok", "message_id": msg_id}
set_kwargs = mock_mc.commands.set_channel.await_args.kwargs
assert set_kwargs["channel_idx"] == 0
assert set_kwargs["channel_name"] == "#resend"
assert set_kwargs["channel_secret"] == bytes.fromhex(chan_key)
send_kwargs = mock_mc.commands.send_chan_msg.await_args.kwargs
assert send_kwargs["chan"] == 0
assert send_kwargs["msg"] == "hello world"
assert send_kwargs["timestamp"] == sent_at.to_bytes(4, "little")
@pytest.mark.asyncio
async def test_resend_channel_message_window_expired(self, test_db, client):
"""Resend endpoint rejects channel messages older than 30 seconds."""
chan_key = "CD" * 16
await ChannelRepository.upsert(key=chan_key, name="#old")
sent_at = int(time.time()) - 60
msg_id = await MessageRepository.create(
msg_type="CHAN",
text="TestNode: too old",
conversation_key=chan_key,
sender_timestamp=sent_at,
received_at=sent_at,
outgoing=True,
)
assert msg_id is not None
mock_mc = MagicMock()
mock_mc.self_info = {"name": "TestNode"}
mock_mc.commands = MagicMock()
mock_mc.commands.set_channel = AsyncMock()
mock_mc.commands.send_chan_msg = AsyncMock()
with patch("app.dependencies.radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
response = await client.post(f"/api/messages/channel/{msg_id}/resend")
assert response.status_code == 400
assert "expired" in response.json()["detail"].lower()
assert mock_mc.commands.set_channel.await_count == 0
assert mock_mc.commands.send_chan_msg.await_count == 0
@pytest.mark.asyncio
async def test_resend_channel_message_returns_404_for_missing(self, test_db, client):
"""Resend endpoint returns 404 for nonexistent message ID."""
mock_mc = MagicMock()
mock_mc.self_info = {"name": "TestNode"}
mock_mc.commands = MagicMock()
mock_mc.commands.set_channel = AsyncMock()
mock_mc.commands.send_chan_msg = AsyncMock()
with patch("app.dependencies.radio_manager") as mock_rm:
mock_rm.is_connected = True
mock_rm.meshcore = mock_mc
response = await client.post("/api/messages/channel/999999/resend")
assert response.status_code == 404
assert "not found" in response.json()["detail"].lower()
assert mock_mc.commands.set_channel.await_count == 0
assert mock_mc.commands.send_chan_msg.await_count == 0
class TestChannelsEndpoint:
"""Test channel-related endpoints."""
+10 -10
View File
@@ -100,8 +100,8 @@ class TestMigration001:
# Run migrations
applied = await run_migrations(conn)
assert applied == 16 # All 16 migrations run
assert await get_version(conn) == 16
assert applied == 17 # All 17 migrations run
assert await get_version(conn) == 17
# 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 == 16 # All 16 migrations run
assert applied1 == 17 # All 17 migrations run
assert applied2 == 0 # No migrations on second run
assert await get_version(conn) == 16
assert await get_version(conn) == 17
finally:
await conn.close()
@@ -245,9 +245,9 @@ class TestMigration001:
# Run migrations - should not fail
applied = await run_migrations(conn)
# All 16 migrations applied (version incremented) but no error
assert applied == 16
assert await get_version(conn) == 16
# All 17 migrations applied (version incremented) but no error
assert applied == 17
assert await get_version(conn) == 17
finally:
await conn.close()
@@ -374,10 +374,10 @@ class TestMigration013:
)
await conn.commit()
# Run migration 13 (plus 14+15+16 which also run)
# Run migration 13 (plus 14+15+16+17 which also run)
applied = await run_migrations(conn)
assert applied == 4
assert await get_version(conn) == 16
assert applied == 5
assert await get_version(conn) == 17
# Verify bots array was created with migrated data
cursor = await conn.execute("SELECT bots FROM app_settings WHERE id = 1")
+23 -2
View File
@@ -385,7 +385,6 @@ 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",
@@ -406,7 +405,6 @@ 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"
@@ -471,3 +469,26 @@ class TestAppSettingsRepository:
assert result.preferences_migrated is True
assert mock_update.call_args.kwargs["sidebar_sort_order"] == "recent"
assert mock_update.call_args.kwargs["preferences_migrated"] is True
class TestMessageRepositoryGetById:
"""Test MessageRepository.get_by_id method."""
@pytest.mark.asyncio
async def test_returns_message_when_exists(self, test_db):
"""Returns message for valid ID."""
msg_id = await _create_message(test_db, text="Find me", outgoing=True)
result = await MessageRepository.get_by_id(msg_id)
assert result is not None
assert result.id == msg_id
assert result.text == "Find me"
assert result.outgoing is True
@pytest.mark.asyncio
async def test_returns_none_when_not_found(self, test_db):
"""Returns None for nonexistent ID."""
result = await MessageRepository.get_by_id(999999)
assert result is None
+158 -44
View File
@@ -1,6 +1,7 @@
"""Tests for bot triggering on outgoing messages sent via the messages router."""
import asyncio
import time
from unittest.mock import AsyncMock, MagicMock, patch
import pytest
@@ -13,11 +14,15 @@ from app.models import (
SendDirectMessageRequest,
)
from app.repository import (
AppSettingsRepository,
ChannelRepository,
ContactRepository,
MessageRepository,
)
from app.routers.messages import (
resend_channel_message,
send_channel_message,
send_direct_message,
)
from app.routers.messages import send_channel_message, send_direct_message
@pytest.fixture
@@ -236,48 +241,6 @@ class TestOutgoingChannelBotTrigger:
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, test_db):
"""Experimental setting triggers an immediate byte-perfect duplicate send."""
mc = _make_mc(name="MyNode")
chan_key = "dd" * 16
await ChannelRepository.upsert(key=chan_key, name="#double")
await AppSettingsRepository.update(experimental_channel_double_send=True)
with (
patch("app.routers.messages.require_connected", return_value=mc),
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=chan_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, test_db):
"""Default setting keeps channel sends to a single radio command."""
mc = _make_mc(name="MyNode")
chan_key = "ee" * 16
await ChannelRepository.upsert(key=chan_key, name="#single")
with (
patch("app.routers.messages.require_connected", return_value=mc),
patch("app.decoder.calculate_channel_hash", return_value="abcd"),
patch("app.bot.run_bot_for_message", new=AsyncMock()),
):
request = SendChannelMessageRequest(channel_key=chan_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, test_db):
"""Send response reflects latest DB ack count at response time."""
@@ -296,3 +259,154 @@ class TestOutgoingChannelBotTrigger:
# Fresh message has acked=0
assert message.id is not None
assert message.acked == 0
class TestResendChannelMessage:
"""Test the user-triggered resend endpoint."""
@pytest.mark.asyncio
async def test_resend_within_window_succeeds(self, test_db):
"""Resend within 30-second window sends with same timestamp bytes."""
mc = _make_mc(name="MyNode")
chan_key = "aa" * 16
await ChannelRepository.upsert(key=chan_key, name="#resend")
now = int(time.time()) - 10 # 10 seconds ago
msg_id = await MessageRepository.create(
msg_type="CHAN",
text="MyNode: hello",
conversation_key=chan_key.upper(),
sender_timestamp=now,
received_at=now,
outgoing=True,
)
assert msg_id is not None
with patch("app.routers.messages.require_connected", return_value=mc):
result = await resend_channel_message(msg_id)
assert result["status"] == "ok"
assert result["message_id"] == msg_id
# Verify radio was called with correct timestamp bytes
mc.commands.send_chan_msg.assert_awaited_once()
call_kwargs = mc.commands.send_chan_msg.await_args.kwargs
assert call_kwargs["timestamp"] == now.to_bytes(4, "little")
assert call_kwargs["msg"] == "hello" # Sender prefix stripped
@pytest.mark.asyncio
async def test_resend_outside_window_returns_400(self, test_db):
"""Resend after 30-second window fails."""
mc = _make_mc(name="MyNode")
chan_key = "bb" * 16
await ChannelRepository.upsert(key=chan_key, name="#old")
old_ts = int(time.time()) - 60 # 60 seconds ago
msg_id = await MessageRepository.create(
msg_type="CHAN",
text="MyNode: old message",
conversation_key=chan_key.upper(),
sender_timestamp=old_ts,
received_at=old_ts,
outgoing=True,
)
assert msg_id is not None
with (
patch("app.routers.messages.require_connected", return_value=mc),
pytest.raises(HTTPException) as exc_info,
):
await resend_channel_message(msg_id)
assert exc_info.value.status_code == 400
assert "expired" in exc_info.value.detail.lower()
@pytest.mark.asyncio
async def test_resend_non_outgoing_returns_400(self, test_db):
"""Resend of incoming message fails."""
mc = _make_mc(name="MyNode")
chan_key = "cc" * 16
await ChannelRepository.upsert(key=chan_key, name="#incoming")
now = int(time.time())
msg_id = await MessageRepository.create(
msg_type="CHAN",
text="SomeUser: incoming",
conversation_key=chan_key.upper(),
sender_timestamp=now,
received_at=now,
outgoing=False,
)
assert msg_id is not None
with (
patch("app.routers.messages.require_connected", return_value=mc),
pytest.raises(HTTPException) as exc_info,
):
await resend_channel_message(msg_id)
assert exc_info.value.status_code == 400
assert "outgoing" in exc_info.value.detail.lower()
@pytest.mark.asyncio
async def test_resend_dm_returns_400(self, test_db):
"""Resend of DM message fails."""
mc = _make_mc(name="MyNode")
pub_key = "dd" * 32
now = int(time.time())
msg_id = await MessageRepository.create(
msg_type="PRIV",
text="hello dm",
conversation_key=pub_key,
sender_timestamp=now,
received_at=now,
outgoing=True,
)
assert msg_id is not None
with (
patch("app.routers.messages.require_connected", return_value=mc),
pytest.raises(HTTPException) as exc_info,
):
await resend_channel_message(msg_id)
assert exc_info.value.status_code == 400
assert "channel" in exc_info.value.detail.lower()
@pytest.mark.asyncio
async def test_resend_nonexistent_returns_404(self, test_db):
"""Resend of nonexistent message fails."""
mc = _make_mc(name="MyNode")
with (
patch("app.routers.messages.require_connected", return_value=mc),
pytest.raises(HTTPException) as exc_info,
):
await resend_channel_message(999999)
assert exc_info.value.status_code == 404
@pytest.mark.asyncio
async def test_resend_strips_sender_prefix(self, test_db):
"""Resend strips the sender prefix before sending to radio."""
mc = _make_mc(name="MyNode")
chan_key = "ee" * 16
await ChannelRepository.upsert(key=chan_key, name="#strip")
now = int(time.time()) - 5
msg_id = await MessageRepository.create(
msg_type="CHAN",
text="MyNode: hello world",
conversation_key=chan_key.upper(),
sender_timestamp=now,
received_at=now,
outgoing=True,
)
assert msg_id is not None
with patch("app.routers.messages.require_connected", return_value=mc):
await resend_channel_message(msg_id)
call_kwargs = mc.commands.send_chan_msg.await_args.kwargs
assert call_kwargs["msg"] == "hello world"
-2
View File
@@ -41,13 +41,11 @@ class TestUpdateSettings:
AppSettingsUpdate(
max_radio_contacts=321,
advert_interval=3600,
experimental_channel_double_send=True,
)
)
assert result.max_radio_contacts == 321
assert result.advert_interval == 3600
assert result.experimental_channel_double_send is True
@pytest.mark.asyncio
async def test_empty_patch_returns_current_settings(self, test_db):