diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 497ff80..1d7231b 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -108,6 +108,10 @@ jobs: - name: Run linting run: flake8 . --count --max-complexity=10 --max-line-length=150 + - name: Create test reports directory + run: | + mkdir -p tests/reports + - name: Run tests run: | pytest --cov=. --cov-report=xml --cov-report=html --junitxml=tests/reports/junit.xml diff --git a/.gitea/workflows/service-adapters.yml b/.gitea/workflows/service-adapters.yml index d9c8a37..4abb5bb 100644 --- a/.gitea/workflows/service-adapters.yml +++ b/.gitea/workflows/service-adapters.yml @@ -83,6 +83,10 @@ jobs: bandit -r . -f json -o bandit-report.json safety check --json --output safety-report.json + - name: Create test reports directory + run: | + mkdir -p tests/reports + - name: Run tests run: | pytest --cov=. --cov-report=xml --cov-report=html --cov-report=term-missing --junitxml=tests/reports/junit.xml diff --git a/services/service-adapters/pytest.ini b/services/service-adapters/pytest.ini new file mode 100644 index 0000000..70d6134 --- /dev/null +++ b/services/service-adapters/pytest.ini @@ -0,0 +1,19 @@ +[tool:pytest] +testpaths = tests +python_files = test_*.py +python_classes = Test* +python_functions = test_* +addopts = + -v + --tb=short + --strict-markers + --disable-warnings + --cov=. + --cov-report=term-missing + --cov-report=html + --cov-report=xml + --junitxml=tests/reports/junit.xml +markers = + unit: Unit tests + integration: Integration tests + slow: Slow running tests diff --git a/services/service-adapters/run_tests.py b/services/service-adapters/run_tests.py new file mode 100644 index 0000000..de31e40 --- /dev/null +++ b/services/service-adapters/run_tests.py @@ -0,0 +1,44 @@ +#!/usr/bin/env python3 +""" +Test runner script for LabFusion Service Adapters +""" +import subprocess +import sys +import os + + +def run_tests(): + """Run the test suite""" + print("🧪 Running LabFusion Service Adapters Tests") + print("=" * 50) + + # Ensure test reports directory exists + os.makedirs("tests/reports", exist_ok=True) + + # Run pytest with coverage + cmd = [ + "pytest", + "tests/", + "-v", + "--cov=.", + "--cov-report=term-missing", + "--cov-report=html", + "--cov-report=xml", + "--junitxml=tests/reports/junit.xml", + "--tb=short" + ] + + print(f"Running: {' '.join(cmd)}") + print() + + result = subprocess.run(cmd, cwd=os.path.dirname(__file__)) + + if result.returncode == 0: + print("\n✅ All tests passed!") + else: + print("\n❌ Some tests failed!") + sys.exit(1) + + +if __name__ == "__main__": + run_tests() diff --git a/services/service-adapters/tests/__init__.py b/services/service-adapters/tests/__init__.py new file mode 100644 index 0000000..7de11f7 --- /dev/null +++ b/services/service-adapters/tests/__init__.py @@ -0,0 +1 @@ +# Test package for LabFusion Service Adapters diff --git a/services/service-adapters/tests/conftest.py b/services/service-adapters/tests/conftest.py new file mode 100644 index 0000000..c3a9cde --- /dev/null +++ b/services/service-adapters/tests/conftest.py @@ -0,0 +1,106 @@ +""" +Pytest configuration and fixtures for service adapters tests +""" +import pytest +from fastapi.testclient import TestClient +from unittest.mock import patch + +from main import app + + +@pytest.fixture +def client(): + """Provide a test client for the FastAPI app""" + return TestClient(app) + + +@pytest.fixture +def mock_services_config(): + """Mock services configuration for testing""" + return { + "home_assistant": { + "enabled": True, + "url": "http://homeassistant.local:8123", + "token": "test_token" + }, + "frigate": { + "enabled": True, + "url": "http://frigate.local:5000" + }, + "immich": { + "enabled": False, + "url": "http://immich.local:2283", + "api_key": "test_key" + } + } + + +@pytest.fixture +def sample_ha_entities(): + """Sample Home Assistant entities for testing""" + return { + "sensor.temperature": { + "entity_id": "sensor.temperature", + "state": "22.5", + "attributes": { + "unit_of_measurement": "°C", + "friendly_name": "Temperature" + } + }, + "light.living_room": { + "entity_id": "light.living_room", + "state": "on", + "attributes": { + "friendly_name": "Living Room Light" + } + } + } + + +@pytest.fixture +def sample_frigate_events(): + """Sample Frigate events for testing""" + return { + "events": [ + { + "id": "event_123", + "timestamp": "2024-01-01T12:00:00Z", + "camera": "front_door", + "label": "person", + "confidence": 0.95 + }, + { + "id": "event_456", + "timestamp": "2024-01-01T12:05:00Z", + "camera": "back_yard", + "label": "car", + "confidence": 0.87 + } + ] + } + + +@pytest.fixture +def sample_immich_assets(): + """Sample Immich assets for testing""" + return { + "assets": [ + { + "id": "asset_123", + "filename": "IMG_20240101_120000.jpg", + "created_at": "2024-01-01T12:00:00Z", + "tags": ["family", "vacation"], + "faces": ["person_1", "person_2"] + } + ] + } + + +@pytest.fixture(autouse=True) +def mock_redis(): + """Mock Redis client for all tests""" + with patch('services.redis_client.redis_client') as mock_redis: + mock_redis.ping.return_value = True + mock_redis.set.return_value = True + mock_redis.get.return_value = None + yield mock_redis diff --git a/services/service-adapters/tests/test_general_routes.py b/services/service-adapters/tests/test_general_routes.py new file mode 100644 index 0000000..8bf6197 --- /dev/null +++ b/services/service-adapters/tests/test_general_routes.py @@ -0,0 +1,87 @@ +""" +Tests for general API routes +""" +import pytest +from fastapi.testclient import TestClient +from unittest.mock import patch + +from main import app + +client = TestClient(app) + + +class TestGeneralRoutes: + """Test general API routes""" + + def test_root_endpoint(self): + """Test the root endpoint""" + response = client.get("/") + assert response.status_code == 200 + + data = response.json() + assert data["message"] == "LabFusion Service Adapters API" + assert data["version"] == "1.0.0" + + def test_health_check(self): + """Test the health check endpoint""" + response = client.get("/health") + assert response.status_code == 200 + + data = response.json() + assert data["status"] == "healthy" + assert "timestamp" in data + # Verify timestamp is in ISO format + assert "T" in data["timestamp"] or "Z" in data["timestamp"] + + @patch('services.config.SERVICES') + def test_get_services(self, mock_services): + """Test the get services endpoint""" + # Mock the services configuration + mock_services.items.return_value = [ + ("home_assistant", { + "enabled": True, + "url": "http://homeassistant.local:8123" + }), + ("frigate", { + "enabled": True, + "url": "http://frigate.local:5000" + }), + ("immich", { + "enabled": False, + "url": "http://immich.local:2283" + }) + ] + + response = client.get("/services") + assert response.status_code == 200 + + data = response.json() + assert "home_assistant" in data + assert "frigate" in data + assert "immich" in data + + # Check service status structure + ha_service = data["home_assistant"] + assert ha_service["enabled"] is True + assert ha_service["url"] == "http://homeassistant.local:8123" + assert ha_service["status"] == "unknown" + + def test_health_check_response_model(self): + """Test that health check returns proper response model""" + response = client.get("/health") + data = response.json() + + # Verify all required fields are present + required_fields = ["status", "timestamp"] + for field in required_fields: + assert field in data + + def test_root_response_model(self): + """Test that root endpoint returns proper response model""" + response = client.get("/") + data = response.json() + + # Verify all required fields are present + required_fields = ["message", "version"] + for field in required_fields: + assert field in data diff --git a/services/service-adapters/tests/test_home_assistant_routes.py b/services/service-adapters/tests/test_home_assistant_routes.py new file mode 100644 index 0000000..45132d4 --- /dev/null +++ b/services/service-adapters/tests/test_home_assistant_routes.py @@ -0,0 +1,94 @@ +""" +Tests for Home Assistant routes +""" +import pytest +from fastapi.testclient import TestClient +from unittest.mock import patch, AsyncMock + +from main import app + +client = TestClient(app) + + +class TestHomeAssistantRoutes: + """Test Home Assistant API routes""" + + @patch('routes.home_assistant.httpx.AsyncClient') + async def test_get_entities_success(self, mock_client_class): + """Test successful retrieval of Home Assistant entities""" + # Mock the HTTP client response + mock_response = AsyncMock() + mock_response.json.return_value = { + "sensor.temperature": { + "entity_id": "sensor.temperature", + "state": "22.5", + "attributes": { + "unit_of_measurement": "°C", + "friendly_name": "Temperature" + } + }, + "light.living_room": { + "entity_id": "light.living_room", + "state": "on", + "attributes": { + "friendly_name": "Living Room Light" + } + } + } + mock_response.status_code = 200 + + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_client_class.return_value.__aenter__.return_value = mock_client + + response = client.get("/home-assistant/entities") + assert response.status_code == 200 + + data = response.json() + assert "entities" in data + assert len(data["entities"]) == 2 + + # Check first entity + temp_entity = data["entities"][0] + assert temp_entity["entity_id"] == "sensor.temperature" + assert temp_entity["state"] == "22.5" + assert temp_entity["attributes"]["unit_of_measurement"] == "°C" + + @patch('routes.home_assistant.httpx.AsyncClient') + async def test_get_entities_api_error(self, mock_client_class): + """Test handling of Home Assistant API errors""" + # Mock HTTP error response + mock_response = AsyncMock() + mock_response.status_code = 500 + mock_response.text = "Internal Server Error" + + mock_client = AsyncMock() + mock_client.get.return_value = mock_response + mock_client_class.return_value.__aenter__.return_value = mock_client + + response = client.get("/home-assistant/entities") + assert response.status_code == 500 + + @patch('routes.home_assistant.httpx.AsyncClient') + async def test_get_entities_connection_error(self, mock_client_class): + """Test handling of connection errors""" + # Mock connection error + mock_client_class.return_value.__aenter__.side_effect = Exception("Connection failed") + + response = client.get("/home-assistant/entities") + assert response.status_code == 500 + + def test_get_entities_endpoint_exists(self): + """Test that the entities endpoint exists""" + # This will fail if the route doesn't exist, but we can't test the actual + # functionality without mocking the Home Assistant API + response = client.get("/home-assistant/entities") + # Should return either 200 (success) or 500 (API error) + assert response.status_code in [200, 500] + + def test_home_assistant_routes_available(self): + """Test that Home Assistant routes are available""" + # Check that the router is included by testing a known endpoint + response = client.get("/home-assistant/entities") + # Should not return 404 (route not found) + assert response.status_code != 404 diff --git a/services/service-adapters/tests/test_main.py b/services/service-adapters/tests/test_main.py new file mode 100644 index 0000000..e41be7f --- /dev/null +++ b/services/service-adapters/tests/test_main.py @@ -0,0 +1,49 @@ +""" +Tests for the main FastAPI application +""" +import pytest +from fastapi.testclient import TestClient + +from main import app + +client = TestClient(app) + + +class TestMainApp: + """Test the main FastAPI application""" + + def test_app_creation(self): + """Test that the FastAPI app is created correctly""" + assert app is not None + assert app.title == "LabFusion Service Adapters" + assert app.version == "1.0.0" + + def test_cors_middleware(self): + """Test that CORS middleware is properly configured""" + # Test a simple request to verify CORS headers + response = client.get("/") + assert response.status_code == 200 + # CORS headers should be present + assert "access-control-allow-origin" in response.headers + + def test_routers_included(self): + """Test that all routers are included""" + # Check that all expected routes are available + routes = [route.path for route in app.routes] + + # General routes + assert "/" in routes + assert "/health" in routes + assert "/services" in routes + + # Other service routes should be included + # (exact paths depend on router definitions) + + def test_openapi_docs(self): + """Test that OpenAPI documentation is available""" + response = client.get("/docs") + assert response.status_code == 200 + + response = client.get("/openapi.json") + assert response.status_code == 200 + assert "openapi" in response.json() diff --git a/services/service-adapters/tests/test_models.py b/services/service-adapters/tests/test_models.py new file mode 100644 index 0000000..d3e98c9 --- /dev/null +++ b/services/service-adapters/tests/test_models.py @@ -0,0 +1,181 @@ +""" +Tests for Pydantic models and schemas +""" +import pytest +from datetime import datetime +from typing import Dict, Any + +from models.schemas import ( + ServiceStatus, HAAttributes, HAEntity, HAEntitiesResponse, + FrigateEvent, FrigateEventsResponse, ImmichAsset, ImmichAssetsResponse, + EventData, EventResponse, Event, EventsResponse, + HealthResponse, RootResponse +) + + +class TestServiceStatus: + """Test ServiceStatus model""" + + def test_service_status_creation(self): + """Test creating a ServiceStatus instance""" + service = ServiceStatus( + enabled=True, + url="http://example.com", + status="healthy" + ) + assert service.enabled is True + assert service.url == "http://example.com" + assert service.status == "healthy" + + def test_service_status_validation(self): + """Test ServiceStatus validation""" + # Valid data + service = ServiceStatus( + enabled=False, + url="https://api.example.com", + status="unhealthy" + ) + assert service.enabled is False + + def test_service_status_required_fields(self): + """Test that required fields are enforced""" + with pytest.raises(ValueError): + ServiceStatus() # Missing required fields + + +class TestHAAttributes: + """Test HAAttributes model""" + + def test_ha_attributes_creation(self): + """Test creating HAAttributes instance""" + attrs = HAAttributes( + unit_of_measurement="°C", + friendly_name="Living Room Temperature" + ) + assert attrs.unit_of_measurement == "°C" + assert attrs.friendly_name == "Living Room Temperature" + + def test_ha_attributes_optional_fields(self): + """Test that fields are optional""" + attrs = HAAttributes() + assert attrs.unit_of_measurement is None + assert attrs.friendly_name is None + + +class TestHAEntity: + """Test HAEntity model""" + + def test_ha_entity_creation(self): + """Test creating HAEntity instance""" + attributes = HAAttributes( + unit_of_measurement="°C", + friendly_name="Temperature" + ) + entity = HAEntity( + entity_id="sensor.temperature", + state="22.5", + attributes=attributes + ) + assert entity.entity_id == "sensor.temperature" + assert entity.state == "22.5" + assert entity.attributes.unit_of_measurement == "°C" + + +class TestFrigateEvent: + """Test FrigateEvent model""" + + def test_frigate_event_creation(self): + """Test creating FrigateEvent instance""" + event = FrigateEvent( + id="event_123", + timestamp="2024-01-01T12:00:00Z", + camera="front_door", + label="person", + confidence=0.95 + ) + assert event.id == "event_123" + assert event.camera == "front_door" + assert event.label == "person" + assert event.confidence == 0.95 + + def test_frigate_event_confidence_validation(self): + """Test confidence validation (0-1 range)""" + # Valid confidence + event = FrigateEvent( + id="event_123", + timestamp="2024-01-01T12:00:00Z", + camera="front_door", + label="person", + confidence=0.5 + ) + assert event.confidence == 0.5 + + # Invalid confidence (too high) + with pytest.raises(ValueError): + FrigateEvent( + id="event_123", + timestamp="2024-01-01T12:00:00Z", + camera="front_door", + label="person", + confidence=1.5 + ) + + # Invalid confidence (negative) + with pytest.raises(ValueError): + FrigateEvent( + id="event_123", + timestamp="2024-01-01T12:00:00Z", + camera="front_door", + label="person", + confidence=-0.1 + ) + + +class TestEventData: + """Test EventData model""" + + def test_event_data_creation(self): + """Test creating EventData instance""" + event = EventData( + service="home_assistant", + event_type="state_changed", + metadata={"entity_id": "sensor.temperature", "new_state": "22.5"} + ) + assert event.service == "home_assistant" + assert event.event_type == "state_changed" + assert event.metadata["entity_id"] == "sensor.temperature" + + def test_event_data_default_metadata(self): + """Test default metadata is empty dict""" + event = EventData( + service="test_service", + event_type="test_event" + ) + assert event.metadata == {} + + +class TestHealthResponse: + """Test HealthResponse model""" + + def test_health_response_creation(self): + """Test creating HealthResponse instance""" + timestamp = datetime.now().isoformat() + health = HealthResponse( + status="healthy", + timestamp=timestamp + ) + assert health.status == "healthy" + assert health.timestamp == timestamp + + +class TestRootResponse: + """Test RootResponse model""" + + def test_root_response_creation(self): + """Test creating RootResponse instance""" + response = RootResponse( + message="Test API", + version="1.0.0" + ) + assert response.message == "Test API" + assert response.version == "1.0.0"