From ad1f8b4fd97695985b5352ddf63c4a68d27b68b7 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 15 May 2026 09:53:18 +0200 Subject: [PATCH] test(screens): cover screen cubits/blocs (+37 tests) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds bloc_test specs for five screen-level cubits/blocs that previously only had widget-level coverage. - legal_disclaimer_cubit (7): initial state, nextStep advance, full walk to last step, onComplete callback on last step, no-op without callback, previousStep, no-op at step 0 - validate_seed_cubit (7): initial state, checkSeedLength (12 valid / short / unknown word / extra whitespace), validateSeed (valid / invalid) - transaction_history_filter_cubit (6): repo stream subscription, default 1-year window, stream-driven population, date-window filter, inclusive boundaries, filter re-application on subsequent emissions - verify_seed_cubit (6): random ascending word indices, debug-mode pre-fill, canVerify, updateWord trims/lowercases + clears error, verify success marks current wallet, verify failure flags error - setup_pin_cubit (11): initial state, addDigit append / 6-digit cap, deleteDigit / no-op on empty, create→confirm transition, matching pin persists salt + hash and emits isComplete (real PBKDF2 on compute-isolate shim), mismatching pin resets + flags mismatch, reset, isBiometricAvailable + enableBiometrics passthrough The matching-pin test exercises a real 600k-iteration PBKDF2 hash via `compute()`; on the Flutter-test isolate shim it takes ~12s. Generous timeout in the test reflects this. --- .../cubit/legal_disclaimer_cubit_test.dart | 95 ++++++++++ test/screens/pin/setup_pin_cubit_test.dart | 162 ++++++++++++++++++ .../cubit/validate_seed_cubit_test.dart | 84 +++++++++ ...transaction_history_filter_cubit_test.dart | 152 ++++++++++++++++ .../cubit/verify_seed_cubit_test.dart | 97 +++++++++++ 5 files changed, 590 insertions(+) create mode 100644 test/screens/legal/cubit/legal_disclaimer_cubit_test.dart create mode 100644 test/screens/pin/setup_pin_cubit_test.dart create mode 100644 test/screens/restore_wallet/cubit/validate_seed_cubit_test.dart create mode 100644 test/screens/transaction_history/cubits/transaction_history_filter_cubit_test.dart create mode 100644 test/screens/verify_seed/cubit/verify_seed_cubit_test.dart diff --git a/test/screens/legal/cubit/legal_disclaimer_cubit_test.dart b/test/screens/legal/cubit/legal_disclaimer_cubit_test.dart new file mode 100644 index 00000000..2119642e --- /dev/null +++ b/test/screens/legal/cubit/legal_disclaimer_cubit_test.dart @@ -0,0 +1,95 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/screens/legal/cubit/legal_disclaimer_cubit.dart'; + +void main() { + group('$LegalDisclaimerCubit', () { + test('initial state is step 0', () { + final cubit = LegalDisclaimerCubit(); + + expect(cubit.state.currentStep, 0); + expect(cubit.state.canGoBack, isFalse); + expect(cubit.state.isLastStep, isFalse); + // 5 steps total → step 0 == 1/5 = 0.2. + expect(cubit.state.progress, closeTo(0.2, 1e-9)); + }); + + blocTest( + 'nextStep advances by one when not on the last step', + build: LegalDisclaimerCubit.new, + act: (cubit) => cubit.nextStep(), + expect: () => [ + const LegalDisclaimerState(currentStep: 1), + ], + ); + + blocTest( + 'nextStep walks from 0 all the way to the last step', + build: LegalDisclaimerCubit.new, + act: (cubit) { + for (var i = 0; i < LegalDisclaimerState.totalSteps - 1; i++) { + cubit.nextStep(); + } + }, + expect: () => [ + const LegalDisclaimerState(currentStep: 1), + const LegalDisclaimerState(currentStep: 2), + const LegalDisclaimerState(currentStep: 3), + const LegalDisclaimerState(currentStep: 4), + ], + verify: (cubit) { + expect(cubit.state.isLastStep, isTrue); + expect(cubit.state.progress, 1.0); + }, + ); + + test('nextStep on the last step invokes onComplete and does not emit', () async { + final cubit = LegalDisclaimerCubit(); + for (var i = 0; i < LegalDisclaimerState.totalSteps - 1; i++) { + cubit.nextStep(); + } + final emitted = []; + final sub = cubit.stream.listen(emitted.add); + + var completed = false; + cubit.nextStep(onComplete: () => completed = true); + await Future.delayed(Duration.zero); + await sub.cancel(); + + expect(completed, isTrue); + expect(emitted, isEmpty); + expect(cubit.state.currentStep, LegalDisclaimerState.totalSteps - 1); + }); + + test('nextStep on the last step without onComplete is a no-op', () async { + final cubit = LegalDisclaimerCubit(); + for (var i = 0; i < LegalDisclaimerState.totalSteps - 1; i++) { + cubit.nextStep(); + } + final emitted = []; + final sub = cubit.stream.listen(emitted.add); + + cubit.nextStep(); + await Future.delayed(Duration.zero); + await sub.cancel(); + + expect(emitted, isEmpty); + expect(cubit.state.currentStep, LegalDisclaimerState.totalSteps - 1); + }); + + blocTest( + 'previousStep moves back when canGoBack is true', + build: LegalDisclaimerCubit.new, + seed: () => const LegalDisclaimerState(currentStep: 2), + act: (cubit) => cubit.previousStep(), + expect: () => [const LegalDisclaimerState(currentStep: 1)], + ); + + blocTest( + 'previousStep at step 0 is a no-op (cannot go below zero)', + build: LegalDisclaimerCubit.new, + act: (cubit) => cubit.previousStep(), + expect: () => [], + ); + }); +} diff --git a/test/screens/pin/setup_pin_cubit_test.dart b/test/screens/pin/setup_pin_cubit_test.dart new file mode 100644 index 00000000..cc0256c6 --- /dev/null +++ b/test/screens/pin/setup_pin_cubit_test.dart @@ -0,0 +1,162 @@ +import 'dart:typed_data'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/biometric_service.dart'; +import 'package:realunit_wallet/packages/storage/secure_storage.dart'; +import 'package:realunit_wallet/screens/pin/bloc/setup_pin/setup_pin_cubit.dart'; + +class _MockSecureStorage extends Mock implements SecureStorage {} + +class _MockBiometricService extends Mock implements BiometricService {} + +void main() { + late _MockSecureStorage secureStorage; + late _MockBiometricService biometricService; + + setUpAll(() { + // Uint8List is a restricted type; a Fake subclass is illegal, but mocktail + // accepts a concrete instance as the registered fallback. + registerFallbackValue(Uint8List(0)); + }); + + setUp(() { + secureStorage = _MockSecureStorage(); + biometricService = _MockBiometricService(); + when(() => secureStorage.setPinSalt(any())).thenAnswer((_) async {}); + when(() => secureStorage.setPinHash(any())).thenAnswer((_) async {}); + }); + + SetupPinCubit build() => SetupPinCubit(secureStorage, biometricService); + + group('$SetupPinCubit', () { + test('initial state is create mode, empty pin, no mismatch, not complete', () { + final cubit = build(); + + expect(cubit.state.mode, SetupPinMode.create); + expect(cubit.state.currentPin, ''); + expect(cubit.state.mismatch, isFalse); + expect(cubit.state.isComplete, isFalse); + }); + + blocTest( + 'addDigit appends a digit to the current pin', + build: build, + act: (cubit) { + cubit.addDigit(1); + cubit.addDigit(2); + }, + expect: () => [ + const SetupPinState(currentPin: '1'), + const SetupPinState(currentPin: '12'), + ], + ); + + blocTest( + 'addDigit ignored when the pin is already 6 digits long', + build: build, + seed: () => const SetupPinState(currentPin: '123456'), + act: (cubit) => cubit.addDigit(7), + expect: () => [], + ); + + blocTest( + 'deleteDigit drops the last character and clears mismatch', + build: build, + seed: () => const SetupPinState(currentPin: '12', mismatch: true), + act: (cubit) => cubit.deleteDigit(), + expect: () => [const SetupPinState(currentPin: '1')], + ); + + blocTest( + 'deleteDigit on an empty pin is a no-op', + build: build, + act: (cubit) => cubit.deleteDigit(), + expect: () => [], + ); + + test('completing 6 digits in create mode switches to confirm with an empty pin', () async { + final cubit = build(); + + for (final d in [1, 2, 3, 4, 5, 6]) { + cubit.addDigit(d); + } + + expect(cubit.state.mode, SetupPinMode.confirm); + expect(cubit.state.currentPin, ''); + expect(cubit.state.isComplete, isFalse); + }); + + test('matching confirm-pin persists salt + hash and emits isComplete=true', () async { + final cubit = build(); + // The cubit's stream is broadcast and does not replay past events — + // subscribe BEFORE driving the digits so we don't race the emit. + final completed = cubit.stream.firstWhere((s) => s.isComplete); + + for (final d in [1, 2, 3, 4, 5, 6]) { + cubit.addDigit(d); + } + for (final d in [1, 2, 3, 4, 5, 6]) { + cubit.addDigit(d); + } + // _onPinComplete fires _confirmPin without awaiting; PBKDF2 with + // 600k iterations runs via `compute()`. On a Flutter-test isolate + // shim this can take several seconds — generous timeout. + await completed.timeout(const Duration(seconds: 30)); + + expect(cubit.state.isComplete, isTrue); + verify(() => secureStorage.setPinSalt(any())).called(1); + verify(() => secureStorage.setPinHash(any())).called(1); + }); + + test('mismatching confirm-pin resets currentPin and sets mismatch=true', () async { + final cubit = build(); + final mismatched = cubit.stream.firstWhere((s) => s.mismatch); + + for (final d in [1, 2, 3, 4, 5, 6]) { + cubit.addDigit(d); + } + for (final d in [9, 9, 9, 9, 9, 9]) { + cubit.addDigit(d); + } + await mismatched.timeout(const Duration(seconds: 1)); + + expect(cubit.state.mismatch, isTrue); + expect(cubit.state.currentPin, ''); + expect(cubit.state.isComplete, isFalse); + verifyNever(() => secureStorage.setPinSalt(any())); + verifyNever(() => secureStorage.setPinHash(any())); + }); + + blocTest( + 'reset returns to the initial state', + build: build, + seed: () => const SetupPinState( + mode: SetupPinMode.confirm, + currentPin: '123', + mismatch: true, + ), + act: (cubit) => cubit.reset(), + expect: () => [const SetupPinState()], + ); + + test('isBiometricAvailable delegates to BiometricService.isAvailable', () async { + when(() => biometricService.isAvailable()).thenAnswer((_) async => true); + + final result = await build().isBiometricAvailable(); + + expect(result, isTrue); + verify(() => biometricService.isAvailable()).called(1); + }); + + test('enableBiometrics delegates to BiometricService.enable', () async { + when(() => biometricService.enable()).thenAnswer((_) async => true); + + final result = await build().enableBiometrics(); + + expect(result, isTrue); + verify(() => biometricService.enable()).called(1); + }); + }); +} diff --git a/test/screens/restore_wallet/cubit/validate_seed_cubit_test.dart b/test/screens/restore_wallet/cubit/validate_seed_cubit_test.dart new file mode 100644 index 00000000..e98d98b6 --- /dev/null +++ b/test/screens/restore_wallet/cubit/validate_seed_cubit_test.dart @@ -0,0 +1,84 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/screens/restore_wallet/cubit/validate_seed/validate_seed_cubit.dart'; + +class _MockWalletService extends Mock implements WalletService {} + +const _validMnemonic = + 'test test test test test test test test test test test junk'; + +void main() { + late _MockWalletService service; + + setUp(() { + service = _MockWalletService(); + }); + + group('$ValidateSeedCubit', () { + test('initial state is uncomplete', () { + expect(ValidateSeedCubit(service).state, ValidateSeedState.uncomplete); + }); + + group('checkSeedLength', () { + blocTest( + 'emits complete when 12 words are all in the bip39 english wordlist', + build: () => ValidateSeedCubit(service), + act: (cubit) => cubit.checkSeedLength(_validMnemonic), + expect: () => [ValidateSeedState.complete], + ); + + blocTest( + 'emits uncomplete when fewer than 12 words', + build: () => ValidateSeedCubit(service), + seed: () => ValidateSeedState.complete, + act: (cubit) => cubit.checkSeedLength('test test test'), + expect: () => [ValidateSeedState.uncomplete], + ); + + blocTest( + 'emits uncomplete when 12 words but at least one is not in the wordlist', + build: () => ValidateSeedCubit(service), + act: (cubit) => cubit.checkSeedLength( + 'test test test test test test test test test test test notaword', + ), + expect: () => [ValidateSeedState.uncomplete], + ); + + blocTest( + 'tolerates extra whitespace between words', + build: () => ValidateSeedCubit(service), + act: (cubit) => + // Extra inner whitespace is filtered out by the where-isNotEmpty. + cubit.checkSeedLength('test test test test test test test test test test test junk'), + expect: () => [ValidateSeedState.complete], + ); + }); + + group('validateSeed', () { + blocTest( + 'emits valid when the underlying wallet service accepts the seed', + build: () { + when(() => service.validateSeed(any())).thenReturn(true); + return ValidateSeedCubit(service); + }, + act: (cubit) => cubit.validateSeed(_validMnemonic), + expect: () => [ValidateSeedState.valid], + verify: (_) { + verify(() => service.validateSeed(_validMnemonic)).called(1); + }, + ); + + blocTest( + 'emits invalid when the underlying wallet service rejects the seed', + build: () { + when(() => service.validateSeed(any())).thenReturn(false); + return ValidateSeedCubit(service); + }, + act: (cubit) => cubit.validateSeed('garbage'), + expect: () => [ValidateSeedState.invalid], + ); + }); + }); +} diff --git a/test/screens/transaction_history/cubits/transaction_history_filter_cubit_test.dart b/test/screens/transaction_history/cubits/transaction_history_filter_cubit_test.dart new file mode 100644 index 00000000..7b242352 --- /dev/null +++ b/test/screens/transaction_history/cubits/transaction_history_filter_cubit_test.dart @@ -0,0 +1,152 @@ +import 'dart:async'; + +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/models/transaction.dart'; +import 'package:realunit_wallet/packages/repository/transaction_repository.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/screens/transaction_history/cubits/filter/transaction_history_filter_cubit.dart'; + +class _MockTransactionRepository extends Mock implements TransactionRepository {} + +const _address = '0x0000000000000000000000000000000000000001'; + +Transaction _tx(DateTime ts) => Transaction( + height: 1, + txId: 'tx-${ts.millisecondsSinceEpoch}', + chainId: realUnitAsset.chainId, + senderAddress: _address, + receiverAddress: _address, + amount: BigInt.one, + asset: realUnitAsset, + type: TransactionTypes.tokenTransfer, + note: '', + data: null, + timestamp: ts, + ); + +void main() { + late _MockTransactionRepository repo; + late StreamController> stream; + + setUp(() { + repo = _MockTransactionRepository(); + stream = StreamController>(); + when(() => repo.watchTransactionsOfAssets(any(), any())) + .thenAnswer((_) => stream.stream); + }); + + tearDown(() async { + await stream.close(); + }); + + TransactionHistoryFilterCubit build() => TransactionHistoryFilterCubit( + repo, + asset: realUnitAsset, + walletAddress: _address, + ); + + group('$TransactionHistoryFilterCubit', () { + test('subscribes to the repository stream for the configured asset + address on init', () { + build(); + + verify(() => repo.watchTransactionsOfAssets([realUnitAsset], _address)).called(1); + }); + + test('initial state has a 1-year-back default startDate', () { + final cubit = build(); + final now = DateTime.now(); + + // The 365-day window should put startDate roughly one year before now. + expect( + now.difference(cubit.state.startDate!).inDays, + inInclusiveRange(364, 366), + ); + }); + + blocTest( + 'stream pushes populate both `all` and `filtered`', + build: build, + act: (_) async { + // Keep timestamps inside the default 1-year-back window so the + // initial filter does not silently drop them. + final now = DateTime.now(); + stream.add([ + _tx(now.subtract(const Duration(days: 30))), + _tx(now.subtract(const Duration(days: 60))), + ]); + await Future.delayed(Duration.zero); + }, + verify: (cubit) { + expect(cubit.state.all, hasLength(2)); + expect(cubit.state.filtered, hasLength(2)); + }, + ); + + blocTest( + 'changeFilter narrows `filtered` to the selected window without touching `all`', + build: build, + act: (cubit) async { + stream.add([ + _tx(DateTime(2026, 1, 1)), + _tx(DateTime(2026, 3, 1)), + _tx(DateTime(2026, 6, 1)), + ]); + await Future.delayed(Duration.zero); + + cubit.changeFilter( + startDate: DateTime(2026, 2, 1), + endDate: DateTime(2026, 4, 1), + ); + }, + verify: (cubit) { + expect(cubit.state.all, hasLength(3)); + expect(cubit.state.filtered, hasLength(1)); + expect(cubit.state.filtered.single.timestamp, DateTime(2026, 3, 1)); + }, + ); + + blocTest( + 'changeFilter includes the boundaries (isBefore / isAfter, not isAtSameMoment)', + build: build, + act: (cubit) async { + stream.add([ + _tx(DateTime(2026, 2, 1)), + _tx(DateTime(2026, 4, 1)), + ]); + await Future.delayed(Duration.zero); + + cubit.changeFilter( + startDate: DateTime(2026, 2, 1), + endDate: DateTime(2026, 4, 1), + ); + }, + verify: (cubit) { + // Both endpoints are inclusive. + expect(cubit.state.filtered, hasLength(2)); + }, + ); + + blocTest( + 'a subsequent stream update re-applies the current filter', + build: build, + act: (cubit) async { + cubit.changeFilter( + startDate: DateTime(2026, 2, 1), + endDate: DateTime(2026, 4, 1), + ); + stream.add([ + _tx(DateTime(2026, 1, 1)), + _tx(DateTime(2026, 3, 1)), + ]); + await Future.delayed(Duration.zero); + }, + verify: (cubit) { + expect(cubit.state.all, hasLength(2)); + expect(cubit.state.filtered, hasLength(1)); + expect(cubit.state.filtered.single.timestamp, DateTime(2026, 3, 1)); + }, + ); + }); +} diff --git a/test/screens/verify_seed/cubit/verify_seed_cubit_test.dart b/test/screens/verify_seed/cubit/verify_seed_cubit_test.dart new file mode 100644 index 00000000..47819c2c --- /dev/null +++ b/test/screens/verify_seed/cubit/verify_seed_cubit_test.dart @@ -0,0 +1,97 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/wallet_service.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/screens/verify_seed/cubit/verify_seed_cubit.dart'; +import 'package:realunit_wallet/widgets/mnemonic_field.dart'; + +class _MockWalletService extends Mock implements WalletService {} + +const _testMnemonic = + 'test test test test test test test test test test test junk'; + +void main() { + late _MockWalletService service; + late SoftwareWallet wallet; + + setUp(() { + service = _MockWalletService(); + wallet = SoftwareWallet(1, 'Main', _testMnemonic); + when(() => service.setCurrentWallet(any())).thenAnswer((_) async {}); + }); + + group('$VerifySeedCubit', () { + test('picks 4 distinct ascending word indices within seed length on init', () { + final cubit = VerifySeedCubit(wallet, service); + + expect(cubit.state.wordIndices, hasLength(4)); + // distinct + expect(cubit.state.wordIndices.toSet().length, 4); + // ascending + final sorted = [...cubit.state.wordIndices]..sort(); + expect(cubit.state.wordIndices, sorted); + // within bounds + for (final i in cubit.state.wordIndices) { + expect(i, inInclusiveRange(0, _testMnemonic.seedWords.length - 1)); + } + }); + + test('initial enteredWords are populated in debug mode (4 entries non-empty)', () { + // `kDebugMode` is true under `flutter test`, so the cubit pre-fills. + final cubit = VerifySeedCubit(wallet, service); + + expect(cubit.state.enteredWords, hasLength(4)); + expect(cubit.state.enteredWords.every((w) => w.isNotEmpty), isTrue); + }); + + test('canVerify reflects whether all four slots are filled', () { + final cubit = VerifySeedCubit(wallet, service); + + // Debug-mode pre-fill leaves canVerify == true. Clear one to flip it. + cubit.updateWord(0, ''); + expect(cubit.state.canVerify, isFalse); + + cubit.updateWord(0, 'anything'); + expect(cubit.state.canVerify, isTrue); + }); + + test('updateWord trims and lowercases the entry and clears the error flag', () async { + final cubit = VerifySeedCubit(wallet, service); + // Force an error state first. + await cubit.verify(); // pre-filled correct words → success, isVerified=true + // The clean way: set up a fresh cubit and corrupt one word. + final fresh = VerifySeedCubit(wallet, service); + fresh.updateWord(0, 'WRONG'); + await fresh.verify(); + expect(fresh.state.hasError, isTrue); + + fresh.updateWord(0, ' HELLO '); + + expect(fresh.state.enteredWords[0], 'hello'); + expect(fresh.state.hasError, isFalse); + }); + + test('verify returns true and marks the wallet current when all words match', () async { + final cubit = VerifySeedCubit(wallet, service); + + final result = await cubit.verify(); + + expect(result, isTrue); + expect(cubit.state.isVerified, isTrue); + expect(cubit.state.hasError, isFalse); + verify(() => service.setCurrentWallet(wallet.id)).called(1); + }); + + test('verify returns false, sets hasError, and does NOT mark current on a wrong word', () async { + final cubit = VerifySeedCubit(wallet, service); + cubit.updateWord(0, 'definitely-not-a-seed-word'); + + final result = await cubit.verify(); + + expect(result, isFalse); + expect(cubit.state.hasError, isTrue); + expect(cubit.state.isVerified, isFalse); + verifyNever(() => service.setCurrentWallet(any())); + }); + }); +}