From d8c2c1d550192ce578175620eafc854fcee42a4d Mon Sep 17 00:00:00 2001 From: David May Date: Thu, 21 May 2026 20:17:13 +0200 Subject: [PATCH 1/4] test: add 45 tests from codebase audit (#506) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emit-after-close tests for 8 cubits (all fail — proving missing isClosed guards): SellConfirmCubit, SellBankAccountsCubit, SellPaymentInfoCubit, SellConverterCubit, BuyPaymentInfoCubit, TransactionHistoryMultiReceiptCubit, TransactionHistoryReceiptCubit, SettingsTaxReportCubit. Regression guards: - Non-ASCII signing (wallet_account, regression for #289) - BuyPaymentInfo equality across all fields (regression for #207) - BuyPaymentInfoCubit BitboxNotConnectedException path - DashboardBloc error survival on service failure (3 handlers) - Sell cubit: negative amount passthrough, comma normalization gap - DfxBrokerbotService: Infinity/NaN input, malformed JSON (4 methods) - BalanceService, BuyPaymentInfoService, RegistrationService: malformed JSON - SecureStorage: corrupted ciphertext (missing colon, empty string) - parseFixed edge cases (empty, multi-dot, dot, zero) - PaymentURI encoding pin, SettingsBloc hideAmounts session-only pin - WalletService persistence failure resilience (2 tests) - AppDatabase: @visibleForTesting constructor + schema/migration tests --- .claude/settings.local.json | 7 + .vscode/settings.json | 3 + .../reports/problems/problems-report.html | 663 ++++++++++++++++++ lib/packages/storage/database.dart | 5 + pubspec.lock | 24 +- .../service/balance_service_test.dart | 11 + .../dfx/dfx_brokerbot_service_test.dart | 72 ++ .../models/payment/buy_payment_info_test.dart | 71 ++ ...al_unit_buy_payment_info_service_test.dart | 14 + .../real_unit_registration_service_test.dart | 11 + .../packages/service/wallet_service_test.dart | 26 + .../storage/database_migration_test.dart | 68 ++ .../storage/secure_storage_static_test.dart | 18 + test/packages/utils/parse_fixed_test.dart | 9 + test/packages/wallet/payment_uri_test.dart | 9 + test/packages/wallet/wallet_account_test.dart | 9 + .../cubits/buy_payment_info_cubit_test.dart | 25 + .../dashboard/dashboard_bloc_test.dart | 33 + .../cubits/sell_bank_accounts_cubit_test.dart | 13 + .../sell/cubits/sell_confirm_cubit_test.dart | 15 + .../cubits/sell_converter_cubit_test.dart | 22 + .../cubits/sell_payment_info_cubit_test.dart | 31 + test/screens/settings/settings_bloc_test.dart | 11 + .../cubit/settings_tax_report_cubit_test.dart | 71 ++ ...tion_history_multi_receipt_cubit_test.dart | 54 ++ ...ransaction_history_receipt_cubit_test.dart | 54 ++ 26 files changed, 1337 insertions(+), 12 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 .vscode/settings.json create mode 100644 android/build/reports/problems/problems-report.html create mode 100644 test/packages/service/dfx/models/payment/buy_payment_info_test.dart create mode 100644 test/packages/storage/database_migration_test.dart create mode 100644 test/screens/settings_tax_report/cubit/settings_tax_report_cubit_test.dart create mode 100644 test/screens/transaction_history/cubits/multi_receipt/transaction_history_multi_receipt_cubit_test.dart create mode 100644 test/screens/transaction_history/cubits/receipt/transaction_history_receipt_cubit_test.dart diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 00000000..0a37a018 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,7 @@ +{ + "permissions": { + "allow": [ + "WebSearch" + ] + } +} diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 00000000..25da6cdd --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "dart.flutterSdkPath": ".fvm/versions/3.38.7" +} \ No newline at end of file diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html new file mode 100644 index 00000000..c4e415b1 --- /dev/null +++ b/android/build/reports/problems/problems-report.html @@ -0,0 +1,663 @@ + + + + + + + + + + + + + Gradle Configuration Cache + + + +
+ +
+ Loading... +
+ + + + + + diff --git a/lib/packages/storage/database.dart b/lib/packages/storage/database.dart index 8b287e97..dc530988 100644 --- a/lib/packages/storage/database.dart +++ b/lib/packages/storage/database.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:drift/drift.dart'; import 'package:drift/native.dart'; +import 'package:flutter/foundation.dart' show visibleForTesting; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart'; import 'package:realunit_wallet/packages/storage/asset_storage.dart'; @@ -51,6 +52,10 @@ Future tryOpeningDatabase(String encryptionPassword) async { class AppDatabase extends _$AppDatabase { AppDatabase(String encryptionPassword) : super(_openDatabase(encryptionPassword)); + /// In-memory database for unit tests. Bypasses SQLCipher and path_provider. + @visibleForTesting + AppDatabase.forTesting(super.executor); + @override int get schemaVersion => 2; diff --git a/pubspec.lock b/pubspec.lock index 9641f2f1..44b30a60 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -166,10 +166,10 @@ packages: dependency: "direct main" description: name: characters - sha256: faf38497bda5ead2a8c7615f4f7939df04333478bf32e4173fcb06d428b5716b + sha256: f71061c654a3380576a52b451dd5532377954cf9dbd272a78fc8479606670803 url: "https://pub.dev" source: hosted - version: "1.4.1" + version: "1.4.0" charcode: dependency: transitive description: @@ -915,18 +915,18 @@ packages: dependency: transitive description: name: matcher - sha256: dc0b7dc7651697ea4ff3e69ef44b0407ea32c487a39fff6a4004fa585e901861 + sha256: dc58c723c3c24bf8d3e2d3ad3f2f9d7bd9cf43ec6feaa64181775e60190153f2 url: "https://pub.dev" source: hosted - version: "0.12.19" + version: "0.12.17" material_color_utilities: dependency: transitive description: name: material_color_utilities - sha256: "9c337007e82b1889149c82ed242ed1cb24a66044e30979c44912381e9be4c48b" + sha256: f7142bb1154231d7ea5f96bc7bde4bda2a0945d2806bb11670e30b850d56bdec url: "https://pub.dev" source: hosted - version: "0.13.0" + version: "0.11.1" meta: dependency: transitive description: @@ -1408,26 +1408,26 @@ packages: dependency: transitive description: name: test - sha256: "280d6d890011ca966ad08df7e8a4ddfab0fb3aa49f96ed6de56e3521347a9ae7" + sha256: "75906bf273541b676716d1ca7627a17e4c4070a3a16272b7a3dc7da3b9f3f6b7" url: "https://pub.dev" source: hosted - version: "1.30.0" + version: "1.26.3" test_api: dependency: transitive description: name: test_api - sha256: "8161c84903fd860b26bfdefb7963b3f0b68fee7adea0f59ef805ecca346f0c7a" + sha256: ab2726c1a94d3176a45960b6234466ec367179b87dd74f1611adb1f3b5fb9d55 url: "https://pub.dev" source: hosted - version: "0.7.10" + version: "0.7.7" test_core: dependency: transitive description: name: test_core - sha256: "0381bd1585d1a924763c308100f2138205252fb90c9d4eeaf28489ee65ccde51" + sha256: "0cc24b5ff94b38d2ae73e1eb43cc302b77964fbf67abad1e296025b78deb53d0" url: "https://pub.dev" source: hosted - version: "0.6.16" + version: "0.6.12" typed_data: dependency: transitive description: diff --git a/test/packages/service/balance_service_test.dart b/test/packages/service/balance_service_test.dart index 2f10c348..93412c06 100644 --- a/test/packages/service/balance_service_test.dart +++ b/test/packages/service/balance_service_test.dart @@ -142,6 +142,17 @@ void main() { verifyNever(() => balanceRepository.saveBalance(any())); }); + + test('updateBalance with malformed JSON body does not crash', () async { + final appStore = buildAppStore( + (_) async => http.Response('not json', 200), + ); + + final service = BalanceService(balanceRepository, appStore); + await service.updateBalance('0xTestAddress'); + + verifyNever(() => balanceRepository.saveBalance(any())); + }); }); test('getBalance delegates to BalanceRepository.getBalance', () async { diff --git a/test/packages/service/dfx/dfx_brokerbot_service_test.dart b/test/packages/service/dfx/dfx_brokerbot_service_test.dart index d29d8e3d..6b8089f3 100644 --- a/test/packages/service/dfx/dfx_brokerbot_service_test.dart +++ b/test/packages/service/dfx/dfx_brokerbot_service_test.dart @@ -93,6 +93,22 @@ void main() { throwsException, ); }); + + test('throws for Infinity input (UI prevents this via digitsOnly formatter)', () { + expect( + () => build(MockClient((_) async => http.Response('{}', 200))) + .getBuyPrice('Infinity', Currency.chf), + throwsException, + ); + }); + + test('throws for NaN input (UI prevents this via digitsOnly formatter)', () { + expect( + () => build(MockClient((_) async => http.Response('{}', 200))) + .getBuyPrice('NaN', Currency.chf), + throwsException, + ); + }); }); group('getBuyShares', () { @@ -230,4 +246,60 @@ void main() { }); }); }); + + group('malformed JSON responses', () { + late _MockAppStore appStore; + late _MockWalletService walletService; + late SessionCache sessionCache; + + setUp(() { + appStore = _MockAppStore(); + walletService = _MockWalletService(); + sessionCache = SessionCache(_MockCacheRepository()); + when(() => appStore.sessionCache).thenReturn(sessionCache); + when(() => appStore.apiConfig) + .thenReturn(const ApiConfig(networkMode: NetworkMode.mainnet)); + when(() => walletService.ensureCurrentWalletUnlocked()).thenAnswer((_) async {}); + when(() => walletService.lockCurrentWallet()).thenAnswer((_) async {}); + }); + + DfxBrokerbotService buildLocal(http.Client client) { + when(() => appStore.httpClient).thenReturn(client); + return DfxBrokerbotService(appStore, walletService); + } + + test('getBuyPrice with non-JSON 200 throws FormatException', () { + final client = MockClient((_) async => http.Response('not json', 200)); + expect( + () => buildLocal(client).getBuyPrice('10', Currency.chf), + throwsA(isA()), + ); + }); + + test('getSellPrice with non-JSON 200 throws FormatException', () { + sessionCache.setAuthToken('jwt-test'); + final client = MockClient((_) async => http.Response('not json', 200)); + expect( + () => buildLocal(client).getSellPrice('10', Currency.chf), + throwsA(isA()), + ); + }); + + test('getBuyShares with non-JSON 200 throws FormatException', () { + final client = MockClient((_) async => http.Response('not json', 200)); + expect( + () => buildLocal(client).getBuyShares('100', Currency.chf), + throwsA(isA()), + ); + }); + + test('getSellShares with non-JSON 200 throws FormatException', () { + sessionCache.setAuthToken('jwt-test'); + final client = MockClient((_) async => http.Response('not json', 200)); + expect( + () => buildLocal(client).getSellShares('100', Currency.chf), + throwsA(isA()), + ); + }); + }); } diff --git a/test/packages/service/dfx/models/payment/buy_payment_info_test.dart b/test/packages/service/dfx/models/payment/buy_payment_info_test.dart new file mode 100644 index 00000000..081bfd77 --- /dev/null +++ b/test/packages/service/dfx/models/payment/buy_payment_info_test.dart @@ -0,0 +1,71 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/payment/buy/buy_payment_info.dart'; +import 'package:realunit_wallet/styles/currency.dart'; + +void main() { + const base = BuyPaymentInfo( + id: 1, + iban: 'CH93 0076 2011 6238 52957', + bic: 'UBSWCHZH80A', + name: 'DFX AG', + street: 'Bahnhofstrasse', + number: '1', + zip: '6300', + city: 'Zug', + country: 'CH', + currency: Currency.chf, + ); + + group('BuyPaymentInfo equality (regression for #207)', () { + test('same id but different iban are NOT equal', () { + const other = BuyPaymentInfo( + id: 1, + iban: 'DE89 3704 0044 0532 0130 00', + bic: 'UBSWCHZH80A', + name: 'DFX AG', + street: 'Bahnhofstrasse', + number: '1', + zip: '6300', + city: 'Zug', + country: 'CH', + currency: Currency.chf, + ); + + expect(base, isNot(other)); + }); + + test('same id but different currency are NOT equal', () { + const other = BuyPaymentInfo( + id: 1, + iban: 'CH93 0076 2011 6238 52957', + bic: 'UBSWCHZH80A', + name: 'DFX AG', + street: 'Bahnhofstrasse', + number: '1', + zip: '6300', + city: 'Zug', + country: 'CH', + currency: Currency.eur, + ); + + expect(base, isNot(other)); + }); + + test('identical fields are equal', () { + const clone = BuyPaymentInfo( + id: 1, + iban: 'CH93 0076 2011 6238 52957', + bic: 'UBSWCHZH80A', + name: 'DFX AG', + street: 'Bahnhofstrasse', + number: '1', + zip: '6300', + city: 'Zug', + country: 'CH', + currency: Currency.chf, + ); + + expect(base, clone); + }); + }); +} diff --git a/test/packages/service/dfx/real_unit_buy_payment_info_service_test.dart b/test/packages/service/dfx/real_unit_buy_payment_info_service_test.dart index 58dc243d..69b0efb3 100644 --- a/test/packages/service/dfx/real_unit_buy_payment_info_service_test.dart +++ b/test/packages/service/dfx/real_unit_buy_payment_info_service_test.dart @@ -131,5 +131,19 @@ void main() { expect(reference, equals(referenceText)); }); }); + + group('malformed JSON responses', () { + test('confirmPayment with non-JSON 200 throws FormatException', () async { + final appStore = buildAppStore( + (_) async => http.Response('not json', 200), + ); + service = RealUnitBuyPaymentInfoService(appStore, walletService); + + expect( + () => service.confirmPayment(1), + throwsA(isA()), + ); + }); + }); }); } diff --git a/test/packages/service/dfx/real_unit_registration_service_test.dart b/test/packages/service/dfx/real_unit_registration_service_test.dart index 5699f86b..efb6bc14 100644 --- a/test/packages/service/dfx/real_unit_registration_service_test.dart +++ b/test/packages/service/dfx/real_unit_registration_service_test.dart @@ -182,4 +182,15 @@ void main() { ); }); }); + + group('malformed JSON responses', () { + test('registerEmail with non-JSON 201 throws FormatException', () async { + final client = MockClient((_) async => http.Response('not json', 201)); + + expect( + () => build(client).registerEmail('a@b.com'), + throwsA(isA()), + ); + }); + }); } diff --git a/test/packages/service/wallet_service_test.dart b/test/packages/service/wallet_service_test.dart index d918748f..8bf08ca5 100644 --- a/test/packages/service/wallet_service_test.dart +++ b/test/packages/service/wallet_service_test.dart @@ -666,5 +666,31 @@ void main() { expect(stored.last, isA()); }); }); + + group('persistence failure resilience', () { + test('commitGeneratedWallet propagates repository exception', () async { + when(() => repo.createWallet(any(), any(), any(), any())) + .thenThrow(Exception('disk full')); + + final draft = await service.generateUncommittedSeedWallet('Main'); + + expect( + () => service.commitGeneratedWallet(draft), + throwsA(isA()), + ); + verifyNever(() => settings.saveCurrentWalletId(any())); + }); + + test('restoreWallet propagates repository exception without setting current', () async { + when(() => repo.createWallet(any(), any(), any(), any())) + .thenThrow(Exception('disk full')); + + expect( + () => service.restoreWallet('Restored', _testMnemonic), + throwsA(isA()), + ); + verifyNever(() => settings.saveCurrentWalletId(any())); + }); + }); }); } diff --git a/test/packages/storage/database_migration_test.dart b/test/packages/storage/database_migration_test.dart new file mode 100644 index 00000000..5b6e477e --- /dev/null +++ b/test/packages/storage/database_migration_test.dart @@ -0,0 +1,68 @@ +import 'package:drift/native.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:realunit_wallet/packages/storage/database.dart'; +import 'package:realunit_wallet/packages/storage/dfx_transaction_storage.dart'; +import 'package:realunit_wallet/packages/storage/transaction_storage.dart'; +import 'package:realunit_wallet/packages/storage/wallet_storage.dart'; + +void main() { + late AppDatabase db; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + }); + + tearDown(() async { + await db.close(); + }); + + group('AppDatabase schema', () { + test('schema version is 2', () { + expect(db.schemaVersion, 2); + }); + + test('creates all expected tables on fresh database', () async { + final rows = await db + .customSelect( + "SELECT name FROM sqlite_master WHERE type='table' AND name NOT LIKE 'sqlite_%'", + ) + .get(); + final names = rows.map((r) => r.read('name')).toSet(); + + expect(names, containsAll([ + 'assets', + 'balances', + 'key_value_cache', + 'nodes', + 'transactions', + 'dfx_transaction_details', + 'wallet_account_infos', + 'wallet_infos', + ])); + }); + + test('wallet_infos accepts inserts via Drift API', () async { + final id = await db.insertWallet('Test', 'encrypted-seed', '0xAddress', 0); + + expect(id, greaterThan(0)); + + final row = await db.getWalletById(id); + expect(row, isNotNull); + expect(row!.name, 'Test'); + expect(row.seed, 'encrypted-seed'); + expect(row.address, '0xAddress'); + }); + + test('dfx_transaction_details references transactions via tx_id', () async { + await db.insertTransactions( + 1, 'tx-1', 1, '0xA', '0xB', '100', 1, 0, '', '', DateTime.now(), + ); + + await db.insertDfxTransactionDetails(txId: 'tx-1', dfxId: 42); + + final details = await db.getDfxTransactionDetailsByDfxId(42); + expect(details, isNotNull); + expect(details!.txId, 'tx-1'); + }); + }); +} diff --git a/test/packages/storage/secure_storage_static_test.dart b/test/packages/storage/secure_storage_static_test.dart index 451aafdf..b92eb112 100644 --- a/test/packages/storage/secure_storage_static_test.dart +++ b/test/packages/storage/secure_storage_static_test.dart @@ -146,6 +146,24 @@ void main() { // IV is 12 bytes → base64 length 16 (with padding). expect(parts[0], hasLength(16)); }); + + test('decryptSeed with missing colon separator throws', () { + final key = aesKey(); + + expect( + () => SecureStorage.decryptSeed(key, 'noColonHere'), + throwsA(anything), + ); + }); + + test('decryptSeed with empty string throws', () { + final key = aesKey(); + + expect( + () => SecureStorage.decryptSeed(key, ''), + throwsA(anything), + ); + }); }); }); } diff --git a/test/packages/utils/parse_fixed_test.dart b/test/packages/utils/parse_fixed_test.dart index 59e66874..cd341116 100644 --- a/test/packages/utils/parse_fixed_test.dart +++ b/test/packages/utils/parse_fixed_test.dart @@ -25,5 +25,14 @@ void main() { test('should fail to parse .1, no whole number', () => expect(() => parseFixed('.1', 6), throwsException)); + + test('empty string throws', () => expect(() => parseFixed('', 6), throwsA(anything))); + + test('multiple decimal points throws', + () => expect(() => parseFixed('1.2.3', 6), throwsA(isA()))); + + test('just a dot throws', () => expect(() => parseFixed('.', 6), throwsA(isA()))); + + test('zero returns BigInt.zero', () => expect(parseFixed('0', 6), BigInt.zero)); }); } diff --git a/test/packages/wallet/payment_uri_test.dart b/test/packages/wallet/payment_uri_test.dart index 068d4180..089bfe6f 100644 --- a/test/packages/wallet/payment_uri_test.dart +++ b/test/packages/wallet/payment_uri_test.dart @@ -30,5 +30,14 @@ void main() { expect(uri.toString(), 'ethereum:$address?amount=0.000001'); }); + + // Amount is not URI-encoded, but both call sites hardcode amount='' so this + // is unreachable today. Kept as defense-in-depth if the receive flow ever + // accepts user-supplied amounts. + test('amount with special characters is not URI-encoded (currently unreachable)', () { + final uri = EthereumURI(amount: '1.5&evil=1', address: address); + + expect(uri.toString(), contains('&evil')); + }); }); } diff --git a/test/packages/wallet/wallet_account_test.dart b/test/packages/wallet/wallet_account_test.dart index b8971df4..761db535 100644 --- a/test/packages/wallet/wallet_account_test.dart +++ b/test/packages/wallet/wallet_account_test.dart @@ -70,5 +70,14 @@ void main() { expect(fromZero, isNot(fromOne)); }); + + test('signMessage with non-ASCII characters succeeds (regression for #289)', () async { + final account = WalletAccount(_testRoot(), 0); + + final sig = await account.signMessage('Grüße 🚀'); + + expect(sig, startsWith('0x')); + expect(sig.length, 132); + }); }); } diff --git a/test/screens/buy/cubits/buy_payment_info_cubit_test.dart b/test/screens/buy/cubits/buy_payment_info_cubit_test.dart index 3c379006..40e520e8 100644 --- a/test/screens/buy/cubits/buy_payment_info_cubit_test.dart +++ b/test/screens/buy/cubits/buy_payment_info_cubit_test.dart @@ -1,5 +1,8 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/exceptions/bitbox_exception.dart'; import 'package:realunit_wallet/packages/service/dfx/exceptions/payment/buy_exceptions.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/buy/buy_payment_info.dart'; import 'package:realunit_wallet/packages/service/dfx/models/payment/payment_info_error.dart'; @@ -174,5 +177,27 @@ void main() { final f = cubit.state as BuyPaymentInfoFailure; expect(f.error, PaymentInfoError.unknown); }); + + test('BitboxNotConnectedException → Failure(bitboxDisconnected)', () async { + when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) + .thenAnswer((_) async => throw const BitboxNotConnectedException()); + + final cubit = build(); + await cubit.getPaymentInfo(amount: '300'); + + final f = cubit.state as BuyPaymentInfoFailure; + expect(f.error, PaymentInfoError.bitboxDisconnected); + }); + + test('does not emit after close', () async { + final completer = Completer(); + when(() => service.getPaymentInfo(any(), currency: any(named: 'currency'))) + .thenAnswer((_) => completer.future); + + final cubit = build(); + unawaited(cubit.getPaymentInfo(amount: '300')); + await cubit.close(); + completer.complete(_info()); + }); }); } diff --git a/test/screens/dashboard/dashboard_bloc_test.dart b/test/screens/dashboard/dashboard_bloc_test.dart index e4a204f2..44be4c87 100644 --- a/test/screens/dashboard/dashboard_bloc_test.dart +++ b/test/screens/dashboard/dashboard_bloc_test.dart @@ -79,6 +79,39 @@ void main() { verify(() => accountService.getPortfolioHistory(Currency.chf)).called(1); }); + test('refresh survives priceService failure without crashing', () async { + when(() => priceService.getPriceOfAsset(any(), any())) + .thenThrow(Exception('503')); + + final bloc = build(); + // If the handler crashes without try-catch, an unhandled error propagates. + await Future.delayed(const Duration(milliseconds: 100)); + + // The bloc should still be usable — priceChart and portfolioHistory + // were fetched independently. + expect(bloc.state.currency, Currency.chf); + }); + + test('refresh survives priceChart failure without crashing', () async { + when(() => priceService.getPriceChart(any(), any())) + .thenThrow(Exception('timeout')); + + final bloc = build(); + await Future.delayed(const Duration(milliseconds: 100)); + + expect(bloc.state.currency, Currency.chf); + }); + + test('refresh survives portfolioHistory failure without crashing', () async { + when(() => accountService.getPortfolioHistory(any())) + .thenThrow(Exception('network')); + + final bloc = build(); + await Future.delayed(const Duration(milliseconds: 100)); + + expect(bloc.state.currency, Currency.chf); + }); + test('CurrencyChangedEvent updates state and re-fetches all three datasets', () async { final bloc = build(); // Drain the initial refresh. diff --git a/test/screens/sell/cubits/sell_bank_accounts_cubit_test.dart b/test/screens/sell/cubits/sell_bank_accounts_cubit_test.dart index 7efbbf35..dd5cfbc2 100644 --- a/test/screens/sell/cubits/sell_bank_accounts_cubit_test.dart +++ b/test/screens/sell/cubits/sell_bank_accounts_cubit_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_bank_account_service.dart'; @@ -119,5 +121,16 @@ void main() { expect(cubit.state, isA()); expect(cubit.state.accounts, hasLength(1)); }); + + test('does not emit after close when loading on construction', () async { + final completer = Completer>(); + when(() => service.getBankAccounts()).thenAnswer((_) => completer.future); + + final cubit = SellBankAccountsCubit(service); + await cubit.close(); + completer.complete([_dto(id: 1)]); + + // If emit fires after close, StateError is thrown by the framework. + }); }); } diff --git a/test/screens/sell/cubits/sell_confirm_cubit_test.dart b/test/screens/sell/cubits/sell_confirm_cubit_test.dart index b8ff5bc9..8dcde61b 100644 --- a/test/screens/sell/cubits/sell_confirm_cubit_test.dart +++ b/test/screens/sell/cubits/sell_confirm_cubit_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + 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'; @@ -101,5 +103,18 @@ void main() { contains('signing cancelled'), ); }); + + test('does not emit after close', () async { + final completer = Completer(); + when(() => service.confirmPayment(any())) + .thenAnswer((_) => completer.future); + + final cubit = SellConfirmCubit(service); + unawaited(cubit.confirmPayment(_stubPaymentInfo())); + await cubit.close(); + completer.complete(); + + // If emit fires after close, StateError is thrown by the framework. + }); }); } diff --git a/test/screens/sell/cubits/sell_converter_cubit_test.dart b/test/screens/sell/cubits/sell_converter_cubit_test.dart index 6817bddd..3da08e35 100644 --- a/test/screens/sell/cubits/sell_converter_cubit_test.dart +++ b/test/screens/sell/cubits/sell_converter_cubit_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_brokerbot_service.dart'; @@ -197,5 +199,25 @@ void main() { verifyNever(() => service.getSellShares(any(), any())); }); + + test('does not emit after close', () async { + final completer = Completer(); + when(() => service.getSellShares(any(), any())) + .thenAnswer((_) => completer.future); + + final cubit = SellConverterCubit(service); + await cubit.onFiatChanged('100'); + // Let the debounce timer fire. + await Future.delayed(const Duration(milliseconds: 150)); + await cubit.close(); + completer.complete(BrokerbotSellSharesDto( + targetAmount: 100, + shares: 1, + pricePerShare: 100, + currency: 'CHF', + )); + + // If emit fires after close, StateError is thrown by the framework. + }); }); } diff --git a/test/screens/sell/cubits/sell_payment_info_cubit_test.dart b/test/screens/sell/cubits/sell_payment_info_cubit_test.dart index a1d68943..8f84fc57 100644 --- a/test/screens/sell/cubits/sell_payment_info_cubit_test.dart +++ b/test/screens/sell/cubits/sell_payment_info_cubit_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/packages/service/app_store.dart'; @@ -202,6 +204,35 @@ void main() { expect(f.error, PaymentInfoError.unknown); expect(f.message, contains('network')); }); + + test('negative amount is sent to service (UI prevents this via digitsOnly formatter)', () async { + when(() => service.getPaymentInfo(any(), any(), currency: any(named: 'currency'))) + .thenAnswer((_) async => _info()); + + final cubit = build(); + await cubit.getPaymentInfo(amount: '-100', iban: 'CH56'); + + verify(() => service.getPaymentInfo(-100, 'CH56', currency: Currency.chf)).called(1); + }); + + test('comma decimal in getPaymentInfo throws (UI converter rejects commas first in practice)', () async { + final cubit = build(); + await cubit.getPaymentInfo(amount: '100,50', iban: 'CH56'); + + expect(cubit.state, isA()); + verifyNever(() => service.getPaymentInfo(any(), any(), currency: any(named: 'currency'))); + }); + + test('does not emit after close', () async { + final completer = Completer(); + when(() => service.getPaymentInfo(any(), any(), currency: any(named: 'currency'))) + .thenAnswer((_) => completer.future); + + final cubit = build(); + unawaited(cubit.getPaymentInfo(amount: '100', iban: 'CH56')); + await cubit.close(); + completer.complete(_info()); + }); }); } diff --git a/test/screens/settings/settings_bloc_test.dart b/test/screens/settings/settings_bloc_test.dart index a0d5c5ac..c84bd285 100644 --- a/test/screens/settings/settings_bloc_test.dart +++ b/test/screens/settings/settings_bloc_test.dart @@ -112,5 +112,16 @@ void main() { expect(bloc.state.hideAmounts, isTrue); }, ); + + // hideAmounts is intentionally session-only (SettingsRepository has no field for it). + test('ToggleHideAmountEvent flips state without persisting (session-only by design)', () async { + final bloc = build(); + + bloc.add(const ToggleHideAmountEvent()); + await bloc.stream.firstWhere((s) => s.hideAmounts == true); + + expect(bloc.state.hideAmounts, isTrue); + verifyNever(() => repo.language = any()); // proxy: no repo call at all + }); }); } diff --git a/test/screens/settings_tax_report/cubit/settings_tax_report_cubit_test.dart b/test/screens/settings_tax_report/cubit/settings_tax_report_cubit_test.dart new file mode 100644 index 00000000..bb5aa18f --- /dev/null +++ b/test/screens/settings_tax_report/cubit/settings_tax_report_cubit_test.dart @@ -0,0 +1,71 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/pdf/pdf_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pdf_service.dart'; +import 'package:realunit_wallet/screens/settings_tax_report/cubit/settings_tax_report_cubit.dart'; +import 'package:realunit_wallet/styles/currency.dart'; +import 'package:realunit_wallet/styles/language.dart'; + +class _MockPdfService extends Mock implements RealUnitPdfService {} + +void main() { + late _MockPdfService service; + + setUpAll(() { + registerFallbackValue(Currency.chf); + registerFallbackValue(Language.de); + registerFallbackValue(DateTime(2026)); + }); + + setUp(() { + service = _MockPdfService(); + }); + + group('$SettingsTaxReportCubit', () { + test('initial state is SettingsTaxReportInitial', () { + expect( + SettingsTaxReportCubit(service).state, + isA(), + ); + }); + + test('generateTaxReport emits Failure on service error', () async { + when(() => service.getBalanceReport( + date: any(named: 'date'), + currency: any(named: 'currency'), + language: any(named: 'language'), + )).thenAnswer((_) async => throw Exception('network')); + + final cubit = SettingsTaxReportCubit(service); + await cubit.generateTaxReport( + date: DateTime(2025, 12, 31), + currency: Currency.chf, + language: Language.de, + ); + + expect(cubit.state, isA()); + }); + + test('does not emit after close', () async { + final completer = Completer(); + when(() => service.getBalanceReport( + date: any(named: 'date'), + currency: any(named: 'currency'), + language: any(named: 'language'), + )).thenAnswer((_) => completer.future); + + final cubit = SettingsTaxReportCubit(service); + unawaited(cubit.generateTaxReport( + date: DateTime(2025, 12, 31), + currency: Currency.chf, + language: Language.de, + )); + await cubit.close(); + completer.completeError(Exception('late')); + + // If emit fires after close, StateError is thrown by the framework. + }); + }); +} diff --git a/test/screens/transaction_history/cubits/multi_receipt/transaction_history_multi_receipt_cubit_test.dart b/test/screens/transaction_history/cubits/multi_receipt/transaction_history_multi_receipt_cubit_test.dart new file mode 100644 index 00000000..40ebc7d7 --- /dev/null +++ b/test/screens/transaction_history/cubits/multi_receipt/transaction_history_multi_receipt_cubit_test.dart @@ -0,0 +1,54 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/pdf/pdf_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pdf_service.dart'; +import 'package:realunit_wallet/screens/transaction_history/cubits/multi_receipt/transaction_history_multi_receipt_cubit.dart'; +import 'package:realunit_wallet/styles/currency.dart'; + +class _MockPdfService extends Mock implements RealUnitPdfService {} + +void main() { + late _MockPdfService service; + + setUpAll(() { + registerFallbackValue(Currency.chf); + }); + + setUp(() { + service = _MockPdfService(); + }); + + group('$TransactionHistoryMultiReceiptCubit', () { + test('initial state is TransactionHistoryMultiReceiptInitial', () { + expect( + TransactionHistoryMultiReceiptCubit(service).state, + isA(), + ); + }); + + test('generateReceipt emits Failure on service error', () async { + when(() => service.getTransactionsReceipt(any(), currency: any(named: 'currency'))) + .thenAnswer((_) async => throw Exception('network')); + + final cubit = TransactionHistoryMultiReceiptCubit(service); + await cubit.generateReceipt(['tx-1', 'tx-2']); + + expect(cubit.state, isA()); + }); + + test('does not emit after close', () async { + final completer = Completer(); + when(() => service.getTransactionsReceipt(any(), currency: any(named: 'currency'))) + .thenAnswer((_) => completer.future); + + final cubit = TransactionHistoryMultiReceiptCubit(service); + unawaited(cubit.generateReceipt(['tx-1'])); + await cubit.close(); + completer.completeError(Exception('late')); + + // If emit fires after close, StateError is thrown by the framework. + }); + }); +} diff --git a/test/screens/transaction_history/cubits/receipt/transaction_history_receipt_cubit_test.dart b/test/screens/transaction_history/cubits/receipt/transaction_history_receipt_cubit_test.dart new file mode 100644 index 00000000..e1e3cc03 --- /dev/null +++ b/test/screens/transaction_history/cubits/receipt/transaction_history_receipt_cubit_test.dart @@ -0,0 +1,54 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:realunit_wallet/packages/service/dfx/models/pdf/pdf_dto.dart'; +import 'package:realunit_wallet/packages/service/dfx/real_unit_pdf_service.dart'; +import 'package:realunit_wallet/screens/transaction_history/cubits/receipt/transaction_history_receipt_cubit.dart'; +import 'package:realunit_wallet/styles/currency.dart'; + +class _MockPdfService extends Mock implements RealUnitPdfService {} + +void main() { + late _MockPdfService service; + + setUpAll(() { + registerFallbackValue(Currency.chf); + }); + + setUp(() { + service = _MockPdfService(); + }); + + group('$TransactionHistoryReceiptCubit', () { + test('initial state is TransactionHistoryReceiptInitial', () { + expect( + TransactionHistoryReceiptCubit(service).state, + isA(), + ); + }); + + test('generateReceipt emits Failure on service error', () async { + when(() => service.getTransactionReceipt(any(), currency: any(named: 'currency'))) + .thenAnswer((_) async => throw Exception('network')); + + final cubit = TransactionHistoryReceiptCubit(service); + await cubit.generateReceipt('tx-1'); + + expect(cubit.state, isA()); + }); + + test('does not emit after close', () async { + final completer = Completer(); + when(() => service.getTransactionReceipt(any(), currency: any(named: 'currency'))) + .thenAnswer((_) => completer.future); + + final cubit = TransactionHistoryReceiptCubit(service); + unawaited(cubit.generateReceipt('tx-1')); + await cubit.close(); + completer.completeError(Exception('late')); + + // If emit fires after close, StateError is thrown by the framework. + }); + }); +} From ff93b6144e2c9a99fec78c254f780e3e60006d85 Mon Sep 17 00:00:00 2001 From: David May Date: Thu, 21 May 2026 20:20:59 +0200 Subject: [PATCH 2/4] fix: add isClosed guards to 8 cubits to prevent emit-after-close Adds `if (isClosed) return;` after every `await` in async methods that call `emit()`. Without these guards, navigating away from a screen while an HTTP request is in-flight throws `StateError: Cannot emit new states after calling close`. Fixed cubits: - SellConfirmCubit - SellBankAccountsCubit (add, deactivate, _loadBankAccounts) - SellPaymentInfoCubit (getPaymentInfo) - SellConverterCubit (onFiatChanged, onSharesChanged, onCurrencyChanged) - BuyPaymentInfoCubit (getPaymentInfo) - TransactionHistoryMultiReceiptCubit (generateReceipt) - TransactionHistoryReceiptCubit (generateReceipt) - SettingsTaxReportCubit (generateTaxReport) --- .../cubits/buy_payment_info/buy_payment_info_cubit.dart | 1 + .../sell_bank_accounts/sell_bank_accounts_cubit.dart | 7 +++++++ .../sell/cubits/sell_confirm/sell_confirm_cubit.dart | 2 ++ .../sell/cubits/sell_converter/sell_converter_cubit.dart | 8 ++++++++ .../cubits/sell_payment_info/sell_payment_info_cubit.dart | 5 +++++ .../cubit/settings_tax_report_cubit.dart | 3 +++ .../transaction_history_multi_receipt_cubit.dart | 3 +++ .../cubits/receipt/transaction_history_receipt_cubit.dart | 3 +++ 8 files changed, 32 insertions(+) diff --git a/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart b/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart index b5bf3280..3bbf127f 100644 --- a/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart +++ b/lib/screens/buy/cubits/buy_payment_info/buy_payment_info_cubit.dart @@ -40,6 +40,7 @@ class BuyPaymentInfoCubit extends Cubit { ); final newState = await _completer!.value; + if (isClosed) return; emit(newState); } diff --git a/lib/screens/sell/cubits/sell_bank_accounts/sell_bank_accounts_cubit.dart b/lib/screens/sell/cubits/sell_bank_accounts/sell_bank_accounts_cubit.dart index e4cde081..71ac82b1 100644 --- a/lib/screens/sell/cubits/sell_bank_accounts/sell_bank_accounts_cubit.dart +++ b/lib/screens/sell/cubits/sell_bank_accounts/sell_bank_accounts_cubit.dart @@ -22,9 +22,11 @@ class SellBankAccountsCubit extends Cubit { emit(SellBankAccountsLoading(state.accounts)); await _dfxBankAccountService.createBankAccount(iban, label); + if (isClosed) return; await _loadBankAccounts(); } catch (e) { developer.log(e.toString()); + if (isClosed) return; emit(SellBankAccountsAddFailure(state.accounts, e.toString())); } } @@ -37,18 +39,22 @@ class SellBankAccountsCubit extends Cubit { id: bankAccount.id, isActive: false, ); + if (isClosed) return; await _loadBankAccounts(); } catch (e) { developer.log(e.toString()); + if (isClosed) return; emit(SellBankAccountsUpdateFailure(state.accounts)); } } Future _loadBankAccounts() async { + if (isClosed) return; try { emit(SellBankAccountsLoading(state.accounts)); final dto = await _dfxBankAccountService.getBankAccounts(); + if (isClosed) return; final bankAccounts = dto .map( (bankAccount) => BankAccount( @@ -63,6 +69,7 @@ class SellBankAccountsCubit extends Cubit { emit(SellBankAccountsSuccess(bankAccounts)); } catch (e) { developer.log(e.toString()); + if (isClosed) return; emit(const SellBankAccountsLoadFailure()); } } diff --git a/lib/screens/sell/cubits/sell_confirm/sell_confirm_cubit.dart b/lib/screens/sell/cubits/sell_confirm/sell_confirm_cubit.dart index 7f176a7d..48feace2 100644 --- a/lib/screens/sell/cubits/sell_confirm/sell_confirm_cubit.dart +++ b/lib/screens/sell/cubits/sell_confirm/sell_confirm_cubit.dart @@ -16,8 +16,10 @@ class SellConfirmCubit extends Cubit { try { emit(SellConfirmLoading()); await _sellPaymentInfoService.confirmPayment(paymentInfo); + if (isClosed) return; emit(SellConfirmSuccess()); } catch (e) { + if (isClosed) return; emit(SellConfirmFailure(e.toString())); } } diff --git a/lib/screens/sell/cubits/sell_converter/sell_converter_cubit.dart b/lib/screens/sell/cubits/sell_converter/sell_converter_cubit.dart index fc5904df..26ee3f87 100644 --- a/lib/screens/sell/cubits/sell_converter/sell_converter_cubit.dart +++ b/lib/screens/sell/cubits/sell_converter/sell_converter_cubit.dart @@ -22,10 +22,12 @@ class SellConverterCubit extends Cubit { _fiatDebounce?.cancel(); _fiatDebounce = Timer(const Duration(milliseconds: 100), () async { + if (isClosed) return; emit(state.copyWith(loading: true)); try { final result = await _brokerbotService.getSellShares(value, currency); + if (isClosed) return; emit( state.copyWith( sharesText: result.shares.toString(), @@ -34,6 +36,7 @@ class SellConverterCubit extends Cubit { ); } catch (e) { developer.log(e.toString()); + if (isClosed) return; emit(state.copyWith(loading: false)); } }); @@ -45,10 +48,12 @@ class SellConverterCubit extends Cubit { _sharesDebounce?.cancel(); _sharesDebounce = Timer(const Duration(milliseconds: 100), () async { + if (isClosed) return; emit(state.copyWith(loading: true)); try { final result = await _brokerbotService.getSellPrice(value, currency); + if (isClosed) return; emit( state.copyWith( fiatText: result.estimatedAmount.toStringAsFixed(_fractionDigits(value)), @@ -57,6 +62,7 @@ class SellConverterCubit extends Cubit { ); } catch (e) { developer.log(e.toString()); + if (isClosed) return; emit(state.copyWith(loading: false)); } }); @@ -66,6 +72,7 @@ class SellConverterCubit extends Cubit { emit(state.copyWith(loading: true)); try { final result = await _brokerbotService.getBuyPrice(state.sharesText, currency); + if (isClosed) return; emit( state.copyWith( fiatText: result.totalCost.toStringAsFixed(_fractionDigits(state.sharesText)), @@ -75,6 +82,7 @@ class SellConverterCubit extends Cubit { ); } catch (e) { developer.log(e.toString()); + if (isClosed) return; emit( state.copyWith( loading: false, diff --git a/lib/screens/sell/cubits/sell_payment_info/sell_payment_info_cubit.dart b/lib/screens/sell/cubits/sell_payment_info/sell_payment_info_cubit.dart index 8264fc5b..ae03538b 100644 --- a/lib/screens/sell/cubits/sell_payment_info/sell_payment_info_cubit.dart +++ b/lib/screens/sell/cubits/sell_payment_info/sell_payment_info_cubit.dart @@ -44,6 +44,7 @@ class SellPaymentInfoCubit extends Cubit { iban, currency: currency, ); + if (isClosed) return; // Only the backend knows the current per-currency limits, exchange // rates and any compliance gating — when it tags the quote @@ -70,6 +71,7 @@ class SellPaymentInfoCubit extends Cubit { final isBitbox = _appStore.wallet.walletType == WalletType.bitbox; emit(SellPaymentInfoSuccess(paymentInfo, isBitbox: isBitbox)); } on KycLevelRequiredException catch (e) { + if (isClosed) return; emit( SellPaymentInfoFailure( PaymentInfoError.kycRequired, @@ -78,6 +80,7 @@ class SellPaymentInfoCubit extends Cubit { ), ); } on RegistrationRequiredException catch (e) { + if (isClosed) return; emit( SellPaymentInfoFailure( PaymentInfoError.registrationRequired, @@ -85,6 +88,7 @@ class SellPaymentInfoCubit extends Cubit { ), ); } on BitboxNotConnectedException catch (e) { + if (isClosed) return; emit( SellPaymentInfoFailure( PaymentInfoError.bitboxDisconnected, @@ -93,6 +97,7 @@ class SellPaymentInfoCubit extends Cubit { ); } catch (e) { developer.log(e.toString()); + if (isClosed) return; emit( SellPaymentInfoFailure( PaymentInfoError.unknown, diff --git a/lib/screens/settings_tax_report/cubit/settings_tax_report_cubit.dart b/lib/screens/settings_tax_report/cubit/settings_tax_report_cubit.dart index 27b97a14..7a3af08e 100644 --- a/lib/screens/settings_tax_report/cubit/settings_tax_report_cubit.dart +++ b/lib/screens/settings_tax_report/cubit/settings_tax_report_cubit.dart @@ -32,10 +32,13 @@ class SettingsTaxReportCubit extends Cubit { currency: currency, language: language, ); + if (isClosed) return; final file = await _createFileFromBytes(response.pdfData, date); + if (isClosed) return; emit(SettingsTaxReportSuccess(file.path)); } catch (e) { + if (isClosed) return; emit(SettingsTaxReportFailure(e.toString())); } } diff --git a/lib/screens/transaction_history/cubits/multi_receipt/transaction_history_multi_receipt_cubit.dart b/lib/screens/transaction_history/cubits/multi_receipt/transaction_history_multi_receipt_cubit.dart index c5d268db..23492255 100644 --- a/lib/screens/transaction_history/cubits/multi_receipt/transaction_history_multi_receipt_cubit.dart +++ b/lib/screens/transaction_history/cubits/multi_receipt/transaction_history_multi_receipt_cubit.dart @@ -21,10 +21,13 @@ class TransactionHistoryMultiReceiptCubit extends Cubit Date: Fri, 22 May 2026 00:00:15 +0200 Subject: [PATCH 3/4] fix: cancel subscription in FilterCubit + isClosed guards in BuyConverterCubit TransactionHistoryFilterCubit: store the stream subscription and cancel it in close(). Previously the subscription leaked, causing potential emit-after-close on screen disposal. BuyConverterCubit: add isClosed guards in all three Timer callbacks and onCurrencyChanged (same pattern as the SellConverterCubit fix in the previous commit). Tests added: - FilterCubit: close cancels subscription (no emit after close) - BuyConverterCubit: does not emit after close - SellPaymentInfoService: malformed JSON response --- .../buy_converter/buy_converter_cubit.dart | 8 ++++++++ .../transaction_history_filter_cubit.dart | 13 ++++++++++++- ...al_unit_sell_payment_info_service_test.dart | 10 ++++++++++ .../buy/cubits/buy_converter_cubit_test.dart | 18 ++++++++++++++++++ .../transaction_history_filter_cubit_test.dart | 12 ++++++++++++ 5 files changed, 60 insertions(+), 1 deletion(-) diff --git a/lib/screens/buy/cubits/buy_converter/buy_converter_cubit.dart b/lib/screens/buy/cubits/buy_converter/buy_converter_cubit.dart index 2d10ed48..c23082bd 100644 --- a/lib/screens/buy/cubits/buy_converter/buy_converter_cubit.dart +++ b/lib/screens/buy/cubits/buy_converter/buy_converter_cubit.dart @@ -22,10 +22,12 @@ class BuyConverterCubit extends Cubit { _fiatDebounce?.cancel(); _fiatDebounce = Timer(const Duration(milliseconds: 100), () async { + if (isClosed) return; emit(state.copyWith(loading: true)); try { final result = await _brokerbotService.getBuyShares(value, state.currency); + if (isClosed) return; emit( state.copyWith( sharesText: result.shares.toString(), @@ -34,6 +36,7 @@ class BuyConverterCubit extends Cubit { ); } catch (e) { developer.log(e.toString()); + if (isClosed) return; emit(state.copyWith(loading: false)); } }); @@ -45,10 +48,12 @@ class BuyConverterCubit extends Cubit { _sharesDebounce?.cancel(); _sharesDebounce = Timer(const Duration(milliseconds: 100), () async { + if (isClosed) return; emit(state.copyWith(loading: true)); try { final result = await _brokerbotService.getBuyPrice(value, state.currency); + if (isClosed) return; emit( state.copyWith( fiatText: result.totalCost.toStringAsFixed(_fractionDigits(value)), @@ -57,6 +62,7 @@ class BuyConverterCubit extends Cubit { ); } catch (e) { developer.log(e.toString()); + if (isClosed) return; emit(state.copyWith(loading: false)); } }); @@ -67,6 +73,7 @@ class BuyConverterCubit extends Cubit { try { final result = await _brokerbotService.getBuyShares(state.fiatText, currency); + if (isClosed) return; emit( state.copyWith( sharesText: result.shares.toString(), @@ -76,6 +83,7 @@ class BuyConverterCubit extends Cubit { ); } catch (e) { developer.log(e.toString()); + if (isClosed) return; emit( state.copyWith( loading: false, diff --git a/lib/screens/transaction_history/cubits/filter/transaction_history_filter_cubit.dart b/lib/screens/transaction_history/cubits/filter/transaction_history_filter_cubit.dart index 3142f972..ebc34231 100644 --- a/lib/screens/transaction_history/cubits/filter/transaction_history_filter_cubit.dart +++ b/lib/screens/transaction_history/cubits/filter/transaction_history_filter_cubit.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:realunit_wallet/models/asset.dart'; import 'package:realunit_wallet/models/transaction.dart'; @@ -12,10 +14,13 @@ class TransactionHistoryFilterCubit extends Cubit required String walletAddress, int? limit, }) : super(TransactionHistoryFilterState()) { - _repository.watchTransactionsOfAssets([asset], walletAddress).listen(_onTransactionsUpdated); + _subscription = _repository.watchTransactionsOfAssets([asset], walletAddress).listen( + _onTransactionsUpdated, + ); } final TransactionRepository _repository; + StreamSubscription>? _subscription; void _onTransactionsUpdated(List transactions) { emit( @@ -56,4 +61,10 @@ class TransactionHistoryFilterCubit extends Cubit return afterStart && beforeEnd; }).toList(); } + + @override + Future close() { + _subscription?.cancel(); + return super.close(); + } } diff --git a/test/packages/service/dfx/real_unit_sell_payment_info_service_test.dart b/test/packages/service/dfx/real_unit_sell_payment_info_service_test.dart index a707944a..82575271 100644 --- a/test/packages/service/dfx/real_unit_sell_payment_info_service_test.dart +++ b/test/packages/service/dfx/real_unit_sell_payment_info_service_test.dart @@ -290,4 +290,14 @@ void main() { expect(body!.containsKey('eip7702'), isFalse); }); }); + + group('malformed JSON responses', () { + test('getPaymentInfo with non-JSON 200 throws FormatException', () { + final client = MockClient((_) async => http.Response('not json', 200)); + expect( + () => build(client).getPaymentInfo(100, 'CH...'), + throwsA(isA()), + ); + }); + }); } diff --git a/test/screens/buy/cubits/buy_converter_cubit_test.dart b/test/screens/buy/cubits/buy_converter_cubit_test.dart index 16102bea..6ff15c6c 100644 --- a/test/screens/buy/cubits/buy_converter_cubit_test.dart +++ b/test/screens/buy/cubits/buy_converter_cubit_test.dart @@ -1,3 +1,5 @@ +import 'dart:async'; + import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:realunit_wallet/packages/service/dfx/dfx_brokerbot_service.dart'; @@ -163,5 +165,21 @@ void main() { verifyNever(() => service.getBuyShares(any(), any())); }); + + test('does not emit after close', () async { + final completer = Completer(); + when(() => service.getBuyShares(any(), any())) + .thenAnswer((_) => completer.future); + + final cubit = BuyConverterCubit(service); + await cubit.onFiatChanged('100'); + await Future.delayed(const Duration(milliseconds: 150)); + await cubit.close(); + completer.complete(BrokerbotBuySharesDto( + shares: 1, + pricePerShare: 100, + availableShares: 100, + )); + }); }); } 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 index 7b242352..f73e82e9 100644 --- a/test/screens/transaction_history/cubits/transaction_history_filter_cubit_test.dart +++ b/test/screens/transaction_history/cubits/transaction_history_filter_cubit_test.dart @@ -128,6 +128,18 @@ void main() { }, ); + test('close cancels the stream subscription (no emit after close)', () async { + final cubit = build(); + await cubit.close(); + + // Emit on the source stream after close — must not propagate. + stream.add([_tx(DateTime.now())]); + await Future.delayed(Duration.zero); + + // If the subscription were still active, the cubit would throw + // StateError on emit. + }); + blocTest( 'a subsequent stream update re-applies the current filter', build: build, From 25200f0148a65534efe42eecca123437fa98a137 Mon Sep 17 00:00:00 2001 From: David May Date: Fri, 22 May 2026 15:19:08 +0200 Subject: [PATCH 4/4] test: malformed JSON response tests for remaining 5 DFX services MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Covers getUser (kyc), getTickets (support), getBalanceReport (pdf), getPortfolioHistory (account), getBankAccounts (bank account). Same pattern as the brokerbot/balance/buy/registration/sell tests from the first commit — verifies FormatException on non-JSON 200. --- .claude/settings.local.json | 7 - .vscode/settings.json | 3 - .../reports/problems/problems-report.html | 663 ------------------ .../dfx/dfx_bank_account_service_test.dart | 10 + .../service/dfx/dfx_kyc_service_test.dart | 10 + .../service/dfx/dfx_support_service_test.dart | 10 + .../dfx/real_unit_account_service_test.dart | 10 + .../dfx/real_unit_pdf_service_test.dart | 14 + 8 files changed, 54 insertions(+), 673 deletions(-) delete mode 100644 .claude/settings.local.json delete mode 100644 .vscode/settings.json delete mode 100644 android/build/reports/problems/problems-report.html diff --git a/.claude/settings.local.json b/.claude/settings.local.json deleted file mode 100644 index 0a37a018..00000000 --- a/.claude/settings.local.json +++ /dev/null @@ -1,7 +0,0 @@ -{ - "permissions": { - "allow": [ - "WebSearch" - ] - } -} diff --git a/.vscode/settings.json b/.vscode/settings.json deleted file mode 100644 index 25da6cdd..00000000 --- a/.vscode/settings.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "dart.flutterSdkPath": ".fvm/versions/3.38.7" -} \ No newline at end of file diff --git a/android/build/reports/problems/problems-report.html b/android/build/reports/problems/problems-report.html deleted file mode 100644 index c4e415b1..00000000 --- a/android/build/reports/problems/problems-report.html +++ /dev/null @@ -1,663 +0,0 @@ - - - - - - - - - - - - - Gradle Configuration Cache - - - -
- -
- Loading... -
- - - - - - diff --git a/test/packages/service/dfx/dfx_bank_account_service_test.dart b/test/packages/service/dfx/dfx_bank_account_service_test.dart index d83686ca..e4458120 100644 --- a/test/packages/service/dfx/dfx_bank_account_service_test.dart +++ b/test/packages/service/dfx/dfx_bank_account_service_test.dart @@ -183,4 +183,14 @@ void main() { ); }); }); + + group('malformed JSON responses', () { + test('getBankAccounts with non-JSON 200 throws FormatException', () { + final client = MockClient((_) async => http.Response('not json', 200)); + expect( + () => build(client).getBankAccounts(), + throwsA(isA()), + ); + }); + }); } diff --git a/test/packages/service/dfx/dfx_kyc_service_test.dart b/test/packages/service/dfx/dfx_kyc_service_test.dart index 9c24bd40..7148f6ef 100644 --- a/test/packages/service/dfx/dfx_kyc_service_test.dart +++ b/test/packages/service/dfx/dfx_kyc_service_test.dart @@ -232,4 +232,14 @@ void main() { }); }); }); + + group('malformed JSON responses', () { + test('getUser with non-JSON 200 throws FormatException', () { + final client = MockClient((_) async => http.Response('not json', 200)); + expect( + () => build(client).getUser(), + throwsA(isA()), + ); + }); + }); } diff --git a/test/packages/service/dfx/dfx_support_service_test.dart b/test/packages/service/dfx/dfx_support_service_test.dart index 351435f1..d0cd927f 100644 --- a/test/packages/service/dfx/dfx_support_service_test.dart +++ b/test/packages/service/dfx/dfx_support_service_test.dart @@ -192,4 +192,14 @@ void main() { ); }); }); + + group('malformed JSON responses', () { + test('getTickets with non-JSON 200 throws FormatException', () { + final client = MockClient((_) async => http.Response('not json', 200)); + expect( + () => build(client).getTickets(), + throwsA(isA()), + ); + }); + }); } diff --git a/test/packages/service/dfx/real_unit_account_service_test.dart b/test/packages/service/dfx/real_unit_account_service_test.dart index 807bce78..32e84461 100644 --- a/test/packages/service/dfx/real_unit_account_service_test.dart +++ b/test/packages/service/dfx/real_unit_account_service_test.dart @@ -106,4 +106,14 @@ void main() { expect(points, isEmpty); }); }); + + group('malformed JSON responses', () { + test('getPortfolioHistory with non-JSON 200 throws FormatException', () { + final client = MockClient((_) async => http.Response('not json', 200)); + expect( + () => build(client).getPortfolioHistory(Currency.chf), + throwsA(isA()), + ); + }); + }); } diff --git a/test/packages/service/dfx/real_unit_pdf_service_test.dart b/test/packages/service/dfx/real_unit_pdf_service_test.dart index 63b31f8c..13b59758 100644 --- a/test/packages/service/dfx/real_unit_pdf_service_test.dart +++ b/test/packages/service/dfx/real_unit_pdf_service_test.dart @@ -198,4 +198,18 @@ void main() { }); }); }); + + group('malformed JSON responses', () { + test('getBalanceReport with non-JSON 200 throws FormatException', () { + final client = MockClient((_) async => http.Response('not json', 200)); + expect( + () => build(client).getBalanceReport( + date: DateTime(2025, 12, 31), + currency: Currency.chf, + language: Language.de, + ), + throwsA(isA()), + ); + }); + }); }