diff --git a/lib/core/data/services/wallet_controller.dart b/lib/core/data/services/wallet_controller.dart index 31d695d..af6d33e 100644 --- a/lib/core/data/services/wallet_controller.dart +++ b/lib/core/data/services/wallet_controller.dart @@ -21,23 +21,29 @@ class WalletController extends ChangeNotifier { String? unlockError; String? unlockingEpisodeId; bool _disposed = false; + int _requestSeq = 0; int get balance => snapshot?.wallet.coinBalance ?? 0; List get ledger => snapshot?.ledger ?? const []; Future load() async { + if (_disposed) return; + final request = ++_requestSeq; loading = true; error = null; _notify(); try { - snapshot = await _repository.fetchWallet(); + final result = await _repository.fetchWallet(); + if (_isCurrent(request)) snapshot = result; } on ApiException catch (exception) { - error = exception.message; + if (_isCurrent(request)) error = exception.message; } catch (_) { - error = 'walletLoadFailed'; + if (_isCurrent(request)) error = 'walletLoadFailed'; } finally { - loading = false; - _notify(); + if (_isCurrent(request)) { + loading = false; + _notify(); + } } } @@ -75,6 +81,7 @@ class WalletController extends ChangeNotifier { } void reset() { + _requestSeq += 1; snapshot = null; loading = false; unlocking = false; @@ -88,6 +95,8 @@ class WalletController extends ChangeNotifier { if (!_disposed) notifyListeners(); } + bool _isCurrent(int request) => !_disposed && request == _requestSeq; + @override void dispose() { _disposed = true; diff --git a/test/commerce/wallet_identity_test.dart b/test/commerce/wallet_identity_test.dart new file mode 100644 index 0000000..f988f9c --- /dev/null +++ b/test/commerce/wallet_identity_test.dart @@ -0,0 +1,84 @@ +// Author: fruit.com.cn +// 验证登出、切换请求及销毁后不会回写旧钱包。 + +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:fruitshorts/core/data/models/wallet.dart'; +import 'package:fruitshorts/core/data/repositories/commerce_repository.dart'; +import 'package:fruitshorts/core/data/services/wallet_controller.dart'; + +const _oldBalance = 320; +const _newBalance = 50; + +void main() { + test('should_discard_inflight_balance_when_identity_is_reset', () async { + final repository = _PendingCommerce(); + final wallet = WalletController(repository); + final pending = wallet.load(); + + wallet.reset(); + repository.requests.single.complete(_snapshot(_oldBalance)); + await pending; + + expect(wallet.snapshot, isNull); + expect(wallet.loading, isFalse); + wallet.dispose(); + }); + + test('should_keep_new_balance_when_an_older_request_finishes_last', () async { + final repository = _PendingCommerce(); + final wallet = WalletController(repository); + final oldRequest = wallet.load(); + wallet.reset(); + final newRequest = wallet.load(); + + repository.requests.last.complete(_snapshot(_newBalance)); + await newRequest; + repository.requests.first.complete(_snapshot(_oldBalance)); + await oldRequest; + + expect(wallet.balance, _newBalance); + expect(wallet.loading, isFalse); + wallet.dispose(); + }); + + test('should_discard_inflight_error_when_wallet_is_reset', () async { + final repository = _PendingCommerce(); + final wallet = WalletController(repository); + final pending = wallet.load(); + + wallet.reset(); + repository.requests.single.completeError(StateError('old request')); + await pending; + + expect(wallet.error, isNull); + expect(wallet.snapshot, isNull); + wallet.dispose(); + }); +} + +WalletSnapshot _snapshot(int balance) => WalletSnapshot( + wallet: CoinWallet( + coinBalance: balance, + totalRechargedCoin: balance, + totalConsumedCoin: 0, + totalRefundedCoin: 0, + ), +); + +class _PendingCommerce implements CommerceRepository { + final requests = >[]; + + @override + Future fetchWallet() { + final request = Completer(); + requests.add(request); + return request.future; + } + + @override + Future unlock(String episodeId) async { + throw UnimplementedError('not needed by wallet identity tests'); + } +}