|
@@ -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")
|