mirror of
https://github.com/jkingsman/Remote-Terminal-for-MeshCore.git
synced 2026-08-08 09:43:03 +02:00
Initial commit
This commit is contained in:
@@ -0,0 +1,16 @@
|
||||
"""Pytest configuration and shared fixtures."""
|
||||
|
||||
import pytest
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_channel_key():
|
||||
"""A sample 16-byte channel key for testing."""
|
||||
return bytes.fromhex("0123456789abcdef0123456789abcdef")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_hashtag_key():
|
||||
"""A channel key derived from hashtag name '#test'."""
|
||||
import hashlib
|
||||
return hashlib.sha256(b"#test").digest()[:16]
|
||||
@@ -0,0 +1,185 @@
|
||||
"""Tests for API endpoints.
|
||||
|
||||
These tests verify the REST API behavior for critical operations.
|
||||
Uses FastAPI's TestClient for synchronous testing.
|
||||
"""
|
||||
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, MagicMock, patch
|
||||
|
||||
|
||||
class TestHealthEndpoint:
|
||||
"""Test the health check endpoint."""
|
||||
|
||||
def test_health_returns_connection_status(self):
|
||||
"""Health endpoint returns radio connection status."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
with patch("app.routers.health.radio_manager") as mock_rm:
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.port = "/dev/ttyUSB0"
|
||||
|
||||
from app.main import app
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["radio_connected"] is True
|
||||
assert data["serial_port"] == "/dev/ttyUSB0"
|
||||
|
||||
def test_health_disconnected_state(self):
|
||||
"""Health endpoint reflects disconnected radio."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
with patch("app.routers.health.radio_manager") as mock_rm:
|
||||
mock_rm.is_connected = False
|
||||
mock_rm.port = None
|
||||
|
||||
from app.main import app
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/health")
|
||||
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["radio_connected"] is False
|
||||
assert data["serial_port"] is None
|
||||
|
||||
|
||||
class TestMessagesEndpoint:
|
||||
"""Test message-related endpoints."""
|
||||
|
||||
def test_send_direct_message_requires_connection(self):
|
||||
"""Sending message when disconnected returns 503."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
with patch("app.dependencies.radio_manager") as mock_rm:
|
||||
mock_rm.is_connected = False
|
||||
mock_rm.meshcore = None
|
||||
|
||||
from app.main import app
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/messages/direct",
|
||||
json={"destination": "abc123", "text": "Hello"}
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
assert "not connected" in response.json()["detail"].lower()
|
||||
|
||||
def test_send_channel_message_requires_connection(self):
|
||||
"""Sending channel message when disconnected returns 503."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
with patch("app.dependencies.radio_manager") as mock_rm:
|
||||
mock_rm.is_connected = False
|
||||
mock_rm.meshcore = None
|
||||
|
||||
from app.main import app
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/messages/channel",
|
||||
json={"channel_key": "0123456789ABCDEF0123456789ABCDEF", "text": "Hello"}
|
||||
)
|
||||
|
||||
assert response.status_code == 503
|
||||
|
||||
def test_send_direct_message_contact_not_found(self):
|
||||
"""Sending to unknown contact returns 404."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
mock_mc = MagicMock()
|
||||
mock_mc.get_contact_by_key_prefix.return_value = None
|
||||
|
||||
with patch("app.dependencies.radio_manager") as mock_rm, \
|
||||
patch("app.repository.ContactRepository.get_by_key_or_prefix", new_callable=AsyncMock) as mock_get:
|
||||
mock_rm.is_connected = True
|
||||
mock_rm.meshcore = mock_mc
|
||||
mock_get.return_value = None
|
||||
|
||||
from app.main import app
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.post(
|
||||
"/messages/direct",
|
||||
json={"destination": "nonexistent", "text": "Hello"}
|
||||
)
|
||||
|
||||
assert response.status_code == 404
|
||||
assert "not found" in response.json()["detail"].lower()
|
||||
|
||||
|
||||
class TestChannelsEndpoint:
|
||||
"""Test channel-related endpoints."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_hashtag_channel_derives_key(self):
|
||||
"""Creating hashtag channel derives key from name and stores in DB."""
|
||||
import hashlib
|
||||
from app.routers.channels import create_channel, CreateChannelRequest
|
||||
|
||||
with patch("app.routers.channels.ChannelRepository") as mock_repo:
|
||||
mock_repo.upsert = AsyncMock()
|
||||
|
||||
request = CreateChannelRequest(name="#mychannel")
|
||||
|
||||
result = await create_channel(request)
|
||||
|
||||
# Verify the key derivation - channel stored in DB, not pushed to radio
|
||||
expected_key_hex = hashlib.sha256(b"#mychannel").digest()[:16].hex().upper()
|
||||
mock_repo.upsert.assert_called_once()
|
||||
call_args = mock_repo.upsert.call_args
|
||||
assert call_args[1]["key"] == expected_key_hex
|
||||
assert call_args[1]["name"] == "#mychannel"
|
||||
assert call_args[1]["is_hashtag"] is True
|
||||
assert call_args[1]["on_radio"] is False # Not pushed to radio on create
|
||||
|
||||
# Verify response
|
||||
assert result.key == expected_key_hex
|
||||
assert result.name == "#mychannel"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_create_channel_with_explicit_key(self):
|
||||
"""Creating channel with explicit key uses provided key."""
|
||||
from app.routers.channels import create_channel, CreateChannelRequest
|
||||
|
||||
with patch("app.routers.channels.ChannelRepository") as mock_repo:
|
||||
mock_repo.upsert = AsyncMock()
|
||||
|
||||
explicit_key = "0123456789abcdef0123456789abcdef" # 32 hex chars = 16 bytes
|
||||
request = CreateChannelRequest(name="private", key=explicit_key)
|
||||
|
||||
result = await create_channel(request)
|
||||
|
||||
# Verify key stored in DB correctly (stored as uppercase hex)
|
||||
mock_repo.upsert.assert_called_once()
|
||||
call_args = mock_repo.upsert.call_args
|
||||
assert call_args[1]["key"] == explicit_key.upper()
|
||||
assert call_args[1]["name"] == "private"
|
||||
assert call_args[1]["on_radio"] is False
|
||||
|
||||
# Verify response
|
||||
assert result.key == explicit_key.upper()
|
||||
|
||||
|
||||
class TestPacketsEndpoint:
|
||||
"""Test packet decryption endpoints."""
|
||||
|
||||
def test_get_undecrypted_count(self):
|
||||
"""Get undecrypted packet count returns correct value."""
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
with patch("app.routers.packets.RawPacketRepository") as mock_repo:
|
||||
mock_repo.get_undecrypted_count = AsyncMock(return_value=42)
|
||||
|
||||
from app.main import app
|
||||
client = TestClient(app)
|
||||
|
||||
response = client.get("/packets/undecrypted/count")
|
||||
|
||||
assert response.status_code == 200
|
||||
assert response.json()["count"] == 42
|
||||
@@ -0,0 +1,302 @@
|
||||
"""Tests for the packet decoder module.
|
||||
|
||||
These tests verify the cryptographic operations for MeshCore packet decryption,
|
||||
which is critical for correctly interpreting mesh network messages.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import hmac
|
||||
|
||||
import pytest
|
||||
from Crypto.Cipher import AES
|
||||
|
||||
from app.decoder import (
|
||||
DecryptedGroupText,
|
||||
PacketInfo,
|
||||
PayloadType,
|
||||
RouteType,
|
||||
calculate_channel_hash,
|
||||
decrypt_group_text,
|
||||
parse_packet,
|
||||
try_decrypt_packet_with_channel_key,
|
||||
)
|
||||
|
||||
|
||||
class TestChannelKeyDerivation:
|
||||
"""Test channel key derivation from hashtag names."""
|
||||
|
||||
def test_hashtag_key_derivation(self):
|
||||
"""Hashtag channel keys are derived as SHA256(name)[:16]."""
|
||||
channel_name = "#test"
|
||||
expected_key = hashlib.sha256(channel_name.encode("utf-8")).digest()[:16]
|
||||
|
||||
# This matches the meshcore_py implementation
|
||||
assert len(expected_key) == 16
|
||||
|
||||
def test_channel_hash_calculation(self):
|
||||
"""Channel hash is the first byte of SHA256(key) as hex."""
|
||||
key = bytes(16) # All zeros
|
||||
expected_hash = format(hashlib.sha256(key).digest()[0], "02x")
|
||||
|
||||
result = calculate_channel_hash(key)
|
||||
|
||||
assert result == expected_hash
|
||||
assert len(result) == 2 # Two hex chars
|
||||
|
||||
|
||||
class TestPacketParsing:
|
||||
"""Test raw packet header parsing."""
|
||||
|
||||
def test_parse_flood_packet(self):
|
||||
"""Parse a FLOOD route type GROUP_TEXT packet."""
|
||||
# Header: route_type=FLOOD(1), payload_type=GROUP_TEXT(5), version=0
|
||||
# Header byte = (0 << 6) | (5 << 2) | 1 = 0x15
|
||||
# Path length = 0
|
||||
header = bytes([0x15, 0x00]) + b"payload_data"
|
||||
|
||||
result = parse_packet(header)
|
||||
|
||||
assert result is not None
|
||||
assert result.route_type == RouteType.FLOOD
|
||||
assert result.payload_type == PayloadType.GROUP_TEXT
|
||||
assert result.path_length == 0
|
||||
assert result.payload == b"payload_data"
|
||||
|
||||
def test_parse_direct_packet_with_path(self):
|
||||
"""Parse a DIRECT route type packet with path data."""
|
||||
# Header: route_type=DIRECT(2), payload_type=TEXT_MESSAGE(2), version=0
|
||||
# Header byte = (0 << 6) | (2 << 2) | 2 = 0x0A
|
||||
# Path length = 3, path = [0x01, 0x02, 0x03]
|
||||
header = bytes([0x0A, 0x03, 0x01, 0x02, 0x03]) + b"msg"
|
||||
|
||||
result = parse_packet(header)
|
||||
|
||||
assert result is not None
|
||||
assert result.route_type == RouteType.DIRECT
|
||||
assert result.payload_type == PayloadType.TEXT_MESSAGE
|
||||
assert result.path_length == 3
|
||||
assert result.payload == b"msg"
|
||||
|
||||
def test_parse_transport_flood_skips_transport_code(self):
|
||||
"""TRANSPORT_FLOOD packets have 4-byte transport code to skip."""
|
||||
# Header: route_type=TRANSPORT_FLOOD(0), payload_type=GROUP_TEXT(5)
|
||||
# Header byte = (0 << 6) | (5 << 2) | 0 = 0x14
|
||||
# Transport code (4 bytes) + path_length + payload
|
||||
header = bytes([0x14, 0xAA, 0xBB, 0xCC, 0xDD, 0x00]) + b"data"
|
||||
|
||||
result = parse_packet(header)
|
||||
|
||||
assert result is not None
|
||||
assert result.route_type == RouteType.TRANSPORT_FLOOD
|
||||
assert result.payload_type == PayloadType.GROUP_TEXT
|
||||
assert result.payload == b"data"
|
||||
|
||||
def test_parse_empty_packet_returns_none(self):
|
||||
"""Empty packets return None."""
|
||||
assert parse_packet(b"") is None
|
||||
assert parse_packet(b"\x00") is None
|
||||
|
||||
def test_parse_truncated_packet_returns_none(self):
|
||||
"""Truncated packets return None."""
|
||||
# Packet claiming path_length=10 but no path data
|
||||
header = bytes([0x15, 0x0A])
|
||||
|
||||
assert parse_packet(header) is None
|
||||
|
||||
|
||||
class TestGroupTextDecryption:
|
||||
"""Test GROUP_TEXT (channel message) decryption."""
|
||||
|
||||
def _create_encrypted_payload(
|
||||
self, channel_key: bytes, timestamp: int, flags: int, message: str
|
||||
) -> bytes:
|
||||
"""Helper to create a valid encrypted GROUP_TEXT payload."""
|
||||
# Build plaintext: timestamp(4) + flags(1) + message + null terminator
|
||||
plaintext = (
|
||||
timestamp.to_bytes(4, "little")
|
||||
+ bytes([flags])
|
||||
+ message.encode("utf-8")
|
||||
+ b"\x00"
|
||||
)
|
||||
|
||||
# Pad to 16-byte boundary
|
||||
pad_len = (16 - len(plaintext) % 16) % 16
|
||||
if pad_len == 0:
|
||||
pad_len = 16
|
||||
plaintext += bytes(pad_len)
|
||||
|
||||
# Encrypt with AES-128 ECB
|
||||
cipher = AES.new(channel_key, AES.MODE_ECB)
|
||||
ciphertext = cipher.encrypt(plaintext)
|
||||
|
||||
# Calculate MAC: HMAC-SHA256(channel_secret, ciphertext)[:2]
|
||||
channel_secret = channel_key + bytes(16)
|
||||
mac = hmac.new(channel_secret, ciphertext, hashlib.sha256).digest()[:2]
|
||||
|
||||
# Build payload: channel_hash(1) + mac(2) + ciphertext
|
||||
channel_hash = hashlib.sha256(channel_key).digest()[0:1]
|
||||
|
||||
return channel_hash + mac + ciphertext
|
||||
|
||||
def test_decrypt_valid_message(self):
|
||||
"""Decrypt a valid GROUP_TEXT message."""
|
||||
channel_key = hashlib.sha256(b"#testchannel").digest()[:16]
|
||||
timestamp = 1700000000
|
||||
message = "TestUser: Hello world"
|
||||
|
||||
payload = self._create_encrypted_payload(channel_key, timestamp, 0, message)
|
||||
|
||||
result = decrypt_group_text(payload, channel_key)
|
||||
|
||||
assert result is not None
|
||||
assert result.timestamp == timestamp
|
||||
assert result.sender == "TestUser"
|
||||
assert result.message == "Hello world"
|
||||
|
||||
def test_decrypt_message_without_sender_prefix(self):
|
||||
"""Messages without 'sender: ' format have no parsed sender."""
|
||||
channel_key = hashlib.sha256(b"#test").digest()[:16]
|
||||
message = "Just a plain message"
|
||||
|
||||
payload = self._create_encrypted_payload(channel_key, 1234567890, 0, message)
|
||||
|
||||
result = decrypt_group_text(payload, channel_key)
|
||||
|
||||
assert result is not None
|
||||
assert result.sender is None
|
||||
assert result.message == "Just a plain message"
|
||||
|
||||
def test_decrypt_with_wrong_key_fails(self):
|
||||
"""Decryption with wrong key fails MAC verification."""
|
||||
correct_key = hashlib.sha256(b"#correct").digest()[:16]
|
||||
wrong_key = hashlib.sha256(b"#wrong").digest()[:16]
|
||||
|
||||
payload = self._create_encrypted_payload(correct_key, 1234567890, 0, "test")
|
||||
|
||||
result = decrypt_group_text(payload, wrong_key)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_decrypt_corrupted_mac_fails(self):
|
||||
"""Corrupted MAC causes decryption to fail."""
|
||||
channel_key = hashlib.sha256(b"#test").digest()[:16]
|
||||
payload = self._create_encrypted_payload(channel_key, 1234567890, 0, "test")
|
||||
|
||||
# Corrupt the MAC (bytes 1-2)
|
||||
corrupted = payload[:1] + bytes([payload[1] ^ 0xFF, payload[2] ^ 0xFF]) + payload[3:]
|
||||
|
||||
result = decrypt_group_text(corrupted, channel_key)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestTryDecryptPacket:
|
||||
"""Test the full packet decryption pipeline."""
|
||||
|
||||
def test_only_group_text_packets_decrypted(self):
|
||||
"""Non-GROUP_TEXT packets return None."""
|
||||
# TEXT_MESSAGE packet (payload_type=2)
|
||||
# Header: route_type=FLOOD(1), payload_type=TEXT_MESSAGE(2)
|
||||
# Header byte = (0 << 6) | (2 << 2) | 1 = 0x09
|
||||
packet = bytes([0x09, 0x00]) + b"some_data"
|
||||
key = bytes(16)
|
||||
|
||||
result = try_decrypt_packet_with_channel_key(packet, key)
|
||||
|
||||
assert result is None
|
||||
|
||||
def test_channel_hash_mismatch_returns_none(self):
|
||||
"""Packets with non-matching channel hash return None early."""
|
||||
# GROUP_TEXT packet with channel_hash that doesn't match our key
|
||||
# Header: route_type=FLOOD(1), payload_type=GROUP_TEXT(5)
|
||||
# Header byte = 0x15
|
||||
wrong_hash = bytes([0xFF]) # Unlikely to match any real key
|
||||
packet = bytes([0x15, 0x00]) + wrong_hash + bytes(20)
|
||||
|
||||
key = hashlib.sha256(b"#test").digest()[:16]
|
||||
|
||||
result = try_decrypt_packet_with_channel_key(packet, key)
|
||||
|
||||
assert result is None
|
||||
|
||||
|
||||
class TestRealWorldPackets:
|
||||
"""Test with real captured packets to ensure decoder matches protocol."""
|
||||
|
||||
def test_decrypt_six77_channel_message(self):
|
||||
"""Decrypt a real packet from #six77 channel."""
|
||||
# Real packet captured from #six77 hashtag channel
|
||||
packet_hex = (
|
||||
"1500E69C7A89DD0AF6A2D69F5823B88F9720731E4B887C56932BF889255D8D926D"
|
||||
"99195927144323A42DD8A158F878B518B8304DF55E80501C7D02A9FFD578D35182"
|
||||
"83156BBA257BF8413E80A237393B2E4149BBBC864371140A9BBC4E23EB9BF203EF"
|
||||
"0D029214B3E3AAC3C0295690ACDB89A28619E7E5F22C83E16073AD679D25FA904D"
|
||||
"07E5ACF1DB5A7C77D7E1719FB9AE5BF55541EE0D7F59ED890E12CF0FEED6700818"
|
||||
)
|
||||
packet = bytes.fromhex(packet_hex)
|
||||
|
||||
# Verify key derivation: SHA256("#six77")[:16]
|
||||
channel_key = hashlib.sha256(b"#six77").digest()[:16]
|
||||
assert channel_key.hex() == "7aba109edcf304a84433cb71d0f3ab73"
|
||||
|
||||
# Decrypt the packet
|
||||
result = try_decrypt_packet_with_channel_key(packet, channel_key)
|
||||
|
||||
assert result is not None
|
||||
assert result.sender == "Flightless🥝"
|
||||
assert "hashtag room is essentially public" in result.message
|
||||
assert result.channel_hash == "e6"
|
||||
assert result.timestamp == 1766604717
|
||||
|
||||
|
||||
class TestAdvertisementParsing:
|
||||
"""Test parsing of advertisement packets."""
|
||||
|
||||
def test_parse_real_advertisement(self):
|
||||
"""Parse a real advertisement packet from 'Flightless 🥝'."""
|
||||
from app.decoder import try_parse_advertisement
|
||||
|
||||
# Real advertisement packet
|
||||
packet_hex = (
|
||||
"1200AE92564C5C9884854F04F469BBB2BAB8871A078053AF6CF4AA2C014B18CE8A83"
|
||||
"54B55C6934EAC9C9BD98A99788B1725379BB25863731ADAB605BCD62F0BA0E467483"
|
||||
"E0A21E81C9279665D117B265B192890B8E0C2AE03E48DA5AA28C3EFB842EF656670B"
|
||||
"915128D902B72DB5F8466C696768746C65737320F09FA59D"
|
||||
)
|
||||
packet = bytes.fromhex(packet_hex)
|
||||
|
||||
result = try_parse_advertisement(packet)
|
||||
|
||||
assert result is not None
|
||||
# Public key is the first 32 bytes of payload
|
||||
assert result.public_key == "ae92564c5c9884854f04f469bbb2bab8871a078053af6cf4aa2c014b18ce8a83"
|
||||
# Name should be extracted from the end
|
||||
assert result.name == "Flightless 🥝"
|
||||
|
||||
def test_parse_advertisement_extracts_public_key(self):
|
||||
"""Advertisement parsing extracts the public key correctly."""
|
||||
from app.decoder import parse_packet, PayloadType
|
||||
|
||||
packet_hex = (
|
||||
"1200AE92564C5C9884854F04F469BBB2BAB8871A078053AF6CF4AA2C014B18CE8A83"
|
||||
"54B55C6934EAC9C9BD98A99788B1725379BB25863731ADAB605BCD62F0BA0E467483"
|
||||
"E0A21E81C9279665D117B265B192890B8E0C2AE03E48DA5AA28C3EFB842EF656670B"
|
||||
"915128D902B72DB5F8466C696768746C65737320F09FA59D"
|
||||
)
|
||||
packet = bytes.fromhex(packet_hex)
|
||||
|
||||
# Verify packet is recognized as ADVERT type
|
||||
info = parse_packet(packet)
|
||||
assert info is not None
|
||||
assert info.payload_type == PayloadType.ADVERT
|
||||
|
||||
def test_non_advertisement_returns_none(self):
|
||||
"""Non-advertisement packets return None from try_parse_advertisement."""
|
||||
from app.decoder import try_parse_advertisement
|
||||
|
||||
# GROUP_TEXT packet, not an advertisement
|
||||
packet = bytes([0x15, 0x00]) + bytes(50)
|
||||
|
||||
result = try_parse_advertisement(packet)
|
||||
|
||||
assert result is None
|
||||
@@ -0,0 +1,195 @@
|
||||
"""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.mark_acked = AsyncMock()
|
||||
|
||||
# Create mock event
|
||||
class MockEvent:
|
||||
payload = {"code": "deadbeef"}
|
||||
|
||||
await on_ack(MockEvent())
|
||||
|
||||
# Verify message marked as acked
|
||||
mock_repo.mark_acked.assert_called_once_with(123)
|
||||
|
||||
# Verify broadcast sent
|
||||
mock_broadcast.assert_called_once_with("message_acked", {"message_id": 123})
|
||||
|
||||
# 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:
|
||||
|
||||
class MockEvent:
|
||||
payload = {"code": "different"}
|
||||
|
||||
await on_ack(MockEvent())
|
||||
|
||||
mock_repo.mark_acked.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.mark_acked = AsyncMock()
|
||||
|
||||
class MockEvent:
|
||||
payload = {"code": ""}
|
||||
|
||||
await on_ack(MockEvent())
|
||||
|
||||
mock_repo.mark_acked.assert_not_called()
|
||||
Reference in New Issue
Block a user