test_builds_service.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777
  1. """
  2. Tests for the Builds Service.
  3. """
  4. import time
  5. import pytest
  6. from unittest.mock import Mock, MagicMock
  7. import build_manager as bm
  8. from metadata_manager import RemoteInfo as ManagerRemoteInfo
  9. from metadata_manager import RemoteInfo, VersionInfo
  10. from web.schemas import BuildRequest
  11. from web.services.builds import BuildsService
  12. @pytest.fixture
  13. def service(
  14. mock_build_manager,
  15. mock_versions_manager,
  16. mock_ap_src_metadata_fetcher,
  17. mock_git_repo,
  18. mock_vehicles_manager,
  19. ):
  20. """Create a BuildsService instance with mocked dependencies."""
  21. mock_versions_manager.get_version_info.return_value = make_version_info()
  22. mock_versions_manager.get_remote_info.return_value = RemoteInfo(
  23. name="ardupilot", url="https://github.com/ArduPilot/ardupilot.git"
  24. )
  25. mock_ap_src_metadata_fetcher.get_boards.return_value = ["MatekH743", "CubeOrange"]
  26. mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = []
  27. mock_git_repo.commit_id_for_remote_ref.return_value = "abc123def456"
  28. mock_git_repo.get_checkout_lock.return_value = MagicMock()
  29. mock_build_manager.submit_build.return_value = "new-build-id"
  30. mock_copter, mock_plane, mock_rover = Mock(), Mock(), Mock()
  31. mock_copter.name, mock_plane.name, mock_rover.name = "Copter", "Plane", "Rover"
  32. mock_copter.id, mock_plane.id, mock_rover.id = "copter", "plane", "rover"
  33. vehicles = {"copter": mock_copter, "plane": mock_plane, "rover": mock_rover}
  34. mock_vehicles_manager.get_vehicle_by_id = Mock(
  35. side_effect=lambda vid: vehicles.get(vid)
  36. )
  37. return BuildsService(
  38. build_manager=mock_build_manager,
  39. versions_manager=mock_versions_manager,
  40. ap_src_metadata_fetcher=mock_ap_src_metadata_fetcher,
  41. repo=mock_git_repo,
  42. vehicles_manager=mock_vehicles_manager,
  43. )
  44. def make_version_info(
  45. remote_name="ardupilot",
  46. remote_url="https://github.com/ArduPilot/ardupilot.git",
  47. commit_ref="refs/tags/Copter-4.5.0",
  48. release_type="stable",
  49. version_number="4.5.0",
  50. ap_build_artifacts_url=None,
  51. ):
  52. return VersionInfo(
  53. remote_info=RemoteInfo(name=remote_name, url=remote_url),
  54. commit_ref=commit_ref,
  55. release_type=release_type,
  56. version_number=version_number,
  57. ap_build_artifacts_url=ap_build_artifacts_url,
  58. )
  59. def make_build_info(
  60. vehicle_id="copter",
  61. version_id="copter-4.5.0-stable",
  62. remote_name="ardupilot",
  63. remote_url="https://github.com/ArduPilot/ardupilot.git",
  64. git_hash="abc123def456",
  65. board="MatekH743",
  66. selected_features=None,
  67. state=bm.BuildState.PENDING,
  68. percent=0,
  69. ):
  70. info = bm.BuildInfo(
  71. vehicle_id=vehicle_id,
  72. version_id=version_id,
  73. remote_info=ManagerRemoteInfo(name=remote_name, url=remote_url),
  74. git_hash=git_hash,
  75. board=board,
  76. selected_features=selected_features or set(),
  77. )
  78. info.progress = bm.BuildProgress(state=state, percent=percent)
  79. return info
  80. class TestBuildsService:
  81. """Test suite for BuildsService."""
  82. @staticmethod
  83. def setup_builds(
  84. mock_build_manager,
  85. build_infos,
  86. ):
  87. """Populate mock build_manager with the given BuildInfo objects."""
  88. ids = [f"build-{i}" for i in range(len(build_infos))]
  89. mock_build_manager.get_all_build_ids.return_value = ids
  90. mock_build_manager.get_build_info.side_effect = lambda bid: (
  91. build_infos[ids.index(bid)]
  92. )
  93. # Tests for create_build
  94. def test_create_build_returns_submit_response(
  95. self,
  96. service,
  97. ):
  98. """A valid request returns a BuildSubmitResponse with build_id and url."""
  99. request = BuildRequest(
  100. vehicle_id="copter",
  101. board_id="MatekH743",
  102. version_id="copter-4.5.0-stable",
  103. selected_features=[],
  104. )
  105. result = service.create_build(request)
  106. assert result.build_id == "new-build-id"
  107. assert result.url == "/api/v1/builds/new-build-id"
  108. assert result.status == "submitted"
  109. def test_create_build_calls_submit_build_once(
  110. self,
  111. service,
  112. mock_build_manager,
  113. ):
  114. """submit_build is called exactly once per create_build invocation."""
  115. request = BuildRequest(
  116. vehicle_id="copter",
  117. board_id="MatekH743",
  118. version_id="copter-4.5.0-stable",
  119. selected_features=[],
  120. )
  121. service.create_build(request)
  122. mock_build_manager.submit_build.assert_called_once()
  123. def test_create_build_raises_value_error_for_missing_version_id(self, service):
  124. """ValueError is raised when version_id is an empty string."""
  125. request = BuildRequest(
  126. vehicle_id="copter",
  127. board_id="MatekH743",
  128. version_id="",
  129. selected_features=[],
  130. )
  131. with pytest.raises(ValueError, match="version_id is required"):
  132. service.create_build(request)
  133. def test_create_build_raises_value_error_for_missing_vehicle_id(self, service):
  134. """ValueError is raised when vehicle_id is an empty string."""
  135. request = BuildRequest(
  136. vehicle_id="",
  137. board_id="MatekH743",
  138. version_id="copter-4.5.0-stable",
  139. selected_features=[],
  140. )
  141. with pytest.raises(ValueError, match="vehicle_id is required"):
  142. service.create_build(request)
  143. def test_create_build_raises_value_error_for_missing_board_id(
  144. self, service
  145. ):
  146. """ValueError is raised when board_id is an empty string."""
  147. request = BuildRequest(
  148. vehicle_id="copter",
  149. board_id="",
  150. version_id="copter-4.5.0-stable",
  151. selected_features=[],
  152. )
  153. with pytest.raises(ValueError, match="board_id is required"):
  154. service.create_build(request)
  155. def test_create_build_raises_value_error_for_invalid_version(
  156. self, service, mock_versions_manager
  157. ):
  158. """ValueError is raised when the version_id is not found."""
  159. mock_versions_manager.get_version_info.return_value = None
  160. request = BuildRequest(
  161. vehicle_id="copter",
  162. board_id="MatekH743",
  163. version_id="nonexistent-version",
  164. selected_features=[],
  165. )
  166. with pytest.raises(ValueError, match="Invalid version_id for vehicle"):
  167. service.create_build(request)
  168. def test_create_build_queries_version_info_with_correct_params(
  169. self, service, mock_versions_manager
  170. ):
  171. """get_version_info is called with the correct vehicle_id and version_id."""
  172. mock_versions_manager.get_version_info.return_value = None
  173. request = BuildRequest(
  174. vehicle_id="plane",
  175. board_id="CubeOrange",
  176. version_id="plane-4.4.0-stable",
  177. selected_features=[],
  178. )
  179. with pytest.raises(ValueError):
  180. service.create_build(request)
  181. mock_versions_manager.get_version_info.assert_called_once_with(
  182. vehicle_id="plane",
  183. version_id="plane-4.4.0-stable",
  184. )
  185. def test_create_build_raises_value_error_when_remote_not_found(
  186. self, service, mock_versions_manager
  187. ):
  188. """ValueError is raised when the remote is not found."""
  189. mock_versions_manager.get_remote_info.return_value = None
  190. request = BuildRequest(
  191. vehicle_id="some-vehicle",
  192. board_id="some-board",
  193. version_id="some-version",
  194. selected_features=[],
  195. )
  196. with pytest.raises(ValueError, match="not whitelisted"):
  197. service.create_build(request)
  198. def test_create_build_raises_value_error_when_board_not_in_version(
  199. self,
  200. service,
  201. mock_ap_src_metadata_fetcher,
  202. ):
  203. """ValueError is raised when the board is not available for the version."""
  204. mock_ap_src_metadata_fetcher.get_boards.return_value = ["CubeOrange"]
  205. request = BuildRequest(
  206. vehicle_id="copter",
  207. board_id="some-nonexistent-board",
  208. version_id="copter-4.5.0-stable",
  209. selected_features=[],
  210. )
  211. with pytest.raises(ValueError, match="Invalid board for this version"):
  212. service.create_build(request)
  213. def test_create_build_maps_feature_labels_to_defines(
  214. self,
  215. service,
  216. mock_ap_src_metadata_fetcher,
  217. mock_build_manager,
  218. ):
  219. """Selected feature labels are translated to defines before build submission."""
  220. opt = Mock()
  221. opt.label = "HAL_LOGGING_ENABLED"
  222. opt.define = "HAL_LOGGING_ENABLED_DEFINE"
  223. mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt]
  224. request = BuildRequest(
  225. vehicle_id="copter",
  226. board_id="MatekH743",
  227. version_id="copter-4.5.0-stable",
  228. selected_features=["HAL_LOGGING_ENABLED"],
  229. )
  230. service.create_build(request)
  231. submitted: bm.BuildInfo = mock_build_manager.submit_build.call_args[1]["build_info"]
  232. assert "HAL_LOGGING_ENABLED_DEFINE" in submitted.selected_features
  233. def test_create_build_ignores_unknown_feature_labels(
  234. self,
  235. service,
  236. mock_ap_src_metadata_fetcher,
  237. mock_build_manager,
  238. ):
  239. """Unknown feature labels are silently skipped (not added to defines set)."""
  240. opt = Mock()
  241. opt.label = "HAL_LOGGING_ENABLED"
  242. opt.define = "HAL_LOGGING_ENABLED_DEFINE"
  243. mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt]
  244. request = BuildRequest(
  245. vehicle_id="copter",
  246. board_id="MatekH743",
  247. version_id="copter-4.5.0-stable",
  248. selected_features=["COMPLETELY_UNKNOWN_FEATURE"],
  249. )
  250. service.create_build(request)
  251. submitted: bm.BuildInfo = mock_build_manager.submit_build.call_args[1]["build_info"]
  252. assert len(submitted.selected_features) == 0
  253. def test_create_build_no_features_submits_empty_set(
  254. self,
  255. service,
  256. mock_build_manager,
  257. ):
  258. """When selected_features is empty, build is submitted with an empty set."""
  259. request = BuildRequest(
  260. vehicle_id="copter",
  261. board_id="MatekH743",
  262. version_id="copter-4.5.0-stable",
  263. selected_features=[],
  264. )
  265. service.create_build(request)
  266. submitted: bm.BuildInfo = mock_build_manager.submit_build.call_args[1]["build_info"]
  267. assert len(submitted.selected_features) == 0
  268. # Tests for list_builds
  269. def test_list_builds_returns_all_when_no_filters(
  270. self,
  271. service,
  272. mock_build_manager,
  273. ):
  274. """Returns all builds when no filters are applied."""
  275. self.setup_builds(
  276. mock_build_manager,
  277. [
  278. make_build_info(vehicle_id="copter", board="MatekH743"),
  279. make_build_info(vehicle_id="plane", board="CubeOrange"),
  280. ],
  281. )
  282. result = service.list_builds()
  283. assert len(result) == 2
  284. def test_list_builds_returns_empty_when_no_builds(
  285. self, service, mock_build_manager
  286. ):
  287. """Returns an empty list when there are no builds."""
  288. mock_build_manager.get_all_build_ids.return_value = []
  289. result = service.list_builds()
  290. assert result == []
  291. def test_list_builds_vehicle_id_filter_keeps_matching(
  292. self,
  293. service,
  294. mock_build_manager,
  295. ):
  296. """vehicle_id filter returns only builds for that vehicle."""
  297. self.setup_builds(
  298. mock_build_manager,
  299. [
  300. make_build_info(vehicle_id="copter"),
  301. make_build_info(vehicle_id="plane"),
  302. make_build_info(vehicle_id="copter"),
  303. ],
  304. )
  305. result = service.list_builds(vehicle_id="copter")
  306. assert len(result) == 2
  307. assert all(b.vehicle.id == "copter" for b in result)
  308. def test_list_builds_board_id_filter_keeps_matching(
  309. self,
  310. service,
  311. mock_build_manager,
  312. ):
  313. """board_id filter returns only builds for that board."""
  314. self.setup_builds(
  315. mock_build_manager,
  316. [
  317. make_build_info(board="MatekH743"),
  318. make_build_info(board="CubeOrange"),
  319. make_build_info(board="MatekH743"),
  320. ],
  321. )
  322. result = service.list_builds(board_id="MatekH743")
  323. assert len(result) == 2
  324. assert all(b.board.id == "MatekH743" for b in result)
  325. def test_list_builds_state_filter_keeps_matching(
  326. self,
  327. service,
  328. mock_build_manager,
  329. ):
  330. """state filter returns only builds in that state."""
  331. self.setup_builds(
  332. mock_build_manager,
  333. [
  334. make_build_info(state=bm.BuildState.PENDING),
  335. make_build_info(state=bm.BuildState.SUCCESS),
  336. make_build_info(state=bm.BuildState.PENDING),
  337. ],
  338. )
  339. result = service.list_builds(state="PENDING")
  340. assert len(result) == 2
  341. assert all(b.progress.state == "PENDING" for b in result)
  342. def test_list_builds_state_filter_excludes_non_matching(
  343. self,
  344. service,
  345. mock_build_manager,
  346. ):
  347. """state filter excludes builds not in that state."""
  348. self.setup_builds(
  349. mock_build_manager,
  350. [
  351. make_build_info(state=bm.BuildState.FAILURE),
  352. make_build_info(state=bm.BuildState.RUNNING),
  353. ],
  354. )
  355. result = service.list_builds(state="SUCCESS")
  356. assert result == []
  357. def test_list_builds_pagination_limit(
  358. self,
  359. service,
  360. mock_build_manager,
  361. ):
  362. """limit restricts the number of results returned."""
  363. self.setup_builds(
  364. mock_build_manager,
  365. [make_build_info() for _ in range(5)],
  366. )
  367. result = service.list_builds(limit=3)
  368. assert len(result) == 3
  369. def test_list_builds_pagination_offset(
  370. self,
  371. service,
  372. mock_build_manager,
  373. ):
  374. """offset skips the given number of results."""
  375. self.setup_builds(
  376. mock_build_manager,
  377. [make_build_info() for _ in range(5)],
  378. )
  379. result_all = service.list_builds(limit=5, offset=0)
  380. result_offset = service.list_builds(limit=5, offset=3)
  381. assert len(result_offset) == 2
  382. assert result_offset[0].build_id == result_all[3].build_id
  383. def test_list_builds_sorted_newest_first(
  384. self,
  385. service,
  386. mock_build_manager,
  387. ):
  388. """Builds are returned sorted by creation time, newest first."""
  389. now = time.time()
  390. old = make_build_info()
  391. old.time_created = now - 1000
  392. new = make_build_info()
  393. new.time_created = now
  394. ids = ["build-old", "build-new"]
  395. mock_build_manager.get_all_build_ids.return_value = ids
  396. mock_build_manager.get_build_info.side_effect = lambda bid: (
  397. old if bid == "build-old" else new
  398. )
  399. result = service.list_builds()
  400. assert result[0].time_created >= result[1].time_created
  401. def test_list_builds_skips_missing_build_info(
  402. self, service, mock_build_manager
  403. ):
  404. """Builds whose info cannot be retrieved are silently skipped."""
  405. mock_build_manager.get_all_build_ids.return_value = ["b1", "b2"]
  406. mock_build_manager.get_build_info.return_value = None
  407. result = service.list_builds()
  408. assert result == []
  409. # Tests for get_build
  410. def test_get_build_returns_build_out_when_found(
  411. self,
  412. service,
  413. mock_build_manager,
  414. ):
  415. """Returns a BuildOut when the build exists."""
  416. mock_build_manager.build_exists.return_value = True
  417. mock_build_manager.get_build_info.return_value = make_build_info()
  418. result = service.get_build("build-abc123")
  419. assert result is not None
  420. assert result.build_id == "build-abc123"
  421. def test_get_build_returns_none_when_not_found(
  422. self, service, mock_build_manager
  423. ):
  424. """Returns None when the build does not exist."""
  425. mock_build_manager.build_exists.return_value = False
  426. result = service.get_build("nonexistent-build")
  427. assert result is None
  428. def test_get_build_returns_none_when_info_unavailable(
  429. self, service, mock_build_manager
  430. ):
  431. """Returns None when build_exists is True but get_build_info returns None."""
  432. mock_build_manager.build_exists.return_value = True
  433. mock_build_manager.get_build_info.return_value = None
  434. result = service.get_build("build-abc123")
  435. assert result is None
  436. def test_get_build_checks_existence_with_correct_id(
  437. self, service, mock_build_manager
  438. ):
  439. """build_exists is called with the provided build_id."""
  440. mock_build_manager.build_exists.return_value = False
  441. service.get_build("specific-build-id")
  442. mock_build_manager.build_exists.assert_called_once_with("specific-build-id")
  443. def test_get_build_output_has_correct_vehicle_and_board(
  444. self,
  445. service,
  446. mock_build_manager,
  447. mock_vehicles_manager,
  448. ):
  449. """The returned BuildOut contains correct vehicle and board information."""
  450. mock_build_manager.build_exists.return_value = True
  451. mock_build_manager.get_build_info.return_value = make_build_info(
  452. vehicle_id="plane", board="CubeOrange"
  453. )
  454. mock_vehicle = Mock()
  455. mock_vehicle.name = "Plane"
  456. mock_vehicles_manager.get_vehicle_by_id.return_value = mock_vehicle
  457. result = service.get_build("build-xyz")
  458. assert result.vehicle.id == "plane"
  459. assert result.board.id == "CubeOrange"
  460. def test_get_build_maps_feature_defines_to_labels(
  461. self,
  462. service,
  463. mock_build_manager,
  464. mock_ap_src_metadata_fetcher,
  465. ):
  466. """Feature defines in BuildInfo are mapped back to labels in the output."""
  467. mock_build_manager.build_exists.return_value = True
  468. mock_build_manager.get_build_info.return_value = make_build_info(
  469. selected_features={"HAL_LOGGING_ENABLED_DEFINE"}
  470. )
  471. opt = Mock()
  472. opt.define = "HAL_LOGGING_ENABLED_DEFINE"
  473. opt.label = "HAL_LOGGING_ENABLED"
  474. mock_ap_src_metadata_fetcher.get_build_options_at_commit.return_value = [opt]
  475. result = service.get_build("build-abc123")
  476. assert "HAL_LOGGING_ENABLED" in result.selected_features
  477. def test_get_build_falls_back_to_define_when_label_not_found(
  478. self,
  479. service,
  480. mock_build_manager,
  481. ):
  482. """When a define has no matching label, the define itself is used as fallback."""
  483. mock_build_manager.build_exists.return_value = True
  484. mock_build_manager.get_build_info.return_value = make_build_info(
  485. selected_features={"ORPHANED_DEFINE"}
  486. )
  487. result = service.get_build("build-abc123")
  488. assert "ORPHANED_DEFINE" in result.selected_features
  489. def test_get_build_no_selected_features_returns_empty_list(
  490. self,
  491. service,
  492. mock_build_manager,
  493. ):
  494. """When a build has no selected features, the output list is empty."""
  495. mock_build_manager.build_exists.return_value = True
  496. mock_build_manager.get_build_info.return_value = make_build_info(
  497. selected_features=set()
  498. )
  499. result = service.get_build("build-abc123")
  500. assert result.selected_features == []
  501. # Tests for get_build_logs
  502. def test_get_build_logs_returns_none_when_build_not_found(
  503. self, service, mock_build_manager
  504. ):
  505. """Returns None when the build does not exist."""
  506. mock_build_manager.build_exists.return_value = False
  507. result = service.get_build_logs("nonexistent-build")
  508. assert result is None
  509. def test_get_build_logs_returns_none_when_log_file_missing(
  510. self, service, mock_build_manager
  511. ):
  512. """Returns None when the log file does not exist on disk."""
  513. mock_build_manager.build_exists.return_value = True
  514. mock_build_manager.get_build_log_path.return_value = "/nonexistent/path/build.log"
  515. result = service.get_build_logs("build-abc123")
  516. assert result is None
  517. def test_get_build_logs_returns_full_content_when_tail_is_none(
  518. self, service, mock_build_manager, tmp_path
  519. ):
  520. """Returns the full log content when tail is None."""
  521. log_file = tmp_path / "build.log"
  522. log_content = "line1\nline2\nline3\n"
  523. log_file.write_text(log_content)
  524. mock_build_manager.build_exists.return_value = True
  525. mock_build_manager.get_build_log_path.return_value = str(log_file)
  526. result = service.get_build_logs("build-abc123", tail=None)
  527. assert result == log_content
  528. def test_get_build_logs_returns_last_n_lines_when_tail_given(
  529. self, service, mock_build_manager, tmp_path
  530. ):
  531. """Returns only the last N lines when tail is specified."""
  532. log_file = tmp_path / "build.log"
  533. log_file.write_text("line1\nline2\nline3\nline4\nline5\n")
  534. mock_build_manager.build_exists.return_value = True
  535. mock_build_manager.get_build_log_path.return_value = str(log_file)
  536. result = service.get_build_logs("build-abc123", tail=2)
  537. assert result == "line4\nline5\n"
  538. def test_get_build_logs_checks_existence_with_correct_build_id(
  539. self, service, mock_build_manager
  540. ):
  541. """build_exists is called with the provided build_id."""
  542. mock_build_manager.build_exists.return_value = False
  543. service.get_build_logs("target-build-id")
  544. mock_build_manager.build_exists.assert_called_once_with("target-build-id")
  545. def test_get_build_logs_retrieves_path_with_correct_build_id(
  546. self, service, mock_build_manager, tmp_path
  547. ):
  548. """get_build_log_path is called with the correct build_id."""
  549. log_file = tmp_path / "build.log"
  550. log_file.write_text("")
  551. mock_build_manager.build_exists.return_value = True
  552. mock_build_manager.get_build_log_path.return_value = str(log_file)
  553. service.get_build_logs("specific-build-id")
  554. mock_build_manager.get_build_log_path.assert_called_once_with("specific-build-id")
  555. # Tests for get_artifact_path
  556. def test_get_artifact_path_returns_none_when_build_not_found(
  557. self, service, mock_build_manager
  558. ):
  559. """Returns None when the build does not exist."""
  560. mock_build_manager.build_exists.return_value = False
  561. result = service.get_artifact_path("nonexistent-build")
  562. assert result is None
  563. def test_get_artifact_path_returns_none_when_build_info_unavailable(
  564. self, service, mock_build_manager
  565. ):
  566. """Returns None when build exists but its info cannot be retrieved."""
  567. mock_build_manager.build_exists.return_value = True
  568. mock_build_manager.get_build_info.return_value = None
  569. result = service.get_artifact_path("build-abc123")
  570. assert result is None
  571. def test_get_artifact_path_returns_none_when_build_pending(
  572. self, service, mock_build_manager
  573. ):
  574. """Returns None when the build is in PENDING state."""
  575. mock_build_manager.build_exists.return_value = True
  576. mock_build_manager.get_build_info.return_value = make_build_info(
  577. state=bm.BuildState.PENDING
  578. )
  579. result = service.get_artifact_path("build-abc123")
  580. assert result is None
  581. def test_get_artifact_path_returns_none_when_build_running(
  582. self, service, mock_build_manager
  583. ):
  584. """Returns None when the build is still RUNNING."""
  585. mock_build_manager.build_exists.return_value = True
  586. mock_build_manager.get_build_info.return_value = make_build_info(
  587. state=bm.BuildState.RUNNING
  588. )
  589. result = service.get_artifact_path("build-abc123")
  590. assert result is None
  591. def test_get_artifact_path_returns_path_when_artifact_exists(
  592. self, service, mock_build_manager, tmp_path
  593. ):
  594. """Returns the artifact path when the build succeeded and file exists."""
  595. artifact = tmp_path / "artifact.tar.gz"
  596. artifact.write_bytes(b"firmware")
  597. mock_build_manager.build_exists.return_value = True
  598. mock_build_manager.get_build_info.return_value = make_build_info(
  599. state=bm.BuildState.SUCCESS
  600. )
  601. mock_build_manager.get_build_archive_path.return_value = str(artifact)
  602. result = service.get_artifact_path("build-abc123")
  603. assert result == str(artifact)
  604. def test_get_artifact_path_returns_none_when_artifact_file_missing(
  605. self, service, mock_build_manager
  606. ):
  607. """Returns None when the build succeeded but the artifact file is absent."""
  608. mock_build_manager.build_exists.return_value = True
  609. mock_build_manager.get_build_info.return_value = make_build_info(
  610. state=bm.BuildState.SUCCESS
  611. )
  612. mock_build_manager.get_build_archive_path.return_value = "/does/not/exist.tar.gz"
  613. result = service.get_artifact_path("build-abc123")
  614. assert result is None
  615. def test_get_artifact_path_available_for_failed_build_if_file_exists(
  616. self, service, mock_build_manager, tmp_path
  617. ):
  618. """Artifact path is returned for FAILURE state if the file happens to exist."""
  619. artifact = tmp_path / "artifact.tar.gz"
  620. artifact.write_bytes(b"partial firmware")
  621. mock_build_manager.build_exists.return_value = True
  622. mock_build_manager.get_build_info.return_value = make_build_info(
  623. state=bm.BuildState.FAILURE
  624. )
  625. mock_build_manager.get_build_archive_path.return_value = str(artifact)
  626. result = service.get_artifact_path("build-abc123")
  627. assert result == str(artifact)
  628. def test_get_artifact_path_uses_correct_build_id(
  629. self, service, mock_build_manager
  630. ):
  631. """build_exists is called with the correct build_id."""
  632. mock_build_manager.build_exists.return_value = False
  633. service.get_artifact_path("my-target-build")
  634. mock_build_manager.build_exists.assert_called_once_with("my-target-build")