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:
230
tests/README.md
Normal file
230
tests/README.md
Normal file
@@ -0,0 +1,230 @@
|
||||
# Dangerous Pi Test Suite
|
||||
|
||||
Comprehensive test suite for the Dangerous Pi backend refactoring.
|
||||
|
||||
## Test Structure
|
||||
|
||||
```
|
||||
tests/
|
||||
├── conftest.py # Shared fixtures and configuration
|
||||
├── unit/ # Unit tests (isolated, fast)
|
||||
│ └── services/ # Service layer tests
|
||||
│ ├── test_pm3_service.py
|
||||
│ ├── test_system_service.py
|
||||
│ ├── test_wifi_service.py
|
||||
│ └── test_update_service.py
|
||||
└── integration/ # Integration tests (API + services)
|
||||
└── api/ # API endpoint tests
|
||||
├── test_pm3_api.py
|
||||
└── ...
|
||||
```
|
||||
|
||||
## Running Tests
|
||||
|
||||
### Run all tests
|
||||
```bash
|
||||
pytest
|
||||
```
|
||||
|
||||
### Run specific test types
|
||||
```bash
|
||||
# Unit tests only
|
||||
pytest tests/unit/
|
||||
|
||||
# Integration tests only
|
||||
pytest tests/integration/
|
||||
|
||||
# Specific service tests
|
||||
pytest tests/unit/services/test_pm3_service.py
|
||||
```
|
||||
|
||||
### Run with coverage
|
||||
```bash
|
||||
# Generate coverage report
|
||||
pytest --cov=app/backend/services --cov=app/backend/api --cov-report=html
|
||||
|
||||
# View coverage report
|
||||
open htmlcov/index.html
|
||||
```
|
||||
|
||||
### Run specific test classes or methods
|
||||
```bash
|
||||
# Run specific test class
|
||||
pytest tests/unit/services/test_pm3_service.py::TestPM3ServiceCommandExecution
|
||||
|
||||
# Run specific test method
|
||||
pytest tests/unit/services/test_pm3_service.py::TestPM3ServiceCommandExecution::test_execute_command_success
|
||||
```
|
||||
|
||||
### Run with markers
|
||||
```bash
|
||||
# Run only async tests
|
||||
pytest -m asyncio
|
||||
|
||||
# Run only unit tests
|
||||
pytest -m unit
|
||||
|
||||
# Skip slow tests
|
||||
pytest -m "not slow"
|
||||
```
|
||||
|
||||
## Test Coverage
|
||||
|
||||
The test suite covers:
|
||||
|
||||
### Unit Tests (tests/unit/services/)
|
||||
|
||||
**PM3Service** (test_pm3_service.py):
|
||||
- ✅ Command execution with session validation
|
||||
- ✅ Session locked error handling
|
||||
- ✅ PM3 not connected error handling
|
||||
- ✅ Command failure handling
|
||||
- ✅ Exception handling
|
||||
- ✅ Status queries
|
||||
- ✅ Connection management
|
||||
- ✅ Session management (create, release, get info)
|
||||
|
||||
**SystemService** (test_system_service.py):
|
||||
- ✅ System information queries (CPU, memory, disk)
|
||||
- ✅ CPU temperature reading
|
||||
- ✅ Shutdown operations (immediate and delayed)
|
||||
- ✅ Restart operations (immediate and delayed)
|
||||
- ✅ Shutdown cancellation
|
||||
- ✅ Log retrieval
|
||||
- ✅ Service status queries
|
||||
- ✅ Error handling for all operations
|
||||
|
||||
**WiFiService** (test_wifi_service.py):
|
||||
- ✅ WiFi status queries (AP and client modes)
|
||||
- ✅ Network scanning
|
||||
- ✅ Network connection (encrypted and open)
|
||||
- ✅ Network disconnection
|
||||
- ✅ Mode switching (AP, client, dual, auto, off)
|
||||
- ✅ Invalid mode handling
|
||||
- ✅ Dual mode without USB adapter error
|
||||
- ✅ Saved network management
|
||||
- ✅ Forget network operation
|
||||
|
||||
**UpdateService** (test_update_service.py):
|
||||
- ✅ Update checking (available and up-to-date)
|
||||
- ✅ Update downloading
|
||||
- ✅ Update installation
|
||||
- ✅ Progress monitoring (all states)
|
||||
- ✅ Release notes retrieval
|
||||
- ✅ Combined operations (check+download, full update)
|
||||
- ✅ Error handling for no update available/downloaded
|
||||
|
||||
### Integration Tests (tests/integration/api/)
|
||||
|
||||
**PM3 API** (test_pm3_api.py):
|
||||
- ✅ Status endpoint integration with PM3Service
|
||||
- ✅ Command endpoint with session management
|
||||
- ✅ HTTP status code mapping (423 for session locked, 503 for not connected)
|
||||
- ✅ Connection/disconnection endpoints
|
||||
- ✅ Request/response format consistency
|
||||
|
||||
## Best Practices Demonstrated
|
||||
|
||||
### 1. Proper Test Isolation
|
||||
- Each test is independent and can run in any order
|
||||
- Mocks are used to isolate unit tests from external dependencies
|
||||
- Fixtures provide clean test setup
|
||||
|
||||
### 2. Clear Test Structure
|
||||
- Tests follow Arrange-Act-Assert (AAA) pattern
|
||||
- Descriptive test names explain what is being tested
|
||||
- Test classes group related tests logically
|
||||
|
||||
### 3. Comprehensive Coverage
|
||||
- Both success and error paths tested
|
||||
- Edge cases covered (session locked, not connected, etc.)
|
||||
- Exception handling verified
|
||||
|
||||
### 4. Async Test Support
|
||||
- `pytest-asyncio` used for testing async code
|
||||
- Proper mocking of async functions with `AsyncMock`
|
||||
|
||||
### 5. Integration Testing
|
||||
- API endpoints tested with FastAPI TestClient
|
||||
- Service integration verified
|
||||
- HTTP status codes validated
|
||||
|
||||
### 6. Maintainability
|
||||
- Shared fixtures in conftest.py reduce duplication
|
||||
- Clear documentation in test docstrings
|
||||
- Consistent naming conventions
|
||||
|
||||
## Writing New Tests
|
||||
|
||||
### Unit Test Template
|
||||
```python
|
||||
@pytest.mark.asyncio
|
||||
async def test_feature_success(self, mock_dependency):
|
||||
"""Test description."""
|
||||
# Arrange
|
||||
mock_dependency.method.return_value = expected_value
|
||||
service = YourService(mock_dependency)
|
||||
|
||||
# Act
|
||||
result = await service.your_method()
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.data["key"] == expected_value
|
||||
mock_dependency.method.assert_called_once()
|
||||
```
|
||||
|
||||
### Integration Test Template
|
||||
```python
|
||||
def test_api_endpoint_success(self, test_client, mock_service):
|
||||
"""Test API endpoint description."""
|
||||
# Arrange
|
||||
mock_service.method.return_value = ServiceResult(...)
|
||||
|
||||
# Act
|
||||
with patch('app.backend.api.module.container.service', mock_service):
|
||||
response = test_client.post("/api/endpoint", json={...})
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
assert response.json()["key"] == expected_value
|
||||
```
|
||||
|
||||
## CI/CD Integration
|
||||
|
||||
Tests can be run in CI/CD pipelines:
|
||||
|
||||
```yaml
|
||||
# Example GitHub Actions
|
||||
- name: Run tests
|
||||
run: |
|
||||
pytest --cov --cov-report=xml
|
||||
|
||||
- name: Upload coverage
|
||||
uses: codecov/codecov-action@v3
|
||||
with:
|
||||
file: ./coverage.xml
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Tests fail with "RuntimeError: Event loop is closed"
|
||||
- Ensure `pytest-asyncio` is installed and configured
|
||||
- Check that `asyncio_mode = auto` is set in pytest.ini
|
||||
|
||||
### Import errors
|
||||
- Verify that PYTHONPATH includes project root
|
||||
- Check that all __init__.py files are present
|
||||
|
||||
### Coverage not generated
|
||||
- Ensure `pytest-cov` is installed
|
||||
- Check that source paths in pytest.ini are correct
|
||||
|
||||
## Contributing
|
||||
|
||||
When adding new features:
|
||||
1. Write unit tests for the service layer
|
||||
2. Write integration tests for API endpoints
|
||||
3. Ensure tests pass: `pytest`
|
||||
4. Verify coverage: `pytest --cov`
|
||||
5. Follow existing test patterns and naming conventions
|
||||
1
tests/__init__.py
Normal file
1
tests/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Test suite for Dangerous Pi backend."""
|
||||
109
tests/conftest.py
Normal file
109
tests/conftest.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""Pytest configuration and shared fixtures."""
|
||||
import pytest
|
||||
import asyncio
|
||||
from typing import Generator
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
# Fixtures available to all tests
|
||||
|
||||
|
||||
@pytest.fixture(scope="session")
|
||||
def event_loop():
|
||||
"""Create an event loop for the test session."""
|
||||
loop = asyncio.get_event_loop_policy().new_event_loop()
|
||||
yield loop
|
||||
loop.close()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pm3_worker():
|
||||
"""Create a mock PM3Worker."""
|
||||
worker = Mock()
|
||||
worker.is_connected = AsyncMock(return_value=True)
|
||||
worker.execute_command = AsyncMock()
|
||||
worker.connect = AsyncMock()
|
||||
worker.disconnect = AsyncMock()
|
||||
worker.device_path = "/dev/ttyACM0"
|
||||
return worker
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_session_manager():
|
||||
"""Create a mock SessionManager."""
|
||||
manager = Mock()
|
||||
manager.can_execute = Mock(return_value=True)
|
||||
manager.update_activity = Mock()
|
||||
manager.has_active_session = Mock(return_value=False)
|
||||
manager.create_session = Mock(return_value="test-session-id")
|
||||
manager.release_session = Mock()
|
||||
manager.get_session = Mock(return_value=None)
|
||||
manager.get_active_session = Mock(return_value=None)
|
||||
return manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_wifi_manager():
|
||||
"""Create a mock WiFiManager."""
|
||||
from app.backend.managers.wifi_manager import WiFiMode, WiFiStatus, WiFiInterface
|
||||
|
||||
manager = Mock()
|
||||
manager.get_status = AsyncMock(return_value=WiFiStatus(
|
||||
mode=WiFiMode.AP,
|
||||
interfaces=[],
|
||||
current_ssid=None,
|
||||
current_ip=None,
|
||||
ap_ssid="raspi-webgui",
|
||||
ap_ip="10.3.141.1",
|
||||
supports_dual=False
|
||||
))
|
||||
manager.scan_networks = AsyncMock(return_value=[])
|
||||
manager.connect_to_network = AsyncMock(return_value=True)
|
||||
manager.disconnect_from_network = AsyncMock(return_value=True)
|
||||
manager.set_mode = AsyncMock(return_value=True)
|
||||
manager.get_saved_networks = AsyncMock(return_value=[])
|
||||
manager.forget_network = AsyncMock(return_value=True)
|
||||
return manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_update_manager():
|
||||
"""Create a mock UpdateManager."""
|
||||
from app.backend.managers.update_manager import UpdateProgress, UpdateStatus
|
||||
|
||||
manager = Mock()
|
||||
manager.check_for_updates = AsyncMock(return_value={
|
||||
"update_available": False,
|
||||
"current_version": "1.0.0",
|
||||
"message": "System is up to date"
|
||||
})
|
||||
manager.download_update = AsyncMock(return_value=True)
|
||||
manager.install_update = AsyncMock(return_value=True)
|
||||
manager.get_progress = AsyncMock(return_value=UpdateProgress(
|
||||
status=UpdateStatus.IDLE,
|
||||
current_version="1.0.0"
|
||||
))
|
||||
manager.get_release_notes = AsyncMock(return_value="Release notes")
|
||||
return manager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pm3_service():
|
||||
"""Create a mock PM3Service for integration tests."""
|
||||
service = Mock()
|
||||
service.execute_command = AsyncMock()
|
||||
service.get_status = AsyncMock()
|
||||
service.connect = AsyncMock()
|
||||
service.disconnect = AsyncMock()
|
||||
service.create_session = Mock()
|
||||
service.release_session = Mock()
|
||||
service.get_session_info = Mock()
|
||||
return service
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_client():
|
||||
"""Create a FastAPI TestClient for integration tests."""
|
||||
from fastapi.testclient import TestClient
|
||||
from app.backend.main import app
|
||||
|
||||
return TestClient(app)
|
||||
0
tests/integration/__init__.py
Normal file
0
tests/integration/__init__.py
Normal file
0
tests/integration/api/__init__.py
Normal file
0
tests/integration/api/__init__.py
Normal file
199
tests/integration/api/test_pm3_api.py
Normal file
199
tests/integration/api/test_pm3_api.py
Normal file
@@ -0,0 +1,199 @@
|
||||
"""Integration tests for PM3 API endpoints.
|
||||
|
||||
Tests the refactored PM3 API endpoints to ensure:
|
||||
- Proper integration with PM3Service
|
||||
- Correct HTTP status codes for different error types
|
||||
- Request/response format consistency
|
||||
- Session management integration
|
||||
"""
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from unittest.mock import patch, AsyncMock, Mock
|
||||
|
||||
from app.backend.services.pm3_service import PM3ServiceResult, PM3ServiceError
|
||||
|
||||
|
||||
class TestPM3APIStatus:
|
||||
"""Test /api/pm3/status endpoint."""
|
||||
|
||||
def test_get_status_success(self, test_client, mock_pm3_service):
|
||||
"""Test successful status retrieval."""
|
||||
# Arrange
|
||||
mock_pm3_service.get_status.return_value = PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"connected": True,
|
||||
"device_path": "/dev/ttyACM0",
|
||||
"version": "Proxmark3 v4.0",
|
||||
"session_active": False
|
||||
}
|
||||
)
|
||||
|
||||
# Act
|
||||
with patch('app.backend.api.pm3.container') as mock_container:
|
||||
mock_container.pm3_service = mock_pm3_service
|
||||
response = test_client.get("/api/pm3/status")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["connected"] is True
|
||||
assert data["device"] == "/dev/ttyACM0"
|
||||
assert "Proxmark3" in data["version"]
|
||||
|
||||
def test_get_status_error(self, test_client, mock_pm3_service):
|
||||
"""Test status retrieval with error."""
|
||||
# Arrange
|
||||
mock_pm3_service.get_status.return_value = PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="status_error",
|
||||
message="Failed to query status"
|
||||
)
|
||||
)
|
||||
|
||||
# Act
|
||||
with patch('app.backend.api.pm3.container') as mock_container:
|
||||
mock_container.pm3_service = mock_pm3_service
|
||||
response = test_client.get("/api/pm3/status")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 500
|
||||
assert "Failed to query status" in response.json()["detail"]
|
||||
|
||||
|
||||
class TestPM3APICommand:
|
||||
"""Test /api/pm3/command endpoint."""
|
||||
|
||||
def test_execute_command_success(self, test_client, mock_pm3_service):
|
||||
"""Test successful command execution."""
|
||||
# Arrange
|
||||
mock_pm3_service.execute_command.return_value = PM3ServiceResult(
|
||||
success=True,
|
||||
data={
|
||||
"output": "Proxmark3 RFID instrument",
|
||||
"command": "hw version",
|
||||
"session_id": "test-session"
|
||||
}
|
||||
)
|
||||
|
||||
# Act
|
||||
with patch('app.backend.api.pm3.container') as mock_container:
|
||||
mock_container.pm3_service = mock_pm3_service
|
||||
response = test_client.post(
|
||||
"/api/pm3/command",
|
||||
json={"command": "hw version", "session_id": "test-session"}
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
data = response.json()
|
||||
assert data["success"] is True
|
||||
assert "Proxmark3" in data["output"]
|
||||
assert data["error"] is None
|
||||
|
||||
def test_execute_command_session_locked(self, test_client, mock_pm3_service):
|
||||
"""Test command execution with session locked."""
|
||||
# Arrange
|
||||
mock_pm3_service.execute_command.return_value = PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="session_locked",
|
||||
message="Another session is active"
|
||||
)
|
||||
)
|
||||
|
||||
# Act
|
||||
with patch('app.backend.api.pm3.container') as mock_container:
|
||||
mock_container.pm3_service = mock_pm3_service
|
||||
response = test_client.post(
|
||||
"/api/pm3/command",
|
||||
json={"command": "hw version", "session_id": "test-session"}
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 423 # Locked
|
||||
assert "Another session is active" in response.json()["detail"]
|
||||
|
||||
def test_execute_command_pm3_not_connected(self, test_client, mock_pm3_service):
|
||||
"""Test command execution when PM3 not connected."""
|
||||
# Arrange
|
||||
mock_pm3_service.execute_command.return_value = PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="pm3_not_connected",
|
||||
message="Proxmark3 not connected"
|
||||
)
|
||||
)
|
||||
|
||||
# Act
|
||||
with patch('app.backend.api.pm3.container') as mock_container:
|
||||
mock_container.pm3_service = mock_pm3_service
|
||||
response = test_client.post(
|
||||
"/api/pm3/command",
|
||||
json={"command": "hw version"}
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200 # Returns in body, not HTTP error
|
||||
data = response.json()
|
||||
assert data["success"] is False
|
||||
assert "not connected" in data["error"].lower()
|
||||
|
||||
|
||||
class TestPM3APIConnection:
|
||||
"""Test PM3 connection endpoints."""
|
||||
|
||||
def test_connect_success(self, test_client, mock_pm3_service):
|
||||
"""Test successful PM3 connection."""
|
||||
# Arrange
|
||||
mock_pm3_service.connect.return_value = PM3ServiceResult(
|
||||
success=True,
|
||||
data={"message": "Connected to Proxmark3"}
|
||||
)
|
||||
|
||||
# Act
|
||||
with patch('app.backend.api.pm3.container') as mock_container:
|
||||
mock_container.pm3_service = mock_pm3_service
|
||||
response = test_client.post("/api/pm3/connect")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
assert response.json()["success"] is True
|
||||
|
||||
def test_connect_failure(self, test_client, mock_pm3_service):
|
||||
"""Test PM3 connection failure."""
|
||||
# Arrange
|
||||
mock_pm3_service.connect.return_value = PM3ServiceResult(
|
||||
success=False,
|
||||
error=PM3ServiceError(
|
||||
code="connection_error",
|
||||
message="Device not found"
|
||||
)
|
||||
)
|
||||
|
||||
# Act
|
||||
with patch('app.backend.api.pm3.container') as mock_container:
|
||||
mock_container.pm3_service = mock_pm3_service
|
||||
response = test_client.post("/api/pm3/connect")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 503
|
||||
assert "Device not found" in response.json()["detail"]
|
||||
|
||||
def test_disconnect_success(self, test_client, mock_pm3_service):
|
||||
"""Test successful PM3 disconnection."""
|
||||
# Arrange
|
||||
mock_pm3_service.disconnect.return_value = PM3ServiceResult(
|
||||
success=True,
|
||||
data={"message": "Disconnected from Proxmark3"}
|
||||
)
|
||||
|
||||
# Act
|
||||
with patch('app.backend.api.pm3.container') as mock_container:
|
||||
mock_container.pm3_service = mock_pm3_service
|
||||
response = test_client.post("/api/pm3/disconnect")
|
||||
|
||||
# Assert
|
||||
assert response.status_code == 200
|
||||
assert response.json()["success"] is True
|
||||
210
tests/integration/test_multi_device_discovery.py
Normal file
210
tests/integration/test_multi_device_discovery.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""Integration tests for multi-device discovery."""
|
||||
import pytest
|
||||
import asyncio
|
||||
from unittest.mock import Mock, patch
|
||||
|
||||
from app.backend.managers.pm3_device_manager import (
|
||||
PM3DeviceManager,
|
||||
DeviceStatus
|
||||
)
|
||||
from app.backend.managers.session_manager import SessionManager
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
async def device_manager():
|
||||
"""Create and start device manager."""
|
||||
manager = PM3DeviceManager()
|
||||
# Don't start monitoring for tests
|
||||
yield manager
|
||||
await manager.stop()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def session_manager():
|
||||
"""Create session manager."""
|
||||
return SessionManager()
|
||||
|
||||
|
||||
class TestMultiDeviceDiscovery:
|
||||
"""Integration tests for device discovery."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_no_devices(self, device_manager):
|
||||
"""Test discovery when no devices are connected."""
|
||||
with patch('app.backend.managers.pm3_device_manager.SERIAL_AVAILABLE', False):
|
||||
with patch('pathlib.Path.glob', return_value=[]):
|
||||
devices = await device_manager.discover_devices()
|
||||
assert len(devices) == 0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_single_device(self, device_manager):
|
||||
"""Test discovering a single device."""
|
||||
with patch('pathlib.Path.glob') as mock_glob:
|
||||
# Mock single device
|
||||
mock_device = Mock()
|
||||
mock_device.is_char_device.return_value = True
|
||||
mock_device.__str__ = lambda x: "/dev/ttyACM0"
|
||||
mock_glob.return_value = [mock_device]
|
||||
|
||||
devices = await device_manager.discover_devices()
|
||||
|
||||
assert len(devices) == 1
|
||||
assert devices[0].device_path == "/dev/ttyACM0"
|
||||
assert devices[0].status == DeviceStatus.CONNECTED
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_multiple_devices(self, device_manager):
|
||||
"""Test discovering multiple devices."""
|
||||
with patch('pathlib.Path.glob') as mock_glob:
|
||||
# Mock multiple devices
|
||||
mock_devices = []
|
||||
for i in range(3):
|
||||
mock_device = Mock()
|
||||
mock_device.is_char_device.return_value = True
|
||||
mock_device.__str__ = lambda x, i=i: f"/dev/ttyACM{i}"
|
||||
mock_devices.append(mock_device)
|
||||
|
||||
mock_glob.return_value = mock_devices
|
||||
|
||||
devices = await device_manager.discover_devices()
|
||||
|
||||
assert len(devices) == 3
|
||||
paths = [d.device_path for d in devices]
|
||||
assert "/dev/ttyACM0" in paths
|
||||
assert "/dev/ttyACM1" in paths
|
||||
assert "/dev/ttyACM2" in paths
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_device_hotplug_simulation(self, device_manager):
|
||||
"""Test device hotplug by simulating device connect/disconnect."""
|
||||
with patch('pathlib.Path.glob') as mock_glob:
|
||||
# First discovery: 1 device
|
||||
mock_device1 = Mock()
|
||||
mock_device1.is_char_device.return_value = True
|
||||
mock_device1.__str__ = lambda x: "/dev/ttyACM0"
|
||||
mock_glob.return_value = [mock_device1]
|
||||
|
||||
devices = await device_manager.discover_devices()
|
||||
assert len(devices) == 1
|
||||
device1_id = devices[0].device_id
|
||||
|
||||
# Second discovery: 2 devices (device plugged in)
|
||||
mock_device2 = Mock()
|
||||
mock_device2.is_char_device.return_value = True
|
||||
mock_device2.__str__ = lambda x: "/dev/ttyACM1"
|
||||
mock_glob.return_value = [mock_device1, mock_device2]
|
||||
|
||||
devices = await device_manager.discover_devices()
|
||||
assert len(devices) == 2
|
||||
|
||||
# Third discovery: 1 device (device unplugged)
|
||||
mock_glob.return_value = [mock_device1]
|
||||
|
||||
devices = await device_manager.discover_devices()
|
||||
# Still 2 devices tracked, but one is DISCONNECTED
|
||||
assert len(devices) == 2
|
||||
|
||||
connected = [d for d in devices if d.status == DeviceStatus.CONNECTED]
|
||||
disconnected = [d for d in devices if d.status == DeviceStatus.DISCONNECTED]
|
||||
|
||||
assert len(connected) == 1
|
||||
assert len(disconnected) == 1
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_device_persistence_across_discovery(self, device_manager):
|
||||
"""Test that device objects persist across discovery runs."""
|
||||
with patch('pathlib.Path.glob') as mock_glob:
|
||||
mock_device = Mock()
|
||||
mock_device.is_char_device.return_value = True
|
||||
mock_device.__str__ = lambda x: "/dev/ttyACM0"
|
||||
mock_glob.return_value = [mock_device]
|
||||
|
||||
# First discovery
|
||||
devices1 = await device_manager.discover_devices()
|
||||
device_id = devices1[0].device_id
|
||||
|
||||
# Second discovery
|
||||
devices2 = await device_manager.discover_devices()
|
||||
|
||||
# Device ID should be the same
|
||||
assert len(devices2) == 1
|
||||
assert devices2[0].device_id == device_id
|
||||
|
||||
|
||||
class TestMultiDeviceSessionIsolation:
|
||||
"""Integration tests for session isolation across devices."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_single_session_per_device(self, device_manager, session_manager):
|
||||
"""Test that each device can have its own session."""
|
||||
# Note: This will be fully implemented in Sprint 2
|
||||
# For now, just verify session manager exists
|
||||
assert session_manager is not None
|
||||
|
||||
# TODO (Sprint 2): Test multiple devices with independent sessions
|
||||
|
||||
|
||||
class TestDeviceFiltering:
|
||||
"""Integration tests for device filtering."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_filter_by_status(self, device_manager):
|
||||
"""Test filtering devices by status."""
|
||||
with patch('pathlib.Path.glob') as mock_glob:
|
||||
mock_devices = []
|
||||
for i in range(3):
|
||||
mock_device = Mock()
|
||||
mock_device.is_char_device.return_value = True
|
||||
mock_device.__str__ = lambda x, i=i: f"/dev/ttyACM{i}"
|
||||
mock_devices.append(mock_device)
|
||||
|
||||
mock_glob.return_value = mock_devices
|
||||
|
||||
# Discover devices
|
||||
await device_manager.discover_devices()
|
||||
|
||||
# Set different statuses
|
||||
devices = await device_manager.get_all_devices()
|
||||
await device_manager.set_device_status(devices[0].device_id, DeviceStatus.CONNECTED)
|
||||
await device_manager.set_device_status(devices[1].device_id, DeviceStatus.IN_USE)
|
||||
await device_manager.set_device_status(devices[2].device_id, DeviceStatus.DISCONNECTED)
|
||||
|
||||
# Get available devices (only CONNECTED)
|
||||
available = await device_manager.get_available_devices()
|
||||
assert len(available) == 1
|
||||
assert available[0].status == DeviceStatus.CONNECTED
|
||||
|
||||
# Get connected devices (CONNECTED + IN_USE)
|
||||
connected = await device_manager.get_connected_devices()
|
||||
assert len(connected) == 2
|
||||
|
||||
|
||||
@pytest.mark.hardware
|
||||
@pytest.mark.asyncio
|
||||
async def test_real_hardware_discovery():
|
||||
"""Test discovery with real hardware.
|
||||
|
||||
This test requires actual Proxmark3 hardware connected.
|
||||
Run with: pytest -m hardware
|
||||
"""
|
||||
manager = PM3DeviceManager()
|
||||
|
||||
try:
|
||||
devices = await manager.discover_devices()
|
||||
|
||||
# If hardware is connected, verify it was found
|
||||
if devices:
|
||||
print(f"\nFound {len(devices)} PM3 device(s):")
|
||||
for device in devices:
|
||||
print(f" - {device.device_path} (ID: {device.device_id})")
|
||||
print(f" Status: {device.status.value}")
|
||||
print(f" VID:PID: {device.usb_vid}:{device.usb_pid}")
|
||||
else:
|
||||
print("\nNo PM3 devices found (this is OK for CI/CD)")
|
||||
|
||||
finally:
|
||||
await manager.stop()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
0
tests/unit/__init__.py
Normal file
0
tests/unit/__init__.py
Normal file
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"
|
||||
339
tests/unit/managers/test_pm3_device_manager.py
Normal file
339
tests/unit/managers/test_pm3_device_manager.py
Normal file
@@ -0,0 +1,339 @@
|
||||
"""Unit tests for PM3DeviceManager."""
|
||||
import pytest
|
||||
import asyncio
|
||||
from unittest.mock import Mock, patch, AsyncMock, MagicMock
|
||||
from datetime import datetime
|
||||
|
||||
from app.backend.managers.pm3_device_manager import (
|
||||
PM3DeviceManager,
|
||||
PM3Device,
|
||||
DeviceStatus,
|
||||
PM3FirmwareInfo
|
||||
)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def device_manager():
|
||||
"""Create a PM3DeviceManager instance."""
|
||||
return PM3DeviceManager()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_device():
|
||||
"""Create a mock PM3Device."""
|
||||
return PM3Device(
|
||||
device_id="pm3_test123",
|
||||
device_path="/dev/ttyACM0",
|
||||
serial_number="ABC123",
|
||||
friendly_name="Test PM3",
|
||||
usb_vid="9ac4",
|
||||
usb_pid="4b8f",
|
||||
status=DeviceStatus.CONNECTED
|
||||
)
|
||||
|
||||
|
||||
class TestPM3DeviceManager:
|
||||
"""Tests for PM3DeviceManager class."""
|
||||
|
||||
def test_initialization(self, device_manager):
|
||||
"""Test device manager initialization."""
|
||||
assert device_manager is not None
|
||||
assert device_manager.auto_discover is True
|
||||
assert device_manager.discovery_interval == 30
|
||||
assert len(device_manager._devices) == 0
|
||||
|
||||
def test_generate_device_id(self, device_manager):
|
||||
"""Test device ID generation."""
|
||||
device_id = device_manager._generate_device_id("/dev/ttyACM0")
|
||||
assert device_id.startswith("pm3_")
|
||||
assert len(device_id) == 12 # pm3_ + 8 char hash
|
||||
|
||||
# Same path should generate same ID
|
||||
device_id2 = device_manager._generate_device_id("/dev/ttyACM0")
|
||||
assert device_id == device_id2
|
||||
|
||||
# Different path should generate different ID
|
||||
device_id3 = device_manager._generate_device_id("/dev/ttyACM1")
|
||||
assert device_id != device_id3
|
||||
|
||||
def test_generate_device_id_with_serial(self, device_manager):
|
||||
"""Test device ID generation with serial number."""
|
||||
device_id = device_manager._generate_device_id("/dev/ttyACM0", serial="ABC123")
|
||||
assert device_id.startswith("pm3_")
|
||||
|
||||
# Same serial should generate same ID even with different path
|
||||
device_id2 = device_manager._generate_device_id("/dev/ttyACM1", serial="ABC123")
|
||||
assert device_id == device_id2
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_device(self, device_manager, mock_device):
|
||||
"""Test getting device by ID."""
|
||||
# Add device to manager
|
||||
device_manager._devices[mock_device.device_id] = mock_device
|
||||
|
||||
# Get existing device
|
||||
device = await device_manager.get_device(mock_device.device_id)
|
||||
assert device is not None
|
||||
assert device.device_id == mock_device.device_id
|
||||
|
||||
# Get non-existent device
|
||||
device = await device_manager.get_device("nonexistent")
|
||||
assert device is None
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_all_devices(self, device_manager, mock_device):
|
||||
"""Test getting all devices."""
|
||||
# Initially empty
|
||||
devices = await device_manager.get_all_devices()
|
||||
assert len(devices) == 0
|
||||
|
||||
# Add device
|
||||
device_manager._devices[mock_device.device_id] = mock_device
|
||||
|
||||
# Get all devices
|
||||
devices = await device_manager.get_all_devices()
|
||||
assert len(devices) == 1
|
||||
assert devices[0].device_id == mock_device.device_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_available_devices(self, device_manager):
|
||||
"""Test getting available (not in use) devices."""
|
||||
# Create devices with different statuses
|
||||
device1 = PM3Device(
|
||||
device_id="pm3_1",
|
||||
device_path="/dev/ttyACM0",
|
||||
status=DeviceStatus.CONNECTED
|
||||
)
|
||||
device2 = PM3Device(
|
||||
device_id="pm3_2",
|
||||
device_path="/dev/ttyACM1",
|
||||
status=DeviceStatus.IN_USE
|
||||
)
|
||||
device3 = PM3Device(
|
||||
device_id="pm3_3",
|
||||
device_path="/dev/ttyACM2",
|
||||
status=DeviceStatus.DISCONNECTED
|
||||
)
|
||||
|
||||
device_manager._devices = {
|
||||
device1.device_id: device1,
|
||||
device2.device_id: device2,
|
||||
device3.device_id: device3,
|
||||
}
|
||||
|
||||
# Get available devices (only CONNECTED)
|
||||
available = await device_manager.get_available_devices()
|
||||
assert len(available) == 1
|
||||
assert available[0].device_id == device1.device_id
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_connected_devices(self, device_manager):
|
||||
"""Test getting connected devices."""
|
||||
# Create devices with different statuses
|
||||
device1 = PM3Device(
|
||||
device_id="pm3_1",
|
||||
device_path="/dev/ttyACM0",
|
||||
status=DeviceStatus.CONNECTED
|
||||
)
|
||||
device2 = PM3Device(
|
||||
device_id="pm3_2",
|
||||
device_path="/dev/ttyACM1",
|
||||
status=DeviceStatus.IN_USE
|
||||
)
|
||||
device3 = PM3Device(
|
||||
device_id="pm3_3",
|
||||
device_path="/dev/ttyACM2",
|
||||
status=DeviceStatus.DISCONNECTED
|
||||
)
|
||||
|
||||
device_manager._devices = {
|
||||
device1.device_id: device1,
|
||||
device2.device_id: device2,
|
||||
device3.device_id: device3,
|
||||
}
|
||||
|
||||
# Get connected devices (CONNECTED + IN_USE, not DISCONNECTED)
|
||||
connected = await device_manager.get_connected_devices()
|
||||
assert len(connected) == 2
|
||||
assert device1 in connected
|
||||
assert device2 in connected
|
||||
assert device3 not in connected
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_device_status(self, device_manager, mock_device):
|
||||
"""Test setting device status."""
|
||||
device_manager._devices[mock_device.device_id] = mock_device
|
||||
|
||||
# Set status
|
||||
result = await device_manager.set_device_status(
|
||||
mock_device.device_id,
|
||||
DeviceStatus.IN_USE
|
||||
)
|
||||
assert result is True
|
||||
assert mock_device.status == DeviceStatus.IN_USE
|
||||
|
||||
# Set status for non-existent device
|
||||
result = await device_manager.set_device_status(
|
||||
"nonexistent",
|
||||
DeviceStatus.IN_USE
|
||||
)
|
||||
assert result is False
|
||||
|
||||
def test_parse_firmware_version(self, device_manager):
|
||||
"""Test parsing firmware version from hw version output."""
|
||||
output = """
|
||||
Proxmark3 RFID instrument
|
||||
bootrom: RRG/Iceman/master/v4.14831
|
||||
os: RRG/Iceman/master/v4.14831
|
||||
client: RRG/Iceman/master/v4.14831
|
||||
"""
|
||||
|
||||
info = device_manager._parse_firmware_version(output)
|
||||
|
||||
assert info.bootrom_version == "v4.14831"
|
||||
assert info.os_version == "v4.14831"
|
||||
assert info.client_version == "v4.14831"
|
||||
assert info.compatible is True
|
||||
assert info.needs_upgrade is False
|
||||
assert info.needs_downgrade is False
|
||||
|
||||
def test_parse_firmware_version_mismatch(self, device_manager):
|
||||
"""Test parsing mismatched firmware versions."""
|
||||
output = """
|
||||
Proxmark3 RFID instrument
|
||||
bootrom: RRG/Iceman/master/v4.14000
|
||||
os: RRG/Iceman/master/v4.14000
|
||||
client: RRG/Iceman/master/v4.14831
|
||||
"""
|
||||
|
||||
info = device_manager._parse_firmware_version(output)
|
||||
|
||||
assert info.bootrom_version == "v4.14000"
|
||||
assert info.os_version == "v4.14000"
|
||||
assert info.client_version == "v4.14831"
|
||||
assert info.compatible is False
|
||||
assert info.needs_upgrade is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_discover_via_dev(self, device_manager):
|
||||
"""Test device discovery via /dev scanning."""
|
||||
with patch('pathlib.Path.glob') as mock_glob:
|
||||
# Mock /dev/ttyACM* files
|
||||
mock_device1 = Mock()
|
||||
mock_device1.is_char_device.return_value = True
|
||||
mock_device1.__str__ = lambda x: "/dev/ttyACM0"
|
||||
|
||||
mock_device2 = Mock()
|
||||
mock_device2.is_char_device.return_value = True
|
||||
mock_device2.__str__ = lambda x: "/dev/ttyACM1"
|
||||
|
||||
mock_glob.return_value = [mock_device1, mock_device2]
|
||||
|
||||
devices = await device_manager._discover_via_dev()
|
||||
|
||||
assert len(devices) == 2
|
||||
assert "/dev/ttyACM0" in devices
|
||||
assert "/dev/ttyACM1" in devices
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_identify_device_not_found(self, device_manager):
|
||||
"""Test identifying a device that doesn't exist."""
|
||||
with pytest.raises(ValueError, match="Device .* not found"):
|
||||
await device_manager.identify_device("nonexistent")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_identify_device_no_worker(self, device_manager):
|
||||
"""Test identifying a device without a worker."""
|
||||
device = PM3Device(
|
||||
device_id="pm3_test",
|
||||
device_path="/dev/ttyACM0",
|
||||
status=DeviceStatus.CONNECTED,
|
||||
worker=None
|
||||
)
|
||||
device_manager._devices[device.device_id] = device
|
||||
|
||||
with pytest.raises(RuntimeError, match="has no worker"):
|
||||
await device_manager.identify_device(device.device_id)
|
||||
|
||||
def test_device_to_dict(self, mock_device):
|
||||
"""Test converting PM3Device to dictionary."""
|
||||
data = mock_device.to_dict()
|
||||
|
||||
assert data["device_id"] == mock_device.device_id
|
||||
assert data["device_path"] == mock_device.device_path
|
||||
assert data["serial_number"] == mock_device.serial_number
|
||||
assert data["friendly_name"] == mock_device.friendly_name
|
||||
assert data["usb_vid"] == mock_device.usb_vid
|
||||
assert data["usb_pid"] == mock_device.usb_pid
|
||||
assert data["status"] == mock_device.status.value
|
||||
assert data["connected"] is True
|
||||
assert "firmware_info" in data
|
||||
|
||||
def test_device_status_enum(self):
|
||||
"""Test DeviceStatus enum values."""
|
||||
assert DeviceStatus.CONNECTED.value == "connected"
|
||||
assert DeviceStatus.DISCONNECTED.value == "disconnected"
|
||||
assert DeviceStatus.IN_USE.value == "in_use"
|
||||
assert DeviceStatus.ERROR.value == "error"
|
||||
assert DeviceStatus.VERSION_MISMATCH.value == "version_mismatch"
|
||||
assert DeviceStatus.FLASHING.value == "flashing"
|
||||
assert DeviceStatus.BOOTLOADER_MODE.value == "bootloader_mode"
|
||||
assert DeviceStatus.DISABLED.value == "disabled"
|
||||
|
||||
|
||||
class TestPM3FirmwareInfo:
|
||||
"""Tests for PM3FirmwareInfo dataclass."""
|
||||
|
||||
def test_default_values(self):
|
||||
"""Test default firmware info values."""
|
||||
info = PM3FirmwareInfo()
|
||||
|
||||
assert info.bootrom_version == "unknown"
|
||||
assert info.os_version == "unknown"
|
||||
assert info.client_version == "unknown"
|
||||
assert info.compatible is False
|
||||
assert info.needs_upgrade is False
|
||||
assert info.needs_downgrade is False
|
||||
assert info.bootloader_outdated is False
|
||||
|
||||
def test_compatibility_check(self):
|
||||
"""Test firmware compatibility logic."""
|
||||
# Compatible versions
|
||||
info1 = PM3FirmwareInfo(
|
||||
bootrom_version="v4.14831",
|
||||
os_version="v4.14831",
|
||||
client_version="v4.14831"
|
||||
)
|
||||
info1.compatible = (
|
||||
info1.os_version == info1.client_version and
|
||||
info1.bootrom_version == info1.client_version
|
||||
)
|
||||
assert info1.compatible is True
|
||||
|
||||
# Incompatible versions
|
||||
info2 = PM3FirmwareInfo(
|
||||
bootrom_version="v4.14000",
|
||||
os_version="v4.14000",
|
||||
client_version="v4.14831"
|
||||
)
|
||||
info2.compatible = (
|
||||
info2.os_version == info2.client_version and
|
||||
info2.bootrom_version == info2.client_version
|
||||
)
|
||||
assert info2.compatible is False
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_device_manager_lifecycle(device_manager):
|
||||
"""Test device manager start/stop lifecycle."""
|
||||
# Start manager
|
||||
await device_manager.start()
|
||||
assert device_manager._monitor_task is not None
|
||||
|
||||
# Stop manager
|
||||
await device_manager.stop()
|
||||
# Monitor task should be cancelled
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
0
tests/unit/services/__init__.py
Normal file
0
tests/unit/services/__init__.py
Normal file
305
tests/unit/services/test_pm3_service.py
Normal file
305
tests/unit/services/test_pm3_service.py
Normal file
@@ -0,0 +1,305 @@
|
||||
"""Unit tests for PM3Service.
|
||||
|
||||
Tests the PM3Service business logic layer, ensuring:
|
||||
- Proper session validation
|
||||
- Command execution flow
|
||||
- Error handling and error codes
|
||||
- Response format consistency
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.backend.services.pm3_service import PM3Service, PM3ServiceResult, PM3ServiceError
|
||||
|
||||
|
||||
@dataclass
|
||||
class MockPM3Result:
|
||||
"""Mock PM3Worker command result."""
|
||||
success: bool
|
||||
output: str = ""
|
||||
error: str = None
|
||||
|
||||
|
||||
class TestPM3ServiceCommandExecution:
|
||||
"""Test command execution with session validation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_command_success(self, mock_pm3_worker, mock_session_manager):
|
||||
"""Test successful command execution."""
|
||||
# Arrange
|
||||
mock_pm3_worker.execute_command.return_value = MockPM3Result(
|
||||
success=True,
|
||||
output="Proxmark3 RFID instrument"
|
||||
)
|
||||
service = PM3Service(mock_pm3_worker, mock_session_manager)
|
||||
|
||||
# Act
|
||||
result = await service.execute_command(
|
||||
command="hw version",
|
||||
session_id="test-session"
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.data is not None
|
||||
assert result.data["output"] == "Proxmark3 RFID instrument"
|
||||
assert result.data["command"] == "hw version"
|
||||
assert result.data["session_id"] == "test-session"
|
||||
assert result.error is None
|
||||
|
||||
# Verify session was updated
|
||||
mock_session_manager.update_activity.assert_called_once_with("test-session")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_command_session_locked(self, mock_pm3_worker, mock_session_manager):
|
||||
"""Test command execution when another session is active."""
|
||||
# Arrange
|
||||
mock_session_manager.can_execute.return_value = False
|
||||
service = PM3Service(mock_pm3_worker, mock_session_manager)
|
||||
|
||||
# Act
|
||||
result = await service.execute_command(
|
||||
command="hw version",
|
||||
session_id="test-session"
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert result.error.code == "session_locked"
|
||||
assert "Another session is active" in result.error.message
|
||||
assert result.data is None
|
||||
|
||||
# Verify command was not executed
|
||||
mock_pm3_worker.execute_command.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_command_pm3_not_connected(self, mock_pm3_worker, mock_session_manager):
|
||||
"""Test command execution when PM3 is not connected."""
|
||||
# Arrange
|
||||
mock_pm3_worker.is_connected.return_value = False
|
||||
service = PM3Service(mock_pm3_worker, mock_session_manager)
|
||||
|
||||
# Act
|
||||
result = await service.execute_command(
|
||||
command="hw version",
|
||||
session_id="test-session"
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert result.error.code == "pm3_not_connected"
|
||||
assert "not connected" in result.error.message.lower()
|
||||
assert result.data is None
|
||||
|
||||
# Verify command was not executed
|
||||
mock_pm3_worker.execute_command.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_command_failure(self, mock_pm3_worker, mock_session_manager):
|
||||
"""Test command execution when PM3 command fails."""
|
||||
# Arrange
|
||||
mock_pm3_worker.execute_command.return_value = MockPM3Result(
|
||||
success=False,
|
||||
error="Invalid command"
|
||||
)
|
||||
service = PM3Service(mock_pm3_worker, mock_session_manager)
|
||||
|
||||
# Act
|
||||
result = await service.execute_command(
|
||||
command="invalid",
|
||||
session_id="test-session"
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert result.error.code == "command_failed"
|
||||
assert "Invalid command" in result.error.details
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_execute_command_exception_handling(self, mock_pm3_worker, mock_session_manager):
|
||||
"""Test command execution when an exception occurs."""
|
||||
# Arrange
|
||||
mock_pm3_worker.execute_command.side_effect = Exception("Connection lost")
|
||||
service = PM3Service(mock_pm3_worker, mock_session_manager)
|
||||
|
||||
# Act
|
||||
result = await service.execute_command(
|
||||
command="hw version",
|
||||
session_id="test-session"
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.success is False
|
||||
assert result.error is not None
|
||||
assert result.error.code == "execution_error"
|
||||
assert "Connection lost" in result.error.details
|
||||
|
||||
|
||||
class TestPM3ServiceStatus:
|
||||
"""Test status query operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_status_connected(self, mock_pm3_worker, mock_session_manager):
|
||||
"""Test status query when PM3 is connected."""
|
||||
# Arrange
|
||||
mock_pm3_worker.execute_command.return_value = MockPM3Result(
|
||||
success=True,
|
||||
output="Proxmark3 RFID instrument\nFirmware version: v4.0"
|
||||
)
|
||||
mock_session_manager.has_active_session.return_value = True
|
||||
service = PM3Service(mock_pm3_worker, mock_session_manager)
|
||||
|
||||
# Act
|
||||
result = await service.get_status()
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.data["connected"] is True
|
||||
assert result.data["device_path"] == "/dev/ttyACM0"
|
||||
assert "Proxmark3" in result.data["version"]
|
||||
assert result.data["session_active"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_status_not_connected(self, mock_pm3_worker, mock_session_manager):
|
||||
"""Test status query when PM3 is not connected."""
|
||||
# Arrange
|
||||
mock_pm3_worker.is_connected.return_value = False
|
||||
service = PM3Service(mock_pm3_worker, mock_session_manager)
|
||||
|
||||
# Act
|
||||
result = await service.get_status()
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.data["connected"] is False
|
||||
assert result.data["version"] is None
|
||||
|
||||
|
||||
class TestPM3ServiceConnection:
|
||||
"""Test connection management."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_success(self, mock_pm3_worker, mock_session_manager):
|
||||
"""Test successful PM3 connection."""
|
||||
# Arrange
|
||||
service = PM3Service(mock_pm3_worker, mock_session_manager)
|
||||
|
||||
# Act
|
||||
result = await service.connect()
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert "Connected" in result.data["message"]
|
||||
mock_pm3_worker.connect.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_failure(self, mock_pm3_worker, mock_session_manager):
|
||||
"""Test PM3 connection failure."""
|
||||
# Arrange
|
||||
mock_pm3_worker.connect.side_effect = Exception("Device not found")
|
||||
service = PM3Service(mock_pm3_worker, mock_session_manager)
|
||||
|
||||
# Act
|
||||
result = await service.connect()
|
||||
|
||||
# Assert
|
||||
assert result.success is False
|
||||
assert result.error.code == "connection_error"
|
||||
assert "Device not found" in result.error.details
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_success(self, mock_pm3_worker, mock_session_manager):
|
||||
"""Test successful PM3 disconnection."""
|
||||
# Arrange
|
||||
service = PM3Service(mock_pm3_worker, mock_session_manager)
|
||||
|
||||
# Act
|
||||
result = await service.disconnect()
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert "Disconnected" in result.data["message"]
|
||||
mock_pm3_worker.disconnect.assert_called_once()
|
||||
|
||||
|
||||
class TestPM3ServiceSessionManagement:
|
||||
"""Test session management operations."""
|
||||
|
||||
def test_create_session_success(self, mock_pm3_worker, mock_session_manager):
|
||||
"""Test successful session creation."""
|
||||
# Arrange
|
||||
service = PM3Service(mock_pm3_worker, mock_session_manager)
|
||||
|
||||
# Act
|
||||
result = service.create_session(force_takeover=False)
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.data["session_id"] == "test-session-id"
|
||||
mock_session_manager.create_session.assert_called_once_with(force_takeover=False)
|
||||
|
||||
def test_create_session_exists(self, mock_pm3_worker, mock_session_manager):
|
||||
"""Test session creation when session already exists."""
|
||||
# Arrange
|
||||
mock_session_manager.create_session.return_value = None
|
||||
service = PM3Service(mock_pm3_worker, mock_session_manager)
|
||||
|
||||
# Act
|
||||
result = service.create_session(force_takeover=False)
|
||||
|
||||
# Assert
|
||||
assert result.success is False
|
||||
assert result.error.code == "session_exists"
|
||||
assert "force_takeover" in result.error.details.lower()
|
||||
|
||||
def test_release_session(self, mock_pm3_worker, mock_session_manager):
|
||||
"""Test session release."""
|
||||
# Arrange
|
||||
service = PM3Service(mock_pm3_worker, mock_session_manager)
|
||||
|
||||
# Act
|
||||
result = service.release_session("test-session")
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert "released" in result.data["message"].lower()
|
||||
mock_session_manager.release_session.assert_called_once_with("test-session")
|
||||
|
||||
def test_get_session_info_found(self, mock_pm3_worker, mock_session_manager):
|
||||
"""Test getting session info for existing session."""
|
||||
# Arrange
|
||||
from datetime import datetime
|
||||
mock_session = Mock()
|
||||
mock_session.session_id = "test-session"
|
||||
mock_session.created_at = datetime(2025, 1, 1, 12, 0, 0)
|
||||
mock_session.last_activity = datetime(2025, 1, 1, 12, 5, 0)
|
||||
|
||||
mock_session_manager.get_session.return_value = mock_session
|
||||
mock_session_manager.is_session_active.return_value = True
|
||||
|
||||
service = PM3Service(mock_pm3_worker, mock_session_manager)
|
||||
|
||||
# Act
|
||||
result = service.get_session_info("test-session")
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.data["session_id"] == "test-session"
|
||||
assert result.data["is_active"] is True
|
||||
|
||||
def test_get_session_info_not_found(self, mock_pm3_worker, mock_session_manager):
|
||||
"""Test getting session info for non-existent session."""
|
||||
# Arrange
|
||||
mock_session_manager.get_session.return_value = None
|
||||
service = PM3Service(mock_pm3_worker, mock_session_manager)
|
||||
|
||||
# Act
|
||||
result = service.get_session_info("nonexistent")
|
||||
|
||||
# Assert
|
||||
assert result.success is False
|
||||
assert result.error.code == "session_not_found"
|
||||
300
tests/unit/services/test_system_service.py
Normal file
300
tests/unit/services/test_system_service.py
Normal file
@@ -0,0 +1,300 @@
|
||||
"""Unit tests for SystemService.
|
||||
|
||||
Tests the SystemService business logic layer, ensuring:
|
||||
- System information queries
|
||||
- Shutdown/restart operations
|
||||
- Error handling and error codes
|
||||
- Command execution and output parsing
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, Mock, patch
|
||||
|
||||
from app.backend.services.system_service import SystemService
|
||||
|
||||
|
||||
class TestSystemServiceInfo:
|
||||
"""Test system information queries."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_info_success(self):
|
||||
"""Test successful system info retrieval."""
|
||||
# Arrange
|
||||
service = SystemService()
|
||||
|
||||
# Act
|
||||
with patch('psutil.cpu_percent', return_value=25.5), \
|
||||
patch('psutil.virtual_memory') as mock_memory, \
|
||||
patch('psutil.disk_usage') as mock_disk, \
|
||||
patch('psutil.cpu_count', return_value=4), \
|
||||
patch('psutil.boot_time', return_value=1000000000.0), \
|
||||
patch('psutil.time.time', return_value=1000086400.0), \
|
||||
patch('platform.node', return_value='dangerous-pi'), \
|
||||
patch('platform.system', return_value='Linux'), \
|
||||
patch('platform.release', return_value='6.1.21'):
|
||||
|
||||
# Mock memory stats
|
||||
mock_memory.return_value = Mock(
|
||||
total=4294967296, # 4GB
|
||||
used=2147483648, # 2GB
|
||||
percent=50.0
|
||||
)
|
||||
|
||||
# Mock disk stats
|
||||
mock_disk.return_value = Mock(
|
||||
total=32212254720, # 30GB
|
||||
used=10737418240, # 10GB
|
||||
percent=33.3
|
||||
)
|
||||
|
||||
result = await service.get_info()
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.data["hostname"] == "dangerous-pi"
|
||||
assert result.data["platform"] == "Linux"
|
||||
assert result.data["cpu"]["count"] == 4
|
||||
assert result.data["cpu"]["percent"] == 25.5
|
||||
assert result.data["memory"]["total"] == 4294967296
|
||||
assert result.data["memory"]["used"] == 2147483648
|
||||
assert result.data["memory"]["percent"] == 50.0
|
||||
assert result.data["disk"]["total"] == 32212254720
|
||||
assert result.data["uptime"] == 86400.0 # 1 day
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_info_with_temperature(self):
|
||||
"""Test system info with CPU temperature."""
|
||||
# Arrange
|
||||
service = SystemService()
|
||||
|
||||
# Act
|
||||
with patch('psutil.cpu_percent', return_value=50.0), \
|
||||
patch('psutil.virtual_memory'), \
|
||||
patch('psutil.disk_usage'), \
|
||||
patch('psutil.cpu_count', return_value=4), \
|
||||
patch('psutil.boot_time', return_value=1000000000.0), \
|
||||
patch('psutil.time.time', return_value=1000000100.0), \
|
||||
patch('platform.node', return_value='test'), \
|
||||
patch('platform.system', return_value='Linux'), \
|
||||
patch('platform.release', return_value='6.1'), \
|
||||
patch('pathlib.Path.exists', return_value=True), \
|
||||
patch('pathlib.Path.read_text', return_value='45678'):
|
||||
|
||||
# Mock memory and disk
|
||||
patch('psutil.virtual_memory', return_value=Mock(total=1, used=1, percent=1))
|
||||
patch('psutil.disk_usage', return_value=Mock(total=1, used=1, percent=1))
|
||||
|
||||
result = await service.get_info()
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.data["cpu"]["temperature"] == 45.678
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_info_exception_handling(self):
|
||||
"""Test system info retrieval with exception."""
|
||||
# Arrange
|
||||
service = SystemService()
|
||||
|
||||
# Act
|
||||
with patch('psutil.cpu_percent', side_effect=Exception("psutil error")):
|
||||
result = await service.get_info()
|
||||
|
||||
# Assert
|
||||
assert result.success is False
|
||||
assert result.error.code == "system_info_error"
|
||||
assert "psutil error" in result.error.details
|
||||
|
||||
|
||||
class TestSystemServiceShutdown:
|
||||
"""Test shutdown operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_immediate(self):
|
||||
"""Test immediate shutdown."""
|
||||
# Arrange
|
||||
service = SystemService()
|
||||
|
||||
# Act
|
||||
with patch.object(service, '_run_command', new_callable=AsyncMock) as mock_cmd:
|
||||
mock_cmd.return_value = ""
|
||||
result = await service.shutdown(delay=0)
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert "initiated" in result.data["message"].lower()
|
||||
mock_cmd.assert_called_once_with("sudo shutdown -h now")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_with_delay(self):
|
||||
"""Test scheduled shutdown."""
|
||||
# Arrange
|
||||
service = SystemService()
|
||||
|
||||
# Act
|
||||
with patch.object(service, '_run_command', new_callable=AsyncMock) as mock_cmd:
|
||||
mock_cmd.return_value = ""
|
||||
result = await service.shutdown(delay=300) # 5 minutes
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert "scheduled" in result.data["message"].lower()
|
||||
assert result.data["delay"] == 300
|
||||
# 300 seconds = 5 minutes
|
||||
mock_cmd.assert_called_once_with("sudo shutdown -h +5")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_shutdown_failure(self):
|
||||
"""Test shutdown command failure."""
|
||||
# Arrange
|
||||
service = SystemService()
|
||||
|
||||
# Act
|
||||
with patch.object(service, '_run_command', new_callable=AsyncMock) as mock_cmd:
|
||||
mock_cmd.side_effect = RuntimeError("Permission denied")
|
||||
result = await service.shutdown(delay=0)
|
||||
|
||||
# Assert
|
||||
assert result.success is False
|
||||
assert result.error.code == "shutdown_error"
|
||||
assert "Permission denied" in result.error.details
|
||||
|
||||
|
||||
class TestSystemServiceRestart:
|
||||
"""Test restart operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restart_immediate(self):
|
||||
"""Test immediate restart."""
|
||||
# Arrange
|
||||
service = SystemService()
|
||||
|
||||
# Act
|
||||
with patch.object(service, '_run_command', new_callable=AsyncMock) as mock_cmd:
|
||||
mock_cmd.return_value = ""
|
||||
result = await service.restart(delay=0)
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert "initiated" in result.data["message"].lower()
|
||||
mock_cmd.assert_called_once_with("sudo shutdown -r now")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_restart_with_delay(self):
|
||||
"""Test scheduled restart."""
|
||||
# Arrange
|
||||
service = SystemService()
|
||||
|
||||
# Act
|
||||
with patch.object(service, '_run_command', new_callable=AsyncMock) as mock_cmd:
|
||||
mock_cmd.return_value = ""
|
||||
result = await service.restart(delay=600) # 10 minutes
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert "scheduled" in result.data["message"].lower()
|
||||
assert result.data["delay"] == 600
|
||||
mock_cmd.assert_called_once_with("sudo shutdown -r +10")
|
||||
|
||||
|
||||
class TestSystemServiceCancelShutdown:
|
||||
"""Test shutdown cancellation."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_cancel_shutdown_success(self):
|
||||
"""Test successful shutdown cancellation."""
|
||||
# Arrange
|
||||
service = SystemService()
|
||||
|
||||
# Act
|
||||
with patch.object(service, '_run_command', new_callable=AsyncMock) as mock_cmd:
|
||||
mock_cmd.return_value = ""
|
||||
result = await service.cancel_shutdown()
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert "cancelled" in result.data["message"].lower()
|
||||
mock_cmd.assert_called_once_with("sudo shutdown -c")
|
||||
|
||||
|
||||
class TestSystemServiceLogs:
|
||||
"""Test log retrieval."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_logs_success(self):
|
||||
"""Test successful log retrieval."""
|
||||
# Arrange
|
||||
service = SystemService()
|
||||
log_content = "Jan 01 12:00:00 test systemd[1]: Started dangerous-pi"
|
||||
|
||||
# Act
|
||||
with patch.object(service, '_run_command', new_callable=AsyncMock) as mock_cmd:
|
||||
mock_cmd.return_value = log_content
|
||||
result = await service.get_logs(service="dangerous-pi", lines=100)
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.data["service"] == "dangerous-pi"
|
||||
assert result.data["lines"] == 100
|
||||
assert result.data["logs"] == log_content
|
||||
mock_cmd.assert_called_once_with(
|
||||
"sudo journalctl -u dangerous-pi -n 100 --no-pager"
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_logs_failure(self):
|
||||
"""Test log retrieval failure."""
|
||||
# Arrange
|
||||
service = SystemService()
|
||||
|
||||
# Act
|
||||
with patch.object(service, '_run_command', new_callable=AsyncMock) as mock_cmd:
|
||||
mock_cmd.side_effect = RuntimeError("Service not found")
|
||||
result = await service.get_logs(service="nonexistent")
|
||||
|
||||
# Assert
|
||||
assert result.success is False
|
||||
assert result.error.code == "logs_error"
|
||||
|
||||
|
||||
class TestSystemServiceStatus:
|
||||
"""Test service status queries."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_service_status_active(self):
|
||||
"""Test getting status of active service."""
|
||||
# Arrange
|
||||
service = SystemService()
|
||||
status_output = "● dangerous-pi.service - Dangerous Pi Backend\n Loaded: loaded\n Active: active (running)"
|
||||
|
||||
# Act
|
||||
with patch.object(service, '_run_command', new_callable=AsyncMock) as mock_cmd, \
|
||||
patch.object(service, '_is_service_enabled', new_callable=AsyncMock) as mock_enabled:
|
||||
mock_cmd.return_value = status_output
|
||||
mock_enabled.return_value = True
|
||||
result = await service.get_service_status("dangerous-pi")
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.data["service"] == "dangerous-pi"
|
||||
assert result.data["active"] is True
|
||||
assert result.data["enabled"] is True
|
||||
assert "active (running)" in result.data["status"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_service_status_inactive(self):
|
||||
"""Test getting status of inactive service."""
|
||||
# Arrange
|
||||
service = SystemService()
|
||||
status_output = "● test.service\n Loaded: loaded\n Active: inactive (dead)"
|
||||
|
||||
# Act
|
||||
with patch.object(service, '_run_command', new_callable=AsyncMock) as mock_cmd, \
|
||||
patch.object(service, '_is_service_enabled', new_callable=AsyncMock) as mock_enabled:
|
||||
mock_cmd.return_value = status_output
|
||||
mock_enabled.return_value = False
|
||||
result = await service.get_service_status("test")
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.data["active"] is False
|
||||
assert result.data["enabled"] is False
|
||||
369
tests/unit/services/test_update_service.py
Normal file
369
tests/unit/services/test_update_service.py
Normal file
@@ -0,0 +1,369 @@
|
||||
"""Unit tests for UpdateService.
|
||||
|
||||
Tests the UpdateService business logic layer, ensuring:
|
||||
- Update checking
|
||||
- Update downloading
|
||||
- Update installation
|
||||
- Progress monitoring
|
||||
- Release notes retrieval
|
||||
- Error handling and error codes
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from app.backend.services.update_service import UpdateService
|
||||
from app.backend.managers.update_manager import UpdateProgress, UpdateStatus
|
||||
|
||||
|
||||
class TestUpdateServiceCheckForUpdates:
|
||||
"""Test update checking operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_for_updates_available(self, mock_update_manager):
|
||||
"""Test checking for updates when update is available."""
|
||||
# Arrange
|
||||
mock_update_manager.check_for_updates.return_value = {
|
||||
"update_available": True,
|
||||
"current_version": "1.0.0",
|
||||
"latest_version": "1.1.0",
|
||||
"release_date": "2025-01-15",
|
||||
"changelog": "## New Features\n- Feature 1\n- Feature 2",
|
||||
"is_prerelease": False,
|
||||
"download_size": 10485760 # 10MB
|
||||
}
|
||||
service = UpdateService(mock_update_manager)
|
||||
|
||||
# Act
|
||||
result = await service.check_for_updates()
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.data["update_available"] is True
|
||||
assert result.data["latest_version"] == "1.1.0"
|
||||
assert result.data["download_size"] == 10485760
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_for_updates_none_available(self, mock_update_manager):
|
||||
"""Test checking for updates when system is up to date."""
|
||||
# Arrange
|
||||
mock_update_manager.check_for_updates.return_value = {
|
||||
"update_available": False,
|
||||
"current_version": "1.1.0",
|
||||
"latest_version": "1.1.0",
|
||||
"message": "System is up to date"
|
||||
}
|
||||
service = UpdateService(mock_update_manager)
|
||||
|
||||
# Act
|
||||
result = await service.check_for_updates()
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.data["update_available"] is False
|
||||
assert result.data["message"] == "System is up to date"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_for_updates_exception(self, mock_update_manager):
|
||||
"""Test update check with exception."""
|
||||
# Arrange
|
||||
mock_update_manager.check_for_updates.side_effect = Exception("Network error")
|
||||
service = UpdateService(mock_update_manager)
|
||||
|
||||
# Act
|
||||
result = await service.check_for_updates()
|
||||
|
||||
# Assert
|
||||
assert result.success is False
|
||||
assert result.error.code == "update_check_error"
|
||||
assert "Network error" in result.error.details
|
||||
|
||||
|
||||
class TestUpdateServiceDownload:
|
||||
"""Test update download operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_update_success(self, mock_update_manager):
|
||||
"""Test successful update download."""
|
||||
# Arrange
|
||||
mock_update_manager.download_update.return_value = True
|
||||
service = UpdateService(mock_update_manager)
|
||||
|
||||
# Act
|
||||
result = await service.download_update()
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert "successfully" in result.data["message"].lower()
|
||||
assert result.data["ready_to_install"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_update_no_update_available(self, mock_update_manager):
|
||||
"""Test download when no update is available."""
|
||||
# Arrange
|
||||
mock_update_manager.download_update.side_effect = ValueError("No update available to download")
|
||||
service = UpdateService(mock_update_manager)
|
||||
|
||||
# Act
|
||||
result = await service.download_update()
|
||||
|
||||
# Assert
|
||||
assert result.success is False
|
||||
assert result.error.code == "no_update_available"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_download_update_failure(self, mock_update_manager):
|
||||
"""Test download failure."""
|
||||
# Arrange
|
||||
mock_update_manager.download_update.side_effect = Exception("Download failed")
|
||||
service = UpdateService(mock_update_manager)
|
||||
|
||||
# Act
|
||||
result = await service.download_update()
|
||||
|
||||
# Assert
|
||||
assert result.success is False
|
||||
assert result.error.code == "update_download_error"
|
||||
|
||||
|
||||
class TestUpdateServiceInstall:
|
||||
"""Test update installation operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_update_success(self, mock_update_manager):
|
||||
"""Test successful update installation."""
|
||||
# Arrange
|
||||
mock_update_manager.install_update.return_value = True
|
||||
service = UpdateService(mock_update_manager)
|
||||
|
||||
# Act
|
||||
result = await service.install_update()
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert "successfully" in result.data["message"].lower()
|
||||
assert result.data["requires_restart"] is True
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_update_no_download(self, mock_update_manager):
|
||||
"""Test install when no update has been downloaded."""
|
||||
# Arrange
|
||||
mock_update_manager.install_update.side_effect = ValueError("No update downloaded")
|
||||
service = UpdateService(mock_update_manager)
|
||||
|
||||
# Act
|
||||
result = await service.install_update()
|
||||
|
||||
# Assert
|
||||
assert result.success is False
|
||||
assert result.error.code == "no_update_downloaded"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_install_update_failure(self, mock_update_manager):
|
||||
"""Test installation failure."""
|
||||
# Arrange
|
||||
mock_update_manager.install_update.side_effect = Exception("Installation failed")
|
||||
service = UpdateService(mock_update_manager)
|
||||
|
||||
# Act
|
||||
result = await service.install_update()
|
||||
|
||||
# Assert
|
||||
assert result.success is False
|
||||
assert result.error.code == "update_install_error"
|
||||
|
||||
|
||||
class TestUpdateServiceProgress:
|
||||
"""Test progress monitoring."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_progress_idle(self, mock_update_manager):
|
||||
"""Test getting progress when idle."""
|
||||
# Arrange
|
||||
mock_update_manager.get_progress.return_value = UpdateProgress(
|
||||
status=UpdateStatus.IDLE,
|
||||
current_version="1.0.0",
|
||||
available_version=None,
|
||||
download_progress=0.0,
|
||||
error_message=None,
|
||||
last_check="2025-01-15T10:00:00"
|
||||
)
|
||||
service = UpdateService(mock_update_manager)
|
||||
|
||||
# Act
|
||||
result = await service.get_progress()
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.data["status"] == "idle"
|
||||
assert result.data["current_version"] == "1.0.0"
|
||||
assert result.data["download_progress"] == 0.0
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_progress_downloading(self, mock_update_manager):
|
||||
"""Test getting progress during download."""
|
||||
# Arrange
|
||||
mock_update_manager.get_progress.return_value = UpdateProgress(
|
||||
status=UpdateStatus.DOWNLOADING,
|
||||
current_version="1.0.0",
|
||||
available_version="1.1.0",
|
||||
download_progress=45.5,
|
||||
error_message=None,
|
||||
last_check="2025-01-15T10:00:00"
|
||||
)
|
||||
service = UpdateService(mock_update_manager)
|
||||
|
||||
# Act
|
||||
result = await service.get_progress()
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.data["status"] == "downloading"
|
||||
assert result.data["available_version"] == "1.1.0"
|
||||
assert result.data["download_progress"] == 45.5
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_progress_failed(self, mock_update_manager):
|
||||
"""Test getting progress after failure."""
|
||||
# Arrange
|
||||
mock_update_manager.get_progress.return_value = UpdateProgress(
|
||||
status=UpdateStatus.FAILED,
|
||||
current_version="1.0.0",
|
||||
available_version="1.1.0",
|
||||
download_progress=0.0,
|
||||
error_message="Network timeout",
|
||||
last_check="2025-01-15T10:00:00"
|
||||
)
|
||||
service = UpdateService(mock_update_manager)
|
||||
|
||||
# Act
|
||||
result = await service.get_progress()
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.data["status"] == "failed"
|
||||
assert result.data["error_message"] == "Network timeout"
|
||||
|
||||
|
||||
class TestUpdateServiceReleaseNotes:
|
||||
"""Test release notes retrieval."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_release_notes_latest(self, mock_update_manager):
|
||||
"""Test getting latest release notes."""
|
||||
# Arrange
|
||||
mock_update_manager.get_release_notes.return_value = "## Version 1.1.0\n- New feature"
|
||||
service = UpdateService(mock_update_manager)
|
||||
|
||||
# Act
|
||||
result = await service.get_release_notes(version=None)
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.data["version"] == "latest"
|
||||
assert "New feature" in result.data["release_notes"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_release_notes_specific_version(self, mock_update_manager):
|
||||
"""Test getting notes for specific version."""
|
||||
# Arrange
|
||||
mock_update_manager.get_release_notes.return_value = "## Version 1.0.0\n- Initial release"
|
||||
service = UpdateService(mock_update_manager)
|
||||
|
||||
# Act
|
||||
result = await service.get_release_notes(version="1.0.0")
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.data["version"] == "1.0.0"
|
||||
assert "Initial release" in result.data["release_notes"]
|
||||
mock_update_manager.get_release_notes.assert_called_once_with("1.0.0")
|
||||
|
||||
|
||||
class TestUpdateServiceCombinedOperations:
|
||||
"""Test combined update operations."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_and_download_update_available(self, mock_update_manager):
|
||||
"""Test combined check and download when update is available."""
|
||||
# Arrange
|
||||
mock_update_manager.check_for_updates.return_value = {
|
||||
"update_available": True,
|
||||
"current_version": "1.0.0",
|
||||
"latest_version": "1.1.0"
|
||||
}
|
||||
mock_update_manager.download_update.return_value = True
|
||||
service = UpdateService(mock_update_manager)
|
||||
|
||||
# Act
|
||||
result = await service.check_and_download_update()
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.data["ready_to_install"] is True
|
||||
assert "update_info" in result.data
|
||||
mock_update_manager.download_update.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_check_and_download_update_not_available(self, mock_update_manager):
|
||||
"""Test combined check and download when no update available."""
|
||||
# Arrange
|
||||
mock_update_manager.check_for_updates.return_value = {
|
||||
"update_available": False,
|
||||
"current_version": "1.1.0"
|
||||
}
|
||||
service = UpdateService(mock_update_manager)
|
||||
|
||||
# Act
|
||||
result = await service.check_and_download_update()
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.data["update_available"] is False
|
||||
mock_update_manager.download_update.assert_not_called()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_update_success(self, mock_update_manager):
|
||||
"""Test full update workflow."""
|
||||
# Arrange
|
||||
mock_update_manager.check_for_updates.return_value = {
|
||||
"update_available": True,
|
||||
"current_version": "1.0.0",
|
||||
"latest_version": "1.1.0"
|
||||
}
|
||||
mock_update_manager.download_update.return_value = True
|
||||
mock_update_manager.install_update.return_value = True
|
||||
service = UpdateService(mock_update_manager)
|
||||
|
||||
# Act
|
||||
result = await service.full_update()
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.data["update_performed"] is True
|
||||
assert result.data["previous_version"] == "1.0.0"
|
||||
assert result.data["new_version"] == "1.1.0"
|
||||
assert result.data["requires_restart"] is True
|
||||
|
||||
# Verify all steps were called
|
||||
mock_update_manager.check_for_updates.assert_called_once()
|
||||
mock_update_manager.download_update.assert_called_once()
|
||||
mock_update_manager.install_update.assert_called_once()
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_full_update_already_up_to_date(self, mock_update_manager):
|
||||
"""Test full update when already up to date."""
|
||||
# Arrange
|
||||
mock_update_manager.check_for_updates.return_value = {
|
||||
"update_available": False,
|
||||
"current_version": "1.1.0"
|
||||
}
|
||||
service = UpdateService(mock_update_manager)
|
||||
|
||||
# Act
|
||||
result = await service.full_update()
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.data["update_performed"] is False
|
||||
mock_update_manager.download_update.assert_not_called()
|
||||
mock_update_manager.install_update.assert_not_called()
|
||||
391
tests/unit/services/test_wifi_service.py
Normal file
391
tests/unit/services/test_wifi_service.py
Normal file
@@ -0,0 +1,391 @@
|
||||
"""Unit tests for WiFiService.
|
||||
|
||||
Tests the WiFiService business logic layer, ensuring:
|
||||
- WiFi status queries
|
||||
- Network scanning and connection
|
||||
- Mode switching
|
||||
- Saved network management
|
||||
- Error handling and error codes
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import AsyncMock, Mock
|
||||
|
||||
from app.backend.services.wifi_service import WiFiService
|
||||
from app.backend.managers.wifi_manager import WiFiMode, WiFiStatus, WiFiNetwork, WiFiInterface
|
||||
|
||||
|
||||
class TestWiFiServiceStatus:
|
||||
"""Test WiFi status queries."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_status_success(self, mock_wifi_manager):
|
||||
"""Test successful WiFi status retrieval."""
|
||||
# Arrange
|
||||
test_interface = WiFiInterface(
|
||||
name="wlan0",
|
||||
mac="00:11:22:33:44:55",
|
||||
is_usb=False,
|
||||
is_up=True,
|
||||
connected=False,
|
||||
ssid=None,
|
||||
ip_address="10.3.141.1"
|
||||
)
|
||||
mock_wifi_manager.get_status.return_value = WiFiStatus(
|
||||
mode=WiFiMode.AP,
|
||||
interfaces=[test_interface],
|
||||
current_ssid=None,
|
||||
current_ip=None,
|
||||
ap_ssid="dangerous-pi",
|
||||
ap_ip="10.3.141.1",
|
||||
supports_dual=False
|
||||
)
|
||||
|
||||
service = WiFiService(mock_wifi_manager)
|
||||
|
||||
# Act
|
||||
result = await service.get_status()
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.data["mode"] == "ap"
|
||||
assert len(result.data["interfaces"]) == 1
|
||||
assert result.data["interfaces"][0]["name"] == "wlan0"
|
||||
assert result.data["access_point"]["ssid"] == "dangerous-pi"
|
||||
assert result.data["supports_dual"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_status_with_client_connection(self, mock_wifi_manager):
|
||||
"""Test WiFi status with client connection."""
|
||||
# Arrange
|
||||
test_interface = WiFiInterface(
|
||||
name="wlan0",
|
||||
mac="00:11:22:33:44:55",
|
||||
is_usb=False,
|
||||
is_up=True,
|
||||
connected=True,
|
||||
ssid="MyNetwork",
|
||||
ip_address="192.168.1.100"
|
||||
)
|
||||
mock_wifi_manager.get_status.return_value = WiFiStatus(
|
||||
mode=WiFiMode.CLIENT,
|
||||
interfaces=[test_interface],
|
||||
current_ssid="MyNetwork",
|
||||
current_ip="192.168.1.100",
|
||||
ap_ssid="dangerous-pi",
|
||||
ap_ip="10.3.141.1",
|
||||
supports_dual=False
|
||||
)
|
||||
|
||||
service = WiFiService(mock_wifi_manager)
|
||||
|
||||
# Act
|
||||
result = await service.get_status()
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.data["mode"] == "client"
|
||||
assert result.data["client"]["ssid"] == "MyNetwork"
|
||||
assert result.data["client"]["ip"] == "192.168.1.100"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_status_exception(self, mock_wifi_manager):
|
||||
"""Test WiFi status query with exception."""
|
||||
# Arrange
|
||||
mock_wifi_manager.get_status.side_effect = Exception("WiFi error")
|
||||
service = WiFiService(mock_wifi_manager)
|
||||
|
||||
# Act
|
||||
result = await service.get_status()
|
||||
|
||||
# Assert
|
||||
assert result.success is False
|
||||
assert result.error.code == "wifi_status_error"
|
||||
assert "WiFi error" in result.error.details
|
||||
|
||||
|
||||
class TestWiFiServiceNetworkScanning:
|
||||
"""Test network scanning."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_networks_success(self, mock_wifi_manager):
|
||||
"""Test successful network scan."""
|
||||
# Arrange
|
||||
test_networks = [
|
||||
WiFiNetwork(
|
||||
ssid="TestNetwork",
|
||||
bssid="AA:BB:CC:DD:EE:FF",
|
||||
signal_strength=-45,
|
||||
frequency=2437,
|
||||
encrypted=True,
|
||||
in_use=False
|
||||
),
|
||||
WiFiNetwork(
|
||||
ssid="OpenNetwork",
|
||||
bssid="11:22:33:44:55:66",
|
||||
signal_strength=-65,
|
||||
frequency=2462,
|
||||
encrypted=False,
|
||||
in_use=False
|
||||
)
|
||||
]
|
||||
mock_wifi_manager.scan_networks.return_value = test_networks
|
||||
service = WiFiService(mock_wifi_manager)
|
||||
|
||||
# Act
|
||||
result = await service.scan_networks()
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.data["count"] == 2
|
||||
assert len(result.data["networks"]) == 2
|
||||
assert result.data["networks"][0]["ssid"] == "TestNetwork"
|
||||
assert result.data["networks"][0]["encrypted"] is True
|
||||
assert result.data["networks"][1]["ssid"] == "OpenNetwork"
|
||||
assert result.data["networks"][1]["encrypted"] is False
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_networks_with_interface(self, mock_wifi_manager):
|
||||
"""Test network scan with specific interface."""
|
||||
# Arrange
|
||||
mock_wifi_manager.scan_networks.return_value = []
|
||||
service = WiFiService(mock_wifi_manager)
|
||||
|
||||
# Act
|
||||
result = await service.scan_networks(interface="wlan1")
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
mock_wifi_manager.scan_networks.assert_called_once_with("wlan1")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_scan_networks_exception(self, mock_wifi_manager):
|
||||
"""Test network scan with exception."""
|
||||
# Arrange
|
||||
mock_wifi_manager.scan_networks.side_effect = Exception("Scan failed")
|
||||
service = WiFiService(mock_wifi_manager)
|
||||
|
||||
# Act
|
||||
result = await service.scan_networks()
|
||||
|
||||
# Assert
|
||||
assert result.success is False
|
||||
assert result.error.code == "wifi_scan_error"
|
||||
|
||||
|
||||
class TestWiFiServiceConnection:
|
||||
"""Test network connection management."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_success(self, mock_wifi_manager):
|
||||
"""Test successful network connection."""
|
||||
# Arrange
|
||||
mock_wifi_manager.connect_to_network.return_value = True
|
||||
mock_wifi_manager.get_status.return_value = WiFiStatus(
|
||||
mode=WiFiMode.CLIENT,
|
||||
interfaces=[],
|
||||
current_ssid="TestNetwork",
|
||||
current_ip="192.168.1.100",
|
||||
ap_ssid=None,
|
||||
ap_ip=None,
|
||||
supports_dual=False
|
||||
)
|
||||
service = WiFiService(mock_wifi_manager)
|
||||
|
||||
# Act
|
||||
result = await service.connect(
|
||||
ssid="TestNetwork",
|
||||
password="testpass123"
|
||||
)
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert "TestNetwork" in result.data["message"]
|
||||
assert result.data["ssid"] == "TestNetwork"
|
||||
assert result.data["ip"] == "192.168.1.100"
|
||||
mock_wifi_manager.connect_to_network.assert_called_once_with(
|
||||
ssid="TestNetwork",
|
||||
password="testpass123",
|
||||
interface=None,
|
||||
hidden=False
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_open_network(self, mock_wifi_manager):
|
||||
"""Test connection to open network."""
|
||||
# Arrange
|
||||
mock_wifi_manager.connect_to_network.return_value = True
|
||||
mock_wifi_manager.get_status.return_value = WiFiStatus(
|
||||
mode=WiFiMode.CLIENT,
|
||||
interfaces=[],
|
||||
current_ssid="OpenNet",
|
||||
current_ip="192.168.1.50",
|
||||
ap_ssid=None,
|
||||
ap_ip=None,
|
||||
supports_dual=False
|
||||
)
|
||||
service = WiFiService(mock_wifi_manager)
|
||||
|
||||
# Act
|
||||
result = await service.connect(ssid="OpenNet", password=None)
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
mock_wifi_manager.connect_to_network.assert_called_once_with(
|
||||
ssid="OpenNet",
|
||||
password=None,
|
||||
interface=None,
|
||||
hidden=False
|
||||
)
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_connect_failure(self, mock_wifi_manager):
|
||||
"""Test failed network connection."""
|
||||
# Arrange
|
||||
mock_wifi_manager.connect_to_network.return_value = False
|
||||
service = WiFiService(mock_wifi_manager)
|
||||
|
||||
# Act
|
||||
result = await service.connect(ssid="BadNetwork", password="wrong")
|
||||
|
||||
# Assert
|
||||
assert result.success is False
|
||||
assert result.error.code == "connection_failed"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_success(self, mock_wifi_manager):
|
||||
"""Test successful disconnect."""
|
||||
# Arrange
|
||||
mock_wifi_manager.disconnect_from_network.return_value = True
|
||||
service = WiFiService(mock_wifi_manager)
|
||||
|
||||
# Act
|
||||
result = await service.disconnect()
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert "Disconnected" in result.data["message"]
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_disconnect_failure(self, mock_wifi_manager):
|
||||
"""Test failed disconnect."""
|
||||
# Arrange
|
||||
mock_wifi_manager.disconnect_from_network.return_value = False
|
||||
service = WiFiService(mock_wifi_manager)
|
||||
|
||||
# Act
|
||||
result = await service.disconnect()
|
||||
|
||||
# Assert
|
||||
assert result.success is False
|
||||
assert result.error.code == "disconnect_failed"
|
||||
|
||||
|
||||
class TestWiFiServiceModeSwitch:
|
||||
"""Test WiFi mode switching."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_mode_ap(self, mock_wifi_manager):
|
||||
"""Test switching to AP mode."""
|
||||
# Arrange
|
||||
mock_wifi_manager.set_mode.return_value = True
|
||||
service = WiFiService(mock_wifi_manager)
|
||||
|
||||
# Act
|
||||
result = await service.set_mode("ap")
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.data["mode"] == "ap"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_mode_client(self, mock_wifi_manager):
|
||||
"""Test switching to client mode."""
|
||||
# Arrange
|
||||
mock_wifi_manager.set_mode.return_value = True
|
||||
service = WiFiService(mock_wifi_manager)
|
||||
|
||||
# Act
|
||||
result = await service.set_mode("client")
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.data["mode"] == "client"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_mode_invalid(self, mock_wifi_manager):
|
||||
"""Test invalid mode."""
|
||||
# Arrange
|
||||
service = WiFiService(mock_wifi_manager)
|
||||
|
||||
# Act
|
||||
result = await service.set_mode("invalid_mode")
|
||||
|
||||
# Assert
|
||||
assert result.success is False
|
||||
assert result.error.code == "invalid_mode"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_set_mode_dual_not_supported(self, mock_wifi_manager):
|
||||
"""Test dual mode without USB adapter."""
|
||||
# Arrange
|
||||
mock_wifi_manager.set_mode.side_effect = ValueError("Dual mode requires USB WiFi adapter")
|
||||
service = WiFiService(mock_wifi_manager)
|
||||
|
||||
# Act
|
||||
result = await service.set_mode("dual")
|
||||
|
||||
# Assert
|
||||
assert result.success is False
|
||||
assert result.error.code == "mode_not_supported"
|
||||
|
||||
|
||||
class TestWiFiServiceSavedNetworks:
|
||||
"""Test saved network management."""
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_get_saved_networks_success(self, mock_wifi_manager):
|
||||
"""Test retrieving saved networks."""
|
||||
# Arrange
|
||||
saved = [
|
||||
{"id": "0", "ssid": "HomeNet", "enabled": True},
|
||||
{"id": "1", "ssid": "WorkNet", "enabled": False}
|
||||
]
|
||||
mock_wifi_manager.get_saved_networks.return_value = saved
|
||||
service = WiFiService(mock_wifi_manager)
|
||||
|
||||
# Act
|
||||
result = await service.get_saved_networks()
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert result.data["count"] == 2
|
||||
assert result.data["networks"][0]["ssid"] == "HomeNet"
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forget_network_success(self, mock_wifi_manager):
|
||||
"""Test forgetting a saved network."""
|
||||
# Arrange
|
||||
mock_wifi_manager.forget_network.return_value = True
|
||||
service = WiFiService(mock_wifi_manager)
|
||||
|
||||
# Act
|
||||
result = await service.forget_network("OldNetwork")
|
||||
|
||||
# Assert
|
||||
assert result.success is True
|
||||
assert "OldNetwork" in result.data["message"]
|
||||
mock_wifi_manager.forget_network.assert_called_once_with("OldNetwork")
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_forget_network_not_found(self, mock_wifi_manager):
|
||||
"""Test forgetting non-existent network."""
|
||||
# Arrange
|
||||
mock_wifi_manager.forget_network.return_value = False
|
||||
service = WiFiService(mock_wifi_manager)
|
||||
|
||||
# Act
|
||||
result = await service.forget_network("NonExistent")
|
||||
|
||||
# Assert
|
||||
assert result.success is False
|
||||
assert result.error.code == "network_not_found"
|
||||
Reference in New Issue
Block a user