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

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 

7 

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" 

14 

15 

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} 

54 

55 

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 ) 

112 

113 def create_app(self): 

114 app = create_app(testing=True) 

115 app.secret_key = "secret_key" 

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

117 

118 return app 

119 

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

129 

130 @responses.activate 

131 def test_has_sboms_success(self): 

132 payload = SNAP_PAYLOAD 

133 

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 ) 

144 

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 ) 

151 

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

153 

154 assert response.status_code == 200 

155 

156 @responses.activate 

157 def test_has_sboms_error(self): 

158 payload = SNAP_PAYLOAD 

159 

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 ) 

170 

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 ) 

177 

178 response = self.client.head(self.api_url_sboms) 

179 

180 assert response.status_code == 404 

181 

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 ) 

190 

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

192 

193 called = responses.calls[0] 

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

195 assert len(responses.calls) == 1 

196 

197 assert response.status_code == 404 

198 

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 } 

212 

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 ) 

237 

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

239 

240 assert response.status_code == 200 

241 

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 ) 

250 

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

252 

253 assert len(responses.calls) == 1 

254 called = responses.calls[0] 

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

256 

257 assert response.status_code == 502 

258 

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 ) 

264 

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

266 

267 assert len(responses.calls) == 1 

268 called = responses.calls[0] 

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

270 

271 assert response.status_code == 502 

272 

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 } 

296 

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 ) 

310 

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

312 

313 assert response.status_code == 404 

314 

315 @responses.activate 

316 def test_user_connected(self): 

317 payload = SNAP_PAYLOAD 

318 

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 ) 

337 

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 ) 

344 

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

352 

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

354 

355 self.assert200(response) 

356 self.assert_context("is_users_snap", True) 

357 

358 @responses.activate 

359 def test_user_not_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 responses.add( 

376 responses.Response( 

377 method="HEAD", url=self.api_url_sboms, json={}, status=200 

378 ) 

379 ) 

380 

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 ) 

387 

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

389 

390 assert response.status_code == 200 

391 self.assert_context("is_users_snap", False) 

392 

393 @responses.activate 

394 def test_user_connected_on_not_own_snap(self): 

395 payload = SNAP_PAYLOAD 

396 

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 ) 

415 

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 ) 

422 

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

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

425 

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

427 

428 assert response.status_code == 200 

429 self.assert_context("is_users_snap", False) 

430 

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 } 

447 

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 ) 

472 

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 ) 

488 

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 ] 

571 

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) 

577 

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

587 

588 self.assert200(response) 

589 self.assert_context( 

590 "featured_snaps", expected_featured 

591 ) 

592 

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 ) 

605 

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 ] 

638 

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

707 

708 self.assert200(response) 

709 self.assert_context( 

710 "featured_snaps", expected_featured 

711 ) 

712 

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 ) 

736 

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 

742 

743 

744if __name__ == "__main__": 

745 import unittest 

746 

747 unittest.main()