import 'dart:async'; 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/presentation/check_email/resend_controller.dart'; import 'package:shuffle/features/auth/presentation/login/login_controller.dart'; import 'package:shuffle/features/auth/presentation/login/login_state.dart'; import '../auth_ui_support.dart'; void main() { late MockAuthRepository repository; late ProviderContainer container; setUpAll(registerAuthFallbacks); setUp(() { repository = MockAuthRepository(); container = ProviderContainer.test(overrides: authUiOverrides(repository)); keepAlive(container, loginControllerProvider); keepAlive(container, resendControllerProvider); }); LoginController notifier() => container.read(loginControllerProvider.notifier); LoginState state() => container.read(loginControllerProvider); void stubSignIn(Result result) => when(() => repository.signIn(any(), any())) .thenAnswer((_) async => result); void fill([String email = ' Anna@Example.com ', String password = 'pw']) { notifier() ..emailChanged(email) ..passwordChanged(password); } group('KTO-01 Kriterium 5: sign in', () { test('sends the trimmed address, continues to the start page', () async { stubSignIn(ok()); fill(); final target = await notifier().submit(); verify(() => repository.signIn('Anna@Example.com', 'pw')).called(1); expect(target, '/'); // The form is left after success; it is not unlocked again. expect(state().isSubmitting, isTrue); expect(state().bannerFailure, isNull); }); test('continues to a safe from path', () async { stubSignIn(ok()); fill(); expect(await notifier().submit(from: '/games/7?tab=1'), '/games/7?tab=1'); }); for (final unsafe in [ 'https://evil.example/x', '//evil.example', r'/\evil.example', 'javascript:alert(1)', 'games', '/login', '/register', '/reset-password', '/auth/confirm?token_hash=x', '/%2F%2Fevil.example', '%2F%2Fevil', '/\t/evil.example', '/%5Cevil.example', '', ]) { test('ignores the unsafe from "$unsafe"', () async { stubSignIn(ok()); fill(); expect(await notifier().submit(from: unsafe), '/'); }); } test('empty fields are not sent and ask for focus', () async { await notifier().submit(); verifyNever(() => repository.signIn(any(), any())); expect(state().shownEmailError, isTrue); expect(state().shownPasswordRequired, isTrue); expect(state().focusField, LoginField.email); expect(state().focusRequest, 1); expect(state().isSubmitting, isFalse); }); test('an invalid address is not sent', () async { fill('anna', 'pw'); expect(await notifier().submit(), isNull); verifyNever(() => repository.signIn(any(), any())); expect(state().shownEmailError, isTrue); expect(state().shownPasswordRequired, isFalse); }); test('blur shows the error of that field only', () { notifier().fieldBlurred(LoginField.password); expect(state().shownPasswordRequired, isTrue); expect(state().shownEmailError, isFalse); }); test('a second submit while one runs is ignored', () async { final completer = Completer>(); when(() => repository.signIn(any(), any())) .thenAnswer((_) => completer.future); fill(); final first = notifier().submit(); await Future.delayed(Duration.zero); expect(state().isSubmitting, isTrue); expect(await notifier().submit(), isNull); completer.complete(ok()); await first; verify(() => repository.signIn(any(), any())).called(1); }); }); group('no credentials in logs', () { test('toString masks email and password', () { fill('anna@example.com', 'geheim-und-lang'); final text = state().toString(); expect(text, isNot(contains('anna@example.com'))); expect(text, isNot(contains('geheim-und-lang'))); expect(text, contains('')); }); }); group('KTO-01 Kriterium 7: failures', () { test('invalid credentials keep the input and show the failure', () async { stubSignIn(failed(const AuthFailure.invalidCredentials())); fill(); expect(await notifier().submit(), isNull); expect(state().bannerFailure, const AuthFailure.invalidCredentials()); expect(state().email, ' Anna@Example.com '); expect(state().password, 'pw'); expect(state().shownEmailError, isFalse); expect(state().shownPasswordRequired, isFalse); expect(state().showsNotConfirmed, isFalse); }); for (final failure in const [ AuthFailure.rateLimited(), AuthFailure.network(), AuthFailure.unexpected(), ]) { test('${failure.runtimeType} is shown as banner', () async { stubSignIn(failed(failure)); fill(); await notifier().submit(); expect(state().bannerFailure, failure); expect(state().isSubmitting, isFalse); }); } test('a new attempt clears the old failure', () async { stubSignIn(failed(const AuthFailure.invalidCredentials())); fill(); await notifier().submit(); stubSignIn(ok()); await notifier().submit(); expect(state().bannerFailure, isNull); }); }); group('KTO-01 Kriterium 6: unconfirmed account', () { setUp(() { stubSignIn(failed(const AuthFailure.emailNotConfirmed())); fill(); }); test('marks the address for resend', () async { await notifier().submit(); expect(state().showsNotConfirmed, isTrue); expect(state().unconfirmedEmail, 'Anna@Example.com'); }); test('resend uses the address of the attempt, not the field now', () async { when(() => repository.resendConfirmation(any())) .thenAnswer((_) async => ok()); await notifier().submit(); notifier().emailChanged('other@example.com'); await notifier().resendConfirmation(); verify(() => repository.resendConfirmation('Anna@Example.com')).called(1); expect(container.read(resendControllerProvider).sent, isTrue); }); test('emailSendLimited shows the throttle hint (login origin)', () async { when( () => repository.resendConfirmation(any()), ).thenAnswer((_) async => failed(const AuthFailure.emailSendLimited())); await notifier().submit(); await notifier().resendConfirmation(); final resend = container.read(resendControllerProvider); expect(resend.failure, const AuthFailure.rateLimited()); expect(resend.sent, isFalse); }); test('resend without a pending attempt does nothing', () async { await notifier().resendConfirmation(); verifyNever(() => repository.resendConfirmation(any())); }); test('a new attempt resets the resend result', () async { when(() => repository.resendConfirmation(any())) .thenAnswer((_) async => ok()); await notifier().submit(); await notifier().resendConfirmation(); expect(container.read(resendControllerProvider).sent, isTrue); await notifier().submit(); expect(container.read(resendControllerProvider).sent, isFalse); }); }); }