diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml new file mode 100644 index 0000000..0dcf695 --- /dev/null +++ b/.github/workflows/tests.yml @@ -0,0 +1,34 @@ +name: Tests + +on: + push: + branches: [ main ] + pull_request: + branches: [ main ] + +jobs: + test: + runs-on: ubuntu-24.04 + + env: + PYTHONPATH: ${{ github.workspace }}:${{ github.workspace }}/web + + steps: + - name: Checkout code + uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: '3.10' + + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install pytest + pip install -r tests/requirements.txt + pip install -r web/requirements.txt + + - name: Run tests + run: | + pytest tests/ -v --tb=short diff --git a/pytest.ini b/pytest.ini new file mode 100644 index 0000000..4584de7 --- /dev/null +++ b/pytest.ini @@ -0,0 +1,3 @@ +[pytest] +testpaths = tests +pythonpath = . diff --git a/tests/requirements.txt b/tests/requirements.txt new file mode 100644 index 0000000..32d72cd --- /dev/null +++ b/tests/requirements.txt @@ -0,0 +1,5 @@ + +pytest-asyncio==0.21.1 +pytest-cov==4.1.0 +httpx==0.25.2 +pytest-mock==3.12.0 diff --git a/tests/web/conftest.py b/tests/web/conftest.py new file mode 100644 index 0000000..41e4690 --- /dev/null +++ b/tests/web/conftest.py @@ -0,0 +1,283 @@ +""" +Pytest configuration and shared fixtures for end-to-end tests. + +This module provides fixtures for setting up test environment including: +- Test client with FastAPI app +- Mock git repository +- Predefined remotes.json to avoid version fetching +- Remote reload authentication tokens +""" +import os +import json +import tempfile +import shutil +from typing import Generator +from unittest.mock import Mock, MagicMock + +import pytest +from fastapi.testclient import TestClient + + +# Sample remotes.json for testing - no version fetching needed +TEST_REMOTES_JSON = [ + { + "name": "test-remote-1", + "url": "https://github.com/test/ardupilot.git", + "vehicles": [ + { + "name": "Copter", + "releases": [ + { + "release_type": "latest", + "version_number": "4.6.0", + "commit_reference": "refs/heads/master" + }, + { + "release_type": "stable", + "version_number": "4.3.0", + "commit_reference": "refs/tags/Copter-4.3.0" + } + ] + }, + { + "name": "Plane", + "releases": [ + { + "release_type": "latest", + "version_number": "4.5.0", + "commit_reference": "refs/heads/master" + } + ] + } + ] + }, + { + "name": "test-remote-2", + "url": "https://github.com/another/ardupilot.git", + "vehicles": [ + { + "name": "Rover", + "releases": [ + { + "release_type": "Custom", + "version_number": "Custom", + "commit_reference": "refs/tags/Rover-4.2.0" + } + ] + } + ] + } +] + + +@pytest.fixture(scope="session") +def test_base_dir() -> Generator[str, None, None]: + """ + Create a temporary base directory structure for testing. + + Yields: + str: Path to the temporary base directory + """ + temp_dir = tempfile.mkdtemp(prefix="custombuild_test_") + + # Create required subdirectories + subdirs = ["artifacts", "configs", "workdir", "secrets", "ardupilot"] + for subdir in subdirs: + os.makedirs(os.path.join(temp_dir, subdir), exist_ok=True) + + # Create remotes.json with test data + remotes_json_path = os.path.join(temp_dir, "configs", "remotes.json") + with open(remotes_json_path, "w") as f: + json.dump(TEST_REMOTES_JSON, f, indent=2) + + # Create remote reload token file + token_file_path = os.path.join(temp_dir, "secrets", "reload_token") + with open(token_file_path, "w") as f: + f.write("test-remote-reload-token-12345") + + yield temp_dir + + # Cleanup + shutil.rmtree(temp_dir, ignore_errors=True) + + +@pytest.fixture +def mock_git_repo(): + """ + Create a mock GitRepo object. + + Returns: + Mock: Mock GitRepo instance + """ + mock_repo = Mock() + mock_repo.path = "/tmp/test/ardupilot" + mock_repo.get_current_commit_hash.return_value = "abc123def456" + mock_repo.checkout_commit.return_value = True + mock_repo.get_tags.return_value = ["Copter-4.3.0", "Copter-4.4.0"] + mock_repo.get_checkout_lock.return_value = MagicMock() + return mock_repo + + +@pytest.fixture +def mock_ap_src_metadata_fetcher(): + """ + Create a mock APSourceMetadataFetcher for testing. + + Returns: + Mock: Mock APSourceMetadataFetcher instance + """ + return Mock() + + +@pytest.fixture +def mock_versions_fetcher(test_base_dir): + """ + Create a mock VersionsFetcher that doesn't actually fetch versions. + + This allows tests to run without starting background threads or + making actual git operations. + + Args: + test_base_dir: Test base directory fixture + + Returns: + Mock: Mock VersionsFetcher instance + """ + from metadata_manager.versions_fetcher import RemoteInfo + + mock_fetcher = Mock() + + # Mock the reload_remotes_json method + mock_fetcher.reload_remotes_json = Mock(return_value=None) + + # Mock get_all_remotes_info to return test remotes + test_remotes = [ + RemoteInfo(name="test-remote-1", url="https://github.com/test/ardupilot.git"), + RemoteInfo(name="test-remote-2", url="https://github.com/another/ardupilot.git") + ] + mock_fetcher.get_all_remotes_info = Mock(return_value=test_remotes) + + # Mock start/stop methods (no-op for tests) + mock_fetcher.start = Mock() + mock_fetcher.stop = Mock() + + return mock_fetcher + + +@pytest.fixture +def mock_build_manager(): + """ + Create a mock BuildManager for testing. + + Returns: + Mock: Mock BuildManager instance + """ + mock_manager = Mock() + mock_manager.submit_build = Mock(return_value="test-build-id-123") + mock_manager.get_build_progress = Mock(return_value={ + "build_id": "test-build-id-123", + "status": "queued", + "progress": 0 + }) + return mock_manager + + +@pytest.fixture +def mock_vehicles_manager(): + """ + Create a mock VehiclesManager for testing. + + Returns: + Mock: Mock VehiclesManager instance + """ + mock_manager = Mock() + mock_manager.get_vehicle_names = Mock(return_value=["Copter", "Plane", "Rover"]) + return mock_manager + + +@pytest.fixture +def app_with_mocked_dependencies( + test_base_dir, + mock_git_repo, + mock_versions_fetcher, + mock_build_manager, + mock_vehicles_manager, +): + """ + Create a FastAPI app instance with mocked dependencies. + + This fixture sets up the application without requiring actual: + - Git repository cloning + - Version fetching background tasks + - Redis connection + - Build artifacts + + Args: + test_base_dir: Test base directory + mock_git_repo: Mock git repository + mock_versions_fetcher: Mock versions fetcher + mock_build_manager: Mock build manager + mock_vehicles_manager: Mock vehicles manager + + Yields: + FastAPI: Configured FastAPI application instance + """ + from contextlib import asynccontextmanager + from fastapi import FastAPI + from slowapi.middleware import SlowAPIMiddleware + from slowapi.errors import RateLimitExceeded + from web.api.v1 import router as v1_router + from web.core.limiter import limiter, rate_limit_exceeded_handler + + # Set environment variables for test configuration + os.environ["CBS_BASEDIR"] = test_base_dir + os.environ["CBS_REDIS_HOST"] = "localhost" + os.environ["CBS_REDIS_PORT"] = "6379" + os.environ["CBS_ENABLE_INBUILT_BUILDER"] = "0" # Disable builder for tests + + @asynccontextmanager + async def test_lifespan(app: FastAPI): + """Test lifespan that doesn't start background tasks.""" + # Setup: Attach mocked dependencies to app state + app.state.repo = mock_git_repo + app.state.versions_fetcher = mock_versions_fetcher + app.state.vehicles_manager = mock_vehicles_manager + app.state.build_manager = mock_build_manager + app.state.limiter = limiter + + # Create mock AP source metadata fetcher + mock_ap_src_fetcher = Mock() + app.state.ap_src_metadata_fetcher = mock_ap_src_fetcher + + # Don't start background tasks in test mode + # versions_fetcher.start() + # cleaner.start() + # progress_updater.start() + + yield + + # Shutdown logic also skipped + + app = FastAPI(title="CustomBuild Test API", lifespan=test_lifespan) + + app.add_middleware(SlowAPIMiddleware) + app.add_exception_handler(RateLimitExceeded, rate_limit_exceeded_handler) + + app.include_router(v1_router, prefix="/api") + + return app + + +@pytest.fixture +def client(app_with_mocked_dependencies) -> Generator[TestClient, None, None]: + """ + Create a TestClient for making requests to the app. + + Args: + app_with_mocked_dependencies: FastAPI app with mocked dependencies + + Yields: + TestClient: Test client for making API requests + """ + with TestClient(app_with_mocked_dependencies) as test_client: + yield test_client diff --git a/tests/web/test_admin_api.py b/tests/web/test_admin_api.py new file mode 100644 index 0000000..4916024 --- /dev/null +++ b/tests/web/test_admin_api.py @@ -0,0 +1,124 @@ +""" +End-to-end tests for the Admin API endpoints. +""" +from contextlib import contextmanager +from unittest.mock import Mock + +from fastapi import status + +from web.core.config import get_settings + + +class TestAdminRefreshRemotesEndpoint: + """Test suite for the /admin/refresh_remotes endpoint.""" + + AUTH_HEADERS = {"Authorization": "Bearer test-remote-reload-token-12345"} + TEST_TOKEN = "test-remote-reload-token-12345" + + @staticmethod + @contextmanager + def override_settings(client, mock_settings): + """override get_settings with the provided mock.""" + client.app.dependency_overrides[get_settings] = lambda: mock_settings + try: + yield + finally: + client.app.dependency_overrides.pop(get_settings, None) + + def test_refresh_remotes_success(self, client, test_base_dir): + """Test successful refresh of remotes with valid auth and verifies against remotes.json.""" + import os + import json + + remotes_file = os.path.join(test_base_dir, "configs", "remotes.json") + assert os.path.exists(remotes_file) + + with open(remotes_file, "r") as f: + initial_remotes = json.load(f) + + mock_settings = Mock() + mock_settings.remote_reload_token = self.TEST_TOKEN + with self.override_settings(client, mock_settings): + response = client.post( + "/api/v1/admin/refresh_remotes", + headers=self.AUTH_HEADERS + ) + + assert response.status_code == status.HTTP_200_OK + assert "application/json" in response.headers["content-type"] + + data = response.json() + + assert len(data["remotes"]) == len(initial_remotes) + expected_names = [r["name"] for r in initial_remotes] + for name in expected_names: + assert name in data["remotes"] + + def test_refresh_remotes_no_auth(self, client): + """Test refresh without authentication - should fail.""" + mock_settings = Mock() + mock_settings.remote_reload_token = self.TEST_TOKEN + with self.override_settings(client, mock_settings): + response = client.post("/api/v1/admin/refresh_remotes") + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_refresh_remotes_invalid_token(self, client): + """Test refresh with invalid token - should fail.""" + mock_settings = Mock() + mock_settings.remote_reload_token = self.TEST_TOKEN + with self.override_settings(client, mock_settings): + response = client.post( + "/api/v1/admin/refresh_remotes", + headers={"Authorization": "Bearer invalid-token-xyz"} + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + data = response.json() + assert "detail" in data + assert "Invalid authentication token" in data["detail"] + + def test_refresh_remotes_malformed_auth_header(self, client): + """Test refresh with malformed authorization header.""" + mock_settings = Mock() + mock_settings.remote_reload_token = self.TEST_TOKEN + with self.override_settings(client, mock_settings): + response = client.post( + "/api/v1/admin/refresh_remotes", + headers={"Authorization": "test-remote-reload-token-12345"} + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_refresh_remotes_empty_token(self, client): + """Test refresh with empty token.""" + mock_settings = Mock() + mock_settings.remote_reload_token = self.TEST_TOKEN + with self.override_settings(client, mock_settings): + response = client.post( + "/api/v1/admin/refresh_remotes", + headers={"Authorization": "Bearer "} + ) + + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + def test_refresh_remotes_method_not_allowed(self, client): + """Test that only POST method is allowed.""" + disallowed_methods = [ + ("GET", client.get), + ("PUT", client.put), + ("PATCH", client.patch), + ("DELETE", client.delete), + ] + + mock_settings = Mock() + mock_settings.remote_reload_token = self.TEST_TOKEN + with self.override_settings(client, mock_settings): + for method_name, method_func in disallowed_methods: + response = method_func( + "/api/v1/admin/refresh_remotes", + headers=self.AUTH_HEADERS + ) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED, \ + f"{method_name} should return 405" diff --git a/tests/web/test_admin_service.py b/tests/web/test_admin_service.py new file mode 100644 index 0000000..dc9271c --- /dev/null +++ b/tests/web/test_admin_service.py @@ -0,0 +1,57 @@ +""" +Tests for the Admin Service. +""" +import pytest +from unittest.mock import Mock + +from web.services.admin import AdminService + + +class TestAdminService: + """Test suite for AdminService business logic.""" + @pytest.mark.asyncio + async def test_verify_token_success(self): + """Test successful token verification.""" + admin_service = AdminService(remote_reload_token="valid-token") + result = await admin_service.verify_remote_reload_token("valid-token") + assert result is True + + @pytest.mark.asyncio + async def test_verify_token_failure(self): + """Test token verification with incorrect token.""" + admin_service = AdminService(remote_reload_token="valid-token") + result = await admin_service.verify_remote_reload_token("invalid-token") + assert result is False + + @pytest.mark.asyncio + async def test_refresh_remotes_success(self, mock_versions_fetcher): + """Test successful refresh of remote metadata.""" + admin_service = AdminService( + remote_reload_token="some-token", + versions_fetcher=mock_versions_fetcher + ) + remotes = await admin_service.refresh_remotes() + + assert len(remotes) == 2 + assert "test-remote-1" in remotes + assert "test-remote-2" in remotes + + mock_versions_fetcher.reload_remotes_json.assert_called_once() + mock_versions_fetcher.get_all_remotes_info.assert_called_once() + + @pytest.mark.asyncio + async def test_refresh_remotes_empty_result(self): + """Test refresh when no remotes are configured.""" + mock_fetcher = Mock() + mock_fetcher.reload_remotes_json = Mock() + mock_fetcher.get_all_remotes_info = Mock(return_value=[]) + + admin_service = AdminService( + remote_reload_token="some-token", + versions_fetcher=mock_fetcher + ) + + remotes = await admin_service.refresh_remotes() + + assert len(remotes) == 0 + mock_fetcher.reload_remotes_json.assert_called_once() diff --git a/tests/web/test_config.py b/tests/web/test_config.py new file mode 100644 index 0000000..3456546 --- /dev/null +++ b/tests/web/test_config.py @@ -0,0 +1,117 @@ +""" +Tests for the configuration module. +""" +import os +from unittest.mock import patch + +from web.core.config import Settings + + +class TestSettings: + """Test suite for Settings class.""" + + def test_default_settings(self): + """Test that default settings are initialized correctly.""" + with patch.dict(os.environ, {}, clear=True): + settings = Settings() + + assert settings.app_name == "CustomBuild API" + assert settings.app_version == "1.0.0" + assert settings.debug is False + assert settings.redis_host == "localhost" + assert settings.redis_port == "6379" + assert settings.log_level == "INFO" + assert settings.ap_git_url == "https://github.com/ardupilot/ardupilot.git" + assert settings.enable_inbuilt_builder is True + + def test_env_var_overrides(self): + """Test that environment variables override default settings.""" + env_overrides = { + "CBS_BASEDIR": "/custom/base/path", + "CBS_REDIS_HOST": "redis.example.com", + "CBS_REDIS_PORT": "6380", + "CBS_LOG_LEVEL": "DEBUG", + "CBS_ENABLE_INBUILT_BUILDER": "0" + } + + with patch.dict(os.environ, env_overrides, clear=True): + settings = Settings() + + assert settings.base_dir == "/custom/base/path" + assert settings.redis_host == "redis.example.com" + assert settings.redis_port == "6380" + assert settings.log_level == "DEBUG" + assert settings.enable_inbuilt_builder is False + + +class TestRemoteReloadToken: + """Test suite for remote_reload_token property.""" + + def test_token_from_file(self, tmp_path): + """Test that token is read from file when it exists.""" + secrets_dir = tmp_path / 'secrets' + secrets_dir.mkdir() + token_file = secrets_dir / 'reload_token' + + expected_token = "test-token-from-file" + token_file.write_text(f" {expected_token} \n") # Test whitespace stripping + + with patch.dict(os.environ, {"CBS_BASEDIR": str(tmp_path)}, clear=True): + settings = Settings() + assert settings.remote_reload_token == expected_token + + def test_token_file_takes_precedence_over_env(self, tmp_path): + """Test that token from file takes precedence over environment variable.""" + secrets_dir = tmp_path / 'secrets' + secrets_dir.mkdir() + token_file = secrets_dir / 'reload_token' + + file_token = "token-from-file" + env_token = "token-from-env" + + token_file.write_text(file_token) + + with patch.dict(os.environ, { + "CBS_BASEDIR": str(tmp_path), + "CBS_REMOTES_RELOAD_TOKEN": env_token + }, clear=True): + settings = Settings() + assert settings.remote_reload_token == file_token + + def test_token_from_env_when_file_not_found(self, tmp_path): + """Test that token falls back to environment variable when file doesn't exist.""" + expected_token = "test-token-from-env" + + with patch.dict(os.environ, { + "CBS_BASEDIR": str(tmp_path), + "CBS_REMOTES_RELOAD_TOKEN": expected_token + }, clear=True): + settings = Settings() + assert settings.remote_reload_token == expected_token + + def test_token_from_env_on_file_read_error(self, tmp_path): + """Test that token falls back to env var when file cannot be read.""" + env_token = "env-fallback-token" + + with patch.dict(os.environ, { + "CBS_BASEDIR": str(tmp_path), + "CBS_REMOTES_RELOAD_TOKEN": env_token + }, clear=True): + with patch("builtins.open", side_effect=PermissionError("No access")): + settings = Settings() + assert settings.remote_reload_token == env_token + + def test_token_none_when_not_configured(self, tmp_path): + """Test that token is None when neither file nor env var is set.""" + with patch.dict(os.environ, {"CBS_BASEDIR": str(tmp_path)}, clear=True): + settings = Settings() + assert settings.remote_reload_token is None + + def test_token_none_when_env_is_empty_string(self, tmp_path): + """Test that token is None when env var is empty string.""" + with patch.dict(os.environ, { + "CBS_BASEDIR": str(tmp_path), + "CBS_REMOTES_RELOAD_TOKEN": "" + }, clear=True): + settings = Settings() + assert settings.remote_reload_token is None diff --git a/tests/web/test_vehicles_api.py b/tests/web/test_vehicles_api.py new file mode 100644 index 0000000..c935654 --- /dev/null +++ b/tests/web/test_vehicles_api.py @@ -0,0 +1,580 @@ +""" +Tests for the Vehicles API endpoints. +""" +from contextlib import contextmanager +from unittest.mock import Mock +from fastapi import status + +from web.schemas import ( + VehicleBase, + VersionOut, + BoardOut, + FeatureOut, + CategoryBase, + FeatureDefault, + RemoteInfo, +) + + +class TestVehiclesAPI: + """ + Tests for all Vehicles API endpoints. + """ + + @staticmethod + @contextmanager + def override_vehicles_service(client, mock_service): + """Temporarily override the get_vehicles_service dependency.""" + from web.services.vehicles import get_vehicles_service + client.app.dependency_overrides[get_vehicles_service] = lambda: mock_service + try: + yield + finally: + client.app.dependency_overrides.pop(get_vehicles_service, None) + + @staticmethod + def dummy_version(): + return VersionOut( + id="copter-4.5.0-stable", + name="stable 4.5.0 (ardupilot)", + type="stable", + remote=RemoteInfo( + name="ardupilot", + url="https://github.com/ArduPilot/ardupilot.git" + ), + commit_ref="refs/tags/Copter-4.5.0", + vehicle_id="copter", + ) + + @staticmethod + def dummy_board( + vehicle_id="copter", + version_id="copter-4.5.0-stable", + board_id="MatekH743", + ): + return BoardOut( + id=board_id, + name=board_id, + vehicle_id=vehicle_id, + version_id=version_id, + ) + + @staticmethod + def dummy_feature( + vehicle_id="copter", + version_id="copter-4.5.0-stable", + board_id="MatekH743", + feature_id="FEATURE_A", + ): + return FeatureOut( + id=feature_id, + name="Feature A", + category=CategoryBase(id="cat1", name="Category 1"), + description="A test feature", + vehicle_id=vehicle_id, + version_id=version_id, + board_id=board_id, + default=FeatureDefault(enabled=True, source="build-options-py"), + dependencies=[], + ) + + # GET /vehicles + + def test_list_vehicles_returns_200_with_vehicle_list(self, client): + """Returns 200 and a list of vehicles when service has data.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_all_vehicles.return_value = [ + VehicleBase(id="copter", name="Copter"), + VehicleBase(id="plane", name="Plane"), + ] + with self.override_vehicles_service(client, mock_vehicles_service): + response = client.get("/api/v1/vehicles") + + assert response.status_code == status.HTTP_200_OK + assert "application/json" in response.headers["content-type"] + + def test_list_vehicles_returns_200_with_empty_list(self, client): + """Returns 200 with an empty list when no vehicles are available.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_all_vehicles.return_value = [] + with self.override_vehicles_service(client, mock_vehicles_service): + response = client.get("/api/v1/vehicles") + + assert response.status_code == status.HTTP_200_OK + assert response.json() == [] + + def test_list_vehicles_response_schema_has_required_fields(self, client): + """Each vehicle in the response has 'id' and 'name' fields.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_all_vehicles.return_value = [ + VehicleBase(id="copter", name="Copter"), + ] + with self.override_vehicles_service(client, mock_vehicles_service): + response = client.get("/api/v1/vehicles") + + data = response.json() + assert len(data) == 1 + assert "id" in data[0] + assert "name" in data[0] + + def test_list_vehicles_method_not_allowed(self, client): + """Non-GET methods on /vehicles return 405.""" + for method in [client.post, client.put, client.patch, client.delete]: + response = method("/api/v1/vehicles") + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + # GET /vehicles/{vehicle_id} + + def test_get_vehicle_returns_200_when_found(self, client): + """Returns 200 when the vehicle exists.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_vehicle.return_value = VehicleBase(id="copter", name="Copter") + with self.override_vehicles_service(client, mock_vehicles_service): + response = client.get("/api/v1/vehicles/copter") + + assert response.status_code == status.HTTP_200_OK + + def test_get_vehicle_returns_404_when_not_found(self, client): + """Returns 404 when the service returns None.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_vehicle.return_value = None + with self.override_vehicles_service(client, mock_vehicles_service): + response = client.get("/api/v1/vehicles/unknown") + + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_get_vehicle_404_detail_contains_vehicle_id(self, client): + """The 404 error detail mentions the requested vehicle ID.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_vehicle.return_value = None + with self.override_vehicles_service(client, mock_vehicles_service): + response = client.get("/api/v1/vehicles/some-vehicle-id") + + assert "some-vehicle-id" in response.json()["detail"] + + def test_get_vehicle_response_schema_has_required_fields(self, client): + """Response body contains 'id' and 'name'.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_vehicle.return_value = VehicleBase(id="copter", name="Copter") + with self.override_vehicles_service(client, mock_vehicles_service): + response = client.get("/api/v1/vehicles/copter") + + data = response.json() + assert data["id"] == "copter" + assert data["name"] == "Copter" + + def test_get_vehicle_service_called_with_correct_vehicle_id(self, client): + """The vehicle_id path param is forwarded to the service.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_vehicle.return_value = VehicleBase(id="plane", name="Plane") + with self.override_vehicles_service(client, mock_vehicles_service): + client.get("/api/v1/vehicles/plane") + + mock_vehicles_service.get_vehicle.assert_called_once_with("plane") + + def test_get_vehicle_method_not_allowed(self, client): + """Non-GET methods on /vehicles/{vehicle_id} return 405.""" + for method in [client.post, client.put, client.patch, client.delete]: + response = method("/api/v1/vehicles/copter") + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + # GET /vehicles/{vehicle_id}/versions + + def test_list_versions_returns_200_with_version_list(self, client): + """Returns 200 and a list of versions.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_versions.return_value = [self.dummy_version()] + with self.override_vehicles_service(client, mock_vehicles_service): + response = client.get("/api/v1/vehicles/copter/versions") + + assert response.status_code == status.HTTP_200_OK + assert "application/json" in response.headers["content-type"] + + def test_list_versions_returns_200_with_empty_list(self, client): + """Returns 200 with an empty list when no versions exist.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_versions.return_value = [] + with self.override_vehicles_service(client, mock_vehicles_service): + response = client.get("/api/v1/vehicles/copter/versions") + + assert response.status_code == status.HTTP_200_OK + assert response.json() == [] + + def test_list_versions_response_schema_has_required_fields(self, client): + """Each version in the response has the required schema fields.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_versions.return_value = [self.dummy_version()] + with self.override_vehicles_service(client, mock_vehicles_service): + response = client.get("/api/v1/vehicles/copter/versions") + + data = response.json() + assert len(data) == 1 + version = data[0] + for field in ["id", "name", "type", "remote", "commit_ref", "vehicle_id"]: + assert field in version + assert "name" in version["remote"] + assert "url" in version["remote"] + + def test_list_versions_type_query_param_forwarded_to_service(self, client): + """The 'type' query param is passed as type_filter to the service.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_versions.return_value = [] + with self.override_vehicles_service(client, mock_vehicles_service): + client.get("/api/v1/vehicles/copter/versions?type=stable") + + mock_vehicles_service.get_versions.assert_called_once_with( + "copter", type_filter="stable" + ) + + def test_list_versions_no_type_query_param_passes_none_to_service(self, client): + """When 'type' is absent, type_filter=None is passed to the service.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_versions.return_value = [] + with self.override_vehicles_service(client, mock_vehicles_service): + client.get("/api/v1/vehicles/copter/versions") + + mock_vehicles_service.get_versions.assert_called_once_with( + "copter", type_filter=None + ) + + def test_list_versions_vehicle_id_forwarded_to_service(self, client): + """The vehicle_id path param is forwarded to the service.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_versions.return_value = [] + with self.override_vehicles_service(client, mock_vehicles_service): + client.get("/api/v1/vehicles/plane/versions") + + mock_vehicles_service.get_versions.assert_called_once_with( + "plane", type_filter=None + ) + + def test_list_versions_method_not_allowed(self, client): + """Non-GET methods on /vehicles/{vehicle_id}/versions return 405.""" + for method in [client.post, client.put, client.patch, client.delete]: + response = method("/api/v1/vehicles/copter/versions") + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + # GET /vehicles/{vehicle_id}/versions/{version_id} + + def test_get_version_returns_200_when_found(self, client): + """Returns 200 when the version exists.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_version.return_value = self.dummy_version() + with self.override_vehicles_service(client, mock_vehicles_service): + response = client.get("/api/v1/vehicles/copter/versions/copter-4.5.0-stable") + + assert response.status_code == status.HTTP_200_OK + + def test_get_version_returns_404_when_not_found(self, client): + """Returns 404 when the service returns None.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_version.return_value = None + with self.override_vehicles_service(client, mock_vehicles_service): + response = client.get("/api/v1/vehicles/copter/versions/nonexistent") + + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_get_version_404_detail_contains_vehicle_and_version_id(self, client): + """The 404 error detail mentions both the vehicle ID and version ID.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_version.return_value = None + with self.override_vehicles_service(client, mock_vehicles_service): + response = client.get("/api/v1/vehicles/copter/versions/nonexistent") + + detail = response.json()["detail"] + assert "copter" in detail + assert "nonexistent" in detail + + def test_get_version_response_schema_has_required_fields(self, client): + """Response body matches VersionOut schema.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_version.return_value = self.dummy_version() + with self.override_vehicles_service(client, mock_vehicles_service): + response = client.get("/api/v1/vehicles/copter/versions/copter-4.5.0-stable") + + data = response.json() + for field in ["id", "name", "type", "remote", "commit_ref", "vehicle_id"]: + assert field in data + + def test_get_version_service_called_with_correct_ids(self, client): + """Both vehicle_id and version_id are forwarded to the service.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_version.return_value = self.dummy_version() + with self.override_vehicles_service(client, mock_vehicles_service): + client.get("/api/v1/vehicles/copter/versions/copter-4.5.0-stable") + + mock_vehicles_service.get_version.assert_called_once_with( + "copter", "copter-4.5.0-stable" + ) + + def test_get_version_method_not_allowed(self, client): + """Non-GET methods on /vehicles/{vehicle_id}/versions/{version_id} return 405.""" + for method in [client.post, client.put, client.patch, client.delete]: + response = method("/api/v1/vehicles/copter/versions/v1") + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + # GET /vehicles/{vehicle_id}/versions/{version_id}/boards + + def test_list_boards_returns_200_when_boards_exist(self, client): + """Returns 200 and a list of boards when boards are available.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_boards.return_value = [self.dummy_board()] + with self.override_vehicles_service(client, mock_vehicles_service): + response = client.get("/api/v1/vehicles/copter/versions/copter-4.5.0-stable/boards") + + assert response.status_code == status.HTTP_200_OK + assert "application/json" in response.headers["content-type"] + + def test_list_boards_returns_404_when_no_boards(self, client): + """Returns 404 (not 200) when service returns an empty list.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_boards.return_value = [] + with self.override_vehicles_service(client, mock_vehicles_service): + response = client.get("/api/v1/vehicles/copter/versions/copter-4.5.0-stable/boards") + + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_list_boards_404_detail_contains_vehicle_and_version_id(self, client): + """The 404 error detail mentions both the vehicle ID and version ID.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_boards.return_value = [] + with self.override_vehicles_service(client, mock_vehicles_service): + response = client.get("/api/v1/vehicles/copter/versions/copter-4.5.0-stable/boards") + + detail = response.json()["detail"] + assert "copter" in detail + assert "copter-4.5.0-stable" in detail + + def test_list_boards_response_schema_has_required_fields(self, client): + """Each board in the response has the required schema fields.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_boards.return_value = [self.dummy_board()] + with self.override_vehicles_service(client, mock_vehicles_service): + response = client.get("/api/v1/vehicles/copter/versions/copter-4.5.0-stable/boards") + + data = response.json() + assert len(data) == 1 + board = data[0] + for field in ["id", "name", "vehicle_id", "version_id"]: + assert field in board + + def test_list_boards_service_called_with_correct_ids(self, client): + """Both vehicle_id and version_id are forwarded to the service.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_boards.return_value = [self.dummy_board()] + with self.override_vehicles_service(client, mock_vehicles_service): + client.get("/api/v1/vehicles/copter/versions/copter-4.5.0-stable/boards") + + mock_vehicles_service.get_boards.assert_called_once_with( + "copter", "copter-4.5.0-stable" + ) + + def test_list_boards_method_not_allowed(self, client): + """Non-GET methods on .../boards return 405.""" + for method in [client.post, client.put, client.patch, client.delete]: + response = method("/api/v1/vehicles/copter/versions/v1/boards") + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + # GET /vehicles/{vehicle_id}/versions/{version_id}/boards/{board_id} + + def test_get_board_returns_200_when_found(self, client): + """Returns 200 when the board exists.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_board.return_value = self.dummy_board() + with self.override_vehicles_service(client, mock_vehicles_service): + response = client.get( + "/api/v1/vehicles/copter/versions/copter-4.5.0-stable/boards/MatekH743" + ) + + assert response.status_code == status.HTTP_200_OK + + def test_get_board_returns_404_when_not_found(self, client): + """Returns 404 when the service returns None.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_board.return_value = None + with self.override_vehicles_service(client, mock_vehicles_service): + response = client.get( + "/api/v1/vehicles/copter/versions/copter-4.5.0-stable/boards/unknown" + ) + + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_get_board_404_detail_contains_board_id(self, client): + """The 404 error detail mentions the requested board ID.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_board.return_value = None + with self.override_vehicles_service(client, mock_vehicles_service): + response = client.get( + "/api/v1/vehicles/copter/versions/copter-4.5.0-stable/boards/unknown" + ) + + assert "unknown" in response.json()["detail"] + + def test_get_board_response_schema_has_required_fields(self, client): + """Response body matches BoardOut schema.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_board.return_value = self.dummy_board() + with self.override_vehicles_service(client, mock_vehicles_service): + response = client.get( + "/api/v1/vehicles/copter/versions/copter-4.5.0-stable/boards/MatekH743" + ) + + data = response.json() + for field in ["id", "name", "vehicle_id", "version_id"]: + assert field in data + + def test_get_board_service_called_with_correct_ids(self, client): + """All three path params are forwarded to the service.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_board.return_value = self.dummy_board() + with self.override_vehicles_service(client, mock_vehicles_service): + client.get( + "/api/v1/vehicles/copter/versions/copter-4.5.0-stable/boards/MatekH743" + ) + + mock_vehicles_service.get_board.assert_called_once_with( + "copter", "copter-4.5.0-stable", "MatekH743" + ) + + def test_get_board_method_not_allowed(self, client): + """Non-GET methods on .../boards/{board_id} return 405.""" + for method in [client.post, client.put, client.patch, client.delete]: + response = method("/api/v1/vehicles/copter/versions/v1/boards/b1") + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + # GET /vehicles/{vehicle_id}/versions/{version_id}/boards/{board_id}/features + + _FEATURES_URL = "/api/v1/vehicles/copter/versions/copter-4.5.0-stable/boards/MatekH743/features" + + def test_list_features_returns_200_with_feature_list(self, client): + """Returns 200 and a list of features.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_features.return_value = [self.dummy_feature()] + with self.override_vehicles_service(client, mock_vehicles_service): + response = client.get(self._FEATURES_URL) + + assert response.status_code == status.HTTP_200_OK + assert "application/json" in response.headers["content-type"] + + def test_list_features_returns_200_with_empty_list(self, client): + """Returns 200 with empty list (unlike boards, empty features is not a 404).""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_features.return_value = [] + with self.override_vehicles_service(client, mock_vehicles_service): + response = client.get(self._FEATURES_URL) + + assert response.status_code == status.HTTP_200_OK + assert response.json() == [] + + def test_list_features_response_schema_has_required_fields(self, client): + """Each feature in the response has the required schema fields.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_features.return_value = [self.dummy_feature()] + with self.override_vehicles_service(client, mock_vehicles_service): + response = client.get(self._FEATURES_URL) + + data = response.json() + assert len(data) == 1 + feature = data[0] + for field in ["id", "name", "category", "vehicle_id", "version_id", "board_id", "default", "dependencies"]: + assert field in feature + assert "enabled" in feature["default"] + assert "source" in feature["default"] + + def test_list_features_category_id_query_param_forwarded_to_service(self, client): + """The 'category_id' query param is forwarded to the service.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_features.return_value = [] + with self.override_vehicles_service(client, mock_vehicles_service): + client.get(self._FEATURES_URL + "?category_id=cat1") + + mock_vehicles_service.get_features.assert_called_once_with( + "copter", "copter-4.5.0-stable", "MatekH743", "cat1" + ) + + def test_list_features_no_category_id_passes_none_to_service(self, client): + """When 'category_id' is absent, None is passed to the service.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_features.return_value = [] + with self.override_vehicles_service(client, mock_vehicles_service): + client.get(self._FEATURES_URL) + + mock_vehicles_service.get_features.assert_called_once_with( + "copter", "copter-4.5.0-stable", "MatekH743", None + ) + + def test_list_features_service_called_with_correct_path_params(self, client): + """All three path params are forwarded to the service.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_features.return_value = [] + with self.override_vehicles_service(client, mock_vehicles_service): + client.get( + "/api/v1/vehicles/plane/versions/plane-4.4.0-stable/boards/CubeOrange/features" + ) + + mock_vehicles_service.get_features.assert_called_once_with( + "plane", "plane-4.4.0-stable", "CubeOrange", None + ) + + def test_list_features_method_not_allowed(self, client): + """Non-GET methods on .../features return 405.""" + for method in [client.post, client.put, client.patch, client.delete]: + response = method(self._FEATURES_URL) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED + + # GET /vehicles/{vehicle_id}/versions/{version_id}/boards/{board_id}/features/{feature_id} + + def test_get_feature_returns_200_when_found(self, client): + """Returns 200 when the feature exists.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_feature.return_value = self.dummy_feature() + with self.override_vehicles_service(client, mock_vehicles_service): + response = client.get(f"{self._FEATURES_URL}/FEATURE_A") + + assert response.status_code == status.HTTP_200_OK + + def test_get_feature_returns_404_when_not_found(self, client): + """Returns 404 when the service returns None.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_feature.return_value = None + with self.override_vehicles_service(client, mock_vehicles_service): + response = client.get(f"{self._FEATURES_URL}/UNKNOWN_FEATURE") + + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_get_feature_404_detail_contains_feature_id(self, client): + """The 404 error detail mentions the requested feature ID.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_feature.return_value = None + with self.override_vehicles_service(client, mock_vehicles_service): + response = client.get(f"{self._FEATURES_URL}/UNKNOWN_FEATURE") + + assert "UNKNOWN_FEATURE" in response.json()["detail"] + + def test_get_feature_response_schema_has_required_fields(self, client): + """Response body matches FeatureOut schema.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_feature.return_value = self.dummy_feature() + with self.override_vehicles_service(client, mock_vehicles_service): + response = client.get(f"{self._FEATURES_URL}/FEATURE_A") + + data = response.json() + for field in ["id", "name", "category", "vehicle_id", "version_id", "board_id", "default", "dependencies"]: + assert field in data + + def test_get_feature_service_called_with_correct_ids(self, client): + """All four path params are forwarded to the service.""" + mock_vehicles_service = Mock() + mock_vehicles_service.get_feature.return_value = self.dummy_feature() + with self.override_vehicles_service(client, mock_vehicles_service): + client.get(f"{self._FEATURES_URL}/FEATURE_A") + + mock_vehicles_service.get_feature.assert_called_once_with( + "copter", "copter-4.5.0-stable", "MatekH743", "FEATURE_A" + ) + + def test_get_feature_method_not_allowed(self, client): + """Non-GET methods on .../features/{feature_id} return 405.""" + for method in [client.post, client.put, client.patch, client.delete]: + response = method(f"{self._FEATURES_URL}/FEATURE_A") + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED diff --git a/tests/web/test_vehicles_service.py b/tests/web/test_vehicles_service.py new file mode 100644 index 0000000..35eb60b --- /dev/null +++ b/tests/web/test_vehicles_service.py @@ -0,0 +1,1147 @@ +""" +Tests for the Vehicles Service. +""" +import pytest +from unittest.mock import Mock + +from metadata_manager import Vehicle +from metadata_manager.versions_fetcher import VersionInfo, RemoteInfo +from web.services.vehicles import VehiclesService + + +@pytest.fixture +def service(mock_vehicles_manager, mock_versions_fetcher, mock_ap_src_metadata_fetcher, mock_git_repo): + return VehiclesService( + vehicle_manager=mock_vehicles_manager, + versions_fetcher=mock_versions_fetcher, + ap_src_metadata_fetcher=mock_ap_src_metadata_fetcher, + repo=mock_git_repo, + ) + + +class TestVehiclesService: + """Test suite for VehiclesService.""" + + # Tests for get_all_vehicles + + def test_get_all_vehicles_returns_all(self, service, mock_vehicles_manager): + """Test fetching all vehicles returns correct count and values.""" + mock_vehicles_manager.get_all_vehicles.return_value = [ + Vehicle( + id="copter", + name="Copter", + ap_source_subdir="ArduCopter", + fw_server_vehicle_sdir="Copter", + waf_build_command="copter" + ), + Vehicle( + id="plane", + name="Plane", + ap_source_subdir="ArduPlane", + fw_server_vehicle_sdir="Plane", + waf_build_command="plane" + ), + ] + vehicles = service.get_all_vehicles() + + assert len(vehicles) == 2 + assert vehicles[0].id == "copter" + assert vehicles[0].name == "Copter" + assert vehicles[1].id == "plane" + assert vehicles[1].name == "Plane" + + def test_get_all_vehicles_empty(self, service, mock_vehicles_manager): + """Test fetching all vehicles when none exist.""" + mock_vehicles_manager.get_all_vehicles.return_value = [] + vehicles = service.get_all_vehicles() + + assert vehicles == [] + + def test_get_all_vehicles_single(self, service, mock_vehicles_manager): + """Test fetching all vehicles when only one exists.""" + mock_vehicles_manager.get_all_vehicles.return_value = [ + Vehicle( + id="copter", + name="Copter", + ap_source_subdir="ArduCopter", + fw_server_vehicle_sdir="Copter", + waf_build_command="copter" + ), + ] + vehicles = service.get_all_vehicles() + + assert len(vehicles) == 1 + assert vehicles[0].id == "copter" + + def test_get_all_vehicles_sorted_by_name(self, service, mock_vehicles_manager): + """Test fetching all vehicles returns them sorted by name.""" + mock_vehicles_manager.get_all_vehicles.return_value = [ + Vehicle( + id="plane", + name="Plane", + ap_source_subdir="ArduPlane", + fw_server_vehicle_sdir="Plane", + waf_build_command="plane" + ), + Vehicle( + id="copter", + name="Copter", + ap_source_subdir="ArduCopter", + fw_server_vehicle_sdir="Copter", + waf_build_command="copter" + ), + Vehicle( + id="rover", + name="Rover", + ap_source_subdir="ArduRover", + fw_server_vehicle_sdir="Rover", + waf_build_command="rover" + ), + ] + vehicles = service.get_all_vehicles() + names = [v.name for v in vehicles] + + assert names == sorted(names) + + def test_get_all_vehicles_calls_manager_once(self, service, mock_vehicles_manager): + """Test that get_all_vehicles calls the manager exactly once.""" + mock_vehicles_manager.get_all_vehicles.return_value = [] + service.get_all_vehicles() + + mock_vehicles_manager.get_all_vehicles.assert_called_once_with() + + # Tests for get_vehicle + + def test_get_vehicle_found(self, service, mock_vehicles_manager): + """Test fetching a specific vehicle that exists.""" + mock_vehicles_manager.get_vehicle_by_id.return_value = Vehicle( + id="copter", + name="Copter", + ap_source_subdir="ArduCopter", + fw_server_vehicle_sdir="Copter", + waf_build_command="copter" + ) + vehicle = service.get_vehicle("copter") + + assert vehicle is not None + assert vehicle.id == "copter" + assert vehicle.name == "Copter" + + def test_get_vehicle_not_found(self, service, mock_vehicles_manager): + """Test fetching a specific vehicle that does not exist.""" + mock_vehicles_manager.get_vehicle_by_id.return_value = None + vehicle = service.get_vehicle("copter") + + assert vehicle is None + + def test_get_vehicle_calls_manager_with_correct_id(self, service, mock_vehicles_manager): + """Test that get_vehicle calls manager with the provided ID.""" + mock_vehicles_manager.get_vehicle_by_id.return_value = None + service.get_vehicle("copter") + + mock_vehicles_manager.get_vehicle_by_id.assert_called_once_with("copter") + + # Tests for get_versions + + def test_get_versions_empty(self, service, mock_versions_fetcher): + """Test that an empty list is returned when no versions exist.""" + mock_versions_fetcher.get_versions_for_vehicle.return_value = [] + versions = service.get_versions("copter") + + assert versions == [] + + def test_get_versions_single(self, service, mock_versions_fetcher): + """Test fetching versions when only one version exists.""" + mock_versions_fetcher.get_versions_for_vehicle.return_value = [ + VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/heads/master", + release_type="latest", + version_number="NA", + ap_build_artifacts_url=None, + ), + ] + versions = service.get_versions("copter") + + assert len(versions) == 1 + + def test_get_versions_many(self, service, mock_versions_fetcher): + """Test fetching versions when multiple versions exist.""" + mock_versions_fetcher.get_versions_for_vehicle.return_value = [ + VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/heads/master", + release_type="latest", + version_number="NA", + ap_build_artifacts_url=None, + ), + VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ), + VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.6.0-beta", + release_type="beta", + version_number="4.6.0", + ap_build_artifacts_url=None, + ), + ] + versions = service.get_versions("copter") + + assert len(versions) == 3 + + def test_get_versions_sorted_by_name(self, service, mock_versions_fetcher): + """Test that versions are returned sorted by their display name.""" + mock_versions_fetcher.get_versions_for_vehicle.return_value = [ + VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ), + VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/heads/master", + release_type="latest", + version_number="NA", + ap_build_artifacts_url=None, + ), + VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.6.0-beta", + release_type="beta", + version_number="4.6.0", + ap_build_artifacts_url=None, + ), + ] + versions = service.get_versions("copter") + names = [v.name for v in versions] + + assert names == sorted(names) + + def test_get_versions_calls_fetcher_once_with_correct_vehicle_id( + self, service, mock_versions_fetcher + ): + """Test that get_versions calls the fetcher exactly once with the correct vehicle_id.""" + mock_versions_fetcher.get_versions_for_vehicle.return_value = [] + service.get_versions("copter") + + mock_versions_fetcher.get_versions_for_vehicle.assert_called_once_with( + vehicle_id="copter" + ) + + def test_get_versions_type_filter_keeps_matching( + self, service, mock_versions_fetcher + ): + """Test that type_filter returns only versions of the specified type.""" + mock_versions_fetcher.get_versions_for_vehicle.return_value = [ + VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ), + VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.6.0-beta", + release_type="beta", + version_number="4.6.0", + ap_build_artifacts_url=None, + ), + VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/heads/master", + release_type="latest", + version_number="NA", + ap_build_artifacts_url=None, + ), + ] + versions = service.get_versions("copter", type_filter="stable") + + assert len(versions) == 1 + assert versions[0].type == "stable" + + def test_get_versions_type_filter_excludes_non_matching( + self, service, mock_versions_fetcher + ): + """Test that type_filter excludes versions that do not match.""" + mock_versions_fetcher.get_versions_for_vehicle.return_value = [ + VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ), + VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.6.0-beta", + release_type="beta", + version_number="4.6.0", + ap_build_artifacts_url=None, + ), + ] + versions = service.get_versions("copter", type_filter="latest") + + assert versions == [] + + def test_get_versions_type_filter_none_returns_all( + self, service, mock_versions_fetcher + ): + """Test that passing no type_filter returns all versions.""" + mock_versions_fetcher.get_versions_for_vehicle.return_value = [ + VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ), + VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.6.0-beta", + release_type="beta", + version_number="4.6.0", + ap_build_artifacts_url=None, + ), + VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/heads/master", + release_type="latest", + version_number="NA", + ap_build_artifacts_url=None, + ), + ] + versions = service.get_versions("copter") + + assert len(versions) == 3 + + def test_get_versions_type_filter_multiple_matches( + self, service, mock_versions_fetcher + ): + """Test that type_filter returns all versions matching the type when there are multiple.""" + mock_versions_fetcher.get_versions_for_vehicle.return_value = [ + VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.4.0", + release_type="stable", + version_number="4.4.0", + ap_build_artifacts_url=None, + ), + VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ), + VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/heads/master", + release_type="latest", + version_number="NA", + ap_build_artifacts_url=None, + ), + ] + versions = service.get_versions("copter", type_filter="stable") + + assert len(versions) == 2 + assert all(v.type == "stable" for v in versions) + + def test_get_versions_latest_name_format( + self, service, mock_versions_fetcher + ): + """Test that latest versions have the correct display name format.""" + mock_versions_fetcher.get_versions_for_vehicle.return_value = [ + VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/heads/master", + release_type="latest", + version_number="NA", + ap_build_artifacts_url=None, + ), + ] + versions = service.get_versions("copter") + + assert versions[0].name == "Latest (ardupilot)" + + def test_get_versions_non_latest_name_format( + self, service, mock_versions_fetcher + ): + """Test that non-latest versions have the correct display name format.""" + mock_versions_fetcher.get_versions_for_vehicle.return_value = [ + VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ), + ] + versions = service.get_versions("copter") + + assert versions[0].name == "stable 4.5.0 (ardupilot)" + + # Tests for get_version + + def test_get_version_found(self, service, mock_versions_fetcher): + """Test that the correct version is returned when it exists.""" + version_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ) + mock_versions_fetcher.get_versions_for_vehicle.return_value = [version_info] + + result = service.get_version("copter", version_info.version_id) + + assert result is not None + assert result.id == version_info.version_id + + def test_get_version_not_found(self, service, mock_versions_fetcher): + """Test that None is returned when the version does not exist.""" + mock_versions_fetcher.get_versions_for_vehicle.return_value = [ + VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ), + ] + + result = service.get_version("copter", "nonexistent-version-id") + + assert result is None + + def test_get_version_no_versions_available(self, service, mock_versions_fetcher): + """Test that None is returned when there are no versions at all.""" + mock_versions_fetcher.get_versions_for_vehicle.return_value = [] + + result = service.get_version("copter", "any-version-id") + + assert result is None + + def test_get_version_returns_correct_match_among_many(self, service, mock_versions_fetcher): + """Test that only the matching version is returned when multiple exist.""" + stable_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ) + beta_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.6.0-beta", + release_type="beta", + version_number="4.6.0", + ap_build_artifacts_url=None, + ) + latest_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/heads/master", + release_type="latest", + version_number="NA", + ap_build_artifacts_url=None, + ) + mock_versions_fetcher.get_versions_for_vehicle.return_value = [ + stable_info, beta_info, latest_info, + ] + + result = service.get_version("copter", beta_info.version_id) + + assert result is not None + assert result.id == beta_info.version_id + assert result.type == "beta" + + # Tests for get_boards + + def test_get_boards_version_not_found_returns_empty(self, service, mock_versions_fetcher): + """Test that an empty list is returned when the version does not exist.""" + mock_versions_fetcher.get_version_info.return_value = None + + result = service.get_boards("copter", "nonexistent-version-id") + + assert result == [] + + def test_get_boards_version_info_queried_with_correct_params( + self, service, mock_versions_fetcher + ): + """Test that get_version_info is called with the correct vehicle and version IDs.""" + mock_versions_fetcher.get_version_info.return_value = None + + service.get_boards("copter", "some-version-id") + + mock_versions_fetcher.get_version_info.assert_called_once_with( + vehicle_id="copter", + version_id="some-version-id", + ) + + def test_get_boards_empty( + self, service, mock_versions_fetcher, mock_ap_src_metadata_fetcher + ): + """Test that an empty list is returned when there are no boards for a version.""" + version_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ) + mock_versions_fetcher.get_version_info.return_value = version_info + mock_ap_src_metadata_fetcher.get_boards.return_value = [] + + result = service.get_boards("copter", version_info.version_id) + + assert result == [] + + def test_get_boards_single( + self, service, mock_versions_fetcher, mock_ap_src_metadata_fetcher + ): + """Test that a single board is returned correctly.""" + version_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ) + mock_versions_fetcher.get_version_info.return_value = version_info + mock_ap_src_metadata_fetcher.get_boards.return_value = ["CubeRed"] + + result = service.get_boards("copter", version_info.version_id) + + assert len(result) == 1 + assert result[0].id == "CubeRed" + assert result[0].name == "CubeRed" + + def test_get_boards_many( + self, service, mock_versions_fetcher, mock_ap_src_metadata_fetcher + ): + """Test that multiple boards are returned correctly.""" + version_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ) + mock_versions_fetcher.get_version_info.return_value = version_info + mock_ap_src_metadata_fetcher.get_boards.return_value = [ + "CubeRed", "CubeOrange", "MatekF405", + ] + + result = service.get_boards("copter", version_info.version_id) + + assert len(result) == 3 + assert [b.id for b in result] == ["CubeRed", "CubeOrange", "MatekF405"] + + def test_get_boards_sets_correct_vehicle_and_version_ids( + self, service, mock_versions_fetcher, mock_ap_src_metadata_fetcher + ): + """Test that returned boards carry the correct vehicle_id and version_id.""" + version_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ) + mock_versions_fetcher.get_version_info.return_value = version_info + mock_ap_src_metadata_fetcher.get_boards.return_value = ["CubeRed"] + + result = service.get_boards("copter", version_info.version_id) + + assert result[0].vehicle_id == "copter" + assert result[0].version_id == version_info.version_id + + def test_get_boards_fetcher_called_with_correct_params( + self, service, mock_versions_fetcher, mock_ap_src_metadata_fetcher + ): + """Test that the metadata fetcher is called with remote name, commit ref, and vehicle ID from version info.""" + version_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ) + mock_versions_fetcher.get_version_info.return_value = version_info + mock_ap_src_metadata_fetcher.get_boards.return_value = [] + + service.get_boards("copter", version_info.version_id) + + mock_ap_src_metadata_fetcher.get_boards.assert_called_once_with( + remote="ardupilot", + commit_ref="refs/tags/Copter-4.5.0", + vehicle_id="copter", + ) + + # Tests for get_board + + def test_get_board_found(self, service, mock_versions_fetcher, mock_ap_src_metadata_fetcher): + """Test that the correct board is returned when it exists.""" + version_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ) + mock_versions_fetcher.get_version_info.return_value = version_info + mock_ap_src_metadata_fetcher.get_boards.return_value = ["CubeRed", "CubeOrange"] + + result = service.get_board("copter", version_info.version_id, "CubeRed") + + assert result is not None + assert result.id == "CubeRed" + assert result.name == "CubeRed" + assert result.vehicle_id == "copter" + assert result.version_id == version_info.version_id + + def test_get_board_not_found(self, service, mock_versions_fetcher, mock_ap_src_metadata_fetcher): + """Test that None is returned when the board does not exist.""" + version_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ) + mock_versions_fetcher.get_version_info.return_value = version_info + mock_ap_src_metadata_fetcher.get_boards.return_value = ["CubeRed", "CubeOrange"] + + result = service.get_board("copter", version_info.version_id, "NonExistentBoard") + + assert result is None + + def test_get_board_returns_correct_match_among_many( + self, service, mock_versions_fetcher, mock_ap_src_metadata_fetcher + ): + """Test that only the matching board is returned when multiple boards exist.""" + version_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ) + mock_versions_fetcher.get_version_info.return_value = version_info + mock_ap_src_metadata_fetcher.get_boards.return_value = [ + "CubeRed", "CubeOrange", "MatekF405", + ] + + result = service.get_board("copter", version_info.version_id, "CubeOrange") + + assert result is not None + assert result.id == "CubeOrange" + + # Tests for get_features + + def test_get_features_version_not_found_returns_empty( + self, service, mock_versions_fetcher + ): + """Test that an empty list is returned when the version does not exist.""" + mock_versions_fetcher.get_version_info.return_value = None + + result = service.get_features("copter", "nonexistent-version-id", "CubeRed") + + assert result == [] + + def test_get_features_zero_options_returns_empty( + self, service, mock_versions_fetcher, mock_ap_src_metadata_fetcher + ): + """Test that an empty list is returned when there are no build options.""" + version_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ) + mock_versions_fetcher.get_version_info.return_value = version_info + mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [] + + result = service.get_features("copter", version_info.version_id, "CubeRed") + + assert result == [] + + def test_get_features_one_option( + self, service, mock_versions_fetcher, mock_ap_src_metadata_fetcher + ): + """Test that a single feature is returned correctly.""" + version_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ) + opt = Mock() + opt.label = "HAL_LOGGING_ENABLED" + opt.define = "HAL_LOGGING_ENABLED" + opt.category = "Logging" + opt.description = "" + opt.default = 1 + opt.dependency = None + mock_versions_fetcher.get_version_info.return_value = version_info + mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt] + mock_ap_src_metadata_fetcher.get_board_defaults_from_fw_server.return_value = None + + result = service.get_features("copter", version_info.version_id, "CubeRed") + + assert len(result) == 1 + assert result[0].id == "HAL_LOGGING_ENABLED" + assert result[0].name == "HAL_LOGGING_ENABLED" + + def test_get_features_many_options( + self, service, mock_versions_fetcher, mock_ap_src_metadata_fetcher + ): + """Test that all features are returned when multiple options exist.""" + version_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ) + opt_logging = Mock() + opt_logging.label, opt_logging.define, opt_logging.category = "HAL_LOGGING_ENABLED", "HAL_LOGGING_ENABLED", "Logging" + opt_logging.description, opt_logging.default, opt_logging.dependency = "", 1, None + opt_ekf = Mock() + opt_ekf.label, opt_ekf.define, opt_ekf.category = "HAL_NAVEKF3_AVAILABLE", "HAL_NAVEKF3_AVAILABLE", "EKF" + opt_ekf.description, opt_ekf.default, opt_ekf.dependency = "", 1, None + opt_sensors = Mock() + opt_sensors.label, opt_sensors.define, opt_sensors.category = "HAL_BEACON_ENABLED", "HAL_BEACON_ENABLED", "Sensors" + opt_sensors.description, opt_sensors.default, opt_sensors.dependency = "", 1, None + mock_versions_fetcher.get_version_info.return_value = version_info + mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt_logging, opt_ekf, opt_sensors] + mock_ap_src_metadata_fetcher.get_board_defaults_from_fw_server.return_value = None + + result = service.get_features("copter", version_info.version_id, "CubeRed") + + assert len(result) == 3 + + def test_get_features_sorted_by_category( + self, service, mock_versions_fetcher, mock_ap_src_metadata_fetcher + ): + """Test that features are sorted by category name.""" + version_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ) + opt_z = Mock() + opt_z.label, opt_z.define, opt_z.category = "FEATURE_Z", "DEFINE_Z", "Sensors" + opt_z.description, opt_z.default, opt_z.dependency = "", 1, None + opt_a = Mock() + opt_a.label, opt_a.define, opt_a.category = "FEATURE_A", "DEFINE_A", "EKF" + opt_a.description, opt_a.default, opt_a.dependency = "", 1, None + opt_m = Mock() + opt_m.label, opt_m.define, opt_m.category = "FEATURE_M", "DEFINE_M", "Logging" + opt_m.description, opt_m.default, opt_m.dependency = "", 1, None + mock_versions_fetcher.get_version_info.return_value = version_info + mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt_z, opt_a, opt_m] + mock_ap_src_metadata_fetcher.get_board_defaults_from_fw_server.return_value = None + + result = service.get_features("copter", version_info.version_id, "CubeRed") + + assert [f.category.name for f in result] == ["EKF", "Logging", "Sensors"] + + def test_get_features_uses_fallback_defaults_when_no_artifacts_url( + self, service, mock_versions_fetcher, mock_ap_src_metadata_fetcher + ): + """Test that build-options-py defaults are used when ap_build_artifacts_url is None.""" + version_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ) + opt_on = Mock() + opt_on.label, opt_on.define, opt_on.category = "FEATURE_ON", "DEFINE_ON", "Cat" + opt_on.description, opt_on.default, opt_on.dependency = "", 1, None + opt_off = Mock() + opt_off.label, opt_off.define, opt_off.category = "FEATURE_OFF", "DEFINE_OFF", "Cat" + opt_off.description, opt_off.default, opt_off.dependency = "", 0, None + mock_versions_fetcher.get_version_info.return_value = version_info + mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt_on, opt_off] + + result = service.get_features("copter", version_info.version_id, "CubeRed") + + by_id = {f.id: f.default for f in result} + assert by_id["FEATURE_ON"].enabled is True + assert by_id["FEATURE_ON"].source == "build-options-py" + assert by_id["FEATURE_OFF"].enabled is False + assert by_id["FEATURE_OFF"].source == "build-options-py" + mock_ap_src_metadata_fetcher.get_board_defaults_from_fw_server.assert_not_called() + + def test_get_features_uses_firmware_server_defaults_when_available( + self, service, mock_versions_fetcher, mock_ap_src_metadata_fetcher + ): + """Test that firmware-server defaults override build-options-py when present.""" + version_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url="https://firmware.ardupilot.org/Copter/stable-4.5.0", + ) + opt_a = Mock() + opt_a.label, opt_a.define, opt_a.category = "FEATURE_A", "DEFINE_A", "Cat" + opt_a.description, opt_a.default, opt_a.dependency = "", 1, None + opt_b = Mock() + opt_b.label, opt_b.define, opt_b.category = "FEATURE_B", "DEFINE_B", "Cat" + opt_b.description, opt_b.default, opt_b.dependency = "", 1, None + mock_versions_fetcher.get_version_info.return_value = version_info + mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt_a, opt_b] + # firmware server says DEFINE_A is disabled, DEFINE_B is enabled + mock_ap_src_metadata_fetcher.get_board_defaults_from_fw_server.return_value = { + "DEFINE_A": 0, + "DEFINE_B": 1, + } + + result = service.get_features("copter", version_info.version_id, "CubeRed") + + by_id = {f.id: f.default for f in result} + assert by_id["FEATURE_A"].enabled is False + assert by_id["FEATURE_A"].source == "firmware-server" + assert by_id["FEATURE_B"].enabled is True + assert by_id["FEATURE_B"].source == "firmware-server" + + def test_get_features_falls_back_to_defaults_when_firmware_server_returns_none( + self, service, mock_versions_fetcher, mock_ap_src_metadata_fetcher + ): + """Test that build-options-py fallback is used when firmware server fetch fails.""" + version_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url="https://firmware.ardupilot.org/Copter/stable-4.5.0", + ) + opt = Mock() + opt.label, opt.define, opt.category = "FEATURE_A", "DEFINE_A", "Cat" + opt.description, opt.default, opt.dependency = "", 1, None + mock_versions_fetcher.get_version_info.return_value = version_info + mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt] + mock_ap_src_metadata_fetcher.get_board_defaults_from_fw_server.return_value = None + + result = service.get_features("copter", version_info.version_id, "CubeRed") + + assert result[0].default.enabled is True + assert result[0].default.source == "build-options-py" + + def test_get_features_firmware_server_overrides_only_known_defines( + self, service, mock_versions_fetcher, mock_ap_src_metadata_fetcher + ): + """Test that a define absent from firmware-server data falls back to build-options-py.""" + version_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url="https://firmware.ardupilot.org/Copter/stable-4.5.0", + ) + opt_known = Mock() + opt_known.label, opt_known.define, opt_known.category = "FEATURE_KNOWN", "DEFINE_KNOWN", "Cat" + opt_known.description, opt_known.default, opt_known.dependency = "", 0, None + opt_unknown = Mock() + opt_unknown.label, opt_unknown.define, opt_unknown.category = "FEATURE_UNKNOWN", "DEFINE_UNKNOWN", "Cat" + opt_unknown.description, opt_unknown.default, opt_unknown.dependency = "", 1, None + mock_versions_fetcher.get_version_info.return_value = version_info + mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt_known, opt_unknown] + # firmware server only knows about DEFINE_KNOWN + mock_ap_src_metadata_fetcher.get_board_defaults_from_fw_server.return_value = { + "DEFINE_KNOWN": 1, + } + + result = service.get_features("copter", version_info.version_id, "CubeRed") + + by_id = {f.id: f.default for f in result} + assert by_id["FEATURE_KNOWN"].enabled is True + assert by_id["FEATURE_KNOWN"].source == "firmware-server" + assert by_id["FEATURE_UNKNOWN"].enabled is True + assert by_id["FEATURE_UNKNOWN"].source == "build-options-py" + + def test_get_features_dependency_none( + self, service, mock_versions_fetcher, mock_ap_src_metadata_fetcher + ): + """Test that a feature with no dependency produces an empty dependencies list.""" + version_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ) + opt = Mock() + opt.label, opt.define, opt.category = "FEATURE_A", "DEFINE_A", "Cat" + opt.description, opt.default, opt.dependency = "", 1, None + mock_versions_fetcher.get_version_info.return_value = version_info + mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt] + mock_ap_src_metadata_fetcher.get_board_defaults_from_fw_server.return_value = None + + result = service.get_features("copter", version_info.version_id, "CubeRed") + + assert result[0].dependencies == [] + + def test_get_features_dependency_single( + self, service, mock_versions_fetcher, mock_ap_src_metadata_fetcher + ): + """Test that a single dependency string is parsed into a one-element list.""" + version_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ) + opt = Mock() + opt.label, opt.define, opt.category = "FEATURE_A", "DEFINE_A", "Cat" + opt.description, opt.default, opt.dependency = "", 1, "DEP_ONE" + mock_versions_fetcher.get_version_info.return_value = version_info + mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt] + mock_ap_src_metadata_fetcher.get_board_defaults_from_fw_server.return_value = None + + result = service.get_features("copter", version_info.version_id, "CubeRed") + + assert result[0].dependencies == ["DEP_ONE"] + + def test_get_features_dependency_multiple_comma_separated( + self, service, mock_versions_fetcher, mock_ap_src_metadata_fetcher + ): + """Test that a comma-separated dependency string is split into multiple entries.""" + version_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ) + opt = Mock() + opt.label, opt.define, opt.category = "FEATURE_A", "DEFINE_A", "Cat" + opt.description, opt.default, opt.dependency = "", 1, "DEP_ONE,DEP_TWO,DEP_THREE" + mock_versions_fetcher.get_version_info.return_value = version_info + mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt] + mock_ap_src_metadata_fetcher.get_board_defaults_from_fw_server.return_value = None + + result = service.get_features("copter", version_info.version_id, "CubeRed") + + assert result[0].dependencies == ["DEP_ONE", "DEP_TWO", "DEP_THREE"] + + def test_get_features_dependency_with_spaces( + self, service, mock_versions_fetcher, mock_ap_src_metadata_fetcher + ): + """Test that spaces around dependency labels are stripped.""" + version_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ) + opt = Mock() + opt.label, opt.define, opt.category = "FEATURE_A", "DEFINE_A", "Cat" + opt.description, opt.default, opt.dependency = "", 1, "DEP_ONE , DEP_TWO , DEP_THREE" + mock_versions_fetcher.get_version_info.return_value = version_info + mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt] + mock_ap_src_metadata_fetcher.get_board_defaults_from_fw_server.return_value = None + + result = service.get_features("copter", version_info.version_id, "CubeRed") + + assert result[0].dependencies == ["DEP_ONE", "DEP_TWO", "DEP_THREE"] + + def test_get_features_ids_filled_correctly( + self, service, mock_versions_fetcher, mock_ap_src_metadata_fetcher + ): + """Test that vehicle_id, version_id, and board_id are correctly set on each feature.""" + version_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ) + opt = Mock() + opt.label, opt.define, opt.category = "FEATURE_A", "DEFINE_A", "Cat" + opt.description, opt.default, opt.dependency = "", 1, None + mock_versions_fetcher.get_version_info.return_value = version_info + mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt] + mock_ap_src_metadata_fetcher.get_board_defaults_from_fw_server.return_value = None + + result = service.get_features("copter", version_info.version_id, "CubeRed") + + assert result[0].vehicle_id == "copter" + assert result[0].version_id == version_info.version_id + assert result[0].board_id == "CubeRed" + + def test_get_features_category_filter_keeps_matching( + self, service, mock_versions_fetcher, mock_ap_src_metadata_fetcher + ): + """Test that category_id filter returns only features whose category matches.""" + version_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ) + opt_logging = Mock() + opt_logging.label, opt_logging.define, opt_logging.category = "HAL_LOGGING_ENABLED", "HAL_LOGGING_ENABLED", "Logging" + opt_logging.description, opt_logging.default, opt_logging.dependency = "", 1, None + opt_ekf = Mock() + opt_ekf.label, opt_ekf.define, opt_ekf.category = "HAL_NAVEKF3_AVAILABLE", "HAL_NAVEKF3_AVAILABLE", "EKF" + opt_ekf.description, opt_ekf.default, opt_ekf.dependency = "", 1, None + opt_sensors = Mock() + opt_sensors.label, opt_sensors.define, opt_sensors.category = "HAL_BEACON_ENABLED", "HAL_BEACON_ENABLED", "Sensors" + opt_sensors.description, opt_sensors.default, opt_sensors.dependency = "", 1, None + mock_versions_fetcher.get_version_info.return_value = version_info + mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt_logging, opt_ekf, opt_sensors] + mock_ap_src_metadata_fetcher.get_board_defaults_from_fw_server.return_value = None + + result = service.get_features("copter", version_info.version_id, "CubeRed", category_id="Logging") + + assert len(result) == 1 + assert result[0].id == "HAL_LOGGING_ENABLED" + assert result[0].category.name == "Logging" + + def test_get_features_category_filter_excludes_non_matching( + self, service, mock_versions_fetcher, mock_ap_src_metadata_fetcher + ): + """Test that category_id filter excludes features whose category does not match.""" + version_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ) + opt_logging = Mock() + opt_logging.label, opt_logging.define, opt_logging.category = "HAL_LOGGING_ENABLED", "HAL_LOGGING_ENABLED", "Logging" + opt_logging.description, opt_logging.default, opt_logging.dependency = "", 1, None + opt_ekf = Mock() + opt_ekf.label, opt_ekf.define, opt_ekf.category = "HAL_NAVEKF3_AVAILABLE", "HAL_NAVEKF3_AVAILABLE", "EKF" + opt_ekf.description, opt_ekf.default, opt_ekf.dependency = "", 1, None + mock_versions_fetcher.get_version_info.return_value = version_info + mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt_logging, opt_ekf] + mock_ap_src_metadata_fetcher.get_board_defaults_from_fw_server.return_value = None + + result = service.get_features("copter", version_info.version_id, "CubeRed", category_id="Sensors") + + assert result == [] + + def test_get_features_category_filter_no_matches_returns_empty( + self, service, mock_versions_fetcher, mock_ap_src_metadata_fetcher + ): + """Test that a category_id with no matching features returns an empty list.""" + version_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ) + opt_a = Mock() + opt_a.label, opt_a.define, opt_a.category = "FEATURE_A", "DEFINE_A", "Logging" + opt_a.description, opt_a.default, opt_a.dependency = "", 1, None + opt_b = Mock() + opt_b.label, opt_b.define, opt_b.category = "FEATURE_B", "DEFINE_B", "Logging" + opt_b.description, opt_b.default, opt_b.dependency = "", 1, None + mock_versions_fetcher.get_version_info.return_value = version_info + mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt_a, opt_b] + mock_ap_src_metadata_fetcher.get_board_defaults_from_fw_server.return_value = None + + result = service.get_features("copter", version_info.version_id, "CubeRed", category_id="NonExistent") + + assert result == [] + + # Tests for get_feature + + def test_get_feature_found( + self, service, mock_versions_fetcher, mock_ap_src_metadata_fetcher + ): + """Test that the correct feature is returned when it exists.""" + version_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ) + opt = Mock() + opt.label, opt.define, opt.category = "HAL_LOGGING_ENABLED", "HAL_LOGGING_ENABLED", "Logging" + opt.description, opt.default, opt.dependency = "", 1, None + mock_versions_fetcher.get_version_info.return_value = version_info + mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt] + mock_ap_src_metadata_fetcher.get_board_defaults_from_fw_server.return_value = None + + result = service.get_feature("copter", version_info.version_id, "CubeRed", "HAL_LOGGING_ENABLED") + + assert result is not None + assert result.id == "HAL_LOGGING_ENABLED" + assert result.name == "HAL_LOGGING_ENABLED" + + def test_get_feature_not_found( + self, service, mock_versions_fetcher, mock_ap_src_metadata_fetcher + ): + """Test that None is returned when the feature does not exist.""" + version_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ) + opt = Mock() + opt.label, opt.define, opt.category = "HAL_LOGGING_ENABLED", "HAL_LOGGING_ENABLED", "Logging" + opt.description, opt.default, opt.dependency = "", 1, None + mock_versions_fetcher.get_version_info.return_value = version_info + mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt] + mock_ap_src_metadata_fetcher.get_board_defaults_from_fw_server.return_value = None + + result = service.get_feature("copter", version_info.version_id, "CubeRed", "NONEXISTENT_FEATURE") + + assert result is None + + def test_get_feature_returns_correct_match_among_many( + self, service, mock_versions_fetcher, mock_ap_src_metadata_fetcher + ): + """Test that only the matching feature is returned when multiple features exist.""" + version_info = VersionInfo( + remote_info=RemoteInfo(name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"), + commit_ref="refs/tags/Copter-4.5.0", + release_type="stable", + version_number="4.5.0", + ap_build_artifacts_url=None, + ) + opt_a = Mock() + opt_a.label, opt_a.define, opt_a.category = "FEATURE_A", "DEFINE_A", "Cat" + opt_a.description, opt_a.default, opt_a.dependency = "", 1, None + opt_b = Mock() + opt_b.label, opt_b.define, opt_b.category = "FEATURE_B", "DEFINE_B", "Cat" + opt_b.description, opt_b.default, opt_b.dependency = "", 0, None + opt_c = Mock() + opt_c.label, opt_c.define, opt_c.category = "FEATURE_C", "DEFINE_C", "Cat" + opt_c.description, opt_c.default, opt_c.dependency = "", 1, None + mock_versions_fetcher.get_version_info.return_value = version_info + mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt_a, opt_b, opt_c] + mock_ap_src_metadata_fetcher.get_board_defaults_from_fw_server.return_value = None + + result = service.get_feature("copter", version_info.version_id, "CubeRed", "FEATURE_B") + + assert result is not None + assert result.id == "FEATURE_B" + assert result.default.enabled is False