Shuffle/app/test/features/auth/presentation/register/register_screen_test.dart
cschlaefke 7d094a6732 fix(auth): treat SMTP failures like success on enumeration-sensitive calls
Refs #37

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

522 lines
17 KiB
Dart
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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<AuthUiHandle> 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<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> 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<void> 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<TextField>(fieldOf('register_email'));
expect(email.autofillHints, contains(AutofillHints.email));
expect(email.keyboardType, TextInputType.emailAddress);
expect(email.textInputAction, TextInputAction.next);
final password = tester.widget<TextField>(fieldOf('register_password'));
expect(password.autofillHints, contains(AutofillHints.newPassword));
expect(password.obscureText, isTrue);
final name = tester.widget<TextField>(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<AppFieldError>(
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<TextField>(fieldOf('register_email'));
expect(email.focusNode?.hasFocus, isTrue);
});
});
group('KTO-01 Kriterium 4 (F12): same screen for every answer', () {
Future<List<String>> textsAfterSubmit(
WidgetTester tester,
Result<void, AuthFailure> 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<Result<void, AuthFailure>>();
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<TextField>(fieldOf('register_email')).enabled,
false,
);
final button = tester.widget<FilledButton>(
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<TextField>(fieldOf('register_email')).controller?.text,
'anna@example.com',
);
expect(
tester.widget<TextField>(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()),
);
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 = <Uri>[];
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));
});
});
}