Add missing tests and address AGENTS.md gaps

This commit is contained in:
Jack Kingsman
2026-02-23 20:26:57 -08:00
parent b9de3b7dd7
commit 5d7a313c53
9 changed files with 462 additions and 65 deletions
+16 -2
View File
@@ -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) =>
+40 -39
View File
@@ -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 });
});
});
+106
View File
@@ -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
+30
View File
@@ -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."""
+99
View File
@@ -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