ソースを参照

tests: add builds api and service test

Shiv Tyagi 5 ヶ月 前
コミット
f4fe1a49fb
3 ファイル変更1225 行追加0 行削除
  1. 1 0
      tests/requirements.txt
  2. 446 0
      tests/web/test_builds_api.py
  3. 778 0
      tests/web/test_builds_service.py

+ 1 - 0
tests/requirements.txt

@@ -3,3 +3,4 @@ pytest-asyncio==0.21.1
 pytest-cov==4.1.0
 httpx==0.25.2
 pytest-mock==3.12.0
+pytest-env==1.6.0

+ 446 - 0
tests/web/test_builds_api.py

@@ -0,0 +1,446 @@
+"""
+Tests for the Builds API endpoints.
+"""
+from contextlib import contextmanager
+from unittest.mock import Mock, patch, PropertyMock, MagicMock
+from fastapi import status
+
+from web.schemas import (
+    BuildRequest,
+    BuildSubmitResponse,
+    BuildOut,
+    BuildProgress,
+    BuildVersionInfo,
+    RemoteInfo,
+)
+from web.schemas.vehicles import VehicleBase, BoardBase
+
+
+class TestBuildsAPI:
+    """
+    Tests for all Builds API endpoints.
+    """
+
+    @staticmethod
+    @contextmanager
+    def override_builds_service(client, mock_service):
+        """Temporarily override the get_builds_service dependency."""
+        from web.services.builds import get_builds_service
+        client.app.dependency_overrides[get_builds_service] = lambda: mock_service
+        try:
+            yield
+        finally:
+            client.app.dependency_overrides.pop(get_builds_service, None)
+
+    @staticmethod
+    def dummy_build(build_id="build-abc123"):
+        return BuildOut(
+            build_id=build_id,
+            vehicle=VehicleBase(id="copter", name="Copter"),
+            board=BoardBase(id="MatekH743", name="MatekH743"),
+            version=BuildVersionInfo(
+                id="copter-4.5.0-stable",
+                remote_info=RemoteInfo(
+                    name="ardupilot",
+                    url="https://github.com/ArduPilot/ardupilot.git",
+                ),
+                git_hash="abc123def456",
+            ),
+            selected_features=["HAL_LOGGING_ENABLED"],
+            progress=BuildProgress(percent=0, state="PENDING"),
+            time_created=1700000000.0,
+        )
+
+    @staticmethod
+    def dummy_submit_response(build_id="build-abc123"):
+        return BuildSubmitResponse(
+            build_id=build_id,
+            url=f"/api/v1/builds/{build_id}",
+            status="submitted",
+        )
+
+    @staticmethod
+    def valid_build_request_body():
+        return {
+            "vehicle_id": "copter",
+            "board_id": "MatekH743",
+            "version_id": "copter-4.5.0-stable",
+            "selected_features": ["HAL_LOGGING_ENABLED"],
+        }
+
+    # POST /builds
+
+    def test_post_build_returns_201_on_success(self, client):
+        """Returns 201 Created when the build is submitted successfully."""
+        mock_service = Mock()
+        mock_service.create_build.return_value = self.dummy_submit_response()
+        with self.override_builds_service(client, mock_service):
+            response = client.post(
+                "/api/v1/builds", json=self.valid_build_request_body()
+            )
+
+        assert response.status_code == status.HTTP_201_CREATED
+
+    def test_post_build_response_schema_has_required_fields(self, client):
+        """Response body contains 'build_id', 'url', and 'status'."""
+        mock_service = Mock()
+        mock_service.create_build.return_value = self.dummy_submit_response()
+        with self.override_builds_service(client, mock_service):
+            response = client.post(
+                "/api/v1/builds", json=self.valid_build_request_body()
+            )
+
+        data = response.json()
+        assert "build_id" in data
+        assert "url" in data
+        assert "status" in data
+        assert data["status"] == "submitted"
+
+    def test_post_build_returns_400_on_value_error(self, client):
+        """Returns 400 when the service raises a ValueError."""
+        mock_service = Mock()
+        error_message = "Invalid version_id for vehicle"
+        mock_service.create_build.side_effect = ValueError(error_message)
+        with self.override_builds_service(client, mock_service):
+            response = client.post(
+                "/api/v1/builds", json=self.valid_build_request_body()
+            )
+
+        assert response.status_code == status.HTTP_400_BAD_REQUEST
+        assert error_message in response.json()["detail"]
+
+    def test_post_build_returns_422_when_required_field_missing(self, client):
+        """Returns 422 when a required field is missing from the request body."""
+        mock_service = Mock()
+        with self.override_builds_service(client, mock_service):
+            response = client.post(
+                "/api/v1/builds",
+                json={"vehicle_id": "copter", "board_id": "MatekH743"},
+            )
+
+        assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
+
+    def test_post_build_passes_request_body_to_service(self, client):
+        """The parsed BuildRequest is forwarded to the service."""
+        mock_service = Mock()
+        mock_service.create_build.return_value = self.dummy_submit_response()
+        body = self.valid_build_request_body()
+        with self.override_builds_service(client, mock_service):
+            client.post("/api/v1/builds", json=body)
+
+        called_with: BuildRequest = mock_service.create_build.call_args[0][0]
+        assert called_with.vehicle_id == body["vehicle_id"]
+        assert called_with.board_id == body["board_id"]
+        assert called_with.version_id == body["version_id"]
+        assert called_with.selected_features == body["selected_features"]
+
+    def test_post_build_selected_features_defaults_to_empty_list(self, client):
+        """When 'selected_features' is omitted, an empty list is sent to the service."""
+        mock_service = Mock()
+        mock_service.create_build.return_value = self.dummy_submit_response()
+        body = {
+            "vehicle_id": "copter",
+            "board_id": "MatekH743",
+            "version_id": "copter-4.5.0-stable",
+        }
+        with self.override_builds_service(client, mock_service):
+            client.post("/api/v1/builds", json=body)
+
+        called_with: BuildRequest = mock_service.create_build.call_args[0][0]
+        assert called_with.selected_features == []
+
+    def test_post_build_rate_limit_exceed(self, client):
+        """The (N+1)th POST /builds request within the window returns 429."""
+        N = 10  # Rate limit is 10 requests per hour
+        mock_service = Mock()
+        mock_service.create_build.return_value = self.dummy_submit_response()
+        with self.override_builds_service(client, mock_service):
+            # Patch the request's client IP to simulate multiple requests from the same IP
+            with patch(
+                "starlette.requests.Request.client",
+                new_callable=PropertyMock,
+                return_value=MagicMock(host="192.0.2.1")
+            ):
+                for _ in range(N):
+                    response = client.post(
+                        "/api/v1/builds",
+                        json=self.valid_build_request_body(),
+                    )
+                    assert response.status_code == status.HTTP_201_CREATED
+
+                response = client.post(
+                    "/api/v1/builds",
+                    json=self.valid_build_request_body(),
+                )
+                assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS
+
+            # A different IP should still be able to make requests successfully
+            with patch(
+                "starlette.requests.Request.client",
+                new_callable=PropertyMock,
+                return_value=MagicMock(host="192.0.2.2")
+            ):
+                response = client.post(
+                    "/api/v1/builds",
+                    json=self.valid_build_request_body(),
+                )
+                assert response.status_code == status.HTTP_201_CREATED
+
+    def test_builds_endpoint_methods_not_allowed(self, client):
+        """Only POST and GET are allowed on /builds"""
+        for method in [client.put, client.patch, client.delete]:
+            response = method("/api/v1/builds")
+            assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
+
+    # GET /builds
+
+    def test_list_builds_returns_200_with_build_list(self, client):
+        """Returns 200 and a list of builds."""
+        mock_service = Mock()
+        mock_service.list_builds.return_value = [
+            self.dummy_build("build-1"),
+            self.dummy_build("build-2"),
+        ]
+        with self.override_builds_service(client, mock_service):
+            response = client.get("/api/v1/builds")
+
+        assert response.status_code == status.HTTP_200_OK
+        assert "application/json" in response.headers["content-type"]
+        assert len(response.json()) == 2
+
+    def test_list_builds_returns_200_with_empty_list(self, client):
+        """Returns 200 with an empty list when no builds exist."""
+        mock_service = Mock()
+        mock_service.list_builds.return_value = []
+        with self.override_builds_service(client, mock_service):
+            response = client.get("/api/v1/builds")
+
+        assert response.status_code == status.HTTP_200_OK
+        assert response.json() == []
+
+    def test_list_builds_response_schema_has_required_fields(self, client):
+        """Each build in the response has the required schema fields."""
+        mock_service = Mock()
+        mock_service.list_builds.return_value = [self.dummy_build()]
+        with self.override_builds_service(client, mock_service):
+            response = client.get("/api/v1/builds")
+
+        data = response.json()
+        build = data[0]
+        for field in [
+            "build_id", "vehicle", "board", "version",
+            "selected_features", "progress", "time_created",
+        ]:
+            assert field in build
+        assert "id" in build["vehicle"] and "name" in build["vehicle"]
+        assert "id" in build["board"] and "name" in build["board"]
+        assert "id" in build["version"]
+        assert "percent" in build["progress"] and "state" in build["progress"]
+
+    def test_list_builds_no_query_params_passes_defaults_to_service(self, client):
+        """Without query params, defaults are forwarded to the service."""
+        mock_service = Mock()
+        mock_service.list_builds.return_value = []
+        with self.override_builds_service(client, mock_service):
+            client.get("/api/v1/builds")
+
+        mock_service.list_builds.assert_called_once_with(
+            vehicle_id=None,
+            board_id=None,
+            state=None,
+            limit=20,
+            offset=0,
+        )
+
+    def test_list_builds_all_filters_forwarded_to_service(self, client):
+        """All query params are forwarded together correctly."""
+        mock_service = Mock()
+        mock_service.list_builds.return_value = []
+        with self.override_builds_service(client, mock_service):
+            client.get(
+                "/api/v1/builds?vehicle_id=copter&board_id=CubeOrange&state=RUNNING&limit=10&offset=5"
+            )
+
+        mock_service.list_builds.assert_called_once_with(
+            vehicle_id="copter",
+            board_id="CubeOrange",
+            state="RUNNING",
+            limit=10,
+            offset=5,
+        )
+
+    def test_list_builds_invalid_limit_returns_422(self, client):
+        """A limit below the minimum (1) returns 422."""
+        mock_service = Mock()
+        with self.override_builds_service(client, mock_service):
+            response = client.get("/api/v1/builds?limit=0")
+
+        assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
+
+    def test_list_builds_invalid_offset_returns_422(self, client):
+        """A negative offset returns 422."""
+        mock_service = Mock()
+        with self.override_builds_service(client, mock_service):
+            response = client.get("/api/v1/builds?offset=-1")
+
+        assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
+
+    # GET /builds/{build_id}
+
+    def test_get_build_returns_200_when_found(self, client):
+        """Returns 200 when the build exists."""
+        mock_service = Mock()
+        mock_service.get_build.return_value = self.dummy_build("build-abc123")
+        with self.override_builds_service(client, mock_service):
+            response = client.get("/api/v1/builds/build-abc123")
+
+        assert response.status_code == status.HTTP_200_OK
+
+    def test_get_build_returns_404_when_not_found(self, client):
+        """Returns 404 when the service returns None."""
+        mock_service = Mock()
+        mock_service.get_build.return_value = None
+        with self.override_builds_service(client, mock_service):
+            response = client.get("/api/v1/builds/some-build-id")
+
+        assert response.status_code == status.HTTP_404_NOT_FOUND
+        assert "some-build-id" in response.json()["detail"]
+
+    def test_get_build_response_schema_has_required_fields(self, client):
+        """Response body matches BuildOut schema."""
+        mock_service = Mock()
+        mock_service.get_build.return_value = self.dummy_build()
+        with self.override_builds_service(client, mock_service):
+            response = client.get("/api/v1/builds/build-abc123")
+
+        data = response.json()
+        for field in [
+            "build_id", "vehicle", "board", "version",
+            "selected_features", "progress", "time_created",
+        ]:
+            assert field in data
+
+    def test_get_build_service_called_with_correct_build_id(self, client):
+        """The build_id path param is forwarded to the service."""
+        mock_service = Mock()
+        mock_service.get_build.return_value = self.dummy_build("build-xyz")
+        with self.override_builds_service(client, mock_service):
+            client.get("/api/v1/builds/build-xyz")
+
+        mock_service.get_build.assert_called_once_with("build-xyz")
+
+    def test_get_build_method_not_allowed(self, client):
+        """Non-GET methods on /builds/{build_id} return 405."""
+        for method in [client.post, client.put, client.patch, client.delete]:
+            response = method("/api/v1/builds/build-abc123")
+            assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
+
+    # GET /builds/{build_id}/logs
+
+    def test_get_build_logs_returns_200_when_available(self, client):
+        """Returns 200 with plain-text logs when available."""
+        mock_service = Mock()
+        mock_service.get_build_logs.return_value = "line1\nline2\nline3"
+        with self.override_builds_service(client, mock_service):
+            response = client.get("/api/v1/builds/build-abc123/logs")
+
+        assert response.status_code == status.HTTP_200_OK
+        assert "text/plain" in response.headers["content-type"]
+
+    def test_get_build_logs_response_is_plain_text(self, client):
+        """Logs endpoint returns the log content as plain text."""
+        mock_service = Mock()
+        mock_service.get_build_logs.return_value = "some log output"
+        with self.override_builds_service(client, mock_service):
+            response = client.get("/api/v1/builds/build-abc123/logs")
+
+        assert response.text == "some log output"
+
+    def test_get_build_logs_returns_404_when_not_available(self, client):
+        """Returns 404 when logs are not available (service returns None)."""
+        mock_service = Mock()
+        mock_service.get_build_logs.return_value = None
+        with self.override_builds_service(client, mock_service):
+            response = client.get("/api/v1/builds/some-build-id/logs")
+
+        assert response.status_code == status.HTTP_404_NOT_FOUND
+        assert "some-build-id" in response.json()["detail"]
+
+    def test_get_build_logs_tail_query_param_forwarded_to_service(self, client):
+        """The 'tail' query param is forwarded to the service."""
+        mock_service = Mock()
+        mock_service.get_build_logs.return_value = "last 10 lines"
+        with self.override_builds_service(client, mock_service):
+            client.get("/api/v1/builds/build-abc123/logs?tail=10")
+
+        mock_service.get_build_logs.assert_called_once_with("build-abc123", 10)
+
+    def test_get_build_logs_no_tail_passes_none_to_service(self, client):
+        """When 'tail' is absent, None is passed to the service."""
+        mock_service = Mock()
+        mock_service.get_build_logs.return_value = "all logs"
+        with self.override_builds_service(client, mock_service):
+            client.get("/api/v1/builds/build-abc123/logs")
+
+        mock_service.get_build_logs.assert_called_once_with("build-abc123", None)
+
+    def test_get_build_logs_invalid_tail_returns_422(self, client):
+        """A tail value below the minimum (1) returns 422."""
+        mock_service = Mock()
+        with self.override_builds_service(client, mock_service):
+            response = client.get("/api/v1/builds/build-abc123/logs?tail=0")
+
+        assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
+
+    def test_get_build_logs_service_called_with_correct_build_id(self, client):
+        """The build_id path param is forwarded to the service for logs."""
+        mock_service = Mock()
+        mock_service.get_build_logs.return_value = "logs"
+        with self.override_builds_service(client, mock_service):
+            client.get("/api/v1/builds/specific-build/logs")
+
+        mock_service.get_build_logs.assert_called_once_with("specific-build", None)
+
+    def test_get_build_logs_method_not_allowed(self, client):
+        """Non-GET methods on /builds/{build_id}/logs return 405."""
+        for method in [client.post, client.put, client.patch, client.delete]:
+            response = method("/api/v1/builds/build-abc123/logs")
+            assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
+
+    # GET /builds/{build_id}/artifact
+
+    def test_get_artifact_returns_200_when_available(self, client, tmp_path):
+        """Returns 200 with a file download when the artifact exists."""
+        artifact = tmp_path / "build-abc123.tar.gz"
+        artifact.write_bytes(b"fake firmware binary content")
+        mock_service = Mock()
+        mock_service.get_artifact_path.return_value = str(artifact)
+        with self.override_builds_service(client, mock_service):
+            response = client.get("/api/v1/builds/build-abc123/artifact")
+
+        assert response.status_code == status.HTTP_200_OK
+
+    def test_get_artifact_returns_404_when_not_available(self, client):
+        """Returns 404 when the artifact is not available (service returns None)."""
+        mock_service = Mock()
+        mock_service.get_artifact_path.return_value = None
+        with self.override_builds_service(client, mock_service):
+            response = client.get("/api/v1/builds/some-build-id/artifact")
+
+        assert response.status_code == status.HTTP_404_NOT_FOUND
+        assert "some-build-id" in response.json()["detail"]
+
+    def test_get_artifact_service_called_with_correct_build_id(self, client):
+        """The build_id path param is forwarded to the service for artifact download."""
+        mock_service = Mock()
+        mock_service.get_artifact_path.return_value = None
+        with self.override_builds_service(client, mock_service):
+            client.get("/api/v1/builds/target-build/artifact")
+
+        mock_service.get_artifact_path.assert_called_once_with("target-build")
+
+    def test_get_artifact_method_not_allowed(self, client):
+        """Non-GET methods on /builds/{build_id}/artifact return 405."""
+        for method in [client.post, client.put, client.patch, client.delete]:
+            response = method("/api/v1/builds/build-abc123/artifact")
+            assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED

+ 778 - 0
tests/web/test_builds_service.py

@@ -0,0 +1,778 @@
+"""
+Tests for the Builds Service.
+"""
+import time
+import pytest
+from unittest.mock import Mock, MagicMock
+
+import build_manager as bm
+from metadata_manager import RemoteInfo as ManagerRemoteInfo
+from metadata_manager.versions_fetcher import RemoteInfo, VersionInfo
+from web.schemas import BuildRequest
+from web.services.builds import BuildsService
+
+
+@pytest.fixture
+def service(
+    mock_build_manager,
+    mock_versions_fetcher,
+    mock_ap_src_metadata_fetcher,
+    mock_git_repo,
+    mock_vehicles_manager,
+):
+    """Create a BuildsService instance with mocked dependencies."""
+    mock_versions_fetcher.get_version_info.return_value = make_version_info()
+    mock_versions_fetcher.get_remote_info.return_value = RemoteInfo(
+        name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"
+    )
+    mock_ap_src_metadata_fetcher.get_boards.return_value = ["MatekH743", "CubeOrange"]
+    mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = []
+    mock_git_repo.commit_id_for_remote_ref.return_value = "abc123def456"
+    mock_git_repo.get_checkout_lock.return_value = MagicMock()
+    mock_build_manager.submit_build.return_value = "new-build-id"
+    mock_copter, mock_plane, mock_rover = Mock(), Mock(), Mock()
+    mock_copter.name, mock_plane.name, mock_rover.name = "Copter", "Plane", "Rover"
+    mock_copter.id, mock_plane.id, mock_rover.id = "copter", "plane", "rover"
+    vehicles = {"copter": mock_copter, "plane": mock_plane, "rover": mock_rover}
+    mock_vehicles_manager.get_vehicle_by_id = Mock(
+        side_effect=lambda vid: vehicles.get(vid)
+    )
+    mock_vehicles_manager.get_vehicle_names = Mock(return_value=[v.name for v in vehicles.values()])
+
+    return BuildsService(
+        build_manager=mock_build_manager,
+        versions_fetcher=mock_versions_fetcher,
+        ap_src_metadata_fetcher=mock_ap_src_metadata_fetcher,
+        repo=mock_git_repo,
+        vehicles_manager=mock_vehicles_manager,
+    )
+
+
+def make_version_info(
+    remote_name="ardupilot",
+    remote_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,
+):
+    return VersionInfo(
+        remote_info=RemoteInfo(name=remote_name, url=remote_url),
+        commit_ref=commit_ref,
+        release_type=release_type,
+        version_number=version_number,
+        ap_build_artifacts_url=ap_build_artifacts_url,
+    )
+
+
+def make_build_info(
+    vehicle_id="copter",
+    version_id="copter-4.5.0-stable",
+    remote_name="ardupilot",
+    remote_url="https://github.com/ArduPilot/ardupilot.git",
+    git_hash="abc123def456",
+    board="MatekH743",
+    selected_features=None,
+    state=bm.BuildState.PENDING,
+    percent=0,
+):
+    info = bm.BuildInfo(
+        vehicle_id=vehicle_id,
+        version_id=version_id,
+        remote_info=ManagerRemoteInfo(name=remote_name, url=remote_url),
+        git_hash=git_hash,
+        board=board,
+        selected_features=selected_features or set(),
+    )
+    info.progress = bm.BuildProgress(state=state, percent=percent)
+    return info
+
+
+class TestBuildsService:
+    """Test suite for BuildsService."""
+
+    @staticmethod
+    def setup_builds(
+        mock_build_manager,
+        build_infos,
+    ):
+        """Populate mock build_manager with the given BuildInfo objects."""
+        ids = [f"build-{i}" for i in range(len(build_infos))]
+        mock_build_manager.get_all_build_ids.return_value = ids
+        mock_build_manager.get_build_info.side_effect = lambda bid: (
+            build_infos[ids.index(bid)]
+        )
+
+    # Tests for create_build
+
+    def test_create_build_returns_submit_response(
+        self,
+        service,
+    ):
+        """A valid request returns a BuildSubmitResponse with build_id and url."""
+        request = BuildRequest(
+            vehicle_id="copter",
+            board_id="MatekH743",
+            version_id="copter-4.5.0-stable",
+            selected_features=[],
+        )
+
+        result = service.create_build(request)
+
+        assert result.build_id == "new-build-id"
+        assert result.url == "/api/v1/builds/new-build-id"
+        assert result.status == "submitted"
+
+    def test_create_build_calls_submit_build_once(
+        self,
+        service,
+        mock_build_manager,
+    ):
+        """submit_build is called exactly once per create_build invocation."""
+        request = BuildRequest(
+            vehicle_id="copter",
+            board_id="MatekH743",
+            version_id="copter-4.5.0-stable",
+            selected_features=[],
+        )
+
+        service.create_build(request)
+
+        mock_build_manager.submit_build.assert_called_once()
+
+    def test_create_build_raises_value_error_for_missing_version_id(self, service):
+        """ValueError is raised when version_id is an empty string."""
+        request = BuildRequest(
+            vehicle_id="copter",
+            board_id="MatekH743",
+            version_id="",
+            selected_features=[],
+        )
+
+        with pytest.raises(ValueError, match="version_id is required"):
+            service.create_build(request)
+
+    def test_create_build_raises_value_error_for_missing_vehicle_id(self, service):
+        """ValueError is raised when vehicle_id is an empty string."""
+        request = BuildRequest(
+            vehicle_id="",
+            board_id="MatekH743",
+            version_id="copter-4.5.0-stable",
+            selected_features=[],
+        )
+
+        with pytest.raises(ValueError, match="vehicle_id is required"):
+            service.create_build(request)
+
+    def test_create_build_raises_value_error_for_missing_board_id(
+        self, service
+    ):
+        """ValueError is raised when board_id is an empty string."""
+        request = BuildRequest(
+            vehicle_id="copter",
+            board_id="",
+            version_id="copter-4.5.0-stable",
+            selected_features=[],
+        )
+
+        with pytest.raises(ValueError, match="board_id is required"):
+            service.create_build(request)
+
+    def test_create_build_raises_value_error_for_invalid_version(
+        self, service, mock_versions_fetcher
+    ):
+        """ValueError is raised when the version_id is not found."""
+        mock_versions_fetcher.get_version_info.return_value = None
+        request = BuildRequest(
+            vehicle_id="copter",
+            board_id="MatekH743",
+            version_id="nonexistent-version",
+            selected_features=[],
+        )
+
+        with pytest.raises(ValueError, match="Invalid version_id for vehicle"):
+            service.create_build(request)
+
+    def test_create_build_queries_version_info_with_correct_params(
+        self, service, mock_versions_fetcher
+    ):
+        """get_version_info is called with the correct vehicle_id and version_id."""
+        mock_versions_fetcher.get_version_info.return_value = None
+        request = BuildRequest(
+            vehicle_id="plane",
+            board_id="CubeOrange",
+            version_id="plane-4.4.0-stable",
+            selected_features=[],
+        )
+
+        with pytest.raises(ValueError):
+            service.create_build(request)
+
+        mock_versions_fetcher.get_version_info.assert_called_once_with(
+            vehicle_id="plane",
+            version_id="plane-4.4.0-stable",
+        )
+
+    def test_create_build_raises_value_error_when_remote_not_found(
+        self, service, mock_versions_fetcher
+    ):
+        """ValueError is raised when the remote is not found."""
+        mock_versions_fetcher.get_remote_info.return_value = None
+        request = BuildRequest(
+            vehicle_id="some-vehicle",
+            board_id="some-board",
+            version_id="some-version",
+            selected_features=[],
+        )
+
+        with pytest.raises(ValueError, match="not whitelisted"):
+            service.create_build(request)
+
+    def test_create_build_raises_value_error_when_board_not_in_version(
+        self,
+        service,
+        mock_ap_src_metadata_fetcher,
+    ):
+        """ValueError is raised when the board is not available for the version."""
+        mock_ap_src_metadata_fetcher.get_boards.return_value = ["CubeOrange"]
+        request = BuildRequest(
+            vehicle_id="copter",
+            board_id="some-nonexistent-board",
+            version_id="copter-4.5.0-stable",
+            selected_features=[],
+        )
+
+        with pytest.raises(ValueError, match="Invalid board for this version"):
+            service.create_build(request)
+
+    def test_create_build_maps_feature_labels_to_defines(
+        self,
+        service,
+        mock_ap_src_metadata_fetcher,
+        mock_build_manager,
+    ):
+        """Selected feature labels are translated to defines before build submission."""
+        opt = Mock()
+        opt.label = "HAL_LOGGING_ENABLED"
+        opt.define = "HAL_LOGGING_ENABLED_DEFINE"
+        mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt]
+        request = BuildRequest(
+            vehicle_id="copter",
+            board_id="MatekH743",
+            version_id="copter-4.5.0-stable",
+            selected_features=["HAL_LOGGING_ENABLED"],
+        )
+
+        service.create_build(request)
+
+        submitted: bm.BuildInfo = mock_build_manager.submit_build.call_args[1]["build_info"]
+        assert "HAL_LOGGING_ENABLED_DEFINE" in submitted.selected_features
+
+    def test_create_build_ignores_unknown_feature_labels(
+        self,
+        service,
+        mock_ap_src_metadata_fetcher,
+        mock_build_manager,
+    ):
+        """Unknown feature labels are silently skipped (not added to defines set)."""
+        opt = Mock()
+        opt.label = "HAL_LOGGING_ENABLED"
+        opt.define = "HAL_LOGGING_ENABLED_DEFINE"
+        mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt]
+        request = BuildRequest(
+            vehicle_id="copter",
+            board_id="MatekH743",
+            version_id="copter-4.5.0-stable",
+            selected_features=["COMPLETELY_UNKNOWN_FEATURE"],
+        )
+
+        service.create_build(request)
+
+        submitted: bm.BuildInfo = mock_build_manager.submit_build.call_args[1]["build_info"]
+        assert len(submitted.selected_features) == 0
+
+    def test_create_build_no_features_submits_empty_set(
+        self,
+        service,
+        mock_build_manager,
+    ):
+        """When selected_features is empty, build is submitted with an empty set."""
+        request = BuildRequest(
+            vehicle_id="copter",
+            board_id="MatekH743",
+            version_id="copter-4.5.0-stable",
+            selected_features=[],
+        )
+
+        service.create_build(request)
+
+        submitted: bm.BuildInfo = mock_build_manager.submit_build.call_args[1]["build_info"]
+        assert len(submitted.selected_features) == 0
+
+    # Tests for list_builds
+
+    def test_list_builds_returns_all_when_no_filters(
+        self,
+        service,
+        mock_build_manager,
+    ):
+        """Returns all builds when no filters are applied."""
+        self.setup_builds(
+            mock_build_manager,
+            [
+                make_build_info(vehicle_id="copter", board="MatekH743"),
+                make_build_info(vehicle_id="plane", board="CubeOrange"),
+            ],
+        )
+
+        result = service.list_builds()
+
+        assert len(result) == 2
+
+    def test_list_builds_returns_empty_when_no_builds(
+        self, service, mock_build_manager
+    ):
+        """Returns an empty list when there are no builds."""
+        mock_build_manager.get_all_build_ids.return_value = []
+
+        result = service.list_builds()
+
+        assert result == []
+
+    def test_list_builds_vehicle_id_filter_keeps_matching(
+        self,
+        service,
+        mock_build_manager,
+    ):
+        """vehicle_id filter returns only builds for that vehicle."""
+        self.setup_builds(
+            mock_build_manager,
+            [
+                make_build_info(vehicle_id="copter"),
+                make_build_info(vehicle_id="plane"),
+                make_build_info(vehicle_id="copter"),
+            ],
+        )
+
+        result = service.list_builds(vehicle_id="copter")
+
+        assert len(result) == 2
+        assert all(b.vehicle.id == "copter" for b in result)
+
+    def test_list_builds_board_id_filter_keeps_matching(
+        self,
+        service,
+        mock_build_manager,
+    ):
+        """board_id filter returns only builds for that board."""
+        self.setup_builds(
+            mock_build_manager,
+            [
+                make_build_info(board="MatekH743"),
+                make_build_info(board="CubeOrange"),
+                make_build_info(board="MatekH743"),
+            ],
+        )
+
+        result = service.list_builds(board_id="MatekH743")
+
+        assert len(result) == 2
+        assert all(b.board.id == "MatekH743" for b in result)
+
+    def test_list_builds_state_filter_keeps_matching(
+        self,
+        service,
+        mock_build_manager,
+    ):
+        """state filter returns only builds in that state."""
+        self.setup_builds(
+            mock_build_manager,
+            [
+                make_build_info(state=bm.BuildState.PENDING),
+                make_build_info(state=bm.BuildState.SUCCESS),
+                make_build_info(state=bm.BuildState.PENDING),
+            ],
+        )
+
+        result = service.list_builds(state="PENDING")
+
+        assert len(result) == 2
+        assert all(b.progress.state == "PENDING" for b in result)
+
+    def test_list_builds_state_filter_excludes_non_matching(
+        self,
+        service,
+        mock_build_manager,
+    ):
+        """state filter excludes builds not in that state."""
+        self.setup_builds(
+            mock_build_manager,
+            [
+                make_build_info(state=bm.BuildState.FAILURE),
+                make_build_info(state=bm.BuildState.RUNNING),
+            ],
+        )
+
+        result = service.list_builds(state="SUCCESS")
+
+        assert result == []
+
+    def test_list_builds_pagination_limit(
+        self,
+        service,
+        mock_build_manager,
+    ):
+        """limit restricts the number of results returned."""
+        self.setup_builds(
+            mock_build_manager,
+            [make_build_info() for _ in range(5)],
+        )
+
+        result = service.list_builds(limit=3)
+
+        assert len(result) == 3
+
+    def test_list_builds_pagination_offset(
+        self,
+        service,
+        mock_build_manager,
+    ):
+        """offset skips the given number of results."""
+        self.setup_builds(
+            mock_build_manager,
+            [make_build_info() for _ in range(5)],
+        )
+
+        result_all = service.list_builds(limit=5, offset=0)
+        result_offset = service.list_builds(limit=5, offset=3)
+
+        assert len(result_offset) == 2
+        assert result_offset[0].build_id == result_all[3].build_id
+
+    def test_list_builds_sorted_newest_first(
+        self,
+        service,
+        mock_build_manager,
+    ):
+        """Builds are returned sorted by creation time, newest first."""
+        now = time.time()
+        old = make_build_info()
+        old.time_created = now - 1000
+        new = make_build_info()
+        new.time_created = now
+
+        ids = ["build-old", "build-new"]
+        mock_build_manager.get_all_build_ids.return_value = ids
+        mock_build_manager.get_build_info.side_effect = lambda bid: (
+            old if bid == "build-old" else new
+        )
+
+        result = service.list_builds()
+
+        assert result[0].time_created >= result[1].time_created
+
+    def test_list_builds_skips_missing_build_info(
+        self, service, mock_build_manager
+    ):
+        """Builds whose info cannot be retrieved are silently skipped."""
+        mock_build_manager.get_all_build_ids.return_value = ["b1", "b2"]
+        mock_build_manager.get_build_info.return_value = None
+
+        result = service.list_builds()
+
+        assert result == []
+
+    # Tests for get_build
+
+    def test_get_build_returns_build_out_when_found(
+        self,
+        service,
+        mock_build_manager,
+    ):
+        """Returns a BuildOut when the build exists."""
+        mock_build_manager.build_exists.return_value = True
+        mock_build_manager.get_build_info.return_value = make_build_info()
+
+        result = service.get_build("build-abc123")
+
+        assert result is not None
+        assert result.build_id == "build-abc123"
+
+    def test_get_build_returns_none_when_not_found(
+        self, service, mock_build_manager
+    ):
+        """Returns None when the build does not exist."""
+        mock_build_manager.build_exists.return_value = False
+
+        result = service.get_build("nonexistent-build")
+
+        assert result is None
+
+    def test_get_build_returns_none_when_info_unavailable(
+        self, service, mock_build_manager
+    ):
+        """Returns None when build_exists is True but get_build_info returns None."""
+        mock_build_manager.build_exists.return_value = True
+        mock_build_manager.get_build_info.return_value = None
+
+        result = service.get_build("build-abc123")
+
+        assert result is None
+
+    def test_get_build_checks_existence_with_correct_id(
+        self, service, mock_build_manager
+    ):
+        """build_exists is called with the provided build_id."""
+        mock_build_manager.build_exists.return_value = False
+
+        service.get_build("specific-build-id")
+
+        mock_build_manager.build_exists.assert_called_once_with("specific-build-id")
+
+    def test_get_build_output_has_correct_vehicle_and_board(
+        self,
+        service,
+        mock_build_manager,
+        mock_vehicles_manager,
+    ):
+        """The returned BuildOut contains correct vehicle and board information."""
+        mock_build_manager.build_exists.return_value = True
+        mock_build_manager.get_build_info.return_value = make_build_info(
+            vehicle_id="plane", board="CubeOrange"
+        )
+        mock_vehicle = Mock()
+        mock_vehicle.name = "Plane"
+        mock_vehicles_manager.get_vehicle_by_id.return_value = mock_vehicle
+
+        result = service.get_build("build-xyz")
+
+        assert result.vehicle.id == "plane"
+        assert result.board.id == "CubeOrange"
+
+    def test_get_build_maps_feature_defines_to_labels(
+        self,
+        service,
+        mock_build_manager,
+        mock_ap_src_metadata_fetcher,
+    ):
+        """Feature defines in BuildInfo are mapped back to labels in the output."""
+        mock_build_manager.build_exists.return_value = True
+        mock_build_manager.get_build_info.return_value = make_build_info(
+            selected_features={"HAL_LOGGING_ENABLED_DEFINE"}
+        )
+        opt = Mock()
+        opt.define = "HAL_LOGGING_ENABLED_DEFINE"
+        opt.label = "HAL_LOGGING_ENABLED"
+        mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt]
+
+        result = service.get_build("build-abc123")
+
+        assert "HAL_LOGGING_ENABLED" in result.selected_features
+
+    def test_get_build_falls_back_to_define_when_label_not_found(
+        self,
+        service,
+        mock_build_manager,
+    ):
+        """When a define has no matching label, the define itself is used as fallback."""
+        mock_build_manager.build_exists.return_value = True
+        mock_build_manager.get_build_info.return_value = make_build_info(
+            selected_features={"ORPHANED_DEFINE"}
+        )
+
+        result = service.get_build("build-abc123")
+
+        assert "ORPHANED_DEFINE" in result.selected_features
+
+    def test_get_build_no_selected_features_returns_empty_list(
+        self,
+        service,
+        mock_build_manager,
+    ):
+        """When a build has no selected features, the output list is empty."""
+        mock_build_manager.build_exists.return_value = True
+        mock_build_manager.get_build_info.return_value = make_build_info(
+            selected_features=set()
+        )
+
+        result = service.get_build("build-abc123")
+
+        assert result.selected_features == []
+
+    # Tests for get_build_logs
+
+    def test_get_build_logs_returns_none_when_build_not_found(
+        self, service, mock_build_manager
+    ):
+        """Returns None when the build does not exist."""
+        mock_build_manager.build_exists.return_value = False
+
+        result = service.get_build_logs("nonexistent-build")
+
+        assert result is None
+
+    def test_get_build_logs_returns_none_when_log_file_missing(
+        self, service, mock_build_manager
+    ):
+        """Returns None when the log file does not exist on disk."""
+        mock_build_manager.build_exists.return_value = True
+        mock_build_manager.get_build_log_path.return_value = "/nonexistent/path/build.log"
+
+        result = service.get_build_logs("build-abc123")
+
+        assert result is None
+
+    def test_get_build_logs_returns_full_content_when_tail_is_none(
+        self, service, mock_build_manager, tmp_path
+    ):
+        """Returns the full log content when tail is None."""
+        log_file = tmp_path / "build.log"
+        log_content = "line1\nline2\nline3\n"
+        log_file.write_text(log_content)
+        mock_build_manager.build_exists.return_value = True
+        mock_build_manager.get_build_log_path.return_value = str(log_file)
+
+        result = service.get_build_logs("build-abc123", tail=None)
+
+        assert result == log_content
+
+    def test_get_build_logs_returns_last_n_lines_when_tail_given(
+        self, service, mock_build_manager, tmp_path
+    ):
+        """Returns only the last N lines when tail is specified."""
+        log_file = tmp_path / "build.log"
+        log_file.write_text("line1\nline2\nline3\nline4\nline5\n")
+        mock_build_manager.build_exists.return_value = True
+        mock_build_manager.get_build_log_path.return_value = str(log_file)
+
+        result = service.get_build_logs("build-abc123", tail=2)
+
+        assert result == "line4\nline5\n"
+
+    def test_get_build_logs_checks_existence_with_correct_build_id(
+        self, service, mock_build_manager
+    ):
+        """build_exists is called with the provided build_id."""
+        mock_build_manager.build_exists.return_value = False
+
+        service.get_build_logs("target-build-id")
+
+        mock_build_manager.build_exists.assert_called_once_with("target-build-id")
+
+    def test_get_build_logs_retrieves_path_with_correct_build_id(
+        self, service, mock_build_manager, tmp_path
+    ):
+        """get_build_log_path is called with the correct build_id."""
+        log_file = tmp_path / "build.log"
+        log_file.write_text("")
+        mock_build_manager.build_exists.return_value = True
+        mock_build_manager.get_build_log_path.return_value = str(log_file)
+
+        service.get_build_logs("specific-build-id")
+
+        mock_build_manager.get_build_log_path.assert_called_once_with("specific-build-id")
+
+    # Tests for get_artifact_path
+
+    def test_get_artifact_path_returns_none_when_build_not_found(
+        self, service, mock_build_manager
+    ):
+        """Returns None when the build does not exist."""
+        mock_build_manager.build_exists.return_value = False
+
+        result = service.get_artifact_path("nonexistent-build")
+
+        assert result is None
+
+    def test_get_artifact_path_returns_none_when_build_info_unavailable(
+        self, service, mock_build_manager
+    ):
+        """Returns None when build exists but its info cannot be retrieved."""
+        mock_build_manager.build_exists.return_value = True
+        mock_build_manager.get_build_info.return_value = None
+
+        result = service.get_artifact_path("build-abc123")
+
+        assert result is None
+
+    def test_get_artifact_path_returns_none_when_build_pending(
+        self, service, mock_build_manager
+    ):
+        """Returns None when the build is in PENDING state."""
+        mock_build_manager.build_exists.return_value = True
+        mock_build_manager.get_build_info.return_value = make_build_info(
+            state=bm.BuildState.PENDING
+        )
+
+        result = service.get_artifact_path("build-abc123")
+
+        assert result is None
+
+    def test_get_artifact_path_returns_none_when_build_running(
+        self, service, mock_build_manager
+    ):
+        """Returns None when the build is still RUNNING."""
+        mock_build_manager.build_exists.return_value = True
+        mock_build_manager.get_build_info.return_value = make_build_info(
+            state=bm.BuildState.RUNNING
+        )
+
+        result = service.get_artifact_path("build-abc123")
+
+        assert result is None
+
+    def test_get_artifact_path_returns_path_when_artifact_exists(
+        self, service, mock_build_manager, tmp_path
+    ):
+        """Returns the artifact path when the build succeeded and file exists."""
+        artifact = tmp_path / "artifact.tar.gz"
+        artifact.write_bytes(b"firmware")
+        mock_build_manager.build_exists.return_value = True
+        mock_build_manager.get_build_info.return_value = make_build_info(
+            state=bm.BuildState.SUCCESS
+        )
+        mock_build_manager.get_build_archive_path.return_value = str(artifact)
+
+        result = service.get_artifact_path("build-abc123")
+
+        assert result == str(artifact)
+
+    def test_get_artifact_path_returns_none_when_artifact_file_missing(
+        self, service, mock_build_manager
+    ):
+        """Returns None when the build succeeded but the artifact file is absent."""
+        mock_build_manager.build_exists.return_value = True
+        mock_build_manager.get_build_info.return_value = make_build_info(
+            state=bm.BuildState.SUCCESS
+        )
+        mock_build_manager.get_build_archive_path.return_value = "/does/not/exist.tar.gz"
+
+        result = service.get_artifact_path("build-abc123")
+
+        assert result is None
+
+    def test_get_artifact_path_available_for_failed_build_if_file_exists(
+        self, service, mock_build_manager, tmp_path
+    ):
+        """Artifact path is returned for FAILURE state if the file happens to exist."""
+        artifact = tmp_path / "artifact.tar.gz"
+        artifact.write_bytes(b"partial firmware")
+        mock_build_manager.build_exists.return_value = True
+        mock_build_manager.get_build_info.return_value = make_build_info(
+            state=bm.BuildState.FAILURE
+        )
+        mock_build_manager.get_build_archive_path.return_value = str(artifact)
+
+        result = service.get_artifact_path("build-abc123")
+
+        assert result == str(artifact)
+
+    def test_get_artifact_path_uses_correct_build_id(
+        self, service, mock_build_manager
+    ):
+        """build_exists is called with the correct build_id."""
+        mock_build_manager.build_exists.return_value = False
+
+        service.get_artifact_path("my-target-build")
+
+        mock_build_manager.build_exists.assert_called_once_with("my-target-build")