ForgotPasswordScreen with neutral reaction (F12), CheckEmail reset mode on its own path, recovery link handling and ResetPasswordScreen with the recovery guard held until the user confirms the success message. Carries the from parameter between login, register and forgot password. Removes the route placeholders. Refs #1 Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
617 lines
20 KiB
Dart
617 lines
20 KiB
Dart
import 'dart:async';
|
|
import 'dart:ui' show Tristate;
|
|
|
|
import 'package:flutter/material.dart';
|
|
import 'package:flutter_test/flutter_test.dart';
|
|
import 'package:go_router/go_router.dart';
|
|
import 'package:mocktail/mocktail.dart';
|
|
import 'package:shuffle/core/result/result.dart';
|
|
import 'package:shuffle/features/auth/domain/auth_failure.dart';
|
|
import 'package:shuffle/features/auth/presentation/auth_routes.dart';
|
|
import 'package:shuffle/l10n/app_localizations_de.dart';
|
|
import 'package:shuffle/l10n/app_localizations_en.dart';
|
|
|
|
import '../../../../helpers/pump_app.dart';
|
|
import '../../../../helpers/router_helpers.dart';
|
|
import '../auth_ui_support.dart';
|
|
|
|
final _de = AppLocalizationsDe();
|
|
final _en = AppLocalizationsEn();
|
|
|
|
void main() {
|
|
late MockAuthRepository repository;
|
|
|
|
setUpAll(registerAuthFallbacks);
|
|
|
|
setUp(() {
|
|
repository = MockAuthRepository();
|
|
when(() => repository.signIn(any(), any())).thenAnswer((_) async => ok());
|
|
when(() => repository.resendConfirmation(any()))
|
|
.thenAnswer((_) async => ok());
|
|
});
|
|
|
|
Future<AuthUiHandle> open(
|
|
WidgetTester tester, {
|
|
String location = '/login',
|
|
Locale locale = const Locale('de'),
|
|
ThemeMode themeMode = ThemeMode.light,
|
|
double? textScale,
|
|
Size size = const Size(400, 800),
|
|
}) async {
|
|
tester.view.physicalSize = size;
|
|
tester.view.devicePixelRatio = 1;
|
|
addTearDown(tester.view.reset);
|
|
final router = GoRouter(
|
|
initialLocation: location,
|
|
routes: [
|
|
...authRoutes,
|
|
GoRoute(path: '/', builder: (_, _) => const Text('home-page')),
|
|
GoRoute(
|
|
path: '/private',
|
|
builder: (_, _) => const Text('private-page'),
|
|
),
|
|
],
|
|
);
|
|
addTearDown(router.dispose);
|
|
final container = await pumpApp(
|
|
tester,
|
|
router: router,
|
|
locale: locale,
|
|
themeMode: themeMode,
|
|
textScale: textScale,
|
|
overrides: authUiOverrides(repository, locale: locale),
|
|
);
|
|
return (container: container, router: router);
|
|
}
|
|
|
|
Future<void> type(WidgetTester tester, String key, String text) async {
|
|
await tester.ensureVisible(fieldOf(key));
|
|
await tester.enterText(fieldOf(key), text);
|
|
await tester.pump();
|
|
}
|
|
|
|
Future<void> tapKey(WidgetTester tester, String key) async {
|
|
final finder = find.byKey(Key(key));
|
|
await tester.ensureVisible(finder);
|
|
await tester.tap(finder);
|
|
await tester.pump();
|
|
}
|
|
|
|
Future<void> fill(WidgetTester tester) async {
|
|
await type(tester, 'login_email', 'anna@example.com');
|
|
await type(tester, 'login_password', 'geheim-und-lang');
|
|
}
|
|
|
|
Future<void> submit(WidgetTester tester) async {
|
|
await tapKey(tester, 'login_submit');
|
|
await tester.pumpAndSettle();
|
|
}
|
|
|
|
void stubSignInFailure(AuthFailure failure) =>
|
|
when(() => repository.signIn(any(), any()))
|
|
.thenAnswer((_) async => failed(failure));
|
|
|
|
group('KTO-01 Kriterium 5: sign in', () {
|
|
testWidgets('signs in and leaves the page', (tester) async {
|
|
final (:container, :router) = await open(tester);
|
|
await fill(tester);
|
|
|
|
await submit(tester);
|
|
|
|
verify(() => repository.signIn('anna@example.com', 'geheim-und-lang'))
|
|
.called(1);
|
|
expect(currentPath(router), '/');
|
|
expect(find.text('home-page'), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('the enter key in the password field submits', (tester) async {
|
|
final (:container, :router) = await open(tester);
|
|
await fill(tester);
|
|
|
|
await tester.showKeyboard(fieldOf('login_password'));
|
|
await tester.testTextInput.receiveAction(TextInputAction.done);
|
|
await tester.pumpAndSettle();
|
|
|
|
verify(() => repository.signIn(any(), any())).called(1);
|
|
expect(currentPath(router), '/');
|
|
});
|
|
|
|
testWidgets('returns to a valid from path', (tester) async {
|
|
final (:container, :router) = await open(
|
|
tester,
|
|
location: '/login?from=%2Fprivate',
|
|
);
|
|
await fill(tester);
|
|
|
|
await submit(tester);
|
|
|
|
expect(currentPath(router), '/private');
|
|
});
|
|
|
|
for (final from in [
|
|
'https://evil.example',
|
|
'//evil.example',
|
|
'/register',
|
|
]) {
|
|
testWidgets('ignores the unsafe from "$from"', (tester) async {
|
|
final (:container, :router) = await open(
|
|
tester,
|
|
location: Uri(
|
|
path: '/login',
|
|
queryParameters: {'from': from},
|
|
).toString(),
|
|
);
|
|
await fill(tester);
|
|
|
|
await submit(tester);
|
|
|
|
expect(currentPath(router), '/');
|
|
});
|
|
}
|
|
|
|
testWidgets('fields carry autofill hints and keyboard types', (
|
|
tester,
|
|
) async {
|
|
await open(tester);
|
|
|
|
final email = tester.widget<TextField>(fieldOf('login_email'));
|
|
expect(email.autofillHints, contains(AutofillHints.username));
|
|
expect(email.autofillHints, contains(AutofillHints.email));
|
|
expect(email.keyboardType, TextInputType.emailAddress);
|
|
expect(email.textInputAction, TextInputAction.next);
|
|
final password = tester.widget<TextField>(fieldOf('login_password'));
|
|
expect(password.autofillHints, contains(AutofillHints.password));
|
|
expect(
|
|
password.autofillHints,
|
|
isNot(contains(AutofillHints.newPassword)),
|
|
);
|
|
expect(password.obscureText, isTrue);
|
|
expect(password.textInputAction, TextInputAction.done);
|
|
});
|
|
|
|
testWidgets('empty fields show errors at the fields and send nothing', (
|
|
tester,
|
|
) async {
|
|
await open(tester);
|
|
|
|
await submit(tester);
|
|
|
|
expect(find.text(_de.authErrorEmailInvalid), findsOneWidget);
|
|
expect(find.text(_de.authPasswordRequired), findsOneWidget);
|
|
verifyNever(() => repository.signIn(any(), any()));
|
|
});
|
|
|
|
testWidgets('double tap sends once and locks the form', (tester) async {
|
|
final completer = Completer<Result<void, AuthFailure>>();
|
|
when(() => repository.signIn(any(), any()))
|
|
.thenAnswer((_) => completer.future);
|
|
await open(tester);
|
|
await fill(tester);
|
|
|
|
await tapKey(tester, 'login_submit');
|
|
await tester.tap(find.byKey(const Key('login_submit')));
|
|
await tester.pump();
|
|
|
|
expect(tester.widget<TextField>(fieldOf('login_email')).enabled, isFalse);
|
|
expect(
|
|
tester.widget<TextField>(fieldOf('login_password')).enabled,
|
|
isFalse,
|
|
);
|
|
completer.complete(ok());
|
|
await tester.pumpAndSettle();
|
|
verify(() => repository.signIn(any(), any())).called(1);
|
|
});
|
|
|
|
testWidgets('links lead to register and forgot password', (tester) async {
|
|
final (:container, :router) = await open(tester);
|
|
|
|
await tapKey(tester, 'login_register_link');
|
|
await tester.pumpAndSettle();
|
|
expect(currentPath(router), '/register');
|
|
|
|
router.go('/login');
|
|
await tester.pumpAndSettle();
|
|
await tapKey(tester, 'login_forgot_link');
|
|
await tester.pumpAndSettle();
|
|
expect(currentPath(router), '/forgot-password');
|
|
});
|
|
});
|
|
|
|
group('from is carried to register and forgot password', () {
|
|
testWidgets('a safe from goes along and comes back', (tester) async {
|
|
final (:container, :router) = await open(
|
|
tester,
|
|
location: '/login?from=%2Fprivate',
|
|
);
|
|
|
|
await tapKey(tester, 'login_register_link');
|
|
await tester.pumpAndSettle();
|
|
expect(currentUri(router).toString(), '/register?from=%2Fprivate');
|
|
await tapKey(tester, 'register_login_link');
|
|
await tester.pumpAndSettle();
|
|
expect(currentUri(router).toString(), '/login?from=%2Fprivate');
|
|
|
|
await tapKey(tester, 'login_forgot_link');
|
|
await tester.pumpAndSettle();
|
|
expect(currentUri(router).toString(), '/forgot-password?from=%2Fprivate');
|
|
await tapKey(tester, 'forgot_password_back');
|
|
await tester.pumpAndSettle();
|
|
expect(currentUri(router).toString(), '/login?from=%2Fprivate');
|
|
});
|
|
|
|
testWidgets('an unsafe from is not forwarded', (tester) async {
|
|
final (:container, :router) = await open(
|
|
tester,
|
|
location: '/login?from=%2F%2Fevil.example',
|
|
);
|
|
|
|
await tapKey(tester, 'login_register_link');
|
|
await tester.pumpAndSettle();
|
|
expect(currentUri(router).toString(), '/register');
|
|
});
|
|
|
|
testWidgets('without from the links stay plain', (tester) async {
|
|
final (:container, :router) = await open(tester);
|
|
|
|
await tapKey(tester, 'login_forgot_link');
|
|
await tester.pumpAndSettle();
|
|
expect(currentUri(router).toString(), '/forgot-password');
|
|
});
|
|
});
|
|
|
|
group('KTO-01 Kriterium 7: generic failure', () {
|
|
testWidgets('invalid credentials: one banner, no field error, input kept', (
|
|
tester,
|
|
) async {
|
|
stubSignInFailure(const AuthFailure.invalidCredentials());
|
|
await open(tester);
|
|
await fill(tester);
|
|
|
|
await submit(tester);
|
|
|
|
expect(find.text(_de.authErrorInvalidCredentials), findsOneWidget);
|
|
expect(find.byKey(const Key('login_banner')), findsOneWidget);
|
|
expect(find.text(_de.authErrorEmailInvalid), findsNothing);
|
|
expect(find.text(_de.authPasswordRequired), findsNothing);
|
|
expect(
|
|
tester.widget<TextField>(fieldOf('login_email')).controller!.text,
|
|
'anna@example.com',
|
|
);
|
|
expect(
|
|
tester.widget<TextField>(fieldOf('login_password')).controller!.text,
|
|
'geheim-und-lang',
|
|
);
|
|
});
|
|
|
|
testWidgets(
|
|
'wrong email and wrong password show exactly the same screen text',
|
|
(tester) async {
|
|
// Screen level only: the repository mock returns invalidCredentials
|
|
// for both cases, so this proves the screen adds nothing that tells
|
|
// them apart. The real proof that an unknown address and a wrong
|
|
// password yield the same `invalid_credentials` answer is the API
|
|
// E2E test (T3).
|
|
stubSignInFailure(const AuthFailure.invalidCredentials());
|
|
final handle = tester.ensureSemantics();
|
|
await open(tester);
|
|
await type(tester, 'login_email', 'unknown@example.com');
|
|
await type(tester, 'login_password', 'irgendein-passwort');
|
|
await submit(tester);
|
|
final wrongEmail = visibleTexts(tester);
|
|
|
|
await type(tester, 'login_email', 'anna@example.com');
|
|
await type(tester, 'login_password', 'falsches-passwort');
|
|
await submit(tester);
|
|
final wrongPassword = visibleTexts(tester);
|
|
|
|
expect(wrongEmail, wrongPassword);
|
|
// Fixed wording, independent of the l10n getter.
|
|
const fixed = 'E-Mail-Adresse oder Passwort ist nicht korrekt.';
|
|
expect(wrongEmail, contains(fixed));
|
|
expect(_de.authErrorInvalidCredentials, fixed);
|
|
expect(find.text(fixed), findsOneWidget);
|
|
expect(find.bySemanticsLabel('Fehler: $fixed'), findsOneWidget);
|
|
handle.dispose();
|
|
},
|
|
);
|
|
|
|
testWidgets('throttling (429) shows the throttle hint', (tester) async {
|
|
stubSignInFailure(const AuthFailure.rateLimited());
|
|
await open(tester);
|
|
await fill(tester);
|
|
|
|
await submit(tester);
|
|
|
|
expect(find.text(_de.authErrorRateLimited), findsOneWidget);
|
|
expect(find.text(_de.authActionRetry), findsNothing);
|
|
});
|
|
|
|
testWidgets('offline: shows the hint, keeps the input, can retry', (
|
|
tester,
|
|
) async {
|
|
var calls = 0;
|
|
when(() => repository.signIn(any(), any())).thenAnswer((_) async {
|
|
calls++;
|
|
return calls == 1 ? failed(const AuthFailure.network()) : ok();
|
|
});
|
|
final (:container, :router) = await open(tester);
|
|
await fill(tester);
|
|
|
|
await submit(tester);
|
|
expect(find.text(_de.authErrorNetwork), findsOneWidget);
|
|
expect(
|
|
tester.widget<TextField>(fieldOf('login_email')).controller!.text,
|
|
'anna@example.com',
|
|
);
|
|
|
|
await tester.tap(find.text(_de.authActionRetry));
|
|
await tester.pumpAndSettle();
|
|
|
|
verify(() => repository.signIn(any(), any())).called(2);
|
|
expect(currentPath(router), '/');
|
|
});
|
|
});
|
|
|
|
group('KTO-01 Kriterium 6: unconfirmed account', () {
|
|
setUp(() => stubSignInFailure(const AuthFailure.emailNotConfirmed()));
|
|
|
|
testWidgets('shows the hint and resends the confirmation', (tester) async {
|
|
await open(tester);
|
|
await fill(tester);
|
|
|
|
await submit(tester);
|
|
expect(find.text(_de.authErrorEmailNotConfirmed), findsOneWidget);
|
|
expect(find.text(_de.authErrorInvalidCredentials), findsNothing);
|
|
|
|
await tester.tap(find.text(_de.authActionResendConfirmation));
|
|
await tester.pumpAndSettle();
|
|
|
|
verify(() => repository.resendConfirmation('anna@example.com')).called(1);
|
|
expect(find.byKey(const Key('login_resent_banner')), findsOneWidget);
|
|
expect(find.text(_de.authCheckEmailResent), findsOneWidget);
|
|
});
|
|
|
|
testWidgets('emailSendLimited shows the throttle hint, not success', (
|
|
tester,
|
|
) async {
|
|
when(
|
|
() => repository.resendConfirmation(any()),
|
|
).thenAnswer((_) async => failed(const AuthFailure.emailSendLimited()));
|
|
await open(tester);
|
|
await fill(tester);
|
|
await submit(tester);
|
|
|
|
await tester.tap(find.text(_de.authActionResendConfirmation));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text(_de.authErrorRateLimited), findsOneWidget);
|
|
expect(find.byKey(const Key('login_resent_banner')), findsNothing);
|
|
});
|
|
|
|
testWidgets('the resend action is locked while sending', (tester) async {
|
|
final semanticsHandle = tester.ensureSemantics();
|
|
final completer = Completer<Result<void, AuthFailure>>();
|
|
when(() => repository.resendConfirmation(any()))
|
|
.thenAnswer((_) => completer.future);
|
|
await open(tester);
|
|
await fill(tester);
|
|
await submit(tester);
|
|
|
|
await tester.tap(find.text(_de.authActionResendConfirmation));
|
|
await tester.pump();
|
|
|
|
// Still there for screen readers, but disabled: no second send.
|
|
final action = find.widgetWithText(
|
|
TextButton,
|
|
_de.authActionResendConfirmation,
|
|
);
|
|
expect(action, findsOneWidget);
|
|
expect(tester.widget<TextButton>(action).onPressed, isNull);
|
|
expect(
|
|
tester
|
|
.getSemantics(action)
|
|
.getSemanticsData()
|
|
.flagsCollection
|
|
.isEnabled,
|
|
Tristate.isFalse,
|
|
);
|
|
await tester.tap(action, warnIfMissed: false);
|
|
semanticsHandle.dispose();
|
|
completer.complete(ok());
|
|
await tester.pumpAndSettle();
|
|
verify(() => repository.resendConfirmation(any())).called(1);
|
|
});
|
|
|
|
testWidgets('offline resend shows the offline hint', (tester) async {
|
|
when(() => repository.resendConfirmation(any()))
|
|
.thenAnswer((_) async => failed(const AuthFailure.network()));
|
|
await open(tester);
|
|
await fill(tester);
|
|
await submit(tester);
|
|
|
|
await tester.tap(find.text(_de.authActionResendConfirmation));
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(find.text(_de.authErrorNetwork), findsOneWidget);
|
|
});
|
|
});
|
|
|
|
group('KTO-01 Kriterium 5: autofill context', () {
|
|
// Only calls that save the credentials (`shouldSave: true`).
|
|
int finishCalls(WidgetTester tester) => tester.testTextInput.log
|
|
.where(
|
|
(call) =>
|
|
call.method == 'TextInput.finishAutofillContext' &&
|
|
call.arguments == true,
|
|
)
|
|
.length;
|
|
|
|
testWidgets('is finished after a successful sign-in', (tester) async {
|
|
await open(tester);
|
|
await fill(tester);
|
|
tester.testTextInput.log.clear();
|
|
|
|
await submit(tester);
|
|
|
|
// Our explicit call, plus the AutofillGroup committing on disposal
|
|
// when the screen is replaced.
|
|
expect(finishCalls(tester), greaterThanOrEqualTo(1));
|
|
});
|
|
|
|
testWidgets('is finished even if the router already removed the screen', (
|
|
tester,
|
|
) async {
|
|
final completer = Completer<Result<void, AuthFailure>>();
|
|
when(() => repository.signIn(any(), any()))
|
|
.thenAnswer((_) => completer.future);
|
|
final (:container, :router) = await open(tester);
|
|
await fill(tester);
|
|
await tapKey(tester, 'login_submit');
|
|
// The status change makes the router redirect away before the
|
|
// sign-in future completes.
|
|
router.go('/private');
|
|
await tester.pumpAndSettle();
|
|
expect(find.byKey(const Key('login_screen')), findsNothing);
|
|
tester.testTextInput.log.clear();
|
|
|
|
completer.complete(ok());
|
|
await tester.pumpAndSettle();
|
|
|
|
expect(finishCalls(tester), 1);
|
|
expect(currentPath(router), '/private');
|
|
});
|
|
|
|
testWidgets('is not finished after a failed sign-in', (tester) async {
|
|
stubSignInFailure(const AuthFailure.invalidCredentials());
|
|
await open(tester);
|
|
await fill(tester);
|
|
tester.testTextInput.log.clear();
|
|
|
|
await submit(tester);
|
|
|
|
expect(finishCalls(tester), 0);
|
|
});
|
|
});
|
|
|
|
group('Zustände Kriterium 3: banners and focus', () {
|
|
testWidgets('unexpected error shows the banner with retry', (tester) async {
|
|
stubSignInFailure(const AuthFailure.unexpected());
|
|
await open(tester);
|
|
await fill(tester);
|
|
|
|
await submit(tester);
|
|
|
|
expect(find.text(_de.authErrorUnexpected), findsOneWidget);
|
|
expect(find.text(_de.authActionRetry), findsOneWidget);
|
|
stubSignInFailure(const AuthFailure.unexpected());
|
|
await tester.tap(find.text(_de.authActionRetry));
|
|
await tester.pumpAndSettle();
|
|
verify(() => repository.signIn(any(), any())).called(2);
|
|
});
|
|
|
|
testWidgets('a failed submit focuses the first invalid field', (
|
|
tester,
|
|
) async {
|
|
await open(tester);
|
|
|
|
await submit(tester);
|
|
expect(
|
|
tester.widget<TextField>(fieldOf('login_email')).focusNode!.hasFocus,
|
|
isTrue,
|
|
);
|
|
|
|
await type(tester, 'login_email', 'anna@example.com');
|
|
await submit(tester);
|
|
expect(
|
|
tester.widget<TextField>(fieldOf('login_password')).focusNode!.hasFocus,
|
|
isTrue,
|
|
);
|
|
});
|
|
});
|
|
|
|
group('QUE-05 Kriterium 2: de and en', () {
|
|
testWidgets('english texts', (tester) async {
|
|
await open(tester, locale: const Locale('en'));
|
|
|
|
expect(find.text(_en.authLoginTitle), findsWidgets);
|
|
expect(find.text(_en.authLoginForgotPassword), findsOneWidget);
|
|
expect(find.text(_en.authLoginCreateAccount), findsOneWidget);
|
|
expect(find.text(_de.authLoginForgotPassword), findsNothing);
|
|
});
|
|
});
|
|
|
|
group('QUE-06 Kriterien 1-4: accessibility', () {
|
|
for (final mode in [ThemeMode.light, ThemeMode.dark]) {
|
|
testWidgets('meets the guidelines ($mode)', (tester) async {
|
|
final handle = tester.ensureSemantics();
|
|
stubSignInFailure(const AuthFailure.emailNotConfirmed());
|
|
await open(tester, themeMode: mode);
|
|
await fill(tester);
|
|
await submit(tester);
|
|
|
|
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
|
|
await expectLater(tester, meetsGuideline(labeledTapTargetGuideline));
|
|
await expectLater(tester, meetsGuideline(textContrastGuideline));
|
|
handle.dispose();
|
|
});
|
|
}
|
|
|
|
for (final mode in [ThemeMode.light, ThemeMode.dark]) {
|
|
testWidgets('error banner meets the contrast guideline ($mode)', (
|
|
tester,
|
|
) async {
|
|
final handle = tester.ensureSemantics();
|
|
stubSignInFailure(const AuthFailure.invalidCredentials());
|
|
await open(tester, themeMode: mode);
|
|
await fill(tester);
|
|
await submit(tester);
|
|
expect(find.byKey(const Key('login_banner')), findsOneWidget);
|
|
|
|
await expectLater(tester, meetsGuideline(textContrastGuideline));
|
|
await expectLater(tester, meetsGuideline(androidTapTargetGuideline));
|
|
handle.dispose();
|
|
});
|
|
}
|
|
|
|
testWidgets('the title is a heading and the banner a live region', (
|
|
tester,
|
|
) async {
|
|
final handle = tester.ensureSemantics();
|
|
stubSignInFailure(const AuthFailure.invalidCredentials());
|
|
await open(tester);
|
|
await fill(tester);
|
|
await submit(tester);
|
|
|
|
expect(
|
|
find.bySemanticsLabel(
|
|
'Fehler: E-Mail-Adresse oder Passwort ist nicht korrekt.',
|
|
),
|
|
findsOneWidget,
|
|
);
|
|
expect(
|
|
tester.getSemantics(find.text(_de.authLoginTitle).first),
|
|
matchesSemantics(label: _de.authLoginTitle, isHeader: true),
|
|
);
|
|
handle.dispose();
|
|
});
|
|
|
|
testWidgets('text scale 2.0 on a small phone does not overflow', (
|
|
tester,
|
|
) async {
|
|
stubSignInFailure(const AuthFailure.emailNotConfirmed());
|
|
await open(tester, textScale: 2, size: const Size(320, 640));
|
|
await fill(tester);
|
|
await submit(tester);
|
|
|
|
expect(tester.takeException(), isNull);
|
|
});
|
|
|
|
testWidgets('wide screens show a centered card', (tester) async {
|
|
await open(tester, size: const Size(1200, 900));
|
|
|
|
final card = tester.getRect(find.byType(Card).first);
|
|
expect(card.width, lessThanOrEqualTo(480));
|
|
expect(card.center.dx, closeTo(600, 1));
|
|
});
|
|
});
|
|
}
|