diff --git a/pytest.ini b/pytest.ini index 4584de7..c69503a 100644 --- a/pytest.ini +++ b/pytest.ini @@ -1,3 +1,5 @@ [pytest] testpaths = tests pythonpath = . +env = + CBS_RATE_LIMITER_STORAGE_URI=memory:// diff --git a/tests/requirements.txt b/tests/requirements.txt index 32d72cd..12ed369 100644 --- a/tests/requirements.txt +++ b/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 diff --git a/tests/web/test_builds_api.py b/tests/web/test_builds_api.py new file mode 100644 index 0000000..cbd7a83 --- /dev/null +++ b/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 diff --git a/tests/web/test_builds_service.py b/tests/web/test_builds_service.py new file mode 100644 index 0000000..b9648db --- /dev/null +++ b/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")