diff --git a/test/screens/sell/cubits/sell_confirm_cubit_test.dart b/test/screens/sell/cubits/sell_confirm_cubit_test.dart new file mode 100644 index 00000000..b8ff5bc9 --- /dev/null +++ b/test/screens/sell/cubits/sell_confirm_cubit_test.dart @@ -0,0 +1,105 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/eip7702/eip7702_data_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/real_unit_sell_payment_info_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/sell_payment_info.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_sell_payment_info_service.dart'; +import 'package:realunit_wallet/screens/sell/cubits/sell_confirm/sell_confirm_cubit.dart'; +import 'package:realunit_wallet/styles/currency.dart'; + +class _MockSellPaymentInfoService extends Mock + implements RealUnitSellPaymentInfoService {} + +class _FakeSellPaymentInfo extends Fake implements SellPaymentInfo {} + +SellPaymentInfo _stubPaymentInfo() => const SellPaymentInfo( + id: 1, + eip7702: Eip7702Data( + relayerAddress: '0x1', + delegationManagerAddress: '0x2', + delegatorAddress: '0x3', + userNonce: 0, + domain: Eip7702Domain( + name: 'RealUnit', + version: '1', + chainId: 1, + verifyingContract: '0x4', + ), + types: Eip7702Types(delegation: [], caveat: []), + message: Eip7702Message( + delegate: '0x5', + delegator: '0x6', + authority: '0x7', + caveats: [], + salt: 0, + ), + tokenAddress: '0x8', + amountWei: '0', + depositAddress: '0x9', + ), + amount: 100, + exchangeRate: 1.0, + rate: 1.0, + beneficiary: BeneficiaryDto(iban: 'CH56'), + estimatedAmount: 100, + currency: Currency.chf, + depositAddress: '0xA', + tokenAddress: '0xB', + chainId: 1, + ethBalance: 0.01, + requiredGasEth: 0.001, + ); + +void main() { + late _MockSellPaymentInfoService service; + + setUpAll(() { + registerFallbackValue(_FakeSellPaymentInfo()); + }); + + setUp(() { + service = _MockSellPaymentInfoService(); + }); + + group('$SellConfirmCubit', () { + test('initial state is SellConfirmInitial', () { + expect(SellConfirmCubit(service).state, isA()); + }); + + test('confirmPayment happy path passes through Loading and ends in Success', () async { + // Mock the service to suspend long enough that Loading is observable + // on the broadcast stream before Success replaces it. + final completer = []; + when(() => service.confirmPayment(any())).thenAnswer((_) async { + await Future.delayed(const Duration(milliseconds: 10)); + }); + + final cubit = SellConfirmCubit(service); + final sub = cubit.stream.listen(completer.add); + final future = cubit.confirmPayment(_stubPaymentInfo()); + // After microtask, Loading should have been emitted. + await Future.delayed(const Duration(milliseconds: 1)); + expect(completer.any((s) => s is SellConfirmLoading), isTrue); + + await future; + await sub.cancel(); + + expect(cubit.state, isA()); + verify(() => service.confirmPayment(any())).called(1); + }); + + test('confirmPayment emits Failure with the error message on throw', () async { + when(() => service.confirmPayment(any())) + .thenAnswer((_) async => throw Exception('signing cancelled')); + final cubit = SellConfirmCubit(service); + + await cubit.confirmPayment(_stubPaymentInfo()); + + expect(cubit.state, isA()); + expect( + (cubit.state as SellConfirmFailure).error, + contains('signing cancelled'), + ); + }); + }); +} diff --git a/test/screens/sell/cubits/sell_payment_info_cubit_test.dart b/test/screens/sell/cubits/sell_payment_info_cubit_test.dart new file mode 100644 index 00000000..583cc5f5 --- /dev/null +++ b/test/screens/sell/cubits/sell_payment_info_cubit_test.dart @@ -0,0 +1,238 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/app_store.dart'; +import 'package:realunit_wallet/packages/service/dfx/dfx_price_service.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/buy_exceptions.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/payment_info_error.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/eip7702/eip7702_data_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/dto/real_unit_sell_payment_info_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/sell/sell_payment_info.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_sell_payment_info_service.dart'; +import 'package:realunit_wallet/packages/wallet/wallet.dart'; +import 'package:realunit_wallet/packages/wallet/wallet_account.dart'; +import 'package:realunit_wallet/screens/sell/cubits/sell_payment_info/sell_payment_info_cubit.dart'; +import 'package:realunit_wallet/styles/currency.dart'; + +class _MockSellPaymentInfoService extends Mock + implements RealUnitSellPaymentInfoService {} + +class _MockPriceService extends Mock implements DFXPriceService {} + +class _MockAppStore extends Mock implements AppStore {} + +const _testMnemonic = + 'test test test test test test test test test test test junk'; + +const _info = SellPaymentInfo( + id: 1, + eip7702: Eip7702Data( + relayerAddress: '0x1', + delegationManagerAddress: '0x2', + delegatorAddress: '0x3', + userNonce: 0, + domain: Eip7702Domain( + name: 'RealUnit', + version: '1', + chainId: 1, + verifyingContract: '0x4', + ), + types: Eip7702Types(delegation: [], caveat: []), + message: Eip7702Message( + delegate: '0x5', + delegator: '0x6', + authority: '0x7', + caveats: [], + salt: 0, + ), + tokenAddress: '0x8', + amountWei: '0', + depositAddress: '0x9', + ), + amount: 100, + exchangeRate: 1.0, + rate: 1.0, + beneficiary: BeneficiaryDto(iban: 'CH56'), + estimatedAmount: 100, + currency: Currency.chf, + depositAddress: '0xA', + tokenAddress: '0xB', + chainId: 1, + ethBalance: 0.01, + requiredGasEth: 0.001, +); + +void main() { + late _MockSellPaymentInfoService service; + late _MockPriceService priceService; + late _MockAppStore appStore; + + setUpAll(() { + registerFallbackValue(Currency.chf); + }); + + setUp(() { + service = _MockSellPaymentInfoService(); + priceService = _MockPriceService(); + appStore = _MockAppStore(); + // Default to a software wallet so isBitbox is false unless overridden. + when(() => appStore.wallet) + .thenReturn(SoftwareWallet(1, 'Main', _testMnemonic)); + }); + + SellPaymentInfoCubit build() => + SellPaymentInfoCubit(service, priceService, appStore); + + group('$SellPaymentInfoCubit', () { + test('initial state is SellPaymentInfoInitial', () { + expect(build().state, isA()); + }); + + group('getPaymentInfo', () { + test('happy path emits Success with isBitbox=false for a software wallet', () async { + when(() => service.getPaymentInfo(any(), any(), currency: any(named: 'currency'))) + .thenAnswer((_) async => _info); + + final cubit = build(); + await cubit.getPaymentInfo(amount: '100', iban: 'CH56'); + + final success = cubit.state as SellPaymentInfoSuccess; + expect(success.sellPaymentInfo, _info); + expect(success.isBitbox, isFalse); + verify(() => service.getPaymentInfo(100, 'CH56', currency: Currency.chf)).called(1); + }); + + test('Success.isBitbox=true when the current wallet is a BitboxWallet', () async { + when(() => service.getPaymentInfo(any(), any(), currency: any(named: 'currency'))) + .thenAnswer((_) async => _info); + when(() => appStore.wallet) + .thenReturn(_BitboxStubWallet()); + + final cubit = build(); + await cubit.getPaymentInfo(amount: '100', iban: 'CH56'); + + expect((cubit.state as SellPaymentInfoSuccess).isBitbox, isTrue); + }); + + test('KycLevelRequiredException → Failure(kycRequired, requiredLevel)', () async { + when(() => service.getPaymentInfo(any(), any(), currency: any(named: 'currency'))) + .thenAnswer( + (_) async => throw const KycLevelRequiredException( + statusCode: 403, + code: 'KYC_REQUIRED', + message: 'KYC required', + requiredLevel: 30, + currentLevel: 10, + ), + ); + + final cubit = build(); + await cubit.getPaymentInfo(amount: '100', iban: 'CH56'); + + final f = cubit.state as SellPaymentInfoFailure; + expect(f.error, PaymentInfoError.kycRequired); + expect(f.requiredLevel, 30); + }); + + test('RegistrationRequiredException → Failure(registrationRequired)', () async { + when(() => service.getPaymentInfo(any(), any(), currency: any(named: 'currency'))) + .thenAnswer( + (_) async => throw const RegistrationRequiredException( + statusCode: 403, + code: 'REGISTRATION_REQUIRED', + message: 'Sign first', + ), + ); + + final cubit = build(); + await cubit.getPaymentInfo(amount: '100', iban: 'CH56'); + + expect( + (cubit.state as SellPaymentInfoFailure).error, + PaymentInfoError.registrationRequired, + ); + }); + + test('generic exception → Failure(unknown) carrying the message', () async { + when(() => service.getPaymentInfo(any(), any(), currency: any(named: 'currency'))) + .thenAnswer((_) async => throw Exception('network')); + + final cubit = build(); + await cubit.getPaymentInfo(amount: '100', iban: 'CH56'); + + final f = cubit.state as SellPaymentInfoFailure; + expect(f.error, PaymentInfoError.unknown); + expect(f.message, contains('network')); + }); + }); + + group('validateMinAmount', () { + test('CHF amount below the 10 CHF floor emits MinAmountNotMet', () async { + final cubit = build(); + + await cubit.validateMinAmount(fiatAmount: '5'); + + final s = cubit.state as SellPaymentInfoMinAmountNotMet; + expect(s.minAmount, 10); + expect(s.currency, Currency.chf); + }); + + test('CHF amount above the floor leaves state untouched (no MinAmountNotMet)', () async { + final cubit = build(); + + await cubit.validateMinAmount(fiatAmount: '15'); + + expect(cubit.state, isA()); + }); + + test('EUR minimum is scaled by getChfToEurRate (ceil)', () async { + // 10 CHF × 0.92 EUR/CHF = 9.2 → ceil = 10. + when(() => priceService.getChfToEurRate()).thenAnswer((_) async => 0.92); + + final cubit = build(); + await cubit.validateMinAmount(fiatAmount: '5', currency: Currency.eur); + + final s = cubit.state as SellPaymentInfoMinAmountNotMet; + expect(s.minAmount, 10); + expect(s.currency, Currency.eur); + }); + + test('previously below-min state is cleared back to Initial when amount rises', () async { + when(() => priceService.getChfToEurRate()).thenAnswer((_) async => 0.92); + final cubit = build(); + await cubit.validateMinAmount(fiatAmount: '5'); + expect(cubit.state, isA()); + + await cubit.validateMinAmount(fiatAmount: '20'); + + expect(cubit.state, isA()); + }); + + test('comma separator is normalised to dot', () async { + final cubit = build(); + + await cubit.validateMinAmount(fiatAmount: '15,5'); + + // 15.5 ≥ 10 → no MinAmountNotMet. + expect(cubit.state, isA()); + }); + + test('empty string is treated as 0 → below minimum', () async { + final cubit = build(); + + await cubit.validateMinAmount(fiatAmount: ''); + + expect(cubit.state, isA()); + }); + }); + }); +} + +class _BitboxStubWallet extends AWallet { + _BitboxStubWallet() : super(1, 'BB'); + @override + WalletType get walletType => WalletType.bitbox; + @override + AWalletAccount get primaryAccount => throw UnimplementedError(); + @override + AWalletAccount get currentAccount => throw UnimplementedError(); +}