Coverage for tests/api/tests_launchpad_provenance.py: 100%
340 statements
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-25 22:09 +0000
« prev ^ index » next coverage.py v7.16.2, created at 2026-10-25 22:09 +0000
1from threading import Lock
2from unittest import TestCase
3from unittest.mock import MagicMock
5from webapp.api.exceptions import ApiTimeoutError
6from webapp.api.launchpad_provenance import (
7 LaunchpadProvenance,
8 extract_github_repository,
9 extract_launchpad_repository,
10)
13def _response(payload):
14 resp = MagicMock()
15 resp.json.return_value = payload
16 resp.raise_for_status.return_value = None
17 return resp
20def _build(arch, revision, commit, status="Uploaded", build_id="216436"):
21 return {
22 "arch_tag": arch,
23 "store_upload_revision": revision,
24 "revision_id": commit,
25 "store_upload_status": status,
26 "self_link": (
27 "https://api.launchpad.net/devel/~build.snapcraft.io"
28 f"/+snap/x/+build/{build_id}"
29 ),
30 }
33class TestExtractGithubRepository(TestCase):
34 def test_github_url(self):
35 self.assertEqual(
36 extract_github_repository(
37 "https://github.com/snapcrafters/mumble"
38 ),
39 "snapcrafters/mumble",
40 )
42 def test_github_url_with_git_suffix(self):
43 self.assertEqual(
44 extract_github_repository(
45 "https://github.com/snapcrafters/mumble.git"
46 ),
47 "snapcrafters/mumble",
48 )
50 def test_non_github_url(self):
51 self.assertIsNone(
52 extract_github_repository("https://gitlab.com/foo/bar")
53 )
55 def test_trailing_slash(self):
56 self.assertEqual(
57 extract_github_repository(
58 "https://github.com/snapcrafters/mumble/"
59 ),
60 "snapcrafters/mumble",
61 )
63 def test_non_github_host_containing_github_com(self):
64 self.assertIsNone(
65 extract_github_repository(
66 "https://git.example.com/mirrors/github.com/torvalds/linux"
67 )
68 )
70 def test_github_com_in_query_string(self):
71 self.assertIsNone(
72 extract_github_repository("https://evil.com/?x=github.com/foo/bar")
73 )
75 def test_extra_path_segments(self):
76 self.assertIsNone(
77 extract_github_repository("https://github.com/a/b/tree/main")
78 )
80 def test_missing_repository_name(self):
81 self.assertIsNone(
82 extract_github_repository("https://github.com/snapcrafters")
83 )
85 def test_none(self):
86 self.assertIsNone(extract_github_repository(None))
89class TestExtractLaunchpadRepository(TestCase):
90 def test_project_repository(self):
91 self.assertEqual(
92 extract_launchpad_repository(
93 "https://api.launchpad.net/devel/~mozilla-snaps"
94 "/firefox-snap/+git/firefox-snap"
95 ),
96 "~mozilla-snaps/firefox-snap/+git/firefox-snap",
97 )
99 def test_distro_source_package_repository(self):
100 self.assertEqual(
101 extract_launchpad_repository(
102 "https://api.launchpad.net/devel/~hellsworth/ubuntu"
103 "/+source/libreoffice/+git/libreoffice-snap"
104 ),
105 "~hellsworth/ubuntu/+source/libreoffice/+git/libreoffice-snap",
106 )
108 def test_personal_repository(self):
109 self.assertEqual(
110 extract_launchpad_repository(
111 "https://api.launchpad.net/devel/~someone/+git/thing"
112 ),
113 "~someone/+git/thing",
114 )
116 def test_trailing_slash(self):
117 self.assertEqual(
118 extract_launchpad_repository(
119 "https://api.launchpad.net/devel/~a/b/+git/c/"
120 ),
121 "~a/b/+git/c",
122 )
124 def test_redacted_private_repository(self):
125 self.assertIsNone(
126 extract_launchpad_repository("tag:launchpad.net:2008:redacted")
127 )
129 def test_non_launchpad_host(self):
130 self.assertIsNone(
131 extract_launchpad_repository(
132 "https://evil.com/?x=api.launchpad.net/devel/~a/+git/b"
133 )
134 )
136 def test_not_a_git_link(self):
137 self.assertIsNone(
138 extract_launchpad_repository(
139 "https://api.launchpad.net/devel/~mozilla-snaps"
140 )
141 )
143 def test_none(self):
144 self.assertIsNone(extract_launchpad_repository(None))
147class TestBuildProvenanceMap(TestCase):
148 def _client(self, recipe, build_pages):
149 """Build a client whose session returns the recipe for the +snaps
150 lookup and successive build pages for the collection link."""
151 session = MagicMock()
152 pages = iter(build_pages)
153 lock = Lock()
155 def get(url, params=None):
156 if url.endswith("+snaps"):
157 return _response(recipe)
158 with lock:
159 return _response(next(pages))
161 session.get.side_effect = get
162 return LaunchpadProvenance(session=session)
164 def test_join_and_filtering(self):
165 recipe = {
166 "entries": [
167 {
168 "store_name": "mumble",
169 "git_repository_url": (
170 "https://github.com/snapcrafters/mumble"
171 ),
172 "completed_builds_collection_link": (
173 "https://api.launchpad.net/devel/x/completed_builds"
174 ),
175 }
176 ]
177 }
178 builds_page = {
179 "entries": [
180 _build("amd64", 1721, "aaaaaaa000"),
181 _build("arm64", 1798, "bbbbbbb111"),
182 # Skipped: not uploaded.
183 _build("armhf", 1799, "ccccccc222", status="Failed"),
184 # Skipped: no commit.
185 _build("s390x", 1800, None),
186 ],
187 "next_collection_link": None,
188 }
190 client = self._client(recipe, [builds_page])
191 result = client.build_provenance_map(
192 "mumble", max_pages=5, max_recipes=5
193 )
195 self.assertFalse(result["failed"])
196 self.assertEqual(result["github_repository"], "snapcrafters/mumble")
197 self.assertIn("1721", result["revisions"])
198 self.assertIn("1798", result["revisions"])
199 self.assertNotIn("1799", result["revisions"])
200 self.assertNotIn("1800", result["revisions"])
202 amd64 = result["revisions"]["1721"]["amd64"]
203 self.assertEqual(amd64["commit_sha"], "aaaaaaa000")
204 self.assertEqual(
205 amd64["commit_url"],
206 "https://github.com/snapcrafters/mumble/commit/aaaaaaa000",
207 )
208 self.assertEqual(amd64["build_id"], "216436")
209 self.assertEqual(
210 amd64["build_url"],
211 "https://launchpad.net/~build.snapcraft.io/+snap/x/+build/216436",
212 )
214 def test_pagination_is_bounded(self):
215 recipe = {
216 "entries": [
217 {
218 "store_name": "mumble",
219 "git_repository_url": (
220 "https://github.com/snapcrafters/mumble"
221 ),
222 "completed_builds_collection_link": "https://lp/p1",
223 }
224 ]
225 }
226 page1 = {
227 "entries": [_build("amd64", 1721, "aaa")],
228 "next_collection_link": "https://lp/p2",
229 }
230 page2 = {
231 "entries": [_build("arm64", 1798, "bbb")],
232 "next_collection_link": "https://lp/p3",
233 }
235 client = self._client(recipe, [page1, page2])
236 result = client.build_provenance_map(
237 "mumble", max_pages=1, max_recipes=5
238 )
240 # Stopping at the bound is not a failure; the data gathered is valid.
241 self.assertFalse(result["failed"])
242 self.assertIn("1721", result["revisions"])
243 self.assertNotIn("1798", result["revisions"])
245 def test_not_failed_when_pagination_ends_naturally(self):
246 recipe = {
247 "entries": [
248 {
249 "store_name": "mumble",
250 "git_repository_url": (
251 "https://github.com/snapcrafters/mumble"
252 ),
253 "completed_builds_collection_link": "https://lp/p1",
254 }
255 ]
256 }
257 page1 = {
258 "entries": [_build("amd64", 1721, "aaa")],
259 "next_collection_link": None,
260 }
262 client = self._client(recipe, [page1])
263 result = client.build_provenance_map(
264 "mumble", max_pages=5, max_recipes=5
265 )
267 self.assertFalse(result["failed"])
269 def test_partial_result_when_a_page_fails(self):
270 # A timeout on page 2 must not discard page 1's data.
271 recipe = {
272 "entries": [
273 {
274 "store_name": "mumble",
275 "git_repository_url": (
276 "https://github.com/snapcrafters/mumble"
277 ),
278 "completed_builds_collection_link": "https://lp/p1",
279 }
280 ]
281 }
282 page1 = {
283 "entries": [_build("amd64", 1721, "aaa")],
284 "next_collection_link": "https://lp/p2",
285 }
286 session = MagicMock()
287 calls = {"builds": 0}
289 def get(url, params=None):
290 if url.endswith("+snaps"):
291 return _response(recipe)
292 calls["builds"] += 1
293 if calls["builds"] == 1:
294 return _response(page1)
295 raise Exception("read timed out")
297 session.get.side_effect = get
298 client = LaunchpadProvenance(session=session)
299 result = client.build_provenance_map(
300 "mumble", max_pages=5, max_recipes=5
301 )
303 self.assertTrue(result["failed"])
304 self.assertIn("1721", result["revisions"])
306 def _github_recipe(self, link="https://lp/p1"):
307 return {
308 "entries": [
309 {
310 "store_name": "mumble",
311 "git_repository_url": (
312 "https://github.com/snapcrafters/mumble"
313 ),
314 "completed_builds_collection_link": link,
315 }
316 ]
317 }
319 def test_stops_paginating_once_wanted_revisions_are_found(self):
320 page1 = {
321 "entries": [_build("amd64", 1721, "aaa")],
322 "next_collection_link": "https://lp/p2",
323 }
324 client = self._client(self._github_recipe(), [page1])
325 result = client.build_provenance_map(
326 "mumble", max_pages=5, max_recipes=5, wanted={"1721"}
327 )
329 self.assertFalse(result["failed"])
330 self.assertIn("1721", result["revisions"])
331 self.assertEqual(client.session.get.call_count, 2)
333 def test_keeps_paginating_while_a_wanted_revision_is_missing(self):
334 page1 = {
335 "entries": [_build("amd64", 1721, "aaa")],
336 "next_collection_link": "https://lp/p2",
337 }
338 page2 = {
339 "entries": [_build("arm64", 1798, "bbb")],
340 "next_collection_link": None,
341 }
342 client = self._client(self._github_recipe(), [page1, page2])
343 result = client.build_provenance_map(
344 "mumble", max_pages=5, max_recipes=5, wanted={"1721", "1798"}
345 )
347 self.assertIn("1721", result["revisions"])
348 self.assertIn("1798", result["revisions"])
349 self.assertEqual(client.session.get.call_count, 3)
351 def test_non_uploaded_builds_do_not_satisfy_wanted(self):
352 page1 = {
353 "entries": [_build("amd64", 1721, "aaa", status="Failed")],
354 "next_collection_link": "https://lp/p2",
355 }
356 page2 = {
357 "entries": [_build("amd64", 1721, "aaa")],
358 "next_collection_link": None,
359 }
360 client = self._client(self._github_recipe(), [page1, page2])
361 result = client.build_provenance_map(
362 "mumble", max_pages=5, max_recipes=5, wanted={"1721"}
363 )
365 self.assertEqual(client.session.get.call_count, 3)
366 self.assertIn("1721", result["revisions"])
368 def test_build_without_commit_does_not_satisfy_wanted(self):
369 incomplete = _build("amd64", 1721, "aaa")
370 incomplete["revision_id"] = None
371 page1 = {
372 "entries": [incomplete],
373 "next_collection_link": "https://lp/p2",
374 }
375 page2 = {
376 "entries": [_build("amd64", 1721, "aaa")],
377 "next_collection_link": None,
378 }
379 client = self._client(self._github_recipe(), [page1, page2])
380 result = client.build_provenance_map(
381 "mumble", max_pages=5, max_recipes=5, wanted={"1721"}
382 )
384 self.assertEqual(client.session.get.call_count, 3)
385 self.assertIn("1721", result["revisions"])
387 def test_wanted_accepts_ints(self):
388 page1 = {
389 "entries": [_build("amd64", 1721, "aaa")],
390 "next_collection_link": "https://lp/p2",
391 }
392 client = self._client(self._github_recipe(), [page1])
393 result = client.build_provenance_map(
394 "mumble", max_pages=5, max_recipes=5, wanted={1721}
395 )
397 self.assertEqual(client.session.get.call_count, 2)
398 self.assertIn("1721", result["revisions"])
400 def test_page_failure_is_not_an_error_when_wanted_is_complete(self):
401 page1 = {
402 "entries": [_build("amd64", 1721, "aaa")],
403 "next_collection_link": "https://lp/p2",
404 }
405 session = MagicMock()
406 calls = {"builds": 0}
408 def get(url, params=None):
409 if url.endswith("+snaps"):
410 return _response(self._github_recipe())
411 calls["builds"] += 1
412 if calls["builds"] == 1:
413 return _response(page1)
414 raise Exception("read timed out")
416 session.get.side_effect = get
417 client = LaunchpadProvenance(session=session)
418 result = client.build_provenance_map(
419 "mumble", max_pages=5, max_recipes=5, wanted={"1721", "1798"}
420 )
421 self.assertTrue(result["failed"])
422 self.assertEqual(result["reason"], "launchpad_error")
424 calls["builds"] = 0
425 result = client.build_provenance_map(
426 "mumble", max_pages=5, max_recipes=5, wanted={"1721"}
427 )
428 self.assertFalse(result["failed"])
429 self.assertIsNone(result["reason"])
430 self.assertIn("1721", result["revisions"])
432 def test_page_failure_is_still_an_error_without_wanted(self):
433 page1 = {
434 "entries": [_build("amd64", 1721, "aaa")],
435 "next_collection_link": "https://lp/p2",
436 }
437 session = MagicMock()
438 calls = {"builds": 0}
440 def get(url, params=None):
441 if url.endswith("+snaps"):
442 return _response(self._github_recipe())
443 calls["builds"] += 1
444 if calls["builds"] == 1:
445 return _response(page1)
446 raise Exception("read timed out")
448 session.get.side_effect = get
449 client = LaunchpadProvenance(session=session)
450 result = client.build_provenance_map(
451 "mumble", max_pages=5, max_recipes=5
452 )
454 self.assertTrue(result["failed"])
456 def test_non_github_repo_yields_no_commit_url(self):
457 recipe = {
458 "entries": [
459 {
460 "store_name": "mumble",
461 "git_repository_url": "https://gitlab.com/foo/mumble",
462 "completed_builds_collection_link": "https://lp/p1",
463 }
464 ]
465 }
466 page = {
467 "entries": [_build("amd64", 1721, "aaa")],
468 "next_collection_link": None,
469 }
471 client = self._client(recipe, [page])
472 result = client.build_provenance_map(
473 "mumble", max_pages=5, max_recipes=5
474 )
476 self.assertIsNone(result["github_repository"])
477 self.assertIsNone(result["revisions"]["1721"]["amd64"]["commit_url"])
479 def test_launchpad_hosted_repo_yields_commit_url(self):
480 recipe = {
481 "entries": [
482 {
483 "store_name": "firefox",
484 "git_repository_url": None,
485 "git_repository_link": (
486 "https://api.launchpad.net/devel/~mozilla-snaps"
487 "/firefox-snap/+git/firefox-snap"
488 ),
489 "completed_builds_collection_link": "https://lp/p1",
490 }
491 ]
492 }
493 page = {
494 "entries": [_build("amd64", 8763, "659a47f4")],
495 "next_collection_link": None,
496 }
498 client = self._client(recipe, [page])
499 result = client.build_provenance_map(
500 "firefox", max_pages=5, max_recipes=5
501 )
503 self.assertIsNone(result["github_repository"])
504 self.assertEqual(
505 result["launchpad_repository"],
506 "~mozilla-snaps/firefox-snap/+git/firefox-snap",
507 )
508 amd64 = result["revisions"]["8763"]["amd64"]
509 self.assertEqual(
510 amd64["commit_url"],
511 "https://git.launchpad.net/~mozilla-snaps/firefox-snap"
512 "/+git/firefox-snap/commit/?id=659a47f4",
513 )
515 def test_github_wins_over_launchpad_link(self):
516 recipe = {
517 "entries": [
518 {
519 "store_name": "mumble",
520 "git_repository_url": (
521 "https://github.com/snapcrafters/mumble"
522 ),
523 "git_repository_link": (
524 "https://api.launchpad.net/devel/~x/+git/y"
525 ),
526 "completed_builds_collection_link": "https://lp/p1",
527 }
528 ]
529 }
530 page = {
531 "entries": [_build("amd64", 1721, "aaa")],
532 "next_collection_link": None,
533 }
535 client = self._client(recipe, [page])
536 result = client.build_provenance_map(
537 "mumble", max_pages=5, max_recipes=5
538 )
540 self.assertEqual(
541 result["revisions"]["1721"]["amd64"]["commit_url"],
542 "https://github.com/snapcrafters/mumble/commit/aaa",
543 )
545 def test_timeout_is_reported_as_launchpad_timeout(self):
546 recipe = {
547 "entries": [
548 {
549 "store_name": "mumble",
550 "git_repository_url": (
551 "https://github.com/snapcrafters/mumble"
552 ),
553 "completed_builds_collection_link": "https://lp/p1",
554 }
555 ]
556 }
557 session = MagicMock()
559 def get(url, params=None):
560 if url.endswith("+snaps"):
561 return _response(recipe)
562 raise ApiTimeoutError("took too long")
564 session.get.side_effect = get
565 client = LaunchpadProvenance(session=session)
566 result = client.build_provenance_map(
567 "mumble", max_pages=5, max_recipes=5
568 )
570 self.assertTrue(result["failed"])
571 self.assertEqual(result["reason"], "launchpad_timeout")
573 def test_other_errors_are_reported_as_launchpad_error(self):
574 recipe = {
575 "entries": [
576 {
577 "store_name": "mumble",
578 "git_repository_url": (
579 "https://github.com/snapcrafters/mumble"
580 ),
581 "completed_builds_collection_link": "https://lp/p1",
582 }
583 ]
584 }
585 session = MagicMock()
587 def get(url, params=None):
588 if url.endswith("+snaps"):
589 return _response(recipe)
590 raise Exception("boom")
592 session.get.side_effect = get
593 client = LaunchpadProvenance(session=session)
594 result = client.build_provenance_map(
595 "mumble", max_pages=5, max_recipes=5
596 )
598 self.assertEqual(result["reason"], "launchpad_error")
600 def test_successful_scan_has_no_reason(self):
601 recipe = {
602 "entries": [
603 {
604 "store_name": "mumble",
605 "git_repository_url": (
606 "https://github.com/snapcrafters/mumble"
607 ),
608 "completed_builds_collection_link": "https://lp/p1",
609 }
610 ]
611 }
612 page = {
613 "entries": [_build("amd64", 1721, "aaa")],
614 "next_collection_link": None,
615 }
617 client = self._client(recipe, [page])
618 result = client.build_provenance_map(
619 "mumble", max_pages=5, max_recipes=5
620 )
622 self.assertFalse(result["failed"])
623 self.assertIsNone(result["reason"])
625 def test_recipe_lookup_timeout_reports_launchpad_timeout(self):
626 session = MagicMock()
627 session.get.side_effect = ApiTimeoutError("took too long")
628 client = LaunchpadProvenance(session=session)
630 result = client.build_provenance_map(
631 "mumble", max_pages=5, max_recipes=5
632 )
634 self.assertTrue(result["failed"])
635 self.assertEqual(result["reason"], "launchpad_timeout")
636 self.assertEqual(result["revisions"], {})
638 def test_recipe_lookup_error_reports_launchpad_error(self):
639 session = MagicMock()
640 session.get.side_effect = Exception("boom")
641 client = LaunchpadProvenance(session=session)
643 result = client.build_provenance_map(
644 "mumble", max_pages=5, max_recipes=5
645 )
647 self.assertTrue(result["failed"])
648 self.assertEqual(result["reason"], "launchpad_error")
650 def test_no_recipe_returns_empty(self):
651 session = MagicMock()
652 session.get.return_value = _response({"entries": []})
653 client = LaunchpadProvenance(session=session)
655 result = client.build_provenance_map(
656 "ghost-snap", max_pages=5, max_recipes=5
657 )
659 self.assertEqual(result["github_repository"], None)
660 self.assertEqual(result["revisions"], {})
663def _recipe(name, url, link, uploads=True, modified="2026-01-01T00:00:00Z"):
664 return {
665 "store_name": "mumble",
666 "git_repository_url": url,
667 "completed_builds_collection_link": link,
668 "can_upload_to_store": uploads,
669 "date_last_modified": modified,
670 "_name": name,
671 }
674class TestRecipeSelection(TestCase):
675 """One store name matches many recipes, so the first is a lottery."""
677 def _client(self, entries, pages_by_link):
678 session = MagicMock()
680 def get(url, params=None):
681 if url.endswith("+snaps"):
682 return _response({"entries": entries})
683 return _response(pages_by_link[url])
685 session.get.side_effect = get
686 return LaunchpadProvenance(session=session)
688 def test_scans_past_a_recipe_with_no_uploads(self):
689 # The firefox shape: a personal recipe sorts above the real one.
690 personal = _recipe("personal", None, "https://lp/personal")
691 official = _recipe(
692 "official",
693 "https://github.com/snapcrafters/mumble",
694 "https://lp/official",
695 )
696 pages = {
697 "https://lp/personal": {
698 "entries": [_build("amd64", 999, "zzz", status="Failed")],
699 "next_collection_link": None,
700 },
701 "https://lp/official": {
702 "entries": [_build("amd64", 1721, "aaa")],
703 "next_collection_link": None,
704 },
705 }
707 client = self._client([personal, official], pages)
708 result = client.build_provenance_map(
709 "mumble", max_pages=5, max_recipes=5
710 )
712 self.assertIn("1721", result["revisions"])
713 # The reported source is the recipe that produced revisions.
714 self.assertEqual(result["github_repository"], "snapcrafters/mumble")
716 def test_merges_revisions_across_recipes(self):
717 # Real recipes are split by series, each holding part of the history.
718 old = _recipe("old", "https://github.com/x/old", "https://lp/old")
719 new = _recipe("new", "https://github.com/x/new", "https://lp/new")
720 pages = {
721 "https://lp/old": {
722 "entries": [_build("amd64", 1000, "aaa")],
723 "next_collection_link": None,
724 },
725 "https://lp/new": {
726 "entries": [_build("amd64", 2000, "bbb")],
727 "next_collection_link": None,
728 },
729 }
731 client = self._client([old, new], pages)
732 result = client.build_provenance_map(
733 "mumble", max_pages=5, max_recipes=5
734 )
736 self.assertIn("1000", result["revisions"])
737 self.assertIn("2000", result["revisions"])
738 # Each row carries its own repository, since they differ.
739 self.assertEqual(
740 result["revisions"]["1000"]["amd64"]["github_repository"], "x/old"
741 )
742 self.assertEqual(
743 result["revisions"]["2000"]["amd64"]["github_repository"], "x/new"
744 )
746 def test_upload_capable_recipes_are_ranked_first(self):
747 cannot = _recipe(
748 "cannot",
749 None,
750 "https://lp/cannot",
751 uploads=False,
752 modified="2026-06-01T00:00:00Z",
753 )
754 can = _recipe(
755 "can",
756 "https://github.com/x/can",
757 "https://lp/can",
758 modified="2020-01-01T00:00:00Z",
759 )
760 pages = {
761 "https://lp/can": {
762 "entries": [_build("amd64", 1721, "aaa")],
763 "next_collection_link": None,
764 },
765 "https://lp/cannot": {
766 "entries": [_build("amd64", 1721, "zzz")],
767 "next_collection_link": None,
768 },
769 }
771 client = self._client([cannot, can], pages)
772 result = client.build_provenance_map(
773 "mumble", max_pages=5, max_recipes=5
774 )
776 # Ranked first despite being far older, and first writer wins.
777 self.assertEqual(
778 result["revisions"]["1721"]["amd64"]["commit_sha"], "aaa"
779 )
781 def test_max_recipes_bounds_the_scan(self):
782 entries = [
783 _recipe(f"r{i}", None, f"https://lp/r{i}") for i in range(4)
784 ]
785 pages = {
786 f"https://lp/r{i}": {"entries": [], "next_collection_link": None}
787 for i in range(4)
788 }
789 scanned = []
791 session = MagicMock()
793 def get(url, params=None):
794 if url.endswith("+snaps"):
795 return _response({"entries": entries})
796 scanned.append(url)
797 return _response(pages[url])
799 session.get.side_effect = get
800 client = LaunchpadProvenance(session=session)
801 client.build_provenance_map("mumble", max_pages=5, max_recipes=2)
803 self.assertEqual(len(scanned), 2)
805 def test_failed_recipe_is_not_an_error_if_others_cover_wanted(self):
806 good = _recipe("good", "https://github.com/a/b", "https://lp/good")
807 bad = _recipe("bad", None, "https://lp/bad")
808 session = MagicMock()
810 def get(url, params=None):
811 if url.endswith("+snaps"):
812 return _response({"entries": [good, bad]})
813 if url == "https://lp/good":
814 return _response(
815 {
816 "entries": [_build("amd64", 1721, "aaa")],
817 "next_collection_link": None,
818 }
819 )
820 raise Exception("read timed out")
822 session.get.side_effect = get
823 client = LaunchpadProvenance(session=session)
825 result = client.build_provenance_map(
826 "mumble", max_pages=5, max_recipes=5, wanted={"1721"}
827 )
828 self.assertFalse(result["failed"])
829 self.assertIn("1721", result["revisions"])
831 result = client.build_provenance_map(
832 "mumble", max_pages=5, max_recipes=5, wanted={"1721", "1798"}
833 )
834 self.assertTrue(result["failed"])
836 def test_failure_does_not_short_circuit_other_recipes(self):
837 entries = [
838 _recipe(f"r{i}", None, f"https://lp/r{i}") for i in range(4)
839 ]
840 attempts = []
841 lock = Lock()
843 session = MagicMock()
845 def get(url, params=None):
846 if url.endswith("+snaps"):
847 return _response({"entries": entries})
848 with lock:
849 attempts.append(url)
850 raise Exception("read timed out")
852 session.get.side_effect = get
853 client = LaunchpadProvenance(session=session)
854 result = client.build_provenance_map(
855 "mumble", max_pages=5, max_recipes=4
856 )
858 self.assertTrue(result["failed"])
859 self.assertEqual(
860 sorted(attempts), [f"https://lp/r{i}" for i in range(4)]
861 )