"""Unit tests for BLE GATT Server. Tests the GATT server implementation to ensure: - Proper delegation to service layer - Correct data format conversion (BLE ↔ Service) - Error handling and response formatting - Notification sending - ZERO business logic in GATT handlers (all in services!) """ import pytest import json from unittest.mock import Mock, AsyncMock, patch from app.backend.ble.gatt_server import DangerousPiGATTServer from app.backend.ble.characteristics import PM3CharacteristicUUIDs, WiFiCharacteristicUUIDs from app.backend.services.pm3_service import PM3ServiceResult, PM3ServiceError class TestGATTServerInitialization: """Test GATT server initialization.""" def test_initialization(self): """Test GATT server initializes with all characteristics.""" # Act server = DangerousPiGATTServer() # Assert assert server.is_running is False characteristics = server.get_all_characteristics() assert len(characteristics) > 0 # Verify PM3 characteristics registered assert PM3CharacteristicUUIDs.COMMAND_WRITE in characteristics assert PM3CharacteristicUUIDs.STATUS in characteristics @pytest.mark.asyncio async def test_start_stop(self): """Test GATT server start and stop.""" # Arrange server = DangerousPiGATTServer() # Act await server.start() assert server.is_running is True await server.stop() assert server.is_running is False class TestPM3GATTCharacteristics: """Test PM3 GATT characteristic handlers. These tests verify that handlers are THIN ADAPTERS that delegate to PM3Service with zero business logic. """ @pytest.mark.asyncio async def test_command_write_success(self): """Test PM3 command execution via BLE delegates to PM3Service.""" # Arrange server = DangerousPiGATTServer() # Mock PM3Service response mock_service_result = PM3ServiceResult( success=True, data={ "output": "Proxmark3 RFID instrument", "command": "hw version", "session_id": "test-session" } ) # Mock notification callback notification_called = False notification_value = None async def mock_notify(value): nonlocal notification_called, notification_value notification_called = True notification_value = value server.register_notification_callback( PM3CharacteristicUUIDs.COMMAND_RESULT, mock_notify ) # Prepare BLE command command_data = {"command": "hw version", "session_id": "test-session"} command_bytes = json.dumps(command_data).encode('utf-8') # Act - patch container and call handler with patch('app.backend.ble.gatt_server.container') as mock_container: mock_container.pm3_service.execute_command = AsyncMock( return_value=mock_service_result ) result = await server._handle_pm3_command_write(command_bytes) # Verify service was called with correct params mock_container.pm3_service.execute_command.assert_called_once_with( command="hw version", session_id="test-session" ) # Assert assert result["success"] is True assert "Proxmark3" in result["output"] # Notification should have been sent assert notification_called is True @pytest.mark.asyncio async def test_command_write_session_locked(self): """Test PM3 command with session locked error from service.""" # Arrange server = DangerousPiGATTServer() mock_service_result = PM3ServiceResult( success=False, error=PM3ServiceError( code="session_locked", message="Another session is active" ) ) command_data = {"command": "hw version", "session_id": "test-session"} command_bytes = json.dumps(command_data).encode('utf-8') # Act with patch('app.backend.ble.gatt_server.container') as mock_container: mock_container.pm3_service.execute_command = AsyncMock( return_value=mock_service_result ) result = await server._handle_pm3_command_write(command_bytes) # Assert assert result["success"] is False assert result["error"]["code"] == "session_locked" assert "Another session is active" in result["error"]["message"] @pytest.mark.asyncio async def test_status_read_delegates_to_service(self): """Test PM3 status read delegates to PM3Service.""" # Arrange server = DangerousPiGATTServer() mock_service_result = PM3ServiceResult( success=True, data={ "connected": True, "device_path": "/dev/ttyACM0", "version": "Proxmark3 v4.0", "session_active": False } ) # Act with patch('app.backend.ble.gatt_server.container') as mock_container: mock_container.pm3_service.get_status = AsyncMock( return_value=mock_service_result ) result_bytes = await server._handle_pm3_status_read() result = json.loads(result_bytes.decode('utf-8')) # Assert assert result["success"] is True assert result["connected"] is True assert result["device_path"] == "/dev/ttyACM0" # Verify service was called mock_container.pm3_service.get_status.assert_called_once() @pytest.mark.asyncio async def test_session_create_delegates_to_service(self): """Test session creation delegates to PM3Service.""" # Arrange server = DangerousPiGATTServer() mock_service_result = PM3ServiceResult( success=True, data={"session_id": "new-session-123"} ) request_data = {"force_takeover": False} request_bytes = json.dumps(request_data).encode('utf-8') # Act with patch('app.backend.ble.gatt_server.container') as mock_container: mock_container.pm3_service.create_session = Mock( return_value=mock_service_result ) result = await server._handle_session_create_write(request_bytes) # Assert assert result["success"] is True assert result["session_id"] == "new-session-123" mock_container.pm3_service.create_session.assert_called_once_with( force_takeover=False ) class TestWiFiGATTCharacteristics: """Test WiFi GATT characteristic handlers.""" @pytest.mark.asyncio async def test_wifi_status_read_delegates_to_service(self): """Test WiFi status read delegates to WiFiService.""" # Arrange server = DangerousPiGATTServer() from app.backend.services.pm3_service import PM3ServiceResult mock_service_result = PM3ServiceResult( success=True, data={ "mode": "ap", "interfaces": [], "client": None, "access_point": {"ssid": "dangerous-pi", "ip": "10.3.141.1"}, "supports_dual": False } ) # Act with patch('app.backend.ble.gatt_server.container') as mock_container: mock_container.wifi_service.get_status = AsyncMock( return_value=mock_service_result ) result_bytes = await server._handle_wifi_status_read() result = json.loads(result_bytes.decode('utf-8')) # Assert assert result["mode"] == "ap" assert result["access_point"]["ssid"] == "dangerous-pi" mock_container.wifi_service.get_status.assert_called_once() @pytest.mark.asyncio async def test_wifi_connect_delegates_to_service(self): """Test WiFi connect delegates to WiFiService.""" # Arrange server = DangerousPiGATTServer() mock_service_result = PM3ServiceResult( success=True, data={ "message": "Connected to TestNetwork", "ssid": "TestNetwork", "ip": "192.168.1.100" } ) request_data = { "ssid": "TestNetwork", "password": "testpass123", "hidden": False } request_bytes = json.dumps(request_data).encode('utf-8') # Act with patch('app.backend.ble.gatt_server.container') as mock_container: mock_container.wifi_service.connect = AsyncMock( return_value=mock_service_result ) result = await server._handle_wifi_connect_write(request_bytes) # Assert assert result["success"] is True assert result["ssid"] == "TestNetwork" assert result["ip"] == "192.168.1.100" # Verify service was called with correct params mock_container.wifi_service.connect.assert_called_once_with( ssid="TestNetwork", password="testpass123", hidden=False ) class TestSystemGATTCharacteristics: """Test System GATT characteristic handlers.""" @pytest.mark.asyncio async def test_system_info_read_delegates_to_service(self): """Test system info read delegates to SystemService.""" # Arrange server = DangerousPiGATTServer() mock_service_result = PM3ServiceResult( success=True, data={ "hostname": "dangerous-pi", "platform": "Linux", "cpu": {"count": 4, "percent": 25.0, "temperature": 45.5}, "memory": {"total": 4294967296, "used": 2147483648, "percent": 50.0}, "disk": {"total": 32212254720, "used": 10737418240, "percent": 33.3}, "uptime": 86400.0 } ) # Act with patch('app.backend.ble.gatt_server.container') as mock_container: mock_container.system_service.get_info = AsyncMock( return_value=mock_service_result ) result_bytes = await server._handle_system_info_read() result = json.loads(result_bytes.decode('utf-8')) # Assert assert result["hostname"] == "dangerous-pi" assert result["cpu"]["count"] == 4 mock_container.system_service.get_info.assert_called_once() @pytest.mark.asyncio async def test_shutdown_write_delegates_to_service(self): """Test shutdown request delegates to SystemService.""" # Arrange server = DangerousPiGATTServer() mock_service_result = PM3ServiceResult( success=True, data={"message": "Shutdown initiated"} ) request_data = {"delay": 30} request_bytes = json.dumps(request_data).encode('utf-8') # Act with patch('app.backend.ble.gatt_server.container') as mock_container: mock_container.system_service.shutdown = AsyncMock( return_value=mock_service_result ) result = await server._handle_system_shutdown_write(request_bytes) # Assert assert result["success"] is True assert "Shutdown initiated" in result["message"] mock_container.system_service.shutdown.assert_called_once_with(delay=30) class TestGATTServerNotifications: """Test notification system.""" @pytest.mark.asyncio async def test_notification_callback_registration(self): """Test registering notification callbacks.""" # Arrange server = DangerousPiGATTServer() callback_called = False callback_value = None async def test_callback(value): nonlocal callback_called, callback_value callback_called = True callback_value = value # Act server.register_notification_callback( PM3CharacteristicUUIDs.COMMAND_RESULT, test_callback ) await server._notify_characteristic( PM3CharacteristicUUIDs.COMMAND_RESULT, b"test notification" ) # Assert assert callback_called is True assert callback_value == b"test notification" @pytest.mark.asyncio async def test_notification_without_callback(self): """Test notification when no callback registered (should not error).""" # Arrange server = DangerousPiGATTServer() # Act & Assert (should not raise) await server._notify_characteristic( "nonexistent-uuid", b"test" ) class TestErrorHandling: """Test error handling in GATT handlers.""" @pytest.mark.asyncio async def test_invalid_json_in_command(self): """Test handling of invalid JSON in command.""" # Arrange server = DangerousPiGATTServer() invalid_json = b"not valid json{" # Act result = await server._handle_pm3_command_write(invalid_json) # Assert assert result["success"] is False assert result["error"]["code"] == "invalid_json" @pytest.mark.asyncio async def test_missing_required_field(self): """Test handling of missing required field.""" # Arrange server = DangerousPiGATTServer() request_data = {} # Missing "command" field request_bytes = json.dumps(request_data).encode('utf-8') # Act result = await server._handle_pm3_command_write(request_bytes) # Assert assert result["success"] is False assert result["error"]["code"] == "invalid_request"