Add multipath tracking

This commit is contained in:
Jack Kingsman
2026-01-18 20:00:32 -08:00
parent 0fea2889b2
commit c4ef8ec9cd
30 changed files with 1115 additions and 311 deletions

View File

@@ -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."""

View File

@@ -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()

View File

@@ -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
View 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