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,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,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user