Merge remote-tracking branch 'origin/main' into kyc-69

This commit is contained in:
Stephan D committed 2026-10-23 12:46:56 +02:00
commit b644a10663
27 files changed
+1004 -84

No files matched your search

+236
View File
@@ -0,0 +1,236 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:sxchnge_theme/sxchnge_theme.dart';
import 'package:sxchnge_app/kyc/birth.dart';
import 'package:sxchnge_app/generated/i18n/app_localizations.dart';
void main() {
final day = find.byKey(const ValueKey('birth-day'));
final year = find.byKey(const ValueKey('birth-year'));
final month = find.byType(DropdownButtonFormField<int>);
Future<void> mount(
WidgetTester t,
ValueNotifier<String> value, {
Locale locale = const Locale('en'),
bool enabled = true,
}) async {
await t.pumpWidget(
MaterialApp(
theme: SxchngeTheme.light(),
locale: locale,
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Scaffold(
body: SingleChildScrollView(
padding: const EdgeInsets.all(24),
child: ValueListenableBuilder<String>(
valueListenable: value,
builder: (_, date, _) => BirthField(
value: date,
enabled: enabled,
onChanged: (date) => value.value = date,
),
),
),
),
),
);
await t.pumpAndSettle();
}
Future<void> select(WidgetTester t, String name) async {
await t.ensureVisible(month);
await t.tap(month);
await t.pumpAndSettle();
await t.tap(find.text(name).last);
await t.pumpAndSettle();
}
testWidgets('enters and corrects a birthday without ISO syntax', (t) async {
final value = ValueNotifier('');
addTearDown(value.dispose);
await mount(t, value);
expect(find.text('Date of birth'), findsOneWidget);
await t.enterText(day, '23');
await select(t, 'April');
expect(value.value, '');
await t.enterText(year, '1990');
await t.pumpAndSettle();
expect(value.value, '1990-04-23');
await t.enterText(day, '9');
await t.pumpAndSettle();
expect(value.value, '1990-04-09');
await t.enterText(year, '19');
await t.pumpAndSettle();
expect(value.value, '');
// A provider rebuild must retain the other components of an incomplete date.
expect(t.widget<TextFormField>(day).controller!.text, '9');
expect(find.text('April'), findsOneWidget);
await t.enterText(year, '1985');
await t.pumpAndSettle();
expect(value.value, '1985-04-09');
});
testWidgets('leap dates, impossible days and future dates recover inline', (
t,
) async {
final value = ValueNotifier('2000-02-29');
addTearDown(value.dispose);
await mount(t, value);
expect(find.text('February'), findsOneWidget);
await t.enterText(year, '2001');
await t.pumpAndSettle();
expect(value.value, '');
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
expect(t.widget<TextFormField>(day).controller!.text, '29');
await t.enterText(year, '2004');
await t.pumpAndSettle();
expect(value.value, '2004-02-29');
expect(find.text('Enter a valid date of birth.'), findsNothing);
await t.enterText(day, '31');
await select(t, 'April');
expect(value.value, '');
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
await t.enterText(day, '30');
await t.enterText(year, '${DateTime.now().year + 1}');
await t.pumpAndSettle();
expect(value.value, '');
expect(find.text('Date of birth cannot be in the future.'), findsOneWidget);
await t.enterText(year, '1990');
await t.pumpAndSettle();
expect(value.value, '1990-04-30');
await t.enterText(day, '0');
await t.pumpAndSettle();
expect(value.value, '');
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
await t.enterText(day, '1');
await t.enterText(year, '0000');
await t.pumpAndSettle();
expect(value.value, '');
expect(find.text('Enter a valid date of birth.'), findsOneWidget);
});
testWidgets(
'prefill, external replacement, locale change and clear synchronize',
(t) async {
final value = ValueNotifier('1990-04-23');
addTearDown(value.dispose);
await mount(t, value);
expect(t.widget<TextFormField>(year).controller!.text, '1990');
value.value = '1985-12-09';
await t.pumpAndSettle();
expect(t.widget<TextFormField>(day).controller!.text, '09');
expect(t.widget<TextFormField>(year).controller!.text, '1985');
expect(find.text('December'), findsOneWidget);
await mount(t, value, locale: const Locale('ru'));
expect(find.text('Дата рождения'), findsOneWidget);
expect(find.text('декабрь'), findsOneWidget);
expect(value.value, '1985-12-09');
await select(t, 'апрель');
expect(value.value, '1985-04-09');
value.value = '';
await t.pumpAndSettle();
expect(t.widget<TextFormField>(day).controller!.text, '');
expect(t.widget<TextFormField>(year).controller!.text, '');
expect(find.text('апрель'), findsNothing);
},
);
testWidgets('digits and length limits keep each component usable', (t) async {
final value = ValueNotifier('1990-04-23');
addTearDown(value.dispose);
await mount(t, value);
await t.enterText(day, '');
await t.enterText(day, 'a123');
await t.enterText(year, '');
await t.enterText(year, 'b19856');
await t.pumpAndSettle();
expect(t.widget<TextFormField>(day).controller!.text, '12');
expect(t.widget<TextFormField>(year).controller!.text, '1985');
expect(value.value, '1985-04-12');
});
testWidgets('busy state disables every birthday control', (t) async {
final value = ValueNotifier('1990-04-23');
addTearDown(value.dispose);
await mount(t, value, enabled: false);
expect(t.widget<TextFormField>(day).enabled, isFalse);
expect(t.widget<TextFormField>(year).enabled, isFalse);
expect(t.widget<DropdownButtonFormField<int>>(month).onChanged, isNull);
expect(value.value, '1990-04-23');
});
testWidgets('keyboard can traverse, choose month, cancel and edit year', (
t,
) async {
final value = ValueNotifier('1990-04-23');
addTearDown(value.dispose);
await mount(t, value);
await t.sendKeyEvent(LogicalKeyboardKey.tab);
await t.pump();
expect(
t
.widget<EditableText>(
find.descendant(of: day, matching: find.byType(EditableText)),
)
.focusNode
.hasFocus,
isTrue,
);
await t.sendKeyEvent(LogicalKeyboardKey.tab);
await t.sendKeyEvent(LogicalKeyboardKey.enter);
await t.pumpAndSettle();
await t.sendKeyEvent(LogicalKeyboardKey.arrowDown);
await t.sendKeyEvent(LogicalKeyboardKey.enter);
await t.pumpAndSettle();
expect(value.value, '1990-05-23');
await t.sendKeyEvent(LogicalKeyboardKey.enter);
await t.pumpAndSettle();
await t.sendKeyEvent(LogicalKeyboardKey.escape);
await t.pumpAndSettle();
expect(value.value, '1990-05-23');
await t.sendKeyEvent(LogicalKeyboardKey.tab);
await t.pump();
expect(
t
.widget<EditableText>(
find.descendant(of: year, matching: find.byType(EditableText)),
)
.focusNode
.hasFocus,
isTrue,
);
});
testWidgets(
'Russian controls and errors fit 320px with large text and labeled targets',
(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);
final semantics = t.ensureSemantics();
final value = ValueNotifier('1990-09-23');
addTearDown(value.dispose);
await mount(t, value, locale: const Locale('ru'));
expect(find.text('сентябрь'), findsOneWidget);
expect(t, meetsGuideline(labeledTapTargetGuideline));
expect(t, meetsGuideline(androidTapTargetGuideline));
await t.enterText(day, '31');
await t.pumpAndSettle();
expect(find.text('Укажите корректную дату рождения.'), findsOneWidget);
await select(t, 'март');
expect(value.value, '1990-03-31');
expect(t.takeException(), isNull);
semantics.dispose();
},
);
}
+48 -2
View File
@@ -11,6 +11,7 @@ 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';
@@ -66,10 +67,16 @@ void main() {
return service;
}
Future<void> submit(WidgetTester t) async {
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('kyc-birth')), '1990-04-23');
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(
@@ -85,6 +92,10 @@ void main() {
'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')));
@@ -93,6 +104,41 @@ void main() {
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 {
+38
View File
@@ -42,6 +42,44 @@ Future<GoRouter> openRoute(
}
void main() {
testWidgets('queued signup receipt is transient and updates its recipient', (
tester,
) async {
final router = await openRoute(tester, '/');
await tester.pumpAndSettle();
router.goNamed(
AppRoutes.verificationSent,
extra: const VerificationDelivery(
email: 'first@example.com',
isSimulated: false,
),
);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
expect(find.text('first@example.com'), findsOneWidget);
router.goNamed(
AppRoutes.verificationSent,
extra: const VerificationDelivery(
email: 'second@example.com',
isSimulated: false,
),
);
await tester.pumpAndSettle();
expect(find.text('first@example.com'), findsNothing);
expect(find.text('second@example.com'), findsOneWidget);
final restored = router.routeInformationParser.restoreRouteInformation(
router.routerDelegate.currentConfiguration,
)!;
expect(restored.state.toString(), isNot(contains('second@example.com')));
expect(restored.uri.toString(), '/verification-sent');
router.goNamed(AppRoutes.login);
await tester.pumpAndSettle();
router.goNamed(AppRoutes.verificationSent);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('verification-idle')), findsOneWidget);
expect(find.text('second@example.com'), findsNothing);
});
for (final language in ['en', 'ru']) {
testWidgets(
'verified link keeps success visible and prefills login ($language)',
+153
View File
@@ -0,0 +1,153 @@
import 'dart:convert';
import 'package:flutter/material.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:sxchnge_analytics/sxchnge_analytics.dart';
import 'package:sxchnge_auth/sxchnge_auth.dart';
import 'package:sxchnge_app/main.dart';
import 'package:sxchnge_app/providers/locale.dart';
class DeliveryBackend extends BackendAuthServices {
DeliveryBackend(List<String> requests)
: super(
baseUri: Uri.parse('https://api.example/api/v1'),
clientId: 'web',
client: MockClient((request) async {
requests.add(request.url.path);
return http.Response(
jsonEncode({
'data': request.url.path.endsWith('/signup')
? {'userId': 'customer'}
: {'accepted': true},
}),
202,
);
}),
);
int sends = 0;
bool failResend = true;
@override
Future<VerificationResult> send({
required String email,
required String locale,
}) async {
sends++;
if (failResend) {
throw const VerificationException(VerificationError.internal);
}
return super.send(email: email, locale: locale);
}
}
void main() {
for (final language in ['en', 'ru']) {
testWidgets(
'accepted signup survives an unavailable resend service ($language)',
(tester) async {
tester.view.physicalSize = const Size(390, 844);
tester.view.devicePixelRatio = 1;
tester.platformDispatcher.textScaleFactorTestValue = 1.5;
addTearDown(tester.view.resetPhysicalSize);
addTearDown(tester.view.resetDevicePixelRatio);
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
final requests = <String>[];
final backend = DeliveryBackend(requests);
final analytics = DummyAnalyticsService();
await tester.pumpWidget(
SxchngeApp(
backend: backend,
analyticsService: analytics,
localeProvider: LocaleProvider(initialLocale: Locale(language)),
),
);
await tester.pumpAndSettle();
final signup = find.text(language == 'ru' ? 'Регистрация' : 'Sign up');
await tester.ensureVisible(signup);
await tester.tap(signup);
await tester.pumpAndSettle();
for (final entry in {
'name': 'Ada Lovelace',
'email': 'ada@example.com',
'password': 'password preserved',
'confirmation': 'password preserved',
}.entries) {
await tester.enterText(find.byKey(ValueKey(entry.key)), entry.value);
}
final submit = find.byKey(const ValueKey('submit'));
await tester.ensureVisible(submit);
await tester.tap(submit);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
expect(find.text('ada@example.com'), findsOneWidget);
expect(backend.sends, 0);
expect(requests, ['/api/v1/accounts/signup']);
final router = GoRouter.of(tester.element(find.byType(Scaffold)));
final restored = router.routeInformationParser.restoreRouteInformation(
router.routerDelegate.currentConfiguration,
)!;
expect(restored.uri.toString(), '/verification-sent');
expect(restored.state.toString(), isNot(contains('ada@example.com')));
expect(
analytics.events.where(
(event) => event.name == 'auth_signup_succeeded',
),
hasLength(1),
);
expect(
analytics.events.where(
(event) => event.name == 'auth_email_delivery_succeeded',
),
hasLength(1),
);
final resend = find.byKey(const ValueKey('verification-resend'));
await tester.ensureVisible(resend);
await tester.tap(resend);
await tester.pumpAndSettle();
expect(
find.byKey(const ValueKey('verification-error')),
findsOneWidget,
);
expect(backend.sends, 1);
expect(
tester
.widget<TextFormField>(
find.byKey(const ValueKey('verification-email')),
)
.controller!
.text,
'ada@example.com',
);
backend.failResend = false;
await tester.ensureVisible(resend);
await tester.tap(resend);
await tester.pumpAndSettle();
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
expect(backend.sends, 2);
expect(requests.last, '/api/v1/accounts/verification/send');
expect(requests, hasLength(2));
expect(
analytics.events.map((event) => event.journeyId).toSet(),
hasLength(1),
);
expect(
analytics.events.map((event) => event.properties).toString(),
isNot(contains('ada@example.com')),
);
expect(tester.takeException(), isNull);
},
);
}
}