test_server_shutdown.py 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591
  1. # -*- coding: utf-8 -*-
  2. """
  3. Tests für Server-Shutdown-Funktionalität.
  4. Testet:
  5. - ServerShutdown Command
  6. - Satellite.send_command() Methode
  7. - Satellite.disconnect() mit Shutdown-Benachrichtigung
  8. - Client-seitige Behandlung von ServerShutdown
  9. """
  10. import asyncio
  11. import pytest
  12. from unittest.mock import MagicMock, AsyncMock, patch
  13. from dataclasses import dataclass
  14. from trixy_core.network.cmd.system import (
  15. ServerShutdown,
  16. SystemReboot,
  17. SystemUpdate,
  18. SystemStatus,
  19. )
  20. from trixy_core.satellite.satellite import (
  21. Satellite,
  22. SatelliteSocket,
  23. ConnectionState,
  24. )
  25. # =============================================================================
  26. # Fixtures
  27. # =============================================================================
  28. @pytest.fixture
  29. def mock_writer():
  30. """Mock StreamWriter für Socket-Tests."""
  31. writer = MagicMock()
  32. writer.write = MagicMock()
  33. writer.drain = AsyncMock()
  34. writer.close = MagicMock()
  35. writer.wait_closed = AsyncMock()
  36. return writer
  37. @pytest.fixture
  38. def connected_satellite(mock_writer):
  39. """Erstellt einen verbundenen Satellite mit Mock-Sockets."""
  40. satellite = Satellite(
  41. satellite_id="test-satellite-123",
  42. room_id="test_room",
  43. mac_address="AA:BB:CC:DD:EE:FF",
  44. alias="Test Satellite",
  45. ip_address="192.168.1.100",
  46. )
  47. satellite._state = ConnectionState.CONNECTED
  48. satellite._sockets.command = mock_writer
  49. return satellite
  50. @pytest.fixture
  51. def disconnected_satellite():
  52. """Erstellt einen nicht verbundenen Satellite."""
  53. satellite = Satellite(
  54. satellite_id="test-satellite-456",
  55. room_id="test_room",
  56. alias="Offline Satellite",
  57. )
  58. satellite._state = ConnectionState.DISCONNECTED
  59. return satellite
  60. @pytest.fixture
  61. def mock_app():
  62. """Mock-Application für Client-Tests."""
  63. app = MagicMock()
  64. app.shutdown = MagicMock()
  65. app._running = True
  66. return app
  67. # =============================================================================
  68. # Tests für ServerShutdown Command
  69. # =============================================================================
  70. class TestServerShutdownCommand:
  71. """Tests für ServerShutdown Dataclass."""
  72. def test_default_values(self):
  73. """Testet Standard-Werte."""
  74. cmd = ServerShutdown()
  75. assert cmd.reason == ""
  76. assert cmd.reconnect_after_seconds == 0
  77. def test_custom_values(self):
  78. """Testet benutzerdefinierte Werte."""
  79. cmd = ServerShutdown(
  80. reason="Server maintenance",
  81. reconnect_after_seconds=300,
  82. )
  83. assert cmd.reason == "Server maintenance"
  84. assert cmd.reconnect_after_seconds == 300
  85. def test_no_reconnect_expected(self):
  86. """Testet permanenten Shutdown ohne Wiederverbindung."""
  87. cmd = ServerShutdown(
  88. reason="Server wird beendet",
  89. reconnect_after_seconds=0,
  90. )
  91. assert cmd.reconnect_after_seconds == 0
  92. def test_reconnect_expected(self):
  93. """Testet temporären Shutdown mit Wiederverbindung."""
  94. cmd = ServerShutdown(
  95. reason="Server-Neustart",
  96. reconnect_after_seconds=60,
  97. )
  98. assert cmd.reconnect_after_seconds == 60
  99. def test_is_dataclass(self):
  100. """Testet, dass ServerShutdown ein Dataclass ist."""
  101. from dataclasses import is_dataclass
  102. assert is_dataclass(ServerShutdown)
  103. def test_inheritance(self):
  104. """Testet, dass ServerShutdown von CommandMessage erbt."""
  105. from trixy_core.network.cmd.base import CommandMessage
  106. cmd = ServerShutdown()
  107. assert isinstance(cmd, CommandMessage)
  108. # =============================================================================
  109. # Tests für andere System-Commands (Vollständigkeit)
  110. # =============================================================================
  111. class TestSystemCommands:
  112. """Tests für andere System-Commands."""
  113. def test_system_reboot(self):
  114. """Testet SystemReboot Command."""
  115. cmd = SystemReboot(
  116. target="satellite-123",
  117. delay_seconds=10,
  118. reason="Update",
  119. )
  120. assert cmd.target == "satellite-123"
  121. assert cmd.delay_seconds == 10
  122. assert cmd.reason == "Update"
  123. def test_system_update(self):
  124. """Testet SystemUpdate Command."""
  125. cmd = SystemUpdate(
  126. target="server",
  127. update_type="models",
  128. version="2.0.0",
  129. force=True,
  130. )
  131. assert cmd.update_type == "models"
  132. assert cmd.force is True
  133. def test_system_status(self):
  134. """Testet SystemStatus Command."""
  135. cmd = SystemStatus(
  136. status="running",
  137. uptime_seconds=3600.0,
  138. memory_usage_mb=512.5,
  139. cpu_usage_percent=25.0,
  140. active_satellites=3,
  141. loaded_plugins=["plugin1", "plugin2"],
  142. )
  143. assert cmd.status == "running"
  144. assert cmd.uptime_seconds == 3600.0
  145. assert len(cmd.loaded_plugins) == 2
  146. # =============================================================================
  147. # Tests für Satellite.send_command()
  148. # =============================================================================
  149. class TestSatelliteSendCommand:
  150. """Tests für Satellite.send_command() Methode."""
  151. @pytest.mark.asyncio
  152. async def test_send_command_success(self, connected_satellite, mock_writer):
  153. """Testet erfolgreiches Senden eines Commands."""
  154. cmd = ServerShutdown(reason="Test shutdown")
  155. result = await connected_satellite.send_command(cmd)
  156. assert result is True
  157. mock_writer.write.assert_called_once()
  158. mock_writer.drain.assert_called_once()
  159. @pytest.mark.asyncio
  160. async def test_send_command_serializes_message(self, connected_satellite, mock_writer):
  161. """Testet, dass die Nachricht serialisiert wird."""
  162. cmd = ServerShutdown(reason="Serialization test")
  163. await connected_satellite.send_command(cmd)
  164. # Prüfe, dass Daten geschrieben wurden
  165. call_args = mock_writer.write.call_args
  166. written_data = call_args[0][0]
  167. # Serialisierte Daten sollten Bytes sein
  168. assert isinstance(written_data, bytes)
  169. # Sollten mit TRXI Magic beginnen
  170. assert written_data[:4] == b"TRXI"
  171. @pytest.mark.asyncio
  172. async def test_send_command_disconnected(self, disconnected_satellite):
  173. """Testet send_command bei nicht verbundenem Satellite."""
  174. cmd = ServerShutdown(reason="Should fail")
  175. result = await disconnected_satellite.send_command(cmd)
  176. assert result is False
  177. @pytest.mark.asyncio
  178. async def test_send_command_no_socket(self, connected_satellite):
  179. """Testet send_command ohne Command-Socket."""
  180. connected_satellite._sockets.command = None
  181. cmd = ServerShutdown(reason="No socket")
  182. result = await connected_satellite.send_command(cmd)
  183. assert result is False
  184. @pytest.mark.asyncio
  185. async def test_send_command_write_error(self, connected_satellite, mock_writer):
  186. """Testet send_command bei Schreibfehler."""
  187. mock_writer.write.side_effect = Exception("Write failed")
  188. cmd = ServerShutdown(reason="Write error test")
  189. result = await connected_satellite.send_command(cmd)
  190. assert result is False
  191. @pytest.mark.asyncio
  192. async def test_send_command_drain_error(self, connected_satellite, mock_writer):
  193. """Testet send_command bei Drain-Fehler."""
  194. mock_writer.drain.side_effect = Exception("Drain failed")
  195. cmd = ServerShutdown(reason="Drain error test")
  196. result = await connected_satellite.send_command(cmd)
  197. assert result is False
  198. @pytest.mark.asyncio
  199. async def test_send_command_different_commands(self, connected_satellite, mock_writer):
  200. """Testet send_command mit verschiedenen Command-Typen."""
  201. commands = [
  202. ServerShutdown(reason="Test"),
  203. SystemReboot(target="test", delay_seconds=5),
  204. SystemStatus(status="running"),
  205. ]
  206. for cmd in commands:
  207. mock_writer.reset_mock()
  208. result = await connected_satellite.send_command(cmd)
  209. assert result is True
  210. mock_writer.write.assert_called_once()
  211. # =============================================================================
  212. # Tests für Satellite.disconnect()
  213. # =============================================================================
  214. class TestSatelliteDisconnect:
  215. """Tests für Satellite.disconnect() Methode."""
  216. @pytest.mark.asyncio
  217. async def test_disconnect_sets_state(self, connected_satellite):
  218. """Testet, dass disconnect() den Zustand setzt."""
  219. await connected_satellite.disconnect("Test disconnect")
  220. assert connected_satellite._state == ConnectionState.DISCONNECTED
  221. @pytest.mark.asyncio
  222. async def test_disconnect_clears_conversation(self, connected_satellite):
  223. """Testet, dass disconnect() die Conversation-ID löscht."""
  224. connected_satellite._conversation_id = "conv-123"
  225. await connected_satellite.disconnect()
  226. assert connected_satellite._conversation_id is None
  227. @pytest.mark.asyncio
  228. async def test_disconnect_sends_shutdown_command(self, connected_satellite, mock_writer):
  229. """Testet, dass disconnect() ServerShutdown sendet."""
  230. await connected_satellite.disconnect("Server shutdown test")
  231. # Command sollte gesendet worden sein
  232. mock_writer.write.assert_called()
  233. # Prüfe gesendete Daten
  234. call_args = mock_writer.write.call_args
  235. written_data = call_args[0][0]
  236. assert b"TRXI" in written_data
  237. @pytest.mark.asyncio
  238. async def test_disconnect_closes_sockets(self, connected_satellite, mock_writer):
  239. """Testet, dass disconnect() alle Sockets schließt."""
  240. # Setze alle Sockets
  241. connected_satellite._sockets.command = mock_writer
  242. connected_satellite._sockets.audio_in = mock_writer
  243. connected_satellite._sockets.audio_out = mock_writer
  244. connected_satellite._sockets.music_out = mock_writer
  245. await connected_satellite.disconnect()
  246. # Alle Sockets sollten geschlossen sein
  247. assert connected_satellite._sockets.command is None
  248. assert connected_satellite._sockets.audio_in is None
  249. assert connected_satellite._sockets.audio_out is None
  250. assert connected_satellite._sockets.music_out is None
  251. @pytest.mark.asyncio
  252. async def test_disconnect_no_command_socket(self, connected_satellite):
  253. """Testet disconnect() ohne Command-Socket."""
  254. connected_satellite._sockets.command = None
  255. # Sollte nicht fehlschlagen
  256. await connected_satellite.disconnect("No socket")
  257. assert connected_satellite._state == ConnectionState.DISCONNECTED
  258. @pytest.mark.asyncio
  259. async def test_disconnect_send_command_fails(self, connected_satellite, mock_writer):
  260. """Testet disconnect() wenn send_command fehlschlägt."""
  261. mock_writer.write.side_effect = Exception("Send failed")
  262. # Sollte trotzdem funktionieren (Fehler werden ignoriert)
  263. await connected_satellite.disconnect("Error test")
  264. assert connected_satellite._state == ConnectionState.DISCONNECTED
  265. @pytest.mark.asyncio
  266. async def test_disconnect_close_socket_fails(self, connected_satellite, mock_writer):
  267. """Testet disconnect() wenn Socket-Close fehlschlägt."""
  268. mock_writer.close.side_effect = Exception("Close failed")
  269. # Sollte trotzdem funktionieren (Fehler werden ignoriert)
  270. await connected_satellite.disconnect("Close error")
  271. assert connected_satellite._state == ConnectionState.DISCONNECTED
  272. @pytest.mark.asyncio
  273. async def test_disconnect_with_reason(self, connected_satellite, mock_writer):
  274. """Testet, dass der Grund in der Shutdown-Nachricht enthalten ist."""
  275. reason = "Scheduled maintenance"
  276. await connected_satellite.disconnect(reason)
  277. # Die geschriebenen Daten sollten den Grund enthalten (in Pickle-Form)
  278. mock_writer.write.assert_called()
  279. # =============================================================================
  280. # Tests für Client-seitige ServerShutdown Behandlung
  281. # =============================================================================
  282. class TestClientServerShutdownHandling:
  283. """Tests für Client-seitige Behandlung von ServerShutdown."""
  284. @pytest.mark.asyncio
  285. async def test_handle_server_shutdown_permanent(self, mock_app):
  286. """Testet Behandlung von permanentem Shutdown."""
  287. from trixy_core.client import ClientApplication
  288. # Minimaler Mock für ClientApplication
  289. client = MagicMock(spec=ClientApplication)
  290. client.shutdown = MagicMock()
  291. client._running = True
  292. # Simuliere die Handler-Logik
  293. data = ServerShutdown(
  294. reason="Server wird beendet",
  295. reconnect_after_seconds=0,
  296. )
  297. # Extrahiere Werte wie im echten Handler
  298. reason = data.reason
  299. reconnect_after = data.reconnect_after_seconds
  300. if reconnect_after == 0:
  301. client.shutdown()
  302. client.shutdown.assert_called_once()
  303. @pytest.mark.asyncio
  304. async def test_handle_server_shutdown_with_reconnect(self, mock_app):
  305. """Testet Behandlung von temporärem Shutdown mit Wiederverbindung."""
  306. data = ServerShutdown(
  307. reason="Server-Neustart",
  308. reconnect_after_seconds=30,
  309. )
  310. reconnect_after = data.reconnect_after_seconds
  311. # Bei reconnect_after > 0 sollte NICHT sofort beendet werden
  312. assert reconnect_after > 0
  313. # Client würde warten und dann reconnecten
  314. def test_handle_server_shutdown_extract_reason(self):
  315. """Testet Extraktion des Shutdown-Grunds."""
  316. data = ServerShutdown(reason="Test reason")
  317. reason = getattr(data, "reason", "")
  318. assert reason == "Test reason"
  319. def test_handle_server_shutdown_dict_data(self):
  320. """Testet Behandlung von Dict-Daten."""
  321. data = {
  322. "reason": "Dict reason",
  323. "reconnect_after_seconds": 60,
  324. }
  325. reason = data.get("reason", "")
  326. reconnect_after = data.get("reconnect_after_seconds", 0)
  327. assert reason == "Dict reason"
  328. assert reconnect_after == 60
  329. # =============================================================================
  330. # Tests für SatelliteManager.disconnect_all()
  331. # =============================================================================
  332. class TestSatelliteManagerDisconnectAll:
  333. """Tests für SatelliteManager.disconnect_all()."""
  334. @pytest.mark.asyncio
  335. async def test_disconnect_all_sends_to_all(self):
  336. """Testet, dass disconnect_all an alle Satellites sendet."""
  337. from trixy_core.satellite.satellite_manager import SatelliteManager
  338. # Mock Application
  339. mock_app = MagicMock()
  340. manager = SatelliteManager(mock_app)
  341. # Mock Satellites erstellen
  342. satellites = []
  343. for i in range(3):
  344. sat = MagicMock(spec=Satellite)
  345. sat.is_connected = True
  346. sat.disconnect = AsyncMock()
  347. satellites.append(sat)
  348. manager._satellites[f"sat-{i}"] = sat
  349. # disconnect_all aufrufen
  350. count = await manager.disconnect_all("Test shutdown")
  351. # Alle sollten getrennt worden sein
  352. assert count == 3
  353. for sat in satellites:
  354. sat.disconnect.assert_called_once_with("Test shutdown")
  355. @pytest.mark.asyncio
  356. async def test_disconnect_all_skips_disconnected(self):
  357. """Testet, dass bereits getrennte Satellites übersprungen werden."""
  358. from trixy_core.satellite.satellite_manager import SatelliteManager
  359. mock_app = MagicMock()
  360. manager = SatelliteManager(mock_app)
  361. # Mix aus verbundenen und getrennten Satellites
  362. connected_sat = MagicMock(spec=Satellite)
  363. connected_sat.is_connected = True
  364. connected_sat.disconnect = AsyncMock()
  365. disconnected_sat = MagicMock(spec=Satellite)
  366. disconnected_sat.is_connected = False
  367. disconnected_sat.disconnect = AsyncMock()
  368. manager._satellites["connected"] = connected_sat
  369. manager._satellites["disconnected"] = disconnected_sat
  370. count = await manager.disconnect_all("Test")
  371. # Nur verbundener sollte getrennt werden
  372. assert count == 1
  373. connected_sat.disconnect.assert_called_once()
  374. disconnected_sat.disconnect.assert_not_called()
  375. # =============================================================================
  376. # Integration Tests
  377. # =============================================================================
  378. class TestServerShutdownIntegration:
  379. """Integrationstests für Server-Shutdown-Flow."""
  380. @pytest.mark.asyncio
  381. async def test_full_shutdown_flow(self, mock_writer):
  382. """Testet den vollständigen Shutdown-Flow."""
  383. # Satellite erstellen
  384. satellite = Satellite(
  385. satellite_id="integration-test",
  386. alias="Integration Satellite",
  387. )
  388. satellite._state = ConnectionState.CONNECTED
  389. satellite._sockets.command = mock_writer
  390. satellite._conversation_id = "active-conversation"
  391. # Disconnect ausführen
  392. await satellite.disconnect("Integration shutdown test")
  393. # Prüfe Ergebnisse
  394. assert satellite._state == ConnectionState.DISCONNECTED
  395. assert satellite._conversation_id is None
  396. assert satellite._sockets.command is None
  397. # Command sollte gesendet worden sein
  398. mock_writer.write.assert_called()
  399. mock_writer.drain.assert_called()
  400. @pytest.mark.asyncio
  401. async def test_shutdown_preserves_satellite_info(self, mock_writer):
  402. """Testet, dass Satellite-Info nach Shutdown erhalten bleibt."""
  403. satellite = Satellite(
  404. satellite_id="preserve-test",
  405. room_id="living_room",
  406. mac_address="11:22:33:44:55:66",
  407. alias="Preserve Satellite",
  408. ip_address="10.0.0.1",
  409. )
  410. satellite._state = ConnectionState.CONNECTED
  411. satellite._sockets.command = mock_writer
  412. await satellite.disconnect("Preserve test")
  413. # Info sollte erhalten bleiben
  414. assert satellite.id == "preserve-test"
  415. assert satellite.room_id == "living_room"
  416. assert satellite.mac_address == "11:22:33:44:55:66"
  417. assert satellite.alias == "Preserve Satellite"
  418. # =============================================================================
  419. # Edge Cases
  420. # =============================================================================
  421. class TestEdgeCases:
  422. """Tests für Grenzfälle."""
  423. def test_server_shutdown_empty_reason(self):
  424. """Testet ServerShutdown mit leerem Grund."""
  425. cmd = ServerShutdown(reason="", reconnect_after_seconds=0)
  426. assert cmd.reason == ""
  427. def test_server_shutdown_long_reason(self):
  428. """Testet ServerShutdown mit langem Grund."""
  429. long_reason = "A" * 1000
  430. cmd = ServerShutdown(reason=long_reason)
  431. assert cmd.reason == long_reason
  432. def test_server_shutdown_negative_reconnect(self):
  433. """Testet ServerShutdown mit negativem reconnect-Wert."""
  434. cmd = ServerShutdown(reconnect_after_seconds=-1)
  435. assert cmd.reconnect_after_seconds == -1
  436. @pytest.mark.asyncio
  437. async def test_multiple_disconnects(self, connected_satellite, mock_writer):
  438. """Testet mehrfaches Aufrufen von disconnect()."""
  439. await connected_satellite.disconnect("First")
  440. await connected_satellite.disconnect("Second")
  441. await connected_satellite.disconnect("Third")
  442. assert connected_satellite._state == ConnectionState.DISCONNECTED
  443. @pytest.mark.asyncio
  444. async def test_disconnect_already_disconnected(self, disconnected_satellite):
  445. """Testet disconnect() bei bereits getrenntem Satellite."""
  446. await disconnected_satellite.disconnect("Already disconnected")
  447. assert disconnected_satellite._state == ConnectionState.DISCONNECTED
  448. @pytest.mark.asyncio
  449. async def test_send_command_while_disconnecting(self, connected_satellite, mock_writer):
  450. """Testet send_command während disconnect läuft."""
  451. connected_satellite._state = ConnectionState.DISCONNECTING
  452. cmd = ServerShutdown(reason="During disconnect")
  453. result = await connected_satellite.send_command(cmd)
  454. # DISCONNECTING ist nicht in is_connected, also sollte es fehlschlagen
  455. assert result is False