| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446 |
- """
- 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
|