mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-07 09:13:04 +02:00
Add resend button for 30s
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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
@@ -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")
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user