# -*- coding: utf-8 -*- """ Tests für Server-Shutdown-Funktionalität. Testet: - ServerShutdown Command - Satellite.send_command() Methode - Satellite.disconnect() mit Shutdown-Benachrichtigung - Client-seitige Behandlung von ServerShutdown """ import asyncio import pytest from unittest.mock import MagicMock, AsyncMock, patch from dataclasses import dataclass from trixy_core.network.cmd.system import ( ServerShutdown, SystemReboot, SystemUpdate, SystemStatus, ) from trixy_core.satellite.satellite import ( Satellite, SatelliteSocket, ConnectionState, ) # ============================================================================= # Fixtures # ============================================================================= @pytest.fixture def mock_writer(): """Mock StreamWriter für Socket-Tests.""" writer = MagicMock() writer.write = MagicMock() writer.drain = AsyncMock() writer.close = MagicMock() writer.wait_closed = AsyncMock() return writer @pytest.fixture def connected_satellite(mock_writer): """Erstellt einen verbundenen Satellite mit Mock-Sockets.""" satellite = Satellite( satellite_id="test-satellite-123", room_id="test_room", mac_address="AA:BB:CC:DD:EE:FF", alias="Test Satellite", ip_address="192.168.1.100", ) satellite._state = ConnectionState.CONNECTED satellite._sockets.command = mock_writer return satellite @pytest.fixture def disconnected_satellite(): """Erstellt einen nicht verbundenen Satellite.""" satellite = Satellite( satellite_id="test-satellite-456", room_id="test_room", alias="Offline Satellite", ) satellite._state = ConnectionState.DISCONNECTED return satellite @pytest.fixture def mock_app(): """Mock-Application für Client-Tests.""" app = MagicMock() app.shutdown = MagicMock() app._running = True return app # ============================================================================= # Tests für ServerShutdown Command # ============================================================================= class TestServerShutdownCommand: """Tests für ServerShutdown Dataclass.""" def test_default_values(self): """Testet Standard-Werte.""" cmd = ServerShutdown() assert cmd.reason == "" assert cmd.reconnect_after_seconds == 0 def test_custom_values(self): """Testet benutzerdefinierte Werte.""" cmd = ServerShutdown( reason="Server maintenance", reconnect_after_seconds=300, ) assert cmd.reason == "Server maintenance" assert cmd.reconnect_after_seconds == 300 def test_no_reconnect_expected(self): """Testet permanenten Shutdown ohne Wiederverbindung.""" cmd = ServerShutdown( reason="Server wird beendet", reconnect_after_seconds=0, ) assert cmd.reconnect_after_seconds == 0 def test_reconnect_expected(self): """Testet temporären Shutdown mit Wiederverbindung.""" cmd = ServerShutdown( reason="Server-Neustart", reconnect_after_seconds=60, ) assert cmd.reconnect_after_seconds == 60 def test_is_dataclass(self): """Testet, dass ServerShutdown ein Dataclass ist.""" from dataclasses import is_dataclass assert is_dataclass(ServerShutdown) def test_inheritance(self): """Testet, dass ServerShutdown von CommandMessage erbt.""" from trixy_core.network.cmd.base import CommandMessage cmd = ServerShutdown() assert isinstance(cmd, CommandMessage) # ============================================================================= # Tests für andere System-Commands (Vollständigkeit) # ============================================================================= class TestSystemCommands: """Tests für andere System-Commands.""" def test_system_reboot(self): """Testet SystemReboot Command.""" cmd = SystemReboot( target="satellite-123", delay_seconds=10, reason="Update", ) assert cmd.target == "satellite-123" assert cmd.delay_seconds == 10 assert cmd.reason == "Update" def test_system_update(self): """Testet SystemUpdate Command.""" cmd = SystemUpdate( target="server", update_type="models", version="2.0.0", force=True, ) assert cmd.update_type == "models" assert cmd.force is True def test_system_status(self): """Testet SystemStatus Command.""" cmd = SystemStatus( status="running", uptime_seconds=3600.0, memory_usage_mb=512.5, cpu_usage_percent=25.0, active_satellites=3, loaded_plugins=["plugin1", "plugin2"], ) assert cmd.status == "running" assert cmd.uptime_seconds == 3600.0 assert len(cmd.loaded_plugins) == 2 # ============================================================================= # Tests für Satellite.send_command() # ============================================================================= class TestSatelliteSendCommand: """Tests für Satellite.send_command() Methode.""" @pytest.mark.asyncio async def test_send_command_success(self, connected_satellite, mock_writer): """Testet erfolgreiches Senden eines Commands.""" cmd = ServerShutdown(reason="Test shutdown") result = await connected_satellite.send_command(cmd) assert result is True mock_writer.write.assert_called_once() mock_writer.drain.assert_called_once() @pytest.mark.asyncio async def test_send_command_serializes_message(self, connected_satellite, mock_writer): """Testet, dass die Nachricht serialisiert wird.""" cmd = ServerShutdown(reason="Serialization test") await connected_satellite.send_command(cmd) # Prüfe, dass Daten geschrieben wurden call_args = mock_writer.write.call_args written_data = call_args[0][0] # Serialisierte Daten sollten Bytes sein assert isinstance(written_data, bytes) # Sollten mit TRXI Magic beginnen assert written_data[:4] == b"TRXI" @pytest.mark.asyncio async def test_send_command_disconnected(self, disconnected_satellite): """Testet send_command bei nicht verbundenem Satellite.""" cmd = ServerShutdown(reason="Should fail") result = await disconnected_satellite.send_command(cmd) assert result is False @pytest.mark.asyncio async def test_send_command_no_socket(self, connected_satellite): """Testet send_command ohne Command-Socket.""" connected_satellite._sockets.command = None cmd = ServerShutdown(reason="No socket") result = await connected_satellite.send_command(cmd) assert result is False @pytest.mark.asyncio async def test_send_command_write_error(self, connected_satellite, mock_writer): """Testet send_command bei Schreibfehler.""" mock_writer.write.side_effect = Exception("Write failed") cmd = ServerShutdown(reason="Write error test") result = await connected_satellite.send_command(cmd) assert result is False @pytest.mark.asyncio async def test_send_command_drain_error(self, connected_satellite, mock_writer): """Testet send_command bei Drain-Fehler.""" mock_writer.drain.side_effect = Exception("Drain failed") cmd = ServerShutdown(reason="Drain error test") result = await connected_satellite.send_command(cmd) assert result is False @pytest.mark.asyncio async def test_send_command_different_commands(self, connected_satellite, mock_writer): """Testet send_command mit verschiedenen Command-Typen.""" commands = [ ServerShutdown(reason="Test"), SystemReboot(target="test", delay_seconds=5), SystemStatus(status="running"), ] for cmd in commands: mock_writer.reset_mock() result = await connected_satellite.send_command(cmd) assert result is True mock_writer.write.assert_called_once() # ============================================================================= # Tests für Satellite.disconnect() # ============================================================================= class TestSatelliteDisconnect: """Tests für Satellite.disconnect() Methode.""" @pytest.mark.asyncio async def test_disconnect_sets_state(self, connected_satellite): """Testet, dass disconnect() den Zustand setzt.""" await connected_satellite.disconnect("Test disconnect") assert connected_satellite._state == ConnectionState.DISCONNECTED @pytest.mark.asyncio async def test_disconnect_clears_conversation(self, connected_satellite): """Testet, dass disconnect() die Conversation-ID löscht.""" connected_satellite._conversation_id = "conv-123" await connected_satellite.disconnect() assert connected_satellite._conversation_id is None @pytest.mark.asyncio async def test_disconnect_sends_shutdown_command(self, connected_satellite, mock_writer): """Testet, dass disconnect() ServerShutdown sendet.""" await connected_satellite.disconnect("Server shutdown test") # Command sollte gesendet worden sein mock_writer.write.assert_called() # Prüfe gesendete Daten call_args = mock_writer.write.call_args written_data = call_args[0][0] assert b"TRXI" in written_data @pytest.mark.asyncio async def test_disconnect_closes_sockets(self, connected_satellite, mock_writer): """Testet, dass disconnect() alle Sockets schließt.""" # Setze alle Sockets connected_satellite._sockets.command = mock_writer connected_satellite._sockets.audio_in = mock_writer connected_satellite._sockets.audio_out = mock_writer connected_satellite._sockets.music_out = mock_writer await connected_satellite.disconnect() # Alle Sockets sollten geschlossen sein assert connected_satellite._sockets.command is None assert connected_satellite._sockets.audio_in is None assert connected_satellite._sockets.audio_out is None assert connected_satellite._sockets.music_out is None @pytest.mark.asyncio async def test_disconnect_no_command_socket(self, connected_satellite): """Testet disconnect() ohne Command-Socket.""" connected_satellite._sockets.command = None # Sollte nicht fehlschlagen await connected_satellite.disconnect("No socket") assert connected_satellite._state == ConnectionState.DISCONNECTED @pytest.mark.asyncio async def test_disconnect_send_command_fails(self, connected_satellite, mock_writer): """Testet disconnect() wenn send_command fehlschlägt.""" mock_writer.write.side_effect = Exception("Send failed") # Sollte trotzdem funktionieren (Fehler werden ignoriert) await connected_satellite.disconnect("Error test") assert connected_satellite._state == ConnectionState.DISCONNECTED @pytest.mark.asyncio async def test_disconnect_close_socket_fails(self, connected_satellite, mock_writer): """Testet disconnect() wenn Socket-Close fehlschlägt.""" mock_writer.close.side_effect = Exception("Close failed") # Sollte trotzdem funktionieren (Fehler werden ignoriert) await connected_satellite.disconnect("Close error") assert connected_satellite._state == ConnectionState.DISCONNECTED @pytest.mark.asyncio async def test_disconnect_with_reason(self, connected_satellite, mock_writer): """Testet, dass der Grund in der Shutdown-Nachricht enthalten ist.""" reason = "Scheduled maintenance" await connected_satellite.disconnect(reason) # Die geschriebenen Daten sollten den Grund enthalten (in Pickle-Form) mock_writer.write.assert_called() # ============================================================================= # Tests für Client-seitige ServerShutdown Behandlung # ============================================================================= class TestClientServerShutdownHandling: """Tests für Client-seitige Behandlung von ServerShutdown.""" @pytest.mark.asyncio async def test_handle_server_shutdown_permanent(self, mock_app): """Testet Behandlung von permanentem Shutdown.""" from trixy_core.client import ClientApplication # Minimaler Mock für ClientApplication client = MagicMock(spec=ClientApplication) client.shutdown = MagicMock() client._running = True # Simuliere die Handler-Logik data = ServerShutdown( reason="Server wird beendet", reconnect_after_seconds=0, ) # Extrahiere Werte wie im echten Handler reason = data.reason reconnect_after = data.reconnect_after_seconds if reconnect_after == 0: client.shutdown() client.shutdown.assert_called_once() @pytest.mark.asyncio async def test_handle_server_shutdown_with_reconnect(self, mock_app): """Testet Behandlung von temporärem Shutdown mit Wiederverbindung.""" data = ServerShutdown( reason="Server-Neustart", reconnect_after_seconds=30, ) reconnect_after = data.reconnect_after_seconds # Bei reconnect_after > 0 sollte NICHT sofort beendet werden assert reconnect_after > 0 # Client würde warten und dann reconnecten def test_handle_server_shutdown_extract_reason(self): """Testet Extraktion des Shutdown-Grunds.""" data = ServerShutdown(reason="Test reason") reason = getattr(data, "reason", "") assert reason == "Test reason" def test_handle_server_shutdown_dict_data(self): """Testet Behandlung von Dict-Daten.""" data = { "reason": "Dict reason", "reconnect_after_seconds": 60, } reason = data.get("reason", "") reconnect_after = data.get("reconnect_after_seconds", 0) assert reason == "Dict reason" assert reconnect_after == 60 # ============================================================================= # Tests für SatelliteManager.disconnect_all() # ============================================================================= class TestSatelliteManagerDisconnectAll: """Tests für SatelliteManager.disconnect_all().""" @pytest.mark.asyncio async def test_disconnect_all_sends_to_all(self): """Testet, dass disconnect_all an alle Satellites sendet.""" from trixy_core.satellite.satellite_manager import SatelliteManager # Mock Application mock_app = MagicMock() manager = SatelliteManager(mock_app) # Mock Satellites erstellen satellites = [] for i in range(3): sat = MagicMock(spec=Satellite) sat.is_connected = True sat.disconnect = AsyncMock() satellites.append(sat) manager._satellites[f"sat-{i}"] = sat # disconnect_all aufrufen count = await manager.disconnect_all("Test shutdown") # Alle sollten getrennt worden sein assert count == 3 for sat in satellites: sat.disconnect.assert_called_once_with("Test shutdown") @pytest.mark.asyncio async def test_disconnect_all_skips_disconnected(self): """Testet, dass bereits getrennte Satellites übersprungen werden.""" from trixy_core.satellite.satellite_manager import SatelliteManager mock_app = MagicMock() manager = SatelliteManager(mock_app) # Mix aus verbundenen und getrennten Satellites connected_sat = MagicMock(spec=Satellite) connected_sat.is_connected = True connected_sat.disconnect = AsyncMock() disconnected_sat = MagicMock(spec=Satellite) disconnected_sat.is_connected = False disconnected_sat.disconnect = AsyncMock() manager._satellites["connected"] = connected_sat manager._satellites["disconnected"] = disconnected_sat count = await manager.disconnect_all("Test") # Nur verbundener sollte getrennt werden assert count == 1 connected_sat.disconnect.assert_called_once() disconnected_sat.disconnect.assert_not_called() # ============================================================================= # Integration Tests # ============================================================================= class TestServerShutdownIntegration: """Integrationstests für Server-Shutdown-Flow.""" @pytest.mark.asyncio async def test_full_shutdown_flow(self, mock_writer): """Testet den vollständigen Shutdown-Flow.""" # Satellite erstellen satellite = Satellite( satellite_id="integration-test", alias="Integration Satellite", ) satellite._state = ConnectionState.CONNECTED satellite._sockets.command = mock_writer satellite._conversation_id = "active-conversation" # Disconnect ausführen await satellite.disconnect("Integration shutdown test") # Prüfe Ergebnisse assert satellite._state == ConnectionState.DISCONNECTED assert satellite._conversation_id is None assert satellite._sockets.command is None # Command sollte gesendet worden sein mock_writer.write.assert_called() mock_writer.drain.assert_called() @pytest.mark.asyncio async def test_shutdown_preserves_satellite_info(self, mock_writer): """Testet, dass Satellite-Info nach Shutdown erhalten bleibt.""" satellite = Satellite( satellite_id="preserve-test", room_id="living_room", mac_address="11:22:33:44:55:66", alias="Preserve Satellite", ip_address="10.0.0.1", ) satellite._state = ConnectionState.CONNECTED satellite._sockets.command = mock_writer await satellite.disconnect("Preserve test") # Info sollte erhalten bleiben assert satellite.id == "preserve-test" assert satellite.room_id == "living_room" assert satellite.mac_address == "11:22:33:44:55:66" assert satellite.alias == "Preserve Satellite" # ============================================================================= # Edge Cases # ============================================================================= class TestEdgeCases: """Tests für Grenzfälle.""" def test_server_shutdown_empty_reason(self): """Testet ServerShutdown mit leerem Grund.""" cmd = ServerShutdown(reason="", reconnect_after_seconds=0) assert cmd.reason == "" def test_server_shutdown_long_reason(self): """Testet ServerShutdown mit langem Grund.""" long_reason = "A" * 1000 cmd = ServerShutdown(reason=long_reason) assert cmd.reason == long_reason def test_server_shutdown_negative_reconnect(self): """Testet ServerShutdown mit negativem reconnect-Wert.""" cmd = ServerShutdown(reconnect_after_seconds=-1) assert cmd.reconnect_after_seconds == -1 @pytest.mark.asyncio async def test_multiple_disconnects(self, connected_satellite, mock_writer): """Testet mehrfaches Aufrufen von disconnect().""" await connected_satellite.disconnect("First") await connected_satellite.disconnect("Second") await connected_satellite.disconnect("Third") assert connected_satellite._state == ConnectionState.DISCONNECTED @pytest.mark.asyncio async def test_disconnect_already_disconnected(self, disconnected_satellite): """Testet disconnect() bei bereits getrenntem Satellite.""" await disconnected_satellite.disconnect("Already disconnected") assert disconnected_satellite._state == ConnectionState.DISCONNECTED @pytest.mark.asyncio async def test_send_command_while_disconnecting(self, connected_satellite, mock_writer): """Testet send_command während disconnect läuft.""" connected_satellite._state = ConnectionState.DISCONNECTING cmd = ServerShutdown(reason="During disconnect") result = await connected_satellite.send_command(cmd) # DISCONNECTING ist nicht in is_connected, also sollte es fehlschlagen assert result is False