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
« 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
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}"
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
26 response = self.client.get(self.endpoint_url)
28 # Assert response
29 self.assertEqual(response.status_code, 200)
30 self.assertIn(b"text/html", response.content_type.encode())
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)
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()
43 response = client.get(self.endpoint_url)
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])
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}"
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 }
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 }
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 }
91 response = self.client.get(self.endpoint_url)
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()
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
118 response = self.client.get(f"{self.endpoint_url}/logs")
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()
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 ]
162 response = self.client.get(f"{self.endpoint_url}/logs")
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()
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
203 response = self.client.get(f"{self.endpoint_url}/logs")
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()
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")
234 response = self.client.get(f"{self.endpoint_url}/logs")
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()
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 )
264 response = self.client.get(f"{self.endpoint_url}/logs")
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()
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
290 response = self.client.get(f"{self.endpoint_url}/logs")
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()
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
311 response = self.client.get(f"{self.endpoint_url}/logs")
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()
322 def test_get_snap_build_logs_requires_login(self):
323 app = self.app
324 client = app.test_client()
326 response = client.get(f"{self.endpoint_url}/logs")
328 self.assertIn(response.status_code, [302, 401, 403])
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"
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 }
346 # Mock launchpad methods
347 mock_launchpad.is_snap_building.return_value = False
348 mock_launchpad.build_snap.return_value = "build-12345"
350 response = self.client.post(self.endpoint_url)
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")
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)
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"
375 response = self.client.post(self.endpoint_url)
377 self.assertEqual(response.status_code, 200)
378 self.assertTrue(response.get_json()["success"])
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)
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"
400 response = self.client.post(self.endpoint_url)
402 self.assertEqual(response.status_code, 200)
403 self.assertTrue(response.get_json()["success"])
405 mock_dashboard.get_package_upload_macaroon.assert_not_called()
406 mock_launchpad.complete_snap_authorization.assert_not_called()
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 }
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"
423 response = self.client.post(self.endpoint_url)
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")
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)
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 = {}
444 response = self.client.post(self.endpoint_url)
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 )
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
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 }
466 # Mock launchpad methods
467 mock_launchpad.is_snap_building.return_value = False
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
477 response = self.client.post(self.endpoint_url)
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)
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()
495 response = client.post(self.endpoint_url)
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])
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/"
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()
514 response = client.post(self.endpoint_url)
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])