test_builds_api.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446
  1. """
  2. Tests for the Builds API endpoints.
  3. """
  4. from contextlib import contextmanager
  5. from unittest.mock import Mock, patch, PropertyMock, MagicMock
  6. from fastapi import status
  7. from web.schemas import (
  8. BuildRequest,
  9. BuildSubmitResponse,
  10. BuildOut,
  11. BuildProgress,
  12. BuildVersionInfo,
  13. RemoteInfo,
  14. )
  15. from web.schemas.vehicles import VehicleBase, BoardBase
  16. class TestBuildsAPI:
  17. """
  18. Tests for all Builds API endpoints.
  19. """
  20. @staticmethod
  21. @contextmanager
  22. def override_builds_service(client, mock_service):
  23. """Temporarily override the get_builds_service dependency."""
  24. from web.services.builds import get_builds_service
  25. client.app.dependency_overrides[get_builds_service] = lambda: mock_service
  26. try:
  27. yield
  28. finally:
  29. client.app.dependency_overrides.pop(get_builds_service, None)
  30. @staticmethod
  31. def dummy_build(build_id="build-abc123"):
  32. return BuildOut(
  33. build_id=build_id,
  34. vehicle=VehicleBase(id="copter", name="Copter"),
  35. board=BoardBase(id="MatekH743", name="MatekH743"),
  36. version=BuildVersionInfo(
  37. id="copter-4.5.0-stable",
  38. remote_info=RemoteInfo(
  39. name="ardupilot",
  40. url="https://github.com/ArduPilot/ardupilot.git",
  41. ),
  42. git_hash="abc123def456",
  43. ),
  44. selected_features=["HAL_LOGGING_ENABLED"],
  45. progress=BuildProgress(percent=0, state="PENDING"),
  46. time_created=1700000000.0,
  47. )
  48. @staticmethod
  49. def dummy_submit_response(build_id="build-abc123"):
  50. return BuildSubmitResponse(
  51. build_id=build_id,
  52. url=f"/api/v1/builds/{build_id}",
  53. status="submitted",
  54. )
  55. @staticmethod
  56. def valid_build_request_body():
  57. return {
  58. "vehicle_id": "copter",
  59. "board_id": "MatekH743",
  60. "version_id": "copter-4.5.0-stable",
  61. "selected_features": ["HAL_LOGGING_ENABLED"],
  62. }
  63. # POST /builds
  64. def test_post_build_returns_201_on_success(self, client):
  65. """Returns 201 Created when the build is submitted successfully."""
  66. mock_service = Mock()
  67. mock_service.create_build.return_value = self.dummy_submit_response()
  68. with self.override_builds_service(client, mock_service):
  69. response = client.post(
  70. "/api/v1/builds", json=self.valid_build_request_body()
  71. )
  72. assert response.status_code == status.HTTP_201_CREATED
  73. def test_post_build_response_schema_has_required_fields(self, client):
  74. """Response body contains 'build_id', 'url', and 'status'."""
  75. mock_service = Mock()
  76. mock_service.create_build.return_value = self.dummy_submit_response()
  77. with self.override_builds_service(client, mock_service):
  78. response = client.post(
  79. "/api/v1/builds", json=self.valid_build_request_body()
  80. )
  81. data = response.json()
  82. assert "build_id" in data
  83. assert "url" in data
  84. assert "status" in data
  85. assert data["status"] == "submitted"
  86. def test_post_build_returns_400_on_value_error(self, client):
  87. """Returns 400 when the service raises a ValueError."""
  88. mock_service = Mock()
  89. error_message = "Invalid version_id for vehicle"
  90. mock_service.create_build.side_effect = ValueError(error_message)
  91. with self.override_builds_service(client, mock_service):
  92. response = client.post(
  93. "/api/v1/builds", json=self.valid_build_request_body()
  94. )
  95. assert response.status_code == status.HTTP_400_BAD_REQUEST
  96. assert error_message in response.json()["detail"]
  97. def test_post_build_returns_422_when_required_field_missing(self, client):
  98. """Returns 422 when a required field is missing from the request body."""
  99. mock_service = Mock()
  100. with self.override_builds_service(client, mock_service):
  101. response = client.post(
  102. "/api/v1/builds",
  103. json={"vehicle_id": "copter", "board_id": "MatekH743"},
  104. )
  105. assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
  106. def test_post_build_passes_request_body_to_service(self, client):
  107. """The parsed BuildRequest is forwarded to the service."""
  108. mock_service = Mock()
  109. mock_service.create_build.return_value = self.dummy_submit_response()
  110. body = self.valid_build_request_body()
  111. with self.override_builds_service(client, mock_service):
  112. client.post("/api/v1/builds", json=body)
  113. called_with: BuildRequest = mock_service.create_build.call_args[0][0]
  114. assert called_with.vehicle_id == body["vehicle_id"]
  115. assert called_with.board_id == body["board_id"]
  116. assert called_with.version_id == body["version_id"]
  117. assert called_with.selected_features == body["selected_features"]
  118. def test_post_build_selected_features_defaults_to_empty_list(self, client):
  119. """When 'selected_features' is omitted, an empty list is sent to the service."""
  120. mock_service = Mock()
  121. mock_service.create_build.return_value = self.dummy_submit_response()
  122. body = {
  123. "vehicle_id": "copter",
  124. "board_id": "MatekH743",
  125. "version_id": "copter-4.5.0-stable",
  126. }
  127. with self.override_builds_service(client, mock_service):
  128. client.post("/api/v1/builds", json=body)
  129. called_with: BuildRequest = mock_service.create_build.call_args[0][0]
  130. assert called_with.selected_features == []
  131. def test_post_build_rate_limit_exceed(self, client):
  132. """The (N+1)th POST /builds request within the window returns 429."""
  133. N = 10 # Rate limit is 10 requests per hour
  134. mock_service = Mock()
  135. mock_service.create_build.return_value = self.dummy_submit_response()
  136. with self.override_builds_service(client, mock_service):
  137. # Patch the request's client IP to simulate multiple requests from the same IP
  138. with patch(
  139. "starlette.requests.Request.client",
  140. new_callable=PropertyMock,
  141. return_value=MagicMock(host="192.0.2.1")
  142. ):
  143. for _ in range(N):
  144. response = client.post(
  145. "/api/v1/builds",
  146. json=self.valid_build_request_body(),
  147. )
  148. assert response.status_code == status.HTTP_201_CREATED
  149. response = client.post(
  150. "/api/v1/builds",
  151. json=self.valid_build_request_body(),
  152. )
  153. assert response.status_code == status.HTTP_429_TOO_MANY_REQUESTS
  154. # A different IP should still be able to make requests successfully
  155. with patch(
  156. "starlette.requests.Request.client",
  157. new_callable=PropertyMock,
  158. return_value=MagicMock(host="192.0.2.2")
  159. ):
  160. response = client.post(
  161. "/api/v1/builds",
  162. json=self.valid_build_request_body(),
  163. )
  164. assert response.status_code == status.HTTP_201_CREATED
  165. def test_builds_endpoint_methods_not_allowed(self, client):
  166. """Only POST and GET are allowed on /builds"""
  167. for method in [client.put, client.patch, client.delete]:
  168. response = method("/api/v1/builds")
  169. assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
  170. # GET /builds
  171. def test_list_builds_returns_200_with_build_list(self, client):
  172. """Returns 200 and a list of builds."""
  173. mock_service = Mock()
  174. mock_service.list_builds.return_value = [
  175. self.dummy_build("build-1"),
  176. self.dummy_build("build-2"),
  177. ]
  178. with self.override_builds_service(client, mock_service):
  179. response = client.get("/api/v1/builds")
  180. assert response.status_code == status.HTTP_200_OK
  181. assert "application/json" in response.headers["content-type"]
  182. assert len(response.json()) == 2
  183. def test_list_builds_returns_200_with_empty_list(self, client):
  184. """Returns 200 with an empty list when no builds exist."""
  185. mock_service = Mock()
  186. mock_service.list_builds.return_value = []
  187. with self.override_builds_service(client, mock_service):
  188. response = client.get("/api/v1/builds")
  189. assert response.status_code == status.HTTP_200_OK
  190. assert response.json() == []
  191. def test_list_builds_response_schema_has_required_fields(self, client):
  192. """Each build in the response has the required schema fields."""
  193. mock_service = Mock()
  194. mock_service.list_builds.return_value = [self.dummy_build()]
  195. with self.override_builds_service(client, mock_service):
  196. response = client.get("/api/v1/builds")
  197. data = response.json()
  198. build = data[0]
  199. for field in [
  200. "build_id", "vehicle", "board", "version",
  201. "selected_features", "progress", "time_created",
  202. ]:
  203. assert field in build
  204. assert "id" in build["vehicle"] and "name" in build["vehicle"]
  205. assert "id" in build["board"] and "name" in build["board"]
  206. assert "id" in build["version"]
  207. assert "percent" in build["progress"] and "state" in build["progress"]
  208. def test_list_builds_no_query_params_passes_defaults_to_service(self, client):
  209. """Without query params, defaults are forwarded to the service."""
  210. mock_service = Mock()
  211. mock_service.list_builds.return_value = []
  212. with self.override_builds_service(client, mock_service):
  213. client.get("/api/v1/builds")
  214. mock_service.list_builds.assert_called_once_with(
  215. vehicle_id=None,
  216. board_id=None,
  217. state=None,
  218. limit=20,
  219. offset=0,
  220. )
  221. def test_list_builds_all_filters_forwarded_to_service(self, client):
  222. """All query params are forwarded together correctly."""
  223. mock_service = Mock()
  224. mock_service.list_builds.return_value = []
  225. with self.override_builds_service(client, mock_service):
  226. client.get(
  227. "/api/v1/builds?vehicle_id=copter&board_id=CubeOrange&state=RUNNING&limit=10&offset=5"
  228. )
  229. mock_service.list_builds.assert_called_once_with(
  230. vehicle_id="copter",
  231. board_id="CubeOrange",
  232. state="RUNNING",
  233. limit=10,
  234. offset=5,
  235. )
  236. def test_list_builds_invalid_limit_returns_422(self, client):
  237. """A limit below the minimum (1) returns 422."""
  238. mock_service = Mock()
  239. with self.override_builds_service(client, mock_service):
  240. response = client.get("/api/v1/builds?limit=0")
  241. assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
  242. def test_list_builds_invalid_offset_returns_422(self, client):
  243. """A negative offset returns 422."""
  244. mock_service = Mock()
  245. with self.override_builds_service(client, mock_service):
  246. response = client.get("/api/v1/builds?offset=-1")
  247. assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
  248. # GET /builds/{build_id}
  249. def test_get_build_returns_200_when_found(self, client):
  250. """Returns 200 when the build exists."""
  251. mock_service = Mock()
  252. mock_service.get_build.return_value = self.dummy_build("build-abc123")
  253. with self.override_builds_service(client, mock_service):
  254. response = client.get("/api/v1/builds/build-abc123")
  255. assert response.status_code == status.HTTP_200_OK
  256. def test_get_build_returns_404_when_not_found(self, client):
  257. """Returns 404 when the service returns None."""
  258. mock_service = Mock()
  259. mock_service.get_build.return_value = None
  260. with self.override_builds_service(client, mock_service):
  261. response = client.get("/api/v1/builds/some-build-id")
  262. assert response.status_code == status.HTTP_404_NOT_FOUND
  263. assert "some-build-id" in response.json()["detail"]
  264. def test_get_build_response_schema_has_required_fields(self, client):
  265. """Response body matches BuildOut schema."""
  266. mock_service = Mock()
  267. mock_service.get_build.return_value = self.dummy_build()
  268. with self.override_builds_service(client, mock_service):
  269. response = client.get("/api/v1/builds/build-abc123")
  270. data = response.json()
  271. for field in [
  272. "build_id", "vehicle", "board", "version",
  273. "selected_features", "progress", "time_created",
  274. ]:
  275. assert field in data
  276. def test_get_build_service_called_with_correct_build_id(self, client):
  277. """The build_id path param is forwarded to the service."""
  278. mock_service = Mock()
  279. mock_service.get_build.return_value = self.dummy_build("build-xyz")
  280. with self.override_builds_service(client, mock_service):
  281. client.get("/api/v1/builds/build-xyz")
  282. mock_service.get_build.assert_called_once_with("build-xyz")
  283. def test_get_build_method_not_allowed(self, client):
  284. """Non-GET methods on /builds/{build_id} return 405."""
  285. for method in [client.post, client.put, client.patch, client.delete]:
  286. response = method("/api/v1/builds/build-abc123")
  287. assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
  288. # GET /builds/{build_id}/logs
  289. def test_get_build_logs_returns_200_when_available(self, client):
  290. """Returns 200 with plain-text logs when available."""
  291. mock_service = Mock()
  292. mock_service.get_build_logs.return_value = "line1\nline2\nline3"
  293. with self.override_builds_service(client, mock_service):
  294. response = client.get("/api/v1/builds/build-abc123/logs")
  295. assert response.status_code == status.HTTP_200_OK
  296. assert "text/plain" in response.headers["content-type"]
  297. def test_get_build_logs_response_is_plain_text(self, client):
  298. """Logs endpoint returns the log content as plain text."""
  299. mock_service = Mock()
  300. mock_service.get_build_logs.return_value = "some log output"
  301. with self.override_builds_service(client, mock_service):
  302. response = client.get("/api/v1/builds/build-abc123/logs")
  303. assert response.text == "some log output"
  304. def test_get_build_logs_returns_404_when_not_available(self, client):
  305. """Returns 404 when logs are not available (service returns None)."""
  306. mock_service = Mock()
  307. mock_service.get_build_logs.return_value = None
  308. with self.override_builds_service(client, mock_service):
  309. response = client.get("/api/v1/builds/some-build-id/logs")
  310. assert response.status_code == status.HTTP_404_NOT_FOUND
  311. assert "some-build-id" in response.json()["detail"]
  312. def test_get_build_logs_tail_query_param_forwarded_to_service(self, client):
  313. """The 'tail' query param is forwarded to the service."""
  314. mock_service = Mock()
  315. mock_service.get_build_logs.return_value = "last 10 lines"
  316. with self.override_builds_service(client, mock_service):
  317. client.get("/api/v1/builds/build-abc123/logs?tail=10")
  318. mock_service.get_build_logs.assert_called_once_with("build-abc123", 10)
  319. def test_get_build_logs_no_tail_passes_none_to_service(self, client):
  320. """When 'tail' is absent, None is passed to the service."""
  321. mock_service = Mock()
  322. mock_service.get_build_logs.return_value = "all logs"
  323. with self.override_builds_service(client, mock_service):
  324. client.get("/api/v1/builds/build-abc123/logs")
  325. mock_service.get_build_logs.assert_called_once_with("build-abc123", None)
  326. def test_get_build_logs_invalid_tail_returns_422(self, client):
  327. """A tail value below the minimum (1) returns 422."""
  328. mock_service = Mock()
  329. with self.override_builds_service(client, mock_service):
  330. response = client.get("/api/v1/builds/build-abc123/logs?tail=0")
  331. assert response.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY
  332. def test_get_build_logs_service_called_with_correct_build_id(self, client):
  333. """The build_id path param is forwarded to the service for logs."""
  334. mock_service = Mock()
  335. mock_service.get_build_logs.return_value = "logs"
  336. with self.override_builds_service(client, mock_service):
  337. client.get("/api/v1/builds/specific-build/logs")
  338. mock_service.get_build_logs.assert_called_once_with("specific-build", None)
  339. def test_get_build_logs_method_not_allowed(self, client):
  340. """Non-GET methods on /builds/{build_id}/logs return 405."""
  341. for method in [client.post, client.put, client.patch, client.delete]:
  342. response = method("/api/v1/builds/build-abc123/logs")
  343. assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED
  344. # GET /builds/{build_id}/artifact
  345. def test_get_artifact_returns_200_when_available(self, client, tmp_path):
  346. """Returns 200 with a file download when the artifact exists."""
  347. artifact = tmp_path / "build-abc123.tar.gz"
  348. artifact.write_bytes(b"fake firmware binary content")
  349. mock_service = Mock()
  350. mock_service.get_artifact_path.return_value = str(artifact)
  351. with self.override_builds_service(client, mock_service):
  352. response = client.get("/api/v1/builds/build-abc123/artifact")
  353. assert response.status_code == status.HTTP_200_OK
  354. def test_get_artifact_returns_404_when_not_available(self, client):
  355. """Returns 404 when the artifact is not available (service returns None)."""
  356. mock_service = Mock()
  357. mock_service.get_artifact_path.return_value = None
  358. with self.override_builds_service(client, mock_service):
  359. response = client.get("/api/v1/builds/some-build-id/artifact")
  360. assert response.status_code == status.HTTP_404_NOT_FOUND
  361. assert "some-build-id" in response.json()["detail"]
  362. def test_get_artifact_service_called_with_correct_build_id(self, client):
  363. """The build_id path param is forwarded to the service for artifact download."""
  364. mock_service = Mock()
  365. mock_service.get_artifact_path.return_value = None
  366. with self.override_builds_service(client, mock_service):
  367. client.get("/api/v1/builds/target-build/artifact")
  368. mock_service.get_artifact_path.assert_called_once_with("target-build")
  369. def test_get_artifact_method_not_allowed(self, client):
  370. """Non-GET methods on /builds/{build_id}/artifact return 405."""
  371. for method in [client.post, client.put, client.patch, client.delete]:
  372. response = method("/api/v1/builds/build-abc123/artifact")
  373. assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED