mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-05-03 03:53:10 +02:00
Add multipath tracking
This commit is contained in:
@@ -1,7 +1,7 @@
|
||||
"""Tests for event handler logic.
|
||||
|
||||
These tests verify the ACK tracking and repeat detection mechanisms
|
||||
that determine message delivery confirmation.
|
||||
These tests verify the ACK tracking mechanism for direct message
|
||||
delivery confirmation.
|
||||
"""
|
||||
|
||||
import time
|
||||
@@ -16,25 +16,15 @@ from app.event_handlers import (
|
||||
register_event_handlers,
|
||||
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_test_state():
|
||||
"""Clear pending ACKs, repeats, and subscriptions before each test."""
|
||||
"""Clear pending ACKs and subscriptions before each test."""
|
||||
_pending_acks.clear()
|
||||
_pending_repeats.clear()
|
||||
_pending_repeat_expiry.clear()
|
||||
_active_subscriptions.clear()
|
||||
yield
|
||||
_pending_acks.clear()
|
||||
_pending_repeats.clear()
|
||||
_pending_repeat_expiry.clear()
|
||||
_active_subscriptions.clear()
|
||||
|
||||
|
||||
@@ -84,67 +74,6 @@ class TestAckTracking:
|
||||
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."""
|
||||
|
||||
|
||||
@@ -100,8 +100,8 @@ class TestMigration001:
|
||||
# Run migrations
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
assert applied == 7 # All 7 migrations run
|
||||
assert await get_version(conn) == 7
|
||||
assert applied == 8 # All 8 migrations run
|
||||
assert await get_version(conn) == 8
|
||||
|
||||
# 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 == 7 # All 7 migrations run
|
||||
assert applied1 == 8 # All 8 migrations run
|
||||
assert applied2 == 0 # No migrations on second run
|
||||
assert await get_version(conn) == 7
|
||||
assert await get_version(conn) == 8
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
@@ -245,9 +245,9 @@ class TestMigration001:
|
||||
# Run migrations - should not fail
|
||||
applied = await run_migrations(conn)
|
||||
|
||||
# All 7 migrations applied (version incremented) but no error
|
||||
assert applied == 7
|
||||
assert await get_version(conn) == 7
|
||||
# All 8 migrations applied (version incremented) but no error
|
||||
assert applied == 8
|
||||
assert await get_version(conn) == 8
|
||||
finally:
|
||||
await conn.close()
|
||||
|
||||
|
||||
@@ -430,7 +430,7 @@ class TestCreateMessageFromDecrypted:
|
||||
assert broadcast["text"] == "TestSender: Hello world"
|
||||
assert broadcast["sender_timestamp"] == 1700000000
|
||||
assert broadcast["received_at"] == 1700000001
|
||||
assert broadcast["path"] is None # Historical decryption has no path info
|
||||
assert broadcast["paths"] is None # Historical decryption has no path info
|
||||
assert broadcast["outgoing"] is False
|
||||
assert broadcast["acked"] == 0
|
||||
|
||||
@@ -551,8 +551,11 @@ class TestMessageBroadcastStructure:
|
||||
|
||||
broadcast = message_broadcasts[0]["data"]
|
||||
# Real-time processing extracts path from packet (flood packets have empty path)
|
||||
assert "path" in broadcast
|
||||
# The test packet is a flood packet, so path should be empty string ""
|
||||
assert "paths" in broadcast
|
||||
# The test packet is a flood packet, so paths should contain a single entry with empty path
|
||||
assert broadcast["paths"] is not None
|
||||
assert len(broadcast["paths"]) == 1
|
||||
assert broadcast["paths"][0]["path"] == "" # Empty string = direct/flood
|
||||
|
||||
|
||||
class TestRawPacketStorage:
|
||||
|
||||
417
tests/test_repository.py
Normal file
417
tests/test_repository.py
Normal file
@@ -0,0 +1,417 @@
|
||||
"""Tests for repository layer, specifically the add_path method."""
|
||||
|
||||
import json
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
class TestMessageRepositoryAddPath:
|
||||
"""Test MessageRepository.add_path method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_path_to_message_with_no_existing_paths(self):
|
||||
"""Adding a path to a message with no existing paths creates a new array."""
|
||||
|
||||
# Mock the database connection
|
||||
mock_conn = AsyncMock()
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.fetchone = AsyncMock(return_value={"paths": None})
|
||||
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
||||
mock_conn.commit = AsyncMock()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.conn = mock_conn
|
||||
|
||||
with patch("app.repository.db", mock_db):
|
||||
from app.repository import MessageRepository
|
||||
|
||||
result = await MessageRepository.add_path(
|
||||
message_id=42, path="1A2B", received_at=1700000000
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].path == "1A2B"
|
||||
assert result[0].received_at == 1700000000
|
||||
|
||||
# Verify the UPDATE was called with correct JSON
|
||||
update_call = mock_conn.execute.call_args_list[-1]
|
||||
assert update_call[0][0] == "UPDATE messages SET paths = ? WHERE id = ?"
|
||||
paths_json = update_call[0][1][0]
|
||||
parsed = json.loads(paths_json)
|
||||
assert len(parsed) == 1
|
||||
assert parsed[0]["path"] == "1A2B"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_path_to_message_with_existing_paths(self):
|
||||
"""Adding a path to a message with existing paths appends to the array."""
|
||||
existing_paths = json.dumps([{"path": "1A", "received_at": 1699999999}])
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.fetchone = AsyncMock(return_value={"paths": existing_paths})
|
||||
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
||||
mock_conn.commit = AsyncMock()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.conn = mock_conn
|
||||
|
||||
with patch("app.repository.db", mock_db):
|
||||
from app.repository import MessageRepository
|
||||
|
||||
result = await MessageRepository.add_path(
|
||||
message_id=42, path="2B3C", received_at=1700000000
|
||||
)
|
||||
|
||||
assert len(result) == 2
|
||||
assert result[0].path == "1A"
|
||||
assert result[1].path == "2B3C"
|
||||
|
||||
# Verify the UPDATE contains both paths
|
||||
update_call = mock_conn.execute.call_args_list[-1]
|
||||
paths_json = update_call[0][1][0]
|
||||
parsed = json.loads(paths_json)
|
||||
assert len(parsed) == 2
|
||||
assert parsed[0]["path"] == "1A"
|
||||
assert parsed[1]["path"] == "2B3C"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_path_to_nonexistent_message_returns_empty(self):
|
||||
"""Adding a path to a nonexistent message returns empty list."""
|
||||
mock_conn = AsyncMock()
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.fetchone = AsyncMock(return_value=None)
|
||||
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.conn = mock_conn
|
||||
|
||||
with patch("app.repository.db", mock_db):
|
||||
from app.repository import MessageRepository
|
||||
|
||||
result = await MessageRepository.add_path(
|
||||
message_id=999, path="1A2B", received_at=1700000000
|
||||
)
|
||||
|
||||
assert result == []
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_path_handles_corrupted_json(self):
|
||||
"""Adding a path handles corrupted JSON in existing paths gracefully."""
|
||||
mock_conn = AsyncMock()
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.fetchone = AsyncMock(return_value={"paths": "not valid json {"})
|
||||
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
||||
mock_conn.commit = AsyncMock()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.conn = mock_conn
|
||||
|
||||
with patch("app.repository.db", mock_db):
|
||||
from app.repository import MessageRepository
|
||||
|
||||
result = await MessageRepository.add_path(
|
||||
message_id=42, path="1A2B", received_at=1700000000
|
||||
)
|
||||
|
||||
# Should recover and create new array with just the new path
|
||||
assert len(result) == 1
|
||||
assert result[0].path == "1A2B"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_path_uses_current_time_if_not_provided(self):
|
||||
"""Adding a path without received_at uses current timestamp."""
|
||||
mock_conn = AsyncMock()
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.fetchone = AsyncMock(return_value={"paths": None})
|
||||
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
||||
mock_conn.commit = AsyncMock()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.conn = mock_conn
|
||||
|
||||
with patch("app.repository.db", mock_db), patch("app.repository.time") as mock_time:
|
||||
mock_time.time.return_value = 1700000500.5
|
||||
|
||||
from app.repository import MessageRepository
|
||||
|
||||
result = await MessageRepository.add_path(message_id=42, path="1A2B")
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].received_at == 1700000500
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_add_empty_path_for_direct_message(self):
|
||||
"""Adding an empty path (direct message) works correctly."""
|
||||
mock_conn = AsyncMock()
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.fetchone = AsyncMock(return_value={"paths": None})
|
||||
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
||||
mock_conn.commit = AsyncMock()
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.conn = mock_conn
|
||||
|
||||
with patch("app.repository.db", mock_db):
|
||||
from app.repository import MessageRepository
|
||||
|
||||
result = await MessageRepository.add_path(
|
||||
message_id=42, path="", received_at=1700000000
|
||||
)
|
||||
|
||||
assert len(result) == 1
|
||||
assert result[0].path == "" # Empty path = direct
|
||||
assert result[0].received_at == 1700000000
|
||||
|
||||
|
||||
class TestMessageRepositoryGetByContent:
|
||||
"""Test MessageRepository.get_by_content method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_content_finds_matching_message(self):
|
||||
"""Returns message when all content fields match."""
|
||||
mock_conn = AsyncMock()
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.fetchone = AsyncMock(
|
||||
return_value={
|
||||
"id": 42,
|
||||
"type": "CHAN",
|
||||
"conversation_key": "ABCD1234",
|
||||
"text": "Hello world",
|
||||
"sender_timestamp": 1700000000,
|
||||
"received_at": 1700000001,
|
||||
"paths": None,
|
||||
"txt_type": 0,
|
||||
"signature": None,
|
||||
"outgoing": 0,
|
||||
"acked": 1,
|
||||
}
|
||||
)
|
||||
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.conn = mock_conn
|
||||
|
||||
with patch("app.repository.db", mock_db):
|
||||
from app.repository import MessageRepository
|
||||
|
||||
result = await MessageRepository.get_by_content(
|
||||
msg_type="CHAN",
|
||||
conversation_key="ABCD1234",
|
||||
text="Hello world",
|
||||
sender_timestamp=1700000000,
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.id == 42
|
||||
assert result.type == "CHAN"
|
||||
assert result.conversation_key == "ABCD1234"
|
||||
assert result.text == "Hello world"
|
||||
assert result.acked == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_content_returns_none_when_not_found(self):
|
||||
"""Returns None when no message matches."""
|
||||
mock_conn = AsyncMock()
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.fetchone = AsyncMock(return_value=None)
|
||||
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.conn = mock_conn
|
||||
|
||||
with patch("app.repository.db", mock_db):
|
||||
from app.repository import MessageRepository
|
||||
|
||||
result = await MessageRepository.get_by_content(
|
||||
msg_type="CHAN",
|
||||
conversation_key="NONEXISTENT",
|
||||
text="Not found",
|
||||
sender_timestamp=1700000000,
|
||||
)
|
||||
|
||||
assert result is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_content_handles_null_sender_timestamp(self):
|
||||
"""Handles messages with NULL sender_timestamp correctly."""
|
||||
mock_conn = AsyncMock()
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.fetchone = AsyncMock(
|
||||
return_value={
|
||||
"id": 43,
|
||||
"type": "PRIV",
|
||||
"conversation_key": "abc123",
|
||||
"text": "Test message",
|
||||
"sender_timestamp": None,
|
||||
"received_at": 1700000001,
|
||||
"paths": None,
|
||||
"txt_type": 0,
|
||||
"signature": None,
|
||||
"outgoing": 1,
|
||||
"acked": 0,
|
||||
}
|
||||
)
|
||||
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.conn = mock_conn
|
||||
|
||||
with patch("app.repository.db", mock_db):
|
||||
from app.repository import MessageRepository
|
||||
|
||||
result = await MessageRepository.get_by_content(
|
||||
msg_type="PRIV",
|
||||
conversation_key="abc123",
|
||||
text="Test message",
|
||||
sender_timestamp=None,
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.sender_timestamp is None
|
||||
assert result.outgoing is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_content_parses_paths_correctly(self):
|
||||
"""Parses paths JSON into MessagePath objects."""
|
||||
paths_json = json.dumps(
|
||||
[
|
||||
{"path": "1A2B", "received_at": 1700000000},
|
||||
{"path": "3C4D", "received_at": 1700000001},
|
||||
]
|
||||
)
|
||||
|
||||
mock_conn = AsyncMock()
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.fetchone = AsyncMock(
|
||||
return_value={
|
||||
"id": 44,
|
||||
"type": "CHAN",
|
||||
"conversation_key": "ABCD1234",
|
||||
"text": "Multi-path message",
|
||||
"sender_timestamp": 1700000000,
|
||||
"received_at": 1700000000,
|
||||
"paths": paths_json,
|
||||
"txt_type": 0,
|
||||
"signature": None,
|
||||
"outgoing": 0,
|
||||
"acked": 2,
|
||||
}
|
||||
)
|
||||
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.conn = mock_conn
|
||||
|
||||
with patch("app.repository.db", mock_db):
|
||||
from app.repository import MessageRepository
|
||||
|
||||
result = await MessageRepository.get_by_content(
|
||||
msg_type="CHAN",
|
||||
conversation_key="ABCD1234",
|
||||
text="Multi-path message",
|
||||
sender_timestamp=1700000000,
|
||||
)
|
||||
|
||||
assert result is not None
|
||||
assert result.paths is not None
|
||||
assert len(result.paths) == 2
|
||||
assert result.paths[0].path == "1A2B"
|
||||
assert result.paths[1].path == "3C4D"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_by_content_handles_corrupted_paths_json(self):
|
||||
"""Handles corrupted paths JSON gracefully."""
|
||||
mock_conn = AsyncMock()
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.fetchone = AsyncMock(
|
||||
return_value={
|
||||
"id": 45,
|
||||
"type": "CHAN",
|
||||
"conversation_key": "ABCD1234",
|
||||
"text": "Corrupted paths",
|
||||
"sender_timestamp": 1700000000,
|
||||
"received_at": 1700000000,
|
||||
"paths": "not valid json {",
|
||||
"txt_type": 0,
|
||||
"signature": None,
|
||||
"outgoing": 0,
|
||||
"acked": 0,
|
||||
}
|
||||
)
|
||||
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.conn = mock_conn
|
||||
|
||||
with patch("app.repository.db", mock_db):
|
||||
from app.repository import MessageRepository
|
||||
|
||||
result = await MessageRepository.get_by_content(
|
||||
msg_type="CHAN",
|
||||
conversation_key="ABCD1234",
|
||||
text="Corrupted paths",
|
||||
sender_timestamp=1700000000,
|
||||
)
|
||||
|
||||
# Should return message with paths=None instead of raising
|
||||
assert result is not None
|
||||
assert result.paths is None
|
||||
|
||||
|
||||
class TestMessageRepositoryGetAckCount:
|
||||
"""Test MessageRepository.get_ack_count method."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_ack_count_returns_count(self):
|
||||
"""Returns ack count for existing message."""
|
||||
mock_conn = AsyncMock()
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.fetchone = AsyncMock(return_value={"acked": 3})
|
||||
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.conn = mock_conn
|
||||
|
||||
with patch("app.repository.db", mock_db):
|
||||
from app.repository import MessageRepository
|
||||
|
||||
result = await MessageRepository.get_ack_count(message_id=42)
|
||||
|
||||
assert result == 3
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_ack_count_returns_zero_for_nonexistent(self):
|
||||
"""Returns 0 for nonexistent message."""
|
||||
mock_conn = AsyncMock()
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.fetchone = AsyncMock(return_value=None)
|
||||
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.conn = mock_conn
|
||||
|
||||
with patch("app.repository.db", mock_db):
|
||||
from app.repository import MessageRepository
|
||||
|
||||
result = await MessageRepository.get_ack_count(message_id=999)
|
||||
|
||||
assert result == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_ack_count_returns_zero_for_unacked(self):
|
||||
"""Returns 0 for message with no acks."""
|
||||
mock_conn = AsyncMock()
|
||||
mock_cursor = AsyncMock()
|
||||
mock_cursor.fetchone = AsyncMock(return_value={"acked": 0})
|
||||
mock_conn.execute = AsyncMock(return_value=mock_cursor)
|
||||
|
||||
mock_db = MagicMock()
|
||||
mock_db.conn = mock_conn
|
||||
|
||||
with patch("app.repository.db", mock_db):
|
||||
from app.repository import MessageRepository
|
||||
|
||||
result = await MessageRepository.get_ack_count(message_id=42)
|
||||
|
||||
assert result == 0
|
||||
Reference in New Issue
Block a user