Deliver localized auth app with verification and go_router
Integrate themed login/signup, EN/RU localization, shared dummy providers, verification recovery screens and named URL routes. Store frontend guidelines, workflow skills, validation and delivery documentation. Related to #2, #3, #5 and #6.
This commit is contained in:
1 parent
27424ed886
commit
10a278c65a
44 files changed
+4128
-28
No files matched your search
@@ -0,0 +1,104 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
|
||||
import 'package:sxchnge_app/main.dart';
|
||||
import 'package:sxchnge_app/providers/locale.dart';
|
||||
|
||||
class ControlledLogin implements LoginService {
|
||||
final calls = <Completer<AuthResult>>[];
|
||||
LoginRequest? request;
|
||||
@override
|
||||
Future<AuthResult> login(LoginRequest input) {
|
||||
request = input;
|
||||
final completer = Completer<AuthResult>();
|
||||
calls.add(completer);
|
||||
return completer.future;
|
||||
}
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'app shows loading, localized failure, and retries through injected service',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(1440, 1200);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final service = ControlledLogin();
|
||||
await tester.pumpWidget(
|
||||
SxchngeApp(
|
||||
loginService: service,
|
||||
localeProvider: LocaleProvider(initialLocale: const Locale('ru')),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('email')),
|
||||
'a@example.com',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('password')),
|
||||
'example-password',
|
||||
);
|
||||
final button = find.byKey(const ValueKey('submit'));
|
||||
await tester.ensureVisible(button);
|
||||
await tester.tap(button);
|
||||
await tester.pump();
|
||||
expect(tester.widget<FilledButton>(button).onPressed, isNull);
|
||||
expect(find.text('Подождите…'), findsOneWidget);
|
||||
expect(service.request!.locale, 'ru');
|
||||
service.calls.single.completeError(
|
||||
const AuthException(AuthError.unavailable),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.text('Сервис недоступен. Попробуйте ещё раз.'),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.tap(button);
|
||||
await tester.pump();
|
||||
expect(service.calls, hasLength(2));
|
||||
service.calls.last.complete(
|
||||
const AuthResult(userId: 'demo', isSimulated: true),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('auth-error')), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('changing mode while loading ignores the old login result', (
|
||||
tester,
|
||||
) async {
|
||||
final service = ControlledLogin();
|
||||
await tester.pumpWidget(SxchngeApp(loginService: service));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('email')),
|
||||
'a@example.com',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('password')),
|
||||
'example-password',
|
||||
);
|
||||
await tester.ensureVisible(find.byKey(const ValueKey('submit')));
|
||||
await tester.tap(find.byKey(const ValueKey('submit')));
|
||||
await tester.pump();
|
||||
await tester.ensureVisible(find.text('Sign up'));
|
||||
await tester.tap(find.text('Sign up'));
|
||||
await tester.pumpAndSettle();
|
||||
service.calls.single.complete(
|
||||
const AuthResult(userId: 'demo', isSimulated: true),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('confirmation')), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,160 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sxchnge_app/auth/validation.dart';
|
||||
import 'package:sxchnge_app/main.dart';
|
||||
|
||||
import 'package:sxchnge_app/generated/i18n/app_localizations_en.dart';
|
||||
|
||||
Future<void> openApp(
|
||||
WidgetTester tester, {
|
||||
Size size = const Size(390, 844),
|
||||
double scale = 1,
|
||||
}) async {
|
||||
tester.view.physicalSize = size;
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.platformDispatcher.textScaleFactorTestValue = scale;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||
await tester.pumpWidget(const SxchngeApp());
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
Future<void> submit(WidgetTester tester) async {
|
||||
final button = find.byKey(const ValueKey('submit'));
|
||||
await tester.ensureVisible(button);
|
||||
await tester.tap(button);
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
void main() {
|
||||
final l = AppLocalizationsEn();
|
||||
test(
|
||||
'validation rejects malformed email and mismatched signup passwords',
|
||||
() {
|
||||
for (final email in ['', 'user', 'user@', 'user @example.com']) {
|
||||
expect(AuthValidation.email(email, l), isNotNull);
|
||||
}
|
||||
expect(AuthValidation.email(' user+tag@example.com ', l), isNull);
|
||||
expect(AuthValidation.password('short', l, signUp: true), isNotNull);
|
||||
expect(AuthValidation.password('short', l, signUp: false), isNull);
|
||||
expect(
|
||||
AuthValidation.confirmation('different', 'password', l),
|
||||
isNotNull,
|
||||
);
|
||||
expect(AuthValidation.name(' ', l), isNotNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'login validates, toggles password, and submits with the keyboard',
|
||||
(tester) async {
|
||||
await openApp(tester);
|
||||
await submit(tester);
|
||||
expect(find.text('Enter your email address.'), findsOneWidget);
|
||||
expect(find.text('Enter your password.'), findsOneWidget);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('email')),
|
||||
'test@example.com',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('password')),
|
||||
'example-password',
|
||||
);
|
||||
await tester.tap(find.byTooltip('Show password'));
|
||||
await tester.pump();
|
||||
expect(
|
||||
tester
|
||||
.widget<TextField>(
|
||||
find.descendant(
|
||||
of: find.byKey(const ValueKey('password')),
|
||||
matching: find.byType(TextField),
|
||||
),
|
||||
)
|
||||
.obscureText,
|
||||
isFalse,
|
||||
);
|
||||
await tester.testTextInput.receiveAction(TextInputAction.done);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('preview-result')), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'signup validates confirmation and clears credentials when switching',
|
||||
(tester) async {
|
||||
await openApp(tester);
|
||||
await tester.tap(find.text('Sign up'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const ValueKey('name')), 'Alex');
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('email')),
|
||||
'alex@example.com',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('password')),
|
||||
'example-password',
|
||||
);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('confirmation')),
|
||||
'different',
|
||||
);
|
||||
await submit(tester);
|
||||
expect(find.text('Passwords do not match.'), findsOneWidget);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('confirmation')),
|
||||
'example-password',
|
||||
);
|
||||
await submit(tester);
|
||||
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
|
||||
await tester.ensureVisible(
|
||||
find.byKey(const ValueKey('demo-confirmation')),
|
||||
);
|
||||
await tester.tap(find.byKey(const ValueKey('demo-confirmation')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.byKey(const ValueKey('verification-verified')),
|
||||
findsOneWidget,
|
||||
);
|
||||
await tester.ensureVisible(find.text('Back to login'));
|
||||
await tester.tap(find.text('Back to login'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('confirmation')), findsNothing);
|
||||
expect(find.text('example-password'), findsNothing);
|
||||
expect(find.byKey(const ValueKey('preview-result')), findsNothing);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('resizing preserves the selected mode and entered values', (
|
||||
tester,
|
||||
) async {
|
||||
await openApp(tester, size: const Size(1440, 1000));
|
||||
await tester.tap(find.text('Sign up'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('email')),
|
||||
'alex@example.com',
|
||||
);
|
||||
tester.view.physicalSize = const Size(390, 844);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('name')), findsOneWidget);
|
||||
expect(find.text('alex@example.com'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
|
||||
for (final size in [const Size(320, 740), const Size(1440, 1000)]) {
|
||||
testWidgets('both modes fit $size at 200% text scale', (tester) async {
|
||||
await openApp(tester, size: size, scale: 2);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.ensureVisible(find.text('Sign up'));
|
||||
await tester.tap(find.text('Sign up'));
|
||||
await tester.pumpAndSettle();
|
||||
await submit(tester);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import 'dart:convert';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:shared_preferences/shared_preferences.dart';
|
||||
|
||||
import 'package:sxchnge_app/main.dart';
|
||||
import 'package:sxchnge_app/providers/locale.dart';
|
||||
|
||||
void main() {
|
||||
TestWidgetsFlutterBinding.ensureInitialized();
|
||||
|
||||
setUp(() => SharedPreferences.setMockInitialValues({}));
|
||||
|
||||
test('saved choice overrides system locale, with English fallback', () async {
|
||||
expect(
|
||||
LocaleProvider.resolve(null, [const Locale('ru', 'RU')]),
|
||||
const Locale('ru'),
|
||||
);
|
||||
expect(
|
||||
LocaleProvider.resolve('en', [const Locale('ru')]),
|
||||
const Locale('en'),
|
||||
);
|
||||
expect(
|
||||
LocaleProvider.resolve('ru-RU', [const Locale('en')]),
|
||||
const Locale('ru'),
|
||||
);
|
||||
expect(
|
||||
LocaleProvider.resolve('de', [const Locale('ru')]),
|
||||
const Locale('en'),
|
||||
);
|
||||
expect(
|
||||
LocaleProvider.resolve(null, [const Locale('fr')]),
|
||||
const Locale('en'),
|
||||
);
|
||||
expect(LocaleProvider.resolve(null, []), const Locale('en'));
|
||||
final first = await LocaleProvider.load([const Locale('en')]);
|
||||
await first.setLocale(const Locale('ru'));
|
||||
final restarted = await LocaleProvider.load([const Locale('en')]);
|
||||
expect(restarted.locale, const Locale('ru'));
|
||||
await Future.wait([
|
||||
first.setLocale(const Locale('en')),
|
||||
first.setLocale(const Locale('ru')),
|
||||
]);
|
||||
expect(
|
||||
(await SharedPreferences.getInstance()).getString(
|
||||
LocaleProvider.storageKey,
|
||||
),
|
||||
'ru',
|
||||
);
|
||||
first.dispose();
|
||||
restarted.dispose();
|
||||
});
|
||||
|
||||
test('catalogs have equal keys and no empty translations', () {
|
||||
final en = jsonDecode(File('lib/l10n/en.arb').readAsStringSync()) as Map;
|
||||
final ru = jsonDecode(File('lib/l10n/ru.arb').readAsStringSync()) as Map;
|
||||
expect(ru.keys.toSet(), en.keys.toSet());
|
||||
expect(
|
||||
ru.values.every((value) => value is String && value.trim().isNotEmpty),
|
||||
isTrue,
|
||||
);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'switching language updates errors and keeps form data and mode',
|
||||
(tester) async {
|
||||
tester.view.physicalSize = const Size(1440, 1200);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
final locale = await LocaleProvider.load([const Locale('en')]);
|
||||
await tester.pumpWidget(SxchngeApp(localeProvider: locale));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(find.text('Sign up'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.enterText(find.byKey(const ValueKey('name')), 'Алексей');
|
||||
await tester.enterText(find.byKey(const ValueKey('email')), 'invalid');
|
||||
await tester.ensureVisible(find.byKey(const ValueKey('submit')));
|
||||
await tester.tap(find.byKey(const ValueKey('submit')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Enter a valid email address.'), findsOneWidget);
|
||||
await tester.ensureVisible(find.byKey(const ValueKey('locale-selector')));
|
||||
await tester.tap(find.byKey(const ValueKey('locale-selector')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.ancestor(
|
||||
of: find.text('Русский'),
|
||||
matching: find.byType(CheckedPopupMenuItem<Locale>),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Введите корректный адрес почты.'), findsOneWidget);
|
||||
expect(find.text('Алексей'), findsOneWidget);
|
||||
expect(find.text('invalid'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('confirmation')), findsOneWidget);
|
||||
expect(find.byTooltip('Показать пароль'), findsOneWidget);
|
||||
expect(
|
||||
(await SharedPreferences.getInstance()).getString(
|
||||
LocaleProvider.storageKey,
|
||||
),
|
||||
'ru',
|
||||
);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
for (final width in [320.0, 1440.0]) {
|
||||
testWidgets('Russian forms fit at width $width with 200% text', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = Size(width, 1000);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.platformDispatcher.textScaleFactorTestValue = 2;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||
await tester.pumpWidget(
|
||||
SxchngeApp(
|
||||
localeProvider: LocaleProvider(initialLocale: const Locale('ru')),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.ensureVisible(find.text('Регистрация'));
|
||||
await tester.tap(find.text('Регистрация'));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.ensureVisible(find.byKey(const ValueKey('submit')));
|
||||
await tester.tap(find.byKey(const ValueKey('submit')));
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Введите ваше имя.'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:go_router/go_router.dart';
|
||||
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
|
||||
import 'package:sxchnge_app/main.dart';
|
||||
import 'package:sxchnge_app/providers/locale.dart';
|
||||
import 'package:sxchnge_app/routing/routes.dart';
|
||||
import 'package:sxchnge_app/verification/page.dart';
|
||||
|
||||
class PendingConfirmation implements VerificationService {
|
||||
final results = <String, Completer<VerificationResult>>{};
|
||||
@override
|
||||
Future<VerificationResult> confirm({required String token}) =>
|
||||
(results[token] = Completer<VerificationResult>()).future;
|
||||
@override
|
||||
Future<VerificationResult> send({
|
||||
required String email,
|
||||
required String locale,
|
||||
}) async => const VerificationResult(isSimulated: true);
|
||||
}
|
||||
|
||||
Future<GoRouter> openRoute(
|
||||
WidgetTester tester,
|
||||
String route, {
|
||||
VerificationService service = const DummyVerificationService(
|
||||
delay: Duration.zero,
|
||||
),
|
||||
}) async {
|
||||
tester.platformDispatcher.defaultRouteNameTestValue = route;
|
||||
addTearDown(tester.platformDispatcher.clearDefaultRouteNameTestValue);
|
||||
await tester.pumpWidget(SxchngeApp(verificationService: service));
|
||||
await tester.pump();
|
||||
return GoRouter.of(tester.element(find.byType(Scaffold).last));
|
||||
}
|
||||
|
||||
void main() {
|
||||
testWidgets('unknown deep link redirects to canonical login URL', (
|
||||
tester,
|
||||
) async {
|
||||
final router = await openRoute(tester, '/unknown?token=private');
|
||||
await tester.pumpAndSettle();
|
||||
expect(router.routeInformationProvider.value.uri.toString(), '/');
|
||||
expect(find.byKey(const ValueKey('email')), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'changing confirmation token ignores the previous pending result',
|
||||
(tester) async {
|
||||
final service = PendingConfirmation();
|
||||
final router = await openRoute(
|
||||
tester,
|
||||
'/verify-email?token=first',
|
||||
service: service,
|
||||
);
|
||||
await tester.pump();
|
||||
expect(service.results.containsKey('first'), isTrue);
|
||||
router.goNamed(
|
||||
AppRoutes.verifyEmail,
|
||||
queryParameters: {'token': 'second'},
|
||||
);
|
||||
await tester.pump();
|
||||
await tester.pump();
|
||||
service.results['second']!.completeError(
|
||||
const VerificationException(VerificationError.expired),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.byKey(const ValueKey('verification-expired')),
|
||||
findsOneWidget,
|
||||
);
|
||||
service.results['first']!.complete(
|
||||
const VerificationResult(isSimulated: true),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.byKey(const ValueKey('verification-expired')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.byKey(const ValueKey('verification-verified')), findsNothing);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets(
|
||||
'platform back and forward route updates recheck confirmation tokens',
|
||||
(tester) async {
|
||||
final router = await openRoute(tester, '/verify-email?token=demo-valid');
|
||||
await tester.pumpAndSettle();
|
||||
await router.routeInformationProvider.didPushRouteInformation(
|
||||
RouteInformation(uri: Uri.parse('/verify-email?token=demo-expired')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.byKey(const ValueKey('verification-expired')),
|
||||
findsOneWidget,
|
||||
);
|
||||
await router.routeInformationProvider.didPushRouteInformation(
|
||||
RouteInformation(uri: Uri.parse('/verify-email?token=demo-valid')),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
find.byKey(const ValueKey('verification-verified')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(
|
||||
router.routeInformationProvider.value.uri.queryParameters['token'],
|
||||
'demo-valid',
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('locale rebuild preserves router, URL and confirmation state', (
|
||||
tester,
|
||||
) async {
|
||||
final router = await openRoute(tester, '/verify-email?token=demo-valid');
|
||||
await tester.pumpAndSettle();
|
||||
final context = tester.element(find.byType(VerificationPage));
|
||||
await context.read<LocaleProvider>().setLocale(const Locale('ru'));
|
||||
await tester.pumpAndSettle();
|
||||
expect(
|
||||
GoRouter.of(tester.element(find.byType(VerificationPage))),
|
||||
same(router),
|
||||
);
|
||||
expect(find.byKey(const ValueKey('verification-verified')), findsOneWidget);
|
||||
expect(find.text('Ваша почта\nподтверждена.'), findsOneWidget);
|
||||
});
|
||||
|
||||
testWidgets(
|
||||
'signup email stays in memory and is excluded from serialized history',
|
||||
(tester) async {
|
||||
final router = await openRoute(tester, '/');
|
||||
await tester.pumpAndSettle();
|
||||
router.goNamed(AppRoutes.verificationSent, extra: 'alex@example.com');
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('alex@example.com'), findsOneWidget);
|
||||
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
|
||||
final restored = router.routeInformationParser.restoreRouteInformation(
|
||||
router.routerDelegate.currentConfiguration,
|
||||
)!;
|
||||
expect(restored.state.toString(), isNot(contains('alex@example.com')));
|
||||
expect(restored.uri.toString(), '/verification-sent');
|
||||
router.goNamed(AppRoutes.login);
|
||||
await tester.pumpAndSettle();
|
||||
router.goNamed(AppRoutes.verificationSent);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('alex@example.com'), findsNothing);
|
||||
expect(find.byKey(const ValueKey('verification-idle')), findsOneWidget);
|
||||
},
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
|
||||
import 'package:sxchnge_auth/sxchnge_auth.dart';
|
||||
|
||||
import 'package:sxchnge_app/main.dart';
|
||||
|
||||
class RecoveringService implements VerificationService {
|
||||
int sends = 0;
|
||||
int confirmations = 0;
|
||||
@override
|
||||
Future<VerificationResult> send({
|
||||
required String email,
|
||||
required String locale,
|
||||
}) async {
|
||||
if (++sends == 1) throw StateError('private error');
|
||||
return const VerificationResult(isSimulated: true);
|
||||
}
|
||||
|
||||
@override
|
||||
Future<VerificationResult> confirm({required String token}) async {
|
||||
if (++confirmations == 1) throw StateError('private error');
|
||||
return const VerificationResult(isSimulated: true);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> openVerification(
|
||||
WidgetTester tester,
|
||||
String route, {
|
||||
VerificationService service = const DummyVerificationService(
|
||||
delay: Duration.zero,
|
||||
),
|
||||
}) async {
|
||||
tester.platformDispatcher.defaultRouteNameTestValue = route;
|
||||
addTearDown(tester.platformDispatcher.clearDefaultRouteNameTestValue);
|
||||
await tester.pumpWidget(SxchngeApp(verificationService: service));
|
||||
await tester.pumpAndSettle();
|
||||
}
|
||||
|
||||
void main() {
|
||||
for (final scenario in {
|
||||
'demo-valid': 'verified',
|
||||
'demo-expired': 'expired',
|
||||
'wrong': 'invalid',
|
||||
'': 'invalid',
|
||||
'demo-internal': 'error',
|
||||
}.entries) {
|
||||
testWidgets(
|
||||
'confirmation link resolves ${scenario.key} as ${scenario.value}',
|
||||
(tester) async {
|
||||
await openVerification(tester, '/verify-email?token=${scenario.key}');
|
||||
expect(
|
||||
find.byKey(ValueKey('verification-${scenario.value}')),
|
||||
findsOneWidget,
|
||||
);
|
||||
expect(find.textContaining('DEMO'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
}
|
||||
testWidgets('internal confirmation error can retry without entering email', (
|
||||
tester,
|
||||
) async {
|
||||
final service = RecoveringService();
|
||||
await openVerification(
|
||||
tester,
|
||||
'/verify-email?token=opaque',
|
||||
service: service,
|
||||
);
|
||||
expect(find.text('private error'), findsNothing);
|
||||
final retry = find.byKey(const ValueKey('verification-retry'));
|
||||
await tester.ensureVisible(retry);
|
||||
await tester.tap(retry);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('verification-verified')), findsOneWidget);
|
||||
expect(service.confirmations, 2);
|
||||
});
|
||||
testWidgets(
|
||||
'request validates email, reports send error and resends successfully',
|
||||
(tester) async {
|
||||
final service = RecoveringService();
|
||||
await openVerification(tester, '/verification-sent', service: service);
|
||||
final resend = find.byKey(const ValueKey('verification-resend'));
|
||||
await tester.ensureVisible(resend);
|
||||
await tester.tap(resend);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('Enter your email address.'), findsOneWidget);
|
||||
expect(service.sends, 0);
|
||||
await tester.enterText(
|
||||
find.byKey(const ValueKey('verification-email')),
|
||||
'alex@example.com',
|
||||
);
|
||||
await tester.ensureVisible(resend);
|
||||
await tester.tap(resend);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('verification-error')), findsOneWidget);
|
||||
await tester.ensureVisible(resend);
|
||||
await tester.tap(resend);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.byKey(const ValueKey('verification-sent')), findsOneWidget);
|
||||
expect(service.sends, 2);
|
||||
},
|
||||
);
|
||||
for (final route in [
|
||||
'/verification-sent',
|
||||
'/verify-email?token=demo-valid',
|
||||
'/verify-email?token=demo-expired',
|
||||
'/verify-email?token=bad',
|
||||
'/verify-email?token=demo-internal',
|
||||
]) {
|
||||
testWidgets('$route fits narrow viewport at 200% in EN and RU', (
|
||||
tester,
|
||||
) async {
|
||||
tester.view.physicalSize = const Size(320, 740);
|
||||
tester.view.devicePixelRatio = 1;
|
||||
tester.platformDispatcher.textScaleFactorTestValue = 2;
|
||||
addTearDown(tester.view.resetPhysicalSize);
|
||||
addTearDown(tester.view.resetDevicePixelRatio);
|
||||
addTearDown(tester.platformDispatcher.clearTextScaleFactorTestValue);
|
||||
await openVerification(tester, route);
|
||||
expect(tester.takeException(), isNull);
|
||||
await tester.tap(find.byKey(const ValueKey('locale-selector')));
|
||||
await tester.pumpAndSettle();
|
||||
await tester.tap(
|
||||
find.ancestor(
|
||||
of: find.text('Русский'),
|
||||
matching: find.byType(CheckedPopupMenuItem<Locale>),
|
||||
),
|
||||
);
|
||||
await tester.pumpAndSettle();
|
||||
expect(find.text('RU'), findsOneWidget);
|
||||
expect(tester.takeException(), isNull);
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user