Przeglądaj źródła

tests: add vehicles service and api tests

Shiv Tyagi 5 miesięcy temu
rodzic
commit
5deca6ff05
2 zmienionych plików z 1727 dodań i 0 usunięć
  1. 580 0
      tests/web/test_vehicles_api.py
  2. 1147 0
      tests/web/test_vehicles_service.py

+ 580 - 0
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

+ 1147 - 0
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