Files
pi-pm3/tests/unit/ble/test_gatt_server.py
michael 4f35df1781 Initial commit - Phase 3/4
🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-06 13:46:22 -08:00

420 lines
14 KiB
Python

"""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"