import 'dart:async'; 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 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 type(WidgetTester tester, String key, String text) async { await tester.ensureVisible(fieldOf(key)); await tester.enterText(fieldOf(key), text); await tester.pump(); } Future tapKey(WidgetTester tester, String key) async { final finder = find.byKey(Key(key)); await tester.ensureVisible(finder); await tester.tap(finder); await tester.pump(); } Future fill(WidgetTester tester) async { await type(tester, 'login_email', 'anna@example.com'); await type(tester, 'login_password', 'geheim-und-lang'); } Future 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(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(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>(); 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(fieldOf('login_email')).enabled, isFalse); expect( tester.widget(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('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(fieldOf('login_email')).controller!.text, 'anna@example.com', ); expect( tester.widget(fieldOf('login_password')).controller!.text, 'geheim-und-lang', ); }); testWidgets( 'wrong email and wrong password show exactly the same screen text', (tester) async { // The repository maps both cases to invalidCredentials (Spec 4.3); the // screen must not add anything that tells them apart. stubSignInFailure(const AuthFailure.invalidCredentials()); 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); expect(wrongEmail, contains(_de.authErrorInvalidCredentials)); verify(() => repository.signIn(any(), any())).called(2); }, ); testWidgets('english text of the generic failure', (tester) async { stubSignInFailure(const AuthFailure.invalidCredentials()); await open(tester, locale: const Locale('en')); await fill(tester); await submit(tester); expect(find.text(_en.authErrorInvalidCredentials), findsOneWidget); expect(find.text(_de.authErrorInvalidCredentials), findsNothing); }); 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(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 completer = Completer>(); 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(); expect(find.text(_de.authActionResendConfirmation), findsNothing); 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('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(); }); } 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( '${_de.authBannerTypeError}: ${_de.authErrorInvalidCredentials}', ), 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)); }); }); }