Build exchange workspace and shared transaction services (#28)
This commit is contained in:
1 parent
52b7135eda
commit
c496da8a55
48 files changed
+4757
-120
No files matched your search
@@ -0,0 +1,289 @@
|
||||
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,
|
||||
),
|
||||
);
|
||||
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',
|
||||
),
|
||||
);
|
||||
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',
|
||||
),
|
||||
);
|
||||
final b = await s.quote(
|
||||
const TransferRequest(
|
||||
kind: TransferKind.payout,
|
||||
source: 'eur',
|
||||
amount: 6000,
|
||||
recipient: 'Sample recipient',
|
||||
),
|
||||
);
|
||||
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',
|
||||
),
|
||||
),
|
||||
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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user