import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:shuffle/core/platform/url_opener.dart'; import 'package:shuffle/core/result/result.dart'; import 'package:shuffle/core/widgets/app_field_error.dart'; import 'package:shuffle/features/auth/domain/auth_failure.dart'; import 'package:shuffle/features/auth/domain/registration_input.dart'; import 'package:shuffle/l10n/app_localizations_de.dart'; import 'package:shuffle/l10n/app_localizations_en.dart'; import '../../../../helpers/fakes.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.signUp(any())).thenAnswer((_) async => ok()); }); Future open( WidgetTester tester, { Locale locale = const Locale('de'), ThemeMode themeMode = ThemeMode.light, double? textScale, Size size = const Size(400, 800), UrlOpener? urlOpener, }) => pumpAuthRoute( tester, repository, locale: locale, themeMode: themeMode, textScale: textScale, size: size, urlOpener: urlOpener, ); 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 fillValid(WidgetTester tester) async { await type(tester, 'register_email', 'anna@example.com'); await type(tester, 'register_password', 'geheim-und-lang'); await type(tester, 'register_display_name', 'Anna'); await tapKey(tester, 'register_age_checkbox'); await tapKey(tester, 'register_terms_checkbox'); } Future submit(WidgetTester tester) async { await tapKey(tester, 'register_submit'); await tester.pumpAndSettle(); } group('KTO-01 Kriterium 1: valid registration', () { testWidgets('sends the data and shows the check-email page', ( tester, ) async { final (:container, :router) = await open(tester); await fillValid(tester); await submit(tester); final input = verify(() => repository.signUp(captureAny())).captured.single as RegistrationInput; expect(input.email, 'anna@example.com'); expect(input.displayName, 'Anna'); expect(input.locale, 'de'); expect(input.termsVersion, testAppConfig().termsVersion); expect(currentPath(router), '/register/check-email'); expect(find.byKey(const Key('check_email_screen')), findsOneWidget); expect(find.text(_de.authCheckEmailBody), findsOneWidget); }); testWidgets('the enter key in the last field submits', (tester) async { final (:container, :router) = await open(tester); await fillValid(tester); await tester.showKeyboard(fieldOf('register_display_name')); await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(); verify(() => repository.signUp(any())).called(1); expect(currentPath(router), '/register/check-email'); }); testWidgets('the fields carry autofill hints and keyboard types', ( tester, ) async { await open(tester); final email = tester.widget(fieldOf('register_email')); expect(email.autofillHints, contains(AutofillHints.email)); expect(email.keyboardType, TextInputType.emailAddress); expect(email.textInputAction, TextInputAction.next); final password = tester.widget(fieldOf('register_password')); expect(password.autofillHints, contains(AutofillHints.newPassword)); expect(password.obscureText, isTrue); final name = tester.widget(fieldOf('register_display_name')); expect(name.textInputAction, TextInputAction.done); }); }); group('KTO-01 Kriterium 2: minimum age', () { testWidgets( 'without the confirmation the reason shows and nothing is sent', (tester) async { await open(tester); await type(tester, 'register_email', 'anna@example.com'); await type(tester, 'register_password', 'geheim-und-lang'); await type(tester, 'register_display_name', 'Anna'); await tapKey(tester, 'register_terms_checkbox'); await submit(tester); expect(find.text(_de.authAgeNotConfirmed), findsOneWidget); verifyNever(() => repository.signUp(any())); }, ); testWidgets('QUE-04 Kriterium 1: without consent the error shows', ( tester, ) async { await open(tester); await fillValid(tester); await tapKey(tester, 'register_terms_checkbox'); await submit(tester); expect(find.text(_de.authTermsNotAccepted), findsOneWidget); verifyNever(() => repository.signUp(any())); }); }); group('KTO-01 Kriterium 3: password at the field', () { testWidgets('shows the missing characters while typing', (tester) async { await open(tester); await type(tester, 'register_password', 'abc'); expect(find.text(_de.authPasswordTooShort(7)), findsOneWidget); await type(tester, 'register_password', 'abcdefghi'); expect(find.text(_de.authPasswordTooShort(1)), findsOneWidget); await type(tester, 'register_password', 'abcdefghij'); expect(find.textContaining('Mindestens 10 Zeichen –'), findsNothing); }); testWidgets( 'the length hint is announced when the field is left, not while typing', (tester) async { await open(tester); bool live() => tester .widget( find.descendant( of: find.byKey(const Key('register_password')), matching: find.byType(AppFieldError), ), ) .live; await type(tester, 'register_password', 'abc'); expect(find.text(_de.authPasswordTooShort(7)), findsOneWidget); expect(live(), isFalse); await type(tester, 'register_password', 'abcd'); expect(live(), isFalse); await tapKey(tester, 'register_display_name'); await tester.pump(); expect(find.text(_de.authPasswordTooShort(6)), findsOneWidget); expect(live(), isTrue); }, ); testWidgets('names a common password', (tester) async { await open(tester); await type(tester, 'register_password', 'Passwort1234'); await tester.pumpAndSettle(); expect(find.text(_de.authPasswordCommon), findsOneWidget); }); testWidgets('shows the rule as hint before any error', (tester) async { await open(tester); expect(find.text(_de.authPasswordRule), findsOneWidget); }); testWidgets('a weak_password answer shows at the field', (tester) async { when(() => repository.signUp(any())).thenAnswer( (_) async => failed(const AuthFailure.weakPassword([WeakPasswordReason.length])), ); await open(tester); await fillValid(tester); await submit(tester); expect(find.text(_de.authErrorWeakPasswordLength), findsOneWidget); expect(find.byKey(const Key('register_banner')), findsNothing); }); }); group('KTO-01 Kriterium 10: display name at the field', () { testWidgets('empty name shows the error on submit', (tester) async { await open(tester); await type(tester, 'register_email', 'anna@example.com'); await type(tester, 'register_password', 'geheim-und-lang'); await submit(tester); expect(find.text(_de.authDisplayNameEmpty), findsOneWidget); verifyNever(() => repository.signUp(any())); }); testWidgets('31 characters are too long, the counter follows', ( tester, ) async { await open(tester); await type(tester, 'register_display_name', 'a' * 31); await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(); expect(find.text(_de.authDisplayNameTooLong(30)), findsOneWidget); expect(find.text('31/30'), findsOneWidget); }); testWidgets('the counter counts trimmed code points', (tester) async { await open(tester); await type(tester, 'register_display_name', ' 😀😀 '); expect(find.text('2/30'), findsOneWidget); }); testWidgets('U+200B shows the same message as an invalid name', ( tester, ) async { await open(tester); await type(tester, 'register_display_name', 'A​B'); await tester.testTextInput.receiveAction(TextInputAction.done); await tester.pumpAndSettle(); expect(find.text(_de.authDisplayNameInvalid), findsOneWidget); }); }); group('KTO-01 Kriterium 1: email at the field', () { testWidgets('an invalid address shows after leaving the field', ( tester, ) async { await open(tester); await type(tester, 'register_email', 'anna'); await tester.tap(fieldOf('register_password')); await tester.pumpAndSettle(); expect(find.text(_de.authErrorEmailInvalid), findsOneWidget); }); testWidgets('after a failed submit the first invalid field has focus', ( tester, ) async { await open(tester); await submit(tester); final email = tester.widget(fieldOf('register_email')); expect(email.focusNode?.hasFocus, isTrue); }); }); group('KTO-01 Kriterium 4 (F12): same screen for every answer', () { Future> textsAfterSubmit( WidgetTester tester, Result answer, ) async { when(() => repository.signUp(any())).thenAnswer((_) async => answer); final (:container, :router) = await open(tester); await fillValid(tester); await submit(tester); expect(currentPath(router), '/register/check-email'); final texts = visibleTexts(tester); // Unmount so that provider disposal timers run before the next pump. await tester.pumpWidget(const SizedBox.shrink()); await tester.pumpAndSettle(); return texts; } testWidgets('success and emailSendLimited show identical texts', ( tester, ) async { final success = await textsAfterSubmit(tester, ok()); final limited = await textsAfterSubmit( tester, failed(const AuthFailure.emailSendLimited()), ); expect(limited, success); expect(limited, isNot(contains(_de.authErrorRateLimited))); expect(limited, contains(_de.authCheckEmailDelayHint)); }); testWidgets('F13: an SMTP failure (unexpected) shows the same texts', ( tester, ) async { final success = await textsAfterSubmit(tester, ok()); final smtp = await textsAfterSubmit( tester, failed(const AuthFailure.unexpected()), ); expect(smtp, success); expect(smtp, isNot(contains(_de.authErrorUnexpected))); expect(smtp.join(' '), isNot(contains('example.com'))); }); }); group('Zustände Kriterium 2, 3: loading, errors, offline', () { testWidgets('while sending the button and fields are locked', ( tester, ) async { final completer = Completer>(); when(() => repository.signUp(any())).thenAnswer((_) => completer.future); await open(tester); await fillValid(tester); await tapKey(tester, 'register_submit'); await tester.pump(); expect(find.byType(CircularProgressIndicator), findsOneWidget); expect( tester.widget(fieldOf('register_email')).enabled, false, ); final button = tester.widget( find.descendant( of: find.byKey(const Key('register_submit')), matching: find.byType(FilledButton), ), ); expect(button.onPressed, isNull); completer.complete(ok()); await tester.pumpAndSettle(); }); testWidgets('offline: shows the hint, keeps the input, can retry', ( tester, ) async { when(() => repository.signUp(any())) .thenAnswer((_) async => failed(const AuthFailure.network())); final (:container, :router) = await open(tester); await fillValid(tester); await submit(tester); expect(find.text(_de.authErrorNetwork), findsOneWidget); expect(currentPath(router), '/register'); expect( tester.widget(fieldOf('register_email')).controller?.text, 'anna@example.com', ); expect( tester.widget(fieldOf('register_password')).controller?.text, 'geheim-und-lang', ); when(() => repository.signUp(any())).thenAnswer((_) async => ok()); await tester.tap(find.text(_de.authActionRetry)); await tester.pumpAndSettle(); expect(currentPath(router), '/register/check-email'); }); testWidgets('a rejected registration shows the general message', ( tester, ) async { when(() => repository.signUp(any())).thenAnswer( (_) async => failed( const AuthFailure.registrationRejected( reason: RegistrationRejectionReason.displayName, ), ), ); await open(tester); await fillValid(tester); await submit(tester); expect(find.text(_de.authErrorRegistrationRejected), findsOneWidget); }); }); group('QUE-04 Kriterium 1: legal links', () { testWidgets('privacy and terms open the configured URLs', (tester) async { final opened = []; await open( tester, urlOpener: (uri) async { opened.add(uri); return true; }, ); await tester.ensureVisible(find.text(_de.authCheckboxTermsPrivacyLink)); await tester.tap(find.text(_de.authCheckboxTermsPrivacyLink)); await tester.tap(find.text(_de.authCheckboxTermsTermsLink)); await tester.pump(); expect(opened, [ Uri.parse(testAppConfig().legalPrivacyUrl), Uri.parse(testAppConfig().legalTermsUrl), ]); // Opening a link does not tick the box. final handle = tester.ensureSemantics(); expect( tester.getSemantics(find.byKey(const Key('register_terms_checkbox'))), isNot(matchesSemantics(isChecked: true)), ); handle.dispose(); }); testWidgets('a failing launch shows a hint', (tester) async { await open(tester, urlOpener: (_) async => false); await tester.ensureVisible(find.text(_de.authCheckboxTermsTermsLink)); await tester.tap(find.text(_de.authCheckboxTermsTermsLink)); await tester.pump(); expect(find.text(_de.authErrorUnexpected), findsOneWidget); }); }); group('QUE-05 Kriterium 2: de and en', () { testWidgets( 'KTO-01 Kriterium 11 (Client): english texts and english mail language', (tester) async { await open(tester, locale: const Locale('en')); expect(find.text(_en.authRegisterTitle), findsWidgets); expect(find.text(_en.authFieldDisplayNameLabel), findsOneWidget); expect(find.text(_en.authCheckboxAgeLabel), findsOneWidget); expect(find.text(_de.authCheckboxAgeLabel), findsNothing); await fillValid(tester); await submit(tester); final input = verify(() => repository.signUp(captureAny())).captured.single as RegistrationInput; expect(input.locale, 'en'); expect(find.text(_en.authCheckEmailTitle), findsWidgets); }, ); testWidgets('german texts and the sentence with both links', ( tester, ) async { await open(tester); expect(find.text(_de.authFieldPasswordLabel), findsOneWidget); final handle = tester.ensureSemantics(); expect( find.bySemanticsLabel( 'Ich habe die Datenschutzerklärung gelesen und stimme den ' 'Nutzungsbedingungen zu', ), findsOneWidget, ); handle.dispose(); }); }); 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(); await open(tester, themeMode: mode); await submit(tester); await expectLater(tester, meetsGuideline(androidTapTargetGuideline)); await expectLater(tester, meetsGuideline(labeledTapTargetGuideline)); await expectLater(tester, meetsGuideline(textContrastGuideline)); handle.dispose(); }); } testWidgets('text scale 2.0 on a small phone does not overflow', ( tester, ) async { await open(tester, textScale: 2, size: const Size(320, 640)); 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)); }); }); }