283 lines
10 KiB
Dart
283 lines
10 KiB
Dart
import 'dart:ui' as ui;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter/services.dart';
|
|
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
|
|
import 'package:go_router/go_router.dart';
|
|
|
|
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
|
|
|
|
import 'package:sxchnge_kyc/sxchnge_kyc.dart';
|
|
|
|
import 'package:sxchnge_app/kyc/birth.dart';
|
|
import 'package:sxchnge_app/kyc/country.dart';
|
|
import 'package:sxchnge_app/main.dart';
|
|
import 'package:sxchnge_app/providers/locale.dart';
|
|
|
|
class Photos implements PhotoService {
|
|
Photos(this.bytes);
|
|
final Uint8List bytes;
|
|
KycError? error;
|
|
@override
|
|
Future<Photo?> pick() async {
|
|
if (error != null) throw KycException(error!);
|
|
return Photo(bytes);
|
|
}
|
|
}
|
|
|
|
Future<void> tap(WidgetTester t, Finder finder) async {
|
|
await t.ensureVisible(finder);
|
|
await t.tap(finder);
|
|
await t.pumpAndSettle();
|
|
}
|
|
|
|
void main() {
|
|
late Uint8List bytes;
|
|
setUpAll(() async {
|
|
final r = ui.PictureRecorder();
|
|
final c = ui.Canvas(r);
|
|
c.drawRect(const Rect.fromLTWH(0, 0, 600, 600), Paint());
|
|
final pic = r.endRecording();
|
|
final image = await pic.toImage(600, 600);
|
|
bytes = (await image.toByteData(format: ui.ImageByteFormat.png))!.buffer
|
|
.asUint8List();
|
|
image.dispose();
|
|
pic.dispose();
|
|
});
|
|
Future<DummyIdentityService> open(
|
|
WidgetTester t, {
|
|
Photos? photos,
|
|
Locale? locale,
|
|
}) async {
|
|
t.platformDispatcher.defaultRouteNameTestValue = '/identity';
|
|
addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue);
|
|
final service = DummyIdentityService(delay: Duration.zero);
|
|
final provider = LocaleProvider();
|
|
if (locale != null) await provider.setLocale(locale);
|
|
await t.pumpWidget(
|
|
SxchngeApp(
|
|
identityService: service,
|
|
photoService: photos ?? Photos(bytes),
|
|
localeProvider: provider,
|
|
),
|
|
);
|
|
await t.pumpAndSettle();
|
|
return service;
|
|
}
|
|
|
|
Future<void> profile(WidgetTester t) async {
|
|
await tap(t, find.byKey(const ValueKey('kyc-start')));
|
|
await t.enterText(find.byKey(const ValueKey('kyc-name')), 'Example Person');
|
|
await t.enterText(find.byKey(const ValueKey('birth-day')), '23');
|
|
await tap(t, find.byType(DropdownButtonFormField<int>));
|
|
// The month label follows the selected app locale.
|
|
await tap(t, find.textContaining(RegExp(r'^(April|апрель)$')).last);
|
|
await t.enterText(find.byKey(const ValueKey('birth-year')), '1990');
|
|
await t.pumpAndSettle();
|
|
expect(t.widget<BirthField>(find.byType(BirthField)).value, '1990-04-23');
|
|
await tap(
|
|
t,
|
|
find.descendant(
|
|
of: find.byKey(const ValueKey('kyc-country')),
|
|
matching: find.byType(OutlinedButton),
|
|
),
|
|
);
|
|
await t.enterText(find.byKey(const ValueKey('country-search')), 'PL');
|
|
await t.pumpAndSettle();
|
|
await tap(t, find.byType(ListTile));
|
|
expect(
|
|
t.widget<CountryField>(find.byKey(const ValueKey('kyc-country'))).value,
|
|
'PL',
|
|
);
|
|
await t.pumpAndSettle();
|
|
}
|
|
|
|
Future<void> submit(WidgetTester t) async {
|
|
await profile(t);
|
|
await tap(t, find.byKey(const ValueKey('pick-passport')));
|
|
await tap(t, find.byKey(const ValueKey('kyc-next')));
|
|
await tap(t, find.byKey(const ValueKey('pick-selfie')));
|
|
await tap(t, find.byKey(const ValueKey('kyc-next')));
|
|
await tap(t, find.byType(CheckboxListTile));
|
|
await tap(t, find.byKey(const ValueKey('kyc-submit')));
|
|
}
|
|
|
|
testWidgets(
|
|
'invalid birthday blocks advancing and valid birthday survives Back',
|
|
(t) async {
|
|
await open(t);
|
|
await profile(t);
|
|
await tap(t, find.byKey(const ValueKey('pick-passport')));
|
|
final next = find.byKey(const ValueKey('kyc-next'));
|
|
await t.enterText(find.byKey(const ValueKey('birth-day')), '31');
|
|
await t.pumpAndSettle();
|
|
expect(t.widget<FilledButton>(next).onPressed, isNull);
|
|
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
|
|
await t.enterText(find.byKey(const ValueKey('birth-day')), '23');
|
|
await t.pumpAndSettle();
|
|
expect(t.widget<FilledButton>(next).onPressed, isNotNull);
|
|
await tap(t, next);
|
|
await tap(t, find.text('Back'));
|
|
expect(t.widget<BirthField>(find.byType(BirthField)).value, '1990-04-23');
|
|
expect(
|
|
t
|
|
.widget<TextFormField>(find.byKey(const ValueKey('birth-day')))
|
|
.controller!
|
|
.text,
|
|
'23',
|
|
);
|
|
expect(find.text('April'), findsOneWidget);
|
|
expect(
|
|
t
|
|
.widget<TextFormField>(find.byKey(const ValueKey('birth-year')))
|
|
.controller!
|
|
.text,
|
|
'1990',
|
|
);
|
|
},
|
|
);
|
|
|
|
testWidgets('two photos, review, pending and approval with service limits', (
|
|
t,
|
|
) async {
|
|
await open(t);
|
|
expect(find.byKey(const ValueKey('kyc-unverified')), findsOneWidget);
|
|
await submit(t);
|
|
expect(find.byKey(const ValueKey('kyc-pending')), findsOneWidget);
|
|
await tap(t, find.byType(ExpansionTile));
|
|
await tap(t, find.text('Approve'));
|
|
expect(find.byKey(const ValueKey('kyc-approved')), findsOneWidget);
|
|
expect(find.text('Verified'), findsOneWidget);
|
|
expect(t.takeException(), null);
|
|
});
|
|
testWidgets('denial provides explanation and support, no resubmit', (
|
|
t,
|
|
) async {
|
|
await open(t);
|
|
await submit(t);
|
|
await tap(t, find.byType(ExpansionTile));
|
|
await tap(t, find.text('Decline'));
|
|
expect(find.byKey(const ValueKey('kyc-denied')), findsOneWidget);
|
|
expect(find.byKey(const ValueKey('kyc-start')), findsNothing);
|
|
await tap(t, find.text('Contact support'));
|
|
expect(find.byType(AlertDialog), findsOneWidget);
|
|
});
|
|
testWidgets('optional onboarding can be skipped before collecting proofs', (
|
|
t,
|
|
) async {
|
|
t.platformDispatcher.defaultRouteNameTestValue = '/identity?onboarding=1';
|
|
addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue);
|
|
await t.pumpWidget(
|
|
SxchngeApp(
|
|
identityService: DummyIdentityService(delay: Duration.zero),
|
|
photoService: Photos(bytes),
|
|
),
|
|
);
|
|
await t.pumpAndSettle();
|
|
final router = GoRouter.of(
|
|
t.element(find.byKey(const ValueKey('kyc-start'))),
|
|
);
|
|
await tap(t, find.text('Keep basic limits'));
|
|
expect(router.routeInformationProvider.value.uri.path, '/accounts');
|
|
expect(find.byType(AlertDialog), findsNothing);
|
|
});
|
|
testWidgets('correctable rejection can be resubmitted', (t) async {
|
|
await open(t);
|
|
await submit(t);
|
|
await tap(t, find.byType(ExpansionTile));
|
|
await tap(t, find.text('Request clearer passport'));
|
|
expect(find.byKey(const ValueKey('kyc-changesRequested')), findsOneWidget);
|
|
await submit(t);
|
|
expect(find.byKey(const ValueKey('kyc-pending')), findsOneWidget);
|
|
});
|
|
testWidgets('picker error recovery and discard confirmation', (t) async {
|
|
final photos = Photos(bytes)..error = KycError.permission;
|
|
await open(t, photos: photos);
|
|
await tap(t, find.byKey(const ValueKey('kyc-start')));
|
|
await tap(t, find.byKey(const ValueKey('pick-passport')));
|
|
expect(find.byKey(const ValueKey('kyc-error')), findsOneWidget);
|
|
photos.error = null;
|
|
await tap(t, find.byKey(const ValueKey('pick-passport')));
|
|
await tap(t, find.text('Keep basic limits'));
|
|
expect(find.byType(AlertDialog), findsOneWidget);
|
|
await tap(t, find.text('Keep editing'));
|
|
expect(find.byKey(const ValueKey('pick-passport')), findsOneWidget);
|
|
await tap(t, find.text('Keep basic limits'));
|
|
await tap(t, find.text('Discard & leave'));
|
|
expect(find.byKey(const ValueKey('kyc-unverified')), findsOneWidget);
|
|
});
|
|
testWidgets(
|
|
'320px Russian at large text works through submission and keyboard',
|
|
(t) async {
|
|
t.view.physicalSize = const Size(320, 900);
|
|
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: const Locale('ru'));
|
|
expect(find.text('Верификация и лимиты'), findsOneWidget);
|
|
await t.sendKeyEvent(LogicalKeyboardKey.tab);
|
|
await t.pump();
|
|
await submit(t);
|
|
expect(find.byKey(const ValueKey('kyc-pending')), findsOneWidget);
|
|
expect(t.takeException(), null);
|
|
},
|
|
);
|
|
testWidgets('status failure exposes retry without asserting basic state', (
|
|
t,
|
|
) async {
|
|
t.platformDispatcher.defaultRouteNameTestValue = '/identity';
|
|
addTearDown(t.platformDispatcher.clearDefaultRouteNameTestValue);
|
|
final service = DummyIdentityService(delay: Duration.zero)
|
|
..nextError = KycError.unavailable;
|
|
await t.pumpWidget(
|
|
SxchngeApp(
|
|
identityService: service,
|
|
photoService: Photos(bytes),
|
|
analyticsService: DummyAnalyticsService(),
|
|
),
|
|
);
|
|
await t.pumpAndSettle();
|
|
expect(find.byKey(const ValueKey('kyc-unverified')), findsNothing);
|
|
await tap(t, find.text('Refresh status & limits'));
|
|
expect(find.byKey(const ValueKey('kyc-unverified')), findsOneWidget);
|
|
});
|
|
testWidgets('route guard preserves draft when navigation is cancelled', (
|
|
t,
|
|
) async {
|
|
await open(t);
|
|
await tap(t, find.byKey(const ValueKey('kyc-start')));
|
|
await tap(t, find.byKey(const ValueKey('pick-passport')));
|
|
final router = GoRouter.of(t.element(find.text('Verification & limits')));
|
|
router.go('/');
|
|
await t.pumpAndSettle();
|
|
expect(find.byType(AlertDialog), findsOneWidget);
|
|
await tap(t, find.text('Keep editing'));
|
|
expect(find.byKey(const ValueKey('pick-passport')), findsOneWidget);
|
|
router.go('/');
|
|
await t.pumpAndSettle();
|
|
await tap(t, find.text('Discard & leave'));
|
|
expect(find.byKey(const ValueKey('kyc-start')), findsNothing);
|
|
expect(find.text('Explore your accounts'), findsOneWidget);
|
|
});
|
|
testWidgets('overview controls have accessible labels and touch targets', (
|
|
t,
|
|
) async {
|
|
final semantics = t.ensureSemantics();
|
|
await open(t);
|
|
expect(t, meetsGuideline(labeledTapTargetGuideline));
|
|
expect(t, meetsGuideline(androidTapTargetGuideline));
|
|
final start = find.byKey(const ValueKey('kyc-start'));
|
|
await t.ensureVisible(start);
|
|
final buttonContext = t.element(start);
|
|
FocusScope.of(buttonContext).requestFocus(Focus.of(buttonContext));
|
|
await t.sendKeyEvent(LogicalKeyboardKey.tab);
|
|
await t.pump();
|
|
expect(t.takeException(), null);
|
|
semantics.dispose();
|
|
});
|
|
}
|