diff --git a/lib/main.dart b/lib/main.dart index 6935674..91cdf55 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -15,16 +15,18 @@ void main() { runApp(const App()); } +const testnetNetwork = Network( + name: NetworkName.testnet, + walletProxyConfig: WalletProxyConfig( + baseUrl: 'https://wallet-proxy.testnet.concordium.com', + ), +); + /// Load fundamental configuration from the source of truth. Future loadConfig(HttpService http) async { // In the future, this will be loaded from a proper source rather than being hardcoded. return Config.ofNetworks([ - const Network( - name: NetworkName.testnet, - walletProxyConfig: WalletProxyConfig( - baseUrl: 'https://wallet-proxy.testnet.concordium.com', - ), - ), + testnetNetwork, ]); } diff --git a/lib/screens/home/screen.dart b/lib/screens/home/screen.dart index 577346a..14bd6ec 100644 --- a/lib/screens/home/screen.dart +++ b/lib/screens/home/screen.dart @@ -1,4 +1,5 @@ import 'package:concordium_wallet/screens/terms_and_conditions/screen.dart'; +import 'package:concordium_wallet/services/url_launcher.dart'; import 'package:concordium_wallet/services/wallet_proxy/service.dart'; import 'package:concordium_wallet/state/network.dart'; import 'package:concordium_wallet/state/terms_and_conditions.dart'; @@ -65,6 +66,7 @@ class _HomeScreenState extends State { return TermsAndConditionsScreen( validTermsAndConditions: validTac.termsAndConditions, acceptedTermsAndConditionsVersion: acceptedTac?.version, + urlLauncher: UrlLauncher(), ); } return Column( diff --git a/lib/screens/terms_and_conditions/screen.dart b/lib/screens/terms_and_conditions/screen.dart index 198428d..1b4367a 100644 --- a/lib/screens/terms_and_conditions/screen.dart +++ b/lib/screens/terms_and_conditions/screen.dart @@ -1,16 +1,18 @@ import 'package:concordium_wallet/screens/terms_and_conditions/widget.dart'; +import 'package:concordium_wallet/services/url_launcher.dart'; import 'package:concordium_wallet/services/wallet_proxy/model.dart'; import 'package:concordium_wallet/state/terms_and_conditions.dart'; import 'package:flutter/material.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_svg/flutter_svg.dart'; -import 'package:url_launcher/url_launcher.dart'; class TermsAndConditionsScreen extends StatefulWidget { final TermsAndConditions validTermsAndConditions; final String? acceptedTermsAndConditionsVersion; + final UrlLauncher urlLauncher; - const TermsAndConditionsScreen({super.key, required this.validTermsAndConditions, this.acceptedTermsAndConditionsVersion}); + const TermsAndConditionsScreen( + {super.key, required this.validTermsAndConditions, this.acceptedTermsAndConditionsVersion, required this.urlLauncher}); @override State createState() => _TermsAndConditionsScreenState(); @@ -132,8 +134,8 @@ class _TermsAndConditionsScreenState extends State { } void _launchUrl(Uri url) async { - if (await canLaunchUrl(url)) { - await launchUrl(url); + if (await widget.urlLauncher.canLaunch(url)) { + await widget.urlLauncher.launch(url); } else { // TODO If this fails, open a dialog with the URL so the user can visit it manually. throw 'Could not launch $url'; diff --git a/lib/services/url_launcher.dart b/lib/services/url_launcher.dart new file mode 100644 index 0000000..eb86602 --- /dev/null +++ b/lib/services/url_launcher.dart @@ -0,0 +1,19 @@ +import 'package:url_launcher/url_launcher.dart'; + +class UrlLauncher { + static final UrlLauncher _singleton = UrlLauncher._internal(); + + factory UrlLauncher() { + return _singleton; + } + + UrlLauncher._internal(); + + Future launch(Uri uri) { + return launchUrl(uri); + } + + Future canLaunch(Uri uri) { + return canLaunchUrl(uri); + } +} diff --git a/lib/services/wallet_proxy/service.dart b/lib/services/wallet_proxy/service.dart index 7c6442c..91f7349 100644 --- a/lib/services/wallet_proxy/service.dart +++ b/lib/services/wallet_proxy/service.dart @@ -5,11 +5,9 @@ import 'package:concordium_wallet/services/wallet_proxy/model.dart'; /// Paths of the wallet-proxy endpoints. enum WalletProxyEndpoint { - termsAndConditionsVersion('v0/termsAndConditionsVersion'), - ; + termsAndConditionsVersion('v0/termsAndConditionsVersion'); final String path; - const WalletProxyEndpoint(this.path); } diff --git a/pubspec.lock b/pubspec.lock index 3cb3419..03a8e77 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -5,18 +5,18 @@ packages: dependency: transitive description: name: _fe_analyzer_shared - sha256: eb376e9acf6938204f90eb3b1f00b578640d3188b4c8a8ec054f9f479af8d051 + sha256: ae92f5d747aee634b87f89d9946000c2de774be1d6ac3e58268224348cd0101a url: "https://pub.dev" source: hosted - version: "64.0.0" + version: "61.0.0" analyzer: dependency: transitive description: name: analyzer - sha256: "69f54f967773f6c26c7dcb13e93d7ccee8b17a641689da39e878d5cf13b06893" + sha256: ea3d8652bda62982addfd92fdc2d0214e5f82e43325104990d4f4c4a2a313562 url: "https://pub.dev" source: hosted - version: "6.2.0" + version: "5.13.0" args: dependency: transitive description: @@ -41,6 +41,14 @@ packages: url: "https://pub.dev" source: hosted version: "8.1.2" + bloc_test: + dependency: "direct dev" + description: + name: bloc_test + sha256: "02f04270be5abae8df171143e61a0058a7acbce5dcac887612e89bb40cca4c33" + url: "https://pub.dev" + source: hosted + version: "9.1.5" boolean_selector: dependency: transitive description: @@ -161,6 +169,14 @@ packages: url: "https://pub.dev" source: hosted version: "3.1.1" + coverage: + dependency: transitive + description: + name: coverage + sha256: "595a29b55ce82d53398e1bcc2cba525d7bd7c59faeb2d2540e9d42c390cfeeeb" + url: "https://pub.dev" + source: hosted + version: "1.6.4" crypto: dependency: transitive description: @@ -181,10 +197,18 @@ packages: dependency: transitive description: name: dart_style - sha256: abd7625e16f51f554ea244d090292945ec4d4be7bfbaf2ec8cccea568919d334 + sha256: "1efa911ca7086affd35f463ca2fc1799584fb6aa89883cf0af8e3664d6a02d55" url: "https://pub.dev" source: hosted - version: "2.3.3" + version: "2.3.2" + diff_match_patch: + dependency: transitive + description: + name: diff_match_patch + sha256: "2efc9e6e8f449d0abe15be240e2c2a3bcd977c8d126cfd70598aee60af35c0a4" + url: "https://pub.dev" + source: hosted + version: "0.4.1" fake_async: dependency: transitive description: @@ -384,6 +408,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.4" + mocktail: + dependency: "direct dev" + description: + name: mocktail + sha256: bac151b31e4ed78bd59ab89aa4c0928f297b1180186d5daf03734519e5f596c1 + url: "https://pub.dev" + source: hosted + version: "1.0.1" nested: dependency: transitive description: @@ -392,6 +424,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.0" + node_preamble: + dependency: transitive + description: + name: node_preamble + sha256: "6e7eac89047ab8a8d26cf16127b5ed26de65209847630400f9aefd7cd5c730db" + url: "https://pub.dev" + source: hosted + version: "2.0.2" package_config: dependency: transitive description: @@ -560,6 +600,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.4.1" + shelf_packages_handler: + dependency: transitive + description: + name: shelf_packages_handler + sha256: "89f967eca29607c933ba9571d838be31d67f53f6e4ee15147d5dc2934fee1b1e" + url: "https://pub.dev" + source: hosted + version: "3.0.2" + shelf_static: + dependency: transitive + description: + name: shelf_static + sha256: a41d3f53c4adf0f57480578c1d61d90342cd617de7fc8077b1304643c2d85c1e + url: "https://pub.dev" + source: hosted + version: "1.1.2" shelf_web_socket: dependency: transitive description: @@ -589,6 +645,22 @@ packages: url: "https://pub.dev" source: hosted version: "1.3.4" + source_map_stack_trace: + dependency: transitive + description: + name: source_map_stack_trace + sha256: "84cf769ad83aa6bb61e0aa5a18e53aea683395f196a6f39c4c881fb90ed4f7ae" + url: "https://pub.dev" + source: hosted + version: "2.1.1" + source_maps: + dependency: transitive + description: + name: source_maps + sha256: "708b3f6b97248e5781f493b765c3337db11c5d2c81c3094f10904bfa8004c703" + url: "https://pub.dev" + source: hosted + version: "0.10.12" source_span: dependency: transitive description: @@ -637,6 +709,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.2.1" + test: + dependency: transitive + description: + name: test + sha256: "13b41f318e2a5751c3169137103b60c584297353d4b1761b66029bae6411fe46" + url: "https://pub.dev" + source: hosted + version: "1.24.3" test_api: dependency: transitive description: @@ -645,6 +725,14 @@ packages: url: "https://pub.dev" source: hosted version: "0.6.0" + test_core: + dependency: transitive + description: + name: test_core + sha256: "99806e9e6d95c7b059b7a0fc08f07fc53fabe54a829497f0d9676299f1e8637e" + url: "https://pub.dev" + source: hosted + version: "0.5.3" timing: dependency: transitive description: @@ -757,6 +845,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.4" + vm_service: + dependency: transitive + description: + name: vm_service + sha256: c538be99af830f478718b51630ec1b6bee5e74e52c8a802d328d9e71d35d2583 + url: "https://pub.dev" + source: hosted + version: "11.10.0" watcher: dependency: transitive description: @@ -781,6 +877,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.4.0" + webkit_inspection_protocol: + dependency: transitive + description: + name: webkit_inspection_protocol + sha256: "87d3f2333bb240704cd3f1c6b5b7acd8a10e7f0bc28c28dcf14e782014f4a572" + url: "https://pub.dev" + source: hosted + version: "1.2.1" win32: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 2af12dd..dffe9e2 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -54,6 +54,8 @@ dev_dependencies: # rules and activating additional ones. flutter_lints: ^2.0.0 json_serializable: ^6.7.1 + mocktail: ^1.0.1 + bloc_test: ^9.1.5 # For information on the generic Dart part of this file, see the # following page: https://dart.dev/tools/pub/pubspec diff --git a/test/helpers.dart b/test/helpers.dart new file mode 100644 index 0000000..e52789f --- /dev/null +++ b/test/helpers.dart @@ -0,0 +1,4 @@ +import 'package:flutter/material.dart'; + +/// Wrap MaterialApp and Scaffold around the given widget. +Widget wrapMaterial({required Widget? child}) => MaterialApp(home: Scaffold(body: child)); diff --git a/test/terms_and_conditions_test.dart b/test/terms_and_conditions_test.dart new file mode 100644 index 0000000..b8edcb2 --- /dev/null +++ b/test/terms_and_conditions_test.dart @@ -0,0 +1,105 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:concordium_wallet/services/url_launcher.dart'; +import 'package:concordium_wallet/state/terms_and_conditions.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:concordium_wallet/screens/terms_and_conditions/widget.dart'; +import 'package:concordium_wallet/screens/terms_and_conditions/screen.dart'; +import 'package:concordium_wallet/services/wallet_proxy/model.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +import 'helpers.dart'; + +class MockUrlLauncher extends Mock implements UrlLauncher {} + +class MockTACCubit extends MockCubit implements TermsAndConditionAcceptance {} + +void main() { + group("Terms and conditions screen", () { + bool checked = false; + late Widget tacScreen; + late MockTACCubit mockTACCubit; + late TermsAndConditionsAcceptanceState state; + const String validVersion = "1.1.0"; + const String acceptedVersion = "1.0.0"; + + setUpAll(() { + registerFallbackValue(const AcceptedTermsAndConditions(version: validVersion)); + }); + + setUp(() { + checked = false; + + final terms = TermsAndConditions(Uri.parse("localhost"), validVersion); + state = TermsAndConditionsAcceptanceState( + accepted: const AcceptedTermsAndConditions(version: acceptedVersion), + valid: ValidTermsAndConditions.refreshedNow(termsAndConditions: terms)); + + // Build the terms and condition screen we wish to test + final rawTacScreen = TermsAndConditionsScreen(validTermsAndConditions: terms, urlLauncher: MockUrlLauncher()); + mockTACCubit = MockTACCubit(); + + tacScreen = BlocProvider.value(value: mockTACCubit, child: wrapMaterial(child: rawTacScreen)); + + when(() => mockTACCubit.state).thenAnswer((_) => state); + when(() => mockTACCubit.userAccepted(any())).thenAnswer((_) { + checked = true; + }); + }); + + testWidgets('Pressing continue does not perform check', (WidgetTester tester) async { + // Arrange + await tester.pumpWidget(wrapMaterial(child: tacScreen)); + + // Act + // TODO use internationalized version here. + await tester.tap(find.text("Continue", findRichText: true)); + + // Assert + expect(checked, false); + }); + + testWidgets('Pressing continue, after toggling accept, performs check', (WidgetTester tester) async { + // Arrange + await tester.pumpWidget(tacScreen); + + // Act + await tester.tap(find.byType(ToggleAcceptedWidget)); + + await tester.pump(); + + // TODO use internationalized version here. + await tester.tap(find.text("Continue", findRichText: true)); + + await tester.pump(); + + // Assert + expect(checked, true); + }); + }); + + testWidgets('Clicking on terms and conditions', (WidgetTester tester) async { + // Arrange + Uri uri = Uri.parse("localhost"); + var launcher = MockUrlLauncher(); + + // Build the terms and condition screen we wish to test + var tacScreen = TermsAndConditionsScreen( + validTermsAndConditions: TermsAndConditions(uri, "1.1.0"), + urlLauncher: launcher, + ); + + await tester.pumpWidget(wrapMaterial(child: tacScreen)); + + when(() => launcher.canLaunch(uri)).thenAnswer((_) => Future.value(true)); + when(() => launcher.launch(uri)).thenAnswer((_) => Future.value(true)); + + // Act + // TODO use internationalized version here. + await tester.tap(find.textContaining("I have read and agree to the", findRichText: true)); + + // Assert + verify(() => launcher.launch(uri)).called(1); + }); +} diff --git a/test/wallet_proxy_service_test.dart b/test/wallet_proxy_service_test.dart new file mode 100644 index 0000000..c749537 --- /dev/null +++ b/test/wallet_proxy_service_test.dart @@ -0,0 +1,42 @@ +import 'package:concordium_wallet/main.dart'; +import 'package:concordium_wallet/services/http.dart'; +import 'package:concordium_wallet/services/wallet_proxy/service.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; + +class MockHttpService extends Mock implements HttpService { + String data; + + MockHttpService(this.data); + + @override + Future get(Uri url) { + return Future.value(http.Response(data, 200)); + } +} + +void main() { + test("WalletProxyService returns specified version and url", () async { + // Arrange + const tacUrl = "http://tac.com"; + const tacVersion = "1.2.3"; + const rawData = '{"url":"$tacUrl","version":"$tacVersion"}'; + + var httpClient = MockHttpService(rawData); + + var service = WalletProxyService(config: const WalletProxyConfig(baseUrl: 'http://test.com'), http: httpClient); + + // Act + var tac = await service.fetchTermsAndConditions(); + + // Assert + expect(tac.url.toString(), tacUrl); + expect(tac.version, tacVersion); + }); + + test('WalletProxyConfig for testnet merges base and path correctly for terms and conditions', () { + expect(testnetNetwork.walletProxyConfig.urlOf(WalletProxyEndpoint.termsAndConditionsVersion).toString(), + 'https://wallet-proxy.testnet.concordium.com/v0/termsAndConditionsVersion'); + }); +}