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

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 

8 

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" 

15 

16 

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} 

55 

56 

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 ) 

108 

109 def create_app(self): 

110 app = create_app(testing=True) 

111 app.secret_key = "secret_key" 

112 app.config["WTF_CSRF_METHODS"] = [] 

113 

114 return app 

115 

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}") 

125 

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 

131 

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" 

148 

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) 

157 

158 response = self.client.get("/" + snap_name) 

159 

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) 

163 

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 } 

175 

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" 

184 

185 def find_snap(name, title): 

186 return { 

187 "name": name, 

188 "snap": { 

189 "title": title, 

190 "summary": title + " summary", 

191 "media": [], 

192 }, 

193 } 

194 

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 } 

203 

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) 

207 

208 response = self.client.get("/" + snap_name) 

209 self.assertEqual(response.status_code, 200) 

210 

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") 

220 

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") 

228 

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 ) 

237 

238 response = self.client.get(self.endpoint_url) 

239 

240 called = responses.calls[0] 

241 assert called.request.url == self.api_url 

242 assert len(responses.calls) == 1 

243 

244 assert response.status_code == 404 

245 

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 } 

259 

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 ) 

273 

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 ) 

280 

281 response = self.client.get(self.endpoint_url) 

282 

283 assert response.status_code == 200 

284 

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 ) 

293 

294 response = self.client.get(self.endpoint_url) 

295 

296 assert len(responses.calls) == 1 

297 called = responses.calls[0] 

298 assert called.request.url == self.api_url 

299 

300 assert response.status_code == 502 

301 

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 ) 

307 

308 response = self.client.get(self.endpoint_url) 

309 

310 assert len(responses.calls) == 1 

311 called = responses.calls[0] 

312 assert called.request.url == self.api_url 

313 

314 assert response.status_code == 502 

315 

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 } 

339 

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 ) 

353 

354 response = self.client.get(self.endpoint_url) 

355 

356 assert response.status_code == 404 

357 

358 @responses.activate 

359 def test_user_connected(self): 

360 payload = SNAP_PAYLOAD 

361 

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 

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 ) 

382 

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"}} 

389 

390 response = self.client.get(self.endpoint_url) 

391 

392 self.assert200(response) 

393 self.assert_context("is_users_snap", True) 

394 

395 @responses.activate 

396 def test_user_not_connected(self): 

397 payload = SNAP_PAYLOAD 

398 

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 ) 

412 

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 ) 

419 

420 response = self.client.get(self.endpoint_url) 

421 

422 assert response.status_code == 200 

423 self.assert_context("is_users_snap", False) 

424 

425 @responses.activate 

426 def test_user_connected_on_not_own_snap(self): 

427 payload = SNAP_PAYLOAD 

428 

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 ) 

442 

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 ) 

449 

450 with self.client.session_transaction() as s: 

451 s["publisher"] = {"nickname": "greg"} 

452 

453 response = self.client.get(self.endpoint_url) 

454 

455 assert response.status_code == 200 

456 self.assert_context("is_users_snap", False) 

457 

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 } 

474 

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 ) 

488 

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 ) 

495 

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 ) 

511 

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 ] 

594 

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) 

600 

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") 

610 

611 self.assert200(response) 

612 self.assert_context( 

613 "featured_snaps", expected_featured 

614 ) 

615 

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 ) 

628 

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 ] 

661 

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") 

730 

731 self.assert200(response) 

732 self.assert_context( 

733 "featured_snaps", expected_featured 

734 ) 

735 

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 ) 

759 

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 

765 

766 

767if __name__ == "__main__": 

768 import unittest 

769 

770 unittest.main()