Shuffle/app/test/features/auth/presentation/login/login_screen_test.dart
cschlaefke 3805c71225
Some checks failed
CI / flutter (pull_request) Failing after 44s
CI / web-build (pull_request) Successful in 51s
CI / supabase (pull_request) Successful in 3s
CI / supabase-tests (pull_request) Successful in 29s
CI / e2e (pull_request) Successful in 2m44s
fix(auth): address review findings for smtp-neutral handling
Refs #37

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-11-03 12:24:25 +01:00

633 lines
21 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('S4: a server failure on resend stays visible at login', (
tester,
) async {
when(() => repository.resendConfirmation(any()))
.thenAnswer((_) async => failed(const AuthFailure.unexpected()));
await open(tester);
await fill(tester);
await submit(tester);
await tester.tap(find.text(_de.authActionResendConfirmation));
await tester.pumpAndSettle();
expect(find.text(_de.authErrorUnexpected), 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));
});
});
}