Coverage for tests/store/tests_details.py: 97%

241 statements  

« prev     ^ index     » next       coverage.py v7.15.0, created at 2026-07-08 12:33 +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 ] 

92 ) 

93 } 

94 ), 

95 ] 

96 ) 

97 self.endpoint_url = "/" + self.snap_name 

98 self.api_url_details = "".join( 

99 [ 

100 "https://api.snapcraft.io/api/v1/", 

101 "snaps/details/", 

102 self.snap_name, 

103 "?", 

104 urlencode({"fields": ",".join(["aliases"])}), 

105 ] 

106 ) 

107 self.api_url_sboms = "".join( 

108 [ 

109 "https://api.snapcraft.io/api/v1/", 

110 "sboms/download/", 

111 f"sbom_snap_{self.snap_id}_{self.revision}.spdx2.3.json", 

112 ] 

113 ) 

114 

115 def create_app(self): 

116 app = create_app(testing=True) 

117 app.secret_key = "secret_key" 

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

119 

120 return app 

121 

122 def assert_not_in_context(self, name): 

123 try: 

124 self.get_context_variable(name) 

125 except Exception: 

126 # flask-testing throws exception if context doesn't have "name" 

127 # that's what we expect so we just return and let the test pass 

128 return 

129 # If we reach this point it means the variable IS in context 

130 self.fail(f"Context variable exists: {name}") 

131 

132 @responses.activate 

133 def test_more_from_publisher_uses_api(self): 

134 snap_name = "clion" 

135 payload = copy.deepcopy(SNAP_PAYLOAD) 

136 payload["name"] = snap_name 

137 payload["snap"]["title"] = "CLion" 

138 payload["snap"]["publisher"] = { 

139 "display-name": "JetBrains", 

140 "username": "jetbrains", 

141 "validation": "verified", 

142 } 

143 

144 info_url = "".join( 

145 [ 

146 "https://api.snapcraft.io/v2/snaps/info/", 

147 snap_name, 

148 ] 

149 ) 

150 find_url = "https://api.snapcraft.io/v2/snaps/find" 

151 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics" 

152 sbom_url = "".join( 

153 [ 

154 "https://api.snapcraft.io/api/v1/sboms/download/", 

155 f"sbom_snap_{self.snap_id}_{self.revision}.spdx2.3.json", 

156 ] 

157 ) 

158 

159 def find_snap(name, title): 

160 return { 

161 "name": name, 

162 "snap": { 

163 "title": title, 

164 "summary": title + " summary", 

165 "media": [], 

166 }, 

167 } 

168 

169 find_response = { 

170 "results": [ 

171 find_snap("clion", "CLion"), 

172 find_snap("intellij-idea", "IntelliJ IDEA"), 

173 find_snap("goland", "GoLand"), 

174 find_snap("webstorm", "WebStorm"), 

175 ] 

176 } 

177 

178 responses.add(responses.GET, info_url, json=payload, status=200) 

179 responses.add(responses.GET, find_url, json=find_response, status=200) 

180 responses.add(responses.POST, metrics_url, json={}, status=200) 

181 responses.add(responses.HEAD, sbom_url, json={}, status=200) 

182 

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

184 self.assertEqual(response.status_code, 200) 

185 

186 # publisher_snaps excludes the current snap (clion) and the 

187 # featured snaps (intellij-idea) 

188 publisher_snaps = self.get_context_variable("publisher_snaps") 

189 names = {snap["package_name"] for snap in publisher_snaps} 

190 self.assertEqual(names, {"goland", "webstorm"}) 

191 goland = next( 

192 s for s in publisher_snaps if s["package_name"] == "goland" 

193 ) 

194 self.assertEqual(goland["title"], "GoLand") 

195 

196 # Featured snaps are hydrated from the API. intellij-idea is in 

197 # the API response so it stays, pycharm is not so it is dropped. 

198 featured = self.get_context_variable("publisher_featured_snaps") 

199 featured_names = [snap["package_name"] for snap in featured] 

200 self.assertEqual(featured_names, ["intellij-idea"]) 

201 self.assertEqual(featured[0]["title"], "IntelliJ IDEA") 

202 self.assertEqual(featured[0]["background"], "#000000") 

203 

204 @responses.activate 

205 def test_has_sboms_success(self): 

206 payload = SNAP_PAYLOAD 

207 

208 responses.add( 

209 responses.Response( 

210 method="GET", url=self.api_url, json=payload, status=200 

211 ) 

212 ) 

213 responses.add( 

214 responses.Response( 

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

216 ) 

217 ) 

218 

219 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics" 

220 responses.add( 

221 responses.Response( 

222 method="POST", url=metrics_url, json={}, status=200 

223 ) 

224 ) 

225 

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

227 

228 assert response.status_code == 200 

229 

230 @responses.activate 

231 def test_has_sboms_error(self): 

232 payload = SNAP_PAYLOAD 

233 

234 responses.add( 

235 responses.Response( 

236 method="GET", url=self.api_url, json=payload, status=200 

237 ) 

238 ) 

239 responses.add( 

240 responses.Response( 

241 method="HEAD", url=self.api_url_sboms, json={}, status=404 

242 ) 

243 ) 

244 

245 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics" 

246 responses.add( 

247 responses.Response( 

248 method="POST", url=metrics_url, json={}, status=200 

249 ) 

250 ) 

251 

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

253 

254 assert response.status_code == 404 

255 

256 @responses.activate 

257 def test_api_404(self): 

258 payload = {"error-list": [{"code": "resource-not-found"}]} 

259 responses.add( 

260 responses.Response( 

261 method="GET", url=self.api_url, json=payload, status=404 

262 ) 

263 ) 

264 

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

266 

267 called = responses.calls[0] 

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

269 assert len(responses.calls) == 1 

270 

271 assert response.status_code == 404 

272 

273 @responses.activate 

274 def test_extra_details_error(self): 

275 payload = SNAP_PAYLOAD 

276 extra_details_payload = { 

277 "error_list": [ 

278 { 

279 "code": "resource-not-found", 

280 "message": "No snap named 'toto' found in series '16'.", 

281 } 

282 ], 

283 "errors": ["No snap named 'toto' found in series '16'."], 

284 "result": "error", 

285 } 

286 

287 responses.add( 

288 responses.Response( 

289 method="GET", url=self.api_url, json=payload, status=200 

290 ) 

291 ) 

292 responses.add( 

293 responses.Response( 

294 method="GET", 

295 url=self.api_url_details, 

296 json=extra_details_payload, 

297 status=404, 

298 ) 

299 ) 

300 responses.add( 

301 responses.Response( 

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

303 ) 

304 ) 

305 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics" 

306 responses.add( 

307 responses.Response( 

308 method="POST", url=metrics_url, json={}, status=200 

309 ) 

310 ) 

311 

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

313 

314 assert response.status_code == 200 

315 

316 @responses.activate 

317 def test_api_500(self): 

318 payload = {"error-list": []} 

319 responses.add( 

320 responses.Response( 

321 method="GET", url=self.api_url, json=payload, status=500 

322 ) 

323 ) 

324 

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

326 

327 assert len(responses.calls) == 1 

328 called = responses.calls[0] 

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

330 

331 assert response.status_code == 502 

332 

333 @responses.activate 

334 def test_api_500_no_answer(self): 

335 responses.add( 

336 responses.Response(method="GET", url=self.api_url, status=500) 

337 ) 

338 

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

340 

341 assert len(responses.calls) == 1 

342 called = responses.calls[0] 

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

344 

345 assert response.status_code == 502 

346 

347 @responses.activate 

348 def test_no_channel_map(self): 

349 payload = { 

350 "snap-id": "id", 

351 "name": "toto", 

352 "default-track": None, 

353 "snap": { 

354 "title": "Snap Title", 

355 "summary": "This is a summary", 

356 "description": "this is a description", 

357 "media": [], 

358 "license": "license", 

359 "publisher": { 

360 "display-name": "Toto", 

361 "username": "toto", 

362 "validation": True, 

363 }, 

364 "categories": [{"name": "test"}], 

365 "trending": False, 

366 "unlisted": False, 

367 "links": {}, 

368 }, 

369 } 

370 

371 responses.add( 

372 responses.Response( 

373 method="GET", url=self.api_url, json=payload, status=200 

374 ) 

375 ) 

376 responses.add( 

377 responses.Response( 

378 method="GET", 

379 url=self.api_url_details, 

380 json=EMPTY_EXTRA_DETAILS_PAYLOAD, 

381 status=200, 

382 ) 

383 ) 

384 

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

386 

387 assert response.status_code == 404 

388 

389 @responses.activate 

390 def test_user_connected(self): 

391 payload = SNAP_PAYLOAD 

392 

393 responses.add( 

394 responses.Response( 

395 method="GET", url=self.api_url, json=payload, status=200 

396 ) 

397 ) 

398 responses.add( 

399 responses.Response( 

400 method="GET", 

401 url=self.api_url_details, 

402 json=EMPTY_EXTRA_DETAILS_PAYLOAD, 

403 status=200, 

404 ) 

405 ) 

406 responses.add( 

407 responses.Response( 

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

409 ) 

410 ) 

411 

412 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics" 

413 responses.add( 

414 responses.Response( 

415 method="POST", url=metrics_url, json={}, status=200 

416 ) 

417 ) 

418 

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

420 # make test session 'authenticated' 

421 s["publisher"] = {"nickname": "toto", "fullname": "Totinio"} 

422 s["macaroon_exchanged"] = "test" 

423 # mock test user snaps list 

424 s["user_snaps"] = {"toto": {"snap-id": "test"}} 

425 

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

427 

428 self.assert200(response) 

429 self.assert_context("is_users_snap", True) 

430 

431 @responses.activate 

432 def test_user_not_connected(self): 

433 payload = SNAP_PAYLOAD 

434 

435 responses.add( 

436 responses.Response( 

437 method="GET", url=self.api_url, json=payload, status=200 

438 ) 

439 ) 

440 responses.add( 

441 responses.Response( 

442 method="GET", 

443 url=self.api_url_details, 

444 json=EMPTY_EXTRA_DETAILS_PAYLOAD, 

445 status=200, 

446 ) 

447 ) 

448 responses.add( 

449 responses.Response( 

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

451 ) 

452 ) 

453 

454 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics" 

455 responses.add( 

456 responses.Response( 

457 method="POST", url=metrics_url, json={}, status=200 

458 ) 

459 ) 

460 

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

462 

463 assert response.status_code == 200 

464 self.assert_context("is_users_snap", False) 

465 

466 @responses.activate 

467 def test_user_connected_on_not_own_snap(self): 

468 payload = SNAP_PAYLOAD 

469 

470 responses.add( 

471 responses.Response( 

472 method="GET", url=self.api_url, json=payload, status=200 

473 ) 

474 ) 

475 responses.add( 

476 responses.Response( 

477 method="GET", 

478 url=self.api_url_details, 

479 json=EMPTY_EXTRA_DETAILS_PAYLOAD, 

480 status=200, 

481 ) 

482 ) 

483 responses.add( 

484 responses.Response( 

485 method="HEAD", url=self.api_url_sboms, json={}, 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 with self.client.session_transaction() as s: 

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

498 

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

500 

501 assert response.status_code == 200 

502 self.assert_context("is_users_snap", False) 

503 

504 @responses.activate 

505 def test_extra_details(self): 

506 payload = SNAP_PAYLOAD 

507 payload_extra_details = { 

508 "aliases": [ 

509 {"name": "nu", "target": "nu"}, 

510 { 

511 "name": "nu_plugin_stress_internals", 

512 "target": "nu-plugin-stress-internals", 

513 }, 

514 {"name": "nu_plugin_gstat", "target": "nu-plugin-gstat"}, 

515 {"name": "nu_plugin_formats", "target": "nu-plugin-formats"}, 

516 {"name": "nu_plugin_polars", "target": "nu-plugin-polars"}, 

517 ], 

518 "package_name": "toto", 

519 } 

520 

521 responses.add( 

522 responses.Response( 

523 method="GET", url=self.api_url, json=payload, status=200 

524 ) 

525 ) 

526 responses.add( 

527 responses.Response( 

528 method="GET", 

529 url=self.api_url_details, 

530 json=payload_extra_details, 

531 status=200, 

532 ) 

533 ) 

534 responses.add( 

535 responses.Response( 

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

537 ) 

538 ) 

539 metrics_url = "https://api.snapcraft.io/api/v1/snaps/metrics" 

540 responses.add( 

541 responses.Response( 

542 method="POST", url=metrics_url, json={}, status=200 

543 ) 

544 ) 

545 

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

547 assert response.status_code == 200 

548 self.assert_context( 

549 "aliases", 

550 [ 

551 ["toto.nu", "nu"], 

552 [ 

553 "toto.nu-plugin-stress-internals", 

554 "nu_plugin_stress_internals", 

555 ], 

556 ["toto.nu-plugin-gstat", "nu_plugin_gstat"], 

557 ["toto.nu-plugin-formats", "nu_plugin_formats"], 

558 ["toto.nu-plugin-polars", "nu_plugin_polars"], 

559 ], 

560 ) 

561 

562 @responses.activate 

563 def test_explore_uses_redis_cache(self): 

564 """When Redis has cached explore data, the recommendation APIs 

565 and category lookup should not be called and the view should 

566 return successfully using the cached values. 

567 """ 

568 # seed redis 

569 popular = [ 

570 { 

571 "details": { 

572 "name": "/pop1", 

573 "icon": "", 

574 "title": "Pop 1", 

575 "publisher": "Pub 1", 

576 "developer_validation": None, 

577 "summary": "Popular snap", 

578 }, 

579 } 

580 ] 

581 recent = [ 

582 { 

583 "details": { 

584 "name": "/recent1", 

585 "icon": "", 

586 "title": "Recent 1", 

587 "publisher": "Pub 2", 

588 "developer_validation": None, 

589 "summary": "Recent snap", 

590 }, 

591 } 

592 ] 

593 trending = [ 

594 { 

595 "details": { 

596 "name": "/trend1", 

597 "icon": "", 

598 "title": "Trend 1", 

599 "publisher": "Pub 3", 

600 "developer_validation": None, 

601 "summary": "Trending snap", 

602 }, 

603 } 

604 ] 

605 top_rated = [ 

606 { 

607 "details": { 

608 "name": "/top1", 

609 "icon": "", 

610 "title": "Top 1", 

611 "publisher": "Pub 4", 

612 "developer_validation": None, 

613 "summary": "Top rated snap", 

614 }, 

615 } 

616 ] 

617 categories = [{"slug": "cat1", "name": "Cat 1"}] 

618 featured = { 

619 "_embedded": { 

620 "clickindex:package": [ 

621 { 

622 "developer_validation": True, 

623 "media": [], 

624 "publisher": "Featured Pub", 

625 "package_name": "featured-snap", 

626 "summary": "Featured snap", 

627 "title": "Featured Snap", 

628 } 

629 ] 

630 } 

631 } 

632 expected_featured = [ 

633 { 

634 "details": { 

635 "developer_validation": True, 

636 "icon": "", 

637 "publisher": "Featured Pub", 

638 "name": "featured-snap", 

639 "summary": "Featured snap", 

640 "title": "Featured Snap", 

641 } 

642 } 

643 ] 

644 

645 redis_cache.set("explore:popular-snaps", popular, ttl=3600) 

646 redis_cache.set("explore:recent-snaps", recent, ttl=3600) 

647 redis_cache.set("explore:trending-snaps", trending, ttl=3600) 

648 redis_cache.set("explore:top-rated-snaps", top_rated, ttl=3600) 

649 redis_cache.set("explore:categories", categories, ttl=3600) 

650 

651 with patch(POPULAR_PATH) as mock_popular: 

652 with patch(RECENT_PATH) as mock_recent: 

653 with patch(TREND_PATH) as mock_trending: 

654 with patch(TOP_PATH) as mock_top_rated: 

655 with patch(CATEGORIES_PATH) as mock_categories: 

656 with patch( 

657 FEATURED_PATH, return_value=featured 

658 ) as mock_featured: 

659 response = self.client.get("/store") 

660 

661 self.assert200(response) 

662 self.assert_context( 

663 "featured_snaps", expected_featured 

664 ) 

665 

666 mock_popular.assert_not_called() 

667 mock_recent.assert_not_called() 

668 mock_trending.assert_not_called() 

669 mock_top_rated.assert_not_called() 

670 mock_categories.assert_not_called() 

671 mock_featured.assert_called_once_with( 

672 fields=( 

673 "developer_validation,media," 

674 "package_name,publisher,summary," 

675 "title" 

676 ) 

677 ) 

678 

679 @responses.activate 

680 def test_explore_populates_cache_when_empty(self): 

681 """When Redis cache is empty, the recommendation/device methods 

682 should be called and their results stored in Redis for subsequent 

683 requests. 

684 """ 

685 featured = { 

686 "_embedded": { 

687 "clickindex:package": [ 

688 { 

689 "developer_validation": None, 

690 "media": [], 

691 "publisher": "Featured Pub", 

692 "package_name": "featured-snap", 

693 "summary": "Featured snap", 

694 "title": "Featured Snap", 

695 } 

696 ] 

697 } 

698 } 

699 expected_featured = [ 

700 { 

701 "details": { 

702 "developer_validation": None, 

703 "icon": "", 

704 "publisher": "Featured Pub", 

705 "name": "featured-snap", 

706 "summary": "Featured snap", 

707 "title": "Featured Snap", 

708 } 

709 } 

710 ] 

711 

712 with patch( 

713 POPULAR_PATH, 

714 return_value=[ 

715 { 

716 "details": { 

717 "name": "/popx", 

718 "icon": "", 

719 "title": "Pop X", 

720 "publisher": "Pub X", 

721 "developer_validation": None, 

722 "summary": "Popular x", 

723 } 

724 } 

725 ], 

726 ) as mock_popular: 

727 with patch( 

728 RECENT_PATH, 

729 return_value=[ 

730 { 

731 "details": { 

732 "name": "/recentx", 

733 "icon": "", 

734 "title": "Recent X", 

735 "publisher": "Pub RX", 

736 "developer_validation": None, 

737 "summary": "Recent x", 

738 } 

739 } 

740 ], 

741 ) as mock_recent: 

742 with patch( 

743 TREND_PATH, 

744 return_value=[ 

745 { 

746 "details": { 

747 "name": "/trendx", 

748 "icon": "", 

749 "title": "Trend X", 

750 "publisher": "Pub TX", 

751 "developer_validation": None, 

752 "summary": "Trend x", 

753 } 

754 } 

755 ], 

756 ) as mock_trending: 

757 with patch( 

758 TOP_PATH, 

759 return_value=[ 

760 { 

761 "details": { 

762 "name": "/topx", 

763 "icon": "", 

764 "title": "Top X", 

765 "publisher": "Pub TX", 

766 "developer_validation": None, 

767 "summary": "Top x", 

768 } 

769 } 

770 ], 

771 ) as mock_top_rated: 

772 with patch( 

773 CATEGORIES_PATH, 

774 return_value=[{"slug": "c1", "name": "C1"}], 

775 ) as mock_categories: 

776 with patch( 

777 FEATURED_PATH, return_value=featured 

778 ) as mock_featured: 

779 response = self.client.get("/store") 

780 

781 self.assert200(response) 

782 self.assert_context( 

783 "featured_snaps", expected_featured 

784 ) 

785 

786 # cache-populating methods were called 

787 self.assertTrue(mock_popular.called) 

788 self.assertTrue(mock_recent.called) 

789 self.assertTrue(mock_trending.called) 

790 self.assertTrue(mock_top_rated.called) 

791 self.assertTrue(mock_categories.called) 

792 self.assertTrue(mock_featured.called) 

793 # cached values should now exist 

794 pop_cached = redis_cache.get( 

795 "explore:popular-snaps" 

796 ) 

797 recent_cached = redis_cache.get( 

798 "explore:recent-snaps" 

799 ) 

800 trend_cached = redis_cache.get( 

801 "explore:trending-snaps" 

802 ) 

803 top_cached = redis_cache.get( 

804 "explore:top-rated-snaps" 

805 ) 

806 categories_cached = redis_cache.get( 

807 "explore:categories" 

808 ) 

809 

810 assert pop_cached is not None 

811 assert recent_cached is not None 

812 assert trend_cached is not None 

813 assert top_cached is not None 

814 assert categories_cached is not None 

815 

816 

817if __name__ == "__main__": 

818 import unittest 

819 

820 unittest.main()