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

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 with patch( 

282 "webapp.store.snap_details_views.logger.exception" 

283 ) as mock_exception: 

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

285 

286 assert response.status_code == 200 

287 mock_exception.assert_not_called() 

288 

289 @responses.activate 

290 def test_extra_details_unexpected_error_is_logged(self): 

291 payload = SNAP_PAYLOAD 

292 

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 ) 

306 

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 ) 

313 

314 with patch( 

315 "webapp.store.snap_details_views.logger.exception" 

316 ) as mock_exception: 

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

318 

319 assert response.status_code == 200 

320 mock_exception.assert_called_once_with( 

321 "Details endpoint returned an error" 

322 ) 

323 

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 ) 

332 

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

334 

335 assert len(responses.calls) == 1 

336 called = responses.calls[0] 

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

338 

339 assert response.status_code == 502 

340 

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 ) 

346 

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

348 

349 assert len(responses.calls) == 1 

350 called = responses.calls[0] 

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

352 

353 assert response.status_code == 502 

354 

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 } 

378 

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 ) 

392 

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

394 

395 assert response.status_code == 404 

396 

397 @responses.activate 

398 def test_user_connected(self): 

399 payload = SNAP_PAYLOAD 

400 

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 ) 

414 

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 ) 

421 

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

428 

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

430 

431 self.assert200(response) 

432 self.assert_context("is_users_snap", True) 

433 

434 @responses.activate 

435 def test_user_not_connected(self): 

436 payload = SNAP_PAYLOAD 

437 

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 ) 

451 

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 ) 

458 

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

460 

461 assert response.status_code == 200 

462 self.assert_context("is_users_snap", False) 

463 

464 @responses.activate 

465 def test_user_connected_on_not_own_snap(self): 

466 payload = SNAP_PAYLOAD 

467 

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 ) 

481 

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 ) 

488 

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

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

491 

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

493 

494 assert response.status_code == 200 

495 self.assert_context("is_users_snap", False) 

496 

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 } 

513 

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 ) 

527 

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 ) 

534 

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 ) 

550 

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 ] 

633 

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) 

639 

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

649 

650 self.assert200(response) 

651 self.assert_context( 

652 "featured_snaps", expected_featured 

653 ) 

654 

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 ) 

667 

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 ] 

700 

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

769 

770 self.assert200(response) 

771 self.assert_context( 

772 "featured_snaps", expected_featured 

773 ) 

774 

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 ) 

798 

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 

804 

805 

806if __name__ == "__main__": 

807 import unittest 

808 

809 unittest.main()