Add saved payout destination UX and service contracts (#54)
This commit is contained in:
1 parent
a5689876ff
commit
24af794112
49 files changed
+4687
-62
No files matched your search
@@ -0,0 +1,31 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
const testDestination = CryptoDestination(
|
||||
assetId: 'demo:ethereum:native',
|
||||
networkId: 'eip155:1',
|
||||
address: '0x1234567890123456789012345678901234567890',
|
||||
assetLabel: 'ETH',
|
||||
networkLabel: 'Ethereum',
|
||||
);
|
||||
Future<void> seedDestination(DummyDestinationService service) async {
|
||||
await service.create(
|
||||
testDestination,
|
||||
'Test wallet',
|
||||
'test-destination-creation',
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> selectPayout(WidgetTester t) async {
|
||||
final button = find.byKey(const ValueKey('choose-payout-destination'));
|
||||
await t.ensureVisible(button);
|
||||
await t.tap(button);
|
||||
await t.pumpAndSettle();
|
||||
final row = find.text('Test wallet');
|
||||
await t.ensureVisible(row);
|
||||
await t.tap(row);
|
||||
await t.pumpAndSettle();
|
||||
}
|
||||
@@ -0,0 +1,245 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
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';
|
||||
|
||||
import 'package:sxchnge_app/main.dart';
|
||||
import 'package:sxchnge_app/providers/locale.dart';
|
||||
|
||||
import 'destination_helpers.dart';
|
||||
|
||||
Future<void> click(WidgetTester t, Finder finder) async {
|
||||
await t.ensureVisible(finder);
|
||||
await t.pumpAndSettle();
|
||||
await t.tap(finder);
|
||||
await t.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<DummyWalletService> open(
|
||||
WidgetTester t, {
|
||||
String path = '/settings/payout-methods',
|
||||
String locale = 'en',
|
||||
DummyDestinationService? destinations,
|
||||
DummyAnalyticsService? analytics,
|
||||
}) async {
|
||||
t.platformDispatcher.defaultRouteNameTestValue = path;
|
||||
addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue);
|
||||
final wallet = DummyWalletService(
|
||||
DummyIdentityService(delay: Duration.zero),
|
||||
delay: Duration.zero,
|
||||
destinations: destinations,
|
||||
initialLocale: locale,
|
||||
settlement: Duration.zero,
|
||||
);
|
||||
await t.pumpWidget(
|
||||
SxchngeApp(
|
||||
walletService: wallet,
|
||||
analyticsService: analytics,
|
||||
localeProvider: LocaleProvider(initialLocale: Locale(locale)),
|
||||
),
|
||||
);
|
||||
await t.pumpAndSettle();
|
||||
addTearDown(() => t.pumpWidget(const SizedBox.shrink()));
|
||||
return wallet;
|
||||
}
|
||||
|
||||
Future<void> cryptoForm(WidgetTester t, {bool payout = false}) async {
|
||||
await click(
|
||||
t,
|
||||
find.text(payout ? 'Use a new destination' : 'Add payout method').first,
|
||||
);
|
||||
await click(t, find.byType(DropdownButtonFormField<DestinationType>));
|
||||
await click(t, find.text('Crypto address').last);
|
||||
await click(t, find.byKey(const ValueKey('asset-null')));
|
||||
await click(t, find.text('ETH').last);
|
||||
await click(t, find.byType(DropdownButtonFormField<String>).last);
|
||||
await click(t, find.text('Ethereum').last);
|
||||
await t.enterText(
|
||||
find.byKey(const ValueKey('destination-address')),
|
||||
testDestination.address,
|
||||
);
|
||||
await t.pump();
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('Settings saves without nickname and exposes renamed method', (
|
||||
t,
|
||||
) async {
|
||||
final wallet = await open(t);
|
||||
await cryptoForm(t);
|
||||
expect(find.text('Save for future payouts'), findsNothing);
|
||||
await click(t, find.byKey(const ValueKey('destination-continue')));
|
||||
expect((await wallet.destinations.list()).length, 1);
|
||||
expect(find.textContaining('ETH · Ethereum'), findsOneWidget);
|
||||
await click(t, find.textContaining('ETH · Ethereum'));
|
||||
await t.enterText(
|
||||
find.byKey(const ValueKey('destination-nickname')),
|
||||
'My own wallet',
|
||||
);
|
||||
await click(t, find.text('Rename'));
|
||||
expect((await wallet.destinations.list()).single.nickname, 'My own wallet');
|
||||
await click(t, find.text('Remove'));
|
||||
expect(find.textContaining('Submitted payouts'), findsOneWidget);
|
||||
await click(t, find.widgetWithText(FilledButton, 'Remove'));
|
||||
expect(await wallet.destinations.list(), isEmpty);
|
||||
expect(t.takeException(), isNull);
|
||||
});
|
||||
testWidgets('new one-time payout keeps saving unchecked and never persists', (
|
||||
t,
|
||||
) async {
|
||||
final wallet = await open(t, path: '/payout');
|
||||
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
|
||||
await click(t, find.byKey(const ValueKey('choose-payout-destination')));
|
||||
await cryptoForm(t, payout: true);
|
||||
expect(
|
||||
t.widget<CheckboxListTile>(find.byType(CheckboxListTile)).value,
|
||||
isFalse,
|
||||
);
|
||||
expect(find.byKey(const ValueKey('destination-nickname')), findsNothing);
|
||||
await click(t, find.byKey(const ValueKey('destination-continue')));
|
||||
await click(t, find.byKey(const ValueKey('transfer-review')));
|
||||
expect(find.textContaining(testDestination.address), findsOneWidget);
|
||||
await click(t, find.byKey(const ValueKey('transfer-confirm')));
|
||||
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
|
||||
expect(await wallet.destinations.list(), isEmpty);
|
||||
});
|
||||
testWidgets(
|
||||
'optional saving failure permits explicit one-time continuation',
|
||||
(t) async {
|
||||
final service = DummyDestinationService();
|
||||
final wallet = await open(t, path: '/payout', destinations: service);
|
||||
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
|
||||
await click(t, find.byKey(const ValueKey('choose-payout-destination')));
|
||||
await cryptoForm(t, payout: true);
|
||||
await click(t, find.text('Save for future payouts'));
|
||||
service.nextSaveError = DestinationIssue.conflict;
|
||||
await click(t, find.byKey(const ValueKey('destination-continue')));
|
||||
expect(find.textContaining('Couldn’t save'), findsOneWidget);
|
||||
await click(t, find.text('Continue without saving'));
|
||||
await click(t, find.byKey(const ValueKey('transfer-review')));
|
||||
await click(t, find.byKey(const ValueKey('transfer-confirm')));
|
||||
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
|
||||
expect(await wallet.destinations.list(), isEmpty);
|
||||
},
|
||||
);
|
||||
testWidgets(
|
||||
'saved destination is never selected automatically and removal preserves amount',
|
||||
(t) async {
|
||||
final service = DummyDestinationService();
|
||||
await seedDestination(service);
|
||||
final wallet = await open(t, path: '/payout', destinations: service);
|
||||
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
|
||||
expect(
|
||||
t
|
||||
.widget<FilledButton>(find.byKey(const ValueKey('transfer-review')))
|
||||
.onPressed,
|
||||
isNull,
|
||||
);
|
||||
await selectPayout(t);
|
||||
await click(t, find.byKey(const ValueKey('transfer-review')));
|
||||
final d = (await service.list()).single;
|
||||
await service.remove(d.id, d.metadataRevision, d.executionRevision);
|
||||
await click(t, find.byKey(const ValueKey('transfer-confirm')));
|
||||
expect(find.textContaining('Payout terms changed'), findsOneWidget);
|
||||
expect(find.text('5'), findsOneWidget);
|
||||
expect((await t.runAsync(wallet.fetch))!.operations, isEmpty);
|
||||
},
|
||||
);
|
||||
for (final locale in ['en', 'ru']) {
|
||||
testWidgets('all method forms fit $locale at 320px with large text', (
|
||||
t,
|
||||
) async {
|
||||
t.view.physicalSize = const Size(320, 1100);
|
||||
t.view.devicePixelRatio = 1;
|
||||
t.platformDispatcher.textScaleFactorTestValue = 1.6;
|
||||
addTearDown(t.view.resetPhysicalSize);
|
||||
addTearDown(t.view.resetDevicePixelRatio);
|
||||
addTearDown(t.platformDispatcher.clearTextScaleFactorTestValue);
|
||||
await open(t, locale: locale);
|
||||
await click(
|
||||
t,
|
||||
find
|
||||
.text(
|
||||
locale == 'en' ? 'Add payout method' : 'Добавить способ вывода',
|
||||
)
|
||||
.first,
|
||||
);
|
||||
for (final name
|
||||
in locale == 'en'
|
||||
? ['Crypto address', 'Bank card', 'Russian bank account']
|
||||
: [
|
||||
'Криптоадрес',
|
||||
'Банковская карта',
|
||||
'Российский банковский счёт',
|
||||
]) {
|
||||
await click(t, find.byType(DropdownButtonFormField<DestinationType>));
|
||||
await click(t, find.text(name).last);
|
||||
expect(t.takeException(), isNull);
|
||||
}
|
||||
});
|
||||
}
|
||||
testWidgets('semantic save outcomes contain no destination fields', (
|
||||
t,
|
||||
) async {
|
||||
final analytics = DummyAnalyticsService();
|
||||
await open(t, analytics: analytics);
|
||||
await cryptoForm(t);
|
||||
await click(t, find.byKey(const ValueKey('destination-continue')));
|
||||
await t.pumpAndSettle();
|
||||
final events = analytics.events
|
||||
.where((e) => e.kind == JourneyKind.destination)
|
||||
.toList();
|
||||
expect(
|
||||
events.any(
|
||||
(e) =>
|
||||
e.step == JourneyStep.destinationSave &&
|
||||
e.signal == JourneySignal.simulated,
|
||||
),
|
||||
isTrue,
|
||||
);
|
||||
expect(
|
||||
events.map((e) => e.properties.toString()).join(),
|
||||
isNot(contains(testDestination.address)),
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'binding recovery uses secure capture and preserves the saved card',
|
||||
(t) async {
|
||||
final service = DummyDestinationService();
|
||||
final created = await service.create(
|
||||
const CardDestination(
|
||||
capture: 'demo-card-recover',
|
||||
brand: '',
|
||||
last4: '',
|
||||
expiryMonth: 0,
|
||||
expiryYear: 0,
|
||||
),
|
||||
'Own card',
|
||||
'creation-key-000001',
|
||||
);
|
||||
await open(t, destinations: service);
|
||||
await click(t, find.text('Own card'));
|
||||
await click(t, find.text('Update authorization'));
|
||||
expect(find.byKey(const ValueKey('destination-nickname')), findsNothing);
|
||||
await click(t, find.text('Demo card •••• 4242'));
|
||||
await click(t, find.byKey(const ValueKey('destination-continue')));
|
||||
final saved = await service.get(created.destinationId);
|
||||
expect(saved.executionRevision, 2);
|
||||
expect(saved.nickname, 'Own card');
|
||||
expect((await service.list()).length, 1);
|
||||
expect(
|
||||
(await service.assess(
|
||||
saved.input,
|
||||
const PayoutContext('eur'),
|
||||
)).eligible,
|
||||
isTrue,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -76,6 +76,14 @@ void main() {
|
||||
await t.ensureVisible(review);
|
||||
if (hasMethod) {
|
||||
expect(find.text(destinationLabel), findsOneWidget);
|
||||
final choose = find.byKey(
|
||||
const ValueKey('choose-configured-destination'),
|
||||
);
|
||||
expect(t.widget<FilledButton>(review).onPressed, isNull);
|
||||
await t.ensureVisible(choose);
|
||||
await t.tap(choose);
|
||||
await t.pumpAndSettle();
|
||||
await t.ensureVisible(review);
|
||||
await t.tap(review);
|
||||
await t.pumpAndSettle();
|
||||
expect(wallet.received, 'method-ref');
|
||||
|
||||
@@ -6,9 +6,15 @@ import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
||||
|
||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
|
||||
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
||||
|
||||
import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
|
||||
import 'package:sxchnge_app/main.dart';
|
||||
import 'package:sxchnge_app/providers/locale.dart';
|
||||
|
||||
import 'destination_helpers.dart';
|
||||
|
||||
Future<void> tapFactor(WidgetTester t, String key) async {
|
||||
final finder = find.byKey(ValueKey(key));
|
||||
await t.ensureVisible(finder);
|
||||
@@ -31,8 +37,16 @@ Future<DummyTwoFactorService> openFactor(
|
||||
delay: Duration.zero,
|
||||
method: method,
|
||||
);
|
||||
final wallet = DummyWalletService(
|
||||
DummyIdentityService(delay: Duration.zero),
|
||||
factors: service,
|
||||
initialLocale: locale,
|
||||
delay: Duration.zero,
|
||||
);
|
||||
await seedDestination(wallet.destinations);
|
||||
await t.pumpWidget(
|
||||
SxchngeApp(
|
||||
walletService: wallet,
|
||||
twoFactorService: service,
|
||||
analyticsService: analytics,
|
||||
localeProvider: LocaleProvider(initialLocale: Locale(locale)),
|
||||
@@ -174,10 +188,7 @@ void main() {
|
||||
await openFactor(t, path: '/$kind', enabled: true);
|
||||
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10');
|
||||
if (kind == 'payout') {
|
||||
await t.enterText(
|
||||
find.byKey(const ValueKey('transfer-recipient')),
|
||||
'Sample recipient',
|
||||
);
|
||||
await selectPayout(t);
|
||||
}
|
||||
await tapFactor(t, 'transfer-review');
|
||||
await tapFactor(t, 'transfer-confirm');
|
||||
|
||||
@@ -11,6 +11,8 @@ import 'package:sxchnge_wallet/sxchnge_wallet.dart';
|
||||
import 'package:sxchnge_app/main.dart';
|
||||
import 'package:sxchnge_app/providers/locale.dart';
|
||||
|
||||
import 'destination_helpers.dart';
|
||||
|
||||
Future<void> tap(WidgetTester t, Finder f) async {
|
||||
await t.ensureVisible(f);
|
||||
await t.tap(f);
|
||||
@@ -32,6 +34,7 @@ Future<DummyWalletService> open(
|
||||
settlement: settlement,
|
||||
initialLocale: locale,
|
||||
);
|
||||
await seedDestination(service.destinations);
|
||||
await t.pumpWidget(
|
||||
SxchngeApp(
|
||||
identityService: identity,
|
||||
@@ -78,10 +81,7 @@ void main() {
|
||||
expect(find.byKey(const ValueKey('operation-completed')), findsOneWidget);
|
||||
await tap(t, find.text('Pay out').last);
|
||||
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '5');
|
||||
await t.enterText(
|
||||
find.byKey(const ValueKey('transfer-recipient')),
|
||||
'Sample recipient',
|
||||
);
|
||||
await selectPayout(t);
|
||||
await tap(t, find.byKey(const ValueKey('transfer-review')));
|
||||
expect(find.text('Recipient receives'), findsOneWidget);
|
||||
await tap(t, find.byKey(const ValueKey('transfer-confirm')));
|
||||
@@ -101,10 +101,7 @@ void main() {
|
||||
);
|
||||
s.failNextOperation = true;
|
||||
await t.enterText(find.byKey(const ValueKey('transfer-amount')), '10');
|
||||
await t.enterText(
|
||||
find.byKey(const ValueKey('transfer-recipient')),
|
||||
'Sample recipient',
|
||||
);
|
||||
await selectPayout(t);
|
||||
await tap(t, find.byKey(const ValueKey('transfer-review')));
|
||||
await tap(t, find.byKey(const ValueKey('transfer-confirm')));
|
||||
// Settlement is service-owned; refresh after the simulated processing period.
|
||||
|
||||
Reference in new issue
Block a user