test_network.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364
  1. # -*- coding: utf-8 -*-
  2. """
  3. Unit-Tests für trixy_core.network.
  4. """
  5. import pytest
  6. from dataclasses import dataclass
  7. from datetime import datetime
  8. from pathlib import Path
  9. from trixy_core.network.protocol import (
  10. TrixyProtocol, ProtocolFlags, ProtocolMessage,
  11. MAGIC, COMMAND_PING, COMMAND_PONG, COMMAND_NOOP, COMMAND_HELLO
  12. )
  13. from trixy_core.network.encryption import TrixyEncryption
  14. from trixy_core.network.cmd.base import CommandMessage
  15. from trixy_core.network.cmd.satellite import (
  16. SatelliteConnect, SatelliteDisconnect,
  17. SatelliteAccepted, SatelliteDenied
  18. )
  19. from trixy_core.network.cmd.wakeword import (
  20. WakewordDetected, WakewordSelected, WakewordAbort
  21. )
  22. from trixy_core.network.cmd.recording import (
  23. RecordingStart, RecordingStop, RecordingDone
  24. )
  25. from trixy_core.network.cmd.system import (
  26. SystemReboot, SystemUpdate, SystemStatus
  27. )
  28. class TestProtocolFlags:
  29. """Tests für ProtocolFlags."""
  30. def test_flag_values(self):
  31. """Testet die Flag-Werte."""
  32. assert ProtocolFlags.NONE == 0
  33. assert ProtocolFlags.COMPRESSED == 1
  34. assert ProtocolFlags.ENCRYPTED == 2
  35. assert ProtocolFlags.JSON == 4
  36. assert ProtocolFlags.ACK_REQUIRED == 8
  37. def test_flag_combination(self):
  38. """Testet Flag-Kombinationen."""
  39. flags = ProtocolFlags.COMPRESSED | ProtocolFlags.ENCRYPTED
  40. assert flags & ProtocolFlags.COMPRESSED
  41. assert flags & ProtocolFlags.ENCRYPTED
  42. assert not (flags & ProtocolFlags.JSON)
  43. class TestProtocolMessage:
  44. """Tests für ProtocolMessage."""
  45. def test_message_creation(self):
  46. """Testet die Nachrichtenerstellung."""
  47. msg = ProtocolMessage(
  48. class_name="TestMessage",
  49. data={"key": "value"},
  50. flags=ProtocolFlags.JSON
  51. )
  52. assert msg.class_name == "TestMessage"
  53. assert msg.data == {"key": "value"}
  54. assert msg.flags == ProtocolFlags.JSON
  55. def test_header_size(self):
  56. """Testet die Header-Größe."""
  57. # Magic(4) + Version(2) + Timestamp(8) + Flags(4) + Checksum(16) + ClassNameLen(2)
  58. assert ProtocolMessage.HEADER_SIZE == 36
  59. class TestTrixyProtocol:
  60. """Tests für TrixyProtocol."""
  61. @pytest.fixture
  62. def protocol(self):
  63. """Erstellt ein TrixyProtocol."""
  64. return TrixyProtocol()
  65. @pytest.fixture
  66. def protocol_with_encryption(self):
  67. """Erstellt ein TrixyProtocol mit Verschlüsselung."""
  68. encryption = TrixyEncryption()
  69. return TrixyProtocol(encryption=encryption)
  70. def test_serialize_deserialize_basic(self, protocol):
  71. """Testet einfache Serialisierung/Deserialisierung mit CommandMessage."""
  72. # Verwende CommandMessage (global definiert, pickle-fähig)
  73. original = SatelliteConnect(
  74. mac_address="AA:BB:CC:DD:EE:FF",
  75. room="test",
  76. alias="TestDevice"
  77. )
  78. serialized = protocol.serialize(original)
  79. deserialized = protocol.deserialize(serialized)
  80. assert deserialized.class_name == "SatelliteConnect"
  81. assert deserialized.data.mac_address == "AA:BB:CC:DD:EE:FF"
  82. assert deserialized.data.room == "test"
  83. def test_serialize_with_json_flag(self, protocol):
  84. """Testet Serialisierung mit JSON-Flag."""
  85. # Verwende einfaches Dictionary für JSON-Test
  86. original = {"mac_address": "BB:CC:DD:EE:FF:00", "room": "wohnzimmer"}
  87. serialized = protocol.serialize(original, ProtocolFlags.JSON)
  88. deserialized = protocol.deserialize(serialized)
  89. assert deserialized.flags & ProtocolFlags.JSON
  90. assert deserialized.data["mac_address"] == "BB:CC:DD:EE:FF:00"
  91. def test_serialize_binary_data(self, protocol):
  92. """Testet Serialisierung von Binärdaten."""
  93. binary = b"\x00\x01\x02\x03\x04"
  94. serialized = protocol.serialize(binary, ProtocolFlags.BINARY)
  95. deserialized = protocol.deserialize(serialized)
  96. assert deserialized.flags & ProtocolFlags.BINARY
  97. assert deserialized.data == binary
  98. def test_checksum_validation(self, protocol):
  99. """Testet die Prüfsummenvalidierung."""
  100. original = SystemStatus(status="running", uptime_seconds=100.0)
  101. serialized = protocol.serialize(original)
  102. # Manipuliere Daten
  103. corrupted = bytearray(serialized)
  104. corrupted[-1] ^= 0xFF
  105. with pytest.raises(ValueError, match="Prüfsummenfehler"):
  106. protocol.deserialize(bytes(corrupted))
  107. def test_hard_commands(self, protocol):
  108. """Testet Hard-coded Befehle."""
  109. assert protocol.create_ping() == COMMAND_PING
  110. assert protocol.create_pong() == COMMAND_PONG
  111. assert protocol.create_noop() == COMMAND_NOOP
  112. assert protocol.create_hello() == COMMAND_HELLO
  113. def test_is_hard_command(self, protocol):
  114. """Testet is_hard_command."""
  115. assert protocol.is_hard_command(COMMAND_PING)
  116. assert protocol.is_hard_command(COMMAND_PONG)
  117. assert not protocol.is_hard_command(b"NOTACMD!")
  118. def test_get_hard_command(self, protocol):
  119. """Testet get_hard_command."""
  120. assert protocol.get_hard_command(COMMAND_PING) == "TRXIPING"
  121. assert protocol.get_hard_command(b"NOTACMD!") is None
  122. def test_register_class(self, protocol):
  123. """Testet die Klassenregistrierung."""
  124. @dataclass
  125. class RegisteredClass:
  126. value: str = "registered"
  127. protocol.register_class(RegisteredClass)
  128. assert "RegisteredClass" in protocol._registered_classes
  129. def test_invalid_magic_raises_error(self, protocol):
  130. """Testet, dass ungültige Magic-Number Fehler wirft."""
  131. invalid_data = b"XXXX" + b"\x00" * 50
  132. with pytest.raises(ValueError, match="Ungültige Magic Number"):
  133. protocol.deserialize(invalid_data)
  134. def test_serialization_with_encryption(self, protocol_with_encryption):
  135. """Testet Serialisierung mit Verschlüsselung."""
  136. original = SatelliteConnect(
  137. mac_address="AA:BB:CC:DD:EE:FF",
  138. room="secret_room",
  139. alias="SecretDevice"
  140. )
  141. flags = ProtocolFlags.ENCRYPTED | ProtocolFlags.PICKLE
  142. serialized = protocol_with_encryption.serialize(original, flags)
  143. deserialized = protocol_with_encryption.deserialize(serialized)
  144. assert deserialized.data.room == "secret_room"
  145. class TestTrixyEncryption:
  146. """Tests für TrixyEncryption."""
  147. @pytest.fixture
  148. def encryption(self):
  149. """Erstellt eine TrixyEncryption-Instanz."""
  150. return TrixyEncryption()
  151. def test_key_generation(self):
  152. """Testet die Schlüsselgenerierung."""
  153. key = TrixyEncryption.generate_key()
  154. assert len(key) == 32
  155. def test_initialization_with_key(self):
  156. """Testet Initialisierung mit Schlüssel."""
  157. key = TrixyEncryption.generate_key()
  158. enc = TrixyEncryption(key)
  159. assert enc.key == key
  160. def test_initialization_generates_key(self):
  161. """Testet, dass Initialisierung Schlüssel generiert."""
  162. enc = TrixyEncryption()
  163. assert len(enc.key) == 32
  164. def test_invalid_key_length_raises_error(self):
  165. """Testet, dass ungültige Schlüssellänge Fehler wirft."""
  166. with pytest.raises(ValueError, match="32 Bytes"):
  167. TrixyEncryption(b"short")
  168. def test_encrypt_decrypt(self, encryption):
  169. """Testet Verschlüsselung und Entschlüsselung."""
  170. plaintext = b"Hello, World!"
  171. ciphertext = encryption.encrypt(plaintext)
  172. decrypted = encryption.decrypt(ciphertext)
  173. assert decrypted == plaintext
  174. assert ciphertext != plaintext
  175. def test_encrypt_with_associated_data(self, encryption):
  176. """Testet Verschlüsselung mit zusätzlichen Daten."""
  177. plaintext = b"Secret message"
  178. aad = b"Additional authenticated data"
  179. ciphertext = encryption.encrypt(plaintext, aad)
  180. decrypted = encryption.decrypt(ciphertext, aad)
  181. assert decrypted == plaintext
  182. def test_decrypt_with_wrong_aad_fails(self, encryption):
  183. """Testet, dass Entschlüsselung mit falschen AAD fehlschlägt."""
  184. plaintext = b"Secret"
  185. ciphertext = encryption.encrypt(plaintext, b"correct")
  186. with pytest.raises(ValueError, match="Entschlüsselung fehlgeschlagen"):
  187. encryption.decrypt(ciphertext, b"wrong")
  188. def test_save_load_key(self, encryption, temp_dir):
  189. """Testet Speichern und Laden von Schlüsseln."""
  190. key_path = temp_dir / "encryption.key"
  191. encryption.save_key(key_path)
  192. loaded = TrixyEncryption.load_key(key_path)
  193. assert loaded.key == encryption.key
  194. def test_load_or_create_creates_new(self, temp_dir):
  195. """Testet load_or_create für neue Schlüssel."""
  196. key_path = temp_dir / "new.key"
  197. enc = TrixyEncryption.load_or_create(key_path)
  198. assert key_path.exists()
  199. assert len(enc.key) == 32
  200. def test_load_or_create_loads_existing(self, temp_dir):
  201. """Testet load_or_create für existierende Schlüssel."""
  202. key_path = temp_dir / "existing.key"
  203. original = TrixyEncryption()
  204. original.save_key(key_path)
  205. loaded = TrixyEncryption.load_or_create(key_path)
  206. assert loaded.key == original.key
  207. def test_load_nonexistent_raises_error(self, temp_dir):
  208. """Testet, dass Laden nicht existierender Datei Fehler wirft."""
  209. with pytest.raises(FileNotFoundError):
  210. TrixyEncryption.load_key(temp_dir / "nonexistent.key")
  211. class TestCommandMessages:
  212. """Tests für Befehlsnachrichten."""
  213. def test_command_message_base(self):
  214. """Testet CommandMessage-Basis."""
  215. msg = CommandMessage()
  216. assert msg.message_id is not None
  217. assert isinstance(msg.timestamp, datetime)
  218. def test_satellite_connect(self):
  219. """Testet SatelliteConnect."""
  220. msg = SatelliteConnect(
  221. mac_address="AA:BB:CC:DD:EE:FF",
  222. room="wohnzimmer",
  223. alias="Echo 1",
  224. ip_address="192.168.1.100",
  225. version="1.0.0"
  226. )
  227. assert msg.mac_address == "AA:BB:CC:DD:EE:FF"
  228. assert msg.room == "wohnzimmer"
  229. def test_satellite_accepted(self):
  230. """Testet SatelliteAccepted."""
  231. msg = SatelliteAccepted(
  232. satellite_id="sat-1",
  233. audio_input_port=2102,
  234. audio_output_port=2103
  235. )
  236. assert msg.satellite_id == "sat-1"
  237. def test_satellite_denied(self):
  238. """Testet SatelliteDenied."""
  239. msg = SatelliteDenied(
  240. reason="Not registered",
  241. retry_allowed=True,
  242. retry_after_seconds=60
  243. )
  244. assert msg.reason == "Not registered"
  245. assert msg.retry_allowed is True
  246. def test_wakeword_detected(self):
  247. """Testet WakewordDetected."""
  248. msg = WakewordDetected(
  249. satellite_id="sat-1",
  250. wakeword_model="custom",
  251. confidence=0.95,
  252. audio_level=0.7
  253. )
  254. assert msg.confidence == 0.95
  255. def test_wakeword_selected(self):
  256. """Testet WakewordSelected."""
  257. msg = WakewordSelected(
  258. conversation_id="conv-123",
  259. start_streaming=True
  260. )
  261. assert msg.start_streaming is True
  262. def test_recording_start(self):
  263. """Testet RecordingStart."""
  264. msg = RecordingStart(
  265. conversation_id="conv-123",
  266. max_duration_seconds=60.0
  267. )
  268. assert msg.max_duration_seconds == 60.0
  269. def test_recording_done(self):
  270. """Testet RecordingDone."""
  271. msg = RecordingDone(
  272. conversation_id="conv-123",
  273. duration_seconds=5.5,
  274. total_samples=88000,
  275. ended_by="silence"
  276. )
  277. assert msg.ended_by == "silence"
  278. def test_system_status(self):
  279. """Testet SystemStatus."""
  280. msg = SystemStatus(
  281. status="running",
  282. uptime_seconds=3600.0,
  283. memory_usage_mb=256.0,
  284. active_satellites=3,
  285. loaded_plugins=["plugin1", "plugin2"]
  286. )
  287. assert msg.active_satellites == 3
  288. assert len(msg.loaded_plugins) == 2
  289. def test_command_message_to_dict(self):
  290. """Testet to_dict."""
  291. msg = SatelliteConnect(
  292. mac_address="AA:BB:CC:DD:EE:FF",
  293. room="test"
  294. )
  295. data = msg.to_dict()
  296. assert "mac_address" in data
  297. assert "room" in data
  298. assert data["mac_address"] == "AA:BB:CC:DD:EE:FF"