"""Tests for event handler logic. These tests verify the ACK tracking and repeat detection mechanisms that determine message delivery confirmation. """ import time from unittest.mock import AsyncMock, patch import pytest from app.event_handlers import ( _cleanup_expired_acks, _pending_acks, track_pending_ack, ) from app.packet_processor import ( _cleanup_expired_repeats, _pending_repeat_expiry, _pending_repeats, track_pending_repeat, ) @pytest.fixture(autouse=True) def clear_pending_state(): """Clear pending ACKs and repeats before each test.""" _pending_acks.clear() _pending_repeats.clear() _pending_repeat_expiry.clear() yield _pending_acks.clear() _pending_repeats.clear() _pending_repeat_expiry.clear() class TestAckTracking: """Test ACK tracking for direct messages.""" def test_track_pending_ack_stores_correctly(self): """Pending ACKs are stored with message ID and timeout.""" track_pending_ack("abc123", message_id=42, timeout_ms=5000) assert "abc123" in _pending_acks msg_id, created_at, timeout = _pending_acks["abc123"] assert msg_id == 42 assert timeout == 5000 assert created_at <= time.time() def test_multiple_acks_tracked_independently(self): """Multiple pending ACKs can be tracked simultaneously.""" track_pending_ack("ack1", message_id=1, timeout_ms=1000) track_pending_ack("ack2", message_id=2, timeout_ms=2000) track_pending_ack("ack3", message_id=3, timeout_ms=3000) assert len(_pending_acks) == 3 assert _pending_acks["ack1"][0] == 1 assert _pending_acks["ack2"][0] == 2 assert _pending_acks["ack3"][0] == 3 def test_cleanup_removes_expired_acks(self): """Expired ACKs are removed during cleanup.""" # Add an ACK that's "expired" (created in the past with short timeout) _pending_acks["expired"] = (1, time.time() - 100, 1000) # Created 100s ago, 1s timeout _pending_acks["valid"] = (2, time.time(), 60000) # Created now, 60s timeout _cleanup_expired_acks() assert "expired" not in _pending_acks assert "valid" in _pending_acks def test_cleanup_uses_2x_timeout_buffer(self): """Cleanup uses 2x timeout as buffer before expiring.""" # ACK created 5 seconds ago with 10 second timeout # 2x buffer = 20 seconds, so should NOT be expired yet _pending_acks["recent"] = (1, time.time() - 5, 10000) _cleanup_expired_acks() assert "recent" in _pending_acks class TestRepeatTracking: """Test repeat tracking for channel/flood messages.""" def test_track_pending_repeat_stores_correctly(self): """Pending repeats are stored with channel key, text hash, and timestamp.""" channel_key = "0123456789ABCDEF0123456789ABCDEF" track_pending_repeat(channel_key=channel_key, text="Hello", timestamp=1700000000, message_id=99) # Key is (channel_key, text_hash, timestamp) text_hash = str(hash("Hello")) key = (channel_key, text_hash, 1700000000) assert key in _pending_repeats assert _pending_repeats[key] == 99 def test_same_message_different_channels_tracked_separately(self): """Same message on different channels creates separate entries.""" track_pending_repeat(channel_key="AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA1", text="Test", timestamp=1000, message_id=1) track_pending_repeat(channel_key="AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA2", text="Test", timestamp=1000, message_id=2) assert len(_pending_repeats) == 2 def test_same_message_different_timestamps_tracked_separately(self): """Same message with different timestamps creates separate entries.""" channel_key = "BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB" track_pending_repeat(channel_key=channel_key, text="Test", timestamp=1000, message_id=1) track_pending_repeat(channel_key=channel_key, text="Test", timestamp=1001, message_id=2) assert len(_pending_repeats) == 2 def test_cleanup_removes_old_repeats(self): """Expired repeats are removed during cleanup.""" channel_key = "CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC" text_hash = str(hash("test")) old_key = (channel_key, text_hash, 1000) new_key = (channel_key, text_hash, 2000) # Set up entries with expiry times _pending_repeats[old_key] = 1 _pending_repeats[new_key] = 2 _pending_repeat_expiry[old_key] = time.time() - 10 # Already expired _pending_repeat_expiry[new_key] = time.time() + 30 # Still valid _cleanup_expired_repeats() assert old_key not in _pending_repeats assert new_key in _pending_repeats class TestAckEventHandler: """Test the on_ack event handler.""" @pytest.mark.asyncio async def test_ack_matches_pending_message(self): """Matching ACK code updates message and broadcasts.""" from app.event_handlers import on_ack # Setup pending ACK track_pending_ack("deadbeef", message_id=123, timeout_ms=10000) # Mock dependencies with patch("app.event_handlers.MessageRepository") as mock_repo, \ patch("app.event_handlers.broadcast_event") as mock_broadcast: mock_repo.increment_ack_count = AsyncMock(return_value=1) # Create mock event class MockEvent: payload = {"code": "deadbeef"} await on_ack(MockEvent()) # Verify ack count incremented mock_repo.increment_ack_count.assert_called_once_with(123) # Verify broadcast sent with ack_count mock_broadcast.assert_called_once_with("message_acked", {"message_id": 123, "ack_count": 1}) # Verify pending ACK removed assert "deadbeef" not in _pending_acks @pytest.mark.asyncio async def test_ack_no_match_does_nothing(self): """Non-matching ACK code is ignored.""" from app.event_handlers import on_ack track_pending_ack("expected", message_id=1, timeout_ms=10000) with patch("app.event_handlers.MessageRepository") as mock_repo, \ patch("app.event_handlers.broadcast_event") as mock_broadcast: mock_repo.increment_ack_count = AsyncMock() class MockEvent: payload = {"code": "different"} await on_ack(MockEvent()) mock_repo.increment_ack_count.assert_not_called() mock_broadcast.assert_not_called() assert "expected" in _pending_acks @pytest.mark.asyncio async def test_ack_empty_code_ignored(self): """ACK with empty code is ignored.""" from app.event_handlers import on_ack with patch("app.event_handlers.MessageRepository") as mock_repo: mock_repo.increment_ack_count = AsyncMock() class MockEvent: payload = {"code": ""} await on_ack(MockEvent()) mock_repo.increment_ack_count.assert_not_called() class TestContactMessageCLIFiltering: """Test that CLI responses (txt_type=1) are filtered out. This prevents duplicate messages when sending CLI commands to repeaters: the command endpoint returns the response directly, so we must NOT also persist/broadcast it via the normal message handler. """ @pytest.mark.asyncio async def test_cli_response_skipped_not_stored(self): """CLI responses (txt_type=1) are not stored in database.""" from app.event_handlers import on_contact_message with patch("app.event_handlers.MessageRepository") as mock_repo, \ patch("app.event_handlers.ContactRepository") as mock_contact_repo, \ patch("app.event_handlers.broadcast_event") as mock_broadcast: class MockEvent: payload = { "pubkey_prefix": "abc123def456", "text": "clock: 2024-01-01 12:00:00", "txt_type": 1, # CLI response "sender_timestamp": 1700000000, } await on_contact_message(MockEvent()) # Should NOT store in database mock_repo.create.assert_not_called() # Should NOT broadcast via WebSocket mock_broadcast.assert_not_called() # Should NOT update contact last_contacted mock_contact_repo.update_last_contacted.assert_not_called() @pytest.mark.asyncio async def test_normal_message_still_processed(self): """Normal messages (txt_type=0) are still processed normally.""" from app.event_handlers import on_contact_message with patch("app.event_handlers.MessageRepository") as mock_repo, \ patch("app.event_handlers.ContactRepository") as mock_contact_repo, \ patch("app.event_handlers.broadcast_event") as mock_broadcast: mock_repo.create = AsyncMock(return_value=42) mock_contact_repo.get_by_key_prefix = AsyncMock(return_value=None) class MockEvent: payload = { "pubkey_prefix": "abc123def456", "text": "Hello, this is a normal message", "txt_type": 0, # Normal message (default) "sender_timestamp": 1700000000, } await on_contact_message(MockEvent()) # SHOULD store in database mock_repo.create.assert_called_once() # SHOULD broadcast via WebSocket mock_broadcast.assert_called_once() @pytest.mark.asyncio async def test_missing_txt_type_defaults_to_normal(self): """Messages without txt_type field are treated as normal (not filtered).""" from app.event_handlers import on_contact_message with patch("app.event_handlers.MessageRepository") as mock_repo, \ patch("app.event_handlers.ContactRepository") as mock_contact_repo, \ patch("app.event_handlers.broadcast_event") as mock_broadcast: mock_repo.create = AsyncMock(return_value=42) mock_contact_repo.get_by_key_prefix = AsyncMock(return_value=None) class MockEvent: payload = { "pubkey_prefix": "abc123def456", "text": "Message without txt_type field", "sender_timestamp": 1700000000, # No txt_type field } await on_contact_message(MockEvent()) # SHOULD still be processed (defaults to txt_type=0) mock_repo.create.assert_called_once()