Coverage for tests/store/tests_details.py: 97%
243 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
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 "sboms",
92 ]
93 )
94 }
95 ),
96 ]
97 )
98 self.endpoint_url = "/" + self.snap_name
99 self.api_url_details = "".join(
100 [
101 "https://api.snapcraft.io/api/v1/",
102 "snaps/details/",
103 self.snap_name,
104 "?",
105 urlencode({"fields": ",".join(["aliases"])}),
106 ]
107 )
109 def create_app(self):
110 app = create_app(testing=True)
111 app.secret_key = "secret_key"
112 app.config["WTF_CSRF_METHODS"] = []
114 return app
116 def assert_not_in_context(self, name):
117 try:
118 self.get_context_variable(name)
119 except Exception:
120 # flask-testing throws exception if context doesn't have "name"
121 # that's what we expect so we just return and let the test pass
122 return
123 # If we reach this point it means the variable IS in context
124 self.fail(f"Context variable exists: {name}")
126 @responses.activate
127 def test_icons_snap_details_page_uses_store_route(self):
128 snap_name = "icons"
129 payload = copy.deepcopy(SNAP_PAYLOAD)
130 payload["name"] = snap_name
132 info_url = "".join(
133 [
134 "https://api.snapcraft.io/v2/snaps/info/",
135 snap_name,
136 ]
137 )
138 details_url = "".join(
139 [
140 "https://api.snapcraft.io/api/v1/snaps/details/",
141 snap_name,
142 "?",
143 urlencode({"fields": ",".join(["aliases"])}),
144 "&channel=",
145 ]
146 )
147 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics"
149 responses.add(responses.GET, info_url, json=payload, status=200)
150 responses.add(
151 responses.GET,
152 details_url,
153 json=EMPTY_EXTRA_DETAILS_PAYLOAD,
154 status=200,
155 )
156 responses.add(responses.POST, metrics_url, json={}, status=200)
158 response = self.client.get("/" + snap_name)
160 self.assertEqual(response.status_code, 200)
161 self.assert_template_used("store/snap-details.html")
162 self.assertEqual(self.get_context_variable("package_name"), snap_name)
164 @responses.activate
165 def test_more_from_publisher_uses_api(self):
166 snap_name = "clion"
167 payload = copy.deepcopy(SNAP_PAYLOAD)
168 payload["name"] = snap_name
169 payload["snap"]["title"] = "CLion"
170 payload["snap"]["publisher"] = {
171 "display-name": "JetBrains",
172 "username": "jetbrains",
173 "validation": "verified",
174 }
176 info_url = "".join(
177 [
178 "https://api.snapcraft.io/v2/snaps/info/",
179 snap_name,
180 ]
181 )
182 find_url = "https://api.snapcraft.io/v2/snaps/find"
183 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics"
185 def find_snap(name, title):
186 return {
187 "name": name,
188 "snap": {
189 "title": title,
190 "summary": title + " summary",
191 "media": [],
192 },
193 }
195 find_response = {
196 "results": [
197 find_snap("clion", "CLion"),
198 find_snap("intellij-idea", "IntelliJ IDEA"),
199 find_snap("goland", "GoLand"),
200 find_snap("webstorm", "WebStorm"),
201 ]
202 }
204 responses.add(responses.GET, info_url, json=payload, status=200)
205 responses.add(responses.GET, find_url, json=find_response, status=200)
206 responses.add(responses.POST, metrics_url, json={}, status=200)
208 response = self.client.get("/" + snap_name)
209 self.assertEqual(response.status_code, 200)
211 # publisher_snaps excludes the current snap (clion) and the
212 # featured snaps (intellij-idea)
213 publisher_snaps = self.get_context_variable("publisher_snaps")
214 names = {snap["package_name"] for snap in publisher_snaps}
215 self.assertEqual(names, {"goland", "webstorm"})
216 goland = next(
217 s for s in publisher_snaps if s["package_name"] == "goland"
218 )
219 self.assertEqual(goland["title"], "GoLand")
221 # Featured snaps are hydrated from the API. intellij-idea is in
222 # the API response so it stays, pycharm is not so it is dropped.
223 featured = self.get_context_variable("publisher_featured_snaps")
224 featured_names = [snap["package_name"] for snap in featured]
225 self.assertEqual(featured_names, ["intellij-idea"])
226 self.assertEqual(featured[0]["title"], "IntelliJ IDEA")
227 self.assertEqual(featured[0]["background"], "#000000")
229 @responses.activate
230 def test_api_404(self):
231 payload = {"error-list": [{"code": "resource-not-found"}]}
232 responses.add(
233 responses.Response(
234 method="GET", url=self.api_url, json=payload, status=404
235 )
236 )
238 response = self.client.get(self.endpoint_url)
240 called = responses.calls[0]
241 assert called.request.url == self.api_url
242 assert len(responses.calls) == 1
244 assert response.status_code == 404
246 @responses.activate
247 def test_extra_details_error(self):
248 payload = SNAP_PAYLOAD
249 extra_details_payload = {
250 "error_list": [
251 {
252 "code": "resource-not-found",
253 "message": "No snap named 'toto' found in series '16'.",
254 }
255 ],
256 "errors": ["No snap named 'toto' found in series '16'."],
257 "result": "error",
258 }
260 responses.add(
261 responses.Response(
262 method="GET", url=self.api_url, json=payload, status=200
263 )
264 )
265 responses.add(
266 responses.Response(
267 method="GET",
268 url=self.api_url_details,
269 json=extra_details_payload,
270 status=404,
271 )
272 )
274 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics"
275 responses.add(
276 responses.Response(
277 method="POST", url=metrics_url, json={}, status=200
278 )
279 )
281 with patch(
282 "webapp.store.snap_details_views.logger.exception"
283 ) as mock_exception:
284 response = self.client.get(self.endpoint_url)
286 assert response.status_code == 200
287 mock_exception.assert_not_called()
289 @responses.activate
290 def test_extra_details_unexpected_error_is_logged(self):
291 payload = SNAP_PAYLOAD
293 responses.add(
294 responses.Response(
295 method="GET", url=self.api_url, json=payload, status=200
296 )
297 )
298 responses.add(
299 responses.Response(
300 method="GET",
301 url=self.api_url_details,
302 json={"error_list": [{"code": "internal-error"}]},
303 status=500,
304 )
305 )
307 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics"
308 responses.add(
309 responses.Response(
310 method="POST", url=metrics_url, json={}, status=200
311 )
312 )
314 with patch(
315 "webapp.store.snap_details_views.logger.exception"
316 ) as mock_exception:
317 response = self.client.get(self.endpoint_url)
319 assert response.status_code == 200
320 mock_exception.assert_called_once_with(
321 "Details endpoint returned an error"
322 )
324 @responses.activate
325 def test_api_500(self):
326 payload = {"error-list": []}
327 responses.add(
328 responses.Response(
329 method="GET", url=self.api_url, json=payload, status=500
330 )
331 )
333 response = self.client.get(self.endpoint_url)
335 assert len(responses.calls) == 1
336 called = responses.calls[0]
337 assert called.request.url == self.api_url
339 assert response.status_code == 502
341 @responses.activate
342 def test_api_500_no_answer(self):
343 responses.add(
344 responses.Response(method="GET", url=self.api_url, status=500)
345 )
347 response = self.client.get(self.endpoint_url)
349 assert len(responses.calls) == 1
350 called = responses.calls[0]
351 assert called.request.url == self.api_url
353 assert response.status_code == 502
355 @responses.activate
356 def test_no_channel_map(self):
357 payload = {
358 "snap-id": "id",
359 "name": "toto",
360 "default-track": None,
361 "snap": {
362 "title": "Snap Title",
363 "summary": "This is a summary",
364 "description": "this is a description",
365 "media": [],
366 "license": "license",
367 "publisher": {
368 "display-name": "Toto",
369 "username": "toto",
370 "validation": True,
371 },
372 "categories": [{"name": "test"}],
373 "trending": False,
374 "unlisted": False,
375 "links": {},
376 },
377 }
379 responses.add(
380 responses.Response(
381 method="GET", url=self.api_url, json=payload, status=200
382 )
383 )
384 responses.add(
385 responses.Response(
386 method="GET",
387 url=self.api_url_details,
388 json=EMPTY_EXTRA_DETAILS_PAYLOAD,
389 status=200,
390 )
391 )
393 response = self.client.get(self.endpoint_url)
395 assert response.status_code == 404
397 @responses.activate
398 def test_user_connected(self):
399 payload = SNAP_PAYLOAD
401 responses.add(
402 responses.Response(
403 method="GET", url=self.api_url, json=payload, status=200
404 )
405 )
406 responses.add(
407 responses.Response(
408 method="GET",
409 url=self.api_url_details,
410 json=EMPTY_EXTRA_DETAILS_PAYLOAD,
411 status=200,
412 )
413 )
415 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics"
416 responses.add(
417 responses.Response(
418 method="POST", url=metrics_url, json={}, status=200
419 )
420 )
422 with self.client.session_transaction() as s:
423 # make test session 'authenticated'
424 s["publisher"] = {"nickname": "toto", "fullname": "Totinio"}
425 s["macaroon_exchanged"] = "test"
426 # mock test user snaps list
427 s["user_snaps"] = {"toto": {"snap-id": "test"}}
429 response = self.client.get(self.endpoint_url)
431 self.assert200(response)
432 self.assert_context("is_users_snap", True)
434 @responses.activate
435 def test_user_not_connected(self):
436 payload = SNAP_PAYLOAD
438 responses.add(
439 responses.Response(
440 method="GET", url=self.api_url, json=payload, status=200
441 )
442 )
443 responses.add(
444 responses.Response(
445 method="GET",
446 url=self.api_url_details,
447 json=EMPTY_EXTRA_DETAILS_PAYLOAD,
448 status=200,
449 )
450 )
452 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics"
453 responses.add(
454 responses.Response(
455 method="POST", url=metrics_url, json={}, status=200
456 )
457 )
459 response = self.client.get(self.endpoint_url)
461 assert response.status_code == 200
462 self.assert_context("is_users_snap", False)
464 @responses.activate
465 def test_user_connected_on_not_own_snap(self):
466 payload = SNAP_PAYLOAD
468 responses.add(
469 responses.Response(
470 method="GET", url=self.api_url, json=payload, status=200
471 )
472 )
473 responses.add(
474 responses.Response(
475 method="GET",
476 url=self.api_url_details,
477 json=EMPTY_EXTRA_DETAILS_PAYLOAD,
478 status=200,
479 )
480 )
482 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics"
483 responses.add(
484 responses.Response(
485 method="POST", url=metrics_url, json={}, status=200
486 )
487 )
489 with self.client.session_transaction() as s:
490 s["publisher"] = {"nickname": "greg"}
492 response = self.client.get(self.endpoint_url)
494 assert response.status_code == 200
495 self.assert_context("is_users_snap", False)
497 @responses.activate
498 def test_extra_details(self):
499 payload = SNAP_PAYLOAD
500 payload_extra_details = {
501 "aliases": [
502 {"name": "nu", "target": "nu"},
503 {
504 "name": "nu_plugin_stress_internals",
505 "target": "nu-plugin-stress-internals",
506 },
507 {"name": "nu_plugin_gstat", "target": "nu-plugin-gstat"},
508 {"name": "nu_plugin_formats", "target": "nu-plugin-formats"},
509 {"name": "nu_plugin_polars", "target": "nu-plugin-polars"},
510 ],
511 "package_name": "toto",
512 }
514 responses.add(
515 responses.Response(
516 method="GET", url=self.api_url, json=payload, status=200
517 )
518 )
519 responses.add(
520 responses.Response(
521 method="GET",
522 url=self.api_url_details,
523 json=payload_extra_details,
524 status=200,
525 )
526 )
528 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics"
529 responses.add(
530 responses.Response(
531 method="POST", url=metrics_url, json={}, status=200
532 )
533 )
535 response = self.client.get(self.endpoint_url)
536 assert response.status_code == 200
537 self.assert_context(
538 "aliases",
539 [
540 ["toto.nu", "nu"],
541 [
542 "toto.nu-plugin-stress-internals",
543 "nu_plugin_stress_internals",
544 ],
545 ["toto.nu-plugin-gstat", "nu_plugin_gstat"],
546 ["toto.nu-plugin-formats", "nu_plugin_formats"],
547 ["toto.nu-plugin-polars", "nu_plugin_polars"],
548 ],
549 )
551 @responses.activate
552 def test_explore_uses_redis_cache(self):
553 """When Redis has cached explore data, the recommendation APIs
554 and category lookup should not be called and the view should
555 return successfully using the cached values.
556 """
557 # seed redis
558 popular = [
559 {
560 "details": {
561 "name": "/pop1",
562 "icon": "",
563 "title": "Pop 1",
564 "publisher": "Pub 1",
565 "developer_validation": None,
566 "summary": "Popular snap",
567 },
568 }
569 ]
570 recent = [
571 {
572 "details": {
573 "name": "/recent1",
574 "icon": "",
575 "title": "Recent 1",
576 "publisher": "Pub 2",
577 "developer_validation": None,
578 "summary": "Recent snap",
579 },
580 }
581 ]
582 trending = [
583 {
584 "details": {
585 "name": "/trend1",
586 "icon": "",
587 "title": "Trend 1",
588 "publisher": "Pub 3",
589 "developer_validation": None,
590 "summary": "Trending snap",
591 },
592 }
593 ]
594 top_rated = [
595 {
596 "details": {
597 "name": "/top1",
598 "icon": "",
599 "title": "Top 1",
600 "publisher": "Pub 4",
601 "developer_validation": None,
602 "summary": "Top rated snap",
603 },
604 }
605 ]
606 categories = [{"slug": "cat1", "name": "Cat 1"}]
607 featured = {
608 "_embedded": {
609 "clickindex:package": [
610 {
611 "developer_validation": True,
612 "media": [],
613 "publisher": "Featured Pub",
614 "package_name": "featured-snap",
615 "summary": "Featured snap",
616 "title": "Featured Snap",
617 }
618 ]
619 }
620 }
621 expected_featured = [
622 {
623 "details": {
624 "developer_validation": True,
625 "icon": "",
626 "publisher": "Featured Pub",
627 "name": "featured-snap",
628 "summary": "Featured snap",
629 "title": "Featured Snap",
630 }
631 }
632 ]
634 redis_cache.set("explore:popular-snaps", popular, ttl=3600)
635 redis_cache.set("explore:recent-snaps", recent, ttl=3600)
636 redis_cache.set("explore:trending-snaps", trending, ttl=3600)
637 redis_cache.set("explore:top-rated-snaps", top_rated, ttl=3600)
638 redis_cache.set("explore:categories", categories, ttl=3600)
640 with patch(POPULAR_PATH) as mock_popular:
641 with patch(RECENT_PATH) as mock_recent:
642 with patch(TREND_PATH) as mock_trending:
643 with patch(TOP_PATH) as mock_top_rated:
644 with patch(CATEGORIES_PATH) as mock_categories:
645 with patch(
646 FEATURED_PATH, return_value=featured
647 ) as mock_featured:
648 response = self.client.get("/store")
650 self.assert200(response)
651 self.assert_context(
652 "featured_snaps", expected_featured
653 )
655 mock_popular.assert_not_called()
656 mock_recent.assert_not_called()
657 mock_trending.assert_not_called()
658 mock_top_rated.assert_not_called()
659 mock_categories.assert_not_called()
660 mock_featured.assert_called_once_with(
661 fields=(
662 "developer_validation,media,"
663 "package_name,publisher,summary,"
664 "title"
665 )
666 )
668 @responses.activate
669 def test_explore_populates_cache_when_empty(self):
670 """When Redis cache is empty, the recommendation/device methods
671 should be called and their results stored in Redis for subsequent
672 requests.
673 """
674 featured = {
675 "_embedded": {
676 "clickindex:package": [
677 {
678 "developer_validation": None,
679 "media": [],
680 "publisher": "Featured Pub",
681 "package_name": "featured-snap",
682 "summary": "Featured snap",
683 "title": "Featured Snap",
684 }
685 ]
686 }
687 }
688 expected_featured = [
689 {
690 "details": {
691 "developer_validation": None,
692 "icon": "",
693 "publisher": "Featured Pub",
694 "name": "featured-snap",
695 "summary": "Featured snap",
696 "title": "Featured Snap",
697 }
698 }
699 ]
701 with patch(
702 POPULAR_PATH,
703 return_value=[
704 {
705 "details": {
706 "name": "/popx",
707 "icon": "",
708 "title": "Pop X",
709 "publisher": "Pub X",
710 "developer_validation": None,
711 "summary": "Popular x",
712 }
713 }
714 ],
715 ) as mock_popular:
716 with patch(
717 RECENT_PATH,
718 return_value=[
719 {
720 "details": {
721 "name": "/recentx",
722 "icon": "",
723 "title": "Recent X",
724 "publisher": "Pub RX",
725 "developer_validation": None,
726 "summary": "Recent x",
727 }
728 }
729 ],
730 ) as mock_recent:
731 with patch(
732 TREND_PATH,
733 return_value=[
734 {
735 "details": {
736 "name": "/trendx",
737 "icon": "",
738 "title": "Trend X",
739 "publisher": "Pub TX",
740 "developer_validation": None,
741 "summary": "Trend x",
742 }
743 }
744 ],
745 ) as mock_trending:
746 with patch(
747 TOP_PATH,
748 return_value=[
749 {
750 "details": {
751 "name": "/topx",
752 "icon": "",
753 "title": "Top X",
754 "publisher": "Pub TX",
755 "developer_validation": None,
756 "summary": "Top x",
757 }
758 }
759 ],
760 ) as mock_top_rated:
761 with patch(
762 CATEGORIES_PATH,
763 return_value=[{"slug": "c1", "name": "C1"}],
764 ) as mock_categories:
765 with patch(
766 FEATURED_PATH, return_value=featured
767 ) as mock_featured:
768 response = self.client.get("/store")
770 self.assert200(response)
771 self.assert_context(
772 "featured_snaps", expected_featured
773 )
775 # cache-populating methods were called
776 self.assertTrue(mock_popular.called)
777 self.assertTrue(mock_recent.called)
778 self.assertTrue(mock_trending.called)
779 self.assertTrue(mock_top_rated.called)
780 self.assertTrue(mock_categories.called)
781 self.assertTrue(mock_featured.called)
782 # cached values should now exist
783 pop_cached = redis_cache.get(
784 "explore:popular-snaps"
785 )
786 recent_cached = redis_cache.get(
787 "explore:recent-snaps"
788 )
789 trend_cached = redis_cache.get(
790 "explore:trending-snaps"
791 )
792 top_cached = redis_cache.get(
793 "explore:top-rated-snaps"
794 )
795 categories_cached = redis_cache.get(
796 "explore:categories"
797 )
799 assert pop_cached is not None
800 assert recent_cached is not None
801 assert trend_cached is not None
802 assert top_cached is not None
803 assert categories_cached is not None
806if __name__ == "__main__":
807 import unittest
809 unittest.main()