Files
sxchnge/frontend/packages/sxchnge_wallet/test/wallet_test.dart

335 lines
10 KiB
Dart

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<WalletOperation> 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<WalletException>()),
);
},
);
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<WalletException>().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<WalletException>().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<WalletException>().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<void>.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>();
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<void>.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<void>.delayed(Duration.zero);
expect(
analytics.events.any((e) => e.signal == JourneySignal.abandoned),
false,
);
},
);
}