| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591 |
- # -*- 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
|