""" Tests for Controller routes (multi-agent management). Tests cover: - Agent CRUD operations via HTTP - Proxy operations to agents - Push data ingestion - SSE streaming - Location estimation """ import json import os import pytest import sys from unittest.mock import Mock, patch, MagicMock sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) # ============================================================================= # Fixtures # ============================================================================= @pytest.fixture def setup_db(tmp_path): """Set up a temporary database.""" import utils.database as db_module from utils.database import init_db test_db_path = tmp_path / 'test.db' original_db_path = db_module.DB_PATH db_module.DB_PATH = test_db_path db_module.DB_DIR = tmp_path if hasattr(db_module._local, 'connection') and db_module._local.connection: db_module._local.connection.close() db_module._local.connection = None init_db() yield if hasattr(db_module._local, 'connection') and db_module._local.connection: db_module._local.connection.close() db_module._local.connection = None db_module.DB_PATH = original_db_path @pytest.fixture def app(setup_db): """Create Flask app with controller blueprint.""" from flask import Flask from routes.controller import controller_bp app = Flask(__name__) app.config['TESTING'] = True app.register_blueprint(controller_bp) return app @pytest.fixture def client(app): """Create test client.""" return app.test_client() @pytest.fixture def sample_agent(setup_db): """Create a sample agent in database.""" from utils.database import create_agent agent_id = create_agent( name='test-sensor', base_url='http://192.168.1.50:8020', api_key='test-key', description='Test sensor node', capabilities={'adsb': True, 'wifi': True}, gps_coords={'lat': 40.7128, 'lon': -74.0060} ) return agent_id # ============================================================================= # Agent CRUD Tests # ============================================================================= class TestAgentCRUD: """Tests for agent CRUD operations.""" def test_list_agents_empty(self, client): """GET /controller/agents should return empty list initially.""" response = client.get('/controller/agents') assert response.status_code == 200 data = json.loads(response.data) assert data['status'] == 'success' assert data['agents'] == [] assert data['count'] == 0 def test_register_agent_success(self, client): """POST /controller/agents should register new agent.""" with patch('routes.controller.AgentClient') as MockClient: # Mock successful capability fetch mock_instance = Mock() mock_instance.get_capabilities.return_value = { 'modes': {'adsb': True, 'wifi': True}, 'devices': [{'name': 'RTL-SDR'}] } MockClient.return_value = mock_instance response = client.post('/controller/agents', json={ 'name': 'new-sensor', 'base_url': 'http://192.168.1.51:8020', 'api_key': 'secret123', 'description': 'New sensor node' }, content_type='application/json' ) assert response.status_code == 201 data = json.loads(response.data) assert data['status'] == 'success' assert data['agent']['name'] == 'new-sensor' def test_register_agent_missing_name(self, client): """POST /controller/agents should reject missing name.""" response = client.post('/controller/agents', json={'base_url': 'http://localhost:8020'}, content_type='application/json' ) assert response.status_code == 400 data = json.loads(response.data) assert 'name is required' in data['message'] def test_register_agent_missing_url(self, client): """POST /controller/agents should reject missing URL.""" response = client.post('/controller/agents', json={'name': 'test-sensor'}, content_type='application/json' ) assert response.status_code == 400 data = json.loads(response.data) assert 'Base URL is required' in data['message'] def test_register_agent_duplicate_name(self, client, sample_agent): """POST /controller/agents should reject duplicate name.""" response = client.post('/controller/agents', json={ 'name': 'test-sensor', # Same as sample_agent 'base_url': 'http://192.168.1.60:8020' }, content_type='application/json' ) assert response.status_code == 409 data = json.loads(response.data) assert 'already exists' in data['message'] def test_list_agents_with_agents(self, client, sample_agent): """GET /controller/agents should return registered agents.""" response = client.get('/controller/agents') assert response.status_code == 200 data = json.loads(response.data) assert data['count'] >= 1 names = [a['name'] for a in data['agents']] assert 'test-sensor' in names def test_get_agent_detail(self, client, sample_agent): """GET /controller/agents/ should return agent details.""" response = client.get(f'/controller/agents/{sample_agent}') assert response.status_code == 200 data = json.loads(response.data) assert data['status'] == 'success' assert data['agent']['name'] == 'test-sensor' assert data['agent']['capabilities']['adsb'] is True def test_get_agent_not_found(self, client): """GET /controller/agents/ should return 404 for missing agent.""" response = client.get('/controller/agents/99999') assert response.status_code == 404 def test_update_agent(self, client, sample_agent): """PATCH /controller/agents/ should update agent.""" response = client.patch(f'/controller/agents/{sample_agent}', json={'description': 'Updated description'}, content_type='application/json' ) assert response.status_code == 200 data = json.loads(response.data) assert data['agent']['description'] == 'Updated description' def test_delete_agent(self, client, sample_agent): """DELETE /controller/agents/ should remove agent.""" response = client.delete(f'/controller/agents/{sample_agent}') assert response.status_code == 200 # Verify deleted response = client.get(f'/controller/agents/{sample_agent}') assert response.status_code == 404 # ============================================================================= # Proxy Operation Tests # ============================================================================= class TestProxyOperations: """Tests for proxying operations to agents.""" def test_proxy_start_mode(self, client, sample_agent): """POST /controller/agents///start should proxy to agent.""" with patch('routes.controller.create_client_from_agent') as mock_create: mock_client = Mock() mock_client.start_mode.return_value = {'status': 'started', 'mode': 'adsb'} mock_create.return_value = mock_client response = client.post( f'/controller/agents/{sample_agent}/adsb/start', json={'device_index': 0}, content_type='application/json' ) assert response.status_code == 200 data = json.loads(response.data) assert data['status'] == 'success' assert data['mode'] == 'adsb' mock_client.start_mode.assert_called_once_with('adsb', {'device_index': 0}) def test_proxy_stop_mode(self, client, sample_agent): """POST /controller/agents///stop should proxy to agent.""" with patch('routes.controller.create_client_from_agent') as mock_create: mock_client = Mock() mock_client.stop_mode.return_value = {'status': 'stopped'} mock_create.return_value = mock_client response = client.post( f'/controller/agents/{sample_agent}/wifi/stop', content_type='application/json' ) assert response.status_code == 200 data = json.loads(response.data) assert data['status'] == 'success' def test_proxy_get_mode_data(self, client, sample_agent): """GET /controller/agents///data should return data.""" with patch('routes.controller.create_client_from_agent') as mock_create: mock_client = Mock() mock_client.get_mode_data.return_value = { 'mode': 'adsb', 'data': [{'icao': 'ABC123'}] } mock_create.return_value = mock_client response = client.get(f'/controller/agents/{sample_agent}/adsb/data') assert response.status_code == 200 data = json.loads(response.data) assert data['status'] == 'success' assert 'agent_name' in data assert data['agent_name'] == 'test-sensor' def test_proxy_agent_not_found(self, client): """Proxy operations should return 404 for missing agent.""" response = client.post('/controller/agents/99999/adsb/start') assert response.status_code == 404 def test_proxy_connection_error(self, client, sample_agent): """Proxy should return 503 when agent unreachable.""" from utils.agent_client import AgentConnectionError with patch('routes.controller.create_client_from_agent') as mock_create: mock_client = Mock() mock_client.start_mode.side_effect = AgentConnectionError("Connection refused") mock_create.return_value = mock_client response = client.post( f'/controller/agents/{sample_agent}/adsb/start', json={}, content_type='application/json' ) assert response.status_code == 503 data = json.loads(response.data) assert 'Cannot connect' in data['message'] # ============================================================================= # Push Data Ingestion Tests # ============================================================================= class TestPushIngestion: """Tests for push data ingestion endpoint.""" def test_ingest_success(self, client, sample_agent): """POST /controller/api/ingest should store payload.""" payload = { 'agent_name': 'test-sensor', 'scan_type': 'adsb', 'interface': 'rtlsdr0', 'payload': { 'aircraft': [{'icao': 'ABC123', 'altitude': 35000}] } } response = client.post('/controller/api/ingest', json=payload, headers={'X-API-Key': 'test-key'}, content_type='application/json' ) assert response.status_code == 202 data = json.loads(response.data) assert data['status'] == 'accepted' assert 'payload_id' in data def test_ingest_unknown_agent(self, client): """POST /controller/api/ingest should reject unknown agent.""" payload = { 'agent_name': 'nonexistent-sensor', 'scan_type': 'adsb', 'payload': {} } response = client.post('/controller/api/ingest', json=payload, content_type='application/json' ) assert response.status_code == 401 data = json.loads(response.data) assert 'Unknown agent' in data['message'] def test_ingest_invalid_api_key(self, client, sample_agent): """POST /controller/api/ingest should reject invalid API key.""" payload = { 'agent_name': 'test-sensor', 'scan_type': 'adsb', 'payload': {} } response = client.post('/controller/api/ingest', json=payload, headers={'X-API-Key': 'wrong-key'}, content_type='application/json' ) assert response.status_code == 401 data = json.loads(response.data) assert 'Invalid API key' in data['message'] def test_ingest_missing_agent_name(self, client): """POST /controller/api/ingest should require agent_name.""" response = client.post('/controller/api/ingest', json={'scan_type': 'adsb', 'payload': {}}, content_type='application/json' ) assert response.status_code == 400 data = json.loads(response.data) assert 'agent_name required' in data['message'] def test_get_payloads(self, client, sample_agent): """GET /controller/api/payloads should return stored payloads.""" # First ingest some data for i in range(3): client.post('/controller/api/ingest', json={ 'agent_name': 'test-sensor', 'scan_type': 'adsb', 'payload': {'aircraft': [{'icao': f'TEST{i}'}]} }, headers={'X-API-Key': 'test-key'}, content_type='application/json' ) response = client.get(f'/controller/api/payloads?agent_id={sample_agent}') assert response.status_code == 200 data = json.loads(response.data) assert data['count'] == 3 def test_get_payloads_filter_by_type(self, client, sample_agent): """GET /controller/api/payloads should filter by scan_type.""" # Ingest mixed data client.post('/controller/api/ingest', json={'agent_name': 'test-sensor', 'scan_type': 'adsb', 'payload': {}}, headers={'X-API-Key': 'test-key'}, content_type='application/json' ) client.post('/controller/api/ingest', json={'agent_name': 'test-sensor', 'scan_type': 'wifi', 'payload': {}}, headers={'X-API-Key': 'test-key'}, content_type='application/json' ) response = client.get('/controller/api/payloads?scan_type=adsb') data = json.loads(response.data) assert all(p['scan_type'] == 'adsb' for p in data['payloads']) # ============================================================================= # Location Estimation Tests # ============================================================================= class TestLocationEstimation: """Tests for device location estimation (trilateration).""" def test_add_observation(self, client): """POST /controller/api/location/observe should accept observation.""" response = client.post('/controller/api/location/observe', json={ 'device_id': 'AA:BB:CC:DD:EE:FF', 'agent_name': 'sensor-1', 'agent_lat': 40.7128, 'agent_lon': -74.0060, 'rssi': -55 }, content_type='application/json' ) assert response.status_code == 200 data = json.loads(response.data) assert data['status'] == 'success' assert data['device_id'] == 'AA:BB:CC:DD:EE:FF' def test_add_observation_missing_fields(self, client): """POST /controller/api/location/observe should require all fields.""" response = client.post('/controller/api/location/observe', json={ 'device_id': 'AA:BB:CC:DD:EE:FF', 'rssi': -55 # Missing agent_name, agent_lat, agent_lon }, content_type='application/json' ) assert response.status_code == 400 def test_estimate_location(self, client): """POST /controller/api/location/estimate should compute location.""" response = client.post('/controller/api/location/estimate', json={ 'observations': [ {'agent_lat': 40.7128, 'agent_lon': -74.0060, 'rssi': -55, 'agent_name': 'node-1'}, {'agent_lat': 40.7135, 'agent_lon': -74.0055, 'rssi': -70, 'agent_name': 'node-2'}, {'agent_lat': 40.7120, 'agent_lon': -74.0050, 'rssi': -62, 'agent_name': 'node-3'} ], 'environment': 'outdoor' }, content_type='application/json' ) assert response.status_code == 200 data = json.loads(response.data) # Should have computed a location if data['location']: assert 'lat' in data['location'] assert 'lon' in data['location'] def test_estimate_location_insufficient_data(self, client): """Estimation should require at least 2 observations.""" response = client.post('/controller/api/location/estimate', json={ 'observations': [ {'agent_lat': 40.7128, 'agent_lon': -74.0060, 'rssi': -55, 'agent_name': 'node-1'} ] }, content_type='application/json' ) assert response.status_code == 400 data = json.loads(response.data) assert 'At least 2' in data['message'] def test_get_device_location_not_found(self, client): """GET /controller/api/location/ returns not_found for unknown device.""" response = client.get('/controller/api/location/unknown-device') assert response.status_code == 200 data = json.loads(response.data) assert data['status'] == 'not_found' assert data['location'] is None def test_get_all_locations(self, client): """GET /controller/api/location/all should return all estimates.""" response = client.get('/controller/api/location/all') assert response.status_code == 200 data = json.loads(response.data) assert data['status'] == 'success' assert 'devices' in data def test_get_devices_near(self, client): """GET /controller/api/location/near should find nearby devices.""" response = client.get( '/controller/api/location/near', query_string={'lat': 40.7128, 'lon': -74.0060, 'radius': 100} ) assert response.status_code == 200 data = json.loads(response.data) assert data['status'] == 'success' assert data['center']['lat'] == 40.7128 # ============================================================================= # Agent Refresh Tests # ============================================================================= class TestAgentRefresh: """Tests for agent refresh operations.""" def test_refresh_agent_success(self, client, sample_agent): """POST /controller/agents//refresh should update metadata.""" with patch('routes.controller.create_client_from_agent') as mock_create: mock_client = Mock() mock_client.refresh_metadata.return_value = { 'healthy': True, 'capabilities': { 'modes': {'adsb': True, 'wifi': True, 'bluetooth': True}, 'devices': [{'name': 'RTL-SDR V3'}] }, 'status': {'running_modes': ['adsb']}, 'config': {} } mock_create.return_value = mock_client response = client.post(f'/controller/agents/{sample_agent}/refresh') assert response.status_code == 200 data = json.loads(response.data) assert data['status'] == 'success' assert data['metadata']['healthy'] is True def test_refresh_agent_unreachable(self, client, sample_agent): """POST /controller/agents//refresh should return 503 if unreachable.""" with patch('routes.controller.create_client_from_agent') as mock_create: mock_client = Mock() mock_client.refresh_metadata.return_value = {'healthy': False} mock_create.return_value = mock_client response = client.post(f'/controller/agents/{sample_agent}/refresh') assert response.status_code == 503 # ============================================================================= # SSE Stream Tests # ============================================================================= class TestSSEStream: """Tests for SSE streaming endpoint.""" def test_stream_all_endpoint_exists(self, client): """GET /controller/stream/all should exist and return SSE.""" # Just verify the endpoint is accessible # Full SSE testing requires more complex setup response = client.get('/controller/stream/all') assert response.content_type == 'text/event-stream'