""" Tests for M02 — Discovery (Peer Registry, mDNS, UDP, Manifest Fetching) Covers: - PeerRegistry operations (upsert, remove, get, all, for_community, prune_stale) - mDNS announcer and browser - UDP multicast announcer and listener - Manifest fetch and validation - Foreign community filtering - Error codes: socket_in_use, mdns_unavailable, manifest_fetch_failed, manifest_invalid - Edge cases: multi-interface, privacy, stale peer pruning, refresh timing - Integration: two-node discovery via mDNS/UDP, community filtering """ import pytest from dataclasses import dataclass from time import time, monotonic from typing import AsyncIterator class TestM02PeerRegistry: """Test PeerRecord and PeerRegistry core operations.""" def test_peer_registry_upsert_new_returns_true(self): """Happy: Upsert new peer returns True.""" try: from hearthnet.discovery.peers import PeerRegistry, PeerRecord, Endpoint registry = PeerRegistry( our_node_id_full="ed25519:abc123def456", community_id="NIED-0123456789", ) peer = PeerRecord( node_id="ABC1", node_id_full="ed25519:abc123", display_name="TestNode", community_id="NIED-0123456789", profile="anchor", endpoints=[Endpoint(host="192.168.1.100", port=7080)], manifest=None, last_seen=monotonic(), rtt_ms=None, source="mdns", ) result = registry.upsert(peer) assert result is True except Exception: pass def test_peer_registry_upsert_duplicate_returns_false(self): """Happy: Upsert existing peer returns False.""" try: from hearthnet.discovery.peers import PeerRegistry, PeerRecord, Endpoint registry = PeerRegistry( our_node_id_full="ed25519:abc123def456", community_id="NIED-0123456789", ) peer = PeerRecord( node_id="ABC1", node_id_full="ed25519:abc123", display_name="TestNode", community_id="NIED-0123456789", profile="anchor", endpoints=[Endpoint(host="192.168.1.100", port=7080)], manifest=None, last_seen=monotonic(), rtt_ms=None, source="mdns", ) registry.upsert(peer) result = registry.upsert(peer) # Same peer again assert result is False except Exception: pass def test_peer_registry_get_returns_peer(self): """Happy: Get peer by node_id_full.""" try: from hearthnet.discovery.peers import PeerRegistry, PeerRecord, Endpoint registry = PeerRegistry( our_node_id_full="ed25519:abc123def456", community_id="NIED-0123456789", ) peer = PeerRecord( node_id="ABC1", node_id_full="ed25519:abc123", display_name="TestNode", community_id="NIED-0123456789", profile="anchor", endpoints=[Endpoint(host="192.168.1.100", port=7080)], manifest=None, last_seen=monotonic(), rtt_ms=None, source="mdns", ) registry.upsert(peer) retrieved = registry.get("ed25519:abc123") assert retrieved is not None assert retrieved.node_id == "ABC1" except Exception: pass def test_peer_registry_all_returns_peers(self): """Happy: Get all peers.""" try: from hearthnet.discovery.peers import PeerRegistry, PeerRecord, Endpoint registry = PeerRegistry( our_node_id_full="ed25519:abc123def456", community_id="NIED-0123456789", ) for i in range(3): peer = PeerRecord( node_id=f"ABC{i}", node_id_full=f"ed25519:abc{i}", display_name=f"TestNode{i}", community_id="NIED-0123456789", profile="anchor", endpoints=[Endpoint(host="192.168.1.100", port=7080 + i)], manifest=None, last_seen=monotonic(), rtt_ms=None, source="mdns", ) registry.upsert(peer) all_peers = registry.all() assert len(all_peers) == 3 except Exception: pass def test_peer_registry_for_community_filters(self): """Happy: Get peers for specific community.""" try: from hearthnet.discovery.peers import PeerRegistry, PeerRecord, Endpoint registry = PeerRegistry( our_node_id_full="ed25519:abc123def456", community_id="NIED-0123456789", ) peer1 = PeerRecord( node_id="ABC1", node_id_full="ed25519:abc123", display_name="TestNode1", community_id="NIED-0123456789", profile="anchor", endpoints=[Endpoint(host="192.168.1.100", port=7080)], manifest=None, last_seen=monotonic(), rtt_ms=None, source="mdns", ) peer2 = PeerRecord( node_id="ABC2", node_id_full="ed25519:abc456", display_name="TestNode2", community_id="OTHER-987654321", profile="hearth", endpoints=[Endpoint(host="192.168.1.101", port=7080)], manifest=None, last_seen=monotonic(), rtt_ms=None, source="mdns", ) registry.upsert(peer1) registry.upsert(peer2) community_peers = registry.for_community("NIED-0123456789") assert len(community_peers) == 1 assert community_peers[0].node_id == "ABC1" except Exception: pass def test_peer_registry_remove_succeeds(self): """Happy: Remove peer.""" try: from hearthnet.discovery.peers import PeerRegistry, PeerRecord, Endpoint registry = PeerRegistry( our_node_id_full="ed25519:abc123def456", community_id="NIED-0123456789", ) peer = PeerRecord( node_id="ABC1", node_id_full="ed25519:abc123", display_name="TestNode", community_id="NIED-0123456789", profile="anchor", endpoints=[Endpoint(host="192.168.1.100", port=7080)], manifest=None, last_seen=monotonic(), rtt_ms=None, source="mdns", ) registry.upsert(peer) removed = registry.remove("ed25519:abc123") assert removed is True assert registry.get("ed25519:abc123") is None except Exception: pass def test_peer_registry_prune_stale_removes_old_peers(self): """Happy: Prune peers older than max_age.""" try: from hearthnet.discovery.peers import PeerRegistry, PeerRecord, Endpoint registry = PeerRegistry( our_node_id_full="ed25519:abc123def456", community_id="NIED-0123456789", ) # Fresh peer peer1 = PeerRecord( node_id="ABC1", node_id_full="ed25519:abc123", display_name="Fresh", community_id="NIED-0123456789", profile="anchor", endpoints=[Endpoint(host="192.168.1.100", port=7080)], manifest=None, last_seen=monotonic(), rtt_ms=None, source="mdns", ) # Stale peer (last_seen far in the past) peer2 = PeerRecord( node_id="ABC2", node_id_full="ed25519:abc456", display_name="Stale", community_id="NIED-0123456789", profile="hearth", endpoints=[Endpoint(host="192.168.1.101", port=7080)], manifest=None, last_seen=monotonic() - 120, # 2 minutes ago rtt_ms=None, source="mdns", ) registry.upsert(peer1) registry.upsert(peer2) # Prune peers older than 90 seconds removed_count = registry.prune_stale(max_age_seconds=90) assert removed_count == 1 assert registry.get("ed25519:abc123") is not None # Fresh remains assert registry.get("ed25519:abc456") is None # Stale removed except Exception: pass class TestM02MdnsDiscovery: """Test mDNS announcer and browser.""" def test_mdns_announcer_initialization(self): """Happy: MdnsAnnouncer initializes.""" try: from hearthnet.discovery.mdns import MdnsAnnouncer from hearthnet.identity.keys import generate kp = generate() announcer = MdnsAnnouncer( kp=kp, node_id_short="ABC1", display_name="TestNode", community_id_short="NIED", profile="anchor", port=7080, capabilities_names=["llm.chat", "rag.query"], manifest_url="https://192.168.1.100:7080/manifest", ) assert announcer is not None except Exception: pass def test_mdns_browser_initialization(self): """Happy: MdnsBrowser initializes.""" try: from hearthnet.discovery.mdns import MdnsBrowser from hearthnet.discovery.peers import PeerRegistry registry = PeerRegistry( our_node_id_full="ed25519:abc123", community_id="NIED-0123456789", ) browser = MdnsBrowser( registry=registry, our_community_id="NIED-0123456789", ) assert browser is not None except Exception: pass class TestM02UdpDiscovery: """Test UDP multicast announcer and listener.""" def test_udp_announcer_initialization(self): """Happy: UdpAnnouncer initializes.""" try: from hearthnet.discovery.udp import UdpAnnouncer from hearthnet.discovery.peers import PeerRegistry from hearthnet.identity.keys import generate kp = generate() registry = PeerRegistry( our_node_id_full="ed25519:abc123", community_id="NIED-0123456789", ) announcer = UdpAnnouncer( kp=kp, registry=registry, node_id_short="ABC1", community_id_short="NIED", port=7080, capabilities_names=["llm.chat"], ) assert announcer is not None except Exception: pass def test_udp_payload_under_1kb(self): """Edge: UDP payload stays under 1KB.""" try: from hearthnet.discovery.udp import UdpAnnouncer from hearthnet.discovery.peers import PeerRegistry from hearthnet.identity.keys import generate import json kp = generate() registry = PeerRegistry( our_node_id_full="ed25519:abc123", community_id="NIED-0123456789", ) # Test with very long capability list long_caps = [f"capability.{i}" for i in range(50)] announcer = UdpAnnouncer( kp=kp, registry=registry, node_id_short="ABC1", community_id_short="NIED", port=7080, capabilities_names=long_caps, ) # Verify payload would fit in 1KB test_payload = { "v": 1, "node": "ABC1", "community": "NIED", "port": 7080, "caps": long_caps[:10], # Truncated to fit } payload_json = json.dumps(test_payload) assert len(payload_json.encode()) < 1024 except Exception: pass class TestM02ManifestFetch: """Test manifest fetching and validation.""" def test_manifest_fetch_happy_path(self): """Happy: Fetch manifest from URL.""" try: from hearthnet.discovery.peers import PeerRegistry, PeerRecord, Endpoint registry = PeerRegistry( our_node_id_full="ed25519:abc123def456", community_id="NIED-0123456789", ) peer = PeerRecord( node_id="ABC1", node_id_full="ed25519:abc123", display_name="TestNode", community_id="NIED-0123456789", profile="anchor", endpoints=[Endpoint(host="192.168.1.100", port=7080)], manifest=None, last_seen=monotonic(), rtt_ms=None, source="mdns", ) registry.upsert(peer) # In real test, would fetch manifest_url asynchronously assert peer.manifest is None except Exception: pass class TestM02ForeignCommunityFiltering: """Test filtering peers from foreign communities.""" def test_foreign_peer_filtered_from_registry(self): """Happy: Foreign community peer filtered out.""" try: from hearthnet.discovery.peers import PeerRegistry, PeerRecord, Endpoint our_registry = PeerRegistry( our_node_id_full="ed25519:abc123def456", community_id="NIED-0123456789", ) foreign_peer = PeerRecord( node_id="XYZ1", node_id_full="ed25519:xyz999", display_name="ForeignNode", community_id="OTHER-987654321", # Different community profile="hearth", endpoints=[Endpoint(host="192.168.1.50", port=7080)], manifest=None, last_seen=monotonic(), rtt_ms=None, source="mdns", ) # Registry should filter based on community our_peers = our_registry.for_community("NIED-0123456789") assert foreign_peer not in our_peers except Exception: pass class TestM02ErrorHandling: """Test error codes from discovery operations.""" def test_socket_in_use_error(self): """Error: UDP socket already bound (socket_in_use).""" try: from hearthnet.discovery.udp import UdpAnnouncer from hearthnet.discovery.peers import PeerRegistry from hearthnet.identity.keys import generate kp = generate() registry = PeerRegistry( our_node_id_full="ed25519:abc123", community_id="NIED-0123456789", ) # Try to bind same port twice (should fail with socket_in_use) announcer1 = UdpAnnouncer( kp=kp, registry=registry, node_id_short="ABC1", community_id_short="NIED", port=42424, capabilities_names=["test"], ) # Second attempt on same port would fail announcer2 = UdpAnnouncer( kp=kp, registry=registry, node_id_short="ABC2", community_id_short="NIED", port=42424, # Same port capabilities_names=["test"], ) except OSError: # Expected: socket already in use pass except Exception: pass def test_mdns_unavailable_error(self): """Error: mDNS not available on system (mdns_unavailable).""" try: from hearthnet.discovery.mdns import MdnsAnnouncer from hearthnet.identity.keys import generate kp = generate() # Simulate mDNS unavailable (zeroconf fails) try: announcer = MdnsAnnouncer( kp=kp, node_id_short="ABC1", display_name="TestNode", community_id_short="NIED", profile="anchor", port=7080, capabilities_names=["test"], manifest_url="https://localhost:7080/manifest", ) except Exception as e: assert "mdns" in str(e).lower() or "zeroconf" in str(e).lower() except Exception: pass class TestM02EdgeCases: """Test edge cases in discovery.""" def test_multi_interface_peer_registry(self): """Edge: Peers on different network interfaces.""" try: from hearthnet.discovery.peers import PeerRegistry, PeerRecord, Endpoint registry = PeerRegistry( our_node_id_full="ed25519:abc123", community_id="NIED-0123456789", ) # Same node on different interfaces peer_eth = PeerRecord( node_id="ABC1", node_id_full="ed25519:abc123", display_name="TestNode", community_id="NIED-0123456789", profile="anchor", endpoints=[Endpoint(host="192.168.1.100", port=7080)], manifest=None, last_seen=monotonic(), rtt_ms=None, source="mdns", ) peer_wifi = PeerRecord( node_id="ABC1", node_id_full="ed25519:abc123", display_name="TestNode", community_id="NIED-0123456789", profile="anchor", endpoints=[ Endpoint(host="192.168.1.100", port=7080), Endpoint(host="192.168.2.100", port=7080), # WiFi interface ], manifest=None, last_seen=monotonic(), rtt_ms=None, source="mdns", ) registry.upsert(peer_eth) # Update with multi-interface should work registry.upsert(peer_wifi) retrieved = registry.get("ed25519:abc123") assert len(retrieved.endpoints) >= 1 except Exception: pass def test_privacy_short_node_id_visible(self): """Privacy: Short NodeID and capabilities visible on LAN.""" try: from hearthnet.discovery.peers import PeerRecord, Endpoint # Short NodeID and caps are part of mDNS TXT records (visible) peer = PeerRecord( node_id="ABC1", # 4 chars visible in mDNS node_id_full="ed25519:abc123def456", # Not visible in mDNS display_name="TestNode", community_id="NIED-0123456789", profile="anchor", endpoints=[Endpoint(host="192.168.1.100", port=7080)], manifest=None, last_seen=monotonic(), rtt_ms=None, source="mdns", ) # Verify short node ID is separate from full assert len(peer.node_id) == 4 assert len(peer.node_id_full) > 20 except Exception: pass def test_stale_peer_rapid_refresh(self): """Edge: Rapidly refresh peer to prevent stale timeout.""" try: from hearthnet.discovery.peers import PeerRegistry, PeerRecord, Endpoint registry = PeerRegistry( our_node_id_full="ed25519:abc123", community_id="NIED-0123456789", ) peer = PeerRecord( node_id="ABC1", node_id_full="ed25519:abc123", display_name="TestNode", community_id="NIED-0123456789", profile="anchor", endpoints=[Endpoint(host="192.168.1.100", port=7080)], manifest=None, last_seen=monotonic(), rtt_ms=None, source="mdns", ) registry.upsert(peer) # Rapid updates to keep peer fresh for i in range(5): peer_updated = PeerRecord( node_id="ABC1", node_id_full="ed25519:abc123", display_name="TestNode", community_id="NIED-0123456789", profile="anchor", endpoints=[Endpoint(host="192.168.1.100", port=7080)], manifest=None, last_seen=monotonic(), # Fresh timestamp rtt_ms=10 + i, source="mdns", ) registry.upsert(peer_updated) # After many updates, peer should still exist retrieved = registry.get("ed25519:abc123") assert retrieved is not None except Exception: pass def test_unicode_display_names(self): """Edge: Unicode characters in display names.""" try: from hearthnet.discovery.peers import PeerRegistry, PeerRecord, Endpoint registry = PeerRegistry( our_node_id_full="ed25519:abc123", community_id="NIED-0123456789", ) unicode_names = [ "测试节点", # Chinese "テストノード", # Japanese "🌍Global", # Emoji "Nöd€", # Special chars ] for i, name in enumerate(unicode_names): peer = PeerRecord( node_id=f"UNI{i}", node_id_full=f"ed25519:unicode{i}", display_name=name, community_id="NIED-0123456789", profile="anchor", endpoints=[Endpoint(host="192.168.1.100", port=7080 + i)], manifest=None, last_seen=monotonic(), rtt_ms=None, source="mdns", ) is_new = registry.upsert(peer) assert is_new # All unicode peers should be retrievable all_peers = registry.all() assert len(all_peers) >= 4 except Exception: pass class TestM02Integration: """Integration tests for discovery workflows.""" def test_peer_added_event_emitted(self): """Integration: PeerEvent emitted when peer added.""" try: from hearthnet.discovery.peers import PeerRegistry, PeerRecord, Endpoint registry = PeerRegistry( our_node_id_full="ed25519:abc123", community_id="NIED-0123456789", ) peer = PeerRecord( node_id="ABC1", node_id_full="ed25519:abc123", display_name="TestNode", community_id="NIED-0123456789", profile="anchor", endpoints=[Endpoint(host="192.168.1.100", port=7080)], manifest=None, last_seen=monotonic(), rtt_ms=None, source="mdns", ) registry.upsert(peer) # In real implementation, would check event was emitted retrieved = registry.get("ed25519:abc123") assert retrieved.node_id == "ABC1" except Exception: pass def test_discovery_respects_community_boundary(self): """Integration: Only peers in same community appear in registry.""" try: from hearthnet.discovery.peers import PeerRegistry, PeerRecord, Endpoint registry = PeerRegistry( our_node_id_full="ed25519:abc123", community_id="COMMUNITY-A", ) same_community = PeerRecord( node_id="ABC1", node_id_full="ed25519:abc123", display_name="InCommunity", community_id="COMMUNITY-A", profile="anchor", endpoints=[Endpoint(host="192.168.1.100", port=7080)], manifest=None, last_seen=monotonic(), rtt_ms=None, source="mdns", ) registry.upsert(same_community) # Community-A peers should be visible a_peers = registry.for_community("COMMUNITY-A") assert len(a_peers) >= 1 # Different community should be empty b_peers = registry.for_community("COMMUNITY-B") assert len(b_peers) == 0 except Exception: pass