import 'dart:async'; import 'package:flutter/widgets.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.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/domain/registration_input.dart'; import 'package:shuffle/features/auth/domain/validation/common_passwords.dart'; import 'package:shuffle/features/auth/domain/validation/display_name_rule.dart'; import 'package:shuffle/features/auth/domain/validation/email_format.dart'; import 'package:shuffle/features/auth/domain/validation/password_policy.dart'; import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart'; import 'package:shuffle/features/auth/presentation/register/register_controller.dart'; import 'package:shuffle/features/auth/presentation/register/register_state.dart'; import '../../../../helpers/fakes.dart'; import '../auth_ui_support.dart'; void main() { late MockAuthRepository repository; late List opened; late bool openResult; setUpAll(registerAuthFallbacks); setUp(() { repository = MockAuthRepository(); opened = []; openResult = true; when(() => repository.signUp(any())).thenAnswer((_) async => ok()); }); ProviderContainer makeContainer({ Locale locale = const Locale('de'), Future Function()? commonPasswords, }) { final container = ProviderContainer.test( overrides: authUiOverrides( repository, locale: locale, urlOpener: (uri) async { opened.add(uri); return openResult; }, commonPasswords: commonPasswords, ), ); keepAlive(container, registerControllerProvider); return container; } RegisterController notifier(ProviderContainer c) => c.read(registerControllerProvider.notifier); RegisterState stateOf(ProviderContainer c) => c.read(registerControllerProvider); void fillValid( RegisterController controller, { String email = ' anna@example.com ', String password = 'geheim-und-lang', String name = ' Anna ', }) { controller ..emailChanged(email) ..passwordChanged(password) ..displayNameChanged(name) ..ageConfirmedChanged(value: true) ..termsAcceptedChanged(value: true); } group('no personal data in logs', () { test('toString masks email, password and display name', () { final container = makeContainer(); fillValid( container.read(registerControllerProvider.notifier), email: 'anna@example.com', name: 'Anna Geheim', ); final text = container.read(registerControllerProvider).toString(); expect(text, isNot(contains('anna@example.com'))); expect(text, isNot(contains('geheim-und-lang'))); expect(text, isNot(contains('Anna Geheim'))); expect(text, contains('')); }); }); group('KTO-01 Kriterium 1: submit', () { test('KTO-01 Kriterium 11 (Client): sends all data, terms version and locale to signUp', () async { final container = makeContainer(locale: const Locale('en')); fillValid(notifier(container)); final result = await notifier(container).submit(); expect(result, isTrue); final input = verify(() => repository.signUp(captureAny())).captured.single as RegistrationInput; expect(input.email, 'anna@example.com'); expect(input.password, 'geheim-und-lang'); expect(input.displayName, 'Anna'); expect(input.ageConfirmed, isTrue); expect(input.termsAccepted, isTrue); expect(input.termsVersion, testAppConfig().termsVersion); expect(input.locale, 'en'); expect(stateOf(container).isSubmitting, isFalse); }); test('KTO-01 Kriterium 11 (Client): mail language follows the active UI language', () async { final container = makeContainer(); fillValid(notifier(container)); await notifier(container).submit(); final input = verify(() => repository.signUp(captureAny())).captured.single as RegistrationInput; expect(input.locale, 'de'); }); test('ignores a second submit while one is running', () async { final completer = Completer>(); when(() => repository.signUp(any())).thenAnswer((_) => completer.future); final container = makeContainer(); fillValid(notifier(container)); final first = notifier(container).submit(); await Future.delayed(Duration.zero); expect(stateOf(container).isSubmitting, isTrue); expect(await notifier(container).submit(), isFalse); completer.complete(ok()); expect(await first, isTrue); verify(() => repository.signUp(any())).called(1); }); }); test( 'two triggers while the password list is still loading send only once', () async { final listLoaded = Completer(); final container = makeContainer(commonPasswords: () => listLoaded.future); fillValid(notifier(container)); final first = notifier(container).submit(); final second = notifier(container).submit(); expect(stateOf(container).isSubmitting, isTrue); expect(await second, isFalse); listLoaded.complete(commonTestPasswords); expect(await first, isTrue); verify(() => repository.signUp(any())).called(1); expect(stateOf(container).isSubmitting, isFalse); }, ); test('an invalid form leaves the submitting state again', () async { final container = makeContainer(); expect(await notifier(container).submit(), isFalse); expect(stateOf(container).isSubmitting, isFalse); }); group('KTO-01 Kriterium 2: minimum age', () { test( 'without the age confirmation nothing is sent and the error shows', () async { final container = makeContainer(); fillValid(notifier(container)); notifier(container).ageConfirmedChanged(value: false); expect(await notifier(container).submit(), isFalse); verifyNever(() => repository.signUp(any())); expect(stateOf(container).shownAgeError, isTrue); expect(stateOf(container).focusField, RegisterField.ageConfirmation); }, ); test('QUE-04 Kriterium 1: without consent nothing is sent', () async { final container = makeContainer(); fillValid(notifier(container)); notifier(container).termsAcceptedChanged(value: false); expect(await notifier(container).submit(), isFalse); verifyNever(() => repository.signUp(any())); expect(stateOf(container).shownTermsError, isTrue); }); test('the error disappears when the box is ticked', () async { final container = makeContainer(); await notifier(container).submit(); expect(stateOf(container).shownAgeError, isTrue); notifier(container).ageConfirmedChanged(value: true); expect(stateOf(container).shownAgeError, isFalse); }); }); group('KTO-01 Kriterium 3: password', () { test('is checked while typing: too short names the missing count', () { final container = makeContainer(); notifier(container).passwordChanged('123456789'); expect( stateOf(container).shownPasswordError, const PasswordError.tooShort(1), ); }); test('10 characters are enough', () { final container = makeContainer(); notifier(container).passwordChanged('1234567890'); expect(stateOf(container).shownPasswordError, isNull); }); test('a common password is rejected once the list is loaded', () async { final container = makeContainer(); notifier(container).passwordChanged(' PassWort1234 '); await notifier(container).loadCommonPasswords(); expect(stateOf(container).shownPasswordError, isA()); expect(await notifier(container).submit(), isFalse); verifyNever(() => repository.signUp(any())); }); test('an empty password is shown as required after blur', () { final container = makeContainer(); notifier(container).fieldBlurred(RegisterField.password); expect(stateOf(container).shownPasswordRequired, isTrue); }); test( 'weak_password from the server is mapped to the password field', () async { when(() => repository.signUp(any())).thenAnswer( (_) async => failed( const AuthFailure.weakPassword([WeakPasswordReason.length]), ), ); final container = makeContainer(); fillValid(notifier(container)); expect(await notifier(container).submit(), isFalse); expect(stateOf(container).serverPasswordReasons, [ WeakPasswordReason.length, ]); expect(stateOf(container).bannerFailure, isNull); expect(stateOf(container).focusField, RegisterField.password); notifier(container).passwordChanged('anderes-passwort'); expect(stateOf(container).serverPasswordReasons, isNull); }, ); test('Zustände Kriterium 3: if the list cannot be loaded, only the length rule runs', () async { final container = makeContainer( commonPasswords: () => Future.error(StateError('asset missing')), ); fillValid(notifier(container), password: 'passwort1234'); expect(await notifier(container).submit(), isTrue); }); }); group('KTO-01 Kriterium 4: no hint whether the account exists', () { test('emailSendLimited counts as success (F12)', () async { when( () => repository.signUp(any()), ).thenAnswer((_) async => failed(const AuthFailure.emailSendLimited())); final container = makeContainer(); fillValid(notifier(container)); expect(await notifier(container).submit(), isTrue); expect(stateOf(container).bannerFailure, isNull); }); test('plain success leads to the same result', () async { final container = makeContainer(); fillValid(notifier(container)); expect(await notifier(container).submit(), isTrue); expect(stateOf(container).bannerFailure, isNull); }); test('over_request_rate_limit stays a visible rate-limit banner', () async { when(() => repository.signUp(any())) .thenAnswer((_) async => failed(const AuthFailure.rateLimited())); final container = makeContainer(); fillValid(notifier(container)); expect(await notifier(container).submit(), isFalse); expect(stateOf(container).bannerFailure, const AuthFailure.rateLimited()); }); }); group('KTO-01 Kriterium 10: display name', () { Future errorFor(String name) async { final container = makeContainer(); notifier(container) ..displayNameChanged(name) ..fieldBlurred(RegisterField.displayName); return stateOf(container).shownDisplayNameError; } test('empty and whitespace only are rejected', () async { expect(await errorFor(''), DisplayNameError.empty); expect(await errorFor(' '), DisplayNameError.empty); }); test('30 code points are valid, 31 are too long', () async { expect(await errorFor('a' * 30), isNull); expect(await errorFor('a' * 31), DisplayNameError.tooLong); }); test('counts code points, not UTF-16 units', () async { expect(await errorFor('😀' * 30), isNull); expect(await errorFor('😀' * 31), DisplayNameError.tooLong); }); test('the counter uses the trimmed code point length', () { final container = makeContainer(); notifier(container).displayNameChanged(' 😀😀 '); expect(stateOf(container).displayNameLength, 2); }); test('U+200B anywhere is invalid', () async { expect(await errorFor('A​B'), DisplayNameError.invalidCharacter); expect(await errorFor('​'), DisplayNameError.invalidCharacter); }); test('an invalid name blocks the submit', () async { final container = makeContainer(); fillValid(notifier(container), name: ' '); expect(await notifier(container).submit(), isFalse); verifyNever(() => repository.signUp(any())); expect(stateOf(container).focusField, RegisterField.displayName); }); }); group('KTO-01 Kriterium 1: email', () { test( 'an invalid address is shown after blur and blocks the submit', () async { final container = makeContainer(); notifier(container) ..emailChanged('anna') ..fieldBlurred(RegisterField.email); expect(stateOf(container).shownEmailError, EmailError.invalid); expect(await notifier(container).submit(), isFalse); }, ); test('invalid email from the server goes to the email field', () async { when(() => repository.signUp(any())) .thenAnswer((_) async => failed(const AuthFailure.invalidEmail())); final container = makeContainer(); fillValid(notifier(container)); await notifier(container).submit(); expect(stateOf(container).serverEmailInvalid, isTrue); expect(stateOf(container).focusField, RegisterField.email); }); }); group('F13: SMTP outage looks like success', () { test('unexpected (HTTP 5xx) continues like a 200', () async { when(() => repository.signUp(any())) .thenAnswer((_) async => failed(const AuthFailure.unexpected())); final container = makeContainer(); fillValid(notifier(container)); expect(await notifier(container).submit(), isTrue); expect(stateOf(container).bannerFailure, isNull); }); }); group('Zustände Kriterium 2, 3: failures keep the input', () { for (final failure in [ const AuthFailure.network(), const AuthFailure.registrationRejected( reason: RegistrationRejectionReason.displayName, ), ]) { test('${failure.runtimeType} becomes a banner', () async { when(() => repository.signUp(any())) .thenAnswer((_) async => failed(failure)); final container = makeContainer(); fillValid(notifier(container)); expect(await notifier(container).submit(), isFalse); final state = stateOf(container); expect(state.bannerFailure, failure); expect(state.isSubmitting, isFalse); expect(state.password, 'geheim-und-lang'); expect(state.displayName, ' Anna '); expect(state.ageConfirmed, isTrue); }); } test('a new attempt clears the banner', () async { when(() => repository.signUp(any())) .thenAnswer((_) async => failed(const AuthFailure.network())); final container = makeContainer(); fillValid(notifier(container)); await notifier(container).submit(); when(() => repository.signUp(any())).thenAnswer((_) async => ok()); expect(await notifier(container).submit(), isTrue); expect(stateOf(container).bannerFailure, isNull); }); }); group('QUE-04 Kriterium 1: legal texts', () { test('open the URLs from the app config', () async { final container = makeContainer(); expect( await notifier(container).openLegalDocument(LegalDocument.privacy), isTrue, ); await notifier(container).openLegalDocument(LegalDocument.terms); expect(opened, [ Uri.parse(testAppConfig().legalPrivacyUrl), Uri.parse(testAppConfig().legalTermsUrl), ]); }); test('report a failure to open', () async { openResult = false; final container = makeContainer(); expect( await notifier(container).openLegalDocument(LegalDocument.terms), isFalse, ); }); }); test('factory provider reads terms version and locale', () { final container = makeContainer(); final input = container.read(registrationInputFactoryProvider)( email: 'a@b.de', password: 'x', displayName: 'n', ageConfirmed: true, termsAccepted: true, ); expect(input.termsVersion, 'test-1'); expect(input.locale, 'de'); }); }