Coverage for tests/endpoints/publisher/tests_builds.py: 100%

274 statements  

« prev     ^ index     » next       coverage.py v7.16.0, created at 2026-09-10 22:08 +0000

1from unittest.mock import MagicMock, call, patch 

2from requests.exceptions import HTTPError 

3from tests.endpoints.endpoint_testing import TestEndpoints 

4from webapp.api.exceptions import ApiConnectionError, ApiTimeoutError 

5from webapp.publisher.snaps.build_views import BUILD_LOG_REQUEST_TIMEOUT 

6 

7 

8class TestGetSnapBuildPage(TestEndpoints): 

9 def setUp(self): 

10 super().setUp() 

11 self.snap_name = "test-snap" 

12 self.build_id = "12345" 

13 self.endpoint_url = f"/{self.snap_name}/builds/{self.build_id}" 

14 

15 @patch("webapp.endpoints.publisher.builds.dashboard") 

16 def test_get_snap_build_page_success(self, mock_dashboard): 

17 """Test successful rendering of snap build page""" 

18 # Mock snap info response 

19 mock_snap_info = { 

20 "snap_name": self.snap_name, 

21 "title": "Test Snap", 

22 "snap_id": "test-snap-id-123", 

23 } 

24 mock_dashboard.get_snap_info.return_value = mock_snap_info 

25 

26 response = self.client.get(self.endpoint_url) 

27 

28 # Assert response 

29 self.assertEqual(response.status_code, 200) 

30 self.assertIn(b"text/html", response.content_type.encode()) 

31 

32 # Verify dashboard method was called with correct session and snap name 

33 mock_dashboard.get_snap_info.assert_called_once() 

34 call_args = mock_dashboard.get_snap_info.call_args 

35 self.assertEqual(call_args[0][1], self.snap_name) 

36 

37 def test_get_snap_build_page_requires_login(self): 

38 """Test that the endpoint requires login""" 

39 # Create a new client without logging in 

40 app = self.app 

41 client = app.test_client() 

42 

43 response = client.get(self.endpoint_url) 

44 

45 # Should redirect to login or return unauthorized 

46 # The exact behavior depends on the login_required decorator 

47 self.assertIn(response.status_code, [302, 401, 403]) 

48 

49 

50class TestGetSnapBuild(TestEndpoints): 

51 def setUp(self): 

52 super().setUp() 

53 self.snap_name = "test-snap" 

54 self.build_id = "12345" 

55 self.endpoint_url = f"/api/{self.snap_name}/builds/{self.build_id}" 

56 

57 def _mock_snap_info(self): 

58 return { 

59 "snap_name": self.snap_name, 

60 "title": "Test Snap", 

61 "snap_id": "test-snap-id-123", 

62 } 

63 

64 def _mock_build(self): 

65 return { 

66 "self_link": ( 

67 "https://api.launchpad.net/devel/~owner/" 

68 "+snap/test-snap/+build/12345" 

69 ), 

70 "arch_tag": "amd64", 

71 "datebuilt": "2023-01-01T12:00:00Z", 

72 "duration": "00:05:30", 

73 "build_log_url": "https://launchpad.net/buildlog.txt", 

74 "revision_id": "abcdef1234567890abcdef1234567890abcdef12", 

75 "buildstate": "Successfully built", 

76 "store_upload_status": "Uploaded", 

77 "title": "Test build", 

78 } 

79 

80 @patch("webapp.publisher.snaps.build_views.launchpad") 

81 @patch("webapp.publisher.snaps.build_views.dashboard") 

82 def test_get_snap_build_success_does_not_fetch_raw_logs( 

83 self, mock_dashboard, mock_launchpad 

84 ): 

85 mock_dashboard.get_snap_info.return_value = self._mock_snap_info() 

86 mock_launchpad.get_snap_build.return_value = self._mock_build() 

87 mock_launchpad.get_snap_by_store_name.return_value = { 

88 "git_repository_url": "https://github.com/owner/repo" 

89 } 

90 

91 response = self.client.get(self.endpoint_url) 

92 

93 self.assertEqual(response.status_code, 200) 

94 response_data = response.get_json() 

95 self.assertTrue(response_data["success"]) 

96 self.assertNotIn("raw_logs", response_data["data"]) 

97 self.assertEqual( 

98 response_data["data"]["snap_build"]["github_repository"], 

99 "owner/repo", 

100 ) 

101 mock_launchpad.get_snap_build_log.assert_not_called() 

102 

103 @patch("webapp.publisher.snaps.build_views.api_publisher_session") 

104 @patch("webapp.publisher.snaps.build_views.launchpad") 

105 @patch("webapp.publisher.snaps.build_views.dashboard") 

106 def test_get_snap_build_logs_streams_from_launchpad( 

107 self, mock_dashboard, mock_launchpad, mock_api_publisher_session 

108 ): 

109 mock_response = MagicMock() 

110 mock_response.status_code = 200 

111 mock_response.is_redirect = False 

112 mock_response.iter_content.return_value = ["Test ", "build logs"] 

113 mock_response.raise_for_status.return_value = None 

114 mock_dashboard.get_snap_info.return_value = self._mock_snap_info() 

115 mock_launchpad.get_snap_build.return_value = self._mock_build() 

116 mock_api_publisher_session.get.return_value = mock_response 

117 

118 response = self.client.get(f"{self.endpoint_url}/logs") 

119 

120 self.assertEqual(response.status_code, 200) 

121 self.assertEqual(response.content_type, "text/plain; charset=utf-8") 

122 self.assertEqual(response.get_data(as_text=True), "Test build logs") 

123 mock_api_publisher_session.get.assert_called_once_with( 

124 "https://launchpad.net/buildlog.txt", 

125 headers={"Accept": "text/plain"}, 

126 stream=True, 

127 timeout=BUILD_LOG_REQUEST_TIMEOUT, 

128 allow_redirects=False, 

129 ) 

130 mock_response.iter_content.assert_called_once_with( 

131 chunk_size=8192, decode_unicode=True 

132 ) 

133 mock_response.raise_for_status.assert_called_once_with() 

134 mock_response.close.assert_called_once() 

135 mock_launchpad.get_snap_build_log.assert_not_called() 

136 

137 @patch("webapp.publisher.snaps.build_views.api_publisher_session") 

138 @patch("webapp.publisher.snaps.build_views.launchpad") 

139 @patch("webapp.publisher.snaps.build_views.dashboard") 

140 def test_get_snap_build_logs_streams_from_launchpadlibrarian( 

141 self, mock_dashboard, mock_launchpad, mock_api_publisher_session 

142 ): 

143 mock_redirect_response = MagicMock() 

144 mock_redirect_response.status_code = 303 

145 mock_redirect_response.is_redirect = True 

146 mock_redirect_response.headers = { 

147 "Location": "https://launchpadlibrarian.net/buildlog.txt" 

148 } 

149 mock_redirect_response.raise_for_status.return_value = None 

150 mock_log_response = MagicMock() 

151 mock_log_response.status_code = 200 

152 mock_log_response.is_redirect = False 

153 mock_log_response.iter_content.return_value = ["Test build logs"] 

154 mock_log_response.raise_for_status.return_value = None 

155 mock_dashboard.get_snap_info.return_value = self._mock_snap_info() 

156 mock_launchpad.get_snap_build.return_value = self._mock_build() 

157 mock_api_publisher_session.get.side_effect = [ 

158 mock_redirect_response, 

159 mock_log_response, 

160 ] 

161 

162 response = self.client.get(f"{self.endpoint_url}/logs") 

163 

164 self.assertEqual(response.status_code, 200) 

165 self.assertEqual(response.get_data(as_text=True), "Test build logs") 

166 mock_api_publisher_session.get.assert_has_calls( 

167 [ 

168 call( 

169 "https://launchpad.net/buildlog.txt", 

170 headers={"Accept": "text/plain"}, 

171 stream=True, 

172 timeout=BUILD_LOG_REQUEST_TIMEOUT, 

173 allow_redirects=False, 

174 ), 

175 call( 

176 "https://launchpadlibrarian.net/buildlog.txt", 

177 headers={"Accept": "text/plain"}, 

178 stream=True, 

179 timeout=BUILD_LOG_REQUEST_TIMEOUT, 

180 ), 

181 ] 

182 ) 

183 mock_redirect_response.close.assert_called_once_with() 

184 mock_log_response.close.assert_called_once_with() 

185 mock_launchpad.get_snap_build_log.assert_not_called() 

186 

187 @patch("webapp.publisher.snaps.build_views.api_publisher_session") 

188 @patch("webapp.publisher.snaps.build_views.launchpad") 

189 @patch("webapp.publisher.snaps.build_views.dashboard") 

190 def test_get_snap_build_logs_returns_error_when_launchpad_fails( 

191 self, mock_dashboard, mock_launchpad, mock_api_publisher_session 

192 ): 

193 mock_response = MagicMock() 

194 mock_response.status_code = 404 

195 mock_response.is_redirect = False 

196 mock_response.raise_for_status.side_effect = HTTPError( 

197 response=mock_response 

198 ) 

199 mock_dashboard.get_snap_info.return_value = self._mock_snap_info() 

200 mock_launchpad.get_snap_build.return_value = self._mock_build() 

201 mock_api_publisher_session.get.return_value = mock_response 

202 

203 response = self.client.get(f"{self.endpoint_url}/logs") 

204 

205 self.assertEqual(response.status_code, 502) 

206 response_data = response.get_json() 

207 self.assertFalse(response_data["success"]) 

208 self.assertEqual( 

209 response_data["error"]["message"], 

210 "The requested build log could not be fetched.", 

211 ) 

212 mock_response.raise_for_status.assert_called_once_with() 

213 mock_response.close.assert_called_once_with() 

214 mock_response.iter_content.assert_not_called() 

215 mock_api_publisher_session.get.assert_called_once_with( 

216 "https://launchpad.net/buildlog.txt", 

217 headers={"Accept": "text/plain"}, 

218 stream=True, 

219 timeout=BUILD_LOG_REQUEST_TIMEOUT, 

220 allow_redirects=False, 

221 ) 

222 mock_launchpad.get_snap_build_log.assert_not_called() 

223 

224 @patch("webapp.publisher.snaps.build_views.api_publisher_session") 

225 @patch("webapp.publisher.snaps.build_views.launchpad") 

226 @patch("webapp.publisher.snaps.build_views.dashboard") 

227 def test_get_snap_build_logs_returns_error_when_launchpad_times_out( 

228 self, mock_dashboard, mock_launchpad, mock_api_publisher_session 

229 ): 

230 mock_dashboard.get_snap_info.return_value = self._mock_snap_info() 

231 mock_launchpad.get_snap_build.return_value = self._mock_build() 

232 mock_api_publisher_session.get.side_effect = ApiTimeoutError("timeout") 

233 

234 response = self.client.get(f"{self.endpoint_url}/logs") 

235 

236 self.assertEqual(response.status_code, 502) 

237 response_data = response.get_json() 

238 self.assertFalse(response_data["success"]) 

239 self.assertEqual( 

240 response_data["error"]["message"], 

241 "The requested build log could not be fetched.", 

242 ) 

243 mock_api_publisher_session.get.assert_called_once_with( 

244 "https://launchpad.net/buildlog.txt", 

245 headers={"Accept": "text/plain"}, 

246 stream=True, 

247 timeout=BUILD_LOG_REQUEST_TIMEOUT, 

248 allow_redirects=False, 

249 ) 

250 mock_launchpad.get_snap_build_log.assert_not_called() 

251 

252 @patch("webapp.publisher.snaps.build_views.api_publisher_session") 

253 @patch("webapp.publisher.snaps.build_views.launchpad") 

254 @patch("webapp.publisher.snaps.build_views.dashboard") 

255 def test_get_snap_build_logs_returns_error_when_launchpad_unreachable( 

256 self, mock_dashboard, mock_launchpad, mock_api_publisher_session 

257 ): 

258 mock_dashboard.get_snap_info.return_value = self._mock_snap_info() 

259 mock_launchpad.get_snap_build.return_value = self._mock_build() 

260 mock_api_publisher_session.get.side_effect = ApiConnectionError( 

261 "connection error" 

262 ) 

263 

264 response = self.client.get(f"{self.endpoint_url}/logs") 

265 

266 self.assertEqual(response.status_code, 502) 

267 response_data = response.get_json() 

268 self.assertFalse(response_data["success"]) 

269 self.assertEqual( 

270 response_data["error"]["message"], 

271 "The requested build log could not be fetched.", 

272 ) 

273 mock_api_publisher_session.get.assert_called_once_with( 

274 "https://launchpad.net/buildlog.txt", 

275 headers={"Accept": "text/plain"}, 

276 stream=True, 

277 timeout=BUILD_LOG_REQUEST_TIMEOUT, 

278 allow_redirects=False, 

279 ) 

280 mock_launchpad.get_snap_build_log.assert_not_called() 

281 

282 @patch("webapp.publisher.snaps.build_views.launchpad") 

283 @patch("webapp.publisher.snaps.build_views.dashboard") 

284 def test_get_snap_build_logs_build_not_found( 

285 self, mock_dashboard, mock_launchpad 

286 ): 

287 mock_dashboard.get_snap_info.return_value = self._mock_snap_info() 

288 mock_launchpad.get_snap_build.return_value = None 

289 

290 response = self.client.get(f"{self.endpoint_url}/logs") 

291 

292 self.assertEqual(response.status_code, 404) 

293 response_data = response.get_json() 

294 self.assertFalse(response_data["success"]) 

295 self.assertEqual( 

296 response_data["error"]["message"], 

297 "The requested build could not be found.", 

298 ) 

299 mock_launchpad.get_snap_build_log.assert_not_called() 

300 

301 @patch("webapp.publisher.snaps.build_views.launchpad") 

302 @patch("webapp.publisher.snaps.build_views.dashboard") 

303 def test_get_snap_build_logs_missing_log_url( 

304 self, mock_dashboard, mock_launchpad 

305 ): 

306 mock_build = self._mock_build() 

307 mock_build["build_log_url"] = None 

308 mock_dashboard.get_snap_info.return_value = self._mock_snap_info() 

309 mock_launchpad.get_snap_build.return_value = mock_build 

310 

311 response = self.client.get(f"{self.endpoint_url}/logs") 

312 

313 self.assertEqual(response.status_code, 404) 

314 response_data = response.get_json() 

315 self.assertFalse(response_data["success"]) 

316 self.assertEqual( 

317 response_data["error"]["message"], 

318 "The requested build has no log.", 

319 ) 

320 mock_launchpad.get_snap_build_log.assert_not_called() 

321 

322 def test_get_snap_build_logs_requires_login(self): 

323 app = self.app 

324 client = app.test_client() 

325 

326 response = client.get(f"{self.endpoint_url}/logs") 

327 

328 self.assertIn(response.status_code, [302, 401, 403]) 

329 

330 

331class TestPostBuild(TestEndpoints): 

332 def setUp(self): 

333 super().setUp() 

334 self.snap_name = "test-snap" 

335 self.endpoint_url = f"/api/{self.snap_name}/builds/trigger-build" 

336 

337 @patch("webapp.endpoints.publisher.builds.launchpad") 

338 @patch("webapp.endpoints.publisher.builds.dashboard") 

339 def test_post_build_success(self, mock_dashboard, mock_launchpad): 

340 """Test successful build trigger""" 

341 # Mock account snaps to include our test snap 

342 mock_dashboard.get_account_snaps.return_value = { 

343 self.snap_name: {"snap_name": self.snap_name} 

344 } 

345 

346 # Mock launchpad methods 

347 mock_launchpad.is_snap_building.return_value = False 

348 mock_launchpad.build_snap.return_value = "build-12345" 

349 

350 response = self.client.post(self.endpoint_url) 

351 

352 # Assert response 

353 self.assertEqual(response.status_code, 200) 

354 response_data = response.get_json() 

355 self.assertTrue(response_data["success"]) 

356 self.assertEqual(response_data["build_id"], "build-12345") 

357 

358 # Verify method calls 

359 mock_dashboard.get_account_snaps.assert_called_once() 

360 mock_launchpad.is_snap_building.assert_called_once_with(self.snap_name) 

361 mock_launchpad.build_snap.assert_called_once_with(self.snap_name) 

362 

363 @patch("webapp.endpoints.publisher.builds.launchpad") 

364 @patch("webapp.endpoints.publisher.builds.dashboard") 

365 def test_post_build_does_not_reauthorize_before_triggering( 

366 self, mock_dashboard, mock_launchpad 

367 ): 

368 """Triggering a build should use existing Launchpad auth.""" 

369 mock_dashboard.get_account_snaps.return_value = { 

370 self.snap_name: {"snap_name": self.snap_name} 

371 } 

372 mock_launchpad.is_snap_building.return_value = False 

373 mock_launchpad.build_snap.return_value = "build-12345" 

374 

375 response = self.client.post(self.endpoint_url) 

376 

377 self.assertEqual(response.status_code, 200) 

378 self.assertTrue(response.get_json()["success"]) 

379 

380 mock_dashboard.get_package_upload_macaroon.assert_not_called() 

381 mock_launchpad.get_snap_by_store_name.assert_not_called() 

382 mock_launchpad.complete_snap_authorization.assert_not_called() 

383 mock_launchpad.is_snap_building.assert_called_once_with(self.snap_name) 

384 mock_launchpad.build_snap.assert_called_once_with(self.snap_name) 

385 

386 @patch("webapp.endpoints.publisher.builds.launchpad") 

387 @patch("webapp.endpoints.publisher.builds.dashboard") 

388 def test_post_build_skips_reauthorization_when_snap_not_in_launchpad( 

389 self, mock_dashboard, mock_launchpad 

390 ): 

391 """If the snap isn't linked in Launchpad yet, there's nothing 

392 to reauthorize, so the reauthorization calls are skipped.""" 

393 mock_dashboard.get_account_snaps.return_value = { 

394 self.snap_name: {"snap_name": self.snap_name} 

395 } 

396 mock_launchpad.get_snap_by_store_name.return_value = None 

397 mock_launchpad.is_snap_building.return_value = False 

398 mock_launchpad.build_snap.return_value = "build-12345" 

399 

400 response = self.client.post(self.endpoint_url) 

401 

402 self.assertEqual(response.status_code, 200) 

403 self.assertTrue(response.get_json()["success"]) 

404 

405 mock_dashboard.get_package_upload_macaroon.assert_not_called() 

406 mock_launchpad.complete_snap_authorization.assert_not_called() 

407 

408 @patch("webapp.endpoints.publisher.builds.launchpad") 

409 @patch("webapp.endpoints.publisher.builds.dashboard") 

410 def test_post_build_cancels_existing_build( 

411 self, mock_dashboard, mock_launchpad 

412 ): 

413 """Test that existing builds are cancelled before starting new one""" 

414 # Mock account snaps to include our test snap 

415 mock_dashboard.get_account_snaps.return_value = { 

416 self.snap_name: {"snap_name": self.snap_name} 

417 } 

418 

419 # Mock launchpad methods - existing build is running 

420 mock_launchpad.is_snap_building.return_value = True 

421 mock_launchpad.build_snap.return_value = "build-12345" 

422 

423 response = self.client.post(self.endpoint_url) 

424 

425 # Assert response 

426 self.assertEqual(response.status_code, 200) 

427 response_data = response.get_json() 

428 self.assertTrue(response_data["success"]) 

429 self.assertEqual(response_data["build_id"], "build-12345") 

430 

431 # Verify existing build was cancelled 

432 mock_launchpad.is_snap_building.assert_called_once_with(self.snap_name) 

433 mock_launchpad.cancel_snap_builds.assert_called_once_with( 

434 self.snap_name 

435 ) 

436 mock_launchpad.build_snap.assert_called_once_with(self.snap_name) 

437 

438 @patch("webapp.endpoints.publisher.builds.dashboard") 

439 def test_post_build_forbidden_non_contributor(self, mock_dashboard): 

440 """Test that non-contributors cannot trigger builds""" 

441 # Mock account snaps to NOT include our test snap 

442 mock_dashboard.get_account_snaps.return_value = {} 

443 

444 response = self.client.post(self.endpoint_url) 

445 

446 # Assert response 

447 self.assertEqual(response.status_code, 200) 

448 response_data = response.get_json() 

449 self.assertFalse(response_data["success"]) 

450 self.assertEqual(response_data["error"]["type"], "FORBIDDEN") 

451 self.assertIn( 

452 "not allowed to request builds", response_data["error"]["message"] 

453 ) 

454 

455 @patch("webapp.endpoints.publisher.builds.launchpad") 

456 @patch("webapp.endpoints.publisher.builds.dashboard") 

457 def test_post_build_http_error(self, mock_dashboard, mock_launchpad): 

458 """Test handling of HTTP errors from Launchpad""" 

459 from unittest.mock import Mock 

460 

461 # Mock account snaps to include our test snap 

462 mock_dashboard.get_account_snaps.return_value = { 

463 self.snap_name: {"snap_name": self.snap_name} 

464 } 

465 

466 # Mock launchpad methods 

467 mock_launchpad.is_snap_building.return_value = False 

468 

469 # Create mock HTTP error 

470 mock_response = Mock() 

471 mock_response.text = "Launchpad error message" 

472 mock_response.status_code = 500 

473 http_error = HTTPError() 

474 http_error.response = mock_response 

475 mock_launchpad.build_snap.side_effect = http_error 

476 

477 response = self.client.post(self.endpoint_url) 

478 

479 # Assert response 

480 self.assertEqual(response.status_code, 200) 

481 response_data = response.get_json() 

482 self.assertFalse(response_data["success"]) 

483 self.assertIn( 

484 "error happened building", response_data["error"]["message"] 

485 ) 

486 self.assertEqual(response_data["details"], "Launchpad error message") 

487 self.assertEqual(response_data["status_code"], 500) 

488 

489 def test_post_build_requires_login(self): 

490 """Test that the endpoint requires login""" 

491 # Create a new client without logging in 

492 app = self.app 

493 client = app.test_client() 

494 

495 response = client.post(self.endpoint_url) 

496 

497 # Should redirect to login or return unauthorized 

498 # The exact behavior depends on the login_required decorator 

499 self.assertIn(response.status_code, [302, 401, 403]) 

500 

501 

502class TestPostDisconnectRepo(TestEndpoints): 

503 def setUp(self): 

504 super().setUp() 

505 self.snap_name = "test-snap" 

506 self.endpoint_url = f"/api/{self.snap_name}/builds/disconnect/" 

507 

508 def test_post_disconnect_repo_requires_login(self): 

509 """Test that the endpoint requires login""" 

510 # Create a new client without logging in 

511 app = self.app 

512 client = app.test_client() 

513 

514 response = client.post(self.endpoint_url) 

515 

516 # Should redirect to login or return unauthorized 

517 # The exact behavior depends on the login_required decorator 

518 self.assertIn(response.status_code, [302, 401, 403])