335 lines
10 KiB
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,
|
|
);
|
|
},
|
|
);
|
|
}
|