import 'dart:async'; import 'package:flutter/material.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:go_router/go_router.dart'; import 'package:mocktail/mocktail.dart'; import 'package:shuffle/core/routing/app_router.dart'; import 'package:shuffle/features/auth/domain/auth_status.dart'; import 'package:shuffle/features/auth/domain/registration_input.dart'; import 'package:shuffle/features/auth/presentation/providers/auth_providers.dart'; import 'package:shuffle/features/auth/presentation/providers/auth_redirect.dart'; import 'package:supabase_flutter/supabase_flutter.dart' hide AuthUser; import '../../../helpers/fakes.dart'; import '../../../helpers/pump_app.dart'; import '../../../helpers/router_helpers.dart'; import '../data/auth_test_support.dart'; import 'auth_ui_support.dart' show commonTestPasswords; void main() { late SupabaseMocks mocks; late StreamController events; late Session? session; late int signedOutCalls; setUp(() { mocks = SupabaseMocks(); events = StreamController.broadcast(sync: true); session = null; signedOutCalls = 0; when(() => mocks.auth.currentSession).thenAnswer((_) => session); when(() => mocks.auth.onAuthStateChange).thenAnswer((_) => events.stream); }); tearDown(() => events.close()); void emit(AuthChangeEvent event, {Session? withSession}) { session = withSession; events.add(AuthState(event, withSession)); } Future start(WidgetTester tester) async { final container = await pumpShuffleApp( tester, overrides: testOverrides( supabase: mocks.client, overrides: [ ...authOverrides(), // No asset loading in router tests. commonPasswordListProvider.overrideWith( (ref) => Future.value(commonTestPasswords), ), privateRoutePathsProvider.overrideWithValue(const ['/private']), signedOutHandlersProvider.overrideWithValue([ (ref) => signedOutCalls++, ]), ], ), ); return (container: container, router: container.read(appRouterProvider)); } group('KTO-01 Kriterium 5: authStatusProvider', () { testWidgets('maps events and starts with the stored session', ( tester, ) async { session = testSession(); final (:container, router: _) = await start(tester); expect(container.read(authStatusProvider).value, isA()); emit(AuthChangeEvent.signedOut); await tester.pumpAndSettle(); expect(container.read(authStatusProvider).value, isA()); emit(AuthChangeEvent.signedIn, withSession: testSession()); await tester.pumpAndSettle(); expect(container.read(authStatusProvider).value, isA()); }); }); group('Sicherheit Kriterium 2: router redirects', () { testWidgets('private route while signed out -> /login?from=', ( tester, ) async { final (container: _, :router) = await start(tester); router.go('/private?tab=1'); await tester.pumpAndSettle(); expect(currentPath(router), '/login'); expect(currentUri(router).queryParameters['from'], '/private?tab=1'); expect(find.byKey(const Key('login_screen')), findsOneWidget); }); testWidgets('signing in on /login returns to from', (tester) async { final (container: _, :router) = await start(tester); router.go('/private'); await tester.pumpAndSettle(); emit(AuthChangeEvent.signedIn, withSession: testSession()); await tester.pumpAndSettle(); expect(currentPath(router), '/private'); }); testWidgets('signed in: login and register lead to the start page', ( tester, ) async { session = testSession(); final (container: _, :router) = await start(tester); router.go('/login'); await tester.pumpAndSettle(); expect(currentPath(router), '/'); router.go('/register'); await tester.pumpAndSettle(); expect(currentPath(router), '/'); }); testWidgets('routes of spec 5.3 exist', (tester) async { final (container: _, :router) = await start(tester); for (final (path, key) in [ ('/login', 'login_screen'), ('/register', 'register_screen'), ('/register/check-email', 'check_email_screen'), ('/forgot-password', 'forgot_password_screen'), ('/forgot-password/check-email', 'check_email_screen'), ('/auth/confirm?token_hash=x&type=email', 'email_link_screen'), ]) { router.go(path); await tester.pumpAndSettle(); expect(find.byKey(Key(key)), findsOneWidget, reason: path); } }); }); group('KTO-01 Kriterium 8, 9: recovery guard and sign-out hook', () { testWidgets('recovery forces /reset-password until the password changed', ( tester, ) async { final (container: _, :router) = await start(tester); emit(AuthChangeEvent.passwordRecovery, withSession: testSession()); await tester.pumpAndSettle(); expect(currentPath(router), '/reset-password'); expect(find.byKey(const Key('reset_password_screen')), findsOneWidget); router.go('/'); await tester.pumpAndSettle(); expect(currentPath(router), '/reset-password'); emit(AuthChangeEvent.tokenRefreshed, withSession: testSession()); await tester.pumpAndSettle(); expect(currentPath(router), '/reset-password'); // The password change alone does not release the guard: the success // message must stay visible (KTO-01 Kriterium 9). emit(AuthChangeEvent.userUpdated, withSession: testSession()); await tester.pumpAndSettle(); expect(currentPath(router), '/reset-password'); expect( find.byKey(const Key('reset_password_success_banner')), findsOneWidget, ); router.go('/'); await tester.pumpAndSettle(); expect(currentPath(router), '/reset-password'); }); testWidgets('aborting the recovery (signOut) leaves /reset-password', ( tester, ) async { final (container: _, :router) = await start(tester); emit(AuthChangeEvent.passwordRecovery, withSession: testSession()); await tester.pumpAndSettle(); emit(AuthChangeEvent.signedOut); await tester.pumpAndSettle(); expect(currentPath(router), '/login'); }); testWidgets('onSignedOut runs on signed-in -> signed-out only', ( tester, ) async { await start(tester); expect(signedOutCalls, 0, reason: 'initial signed-out state'); emit(AuthChangeEvent.signedIn, withSession: testSession()); await tester.pumpAndSettle(); expect(signedOutCalls, 0); emit(AuthChangeEvent.signedOut); await tester.pumpAndSettle(); expect(signedOutCalls, 1); emit(AuthChangeEvent.signedOut); await tester.pumpAndSettle(); expect(signedOutCalls, 1, reason: 'no transition'); }); testWidgets('a failed refresh on a private route ends on /login', ( tester, ) async { session = testSession(); final (container: _, :router) = await start(tester); router.go('/private'); await tester.pumpAndSettle(); expect(currentPath(router), '/private'); emit(AuthChangeEvent.signedOut); await tester.pumpAndSettle(); expect(currentPath(router), '/login'); expect(signedOutCalls, 1); }); }); group('KTO-01 Kriterium 8, 9: password reset flow through the router', () { void stubGoTrue() { registerFallbackValue(OtpType.recovery); registerFallbackValue(UserAttributes()); when( () => mocks.auth.verifyOTP( type: any(named: 'type'), tokenHash: any(named: 'tokenHash'), ), ).thenAnswer((_) async { // gotrue emits passwordRecovery for a redeemed recovery link. emit(AuthChangeEvent.passwordRecovery, withSession: testSession()); return AuthResponse(session: testSession()); }); when(() => mocks.auth.updateUser(any())).thenAnswer((_) async { // gotrue emits userUpdated before updateUser returns. emit(AuthChangeEvent.userUpdated, withSession: testSession()); return UserResponse.fromJson(testUser().toJson()); }); when(() => mocks.auth.signOut()).thenAnswer((_) async { emit(AuthChangeEvent.signedOut); }); } Future redeemRecoveryLink( WidgetTester tester, GoRouter router, ) async { router.go('/auth/confirm?token_hash=abc&type=recovery'); await tester.pumpAndSettle(); expect(currentPath(router), '/auth/confirm'); await tester.tap(find.byKey(const Key('email_link_submit'))); await tester.pumpAndSettle(); } Future savePassword(WidgetTester tester, String password) async { await tester.enterText( find.descendant( of: find.byKey(const Key('reset_password_password')), matching: find.byType(TextField), ), password, ); await tester.pump(); await tester.tap(find.byKey(const Key('reset_password_submit'))); await tester.pumpAndSettle(); } testWidgets('link -> new password -> message stays -> continue -> start', ( tester, ) async { stubGoTrue(); final (container: _, :router) = await start(tester); await redeemRecoveryLink(tester, router); expect(currentPath(router), '/reset-password'); expect(find.byKey(const Key('reset_password_screen')), findsOneWidget); // Navigating elsewhere is blocked while the guard holds. router.go('/'); await tester.pumpAndSettle(); expect(currentPath(router), '/reset-password'); await savePassword(tester, 'ein-langes-passwort'); verify(() => mocks.auth.updateUser(any())).called(1); // The status moved to "changed", but the page and its message stay. expect(currentPath(router), '/reset-password'); expect( find.byKey(const Key('reset_password_success_banner')), findsOneWidget, ); router.go('/'); await tester.pumpAndSettle(); expect(currentPath(router), '/reset-password'); await tester.tap(find.byKey(const Key('reset_password_continue'))); await tester.pumpAndSettle(); expect(currentPath(router), '/'); // The session stays: the user is signed in with the new password. router.go('/login'); await tester.pumpAndSettle(); expect(currentPath(router), '/'); verifyNever(() => mocks.auth.signOut()); }); testWidgets('cancel signs out and leads to /login', (tester) async { stubGoTrue(); final (container: _, :router) = await start(tester); await redeemRecoveryLink(tester, router); expect(currentPath(router), '/reset-password'); await tester.tap(find.byKey(const Key('reset_password_cancel'))); await tester.pumpAndSettle(); verify(() => mocks.auth.signOut()).called(1); expect(currentPath(router), '/login'); }); testWidgets('a signed-in user cannot open the reset page', (tester) async { session = testSession(); final (container: _, :router) = await start(tester); router.go('/reset-password'); await tester.pumpAndSettle(); expect(currentPath(router), '/'); }); }); group('KTO-01 Kriterium 1: registrationInputFactory', () { testWidgets('fills terms_version from AppConfig and the app language', ( tester, ) async { final (:container, router: _) = await start(tester); final input = container.read(registrationInputFactoryProvider)( email: 'a@b.de', password: 'pw', displayName: 'Anna', ageConfirmed: true, termsAccepted: true, ); expect( input, const RegistrationInput( email: 'a@b.de', password: 'pw', displayName: 'Anna', ageConfirmed: true, termsAccepted: true, termsVersion: 'test-1', locale: 'en', ), ); }); }); } typedef ProviderContainerAndRouter = ({ ProviderContainer container, GoRouter router, });