From 32f189dd524e20019ad1aa16103917c3e757d048 Mon Sep 17 00:00:00 2001 From: TaprootFreak <142087526+TaprootFreak@users.noreply.github.com> Date: Fri, 15 May 2026 11:48:02 +0200 Subject: [PATCH] test: sell + buy cubits, non-PR-conflict subset (+22 tests) Stage 13 of the coverage push. - sell_selected_bank_account_cubit (3): initial null, select emits the account, select(null) clears - sell_balance_cubit (4): initial zero balance derived from AppStore, subscribes to BalanceRepository.watchBalance on init, emits each pushed balance, close() cancels cleanly - sell_bank_accounts_cubit (6): Success with mapped DTOs on init, LoadFailure on getBankAccounts throw, add() calls createBankAccount + refetches, AddFailure preserves prior accounts, deactivate() calls updateBankAccount(isActive=false) + refetches, UpdateFailure preserves prior accounts - buy_converter_cubit (9): initial empty state, onFiatChanged debounces + writes converted shares, debounce keeps only the last value, error path leaves state stable, onSharesChanged matches output fractional digits to the input (and defaults to 2 without dot), onCurrencyChanged refetches in the new currency, currency still flips on service error, close() cancels pending debounce timers (no service call after close) --- .../buy/cubits/buy_converter_cubit_test.dart | 167 ++++++++++++++++++ .../sell/cubits/sell_balance_cubit_test.dart | 86 +++++++++ .../cubits/sell_bank_accounts_cubit_test.dart | 123 +++++++++++++ ...sell_selected_bank_account_cubit_test.dart | 29 +++ 4 files changed, 405 insertions(+) create mode 100644 test/screens/buy/cubits/buy_converter_cubit_test.dart create mode 100644 test/screens/sell/cubits/sell_balance_cubit_test.dart create mode 100644 test/screens/sell/cubits/sell_bank_accounts_cubit_test.dart create mode 100644 test/screens/sell/cubits/sell_selected_bank_account_cubit_test.dart diff --git a/test/screens/buy/cubits/buy_converter_cubit_test.dart b/test/screens/buy/cubits/buy_converter_cubit_test.dart new file mode 100644 index 00000000..16102bea --- /dev/null +++ b/test/screens/buy/cubits/buy_converter_cubit_test.dart @@ -0,0 +1,167 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_brokerbot_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/brokerbot/dfx_buy_price_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/brokerbot/dfx_buy_shares_dto.dart'; +import 'package:realunit_wallet/screens/buy/cubits/buy_converter/buy_converter_cubit.dart'; +import 'package:realunit_wallet/styles/currency.dart'; + +class _MockBrokerbotService extends Mock implements DfxBrokerbotService {} + +void main() { + late _MockBrokerbotService service; + + setUpAll(() { + registerFallbackValue(Currency.chf); + }); + + setUp(() { + service = _MockBrokerbotService(); + }); + + group('$BuyConverterCubit', () { + test('initial state is empty with CHF', () { + final cubit = BuyConverterCubit(service); + + expect(cubit.state.fiatText, ''); + expect(cubit.state.sharesText, ''); + expect(cubit.state.currency, Currency.chf); + expect(cubit.state.loading, isFalse); + }); + + test('onFiatChanged debounces, then writes the converted shares', () async { + when(() => service.getBuyShares(any(), any())).thenAnswer( + (_) async => BrokerbotBuySharesDto( + shares: 7, + pricePerShare: 12.5, + availableShares: 100, + ), + ); + + final cubit = BuyConverterCubit(service); + await cubit.onFiatChanged('100'); + // Past the 100ms debounce. + await Future.delayed(const Duration(milliseconds: 250)); + + expect(cubit.state.fiatText, '100'); + expect(cubit.state.sharesText, '7'); + expect(cubit.state.loading, isFalse); + verify(() => service.getBuyShares('100', Currency.chf)).called(1); + }); + + test('onFiatChanged debounces — only the latest value reaches the service', () async { + when(() => service.getBuyShares(any(), any())).thenAnswer( + (_) async => BrokerbotBuySharesDto( + shares: 9, + pricePerShare: 1, + availableShares: 99, + ), + ); + + final cubit = BuyConverterCubit(service); + // Three rapid keystrokes; only the last should reach the service. + await cubit.onFiatChanged('1'); + await cubit.onFiatChanged('12'); + await cubit.onFiatChanged('123'); + await Future.delayed(const Duration(milliseconds: 250)); + + verify(() => service.getBuyShares('123', any())).called(1); + verifyNever(() => service.getBuyShares('1', any())); + verifyNever(() => service.getBuyShares('12', any())); + }); + + test('onFiatChanged keeps state intact when the service throws', () async { + when(() => service.getBuyShares(any(), any())) + .thenAnswer((_) async => throw Exception('bad input')); + + final cubit = BuyConverterCubit(service); + await cubit.onFiatChanged('-1'); + await Future.delayed(const Duration(milliseconds: 250)); + + expect(cubit.state.fiatText, '-1'); + expect(cubit.state.sharesText, ''); + expect(cubit.state.loading, isFalse); + }); + + test('onSharesChanged debounces, then writes the converted fiat with matching fractional digits', () async { + when(() => service.getBuyPrice(any(), any())).thenAnswer( + (_) async => BrokerbotBuyPriceDto( + totalCost: 125.5, + pricePerShare: 25.1, + availableShares: 100, + ), + ); + + final cubit = BuyConverterCubit(service); + // Input has 3 fractional digits → output has 3. + await cubit.onSharesChanged('5.000'); + await Future.delayed(const Duration(milliseconds: 250)); + + expect(cubit.state.sharesText, '5.000'); + expect(cubit.state.fiatText, '125.500'); + }); + + test('onSharesChanged uses 2 fractional digits when input has no dot', () async { + when(() => service.getBuyPrice(any(), any())).thenAnswer( + (_) async => BrokerbotBuyPriceDto( + totalCost: 125.5, + pricePerShare: 25.1, + availableShares: 100, + ), + ); + + final cubit = BuyConverterCubit(service); + await cubit.onSharesChanged('5'); + await Future.delayed(const Duration(milliseconds: 250)); + + expect(cubit.state.fiatText, '125.50'); + }); + + test('onCurrencyChanged refetches shares with the new currency and emits both fields', () async { + when(() => service.getBuyShares(any(), any())).thenAnswer( + (_) async => BrokerbotBuySharesDto( + shares: 3, + pricePerShare: 10, + availableShares: 100, + ), + ); + + final cubit = BuyConverterCubit(service); + await cubit.onCurrencyChanged(Currency.eur); + + expect(cubit.state.currency, Currency.eur); + expect(cubit.state.sharesText, '3'); + expect(cubit.state.loading, isFalse); + verify(() => service.getBuyShares('', Currency.eur)).called(1); + }); + + test('onCurrencyChanged still flips currency even on service error', () async { + when(() => service.getBuyShares(any(), any())) + .thenAnswer((_) async => throw Exception('throttle')); + + final cubit = BuyConverterCubit(service); + await cubit.onCurrencyChanged(Currency.eur); + + expect(cubit.state.currency, Currency.eur); + expect(cubit.state.loading, isFalse); + }); + + test('close() cancels pending debounce timers', () async { + when(() => service.getBuyShares(any(), any())).thenAnswer( + (_) async => BrokerbotBuySharesDto( + shares: 1, + pricePerShare: 1, + availableShares: 1, + ), + ); + + final cubit = BuyConverterCubit(service); + await cubit.onFiatChanged('5'); + // Close before the 100ms debounce fires. + await cubit.close(); + await Future.delayed(const Duration(milliseconds: 250)); + + verifyNever(() => service.getBuyShares(any(), any())); + }); + }); +} diff --git a/test/screens/sell/cubits/sell_balance_cubit_test.dart b/test/screens/sell/cubits/sell_balance_cubit_test.dart new file mode 100644 index 00000000..23882851 --- /dev/null +++ b/test/screens/sell/cubits/sell_balance_cubit_test.dart @@ -0,0 +1,86 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/models/balance.dart'; +import 'package:realunit_wallet/packages/config/api_config.dart'; +import 'package:realunit_wallet/packages/config/network_mode.dart'; +import 'package:realunit_wallet/packages/repository/balance_repository.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/utils/default_assets.dart'; +import 'package:realunit_wallet/screens/sell/cubits/sell_balance/sell_balance_cubit.dart'; + +class _MockBalanceRepository extends Mock implements BalanceRepository {} + +class _MockAppStore extends Mock implements AppStore {} + +class _FakeBalance extends Fake implements Balance {} + +const _wallet = '0x000000000000000000000000000000000000beef'; + +void main() { + late _MockBalanceRepository repo; + late _MockAppStore appStore; + late StreamController controller; + + setUpAll(() { + registerFallbackValue(_FakeBalance()); + }); + + setUp(() { + repo = _MockBalanceRepository(); + appStore = _MockAppStore(); + controller = StreamController(); + when(() => appStore.apiConfig) + .thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); + when(() => appStore.primaryAddress).thenReturn(_wallet); + when(() => repo.watchBalance(any())).thenAnswer((_) => controller.stream); + }); + + tearDown(() async { + await controller.close(); + }); + + group('$SellBalanceCubit', () { + test('initial state is a zero balance derived from appStore', () { + final cubit = SellBalanceCubit(repo, appStore); + + expect(cubit.state.chainId, realUnitAsset.chainId); + expect(cubit.state.contractAddress, realUnitAsset.address); + expect(cubit.state.walletAddress, _wallet); + expect(cubit.state.balance, BigInt.zero); + expect(cubit.state.asset, realUnitAsset); + }); + + test('subscribes to BalanceRepository.watchBalance on init', () { + SellBalanceCubit(repo, appStore); + + verify(() => repo.watchBalance(any())).called(1); + }); + + test('emits balance updates pushed through the repo stream', () async { + final cubit = SellBalanceCubit(repo, appStore); + + final updated = Balance( + chainId: realUnitAsset.chainId, + contractAddress: realUnitAsset.address, + walletAddress: _wallet, + balance: BigInt.from(7000), + asset: realUnitAsset, + ); + final ready = cubit.stream.firstWhere((b) => b.balance == BigInt.from(7000)); + controller.add(updated); + await ready.timeout(const Duration(seconds: 1)); + + expect(cubit.state.balance, BigInt.from(7000)); + }); + + test('close() cancels the subscription cleanly', () async { + final cubit = SellBalanceCubit(repo, appStore); + + await cubit.close(); + + expect(cubit.isClosed, isTrue); + }); + }); +} diff --git a/test/screens/sell/cubits/sell_bank_accounts_cubit_test.dart b/test/screens/sell/cubits/sell_bank_accounts_cubit_test.dart new file mode 100644 index 00000000..7efbbf35 --- /dev/null +++ b/test/screens/sell/cubits/sell_bank_accounts_cubit_test.dart @@ -0,0 +1,123 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_bank_account_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/bank_account/dto/bank_account_dto.dart'; +import 'package:realunit_wallet/screens/sell/cubits/sell_bank_accounts/sell_bank_accounts_cubit.dart'; + +class _MockBankAccountService extends Mock implements DfxBankAccountService {} + +BankAccountDto _dto({int id = 1, String? label, bool isActive = true}) => + BankAccountDto( + id: id, + iban: 'CH56 0483 5012 3456 78$id', + label: label, + isActive: isActive, + isDefault: false, + ); + +void main() { + late _MockBankAccountService service; + + setUp(() { + service = _MockBankAccountService(); + }); + + group('$SellBankAccountsCubit', () { + test('reaches Success with the mapped accounts on construction', () async { + when(() => service.getBankAccounts()) + .thenAnswer((_) async => [_dto(id: 1, label: 'Main'), _dto(id: 2)]); + + final cubit = SellBankAccountsCubit(service); + await cubit.stream.firstWhere((s) => s is SellBankAccountsSuccess); + + final success = cubit.state as SellBankAccountsSuccess; + expect(success.accounts.map((a) => a.id), [1, 2]); + expect(success.accounts.first.name, 'Main'); + }); + + test('reaches LoadFailure when getBankAccounts throws', () async { + when(() => service.getBankAccounts()) + .thenAnswer((_) async => throw Exception('boom')); + + final cubit = SellBankAccountsCubit(service); + await cubit.stream.firstWhere((s) => s is SellBankAccountsLoadFailure); + + expect(cubit.state, isA()); + }); + + test('add() calls createBankAccount and re-fetches the list', () async { + var calls = 0; + when(() => service.getBankAccounts()).thenAnswer((_) async { + calls++; + if (calls == 1) return [_dto(id: 1)]; + return [_dto(id: 1), _dto(id: 2, label: 'Added')]; + }); + when(() => service.createBankAccount(any(), any())) + .thenAnswer((_) async => _dto(id: 2, label: 'Added')); + + final cubit = SellBankAccountsCubit(service); + await cubit.stream.firstWhere((s) => s is SellBankAccountsSuccess); + + await cubit.add(iban: 'CH99', label: 'Added'); + + final success = cubit.state as SellBankAccountsSuccess; + expect(success.accounts.map((a) => a.id), [1, 2]); + verify(() => service.createBankAccount('CH99', 'Added')).called(1); + }); + + test('add() emits AddFailure on createBankAccount error and keeps prior accounts', () async { + when(() => service.getBankAccounts()).thenAnswer((_) async => [_dto(id: 1)]); + when(() => service.createBankAccount(any(), any())) + .thenAnswer((_) async => throw Exception('invalid iban')); + + final cubit = SellBankAccountsCubit(service); + await cubit.stream.firstWhere((s) => s is SellBankAccountsSuccess); + + await cubit.add(iban: 'NOPE'); + + expect(cubit.state, isA()); + expect((cubit.state as SellBankAccountsAddFailure).message, contains('invalid iban')); + expect(cubit.state.accounts, hasLength(1)); + }); + + test('deactivate() calls updateBankAccount(isActive=false) and re-fetches', () async { + var calls = 0; + when(() => service.getBankAccounts()).thenAnswer((_) async { + calls++; + if (calls == 1) return [_dto(id: 1, isActive: true)]; + return [_dto(id: 1, isActive: false)]; + }); + when(() => service.updateBankAccount( + id: any(named: 'id'), + isActive: any(named: 'isActive'), + )).thenAnswer((_) async => _dto(id: 1, isActive: false)); + + final cubit = SellBankAccountsCubit(service); + await cubit.stream.firstWhere((s) => s is SellBankAccountsSuccess); + + final target = (cubit.state as SellBankAccountsSuccess).accounts.first; + await cubit.deactivate(bankAccount: target); + + verify(() => service.updateBankAccount(id: 1, isActive: false)).called(1); + final success = cubit.state as SellBankAccountsSuccess; + expect(success.accounts.first.isActive, isFalse); + }); + + test('deactivate() emits UpdateFailure on updateBankAccount error', () async { + when(() => service.getBankAccounts()).thenAnswer((_) async => [_dto(id: 1)]); + when(() => service.updateBankAccount( + id: any(named: 'id'), + isActive: any(named: 'isActive'), + )).thenAnswer((_) async => throw Exception('500')); + + final cubit = SellBankAccountsCubit(service); + await cubit.stream.firstWhere((s) => s is SellBankAccountsSuccess); + + final target = (cubit.state as SellBankAccountsSuccess).accounts.first; + await cubit.deactivate(bankAccount: target); + + expect(cubit.state, isA()); + expect(cubit.state.accounts, hasLength(1)); + }); + }); +} diff --git a/test/screens/sell/cubits/sell_selected_bank_account_cubit_test.dart b/test/screens/sell/cubits/sell_selected_bank_account_cubit_test.dart new file mode 100644 index 00000000..a07fb626 --- /dev/null +++ b/test/screens/sell/cubits/sell_selected_bank_account_cubit_test.dart @@ -0,0 +1,29 @@ +import 'package:bloc_test/bloc_test.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/bank_account/bank_account.dart'; +import 'package:realunit_wallet/screens/sell/cubits/sell_selected_bank_account/sell_selected_bank_account_cubit.dart'; + +const _account = BankAccount(id: 1, iban: 'CH56 0483 5012 3456 7800 9', isActive: true); + +void main() { + group('$SellSelectedBankAccountCubit', () { + test('initial state is null', () { + expect(SellSelectedBankAccountCubit().state, isNull); + }); + + blocTest( + 'selectBankAccount emits the provided account', + build: SellSelectedBankAccountCubit.new, + act: (cubit) => cubit.selectBankAccount(_account), + expect: () => [_account], + ); + + blocTest( + 'selectBankAccount(null) clears the selection', + build: SellSelectedBankAccountCubit.new, + seed: () => _account, + act: (cubit) => cubit.selectBankAccount(null), + expect: () => [null], + ); + }); +}