mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-06 08:43:36 +02:00
Add missing tests and address AGENTS.md gaps
This commit is contained in:
@@ -64,8 +64,22 @@ test.describe('Channel messaging in #flightless', () => {
|
||||
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 });
|
||||
// Resend actions now live in the outgoing message status/path modal.
|
||||
// Open it from either pending status (?) or echo-path indicator (✓...).
|
||||
const statusOrPathTrigger = messageContainer.locator(
|
||||
'[title="Message status"], [title="View echo paths"]'
|
||||
);
|
||||
await expect(statusOrPathTrigger.first()).toBeVisible({ timeout: 15_000 });
|
||||
await statusOrPathTrigger.first().click();
|
||||
|
||||
const modal = page.getByRole('dialog');
|
||||
await expect(modal).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// Byte-perfect resend option (within 30s) includes this helper text.
|
||||
const resendButton = modal.getByRole('button', {
|
||||
name: /Only repeated by new repeaters/i,
|
||||
});
|
||||
await expect(resendButton).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
const resendResponsePromise = page.waitForResponse(
|
||||
(response) =>
|
||||
|
||||
@@ -2,53 +2,54 @@ import { test, expect } from '@playwright/test';
|
||||
import { getRadioConfig, updateRadioConfig } from '../helpers/api';
|
||||
|
||||
test.describe('Radio settings', () => {
|
||||
let originalName: string;
|
||||
|
||||
test.beforeAll(async () => {
|
||||
const config = await getRadioConfig();
|
||||
originalName = config.name;
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Restore original name via API
|
||||
try {
|
||||
await updateRadioConfig({ name: originalName });
|
||||
} catch {
|
||||
console.warn('Failed to restore radio name — manual intervention may be needed');
|
||||
}
|
||||
});
|
||||
|
||||
test('change radio name via settings UI and verify persistence', async ({ page }) => {
|
||||
// Radio names are limited to 8 characters
|
||||
const testName = 'E2Etest1';
|
||||
const originalConfig = await getRadioConfig();
|
||||
const originalName = originalConfig.name;
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.getByText('Connected')).toBeVisible();
|
||||
// Radio names are limited to 8 characters.
|
||||
// Use a randomized name per run to avoid collisions with stale state.
|
||||
const randomSuffix = Math.floor(Math.random() * 10000)
|
||||
.toString()
|
||||
.padStart(4, '0');
|
||||
const testName = `E2E${randomSuffix}`; // 7 chars
|
||||
|
||||
// --- Step 1: Change the name via settings UI ---
|
||||
await page.getByText('Settings').click();
|
||||
await page.getByRole('button', { name: /Identity/i }).click();
|
||||
try {
|
||||
await page.goto('/');
|
||||
await expect(page.getByText('Connected')).toBeVisible();
|
||||
|
||||
const nameInput = page.locator('#name');
|
||||
await nameInput.clear();
|
||||
await nameInput.fill(testName);
|
||||
// --- Step 1: Change the name via settings UI ---
|
||||
await page.getByText('Settings').click();
|
||||
await page.getByRole('button', { name: /Identity/i }).click();
|
||||
|
||||
await page.getByRole('button', { name: 'Save Identity Settings' }).click();
|
||||
await expect(page.getByText('Identity settings saved')).toBeVisible({ timeout: 10_000 });
|
||||
const nameInput = page.locator('#name');
|
||||
await nameInput.clear();
|
||||
await nameInput.fill(testName);
|
||||
|
||||
// Exit settings page mode
|
||||
await page.getByRole('button', { name: /Back to Chat/i }).click();
|
||||
await page.getByRole('button', { name: 'Save Identity Settings' }).click();
|
||||
await expect(page.getByText('Identity settings saved')).toBeVisible({ timeout: 10_000 });
|
||||
|
||||
// --- Step 2: Verify via API (now returns fresh data after send_appstart fix) ---
|
||||
const config = await getRadioConfig();
|
||||
expect(config.name).toBe(testName);
|
||||
// Exit settings page mode
|
||||
await page.getByRole('button', { name: /Back to Chat/i }).click();
|
||||
|
||||
// --- Step 3: Verify persistence across page reload ---
|
||||
await page.reload();
|
||||
await expect(page.getByText('Connected')).toBeVisible({ timeout: 15_000 });
|
||||
// --- Step 2: Verify via API (now returns fresh data after send_appstart fix) ---
|
||||
const config = await getRadioConfig();
|
||||
expect(config.name).toBe(testName);
|
||||
|
||||
// --- Step 3: Verify persistence across page reload ---
|
||||
await page.reload();
|
||||
await expect(page.getByText('Connected')).toBeVisible({ timeout: 15_000 });
|
||||
|
||||
await page.getByText('Settings').click();
|
||||
await page.getByRole('button', { name: /Identity/i }).click();
|
||||
await expect(page.locator('#name')).toHaveValue(testName, { timeout: 10_000 });
|
||||
} finally {
|
||||
// Always restore original name, even when assertions fail.
|
||||
try {
|
||||
await updateRadioConfig({ name: originalName });
|
||||
} catch {
|
||||
console.warn('Failed to restore radio name — manual intervention may be needed');
|
||||
}
|
||||
}
|
||||
|
||||
await page.getByText('Settings').click();
|
||||
await page.getByRole('button', { name: /Identity/i }).click();
|
||||
await expect(page.locator('#name')).toHaveValue(testName, { timeout: 10_000 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -6,6 +6,7 @@ messages, accumulate multi-path routing info, and ensure the dual DM processing
|
||||
paths (packet_processor + event_handler fallback) don't double-store messages.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
@@ -689,3 +690,108 @@ class TestDirectMessageDirectionDetection:
|
||||
|
||||
# Not our message - should return None without attempting decryption
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestConcurrentDMDedup:
|
||||
"""Test that concurrent DM processing deduplicates via atomic INSERT OR IGNORE.
|
||||
|
||||
On a mesh network, the same DM packet can arrive via two RF paths nearly
|
||||
simultaneously, causing two concurrent calls to create_dm_message_from_decrypted.
|
||||
SQLite's INSERT OR IGNORE ensures only one message is stored.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_identical_dms_only_store_once(self, test_db, captured_broadcasts):
|
||||
"""Two concurrent create_dm_message_from_decrypted calls with identical content
|
||||
should result in exactly one stored message."""
|
||||
from app.packet_processor import create_dm_message_from_decrypted
|
||||
|
||||
pkt1, _ = await RawPacketRepository.create(b"concurrent_dm_1", SENDER_TIMESTAMP)
|
||||
pkt2, _ = await RawPacketRepository.create(b"concurrent_dm_2", SENDER_TIMESTAMP + 1)
|
||||
|
||||
decrypted = DecryptedDirectMessage(
|
||||
timestamp=SENDER_TIMESTAMP,
|
||||
flags=0,
|
||||
message="Concurrent dedup test",
|
||||
dest_hash="fa",
|
||||
src_hash="a1",
|
||||
)
|
||||
|
||||
broadcasts, mock_broadcast = captured_broadcasts
|
||||
|
||||
with patch("app.packet_processor.broadcast_event", mock_broadcast):
|
||||
results = await asyncio.gather(
|
||||
create_dm_message_from_decrypted(
|
||||
packet_id=pkt1,
|
||||
decrypted=decrypted,
|
||||
their_public_key=CONTACT_PUB,
|
||||
our_public_key=OUR_PUB,
|
||||
received_at=SENDER_TIMESTAMP,
|
||||
path="aa",
|
||||
outgoing=False,
|
||||
),
|
||||
create_dm_message_from_decrypted(
|
||||
packet_id=pkt2,
|
||||
decrypted=decrypted,
|
||||
their_public_key=CONTACT_PUB,
|
||||
our_public_key=OUR_PUB,
|
||||
received_at=SENDER_TIMESTAMP + 1,
|
||||
path="bbcc",
|
||||
outgoing=False,
|
||||
),
|
||||
)
|
||||
|
||||
# Exactly one should create, the other should return None (duplicate)
|
||||
created = [r for r in results if r is not None]
|
||||
duplicates = [r for r in results if r is None]
|
||||
assert len(created) == 1
|
||||
assert len(duplicates) == 1
|
||||
|
||||
# Only one message in DB
|
||||
messages = await MessageRepository.get_all(
|
||||
msg_type="PRIV", conversation_key=CONTACT_PUB.lower(), limit=10
|
||||
)
|
||||
assert len(messages) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_channel_echoes_only_store_once(self, test_db, captured_broadcasts):
|
||||
"""Two concurrent create_message_from_decrypted calls with identical content
|
||||
should result in exactly one stored message."""
|
||||
from app.packet_processor import create_message_from_decrypted
|
||||
|
||||
pkt1, _ = await RawPacketRepository.create(b"concurrent_chan_1", SENDER_TIMESTAMP)
|
||||
pkt2, _ = await RawPacketRepository.create(b"concurrent_chan_2", SENDER_TIMESTAMP + 1)
|
||||
|
||||
broadcasts, mock_broadcast = captured_broadcasts
|
||||
|
||||
with patch("app.packet_processor.broadcast_event", mock_broadcast):
|
||||
results = await asyncio.gather(
|
||||
create_message_from_decrypted(
|
||||
packet_id=pkt1,
|
||||
channel_key=CHANNEL_KEY,
|
||||
sender="Alice",
|
||||
message_text="Concurrent channel test",
|
||||
timestamp=SENDER_TIMESTAMP,
|
||||
received_at=SENDER_TIMESTAMP,
|
||||
path="aa",
|
||||
),
|
||||
create_message_from_decrypted(
|
||||
packet_id=pkt2,
|
||||
channel_key=CHANNEL_KEY,
|
||||
sender="Alice",
|
||||
message_text="Concurrent channel test",
|
||||
timestamp=SENDER_TIMESTAMP,
|
||||
received_at=SENDER_TIMESTAMP + 1,
|
||||
path="bbcc",
|
||||
),
|
||||
)
|
||||
|
||||
created = [r for r in results if r is not None]
|
||||
duplicates = [r for r in results if r is None]
|
||||
assert len(created) == 1
|
||||
assert len(duplicates) == 1
|
||||
|
||||
messages = await MessageRepository.get_all(
|
||||
msg_type="CHAN", conversation_key=CHANNEL_KEY, limit=10
|
||||
)
|
||||
assert len(messages) == 1
|
||||
|
||||
@@ -96,6 +96,36 @@ class TestAckTracking:
|
||||
|
||||
assert "recent" in _pending_acks
|
||||
|
||||
def test_cleanup_handles_many_expired_acks_without_growth(self):
|
||||
"""Many tracked ACKs that all expire should all be cleaned up,
|
||||
preventing unbounded memory growth when no ACKs ever arrive."""
|
||||
now = time.time()
|
||||
for i in range(100):
|
||||
_pending_acks[f"ack_{i}"] = (i, now - 300, 5000) # All expired (300s ago, 5s timeout)
|
||||
|
||||
assert len(_pending_acks) == 100
|
||||
|
||||
cleanup_expired_acks()
|
||||
|
||||
assert len(_pending_acks) == 0
|
||||
|
||||
def test_cleanup_preserves_valid_acks_among_expired(self):
|
||||
"""Cleanup removes only expired ACKs, preserving valid ones."""
|
||||
now = time.time()
|
||||
# 50 expired
|
||||
for i in range(50):
|
||||
_pending_acks[f"expired_{i}"] = (i, now - 300, 5000)
|
||||
# 50 valid
|
||||
for i in range(50):
|
||||
_pending_acks[f"valid_{i}"] = (100 + i, now, 60000)
|
||||
|
||||
assert len(_pending_acks) == 100
|
||||
|
||||
cleanup_expired_acks()
|
||||
|
||||
assert len(_pending_acks) == 50
|
||||
assert all(k.startswith("valid_") for k in _pending_acks)
|
||||
|
||||
|
||||
class TestAckEventHandler:
|
||||
"""Test the on_ack event handler."""
|
||||
|
||||
@@ -588,3 +588,102 @@ class TestResendChannelMessage:
|
||||
|
||||
assert exc_info.value.status_code == 400
|
||||
assert "expired" in exc_info.value.detail.lower()
|
||||
|
||||
|
||||
class TestConcurrentChannelSends:
|
||||
"""Test that concurrent channel sends are serialized by the radio operation lock.
|
||||
|
||||
The send_channel_message endpoint uses set_channel (slot 0) then send_chan_msg.
|
||||
Concurrent sends must be serialized so two messages don't clobber the same
|
||||
temporary radio slot.
|
||||
"""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_sends_to_different_channels_both_succeed(self, test_db):
|
||||
"""Two concurrent send_channel_message calls to different channels
|
||||
should both succeed — the radio_operation lock serializes them."""
|
||||
mc = _make_mc(name="TestNode")
|
||||
chan_key_a = "aa" * 16
|
||||
chan_key_b = "bb" * 16
|
||||
await ChannelRepository.upsert(key=chan_key_a, name="#alpha")
|
||||
await ChannelRepository.upsert(key=chan_key_b, name="#bravo")
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
):
|
||||
results = await asyncio.gather(
|
||||
send_channel_message(
|
||||
SendChannelMessageRequest(channel_key=chan_key_a, text="Hello alpha")
|
||||
),
|
||||
send_channel_message(
|
||||
SendChannelMessageRequest(channel_key=chan_key_b, text="Hello bravo")
|
||||
),
|
||||
)
|
||||
|
||||
# Both should have returned Message objects with distinct IDs
|
||||
assert results[0].id != results[1].id
|
||||
assert results[0].conversation_key == chan_key_a.upper()
|
||||
assert results[1].conversation_key == chan_key_b.upper()
|
||||
|
||||
# set_channel should have been called twice (once per send, serialized)
|
||||
assert mc.commands.set_channel.await_count == 2
|
||||
|
||||
# send_chan_msg should have been called twice
|
||||
assert mc.commands.send_chan_msg.await_count == 2
|
||||
|
||||
# Both messages should be in DB
|
||||
msgs_a = await MessageRepository.get_all(
|
||||
msg_type="CHAN", conversation_key=chan_key_a.upper(), limit=10
|
||||
)
|
||||
msgs_b = await MessageRepository.get_all(
|
||||
msg_type="CHAN", conversation_key=chan_key_b.upper(), limit=10
|
||||
)
|
||||
assert len(msgs_a) == 1
|
||||
assert len(msgs_b) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_concurrent_sends_to_same_channel_both_succeed(self, test_db):
|
||||
"""Two concurrent sends to the same channel should both succeed
|
||||
with distinct timestamps (serialized, no slot clobber)."""
|
||||
mc = _make_mc(name="TestNode")
|
||||
chan_key = "cc" * 16
|
||||
await ChannelRepository.upsert(key=chan_key, name="#charlie")
|
||||
|
||||
call_count = 0
|
||||
|
||||
# Mock time to return incrementing seconds so the two messages
|
||||
# get distinct sender_timestamps (avoiding same-second collision).
|
||||
original_time = time.time
|
||||
|
||||
def advancing_time():
|
||||
nonlocal call_count
|
||||
call_count += 1
|
||||
return original_time() + call_count
|
||||
|
||||
with (
|
||||
patch("app.routers.messages.require_connected", return_value=mc),
|
||||
patch.object(radio_manager, "_meshcore", mc),
|
||||
patch("app.routers.messages.broadcast_event"),
|
||||
patch("app.routers.messages.time") as mock_time,
|
||||
):
|
||||
mock_time.time = advancing_time
|
||||
results = await asyncio.gather(
|
||||
send_channel_message(
|
||||
SendChannelMessageRequest(channel_key=chan_key, text="Message one")
|
||||
),
|
||||
send_channel_message(
|
||||
SendChannelMessageRequest(channel_key=chan_key, text="Message two")
|
||||
),
|
||||
)
|
||||
|
||||
assert results[0].id != results[1].id
|
||||
texts = {results[0].text, results[1].text}
|
||||
assert "TestNode: Message one" in texts
|
||||
assert "TestNode: Message two" in texts
|
||||
|
||||
msgs = await MessageRepository.get_all(
|
||||
msg_type="CHAN", conversation_key=chan_key.upper(), limit=10
|
||||
)
|
||||
assert len(msgs) == 2
|
||||
|
||||
Reference in New Issue
Block a user