import 'dart:async'; import 'package:flutter_test/flutter_test.dart'; import 'package:sxchnge_analytics/sxchnge_analytics.dart'; import 'package:sxchnge_kyc/sxchnge_kyc.dart'; import 'package:sxchnge_wallet/sxchnge_wallet.dart'; void main() { late DateTime now; late DummyWalletService s; late WalletProvider wallet; late DummyAnalyticsService analytics; setUp(() { now = DateTime(2026, 9, 12, 10); s = DummyWalletService( DummyIdentityService(delay: Duration.zero), delay: Duration.zero, now: () => now, ); analytics = DummyAnalyticsService(); wallet = WalletProvider(s, analytics); }); tearDown(() => wallet.dispose()); Future transfer( TransferKind kind, int amount, { String source = 'eur', String? to, String recipient = 'Sample recipient', String key = 'one', }) async { final q = await s.quote( TransferRequest( kind: kind, source: source, destination: to, amount: amount, recipient: recipient, payoutDestination: OneTimeDestination( CryptoDestination( assetId: 'demo:ethereum:native', networkId: 'eip155:1', address: '0x1234567890123456789012345678901234567890', assetLabel: 'ETH', networkLabel: 'Ethereum', ), ), ), ); return s.submit(q.id, key); } test( 'funding credits only on completion; history and available balance agree', () async { final o = await transfer(TransferKind.topUp, 2500); expect(o.status, OperationStatus.pending); expect((await s.fetch()).accounts.first.available, 7500); now = now.add(const Duration(seconds: 3)); final state = await s.fetch(); expect(state.accounts.first.available, 10000); expect(state.operations.single.status, OperationStatus.completed); }, ); test( 'exchange debits total once, then credits target and updates usage', () async { final q = await s.quote( const TransferRequest( kind: TransferKind.exchange, source: 'eur', destination: 'usd', amount: 2000, ), ); expect(q.debit, 2010); expect(q.credit, 2200); await Future.wait([s.submit(q.id, 'same'), s.submit(q.id, 'same')]); var data = await s.fetch(); expect(data.accounts.first.available, 5490); expect(data.accounts[1].available, 0); expect(data.operations.length, 1); var limits = await s.fetchLimits(); expect(limits.rows.first.reservedMinor, 2010); expect(limits.rows.first.usedMinor, 0); now = now.add(const Duration(seconds: 3)); data = await s.fetch(); expect(data.accounts[1].available, 2200); limits = await s.fetchLimits(); expect(limits.rows.first.usedMinor, 2010); expect(limits.rows.first.reservedMinor, 0); expect(limits.rows.first.remainingMinor, 7990); await expectLater( s.submit(q.id, 'different'), throwsA(isA()), ); }, ); test('failed payout restores amount and fee, releases limit usage', () async { s.failNextOperation = true; await transfer(TransferKind.payout, 2000); expect((await s.fetch()).accounts.first.available, 5400); now = now.add(const Duration(seconds: 3)); final data = await s.fetch(); expect(data.accounts.first.available, 7500); expect(data.operations.single.status, OperationStatus.failed); expect((await s.fetchLimits()).rows.first.remainingMinor, 10000); }); test('expired quotes and stale balance cannot execute', () async { final q = await s.quote( const TransferRequest( kind: TransferKind.payout, source: 'eur', amount: 7000, recipient: 'Sample recipient', payoutDestination: OneTimeDestination( CryptoDestination( assetId: 'demo:ethereum:native', networkId: 'eip155:1', address: '0x1234567890123456789012345678901234567890', assetLabel: 'ETH', networkLabel: 'Ethereum', ), ), ), ); now = now.add(const Duration(seconds: 61)); await expectLater( s.submit(q.id, 'expired'), throwsA( isA().having( (e) => e.code, 'code', WalletError.expiredQuote, ), ), ); expect((await s.fetch()).accounts.first.available, 7500); final a = await s.quote( const TransferRequest( kind: TransferKind.payout, source: 'eur', amount: 6000, recipient: 'Sample recipient', payoutDestination: OneTimeDestination( CryptoDestination( assetId: 'demo:ethereum:native', networkId: 'eip155:1', address: '0x1234567890123456789012345678901234567890', assetLabel: 'ETH', networkLabel: 'Ethereum', ), ), ), ); final b = await s.quote( const TransferRequest( kind: TransferKind.payout, source: 'eur', amount: 6000, recipient: 'Sample recipient', payoutDestination: OneTimeDestination( CryptoDestination( assetId: 'demo:ethereum:native', networkId: 'eip155:1', address: '0x1234567890123456789012345678901234567890', assetLabel: 'ETH', networkLabel: 'Ethereum', ), ), ), ); await s.submit(a.id, 'first'); await expectLater( s.submit(b.id, 'second'), throwsA( isA().having( (e) => e.code, 'code', WalletError.insufficientFunds, ), ), ); }); test('limits block a funded account independently of balance', () async { await transfer(TransferKind.topUp, 20000); now = now.add(const Duration(seconds: 3)); await s.fetch(); await expectLater( s.quote( const TransferRequest( kind: TransferKind.payout, source: 'eur', amount: 11000, recipient: 'Sample recipient', payoutDestination: OneTimeDestination( CryptoDestination( assetId: 'demo:ethereum:native', networkId: 'eip155:1', address: '0x1234567890123456789012345678901234567890', assetLabel: 'ETH', networkLabel: 'Ethereum', ), ), ), ), throwsA( isA().having( (e) => e.code, 'code', WalletError.limitExceeded, ), ), ); }); test('decimal parser accepts locale decimal, rejects grouping and excess precision', () { const a = CurrencyAccount( id: 'eur', currency: 'EUR', precision: 2, available: 0, ); expect(a.parse('12,34'), 1234); expect(a.parse('12.3'), 1230); expect(a.parse('1,000.00'), null); expect(a.parse('1.234'), null); expect(a.parse('-2'), null); expect(a.parse('1e5'), null); }); test( 'preferences are service-owned; failed save preserves stored locale', () async { expect((await s.profile()).locale, 'en'); await wallet.saveLocale('ru'); expect((await s.profile()).locale, 'ru'); s.nextError = WalletError.unavailable; expect(await wallet.saveLocale('en'), false); expect((await s.profile()).locale, 'ru'); expect(wallet.saveFailed, true); }, ); test( 'provider reconciles lost response and records settlement once', () async { // Use real-clock quote lifetime for the provider UI expiry check. final service = DummyWalletService( DummyIdentityService(delay: Duration.zero), delay: Duration.zero, settlement: Duration.zero, ); final p = WalletProvider(service, analytics); addTearDown(p.dispose); await p.refresh(); final draft = TransferProvider(p, TransferKind.exchange); addTearDown(draft.dispose); draft.change(amount: '20'); await draft.review(); service.loseNextResponse = true; await draft.submit(); expect(draft.uncertain, true); expect(draft.result, null); await draft.reconcile(); expect(draft.uncertain, false); expect(draft.result, isNotNull); await p.refresh(); await p.refresh(); await Future.delayed(Duration.zero); expect(p.data!.operations.length, 1); expect( analytics.events .where((e) => e.step == JourneyStep.settlement && e.terminal) .length, 1, ); expect( analytics.events.every( (e) => !e.properties.containsKey('amount') && !e.properties.containsKey('recipient'), ), true, ); }, ); test( 'pending operations refresh balances and limits without a detail screen', () async { await wallet.refresh(); final limits = WorkspaceLimitsProvider(WalletLimitsService(s), wallet); addTearDown(limits.dispose); await limits.refresh(); s.failNextOperation = true; await transfer(TransferKind.payout, 2000); await wallet.refresh(); expect(wallet.account('eur')!.available, 5400); now = now.add(const Duration(seconds: 3)); final done = Completer(); void changed() { if (wallet.operation(wallet.data!.operations.single.id)?.status == OperationStatus.failed && !done.isCompleted) done.complete(); } wallet.addListener(changed); await done.future.timeout(const Duration(seconds: 5)); wallet.removeListener(changed); await Future.delayed(const Duration(milliseconds: 20)); expect(wallet.account('eur')!.available, 7500); expect(limits.data!.rows.first.remainingMinor, 10000); }, ); test( 'closing a pending operation does not report draft abandonment', () async { final r = JourneyRecorder(analytics); final op = r.begin( JourneyKind.exchange, JourneyStep.settlement, simulated: true, ); expect(op, isNotNull); r.dispose(abandonActive: false); await Future.delayed(Duration.zero); expect( analytics.events.any((e) => e.signal == JourneySignal.abandoned), false, ); }, ); }