Coverage for tests/store/tests_details.py: 97%
241 statements
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-08 12:33 +0000
« prev ^ index » next coverage.py v7.15.0, created at 2026-07-08 12:33 +0000
1import copy
2import responses
3from urllib.parse import urlencode
4from flask_testing import TestCase
5from webapp.app import create_app
6from unittest.mock import patch
7from cache.cache_utility import redis_cache
9POPULAR_PATH = "webapp.store.views.snap_recommendations.get_popular"
10RECENT_PATH = "webapp.store.views.snap_recommendations.get_recent"
11TREND_PATH = "webapp.store.views.snap_recommendations.get_trending"
12TOP_PATH = "webapp.store.views.snap_recommendations.get_top_rated"
13CATEGORIES_PATH = "webapp.store.views.device_gateway.get_categories"
14FEATURED_PATH = "webapp.store.views.device_gateway.get_featured_snaps"
17EMPTY_EXTRA_DETAILS_PAYLOAD = {"aliases": None, "package_name": "vault"}
18SNAP_PAYLOAD = {
19 "snap-id": "id",
20 "name": "toto",
21 "default-track": None,
22 "snap": {
23 "title": "Snap Title",
24 "summary": "This is a summary",
25 "description": "this is a description",
26 "media": [],
27 "license": "license",
28 "publisher": {
29 "display-name": "Toto",
30 "username": "toto",
31 "validation": True,
32 },
33 "categories": [{"name": "test"}],
34 "trending": False,
35 "unlisted": False,
36 "links": {},
37 },
38 "channel-map": [
39 {
40 "channel": {
41 "architecture": "amd64",
42 "name": "stable",
43 "risk": "stable",
44 "track": "latest",
45 "released-at": "2018-09-18T14:45:28.064633+00:00",
46 },
47 "created-at": "2018-09-18T14:45:28.064633+00:00",
48 "version": "1.0",
49 "confinement": "conf",
50 "download": {"size": 100000},
51 "revision": 1,
52 }
53 ],
54}
57class GetDetailsPageTest(TestCase):
58 def setUp(self):
59 super().setUp()
60 redis_cache.fallback.clear()
61 self.snap_name = "toto"
62 self.snap_id = "id"
63 self.revision = 1
64 self.api_url = "".join(
65 [
66 "https://api.snapcraft.io/v2/",
67 "snaps/info/",
68 self.snap_name,
69 "?",
70 urlencode(
71 {
72 "fields": ",".join(
73 [
74 "title",
75 "summary",
76 "description",
77 "license",
78 "contact",
79 "website",
80 "publisher",
81 "media",
82 "download",
83 "version",
84 "created-at",
85 "confinement",
86 "categories",
87 "trending",
88 "unlisted",
89 "links",
90 "revision",
91 ]
92 )
93 }
94 ),
95 ]
96 )
97 self.endpoint_url = "/" + self.snap_name
98 self.api_url_details = "".join(
99 [
100 "https://api.snapcraft.io/api/v1/",
101 "snaps/details/",
102 self.snap_name,
103 "?",
104 urlencode({"fields": ",".join(["aliases"])}),
105 ]
106 )
107 self.api_url_sboms = "".join(
108 [
109 "https://api.snapcraft.io/api/v1/",
110 "sboms/download/",
111 f"sbom_snap_{self.snap_id}_{self.revision}.spdx2.3.json",
112 ]
113 )
115 def create_app(self):
116 app = create_app(testing=True)
117 app.secret_key = "secret_key"
118 app.config["WTF_CSRF_METHODS"] = []
120 return app
122 def assert_not_in_context(self, name):
123 try:
124 self.get_context_variable(name)
125 except Exception:
126 # flask-testing throws exception if context doesn't have "name"
127 # that's what we expect so we just return and let the test pass
128 return
129 # If we reach this point it means the variable IS in context
130 self.fail(f"Context variable exists: {name}")
132 @responses.activate
133 def test_more_from_publisher_uses_api(self):
134 snap_name = "clion"
135 payload = copy.deepcopy(SNAP_PAYLOAD)
136 payload["name"] = snap_name
137 payload["snap"]["title"] = "CLion"
138 payload["snap"]["publisher"] = {
139 "display-name": "JetBrains",
140 "username": "jetbrains",
141 "validation": "verified",
142 }
144 info_url = "".join(
145 [
146 "https://api.snapcraft.io/v2/snaps/info/",
147 snap_name,
148 ]
149 )
150 find_url = "https://api.snapcraft.io/v2/snaps/find"
151 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics"
152 sbom_url = "".join(
153 [
154 "https://api.snapcraft.io/api/v1/sboms/download/",
155 f"sbom_snap_{self.snap_id}_{self.revision}.spdx2.3.json",
156 ]
157 )
159 def find_snap(name, title):
160 return {
161 "name": name,
162 "snap": {
163 "title": title,
164 "summary": title + " summary",
165 "media": [],
166 },
167 }
169 find_response = {
170 "results": [
171 find_snap("clion", "CLion"),
172 find_snap("intellij-idea", "IntelliJ IDEA"),
173 find_snap("goland", "GoLand"),
174 find_snap("webstorm", "WebStorm"),
175 ]
176 }
178 responses.add(responses.GET, info_url, json=payload, status=200)
179 responses.add(responses.GET, find_url, json=find_response, status=200)
180 responses.add(responses.POST, metrics_url, json={}, status=200)
181 responses.add(responses.HEAD, sbom_url, json={}, status=200)
183 response = self.client.get("/" + snap_name)
184 self.assertEqual(response.status_code, 200)
186 # publisher_snaps excludes the current snap (clion) and the
187 # featured snaps (intellij-idea)
188 publisher_snaps = self.get_context_variable("publisher_snaps")
189 names = {snap["package_name"] for snap in publisher_snaps}
190 self.assertEqual(names, {"goland", "webstorm"})
191 goland = next(
192 s for s in publisher_snaps if s["package_name"] == "goland"
193 )
194 self.assertEqual(goland["title"], "GoLand")
196 # Featured snaps are hydrated from the API. intellij-idea is in
197 # the API response so it stays, pycharm is not so it is dropped.
198 featured = self.get_context_variable("publisher_featured_snaps")
199 featured_names = [snap["package_name"] for snap in featured]
200 self.assertEqual(featured_names, ["intellij-idea"])
201 self.assertEqual(featured[0]["title"], "IntelliJ IDEA")
202 self.assertEqual(featured[0]["background"], "#000000")
204 @responses.activate
205 def test_has_sboms_success(self):
206 payload = SNAP_PAYLOAD
208 responses.add(
209 responses.Response(
210 method="GET", url=self.api_url, json=payload, status=200
211 )
212 )
213 responses.add(
214 responses.Response(
215 method="HEAD", url=self.api_url_sboms, json={}, status=200
216 )
217 )
219 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics"
220 responses.add(
221 responses.Response(
222 method="POST", url=metrics_url, json={}, status=200
223 )
224 )
226 response = self.client.get(self.endpoint_url)
228 assert response.status_code == 200
230 @responses.activate
231 def test_has_sboms_error(self):
232 payload = SNAP_PAYLOAD
234 responses.add(
235 responses.Response(
236 method="GET", url=self.api_url, json=payload, status=200
237 )
238 )
239 responses.add(
240 responses.Response(
241 method="HEAD", url=self.api_url_sboms, json={}, status=404
242 )
243 )
245 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics"
246 responses.add(
247 responses.Response(
248 method="POST", url=metrics_url, json={}, status=200
249 )
250 )
252 response = self.client.head(self.api_url_sboms)
254 assert response.status_code == 404
256 @responses.activate
257 def test_api_404(self):
258 payload = {"error-list": [{"code": "resource-not-found"}]}
259 responses.add(
260 responses.Response(
261 method="GET", url=self.api_url, json=payload, status=404
262 )
263 )
265 response = self.client.get(self.endpoint_url)
267 called = responses.calls[0]
268 assert called.request.url == self.api_url
269 assert len(responses.calls) == 1
271 assert response.status_code == 404
273 @responses.activate
274 def test_extra_details_error(self):
275 payload = SNAP_PAYLOAD
276 extra_details_payload = {
277 "error_list": [
278 {
279 "code": "resource-not-found",
280 "message": "No snap named 'toto' found in series '16'.",
281 }
282 ],
283 "errors": ["No snap named 'toto' found in series '16'."],
284 "result": "error",
285 }
287 responses.add(
288 responses.Response(
289 method="GET", url=self.api_url, json=payload, status=200
290 )
291 )
292 responses.add(
293 responses.Response(
294 method="GET",
295 url=self.api_url_details,
296 json=extra_details_payload,
297 status=404,
298 )
299 )
300 responses.add(
301 responses.Response(
302 method="HEAD", url=self.api_url_sboms, json={}, status=200
303 )
304 )
305 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics"
306 responses.add(
307 responses.Response(
308 method="POST", url=metrics_url, json={}, status=200
309 )
310 )
312 response = self.client.get(self.endpoint_url)
314 assert response.status_code == 200
316 @responses.activate
317 def test_api_500(self):
318 payload = {"error-list": []}
319 responses.add(
320 responses.Response(
321 method="GET", url=self.api_url, json=payload, status=500
322 )
323 )
325 response = self.client.get(self.endpoint_url)
327 assert len(responses.calls) == 1
328 called = responses.calls[0]
329 assert called.request.url == self.api_url
331 assert response.status_code == 502
333 @responses.activate
334 def test_api_500_no_answer(self):
335 responses.add(
336 responses.Response(method="GET", url=self.api_url, status=500)
337 )
339 response = self.client.get(self.endpoint_url)
341 assert len(responses.calls) == 1
342 called = responses.calls[0]
343 assert called.request.url == self.api_url
345 assert response.status_code == 502
347 @responses.activate
348 def test_no_channel_map(self):
349 payload = {
350 "snap-id": "id",
351 "name": "toto",
352 "default-track": None,
353 "snap": {
354 "title": "Snap Title",
355 "summary": "This is a summary",
356 "description": "this is a description",
357 "media": [],
358 "license": "license",
359 "publisher": {
360 "display-name": "Toto",
361 "username": "toto",
362 "validation": True,
363 },
364 "categories": [{"name": "test"}],
365 "trending": False,
366 "unlisted": False,
367 "links": {},
368 },
369 }
371 responses.add(
372 responses.Response(
373 method="GET", url=self.api_url, json=payload, status=200
374 )
375 )
376 responses.add(
377 responses.Response(
378 method="GET",
379 url=self.api_url_details,
380 json=EMPTY_EXTRA_DETAILS_PAYLOAD,
381 status=200,
382 )
383 )
385 response = self.client.get(self.endpoint_url)
387 assert response.status_code == 404
389 @responses.activate
390 def test_user_connected(self):
391 payload = SNAP_PAYLOAD
393 responses.add(
394 responses.Response(
395 method="GET", url=self.api_url, json=payload, status=200
396 )
397 )
398 responses.add(
399 responses.Response(
400 method="GET",
401 url=self.api_url_details,
402 json=EMPTY_EXTRA_DETAILS_PAYLOAD,
403 status=200,
404 )
405 )
406 responses.add(
407 responses.Response(
408 method="HEAD", url=self.api_url_sboms, json={}, status=200
409 )
410 )
412 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics"
413 responses.add(
414 responses.Response(
415 method="POST", url=metrics_url, json={}, status=200
416 )
417 )
419 with self.client.session_transaction() as s:
420 # make test session 'authenticated'
421 s["publisher"] = {"nickname": "toto", "fullname": "Totinio"}
422 s["macaroon_exchanged"] = "test"
423 # mock test user snaps list
424 s["user_snaps"] = {"toto": {"snap-id": "test"}}
426 response = self.client.get(self.endpoint_url)
428 self.assert200(response)
429 self.assert_context("is_users_snap", True)
431 @responses.activate
432 def test_user_not_connected(self):
433 payload = SNAP_PAYLOAD
435 responses.add(
436 responses.Response(
437 method="GET", url=self.api_url, json=payload, status=200
438 )
439 )
440 responses.add(
441 responses.Response(
442 method="GET",
443 url=self.api_url_details,
444 json=EMPTY_EXTRA_DETAILS_PAYLOAD,
445 status=200,
446 )
447 )
448 responses.add(
449 responses.Response(
450 method="HEAD", url=self.api_url_sboms, json={}, status=200
451 )
452 )
454 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics"
455 responses.add(
456 responses.Response(
457 method="POST", url=metrics_url, json={}, status=200
458 )
459 )
461 response = self.client.get(self.endpoint_url)
463 assert response.status_code == 200
464 self.assert_context("is_users_snap", False)
466 @responses.activate
467 def test_user_connected_on_not_own_snap(self):
468 payload = SNAP_PAYLOAD
470 responses.add(
471 responses.Response(
472 method="GET", url=self.api_url, json=payload, status=200
473 )
474 )
475 responses.add(
476 responses.Response(
477 method="GET",
478 url=self.api_url_details,
479 json=EMPTY_EXTRA_DETAILS_PAYLOAD,
480 status=200,
481 )
482 )
483 responses.add(
484 responses.Response(
485 method="HEAD", url=self.api_url_sboms, json={}, status=200
486 )
487 )
489 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics"
490 responses.add(
491 responses.Response(
492 method="POST", url=metrics_url, json={}, status=200
493 )
494 )
496 with self.client.session_transaction() as s:
497 s["publisher"] = {"nickname": "greg"}
499 response = self.client.get(self.endpoint_url)
501 assert response.status_code == 200
502 self.assert_context("is_users_snap", False)
504 @responses.activate
505 def test_extra_details(self):
506 payload = SNAP_PAYLOAD
507 payload_extra_details = {
508 "aliases": [
509 {"name": "nu", "target": "nu"},
510 {
511 "name": "nu_plugin_stress_internals",
512 "target": "nu-plugin-stress-internals",
513 },
514 {"name": "nu_plugin_gstat", "target": "nu-plugin-gstat"},
515 {"name": "nu_plugin_formats", "target": "nu-plugin-formats"},
516 {"name": "nu_plugin_polars", "target": "nu-plugin-polars"},
517 ],
518 "package_name": "toto",
519 }
521 responses.add(
522 responses.Response(
523 method="GET", url=self.api_url, json=payload, status=200
524 )
525 )
526 responses.add(
527 responses.Response(
528 method="GET",
529 url=self.api_url_details,
530 json=payload_extra_details,
531 status=200,
532 )
533 )
534 responses.add(
535 responses.Response(
536 method="HEAD", url=self.api_url_sboms, json={}, status=200
537 )
538 )
539 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics"
540 responses.add(
541 responses.Response(
542 method="POST", url=metrics_url, json={}, status=200
543 )
544 )
546 response = self.client.get(self.endpoint_url)
547 assert response.status_code == 200
548 self.assert_context(
549 "aliases",
550 [
551 ["toto.nu", "nu"],
552 [
553 "toto.nu-plugin-stress-internals",
554 "nu_plugin_stress_internals",
555 ],
556 ["toto.nu-plugin-gstat", "nu_plugin_gstat"],
557 ["toto.nu-plugin-formats", "nu_plugin_formats"],
558 ["toto.nu-plugin-polars", "nu_plugin_polars"],
559 ],
560 )
562 @responses.activate
563 def test_explore_uses_redis_cache(self):
564 """When Redis has cached explore data, the recommendation APIs
565 and category lookup should not be called and the view should
566 return successfully using the cached values.
567 """
568 # seed redis
569 popular = [
570 {
571 "details": {
572 "name": "/pop1",
573 "icon": "",
574 "title": "Pop 1",
575 "publisher": "Pub 1",
576 "developer_validation": None,
577 "summary": "Popular snap",
578 },
579 }
580 ]
581 recent = [
582 {
583 "details": {
584 "name": "/recent1",
585 "icon": "",
586 "title": "Recent 1",
587 "publisher": "Pub 2",
588 "developer_validation": None,
589 "summary": "Recent snap",
590 },
591 }
592 ]
593 trending = [
594 {
595 "details": {
596 "name": "/trend1",
597 "icon": "",
598 "title": "Trend 1",
599 "publisher": "Pub 3",
600 "developer_validation": None,
601 "summary": "Trending snap",
602 },
603 }
604 ]
605 top_rated = [
606 {
607 "details": {
608 "name": "/top1",
609 "icon": "",
610 "title": "Top 1",
611 "publisher": "Pub 4",
612 "developer_validation": None,
613 "summary": "Top rated snap",
614 },
615 }
616 ]
617 categories = [{"slug": "cat1", "name": "Cat 1"}]
618 featured = {
619 "_embedded": {
620 "clickindex:package": [
621 {
622 "developer_validation": True,
623 "media": [],
624 "publisher": "Featured Pub",
625 "package_name": "featured-snap",
626 "summary": "Featured snap",
627 "title": "Featured Snap",
628 }
629 ]
630 }
631 }
632 expected_featured = [
633 {
634 "details": {
635 "developer_validation": True,
636 "icon": "",
637 "publisher": "Featured Pub",
638 "name": "featured-snap",
639 "summary": "Featured snap",
640 "title": "Featured Snap",
641 }
642 }
643 ]
645 redis_cache.set("explore:popular-snaps", popular, ttl=3600)
646 redis_cache.set("explore:recent-snaps", recent, ttl=3600)
647 redis_cache.set("explore:trending-snaps", trending, ttl=3600)
648 redis_cache.set("explore:top-rated-snaps", top_rated, ttl=3600)
649 redis_cache.set("explore:categories", categories, ttl=3600)
651 with patch(POPULAR_PATH) as mock_popular:
652 with patch(RECENT_PATH) as mock_recent:
653 with patch(TREND_PATH) as mock_trending:
654 with patch(TOP_PATH) as mock_top_rated:
655 with patch(CATEGORIES_PATH) as mock_categories:
656 with patch(
657 FEATURED_PATH, return_value=featured
658 ) as mock_featured:
659 response = self.client.get("/store")
661 self.assert200(response)
662 self.assert_context(
663 "featured_snaps", expected_featured
664 )
666 mock_popular.assert_not_called()
667 mock_recent.assert_not_called()
668 mock_trending.assert_not_called()
669 mock_top_rated.assert_not_called()
670 mock_categories.assert_not_called()
671 mock_featured.assert_called_once_with(
672 fields=(
673 "developer_validation,media,"
674 "package_name,publisher,summary,"
675 "title"
676 )
677 )
679 @responses.activate
680 def test_explore_populates_cache_when_empty(self):
681 """When Redis cache is empty, the recommendation/device methods
682 should be called and their results stored in Redis for subsequent
683 requests.
684 """
685 featured = {
686 "_embedded": {
687 "clickindex:package": [
688 {
689 "developer_validation": None,
690 "media": [],
691 "publisher": "Featured Pub",
692 "package_name": "featured-snap",
693 "summary": "Featured snap",
694 "title": "Featured Snap",
695 }
696 ]
697 }
698 }
699 expected_featured = [
700 {
701 "details": {
702 "developer_validation": None,
703 "icon": "",
704 "publisher": "Featured Pub",
705 "name": "featured-snap",
706 "summary": "Featured snap",
707 "title": "Featured Snap",
708 }
709 }
710 ]
712 with patch(
713 POPULAR_PATH,
714 return_value=[
715 {
716 "details": {
717 "name": "/popx",
718 "icon": "",
719 "title": "Pop X",
720 "publisher": "Pub X",
721 "developer_validation": None,
722 "summary": "Popular x",
723 }
724 }
725 ],
726 ) as mock_popular:
727 with patch(
728 RECENT_PATH,
729 return_value=[
730 {
731 "details": {
732 "name": "/recentx",
733 "icon": "",
734 "title": "Recent X",
735 "publisher": "Pub RX",
736 "developer_validation": None,
737 "summary": "Recent x",
738 }
739 }
740 ],
741 ) as mock_recent:
742 with patch(
743 TREND_PATH,
744 return_value=[
745 {
746 "details": {
747 "name": "/trendx",
748 "icon": "",
749 "title": "Trend X",
750 "publisher": "Pub TX",
751 "developer_validation": None,
752 "summary": "Trend x",
753 }
754 }
755 ],
756 ) as mock_trending:
757 with patch(
758 TOP_PATH,
759 return_value=[
760 {
761 "details": {
762 "name": "/topx",
763 "icon": "",
764 "title": "Top X",
765 "publisher": "Pub TX",
766 "developer_validation": None,
767 "summary": "Top x",
768 }
769 }
770 ],
771 ) as mock_top_rated:
772 with patch(
773 CATEGORIES_PATH,
774 return_value=[{"slug": "c1", "name": "C1"}],
775 ) as mock_categories:
776 with patch(
777 FEATURED_PATH, return_value=featured
778 ) as mock_featured:
779 response = self.client.get("/store")
781 self.assert200(response)
782 self.assert_context(
783 "featured_snaps", expected_featured
784 )
786 # cache-populating methods were called
787 self.assertTrue(mock_popular.called)
788 self.assertTrue(mock_recent.called)
789 self.assertTrue(mock_trending.called)
790 self.assertTrue(mock_top_rated.called)
791 self.assertTrue(mock_categories.called)
792 self.assertTrue(mock_featured.called)
793 # cached values should now exist
794 pop_cached = redis_cache.get(
795 "explore:popular-snaps"
796 )
797 recent_cached = redis_cache.get(
798 "explore:recent-snaps"
799 )
800 trend_cached = redis_cache.get(
801 "explore:trending-snaps"
802 )
803 top_cached = redis_cache.get(
804 "explore:top-rated-snaps"
805 )
806 categories_cached = redis_cache.get(
807 "explore:categories"
808 )
810 assert pop_cached is not None
811 assert recent_cached is not None
812 assert trend_cached is not None
813 assert top_cached is not None
814 assert categories_cached is not None
817if __name__ == "__main__":
818 import unittest
820 unittest.main()