test_builds_service.py 26 KB

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