Coverage for tests/store/tests_details.py: 97%
210 statements
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-15 22:43 +0000
« prev ^ index » next coverage.py v7.14.1, created at 2026-06-15 22:43 +0000
1import responses
2from urllib.parse import urlencode
3from flask_testing import TestCase
4from webapp.app import create_app
5from unittest.mock import patch
6from cache.cache_utility import redis_cache
8POPULAR_PATH = "webapp.store.views.snap_recommendations.get_popular"
9RECENT_PATH = "webapp.store.views.snap_recommendations.get_recent"
10TREND_PATH = "webapp.store.views.snap_recommendations.get_trending"
11TOP_PATH = "webapp.store.views.snap_recommendations.get_top_rated"
12CATEGORIES_PATH = "webapp.store.views.device_gateway.get_categories"
13FEATURED_PATH = "webapp.store.views.device_gateway.get_featured_snaps"
16EMPTY_EXTRA_DETAILS_PAYLOAD = {"aliases": None, "package_name": "vault"}
17SNAP_PAYLOAD = {
18 "snap-id": "id",
19 "name": "toto",
20 "default-track": None,
21 "snap": {
22 "title": "Snap Title",
23 "summary": "This is a summary",
24 "description": "this is a description",
25 "media": [],
26 "license": "license",
27 "publisher": {
28 "display-name": "Toto",
29 "username": "toto",
30 "validation": True,
31 },
32 "categories": [{"name": "test"}],
33 "trending": False,
34 "unlisted": False,
35 "links": {},
36 },
37 "channel-map": [
38 {
39 "channel": {
40 "architecture": "amd64",
41 "name": "stable",
42 "risk": "stable",
43 "track": "latest",
44 "released-at": "2018-09-18T14:45:28.064633+00:00",
45 },
46 "created-at": "2018-09-18T14:45:28.064633+00:00",
47 "version": "1.0",
48 "confinement": "conf",
49 "download": {"size": 100000},
50 "revision": 1,
51 }
52 ],
53}
56class GetDetailsPageTest(TestCase):
57 def setUp(self):
58 super().setUp()
59 self.snap_name = "toto"
60 self.snap_id = "id"
61 self.revision = 1
62 self.api_url = "".join(
63 [
64 "https://api.snapcraft.io/v2/",
65 "snaps/info/",
66 self.snap_name,
67 "?",
68 urlencode(
69 {
70 "fields": ",".join(
71 [
72 "title",
73 "summary",
74 "description",
75 "license",
76 "contact",
77 "website",
78 "publisher",
79 "media",
80 "download",
81 "version",
82 "created-at",
83 "confinement",
84 "categories",
85 "trending",
86 "unlisted",
87 "links",
88 "revision",
89 ]
90 )
91 }
92 ),
93 ]
94 )
95 self.endpoint_url = "/" + self.snap_name
96 self.api_url_details = "".join(
97 [
98 "https://api.snapcraft.io/api/v1/",
99 "snaps/details/",
100 self.snap_name,
101 "?",
102 urlencode({"fields": ",".join(["aliases"])}),
103 ]
104 )
105 self.api_url_sboms = "".join(
106 [
107 "https://api.snapcraft.io/api/v1/",
108 "sboms/download/",
109 f"sbom_snap_{self.snap_id}_{self.revision}.spdx2.3.json",
110 ]
111 )
113 def create_app(self):
114 app = create_app(testing=True)
115 app.secret_key = "secret_key"
116 app.config["WTF_CSRF_METHODS"] = []
118 return app
120 def assert_not_in_context(self, name):
121 try:
122 self.get_context_variable(name)
123 except Exception:
124 # flask-testing throws exception if context doesn't have "name"
125 # that's what we expect so we just return and let the test pass
126 return
127 # If we reach this point it means the variable IS in context
128 self.fail(f"Context variable exists: {name}")
130 @responses.activate
131 def test_has_sboms_success(self):
132 payload = SNAP_PAYLOAD
134 responses.add(
135 responses.Response(
136 method="GET", url=self.api_url, json=payload, status=200
137 )
138 )
139 responses.add(
140 responses.Response(
141 method="HEAD", url=self.api_url_sboms, json={}, status=200
142 )
143 )
145 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics"
146 responses.add(
147 responses.Response(
148 method="POST", url=metrics_url, json={}, status=200
149 )
150 )
152 response = self.client.get(self.endpoint_url)
154 assert response.status_code == 200
156 @responses.activate
157 def test_has_sboms_error(self):
158 payload = SNAP_PAYLOAD
160 responses.add(
161 responses.Response(
162 method="GET", url=self.api_url, json=payload, status=200
163 )
164 )
165 responses.add(
166 responses.Response(
167 method="HEAD", url=self.api_url_sboms, json={}, status=404
168 )
169 )
171 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics"
172 responses.add(
173 responses.Response(
174 method="POST", url=metrics_url, json={}, status=200
175 )
176 )
178 response = self.client.head(self.api_url_sboms)
180 assert response.status_code == 404
182 @responses.activate
183 def test_api_404(self):
184 payload = {"error-list": [{"code": "resource-not-found"}]}
185 responses.add(
186 responses.Response(
187 method="GET", url=self.api_url, json=payload, status=404
188 )
189 )
191 response = self.client.get(self.endpoint_url)
193 called = responses.calls[0]
194 assert called.request.url == self.api_url
195 assert len(responses.calls) == 1
197 assert response.status_code == 404
199 @responses.activate
200 def test_extra_details_error(self):
201 payload = SNAP_PAYLOAD
202 extra_details_payload = {
203 "error_list": [
204 {
205 "code": "resource-not-found",
206 "message": "No snap named 'toto' found in series '16'.",
207 }
208 ],
209 "errors": ["No snap named 'toto' found in series '16'."],
210 "result": "error",
211 }
213 responses.add(
214 responses.Response(
215 method="GET", url=self.api_url, json=payload, status=200
216 )
217 )
218 responses.add(
219 responses.Response(
220 method="GET",
221 url=self.api_url_details,
222 json=extra_details_payload,
223 status=404,
224 )
225 )
226 responses.add(
227 responses.Response(
228 method="HEAD", url=self.api_url_sboms, json={}, status=200
229 )
230 )
231 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics"
232 responses.add(
233 responses.Response(
234 method="POST", url=metrics_url, json={}, status=200
235 )
236 )
238 response = self.client.get(self.endpoint_url)
240 assert response.status_code == 200
242 @responses.activate
243 def test_api_500(self):
244 payload = {"error-list": []}
245 responses.add(
246 responses.Response(
247 method="GET", url=self.api_url, json=payload, status=500
248 )
249 )
251 response = self.client.get(self.endpoint_url)
253 assert len(responses.calls) == 1
254 called = responses.calls[0]
255 assert called.request.url == self.api_url
257 assert response.status_code == 502
259 @responses.activate
260 def test_api_500_no_answer(self):
261 responses.add(
262 responses.Response(method="GET", url=self.api_url, status=500)
263 )
265 response = self.client.get(self.endpoint_url)
267 assert len(responses.calls) == 1
268 called = responses.calls[0]
269 assert called.request.url == self.api_url
271 assert response.status_code == 502
273 @responses.activate
274 def test_no_channel_map(self):
275 payload = {
276 "snap-id": "id",
277 "name": "toto",
278 "default-track": None,
279 "snap": {
280 "title": "Snap Title",
281 "summary": "This is a summary",
282 "description": "this is a description",
283 "media": [],
284 "license": "license",
285 "publisher": {
286 "display-name": "Toto",
287 "username": "toto",
288 "validation": True,
289 },
290 "categories": [{"name": "test"}],
291 "trending": False,
292 "unlisted": False,
293 "links": {},
294 },
295 }
297 responses.add(
298 responses.Response(
299 method="GET", url=self.api_url, json=payload, status=200
300 )
301 )
302 responses.add(
303 responses.Response(
304 method="GET",
305 url=self.api_url_details,
306 json=EMPTY_EXTRA_DETAILS_PAYLOAD,
307 status=200,
308 )
309 )
311 response = self.client.get(self.endpoint_url)
313 assert response.status_code == 404
315 @responses.activate
316 def test_user_connected(self):
317 payload = SNAP_PAYLOAD
319 responses.add(
320 responses.Response(
321 method="GET", url=self.api_url, json=payload, status=200
322 )
323 )
324 responses.add(
325 responses.Response(
326 method="GET",
327 url=self.api_url_details,
328 json=EMPTY_EXTRA_DETAILS_PAYLOAD,
329 status=200,
330 )
331 )
332 responses.add(
333 responses.Response(
334 method="HEAD", url=self.api_url_sboms, json={}, status=200
335 )
336 )
338 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics"
339 responses.add(
340 responses.Response(
341 method="POST", url=metrics_url, json={}, status=200
342 )
343 )
345 with self.client.session_transaction() as s:
346 # make test session 'authenticated'
347 s["publisher"] = {"nickname": "toto", "fullname": "Totinio"}
348 s["macaroon_root"] = "test"
349 s["macaroon_discharge"] = "test"
350 # mock test user snaps list
351 s["user_snaps"] = {"toto": {"snap-id": "test"}}
353 response = self.client.get(self.endpoint_url)
355 self.assert200(response)
356 self.assert_context("is_users_snap", True)
358 @responses.activate
359 def test_user_not_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 )
375 responses.add(
376 responses.Response(
377 method="HEAD", url=self.api_url_sboms, json={}, status=200
378 )
379 )
381 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics"
382 responses.add(
383 responses.Response(
384 method="POST", url=metrics_url, json={}, status=200
385 )
386 )
388 response = self.client.get(self.endpoint_url)
390 assert response.status_code == 200
391 self.assert_context("is_users_snap", False)
393 @responses.activate
394 def test_user_connected_on_not_own_snap(self):
395 payload = SNAP_PAYLOAD
397 responses.add(
398 responses.Response(
399 method="GET", url=self.api_url, json=payload, status=200
400 )
401 )
402 responses.add(
403 responses.Response(
404 method="GET",
405 url=self.api_url_details,
406 json=EMPTY_EXTRA_DETAILS_PAYLOAD,
407 status=200,
408 )
409 )
410 responses.add(
411 responses.Response(
412 method="HEAD", url=self.api_url_sboms, json={}, status=200
413 )
414 )
416 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics"
417 responses.add(
418 responses.Response(
419 method="POST", url=metrics_url, json={}, status=200
420 )
421 )
423 with self.client.session_transaction() as s:
424 s["publisher"] = {"nickname": "greg"}
426 response = self.client.get(self.endpoint_url)
428 assert response.status_code == 200
429 self.assert_context("is_users_snap", False)
431 @responses.activate
432 def test_extra_details(self):
433 payload = SNAP_PAYLOAD
434 payload_extra_details = {
435 "aliases": [
436 {"name": "nu", "target": "nu"},
437 {
438 "name": "nu_plugin_stress_internals",
439 "target": "nu-plugin-stress-internals",
440 },
441 {"name": "nu_plugin_gstat", "target": "nu-plugin-gstat"},
442 {"name": "nu_plugin_formats", "target": "nu-plugin-formats"},
443 {"name": "nu_plugin_polars", "target": "nu-plugin-polars"},
444 ],
445 "package_name": "toto",
446 }
448 responses.add(
449 responses.Response(
450 method="GET", url=self.api_url, json=payload, status=200
451 )
452 )
453 responses.add(
454 responses.Response(
455 method="GET",
456 url=self.api_url_details,
457 json=payload_extra_details,
458 status=200,
459 )
460 )
461 responses.add(
462 responses.Response(
463 method="HEAD", url=self.api_url_sboms, json={}, status=200
464 )
465 )
466 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics"
467 responses.add(
468 responses.Response(
469 method="POST", url=metrics_url, json={}, status=200
470 )
471 )
473 response = self.client.get(self.endpoint_url)
474 assert response.status_code == 200
475 self.assert_context(
476 "aliases",
477 [
478 ["toto.nu", "nu"],
479 [
480 "toto.nu-plugin-stress-internals",
481 "nu_plugin_stress_internals",
482 ],
483 ["toto.nu-plugin-gstat", "nu_plugin_gstat"],
484 ["toto.nu-plugin-formats", "nu_plugin_formats"],
485 ["toto.nu-plugin-polars", "nu_plugin_polars"],
486 ],
487 )
489 @responses.activate
490 def test_explore_uses_redis_cache(self):
491 """When Redis has cached explore data, the recommendation APIs
492 and category lookup should not be called and the view should
493 return successfully using the cached values.
494 """
495 # seed redis
496 popular = [
497 {
498 "details": {
499 "name": "/pop1",
500 "icon": "",
501 "title": "Pop 1",
502 "publisher": "Pub 1",
503 "developer_validation": None,
504 "summary": "Popular snap",
505 },
506 }
507 ]
508 recent = [
509 {
510 "details": {
511 "name": "/recent1",
512 "icon": "",
513 "title": "Recent 1",
514 "publisher": "Pub 2",
515 "developer_validation": None,
516 "summary": "Recent snap",
517 },
518 }
519 ]
520 trending = [
521 {
522 "details": {
523 "name": "/trend1",
524 "icon": "",
525 "title": "Trend 1",
526 "publisher": "Pub 3",
527 "developer_validation": None,
528 "summary": "Trending snap",
529 },
530 }
531 ]
532 top_rated = [
533 {
534 "details": {
535 "name": "/top1",
536 "icon": "",
537 "title": "Top 1",
538 "publisher": "Pub 4",
539 "developer_validation": None,
540 "summary": "Top rated snap",
541 },
542 }
543 ]
544 categories = [{"slug": "cat1", "name": "Cat 1"}]
545 featured = {
546 "_embedded": {
547 "clickindex:package": [
548 {
549 "developer_validation": True,
550 "media": [],
551 "publisher": "Featured Pub",
552 "package_name": "featured-snap",
553 "summary": "Featured snap",
554 "title": "Featured Snap",
555 }
556 ]
557 }
558 }
559 expected_featured = [
560 {
561 "details": {
562 "developer_validation": True,
563 "icon": "",
564 "publisher": "Featured Pub",
565 "name": "featured-snap",
566 "summary": "Featured snap",
567 "title": "Featured Snap",
568 }
569 }
570 ]
572 redis_cache.set("explore:popular-snaps", popular, ttl=3600)
573 redis_cache.set("explore:recent-snaps", recent, ttl=3600)
574 redis_cache.set("explore:trending-snaps", trending, ttl=3600)
575 redis_cache.set("explore:top-rated-snaps", top_rated, ttl=3600)
576 redis_cache.set("explore:categories", categories, ttl=3600)
578 with patch(POPULAR_PATH) as mock_popular:
579 with patch(RECENT_PATH) as mock_recent:
580 with patch(TREND_PATH) as mock_trending:
581 with patch(TOP_PATH) as mock_top_rated:
582 with patch(CATEGORIES_PATH) as mock_categories:
583 with patch(
584 FEATURED_PATH, return_value=featured
585 ) as mock_featured:
586 response = self.client.get("/store")
588 self.assert200(response)
589 self.assert_context(
590 "featured_snaps", expected_featured
591 )
593 mock_popular.assert_not_called()
594 mock_recent.assert_not_called()
595 mock_trending.assert_not_called()
596 mock_top_rated.assert_not_called()
597 mock_categories.assert_not_called()
598 mock_featured.assert_called_once_with(
599 fields=(
600 "developer_validation,media,"
601 "package_name,publisher,summary,"
602 "title"
603 )
604 )
606 @responses.activate
607 def test_explore_populates_cache_when_empty(self):
608 """When Redis cache is empty, the recommendation/device methods
609 should be called and their results stored in Redis for subsequent
610 requests.
611 """
612 featured = {
613 "_embedded": {
614 "clickindex:package": [
615 {
616 "developer_validation": None,
617 "media": [],
618 "publisher": "Featured Pub",
619 "package_name": "featured-snap",
620 "summary": "Featured snap",
621 "title": "Featured Snap",
622 }
623 ]
624 }
625 }
626 expected_featured = [
627 {
628 "details": {
629 "developer_validation": None,
630 "icon": "",
631 "publisher": "Featured Pub",
632 "name": "featured-snap",
633 "summary": "Featured snap",
634 "title": "Featured Snap",
635 }
636 }
637 ]
639 with patch(
640 POPULAR_PATH,
641 return_value=[
642 {
643 "details": {
644 "name": "/popx",
645 "icon": "",
646 "title": "Pop X",
647 "publisher": "Pub X",
648 "developer_validation": None,
649 "summary": "Popular x",
650 }
651 }
652 ],
653 ) as mock_popular:
654 with patch(
655 RECENT_PATH,
656 return_value=[
657 {
658 "details": {
659 "name": "/recentx",
660 "icon": "",
661 "title": "Recent X",
662 "publisher": "Pub RX",
663 "developer_validation": None,
664 "summary": "Recent x",
665 }
666 }
667 ],
668 ) as mock_recent:
669 with patch(
670 TREND_PATH,
671 return_value=[
672 {
673 "details": {
674 "name": "/trendx",
675 "icon": "",
676 "title": "Trend X",
677 "publisher": "Pub TX",
678 "developer_validation": None,
679 "summary": "Trend x",
680 }
681 }
682 ],
683 ) as mock_trending:
684 with patch(
685 TOP_PATH,
686 return_value=[
687 {
688 "details": {
689 "name": "/topx",
690 "icon": "",
691 "title": "Top X",
692 "publisher": "Pub TX",
693 "developer_validation": None,
694 "summary": "Top x",
695 }
696 }
697 ],
698 ) as mock_top_rated:
699 with patch(
700 CATEGORIES_PATH,
701 return_value=[{"slug": "c1", "name": "C1"}],
702 ) as mock_categories:
703 with patch(
704 FEATURED_PATH, return_value=featured
705 ) as mock_featured:
706 response = self.client.get("/store")
708 self.assert200(response)
709 self.assert_context(
710 "featured_snaps", expected_featured
711 )
713 # cache-populating methods were called
714 self.assertTrue(mock_popular.called)
715 self.assertTrue(mock_recent.called)
716 self.assertTrue(mock_trending.called)
717 self.assertTrue(mock_top_rated.called)
718 self.assertTrue(mock_categories.called)
719 self.assertTrue(mock_featured.called)
720 # cached values should now exist
721 pop_cached = redis_cache.get(
722 "explore:popular-snaps"
723 )
724 recent_cached = redis_cache.get(
725 "explore:recent-snaps"
726 )
727 trend_cached = redis_cache.get(
728 "explore:trending-snaps"
729 )
730 top_cached = redis_cache.get(
731 "explore:top-rated-snaps"
732 )
733 categories_cached = redis_cache.get(
734 "explore:categories"
735 )
737 assert pop_cached is not None
738 assert recent_cached is not None
739 assert trend_cached is not None
740 assert top_cached is not None
741 assert categories_cached is not None
744if __name__ == "__main__":
745 import unittest
747 unittest.main()