Initial commit - Phase 3/4
🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
0
tests/unit/ble/__init__.py
Normal file
0
tests/unit/ble/__init__.py
Normal file
419
tests/unit/ble/test_gatt_server.py
Normal file
419
tests/unit/ble/test_gatt_server.py
Normal file
@@ -0,0 +1,419 @@
|
||||
"""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"
|
||||
Reference in New Issue
Block a user