526 lines
17 KiB
Dart
526 lines
17 KiB
Dart
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', 'AB');
|
||
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(
|
||
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 = <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));
|
||
});
|
||
});
|
||
}
|