test_builds_service.py 26 KB

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