From e51e49b85762b410c6aed82f7cdc691ad90efd2c Mon Sep 17 00:00:00 2001 From: ice-alcides <171546305+ice-alcides@users.noreply.github.com> Date: Wed, 7 Aug 2024 16:20:30 +0300 Subject: [PATCH] feat: add unit tests for wallets data providers, local storage (#138) ### Added unit tests for wallet-related providers and LocalStorage This PR adds unit tests for the following components: **currentWalletIdProvider:** - Verifies correct wallet ID selection based on existing data - Handles edge cases like non-existent IDs and empty wallet lists **currentWalletDataProvider:** - Ensures correct wallet data retrieval - Validates error handling for non-existent wallet IDs **walletByIdProvider:** - Confirms accurate wallet data retrieval by ID - Tests error cases for invalid IDs **WalletsDataNotifier:** - Validates initial state setup - Tests CRUD operations (add, update, delete) for wallets - Verifies exception handling for duplicate and non-existent wallets **SelectedWalletIdNotifier:** - Tests initialization from local storage - Verifies data persistence to local storage - Ensures proper reaction to local storage changes **LocalStorage:** - Comprehensive tests for various data types (bool, double, string, enum) - Validates default value handling and error cases --- .../core/providers/init_provider.dart | 3 +- .../providers/user_preferences_provider.dart | 21 +- .../selected_wallet_id_provider.dart | 5 +- lib/app/services/storage/local_storage.dart | 46 ++-- lib/l10n/app_es.arb | 1 - pubspec.lock | 8 + pubspec.yaml | 1 + test/mocks.dart | 20 ++ .../local_storage/local_storage_test.dart | 95 +++++++ test/test_utils.dart | 22 ++ .../selected_wallet_id_provider_test.dart | 105 ++++++++ test/wallets/wallets_data_provider_test.dart | 243 ++++++++++++++++++ 12 files changed, 542 insertions(+), 28 deletions(-) delete mode 100644 lib/l10n/app_es.arb create mode 100644 test/mocks.dart create mode 100644 test/services/local_storage/local_storage_test.dart create mode 100644 test/test_utils.dart create mode 100644 test/wallets/selected_wallet_id_provider_test.dart create mode 100644 test/wallets/wallets_data_provider_test.dart diff --git a/lib/app/features/core/providers/init_provider.dart b/lib/app/features/core/providers/init_provider.dart index 7c48f561f..46411909d 100644 --- a/lib/app/features/core/providers/init_provider.dart +++ b/lib/app/features/core/providers/init_provider.dart @@ -16,8 +16,9 @@ Future initApp(InitAppRef ref) async { await Future.wait(>[ ref.read(appTemplateProvider.future), ref.read(authProvider.notifier).rehydrate(), - LocalStorage.initialize(), ]); + + await ref.watch(sharedPreferencesProvider.future); ref.watch(relaysProvider.notifier); await ref.read(permissionsProvider.notifier).checkAllPermissions(); } diff --git a/lib/app/features/user/providers/user_preferences_provider.dart b/lib/app/features/user/providers/user_preferences_provider.dart index 05f2f0f28..edea101df 100644 --- a/lib/app/features/user/providers/user_preferences_provider.dart +++ b/lib/app/features/user/providers/user_preferences_provider.dart @@ -15,15 +15,16 @@ class UserPreferencesNotifier extends _$UserPreferencesNotifier { @override UserPreferences build() { - final isBalanceVisible = LocalStorage.getBool(isBalanceVisibleKey, defaultValue: true); + final localStorage = ref.watch(localStorageProvider); + final isBalanceVisible = localStorage.getBool(isBalanceVisibleKey, defaultValue: true); final isZeroValueAssetsVisible = - LocalStorage.getBool(isZeroValueAssetsVisibleKey, defaultValue: true); - final nftLayoutType = LocalStorage.getEnum( + localStorage.getBool(isZeroValueAssetsVisibleKey, defaultValue: true); + final nftLayoutType = localStorage.getEnum( nftLayoutTypeKey, NftLayoutType.values, defaultValue: NftLayoutType.list, ); - final nftSortingType = LocalStorage.getEnum( + final nftSortingType = localStorage.getEnum( nftSortingTypeKey, NftSortingType.values, defaultValue: NftSortingType.desc, @@ -38,38 +39,42 @@ class UserPreferencesNotifier extends _$UserPreferencesNotifier { } void switchBalanceVisibility() { + final localStorage = ref.read(localStorageProvider); state = state.copyWith(isBalanceVisible: !state.isBalanceVisible); - LocalStorage.setBool( + localStorage.setBool( key: isBalanceVisibleKey, value: state.isBalanceVisible, ); } void switchZeroValueAssetsVisibility() { + final localStorage = ref.read(localStorageProvider); state = state.copyWith( isZeroValueAssetsVisible: !state.isZeroValueAssetsVisible, ); - LocalStorage.setBool( + localStorage.setBool( key: isZeroValueAssetsVisibleKey, value: state.isZeroValueAssetsVisible, ); } void setNftLayoutType(NftLayoutType newNftLayoutType) { + final localStorage = ref.read(localStorageProvider); state = state.copyWith( nftLayoutType: newNftLayoutType, ); - LocalStorage.setEnum( + localStorage.setEnum( nftLayoutTypeKey, newNftLayoutType, ); } void setNftSortingType(NftSortingType newNftSortingType) { + final localStorage = ref.read(localStorageProvider); state = state.copyWith( nftSortingType: newNftSortingType, ); - LocalStorage.setEnum( + localStorage.setEnum( nftSortingTypeKey, newNftSortingType, ); diff --git a/lib/app/features/wallets/providers/selected_wallet_id_provider.dart b/lib/app/features/wallets/providers/selected_wallet_id_provider.dart index e9175226c..a3749c131 100644 --- a/lib/app/features/wallets/providers/selected_wallet_id_provider.dart +++ b/lib/app/features/wallets/providers/selected_wallet_id_provider.dart @@ -10,11 +10,12 @@ class SelectedWalletIdNotifier extends _$SelectedWalletIdNotifier { @override String? build() { - return LocalStorage.getString(selectedWalletIdKey) ?? mockedWalletDataArray[0].id; + return ref.watch(localStorageProvider).getString(selectedWalletIdKey) ?? + mockedWalletDataArray.first.id; } set selectedWalletId(String selectedWalletId) { state = selectedWalletId; - LocalStorage.setString(selectedWalletIdKey, selectedWalletId); + ref.read(localStorageProvider).setString(selectedWalletIdKey, selectedWalletId); } } diff --git a/lib/app/services/storage/local_storage.dart b/lib/app/services/storage/local_storage.dart index 29b076f44..3bc87072c 100644 --- a/lib/app/services/storage/local_storage.dart +++ b/lib/app/services/storage/local_storage.dart @@ -1,43 +1,57 @@ +import 'dart:async'; + import 'package:ice/app/extensions/enum.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; import 'package:shared_preferences/shared_preferences.dart'; +part 'local_storage.g.dart'; + +@Riverpod(keepAlive: true) +Future sharedPreferences(SharedPreferencesRef ref) => + SharedPreferences.getInstance(); + +@Riverpod(keepAlive: true) +LocalStorage localStorage(LocalStorageRef ref) { + final prefs = ref.watch(sharedPreferencesProvider); + + return LocalStorage(prefs.requireValue); +} + class LocalStorage { - static late SharedPreferences _prefs; + final SharedPreferences _prefs; - static Future initialize() async { - _prefs = await SharedPreferences.getInstance(); - } + const LocalStorage(this._prefs); - static Future setBool({required String key, required bool value}) { - return _prefs.setBool(key, value); + void setBool({required String key, required bool value}) { + return unawaited(_prefs.setBool(key, value)); } - static bool getBool(String key, {bool defaultValue = false}) { + bool getBool(String key, {bool defaultValue = false}) { return _prefs.getBool(key) ?? defaultValue; } - static Future setDouble(String key, double value) { - return _prefs.setDouble(key, value); + void setDouble(String key, double value) { + return unawaited(_prefs.setDouble(key, value)); } - static double getDouble(String key, {double defaultValue = 0.0}) { + double getDouble(String key, {double defaultValue = 0.0}) { return _prefs.getDouble(key) ?? defaultValue; } - static Future setString(String key, String value) { - return _prefs.setString(key, value); + void setString(String key, String value) { + return unawaited(_prefs.setString(key, value)); } - static String? getString(String key) { + String? getString(String key) { return _prefs.getString(key); } - static Future setEnum(String key, T value) { - return _prefs.setString(key, value.toShortString()); + void setEnum(String key, T value) { + return unawaited(_prefs.setString(key, value.toShortString())); } // Get an enum value - static T getEnum( + T getEnum( String key, List enumValues, { required T defaultValue, diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb deleted file mode 100644 index 0967ef424..000000000 --- a/lib/l10n/app_es.arb +++ /dev/null @@ -1 +0,0 @@ -{} diff --git a/pubspec.lock b/pubspec.lock index 4144e0103..5e6e961cd 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -994,6 +994,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.0.5" + mocktail: + dependency: "direct main" + description: + name: mocktail + sha256: "890df3f9688106f25755f26b1c60589a92b3ab91a22b8b224947ad041bf172d8" + url: "https://pub.dev" + source: hosted + version: "1.0.4" nested: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 9593511d3..a25b24b4e 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -32,6 +32,7 @@ dependencies: intl: any json_annotation: ^4.8.1 lottie: ^3.1.0 + mocktail: ^1.0.4 nostr_dart: git: https://github.com/ice-blockchain/nostr-dart.git permission_handler: ^11.3.0 diff --git a/test/mocks.dart b/test/mocks.dart new file mode 100644 index 000000000..c650e345f --- /dev/null +++ b/test/mocks.dart @@ -0,0 +1,20 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:ice/app/features/wallet/model/wallet_data.dart'; +import 'package:ice/app/features/wallets/providers/selected_wallet_id_provider.dart'; +import 'package:ice/app/features/wallets/providers/wallets_data_provider.dart'; +import 'package:ice/app/services/storage/local_storage.dart'; +import 'package:mocktail/mocktail.dart'; + +class Listener extends Mock { + void call(T? previous, T value); +} + +class MockLocalStorage extends Mock implements LocalStorage {} + +class MockSelectedWalletIdNotifier extends Notifier + with Mock + implements SelectedWalletIdNotifier {} + +class MockWalletsDataNotifier extends Notifier> + with Mock + implements WalletsDataNotifier {} diff --git a/test/services/local_storage/local_storage_test.dart b/test/services/local_storage/local_storage_test.dart new file mode 100644 index 000000000..946c9bea5 --- /dev/null +++ b/test/services/local_storage/local_storage_test.dart @@ -0,0 +1,95 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ice/app/services/storage/local_storage.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +enum TestEnum { one, two, three } + +const testKey = 'testKey'; +const testStringValue = 'testValue'; +const testBoolValue = true; +const testDoubleValue = 1.5; +final testEnumValue = TestEnum.two; + +void main() { + late LocalStorage localStorage; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + final prefs = await SharedPreferences.getInstance(); + localStorage = LocalStorage(prefs); + }); + + group('LocalStorage', () { + test('setBool() and getBool()', () { + localStorage.setBool(key: testKey, value: testBoolValue); + + expect(localStorage.getBool(testKey), testBoolValue); + }); + + test('getBool() returns default value when key not found', () { + expect( + localStorage.getBool(testKey, defaultValue: !testBoolValue), + !testBoolValue, + ); + }); + + test('setDouble() and getDouble()', () { + localStorage.setDouble(testKey, testDoubleValue); + + expect(localStorage.getDouble(testKey), testDoubleValue); + }); + + test('getDouble() returns default value when key not found', () { + expect( + localStorage.getDouble(testKey, defaultValue: 0.0), + 0.0, + ); + }); + + test('setString() and getString()', () { + localStorage.setString(testKey, testStringValue); + + expect( + localStorage.getString(testKey), + testStringValue, + ); + }); + + test('setEnum() and getEnum()', () { + localStorage.setEnum(testKey, testEnumValue); + + expect( + localStorage.getEnum( + testKey, + TestEnum.values, + defaultValue: TestEnum.one, + ), + testEnumValue, + ); + }); + + test('getEnum() returns default value when key not found', () { + expect( + localStorage.getEnum( + testKey, + TestEnum.values, + defaultValue: TestEnum.one, + ), + TestEnum.one, + ); + }); + + test('getEnum() returns default value when value is invalid', () { + localStorage.setString(testKey, 'invalid'); + + expect( + localStorage.getEnum( + testKey, + TestEnum.values, + defaultValue: TestEnum.one, + ), + TestEnum.one, + ); + }); + }); +} diff --git a/test/test_utils.dart b/test/test_utils.dart new file mode 100644 index 000000000..70ca87555 --- /dev/null +++ b/test/test_utils.dart @@ -0,0 +1,22 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:riverpod/riverpod.dart'; + +/// A testing utility which creates a [ProviderContainer] and automatically +/// disposes it at the end of the test. +ProviderContainer createContainer({ + ProviderContainer? parent, + List overrides = const [], + List? observers, +}) { + // Create a ProviderContainer, and optionally allow specifying parameters. + final container = ProviderContainer( + parent: parent, + overrides: overrides, + observers: observers, + ); + + // When the test ends, dispose the container. + addTearDown(container.dispose); + + return container; +} diff --git a/test/wallets/selected_wallet_id_provider_test.dart b/test/wallets/selected_wallet_id_provider_test.dart new file mode 100644 index 000000000..94e6cc58e --- /dev/null +++ b/test/wallets/selected_wallet_id_provider_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ice/app/features/wallets/providers/mock_data/mock_data.dart'; +import 'package:ice/app/features/wallets/providers/selected_wallet_id_provider.dart'; +import 'package:ice/app/services/storage/local_storage.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:riverpod/riverpod.dart'; + +import '../mocks.dart'; +import '../test_utils.dart'; + +void main() { + late ProviderContainer container; + late MockLocalStorage mockLocalStorage; + + setUp( + () { + mockLocalStorage = MockLocalStorage(); + container = createContainer( + overrides: [ + localStorageProvider.overrideWithValue(mockLocalStorage), + ], + ); + }, + ); + + group('SelectedWalletIdNotifier Tests', () { + test('build returns value from localStorage if it exists', () { + when( + () => mockLocalStorage.getString( + SelectedWalletIdNotifier.selectedWalletIdKey, + ), + ).thenReturn('testWalletId'); + + final result = container.read(selectedWalletIdNotifierProvider); + + expect(result, 'testWalletId'); + + verify( + () => mockLocalStorage.getString(SelectedWalletIdNotifier.selectedWalletIdKey), + ).called(1); + }); + + test('build() returns first mocked wallet id if localStorage is empty', () { + when( + () => mockLocalStorage.getString(SelectedWalletIdNotifier.selectedWalletIdKey), + ).thenReturn(null); + + final result = container.read(selectedWalletIdNotifierProvider); + + expect(result, mockedWalletDataArray[0].id); + + verify( + () => mockLocalStorage.getString(SelectedWalletIdNotifier.selectedWalletIdKey), + ).called(1); + }); + + test('selectedWalletId setter updates state and localStorage', () { + final notifier = container.read(selectedWalletIdNotifierProvider.notifier); + + when( + () => mockLocalStorage.setString(any(), any()), + ).thenReturn(null); + + notifier.selectedWalletId = 'newWalletId'; + + expect( + container.read(selectedWalletIdNotifierProvider), + 'newWalletId', + ); + + verify(() => mockLocalStorage.setString( + SelectedWalletIdNotifier.selectedWalletIdKey, 'newWalletId')).called(1); + }); + + test('notifier reacts to changes in localStorage', () { + final listener = Listener(); + + when( + () => mockLocalStorage.getString(SelectedWalletIdNotifier.selectedWalletIdKey), + ).thenReturn( + mockedWalletDataArray.first.id, + ); + + container.listen( + selectedWalletIdNotifierProvider, + listener, + fireImmediately: true, + ); + + verify( + () => listener(null, mockedWalletDataArray.first.id), + ).called(1); + + when( + () => mockLocalStorage.getString(SelectedWalletIdNotifier.selectedWalletIdKey), + ).thenReturn('updatedWalletId'); + + container.refresh(selectedWalletIdNotifierProvider); + + verify( + () => listener(mockedWalletDataArray.first.id, 'updatedWalletId'), + ).called(1); + }); + }); +} diff --git a/test/wallets/wallets_data_provider_test.dart b/test/wallets/wallets_data_provider_test.dart new file mode 100644 index 000000000..b219637c9 --- /dev/null +++ b/test/wallets/wallets_data_provider_test.dart @@ -0,0 +1,243 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:ice/app/features/wallet/model/wallet_data.dart'; +import 'package:ice/app/features/wallets/providers/mock_data/mock_data.dart'; +import 'package:ice/app/features/wallets/providers/selected_wallet_id_provider.dart'; +import 'package:ice/app/features/wallets/providers/wallets_data_provider.dart'; + +import '../mocks.dart'; +import '../test_utils.dart'; + +void main() { + group('CurrentWalletIdProvider', () { + test('returns the selected wallet id when it exists in the data', () { + final container = createContainer( + overrides: [ + walletsDataNotifierProvider.overrideWith(WalletsDataNotifier.new), + selectedWalletIdNotifierProvider.overrideWith(MockSelectedWalletIdNotifier.new), + ], + ); + + final mockNotifier = container.read(selectedWalletIdNotifierProvider.notifier); + mockNotifier.selectedWalletId = '1'; + + final currentWalletId = container.read(currentWalletIdProvider); + + expect(currentWalletId, equals('1')); + }); + + test('returns the first wallet id when selected id does not exist', () { + final container = createContainer( + overrides: [ + walletsDataNotifierProvider.overrideWith(WalletsDataNotifier.new), + selectedWalletIdNotifierProvider.overrideWith(MockSelectedWalletIdNotifier.new), + ], + ); + + final mockNotifier = container.read(selectedWalletIdNotifierProvider.notifier); + + mockNotifier.selectedWalletId = 'non_existing_id'; + + final currentWalletId = container.read(currentWalletIdProvider); + + expect(currentWalletId, equals(mockedWalletDataArray.first.id)); + }); + + test('returns an empty string when there are no wallets', () { + final container = createContainer( + overrides: [ + walletsDataNotifierProvider.overrideWith(MockWalletsDataNotifier.new), + selectedWalletIdNotifierProvider.overrideWith(MockSelectedWalletIdNotifier.new), + ], + ); + + final mockWalletsNotifier = container.read(walletsDataNotifierProvider.notifier); + final mockSelectedWalletNotifier = container.read(selectedWalletIdNotifierProvider.notifier); + + mockWalletsNotifier.state = []; + mockSelectedWalletNotifier.selectedWalletId = 'non_existing_id'; + + final currentWalletId = container.read(currentWalletIdProvider); + + expect(currentWalletId, equals('')); + }); + }); + + group('currentWalletDataProvider', () { + test('returns the correct wallet data for the current wallet id', () { + final container = createContainer( + overrides: [ + walletsDataNotifierProvider.overrideWith(MockWalletsDataNotifier.new), + currentWalletIdProvider.overrideWithValue('1'), + ], + ); + + final mockWalletsNotifier = container.read(walletsDataNotifierProvider.notifier); + + mockWalletsNotifier.state = mockedWalletDataArray; + + final currentWalletData = container.read(currentWalletDataProvider); + + expect(currentWalletData.id, equals('1')); + expect(currentWalletData.name, equals('ice.wallet')); + expect(currentWalletData.balance, equals(36594.33)); + }); + + test('throws StateError when the current wallet id does not exist', () { + final container = createContainer( + overrides: [ + walletsDataNotifierProvider.overrideWith(MockWalletsDataNotifier.new), + currentWalletIdProvider.overrideWithValue('non_existing_id'), + ], + ); + + final mockWalletsNotifier = container.read(walletsDataNotifierProvider.notifier); + + mockWalletsNotifier.state = mockedWalletDataArray; + + expect(() => container.read(currentWalletDataProvider), throwsStateError); + }); + }); + + group('walletByIdProvider', () { + test('returns correct wallet data for given id', () { + final container = createContainer( + overrides: [ + walletsDataNotifierProvider.overrideWith(WalletsDataNotifier.new), + ], + ); + + final walletData = container.read(walletByIdProvider(id: '1')); + + expect(walletData, isA()); + expect(walletData.id, '1'); + }); + + test('throws when wallet with given id does not exist', () { + final container = createContainer( + overrides: [ + walletsDataNotifierProvider.overrideWith(WalletsDataNotifier.new), + ], + ); + + expect(() => container.read(walletByIdProvider(id: 'non-existent')), throwsStateError); + }); + }); + + group('WalletsDataNotifier', () { + test('initial state is mockedWalletDataArray', () { + final container = createContainer( + overrides: [ + walletsDataNotifierProvider.overrideWith(WalletsDataNotifier.new), + ], + ); + final state = container.read(walletsDataNotifierProvider); + + expect(state, equals(mockedWalletDataArray)); + }); + + test('addWallet() adds a new wallet', () { + final container = createContainer( + overrides: [ + walletsDataNotifierProvider.overrideWith(WalletsDataNotifier.new), + ], + ); + final notifier = container.read(walletsDataNotifierProvider.notifier); + + final newWallet = WalletData( + id: '4', + name: 'New Wallet', + icon: 'icon', + balance: 100, + ); + + notifier.addWallet(newWallet); + + expect(notifier.state, contains(newWallet)); + }); + + test('updateWallet() updates an existing wallet', () { + final container = createContainer( + overrides: [ + walletsDataNotifierProvider.overrideWith(WalletsDataNotifier.new), + ], + ); + final notifier = container.read(walletsDataNotifierProvider.notifier); + + final updatedWallet = WalletData( + id: '1', + name: 'Updated Wallet', + icon: 'new_icon', + balance: 200, + ); + + notifier.updateWallet(updatedWallet); + + expect( + notifier.state.firstWhere((wallet) => wallet.id == '1'), + equals(updatedWallet), + ); + }); + + test('deleteWallet() removes a wallet', () { + final container = createContainer( + overrides: [ + walletsDataNotifierProvider.overrideWith(WalletsDataNotifier.new), + ], + ); + final notifier = container.read(walletsDataNotifierProvider.notifier); + + notifier.deleteWallet('1'); + + expect(notifier.state.any((wallet) => wallet.id == '1'), isFalse); + }); + + test('addWallet() throws exception when wallet with same id already exists', () { + final container = createContainer( + overrides: [ + walletsDataNotifierProvider.overrideWith(WalletsDataNotifier.new), + ], + ); + final notifier = container.read(walletsDataNotifierProvider.notifier); + + final existingWallet = mockedWalletDataArray.first; + + expect( + () => notifier.addWallet(existingWallet), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('Wallet with id ${existingWallet.id} already exists'), + ), + ), + ); + }); + + test('updateWallet() throws exception when wallet does not exist', () { + final container = createContainer( + overrides: [ + walletsDataNotifierProvider.overrideWith(WalletsDataNotifier.new), + ], + ); + final notifier = container.read(walletsDataNotifierProvider.notifier); + + final nonExistentWallet = WalletData( + id: 'non-existent', + name: 'Non-existent Wallet', + icon: 'icon', + balance: 0, + ); + + expect( + () => notifier.updateWallet(nonExistentWallet), + throwsA( + isA().having( + (e) => e.toString(), + 'message', + contains('Wallet with id ${nonExistentWallet.id} does not exist'), + ), + ), + ); + }); + }); +}