Coverage for tests/store/tests_details.py: 97%
230 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
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 response = self.client.get(self.endpoint_url)
283 assert response.status_code == 200
285 @responses.activate
286 def test_api_500(self):
287 payload = {"error-list": []}
288 responses.add(
289 responses.Response(
290 method="GET", url=self.api_url, json=payload, status=500
291 )
292 )
294 response = self.client.get(self.endpoint_url)
296 assert len(responses.calls) == 1
297 called = responses.calls[0]
298 assert called.request.url == self.api_url
300 assert response.status_code == 502
302 @responses.activate
303 def test_api_500_no_answer(self):
304 responses.add(
305 responses.Response(method="GET", url=self.api_url, status=500)
306 )
308 response = self.client.get(self.endpoint_url)
310 assert len(responses.calls) == 1
311 called = responses.calls[0]
312 assert called.request.url == self.api_url
314 assert response.status_code == 502
316 @responses.activate
317 def test_no_channel_map(self):
318 payload = {
319 "snap-id": "id",
320 "name": "toto",
321 "default-track": None,
322 "snap": {
323 "title": "Snap Title",
324 "summary": "This is a summary",
325 "description": "this is a description",
326 "media": [],
327 "license": "license",
328 "publisher": {
329 "display-name": "Toto",
330 "username": "toto",
331 "validation": True,
332 },
333 "categories": [{"name": "test"}],
334 "trending": False,
335 "unlisted": False,
336 "links": {},
337 },
338 }
340 responses.add(
341 responses.Response(
342 method="GET", url=self.api_url, json=payload, status=200
343 )
344 )
345 responses.add(
346 responses.Response(
347 method="GET",
348 url=self.api_url_details,
349 json=EMPTY_EXTRA_DETAILS_PAYLOAD,
350 status=200,
351 )
352 )
354 response = self.client.get(self.endpoint_url)
356 assert response.status_code == 404
358 @responses.activate
359 def test_user_connected(self):
360 payload = SNAP_PAYLOAD
362 responses.add(
363 responses.Response(
364 method="GET", url=self.api_url, json=payload, status=200
365 )
366 )
367 responses.add(
368 responses.Response(
369 method="GET",
370 url=self.api_url_details,
371 json=EMPTY_EXTRA_DETAILS_PAYLOAD,
372 status=200,
373 )
374 )
376 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics"
377 responses.add(
378 responses.Response(
379 method="POST", url=metrics_url, json={}, status=200
380 )
381 )
383 with self.client.session_transaction() as s:
384 # make test session 'authenticated'
385 s["publisher"] = {"nickname": "toto", "fullname": "Totinio"}
386 s["macaroon_exchanged"] = "test"
387 # mock test user snaps list
388 s["user_snaps"] = {"toto": {"snap-id": "test"}}
390 response = self.client.get(self.endpoint_url)
392 self.assert200(response)
393 self.assert_context("is_users_snap", True)
395 @responses.activate
396 def test_user_not_connected(self):
397 payload = SNAP_PAYLOAD
399 responses.add(
400 responses.Response(
401 method="GET", url=self.api_url, json=payload, status=200
402 )
403 )
404 responses.add(
405 responses.Response(
406 method="GET",
407 url=self.api_url_details,
408 json=EMPTY_EXTRA_DETAILS_PAYLOAD,
409 status=200,
410 )
411 )
413 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics"
414 responses.add(
415 responses.Response(
416 method="POST", url=metrics_url, json={}, status=200
417 )
418 )
420 response = self.client.get(self.endpoint_url)
422 assert response.status_code == 200
423 self.assert_context("is_users_snap", False)
425 @responses.activate
426 def test_user_connected_on_not_own_snap(self):
427 payload = SNAP_PAYLOAD
429 responses.add(
430 responses.Response(
431 method="GET", url=self.api_url, json=payload, status=200
432 )
433 )
434 responses.add(
435 responses.Response(
436 method="GET",
437 url=self.api_url_details,
438 json=EMPTY_EXTRA_DETAILS_PAYLOAD,
439 status=200,
440 )
441 )
443 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics"
444 responses.add(
445 responses.Response(
446 method="POST", url=metrics_url, json={}, status=200
447 )
448 )
450 with self.client.session_transaction() as s:
451 s["publisher"] = {"nickname": "greg"}
453 response = self.client.get(self.endpoint_url)
455 assert response.status_code == 200
456 self.assert_context("is_users_snap", False)
458 @responses.activate
459 def test_extra_details(self):
460 payload = SNAP_PAYLOAD
461 payload_extra_details = {
462 "aliases": [
463 {"name": "nu", "target": "nu"},
464 {
465 "name": "nu_plugin_stress_internals",
466 "target": "nu-plugin-stress-internals",
467 },
468 {"name": "nu_plugin_gstat", "target": "nu-plugin-gstat"},
469 {"name": "nu_plugin_formats", "target": "nu-plugin-formats"},
470 {"name": "nu_plugin_polars", "target": "nu-plugin-polars"},
471 ],
472 "package_name": "toto",
473 }
475 responses.add(
476 responses.Response(
477 method="GET", url=self.api_url, json=payload, status=200
478 )
479 )
480 responses.add(
481 responses.Response(
482 method="GET",
483 url=self.api_url_details,
484 json=payload_extra_details,
485 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 response = self.client.get(self.endpoint_url)
497 assert response.status_code == 200
498 self.assert_context(
499 "aliases",
500 [
501 ["toto.nu", "nu"],
502 [
503 "toto.nu-plugin-stress-internals",
504 "nu_plugin_stress_internals",
505 ],
506 ["toto.nu-plugin-gstat", "nu_plugin_gstat"],
507 ["toto.nu-plugin-formats", "nu_plugin_formats"],
508 ["toto.nu-plugin-polars", "nu_plugin_polars"],
509 ],
510 )
512 @responses.activate
513 def test_explore_uses_redis_cache(self):
514 """When Redis has cached explore data, the recommendation APIs
515 and category lookup should not be called and the view should
516 return successfully using the cached values.
517 """
518 # seed redis
519 popular = [
520 {
521 "details": {
522 "name": "/pop1",
523 "icon": "",
524 "title": "Pop 1",
525 "publisher": "Pub 1",
526 "developer_validation": None,
527 "summary": "Popular snap",
528 },
529 }
530 ]
531 recent = [
532 {
533 "details": {
534 "name": "/recent1",
535 "icon": "",
536 "title": "Recent 1",
537 "publisher": "Pub 2",
538 "developer_validation": None,
539 "summary": "Recent snap",
540 },
541 }
542 ]
543 trending = [
544 {
545 "details": {
546 "name": "/trend1",
547 "icon": "",
548 "title": "Trend 1",
549 "publisher": "Pub 3",
550 "developer_validation": None,
551 "summary": "Trending snap",
552 },
553 }
554 ]
555 top_rated = [
556 {
557 "details": {
558 "name": "/top1",
559 "icon": "",
560 "title": "Top 1",
561 "publisher": "Pub 4",
562 "developer_validation": None,
563 "summary": "Top rated snap",
564 },
565 }
566 ]
567 categories = [{"slug": "cat1", "name": "Cat 1"}]
568 featured = {
569 "_embedded": {
570 "clickindex:package": [
571 {
572 "developer_validation": True,
573 "media": [],
574 "publisher": "Featured Pub",
575 "package_name": "featured-snap",
576 "summary": "Featured snap",
577 "title": "Featured Snap",
578 }
579 ]
580 }
581 }
582 expected_featured = [
583 {
584 "details": {
585 "developer_validation": True,
586 "icon": "",
587 "publisher": "Featured Pub",
588 "name": "featured-snap",
589 "summary": "Featured snap",
590 "title": "Featured Snap",
591 }
592 }
593 ]
595 redis_cache.set("explore:popular-snaps", popular, ttl=3600)
596 redis_cache.set("explore:recent-snaps", recent, ttl=3600)
597 redis_cache.set("explore:trending-snaps", trending, ttl=3600)
598 redis_cache.set("explore:top-rated-snaps", top_rated, ttl=3600)
599 redis_cache.set("explore:categories", categories, ttl=3600)
601 with patch(POPULAR_PATH) as mock_popular:
602 with patch(RECENT_PATH) as mock_recent:
603 with patch(TREND_PATH) as mock_trending:
604 with patch(TOP_PATH) as mock_top_rated:
605 with patch(CATEGORIES_PATH) as mock_categories:
606 with patch(
607 FEATURED_PATH, return_value=featured
608 ) as mock_featured:
609 response = self.client.get("/store")
611 self.assert200(response)
612 self.assert_context(
613 "featured_snaps", expected_featured
614 )
616 mock_popular.assert_not_called()
617 mock_recent.assert_not_called()
618 mock_trending.assert_not_called()
619 mock_top_rated.assert_not_called()
620 mock_categories.assert_not_called()
621 mock_featured.assert_called_once_with(
622 fields=(
623 "developer_validation,media,"
624 "package_name,publisher,summary,"
625 "title"
626 )
627 )
629 @responses.activate
630 def test_explore_populates_cache_when_empty(self):
631 """When Redis cache is empty, the recommendation/device methods
632 should be called and their results stored in Redis for subsequent
633 requests.
634 """
635 featured = {
636 "_embedded": {
637 "clickindex:package": [
638 {
639 "developer_validation": None,
640 "media": [],
641 "publisher": "Featured Pub",
642 "package_name": "featured-snap",
643 "summary": "Featured snap",
644 "title": "Featured Snap",
645 }
646 ]
647 }
648 }
649 expected_featured = [
650 {
651 "details": {
652 "developer_validation": None,
653 "icon": "",
654 "publisher": "Featured Pub",
655 "name": "featured-snap",
656 "summary": "Featured snap",
657 "title": "Featured Snap",
658 }
659 }
660 ]
662 with patch(
663 POPULAR_PATH,
664 return_value=[
665 {
666 "details": {
667 "name": "/popx",
668 "icon": "",
669 "title": "Pop X",
670 "publisher": "Pub X",
671 "developer_validation": None,
672 "summary": "Popular x",
673 }
674 }
675 ],
676 ) as mock_popular:
677 with patch(
678 RECENT_PATH,
679 return_value=[
680 {
681 "details": {
682 "name": "/recentx",
683 "icon": "",
684 "title": "Recent X",
685 "publisher": "Pub RX",
686 "developer_validation": None,
687 "summary": "Recent x",
688 }
689 }
690 ],
691 ) as mock_recent:
692 with patch(
693 TREND_PATH,
694 return_value=[
695 {
696 "details": {
697 "name": "/trendx",
698 "icon": "",
699 "title": "Trend X",
700 "publisher": "Pub TX",
701 "developer_validation": None,
702 "summary": "Trend x",
703 }
704 }
705 ],
706 ) as mock_trending:
707 with patch(
708 TOP_PATH,
709 return_value=[
710 {
711 "details": {
712 "name": "/topx",
713 "icon": "",
714 "title": "Top X",
715 "publisher": "Pub TX",
716 "developer_validation": None,
717 "summary": "Top x",
718 }
719 }
720 ],
721 ) as mock_top_rated:
722 with patch(
723 CATEGORIES_PATH,
724 return_value=[{"slug": "c1", "name": "C1"}],
725 ) as mock_categories:
726 with patch(
727 FEATURED_PATH, return_value=featured
728 ) as mock_featured:
729 response = self.client.get("/store")
731 self.assert200(response)
732 self.assert_context(
733 "featured_snaps", expected_featured
734 )
736 # cache-populating methods were called
737 self.assertTrue(mock_popular.called)
738 self.assertTrue(mock_recent.called)
739 self.assertTrue(mock_trending.called)
740 self.assertTrue(mock_top_rated.called)
741 self.assertTrue(mock_categories.called)
742 self.assertTrue(mock_featured.called)
743 # cached values should now exist
744 pop_cached = redis_cache.get(
745 "explore:popular-snaps"
746 )
747 recent_cached = redis_cache.get(
748 "explore:recent-snaps"
749 )
750 trend_cached = redis_cache.get(
751 "explore:trending-snaps"
752 )
753 top_cached = redis_cache.get(
754 "explore:top-rated-snaps"
755 )
756 categories_cached = redis_cache.get(
757 "explore:categories"
758 )
760 assert pop_cached is not None
761 assert recent_cached is not None
762 assert trend_cached is not None
763 assert top_cached is not None
764 assert categories_cached is not None
767if __name__ == "__main__":
768 import unittest
770 unittest.main()