From 5f0b7e026be11096e2dc1997b6b2aba1d38b1769 Mon Sep 17 00:00:00 2001 From: alex-chevski Date: Fri, 30 Oct 2026 15:15:21 +0300 Subject: [PATCH 1/3] fix(profile): refresh bottom-nav avatar on account switch Reset ProfileAvatarCubit and user-scoped feed, chat and notification state so stale data from the previous account cannot be rendered. Refresh the avatar when the stored token changes and rebuild nav tabs when the profile identity or consultant role changes. Refs: #1113 --- lib/core/routing/main_navigation_routes.dart | 11 +-- .../new_messages_cubit.dart | 7 ++ .../profile_avatar_cubit.dart | 75 ++++++++++++++++--- .../widgets/bottom_navigation_bar_widget.dart | 13 +++- .../bloc/chats_cubit/chats_cubit.dart | 11 +++ .../bloc/feed_cubit/feed_cubit.dart | 28 +++++-- .../notifications_cubit.dart | 5 ++ .../bloc/profile_cubit/profile_cubit.dart | 4 + 8 files changed, 129 insertions(+), 25 deletions(-) diff --git a/lib/core/routing/main_navigation_routes.dart b/lib/core/routing/main_navigation_routes.dart index d0746c88..36ca1a71 100644 --- a/lib/core/routing/main_navigation_routes.dart +++ b/lib/core/routing/main_navigation_routes.dart @@ -1,6 +1,5 @@ import 'package:eatvio_app/core/routing/route_constants.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; -import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_nav_scope.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart'; import 'package:eatvio_app/features/chat/presentation/pages/chats_screen.dart'; @@ -24,14 +23,10 @@ class MainNavigationRoutes { return [ ShellRoute( builder: (context, state, child) { - // Инициализация ProfileAvatarCubit при первом использовании ShellRoute final profileAvatarCubit = context.read(); - if (profileAvatarCubit.state is ProfileAvatarStateLoading) { - // Вызываем getMyProfile() только если состояние loading (первый запуск) - WidgetsBinding.instance.addPostFrameCallback((_) { - profileAvatarCubit.getMyProfile(); - }); - } + WidgetsBinding.instance.addPostFrameCallback((_) { + profileAvatarCubit.ensureFreshForCurrentSession(); + }); return _MainShellWithNavScope(currentLocation: state.matchedLocation, child: child); }, diff --git a/lib/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart b/lib/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart index 8aef9871..8293bdc7 100644 --- a/lib/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart +++ b/lib/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart @@ -6,6 +6,13 @@ class NewMessagesCubit extends Cubit { NewMessagesCubit() : super(const NewMessagesState.empty()); + void reset() { + _messagesCount = 0; + if (!isClosed) { + emit(const NewMessagesState.empty()); + } + } + void incrementCounter() { _messagesCount++; emit(NewMessagesState.loaded(messagesCount: _messagesCount)); diff --git a/lib/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart b/lib/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart index 2a51eca4..ffde009e 100644 --- a/lib/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart +++ b/lib/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart @@ -1,18 +1,22 @@ import 'dart:async'; import 'package:appmetrica_plugin/appmetrica_plugin.dart'; -import 'package:eatvio_app/core/app_router.dart'; +import 'package:eatvio_app/core/token_storage.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart'; import 'package:eatvio_app/features/profile/repositories/profile_repository.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:eatvio_app/core/error_reporting/report_from.dart'; -import 'package:go_router/go_router.dart'; import 'package:permission_handler/permission_handler.dart'; class ProfileAvatarCubit extends Cubit { final ProfileRepository profileRepository; - ProfileAvatarCubit({required this.profileRepository}) : super(const ProfileAvatarState.loading()); + ProfileAvatarCubit({required this.profileRepository}) + : super(const ProfileAvatarState.loading()); + + int _requestId = 0; + int? _sessionFingerprint; + bool _ensuring = false; int _calculateAge(String birthdate) { try { @@ -25,7 +29,8 @@ class ProfileAvatarCubit extends Cubit { final now = DateTime.now(); int age = now.year - birthYear; - if (now.month < birthMonth || (now.month == birthMonth && now.day < birthDay)) { + if (now.month < birthMonth || + (now.month == birthMonth && now.day < birthDay)) { age--; } @@ -38,30 +43,82 @@ class ProfileAvatarCubit extends Cubit { return 0; } + void reset() { + _requestId++; + _sessionFingerprint = null; + unawaited(AppMetrica.setUserProfileID(null).catchError((Object _) {})); + if (!isClosed) emit(const ProfileAvatarState.loading()); + } + + Future ensureFreshForCurrentSession() async { + final token = await TokenStorage.getTokenFromStorage(); + if (token == null || token.isEmpty) { + reset(); + return; + } + + final fingerprint = token.hashCode; + if (_sessionFingerprint == fingerprint && + state is ProfileAvatarStateLoaded) { + return; + } + + if (_ensuring) { + return; + } + + _ensuring = true; + try { + await getMyProfile(); + } finally { + _ensuring = false; + } + } + Future getMyProfile() async { + final requestId = ++_requestId; + final token = await TokenStorage.getTokenFromStorage(); + final fingerprint = token?.hashCode; + if (isClosed || requestId != _requestId) return; + emit(const ProfileAvatarState.loading()); try { final profile = await profileRepository.getMyProfile(); + if (isClosed || requestId != _requestId) return; + final profileDetails = await profileRepository.getMyProfileDetails(); + if (isClosed || requestId != _requestId) return; + + _sessionFingerprint = fingerprint; emit(ProfileAvatarState.loaded(profile: profile)); + try { final notificationStatus = await Permission.notification.status; + if (isClosed || requestId != _requestId) return; final isNotificationEnabled = notificationStatus.isGranted; AppMetricaUserProfile userProfile = AppMetricaUserProfile([ AppMetricaNameAttribute.withValue(profile.name), - AppMetricaGenderAttribute.withValue(profileDetails.gender == 'male' ? AppMetricaGender.male : AppMetricaGender.female), - AppMetricaBirthDateAttribute.withAge(_calculateAge(profileDetails.birthdate)), - AppMetricaNotificationEnabledAttribute.withValue(isNotificationEnabled), + AppMetricaGenderAttribute.withValue( + profileDetails.gender == 'male' + ? AppMetricaGender.male + : AppMetricaGender.female, + ), + AppMetricaBirthDateAttribute.withAge( + _calculateAge(profileDetails.birthdate), + ), + AppMetricaNotificationEnabledAttribute.withValue( + isNotificationEnabled, + ), ]); AppMetrica.setUserProfileID(profile.slug); AppMetrica.reportUserProfile(userProfile); } catch (e, st) { await reportFrom(e, st, this); - // Игнорируем ошибки плагинов, профиль уже загружен } } catch (e, st) { + if (isClosed || requestId != _requestId) return; await reportFrom(e, st, this); - AppRouter.navigatorKey.currentContext?.go('/auth-screen'); + if (isClosed || requestId != _requestId) return; emit(ProfileAvatarState.error(errorMessage: e.toString())); } } diff --git a/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart b/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart index c3317d8c..b46ae479 100644 --- a/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart +++ b/lib/features/bottom_navigation_bar/presentation/widgets/bottom_navigation_bar_widget.dart @@ -39,6 +39,8 @@ class BottomNavigationBarWidget extends StatefulWidget { class _BottomNavigationBarWidgetState extends State { int currentBottomNavBarIndex = 1; List? pages; + int? _pagesProfileId; + bool? _pagesIsConsultant; int tapsCount = 0; List _getPages(bool isConsultant) { @@ -179,7 +181,16 @@ class _BottomNavigationBarWidgetState extends State { final isConsultant = profileModel.roles.contains('consultant'); // Инициализируем pages на основе profileModel - pages ??= _getPages(isConsultant); + if (pages == null || + _pagesProfileId != profileModel.id || + _pagesIsConsultant != isConsultant) { + _pagesProfileId = profileModel.id; + _pagesIsConsultant = isConsultant; + pages = _getPages(isConsultant); + if (currentBottomNavBarIndex >= pages!.length) { + currentBottomNavBarIndex = 0; + } + } // Expose tab switching to descendants (e.g. notification widget) final controller = BottomNavScope.maybeOf(context); diff --git a/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart b/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart index 499766f6..363d02fd 100644 --- a/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart +++ b/lib/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart @@ -57,6 +57,17 @@ class ChatsCubit extends Cubit { int page = 1; late int lastPage; + /// Clears all user-scoped state. WS subscriptions stay intact so that + /// `getChatRoomsWithLoading` can re-load the new user's chats. + void reset() { + _query = null; + page = 1; + lastPage = 1; + if (!isClosed) { + emit(ChatsState()); + } + } + Future connectToWebSocket() async { try { await newIncomingMessagesSubscription?.cancel(); diff --git a/lib/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart b/lib/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart index a88d4623..0aa05206 100644 --- a/lib/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart +++ b/lib/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart @@ -12,9 +12,19 @@ class FeedCubit extends Cubit { bool _hasMore = true; bool isLoading = false; String? _query; + int _requestId = 0; FeedCubit({required this.feedRepository}) : super(const FeedState.loading()); + void reset() { + _requestId++; + _nextCursor = null; + _hasMore = true; + isLoading = false; + _query = null; + if (!isClosed) emit(const FeedState.loading()); + } + void updateFeedBlogPostList(FeedBlogPostModel? feedBlogPostModel, bool wasLikedByMe) { if (feedBlogPostModel == null || isClosed || state is! FeedStateLoaded) return; final List feedBlogPostList = [...(state as FeedStateLoaded).feedBlogPostList]; @@ -39,17 +49,18 @@ class FeedCubit extends Cubit { } Future loadFeedBlogPostList({String? query, int? currentUserId}) async { + final requestId = ++_requestId; try { _query = query; _nextCursor = null; _hasMore = true; final feed = await feedRepository.getFeed(search: query, currentUserId: currentUserId); + if (isClosed || requestId != _requestId) return; _nextCursor = feed.nextCursor; _hasMore = _nextCursor != null; - if (!isClosed) { - emit(FeedState.loaded(feedBlogPostList: feed.posts, isLoading: false)); - } + emit(FeedState.loaded(feedBlogPostList: feed.posts, isLoading: false)); } catch (e, st) { + if (isClosed || requestId != _requestId) return; await reportFrom(e, st, this); if (!isClosed) { emit(FeedState.error(errorMessage: e.toString())); @@ -62,14 +73,16 @@ class FeedCubit extends Cubit { return; } + final requestId = _requestId; isLoading = true; emit((state as FeedStateLoaded).copyWith(isLoading: true)); try { final feed = await feedRepository.getFeed(search: _query, cursor: _nextCursor, currentUserId: currentUserId); + if (isClosed || requestId != _requestId) return; _nextCursor = feed.nextCursor; _hasMore = _nextCursor != null; - if (!isClosed && state is FeedStateLoaded) { + if (state is FeedStateLoaded) { emit( FeedState.loaded( feedBlogPostList: [...(state as FeedStateLoaded).feedBlogPostList, ...feed.posts], @@ -78,14 +91,15 @@ class FeedCubit extends Cubit { ); } } catch (e, st) { + if (isClosed || requestId != _requestId) return; await reportFrom(e, st, this); - if (!isClosed && state is FeedStateLoaded) { + if (state is FeedStateLoaded) { emit((state as FeedStateLoaded).copyWith(isLoading: false)); - } else if (!isClosed) { + } else { emit(FeedState.error(errorMessage: e.toString())); } } finally { - isLoading = false; + if (requestId == _requestId) isLoading = false; } } diff --git a/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart b/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart index a3c5827d..c750c513 100644 --- a/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart +++ b/lib/features/notifications/presentation/notifications_cubit/notifications_cubit.dart @@ -16,6 +16,11 @@ class NotificationsCubit extends Cubit { StreamSubscription? _newNotificationsSubscription; NotificationsCubit({required this.wsManager, required this.notificationsRepository}) : super(const NotificationsState.empty()); + void reset() { + _notifications.clear(); + if (!isClosed) emit(const NotificationsState.empty()); + } + Future connectToWebSocket() async { try { await _newNotificationsSubscription?.cancel(); diff --git a/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart b/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart index 2801c2b4..c85a7e63 100644 --- a/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart +++ b/lib/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart @@ -22,6 +22,10 @@ class ProfileCubit extends Cubit { /// Bottom-nav / own profile instance, used to refresh posts after create/update/delete. static ProfileCubit? myProfileCubit; + static void clearMyProfileReference() { + myProfileCubit = null; + } + ProfileCubit({required this.profileRepository, required this.chatRepository, required this.consultantRepository}) : super(const ProfileState.loading()); Future getProfileBySlug(String slug) async { From cba2689406e0d3477a243f30e4dfa03e9c2a2bb1 Mon Sep 17 00:00:00 2001 From: alex-chevski Date: Fri, 30 Oct 2026 15:15:28 +0300 Subject: [PATCH 2/3] feat(session): centralize session teardown and refresh Add SessionLifecycleService to reset user-scoped cubits, analytics, socket id and account-scoped preferences on logout, 401 or account deletion. Run the same refresh after every successful auth path, including registration v1/v2, and ignore 401 responses from stale tokens so an old request cannot terminate the new user's session. Refs: #1113 --- lib/core/di/cubit_registrations.dart | 26 +++- lib/core/di/session_registrations.dart | 15 +++ lib/core/interceptors/token_interceptor.dart | 20 ++- lib/core/locator_service.dart | 3 + .../session/session_lifecycle_service.dart | 122 ++++++++++++++++++ .../bloc/auth_cubit/auth_cubit.dart | 19 ++- .../bloc/oauth_cubit/oauth_cubit.dart | 8 +- .../auth/presentation/pages/auth_screen.dart | 38 +----- .../pages/register_via_social_screen.dart | 27 +--- .../pages/scan_qr_code_screen.dart | 47 +++---- .../bloc/menu_cubit/menu_cubit.dart | 12 +- .../menu/presentation/pages/menu_screen.dart | 29 +---- .../registration_cubit.dart | 9 +- .../cubit/registration_cubit.dart | 5 +- .../bloc/settings_cubit/settings_cubit.dart | 53 +++++--- .../presentation/pages/settings_screen.dart | 1 - 16 files changed, 288 insertions(+), 146 deletions(-) create mode 100644 lib/core/di/session_registrations.dart create mode 100644 lib/core/session/session_lifecycle_service.dart diff --git a/lib/core/di/cubit_registrations.dart b/lib/core/di/cubit_registrations.dart index 05bfd7d1..8428bae8 100644 --- a/lib/core/di/cubit_registrations.dart +++ b/lib/core/di/cubit_registrations.dart @@ -97,6 +97,7 @@ class CubitRegistrations { systemPipCoordinator: sl(), ), ); + sl.registerLazySingleton(() => FeedCubit(feedRepository: sl())); } /// Регистрация Cubit'ов как фабрик @@ -108,10 +109,15 @@ class CubitRegistrations { firebaseApi: sl(), prefs: sl(), profileRepository: sl(), + sessionLifecycleService: sl(), ), ); sl.registerFactory( - () => RegistrationCubit(registrationRepository: sl(), prefs: sl()), + () => RegistrationCubit( + registrationRepository: sl(), + prefs: sl(), + sessionLifecycleService: sl(), + ), ); sl.registerFactory( () => ResetPasswordCubit(resetPasswordRepository: sl()), @@ -120,14 +126,21 @@ class CubitRegistrations { () => VerifyEmailCubit(profileRepository: sl(), prefs: sl()), ); sl.registerFactory( - () => OAuthCubit(authRepository: sl(), firebaseApi: sl(), prefs: sl()), + () => OAuthCubit( + authRepository: sl(), + firebaseApi: sl(), + prefs: sl(), + sessionLifecycleService: sl(), + ), ); sl.registerFactory( - () => - RegistrationCubitV2(registrationRepository: sl(), firebaseApi: sl()), + () => RegistrationCubitV2( + registrationRepository: sl(), + firebaseApi: sl(), + sessionLifecycleService: sl(), + ), ); // Feed related cubits - sl.registerFactory(() => FeedCubit(feedRepository: sl())); sl.registerFactory( () => RecipesCubit( feedRepository: sl(), @@ -242,6 +255,7 @@ class CubitRegistrations { settingsRepository: sl(), consultantRepository: sl(), consultationsRepository: sl(), + sessionLifecycleService: sl(), ), ); sl.registerFactory( @@ -297,7 +311,7 @@ class CubitRegistrations { sl.registerFactory( () => UserDetailsCubit(profileRepository: sl()), ); - sl.registerFactory(() => MenuCubit(sl())); + sl.registerFactory(() => MenuCubit(sl(), sl())); sl.registerFactory(() => MarketingCubit(sl())); sl.registerFactory( () => PersonalDietSuggestionCubit( diff --git a/lib/core/di/session_registrations.dart b/lib/core/di/session_registrations.dart new file mode 100644 index 00000000..04e27f60 --- /dev/null +++ b/lib/core/di/session_registrations.dart @@ -0,0 +1,15 @@ +import 'package:eatvio_app/core/error_reporting/error_reporter.dart'; +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/session/session_lifecycle_service.dart'; +import 'package:eatvio_app/core/websockets/ws_manager.dart'; + +class SessionRegistrations { + static void registerAll() { + sl.registerLazySingleton( + () => SessionLifecycleService( + wsManager: sl(), + errorReporter: sl(), + ), + ); + } +} diff --git a/lib/core/interceptors/token_interceptor.dart b/lib/core/interceptors/token_interceptor.dart index 05f6be0c..2d65d04f 100644 --- a/lib/core/interceptors/token_interceptor.dart +++ b/lib/core/interceptors/token_interceptor.dart @@ -1,9 +1,7 @@ import 'package:dio/dio.dart'; -import 'package:eatvio_app/core/app_router.dart'; +import 'package:eatvio_app/core/session/session_lifecycle_service.dart'; import 'package:eatvio_app/core/token_storage.dart'; -import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/core/locator_service.dart'; -import 'package:go_router/go_router.dart'; class TokenInterceptor extends Interceptor { @override @@ -14,6 +12,7 @@ class TokenInterceptor extends Interceptor { final token = await TokenStorage.getTokenFromStorage(); if (token != null && token.isNotEmpty) { options.headers['Authorization'] = 'Bearer $token'; + options.extra['authToken'] = token; } super.onRequest(options, handler); @@ -25,9 +24,18 @@ class TokenInterceptor extends Interceptor { ErrorInterceptorHandler handler, ) async { if (err.response?.statusCode == 401) { - await sl().clear(); - await TokenStorage.removeTokenInStorage(); - AppRouter.navigatorKey.currentContext?.go('/auth-screen'); + final requestCarriedAuth = err.requestOptions.headers.containsKey( + 'Authorization', + ); + final requestToken = err.requestOptions.extra['authToken']; + final currentToken = await TokenStorage.getTokenFromStorage(); + if (requestCarriedAuth && + requestToken != null && + requestToken == currentToken) { + await sl().endSession( + reason: SessionEndReason.unauthorized, + ); + } } super.onError(err, handler); } diff --git a/lib/core/locator_service.dart b/lib/core/locator_service.dart index cb2e63cc..46128cd3 100644 --- a/lib/core/locator_service.dart +++ b/lib/core/locator_service.dart @@ -5,6 +5,7 @@ import 'package:eatvio_app/core/di/cubit_registrations.dart'; import 'package:eatvio_app/core/di/external_registrations.dart'; import 'package:eatvio_app/core/di/repository_registrations.dart'; import 'package:eatvio_app/core/di/service_registrations.dart'; +import 'package:eatvio_app/core/di/session_registrations.dart'; import 'package:eatvio_app/core/prefs.dart'; import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:get_it/get_it.dart'; @@ -38,4 +39,6 @@ Future init({required AppBuildType buildType}) async { sl.registerLazySingleton( () => WsManager(wsService: sl(), errorReporter: sl()), ); + + SessionRegistrations.registerAll(); } diff --git a/lib/core/session/session_lifecycle_service.dart b/lib/core/session/session_lifecycle_service.dart new file mode 100644 index 00000000..1019a338 --- /dev/null +++ b/lib/core/session/session_lifecycle_service.dart @@ -0,0 +1,122 @@ +import 'dart:async'; + +import 'package:appmetrica_plugin/appmetrica_plugin.dart'; +import 'package:dio/dio.dart'; +import 'package:eatvio_app/core/app_config.dart'; +import 'package:eatvio_app/core/app_router.dart'; +import 'package:eatvio_app/core/error_reporting/error_reporter.dart'; +import 'package:eatvio_app/core/firebase_api.dart'; +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/prefs.dart'; +import 'package:eatvio_app/core/prefs_keys.dart'; +import 'package:eatvio_app/core/routing/route_constants.dart'; +import 'package:eatvio_app/core/token_storage.dart'; +import 'package:eatvio_app/core/websockets/ws_manager.dart'; +import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart'; +import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; +import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; +import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; +import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart'; +import 'package:eatvio_app/features/profile/presentation/bloc/profile_cubit/profile_cubit.dart'; +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; +import 'package:go_router/go_router.dart'; + +enum SessionEndReason { logout, unauthorized, accountDeleted } + +class SessionLifecycleService { + SessionLifecycleService({ + required WsManager wsManager, + required ErrorReporter errorReporter, + }) : _wsManager = wsManager, + _errorReporter = errorReporter; + + final WsManager _wsManager; + final ErrorReporter _errorReporter; + + int? _authenticatedFingerprint; + bool _endingSession = false; + + Future endSession({required SessionEndReason reason}) async { + if (_endingSession) return; + _endingSession = true; + try { + await _run(() => _wsManager.clear()); + await _run(TokenStorage.removeTokenInStorage); + await _run(SocketIdStorage.removeSocketIdInStorage); + await _run(() => sl().reset()); + await _run(() => sl().reset()); + await _run(() => sl().reset()); + await _run(() => sl().reset()); + await _run(() => sl().reset()); + await _run(() async => ProfileCubit.clearMyProfileReference()); + await _run(() => sl().endCall()); + await _run(() async => AppMetrica.setUserProfileID(null)); + await _run( + () => sl().remove(PrefsKeys.isEmailVerificationSkipped), + ); + await _run(() => sl().remove(PrefsKeys.registrationDate)); + _authenticatedFingerprint = null; + await _run(() async { + final context = AppRouter.navigatorKey.currentContext; + if (context == null) return; + final path = + AppRouter.appRouter.routeInformationProvider.value.uri.path; + if (path != RouteConstants.authScreen) { + context.go(RouteConstants.authScreen); + } + }); + } finally { + _endingSession = false; + } + } + + Future onAuthenticated() async { + String? token; + try { + token = await TokenStorage.getTokenFromStorage(); + } catch (e, st) { + await _report(e, st); + return; + } + if (token == null || token.isEmpty) return; + final fingerprint = token.hashCode; + if (_authenticatedFingerprint == fingerprint) return; + _authenticatedFingerprint = fingerprint; + + await _run(() async { + await _wsManager.clear(); + await _wsManager.initialize(); + }); + await _run(() => sl().getMyProfile()); + await _run(() => sl().getChatRoomsWithLoading()); + await _run(() => sl().getNotifications()); + await _run(() => sl().registerPushToken()); + } + + Future logout() async { + try { + await _run(() => sl().unregisterPushTokens()); + await _run( + () => sl().post('${sl().baseUrl}/api/profile/logout'), + ); + } finally { + await endSession(reason: SessionEndReason.logout); + } + } + + Future _run(FutureOr Function() action) async { + try { + await action(); + } catch (e, st) { + await _report(e, st); + } + } + + Future _report(Object error, StackTrace stackTrace) { + return _errorReporter.report( + error, + stackTrace, + origin: 'SessionLifecycleService', + ); + } +} diff --git a/lib/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart b/lib/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart index f36f43cf..98865cf2 100644 --- a/lib/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart +++ b/lib/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart @@ -3,6 +3,7 @@ import 'dart:developer'; import 'package:eatvio_app/core/firebase_api.dart'; import 'package:eatvio_app/core/prefs.dart'; import 'package:eatvio_app/core/prefs_keys.dart'; +import 'package:eatvio_app/core/session/session_lifecycle_service.dart'; import 'package:eatvio_app/core/token_storage.dart'; import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_state.dart'; @@ -17,15 +18,26 @@ class AuthCubit extends Cubit { final ProfileRepository profileRepository; final FirebaseApi firebaseApi; final Prefs prefs; + final SessionLifecycleService sessionLifecycleService; - AuthCubit({required this.authRepository, required this.prefs, required this.profileRepository, required this.firebaseApi}) : super(const AuthState()); + AuthCubit({ + required this.authRepository, + required this.prefs, + required this.profileRepository, + required this.firebaseApi, + required this.sessionLifecycleService, + }) : super(const AuthState()); Future getTokenByCredentials(String email, String password) async { emit(const AuthState.loading()); try { - final token = (await authRepository.getTokenByCredentials(email, password)).token; + final token = (await authRepository.getTokenByCredentials( + email, + password, + )).token; emit(AuthState.loaded(token: token)); await TokenStorage.saveTokenInStorage(token); + await sessionLifecycleService.onAuthenticated(); return true; } catch (e, st) { await reportFrom(e, st, this); @@ -48,7 +60,8 @@ class AuthCubit extends Cubit { Future registerAnonymousPushToken() async { try { - final hasAnonymousTokenBeenSent = prefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent) ?? false; + final hasAnonymousTokenBeenSent = + prefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent) ?? false; if (!hasAnonymousTokenBeenSent) { await firebaseApi.registerAnonymousPushToken(); prefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true); diff --git a/lib/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart b/lib/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart index f6f27797..22193998 100644 --- a/lib/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart +++ b/lib/features/auth/presentation/bloc/oauth_cubit/oauth_cubit.dart @@ -3,6 +3,7 @@ import 'package:dio/dio.dart'; import 'package:eatvio_app/core/firebase_api.dart'; import 'package:eatvio_app/core/mixins/registration_date_cache_mixin.dart'; import 'package:eatvio_app/core/prefs.dart'; +import 'package:eatvio_app/core/session/session_lifecycle_service.dart'; import 'package:eatvio_app/core/token_storage.dart'; import 'package:eatvio_app/features/auth/models/oauth_verification_request_response.dart'; import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart'; @@ -13,10 +14,12 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart'; class OAuthCubit extends Cubit with RegistrationDateCacheMixin { final AuthRepository authRepository; final FirebaseApi firebaseApi; + final SessionLifecycleService sessionLifecycleService; OAuthCubit({ required this.authRepository, required this.firebaseApi, + required this.sessionLifecycleService, required Prefs prefs, }) : super(const OAuthState()) { this.prefs = prefs; @@ -40,6 +43,7 @@ class OAuthCubit extends Cubit with RegistrationDateCacheMixin { log('OAuthCubit: Got token model: ${tokenModel.token}'); await TokenStorage.saveTokenInStorage(tokenModel.token); + await sessionLifecycleService.onAuthenticated(); emit(OAuthState.loaded(token: tokenModel.token)); } on DioException catch (e, st) { await reportFrom(e, st, this); @@ -122,7 +126,7 @@ class OAuthCubit extends Cubit with RegistrationDateCacheMixin { clientId: clientId, ); await TokenStorage.saveTokenInStorage(token); - await firebaseApi.registerPushToken(); + await sessionLifecycleService.onAuthenticated(); cacheRegistrationDate(DateTime.now()); emit(OAuthState.loaded(token: token)); } on DioException catch (e, st) { @@ -241,7 +245,7 @@ class OAuthCubit extends Cubit with RegistrationDateCacheMixin { 'OAuthCubit: OAuth verification confirmed successfully, token received', ); await TokenStorage.saveTokenInStorage(response.token); - await firebaseApi.registerPushToken(); + await sessionLifecycleService.onAuthenticated(); emit(OAuthState.loaded(token: response.token)); } catch (e, st) { await reportFrom(e, st, this); diff --git a/lib/features/auth/presentation/pages/auth_screen.dart b/lib/features/auth/presentation/pages/auth_screen.dart index d1c1ccfd..80442c1f 100644 --- a/lib/features/auth/presentation/pages/auth_screen.dart +++ b/lib/features/auth/presentation/pages/auth_screen.dart @@ -4,12 +4,10 @@ import 'package:eatvio_app/app_keys.dart'; import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/const.dart'; -import 'package:eatvio_app/core/firebase_api.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/core/utils/image_constant.dart'; import 'package:eatvio_app/core/utils/toasts.dart'; -import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/core/widgets/custom_divider.dart'; import 'package:eatvio_app/core/widgets/custom_text_form_field.dart'; import 'package:eatvio_app/features/auth/presentation/bloc/auth_cubit/auth_cubit.dart'; @@ -18,8 +16,6 @@ import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_cub import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_state.dart'; import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart'; import 'package:eatvio_app/core/di/cubit_registrations.dart'; -import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; -import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart'; import 'package:eatvio_app/generated/l10n.dart'; import 'package:flutter/cupertino.dart'; @@ -68,23 +64,8 @@ class _AuthScreenState extends State { super.dispose(); } - Future _reconnectWebSocket(BuildContext context) async { - try { - await sl().clear(); - await sl().initialize(); - } catch (e) { - rethrow; - } - if (context.mounted) { - try { - await sl().getMyProfile(); - await sl().getChatRoomsWithLoading(); - await sl().registerPushToken(); - showSuccessToast('Успешный вход'); - } catch (e) { - rethrow; - } - } + Future _reconnectWebSocket() async { + showSuccessToast('Успешный вход'); AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false}); } @@ -202,18 +183,7 @@ class _AuthScreenState extends State { } Future _reconnectAfterAuth() async { - final context = AppRouter.navigatorKey.currentContext; - if (context == null) return; - try { - await sl().clear(); - await sl().initialize(); - await sl().getMyProfile(); - await sl().getChatRoomsWithLoading(); - await sl().registerPushToken(); - showSuccessToast('Успешный вход'); - } catch (e) { - log(e.toString()); - } + showSuccessToast('Успешный вход'); AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false}); } @@ -395,7 +365,7 @@ class _AuthScreenState extends State { final isAuthenticated = await authCubit.getTokenByCredentials(emailController.text, passwordController.text); if (isAuthenticated) { if (context.mounted) { - await _reconnectWebSocket(context); + await _reconnectWebSocket(); } } setState(() { diff --git a/lib/features/auth/presentation/pages/register_via_social_screen.dart b/lib/features/auth/presentation/pages/register_via_social_screen.dart index 1ea307f0..9aed6116 100644 --- a/lib/features/auth/presentation/pages/register_via_social_screen.dart +++ b/lib/features/auth/presentation/pages/register_via_social_screen.dart @@ -9,11 +9,7 @@ import 'package:eatvio_app/features/auth/presentation/bloc/oauth_cubit/oauth_sta import 'package:eatvio_app/features/auth/entities/auth_result_entity.dart'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/locator_service.dart'; -import 'package:eatvio_app/core/websockets/ws_manager.dart'; -import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; -import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; import 'package:eatvio_app/features/create_diet/presentation/widgets/error_widget.dart'; -import 'package:eatvio_app/core/firebase_api.dart'; import 'package:eatvio_app/core/utils/toasts.dart'; import 'package:eatvio_app/core/utils/validation_utils.dart'; import 'package:flutter/gestures.dart'; @@ -62,23 +58,8 @@ class _RegisterViaSocialScreenState extends State { _initializeFieldsFromAuthResult(); } - Future _reconnectWebSocket(BuildContext context) async { - try { - await sl().clear(); - await sl().initialize(); - } catch (e) { - rethrow; - } - if (context.mounted) { - try { - await sl().getMyProfile(); - await sl().getChatRoomsWithLoading(); - await sl().registerPushToken(); - showSuccessToast('Успешная регистрация'); - } catch (e) { - rethrow; - } - } + Future _reconnectWebSocket() async { + showSuccessToast('Успешная регистрация'); AppRouter.appRouter.go('/feed-screen', extra: {'needToInitializeWebSocket': false}); } @@ -451,7 +432,7 @@ class _RegisterViaSocialScreenState extends State { Builder( builder: (context) { WidgetsBinding.instance.addPostFrameCallback((_) { - _reconnectWebSocket(context); + _reconnectWebSocket(); }); return const SizedBox.shrink(); }, @@ -795,7 +776,7 @@ class _RegisterViaSocialScreenState extends State { // Перезагружаем веб-сокет и переходим на главный экран if (widgetContext.mounted) { - _reconnectWebSocket(widgetContext); + _reconnectWebSocket(); } } catch (e, stackTrace) { log('OAuth Verification Confirm Error: $e', error: e, stackTrace: stackTrace); diff --git a/lib/features/auth/presentation/pages/scan_qr_code_screen.dart b/lib/features/auth/presentation/pages/scan_qr_code_screen.dart index 4b90fe70..f4a1dd56 100644 --- a/lib/features/auth/presentation/pages/scan_qr_code_screen.dart +++ b/lib/features/auth/presentation/pages/scan_qr_code_screen.dart @@ -3,13 +3,10 @@ import 'dart:io'; import 'package:dio/dio.dart'; import 'package:eatvio_app/core/app_config.dart'; import 'package:eatvio_app/core/app_router.dart'; -import 'package:eatvio_app/core/firebase_api.dart'; import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/session/session_lifecycle_service.dart'; import 'package:eatvio_app/core/token_storage.dart'; import 'package:eatvio_app/core/utils/toasts.dart'; -import 'package:eatvio_app/core/websockets/ws_manager.dart'; -import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; -import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; import 'package:flutter/material.dart'; import 'package:go_router/go_router.dart'; import 'package:qr_code_scanner_plus/qr_code_scanner_plus.dart'; @@ -39,7 +36,10 @@ class _ScanQRCodeScreenState extends State { @override Widget build(BuildContext context) { return Scaffold( - appBar: AppBar(title: const Text('Сканирование QR-кода'), centerTitle: true), + appBar: AppBar( + title: const Text('Сканирование QR-кода'), + centerTitle: true, + ), body: Column( children: [ Expanded( @@ -60,30 +60,33 @@ class _ScanQRCodeScreenState extends State { try { final scannedUrl = scanData.code; final uri = Uri.parse(scannedUrl ?? ''); - final scannedBaseUrl = (uri.hasAuthority) ? '${uri.scheme}://${uri.host}' : ''; + final scannedBaseUrl = (uri.hasAuthority) + ? '${uri.scheme}://${uri.host}' + : ''; sl().setBaseUrl(scannedBaseUrl); sl().options.baseUrl = scannedBaseUrl; - final response = await Dio().getUri(uri, options: Options(headers: {'accept': 'application/json', 'content-type': 'application/json'})); + final response = await Dio().getUri( + uri, + options: Options( + headers: { + 'accept': 'application/json', + 'content-type': 'application/json', + }, + ), + ); final token = response.data['token']; await TokenStorage.saveTokenInStorage(token); - try { - await sl().clear(); - await sl().initialize(); - } catch (e) { - rethrow; - } - try { - await sl().getMyProfile(); - await sl().getChatRoomsWithLoading(); - await sl().registerPushToken(); - } catch (e) { - rethrow; - } - AppRouter.navigatorKey.currentContext?.go('/feed-screen', extra: {'needToInitializeWebSocket': false}); + await sl().onAuthenticated(); + AppRouter.navigatorKey.currentContext?.go( + '/feed-screen', + extra: {'needToInitializeWebSocket': false}, + ); showSuccessToast('Добро пожаловать!'); } catch (e) { await controller.resumeCamera(); - showErrorToast('Что-то пошло не так. Попробуйте обновить страницу с QR-кодом или войдите с помощью логина и пароля.'); + showErrorToast( + 'Что-то пошло не так. Попробуйте обновить страницу с QR-кодом или войдите с помощью логина и пароля.', + ); } }); } diff --git a/lib/features/menu/presentation/bloc/menu_cubit/menu_cubit.dart b/lib/features/menu/presentation/bloc/menu_cubit/menu_cubit.dart index 3d5b94ed..c9e80d18 100644 --- a/lib/features/menu/presentation/bloc/menu_cubit/menu_cubit.dart +++ b/lib/features/menu/presentation/bloc/menu_cubit/menu_cubit.dart @@ -1,5 +1,6 @@ import 'dart:developer'; +import 'package:eatvio_app/core/session/session_lifecycle_service.dart'; import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_state.dart'; import 'package:eatvio_app/features/profile/repositories/profile_repository.dart'; import 'package:flutter_bloc/flutter_bloc.dart'; @@ -7,7 +8,11 @@ import 'package:eatvio_app/core/error_reporting/report_from.dart'; class MenuCubit extends Cubit { final ProfileRepository profileRepository; - MenuCubit(this.profileRepository) : super(const MenuState.initial()); + final SessionLifecycleService sessionLifecycleService; + MenuCubit(this.profileRepository, this.sessionLifecycleService) + : super(const MenuState.initial()); + + Future logout() => sessionLifecycleService.logout(); Future getMyProfile() async { emit(const MenuState.loading()); @@ -23,7 +28,10 @@ class MenuCubit extends Cubit { Future makePassKey() async { try { - final passKey = await profileRepository.getPassKey(routeName: 'product_nutritional_value.index', routeParams: {}); + final passKey = await profileRepository.getPassKey( + routeName: 'product_nutritional_value.index', + routeParams: {}, + ); return passKey; } catch (e, st) { await reportFrom(e, st, this); diff --git a/lib/features/menu/presentation/pages/menu_screen.dart b/lib/features/menu/presentation/pages/menu_screen.dart index 84d78dbb..ba53911f 100644 --- a/lib/features/menu/presentation/pages/menu_screen.dart +++ b/lib/features/menu/presentation/pages/menu_screen.dart @@ -1,22 +1,15 @@ -import 'package:dio/dio.dart'; -import 'package:eatvio_app/core/app_config.dart'; -import 'package:eatvio_app/core/error_reporting/error_reporter.dart'; import 'package:eatvio_app/core/const.dart'; -import 'package:eatvio_app/core/firebase_api.dart'; import 'package:eatvio_app/core/theme/custom_text_style.dart'; -import 'package:eatvio_app/core/token_storage.dart'; import 'package:eatvio_app/core/utils/image_constant.dart'; import 'package:eatvio_app/core/utils/date_time_utils.dart'; import 'package:eatvio_app/core/widgets/data_error_widget.dart'; import 'package:eatvio_app/core/widgets/loader.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; -import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/features/consultant_rating/presentation/consultant_rating_screen.dart'; import 'package:eatvio_app/features/finances_and_consultations_archive/presentation/consultant_finances_screen.dart'; import 'package:eatvio_app/features/finances_and_consultations_archive/presentation/user_purchases_screen.dart'; import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_cubit.dart'; -import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; import 'package:eatvio_app/features/menu/presentation/bloc/menu_cubit/menu_state.dart'; import 'package:eatvio_app/features/my_diets/presentation/my_diets_screen.dart'; import 'package:eatvio_app/features/my_plans/presentation/my_plans_screen.dart'; @@ -155,6 +148,7 @@ class MenuScreen extends StatelessWidget { Future Function() _onTapLogout(BuildContext context) { return () async { + final menuCubit = context.read(); showDialog( context: context, builder: (context) => CupertinoAlertDialog( @@ -162,25 +156,8 @@ class MenuScreen extends StatelessWidget { actions: [ CupertinoDialogAction( onPressed: () async { - try { - // Logout revokes the Sanctum token. Unregister first, or - // FCM / VoIP tokens stay on the server and calls still ring. - await sl().unregisterPushTokens(); - } catch (e, s) { - await sl().report(e, s, origin: 'MenuScreen'); - } - try { - await sl().post('${sl().baseUrl}/api/profile/logout'); - } catch (e, s) { - await sl().report(e, s, origin: 'MenuScreen'); - } finally { - await sl().endCall(); - await sl().clear(); - await TokenStorage.removeTokenInStorage(); - if (context.mounted) { - GoRouter.of(context).go('/auth-screen', extra: true); - } - } + Navigator.of(context).pop(); + await menuCubit.logout(); }, isDestructiveAction: true, child: const Text("Выйти"), diff --git a/lib/features/registration/presentation/bloc/registration_cubit/registration_cubit.dart b/lib/features/registration/presentation/bloc/registration_cubit/registration_cubit.dart index e9260cfd..d02ec877 100644 --- a/lib/features/registration/presentation/bloc/registration_cubit/registration_cubit.dart +++ b/lib/features/registration/presentation/bloc/registration_cubit/registration_cubit.dart @@ -1,11 +1,9 @@ import 'dart:developer'; import 'package:eatvio_app/core/app_router.dart'; -import 'package:eatvio_app/core/firebase_api.dart'; -import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/mixins/registration_date_cache_mixin.dart'; import 'package:eatvio_app/core/prefs.dart'; - +import 'package:eatvio_app/core/session/session_lifecycle_service.dart'; import 'package:eatvio_app/core/token_storage.dart'; import 'package:eatvio_app/features/registration/models/user_registration_model.dart'; import 'package:eatvio_app/features/registration/presentation/bloc/registration_cubit/registration_state.dart'; @@ -18,7 +16,8 @@ import 'package:go_router/go_router.dart'; class RegistrationCubit extends Cubit with RegistrationDateCacheMixin { final RegistrationRepository registrationRepository; - RegistrationCubit({required this.registrationRepository, required Prefs prefs}) + final SessionLifecycleService sessionLifecycleService; + RegistrationCubit({required this.registrationRepository, required this.sessionLifecycleService, required Prefs prefs}) : super( const RegistrationState( sheet: UserRegistrationSheet( @@ -62,7 +61,7 @@ class RegistrationCubit extends Cubit with RegistrationDateCa state.sheet.copyWith(phone: state.sheet.phone.replaceAll(' ', '').replaceAll('(', '').replaceAll(')', ''), responseBody: '{}'), ); await TokenStorage.saveTokenInStorage(token); - await sl().registerPushToken(); + await sessionLifecycleService.onAuthenticated(); RegistrationAnalyticsService.reportRegistrationComplete(); cacheRegistrationDate(DateTime.now()); AppRouter.navigatorKey.currentContext?.replaceNamed('verifyEmailScreen'); diff --git a/lib/features/registration_v2/cubit/registration_cubit.dart b/lib/features/registration_v2/cubit/registration_cubit.dart index 8dee14d3..9246a5b1 100644 --- a/lib/features/registration_v2/cubit/registration_cubit.dart +++ b/lib/features/registration_v2/cubit/registration_cubit.dart @@ -2,6 +2,7 @@ import 'dart:developer'; import 'package:dio/dio.dart'; import 'package:eatvio_app/core/firebase_api.dart'; +import 'package:eatvio_app/core/session/session_lifecycle_service.dart'; import 'package:eatvio_app/core/token_storage.dart'; import 'package:eatvio_app/core/utils/date_time_utils.dart'; import 'package:eatvio_app/core/utils/validation_utils.dart'; @@ -15,9 +16,11 @@ import 'package:gpassword/gpassword.dart'; class RegistrationCubitV2 extends Cubit { final RegistrationRepositoryV2 registrationRepository; final FirebaseApi firebaseApi; + final SessionLifecycleService sessionLifecycleService; RegistrationCubitV2({ required this.registrationRepository, required this.firebaseApi, + required this.sessionLifecycleService, }) : super(const RegistrationStateV2()); void setFirstName(String firstName) { @@ -204,7 +207,7 @@ class RegistrationCubitV2 extends Cubit { ); if (token != null) { await TokenStorage.saveTokenInStorage(token); - await firebaseApi.registerPushToken(); + await sessionLifecycleService.onAuthenticated(); emit(state.copyWith(registrationError: null)); return (true, false); } diff --git a/lib/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart b/lib/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart index f468a1ce..a8479ffc 100644 --- a/lib/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart +++ b/lib/features/settings/presentation/bloc/settings_cubit/settings_cubit.dart @@ -1,3 +1,5 @@ +import 'package:dio/dio.dart'; +import 'package:eatvio_app/core/session/session_lifecycle_service.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultation_request_model.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; import 'package:eatvio_app/features/consultations/repositories/consultations_repository.dart'; @@ -17,12 +19,14 @@ class SettingsCubit extends Cubit { final SettingsRepository settingsRepository; final ConsultantRepository consultantRepository; final ConsultationsRepository consultationsRepository; + final SessionLifecycleService sessionLifecycleService; SettingsCubit({ - required this.profileRepository, - required this.settingsRepository, + required this.profileRepository, + required this.settingsRepository, required this.consultantRepository, required this.consultationsRepository, + required this.sessionLifecycleService, }) : super(const SettingsState.loading()); Future init() async { @@ -30,10 +34,12 @@ class SettingsCubit extends Cubit { late final ConsultationRequestModel? consultantInfo; late final bool hasActiveConsultation; late final OAuthAccountsDataModel? oauthAccounts; - + try { if (profile.phoneConfirmationState.contains('confirmed')) { - consultantInfo = await consultantRepository.getMyRequestStatus(profile.slug); + consultantInfo = await consultantRepository.getMyRequestStatus( + profile.slug, + ); } else { consultantInfo = null; } @@ -41,14 +47,14 @@ class SettingsCubit extends Cubit { await reportFrom(e, st, this); consultantInfo = null; } - + try { hasActiveConsultation = await _checkActiveConsultation(); } catch (e, st) { await reportFrom(e, st, this); hasActiveConsultation = false; } - + try { final oauthResponse = await settingsRepository.getOAuthAccounts(); oauthAccounts = oauthResponse.data; @@ -56,7 +62,7 @@ class SettingsCubit extends Cubit { await reportFrom(e, st, this); oauthAccounts = null; } - + try { emit( SettingsState.loaded( @@ -74,9 +80,13 @@ class SettingsCubit extends Cubit { } } - Future updateProfileDetails({required ProfileDetailsModel profileDetailsModel}) async { + Future updateProfileDetails({ + required ProfileDetailsModel profileDetailsModel, + }) async { try { - await settingsRepository.updateProfileDetails(profileDetailsModel: profileDetailsModel); + await settingsRepository.updateProfileDetails( + profileDetailsModel: profileDetailsModel, + ); } catch (e, st) { await reportFrom(e, st, this); } finally { @@ -84,9 +94,13 @@ class SettingsCubit extends Cubit { } } - Future updateProfileSettings({required ProfileSettingsModel profileSettingsModel}) async { + Future updateProfileSettings({ + required ProfileSettingsModel profileSettingsModel, + }) async { try { - await settingsRepository.updateProfileSettings(profileSettingsModel: profileSettingsModel); + await settingsRepository.updateProfileSettings( + profileSettingsModel: profileSettingsModel, + ); } catch (e, st) { await reportFrom(e, st, this); } finally { @@ -97,9 +111,15 @@ class SettingsCubit extends Cubit { Future deleteAccount() async { try { await profileRepository.deleteAccount(); + await sessionLifecycleService.endSession( + reason: SessionEndReason.accountDeleted, + ); return true; } catch (e, st) { await reportFrom(e, st, this); + if (e is DioException && e.response?.statusCode == 401) { + return true; + } return false; } } @@ -135,11 +155,14 @@ class SettingsCubit extends Cubit { Future _checkActiveConsultation() async { try { - final userPurchases = await consultationsRepository.getUserPurchases(null, null); + final userPurchases = await consultationsRepository.getUserPurchases( + null, + null, + ); final activeStates = ChosenPlanStateEnum.getActiveStates(); - - return userPurchases.any((purchase) => - activeStates.contains(purchase.chosenPlanState) + + return userPurchases.any( + (purchase) => activeStates.contains(purchase.chosenPlanState), ); } catch (e, st) { await reportFrom(e, st, this); diff --git a/lib/features/settings/presentation/pages/settings_screen.dart b/lib/features/settings/presentation/pages/settings_screen.dart index a1b0acab..1f110d2b 100644 --- a/lib/features/settings/presentation/pages/settings_screen.dart +++ b/lib/features/settings/presentation/pages/settings_screen.dart @@ -698,7 +698,6 @@ class _DeleteAccountBottomSheet extends State { onPressed: () async { final result = await widget.cubit.deleteAccount(); if (result == true) { - AppRouter.navigatorKey.currentContext?.go('/auth-screen'); return; } AppRouter.navigatorKey.currentContext?.pop(); From 51aff6c175ec2d5dd3191e8b12f9f0201a8a6c8f Mon Sep 17 00:00:00 2001 From: alex-chevski Date: Fri, 30 Oct 2026 15:15:36 +0300 Subject: [PATCH 3/3] test(session): cover avatar refresh and session teardown Add regression tests for avatar reset and token-change refresh, stale 401 handling, idempotent session teardown and account-scoped preference cleanup. Update existing cubit tests for the new session dependency. Refs: #1113 --- .../token_interceptor_session_test.dart | 118 +++++++++ .../interceptors/token_interceptor_test.dart | 53 +++-- .../session_lifecycle_service_test.dart | 223 ++++++++++++++++++ .../bloc/auth_cubit/auth_cubit_test.dart | 8 + .../presentation/bloc/auth_cubit_test.dart | 25 +- .../bloc/oauth_cubit/oauth_cubit_test.dart | 31 +-- .../widgets/basic_widget_test.dart | 5 +- .../profile_avatar_cubit_session_test.dart | 204 ++++++++++++++++ .../profile_avatar_cubit_test.dart | 4 + .../presentation/bloc/menu_cubit_test.dart | 8 +- .../pages/menu_screen_smoke_test.dart | 5 +- .../registration/basic_widget_test.dart | 5 +- .../registration/integration_widget_test.dart | 3 +- .../registration_cubit_test.dart | 4 +- .../pages/registration_screen_smoke_test.dart | 5 +- .../cubit/registration_cubit_v2_test.dart | 7 +- .../bloc/settings_cubit_test.dart | 15 ++ .../pages/change_password_screen_test.dart | 2 + .../pages/settings_screen_smoke_test.dart | 2 + test/fixtures/doubles/session_doubles.dart | 24 ++ 20 files changed, 684 insertions(+), 67 deletions(-) create mode 100644 test/core/interceptors/token_interceptor_session_test.dart create mode 100644 test/core/session/session_lifecycle_service_test.dart create mode 100644 test/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit_session_test.dart create mode 100644 test/fixtures/doubles/session_doubles.dart diff --git a/test/core/interceptors/token_interceptor_session_test.dart b/test/core/interceptors/token_interceptor_session_test.dart new file mode 100644 index 00000000..4c82bd40 --- /dev/null +++ b/test/core/interceptors/token_interceptor_session_test.dart @@ -0,0 +1,118 @@ +import 'package:dio/dio.dart'; +import 'package:eatvio_app/core/interceptors/token_interceptor.dart'; +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/session/session_lifecycle_service.dart'; +import 'package:eatvio_app/core/token_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +import '../../fixtures/doubles/session_doubles.dart'; + +class _RecordingErrorInterceptorHandler extends ErrorInterceptorHandler { + int nextCalls = 0; + DioException? nextError; + + @override + void next(DioException error) { + nextCalls++; + nextError = error; + } + + @override + void resolve(Response response) { + throw UnimplementedError('resolve should not be called'); + } + + @override + void reject( + DioException error, [ + bool callFollowingErrorInterceptor = false, + ]) { + throw UnimplementedError('reject should not be called'); + } +} + +DioException _unauthorized(RequestOptions options) { + return DioException( + requestOptions: options, + response: Response(requestOptions: options, statusCode: 401), + type: DioExceptionType.badResponse, + ); +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late FakeSessionLifecycleService sessionLifecycleService; + late TokenInterceptor interceptor; + + setUp(() async { + SharedPreferences.setMockInitialValues({}); + TokenStorage.clearCache(); + await sl.reset(); + + sessionLifecycleService = FakeSessionLifecycleService(); + sl.registerSingleton(sessionLifecycleService); + interceptor = TokenInterceptor(); + }); + + tearDown(() async { + await sl.reset(); + TokenStorage.clearCache(); + }); + + group('TokenInterceptor.onError 401', () { + test('request without Authorization does not end the session', () async { + await TokenStorage.saveTokenInStorage('current-token'); + + final options = RequestOptions(path: '/api/profile/index'); + final error = _unauthorized(options); + final handler = _RecordingErrorInterceptorHandler(); + + await interceptor.onError(error, handler); + + expect(handler.nextCalls, 1); + expect(handler.nextError, same(error)); + expect(sessionLifecycleService.endSessionCalls, 0); + }); + + test('stale request token does not end the new session', () async { + await TokenStorage.saveTokenInStorage('new-token'); + + final options = RequestOptions(path: '/api/profile/index'); + options.headers['Authorization'] = 'Bearer old-token'; + options.extra['authToken'] = 'old-token'; + + final error = _unauthorized(options); + final handler = _RecordingErrorInterceptorHandler(); + + await interceptor.onError(error, handler); + + expect(handler.nextCalls, 1); + expect(sessionLifecycleService.endSessionCalls, 0); + }); + + test( + 'current request token ends the session once as unauthorized', + () async { + await TokenStorage.saveTokenInStorage('current-token'); + + final options = RequestOptions(path: '/api/profile/index'); + options.headers['Authorization'] = 'Bearer current-token'; + options.extra['authToken'] = 'current-token'; + + final error = _unauthorized(options); + final handler = _RecordingErrorInterceptorHandler(); + + await interceptor.onError(error, handler); + + expect(handler.nextCalls, 1); + expect(sessionLifecycleService.endSessionCalls, 1); + expect( + sessionLifecycleService.lastEndReason, + SessionEndReason.unauthorized, + ); + }, + ); + }); +} diff --git a/test/core/interceptors/token_interceptor_test.dart b/test/core/interceptors/token_interceptor_test.dart index 5995d4a5..d997a13a 100644 --- a/test/core/interceptors/token_interceptor_test.dart +++ b/test/core/interceptors/token_interceptor_test.dart @@ -1,22 +1,24 @@ import 'package:dio/dio.dart'; import 'package:eatvio_app/core/interceptors/token_interceptor.dart'; -import 'package:eatvio_app/core/token_storage.dart'; -import 'package:eatvio_app/core/websockets/ws_manager.dart'; import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/session/session_lifecycle_service.dart'; +import 'package:eatvio_app/core/token_storage.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../../fixtures/doubles/session_doubles.dart'; + class MockRequestOptions extends Mock implements RequestOptions {} -class MockRequestInterceptorHandler extends Mock implements RequestInterceptorHandler {} +class MockRequestInterceptorHandler extends Mock + implements RequestInterceptorHandler {} -class MockErrorInterceptorHandler extends Mock implements ErrorInterceptorHandler {} +class MockErrorInterceptorHandler extends Mock + implements ErrorInterceptorHandler {} class MockResponse extends Mock implements Response {} -class MockWsManager extends Mock implements WsManager {} - class MockDioException extends Mock implements DioException {} class FakeRequestOptions extends Fake implements RequestOptions {} @@ -32,23 +34,18 @@ void main() { }); group('TokenInterceptor', () { late TokenInterceptor interceptor; + late FakeSessionLifecycleService fakeSession; setUp(() async { SharedPreferences.setMockInitialValues({}); - - final mockWsManager = MockWsManager(); - when(() => mockWsManager.clear()).thenAnswer((_) async {}); - - await sl.reset(); // Сбрасываем sl перед каждым тестом - sl.registerSingleton(mockWsManager); - + TokenStorage.clearCache(); + + await sl.reset(); + fakeSession = FakeSessionLifecycleService(); + sl.registerSingleton(fakeSession); + interceptor = TokenInterceptor(); - // Сбрасываем токен перед каждым тестом - try { - await TokenStorage.removeTokenInStorage(); - } catch (e) { - // Игнорируем ошибки при очистке - } + await TokenStorage.removeTokenInStorage(); }); group('onRequest', () { @@ -59,6 +56,7 @@ void main() { final headers = {}; final options = MockRequestOptions(); when(() => options.headers).thenReturn(headers); + when(() => options.extra).thenReturn({}); final handler = MockRequestInterceptorHandler(); when(() => handler.next(any())).thenReturn(null); @@ -73,6 +71,7 @@ void main() { final headers = {}; final options = MockRequestOptions(); when(() => options.headers).thenReturn(headers); + when(() => options.extra).thenReturn({}); final handler = MockRequestInterceptorHandler(); when(() => handler.next(any())).thenReturn(null); @@ -89,6 +88,7 @@ void main() { final headers = {}; final options = MockRequestOptions(); when(() => options.headers).thenReturn(headers); + when(() => options.extra).thenReturn({}); final handler = MockRequestInterceptorHandler(); when(() => handler.next(any())).thenReturn(null); @@ -101,11 +101,16 @@ void main() { }); group('onError', () { - test('удаляет токен и перенаправляет на auth при 401', () async { + test('завершает сессию при 401 для текущего токена', () async { const token = 'test-token'; await TokenStorage.saveTokenInStorage(token); final options = MockRequestOptions(); + when( + () => options.headers, + ).thenReturn({'Authorization': 'Bearer $token'}); + when(() => options.extra).thenReturn({'authToken': token}); + final response = MockResponse(); when(() => response.statusCode).thenReturn(401); @@ -116,14 +121,10 @@ void main() { final handler = MockErrorInterceptorHandler(); when(() => handler.next(any())).thenReturn(null); - // Проверяем, что токен был удален - final tokenBefore = await TokenStorage.getTokenFromStorage(); - expect(tokenBefore, token); - await interceptor.onError(error, handler); - final tokenAfter = await TokenStorage.getTokenFromStorage(); - expect(tokenAfter, null); + expect(fakeSession.endSessionCalls, 1); + expect(fakeSession.lastEndReason, SessionEndReason.unauthorized); verify(() => handler.next(error)).called(1); }); diff --git a/test/core/session/session_lifecycle_service_test.dart b/test/core/session/session_lifecycle_service_test.dart new file mode 100644 index 00000000..ce991a17 --- /dev/null +++ b/test/core/session/session_lifecycle_service_test.dart @@ -0,0 +1,223 @@ +import 'package:eatvio_app/core/error_reporting/error_reporter.dart'; +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/prefs.dart'; +import 'package:eatvio_app/core/prefs_keys.dart'; +import 'package:eatvio_app/core/session/session_lifecycle_service.dart'; +import 'package:eatvio_app/core/token_storage.dart'; +import 'package:eatvio_app/core/websockets/ws_manager.dart'; +import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/new_messages_cubit/new_messages_cubit.dart'; +import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; +import 'package:eatvio_app/features/chat/presentation/bloc/chats_cubit/chats_cubit.dart'; +import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; +import 'package:eatvio_app/features/notifications/presentation/notifications_cubit/notifications_cubit.dart'; +import 'package:eatvio_app/features/video_calls/presentation/bloc/video_call_session/video_call_session_cubit.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class _FakeWsManager implements WsManager { + int clearCalls = 0; + + @override + Future clear() async { + clearCalls++; + } + + @override + dynamic noSuchMethod(Invocation invocation) { + throw UnimplementedError('WsManager.${invocation.memberName}'); + } +} + +class _FakeErrorReporter implements ErrorReporter { + int reportCalls = 0; + + @override + Future report( + Object error, + StackTrace? stackTrace, { + String? origin, + Map? extras, + }) async { + reportCalls++; + } +} + +class _FakeProfileAvatarCubit implements ProfileAvatarCubit { + int resetCalls = 0; + + @override + void reset() { + resetCalls++; + } + + @override + dynamic noSuchMethod(Invocation invocation) { + throw UnimplementedError('ProfileAvatarCubit.${invocation.memberName}'); + } +} + +class _FakeChatsCubit implements ChatsCubit { + int resetCalls = 0; + + @override + void reset() { + resetCalls++; + } + + @override + dynamic noSuchMethod(Invocation invocation) { + throw UnimplementedError('ChatsCubit.${invocation.memberName}'); + } +} + +class _FakeNotificationsCubit implements NotificationsCubit { + int resetCalls = 0; + + @override + void reset() { + resetCalls++; + } + + @override + dynamic noSuchMethod(Invocation invocation) { + throw UnimplementedError('NotificationsCubit.${invocation.memberName}'); + } +} + +class _FakeNewMessagesCubit implements NewMessagesCubit { + int resetCalls = 0; + + @override + void reset() { + resetCalls++; + } + + @override + dynamic noSuchMethod(Invocation invocation) { + throw UnimplementedError('NewMessagesCubit.${invocation.memberName}'); + } +} + +class _FakeFeedCubit implements FeedCubit { + int resetCalls = 0; + + @override + void reset() { + resetCalls++; + } + + @override + dynamic noSuchMethod(Invocation invocation) { + throw UnimplementedError('FeedCubit.${invocation.memberName}'); + } +} + +class _FakeVideoCallSessionCubit implements VideoCallSessionCubit { + int endCallCalls = 0; + + @override + Future endCall() async { + endCallCalls++; + } + + @override + dynamic noSuchMethod(Invocation invocation) { + throw UnimplementedError('VideoCallSessionCubit.${invocation.memberName}'); + } +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late _FakeWsManager wsManager; + late _FakeProfileAvatarCubit profileAvatarCubit; + late _FakeChatsCubit chatsCubit; + late _FakeNotificationsCubit notificationsCubit; + late _FakeNewMessagesCubit newMessagesCubit; + late _FakeFeedCubit feedCubit; + late _FakeVideoCallSessionCubit videoCallSessionCubit; + late SessionLifecycleService service; + + setUp(() async { + SharedPreferences.setMockInitialValues({ + 'token': 'token-a', + 'socketId': 'socket-a', + PrefsKeys.customBaseUrl.name: 'https://old.example', + PrefsKeys.isEmailVerificationSkipped.name: true, + PrefsKeys.registrationDate.name: '2024-01-01', + PrefsKeys.welcomeScreenWasShowed.name: true, + }); + TokenStorage.clearCache(); + await sl.reset(); + + wsManager = _FakeWsManager(); + profileAvatarCubit = _FakeProfileAvatarCubit(); + chatsCubit = _FakeChatsCubit(); + notificationsCubit = _FakeNotificationsCubit(); + newMessagesCubit = _FakeNewMessagesCubit(); + feedCubit = _FakeFeedCubit(); + videoCallSessionCubit = _FakeVideoCallSessionCubit(); + + sl.registerSingleton(profileAvatarCubit); + sl.registerSingleton(chatsCubit); + sl.registerSingleton(notificationsCubit); + sl.registerSingleton(newMessagesCubit); + sl.registerSingleton(feedCubit); + sl.registerSingleton(videoCallSessionCubit); + sl.registerSingleton(Prefs(await SharedPreferences.getInstance())); + + service = SessionLifecycleService( + wsManager: wsManager, + errorReporter: _FakeErrorReporter(), + ); + }); + + tearDown(() async { + await sl.reset(); + TokenStorage.clearCache(); + }); + + group('SessionLifecycleService.endSession', () { + test( + 'resets user-scoped state and clears only session-scoped keys', + () async { + await service.endSession(reason: SessionEndReason.logout); + + expect(wsManager.clearCalls, 1); + expect(profileAvatarCubit.resetCalls, 1); + expect(chatsCubit.resetCalls, 1); + expect(notificationsCubit.resetCalls, 1); + expect(newMessagesCubit.resetCalls, 1); + expect(feedCubit.resetCalls, 1); + expect(videoCallSessionCubit.endCallCalls, 1); + + expect(await TokenStorage.getTokenFromStorage(), isNull); + expect(await SocketIdStorage.getSocketIdFromStorage(), isNull); + + final prefs = sl(); + expect(prefs.getString(PrefsKeys.customBaseUrl), 'https://old.example'); + expect(prefs.getBool(PrefsKeys.isEmailVerificationSkipped), isNull); + expect(prefs.getString(PrefsKeys.registrationDate), isNull); + expect(prefs.getBool(PrefsKeys.welcomeScreenWasShowed), isTrue); + }, + ); + + test('is idempotent for concurrent calls', () async { + final first = service.endSession(reason: SessionEndReason.logout); + final second = service.endSession(reason: SessionEndReason.unauthorized); + + await Future.wait([first, second]); + + expect(wsManager.clearCalls, 1); + expect(profileAvatarCubit.resetCalls, 1); + expect(chatsCubit.resetCalls, 1); + expect(notificationsCubit.resetCalls, 1); + expect(newMessagesCubit.resetCalls, 1); + expect(feedCubit.resetCalls, 1); + expect(videoCallSessionCubit.endCallCalls, 1); + + expect(await TokenStorage.getTokenFromStorage(), isNull); + expect(await SocketIdStorage.getSocketIdFromStorage(), isNull); + }); + }); +} diff --git a/test/features/auth/presentation/bloc/auth_cubit/auth_cubit_test.dart b/test/features/auth/presentation/bloc/auth_cubit/auth_cubit_test.dart index 65bb710e..27f3c737 100644 --- a/test/features/auth/presentation/bloc/auth_cubit/auth_cubit_test.dart +++ b/test/features/auth/presentation/bloc/auth_cubit/auth_cubit_test.dart @@ -12,6 +12,7 @@ import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../../../../../fixtures/doubles/session_doubles.dart'; import 'auth_cubit_test.mocks.dart'; @GenerateNiceMocks([ @@ -75,6 +76,7 @@ void main() { profileRepository: mockProfileRepository, firebaseApi: mockFirebaseApi, prefs: mockPrefs, + sessionLifecycleService: FakeSessionLifecycleService(), ); expect(cubit!.state, isA()); @@ -86,6 +88,7 @@ void main() { profileRepository: mockProfileRepository, firebaseApi: mockFirebaseApi, prefs: mockPrefs, + sessionLifecycleService: FakeSessionLifecycleService(), ); const email = 'test@example.com'; @@ -116,6 +119,7 @@ void main() { profileRepository: mockProfileRepository, firebaseApi: mockFirebaseApi, prefs: mockPrefs, + sessionLifecycleService: FakeSessionLifecycleService(), ); const email = 'test@example.com'; @@ -140,6 +144,7 @@ void main() { profileRepository: mockProfileRepository, firebaseApi: mockFirebaseApi, prefs: mockPrefs, + sessionLifecycleService: FakeSessionLifecycleService(), ); when(mockPrefs.getBool(any)).thenReturn(false); @@ -159,6 +164,7 @@ void main() { profileRepository: mockProfileRepository, firebaseApi: mockFirebaseApi, prefs: mockPrefs, + sessionLifecycleService: FakeSessionLifecycleService(), ); when(mockPrefs.getBool(any)).thenReturn(true); @@ -174,6 +180,7 @@ void main() { profileRepository: mockProfileRepository, firebaseApi: mockFirebaseApi, prefs: mockPrefs, + sessionLifecycleService: FakeSessionLifecycleService(), ); when(mockFirebaseApi.registerPushToken()) @@ -190,6 +197,7 @@ void main() { profileRepository: mockProfileRepository, firebaseApi: mockFirebaseApi, prefs: mockPrefs, + sessionLifecycleService: FakeSessionLifecycleService(), ); // TokenStorage.haveTokenInStorage() вернет false diff --git a/test/features/auth/presentation/bloc/auth_cubit_test.dart b/test/features/auth/presentation/bloc/auth_cubit_test.dart index 790775b0..cc57e231 100644 --- a/test/features/auth/presentation/bloc/auth_cubit_test.dart +++ b/test/features/auth/presentation/bloc/auth_cubit_test.dart @@ -13,6 +13,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../../../../fixtures/doubles/auth_doubles.dart'; import '../../../../fixtures/doubles/profile_doubles.dart'; +import '../../../../fixtures/doubles/session_doubles.dart'; import '../../../../fixtures/fixture_reader.dart'; import 'auth_cubit_test.mocks.dart'; @@ -26,6 +27,7 @@ void main() { late FixtureProfileRepository profileRepository; late MockPrefs mockPrefs; late MockFirebaseApi mockFirebaseApi; + late FakeSessionLifecycleService fakeSession; late Map credentialsFixture; late TokenModel tokenFixture; @@ -37,6 +39,7 @@ void main() { profileRepository = FixtureProfileRepository(); mockPrefs = MockPrefs(); mockFirebaseApi = MockFirebaseApi(); + fakeSession = FakeSessionLifecycleService(); SharedPreferences.setMockInitialValues({}); // Mock Fluttertoast plugin @@ -59,12 +62,12 @@ void main() { }); test('начальное состояние - AuthState', () { - authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); + authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession); expect(authCubit!.state, const AuthState()); }); test('getTokenByCredentials - успешная авторизация', () async { - authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); + authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession); final result = await authCubit!.getTokenByCredentials(credentialsFixture['email'] as String, credentialsFixture['password'] as String); @@ -77,7 +80,7 @@ void main() { }); test('getTokenByCredentials - переход в состояние loading', () async { - authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); + authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession); authRepository.responseDelay = const Duration(milliseconds: 50); final future = authCubit!.getTokenByCredentials(credentialsFixture['email'] as String, credentialsFixture['password'] as String); @@ -88,7 +91,7 @@ void main() { }); test('getTokenByCredentials - обработка ошибки авторизации', () async { - authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); + authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession); authRepository.error = Exception('Неверный адрес электронной почты или пароль'); final result = await authCubit!.getTokenByCredentials('wrong@example.com', 'wrongpassword'); @@ -102,7 +105,7 @@ void main() { }); test('getTokenByCredentials - обработка различных типов ошибок', () async { - authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); + authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession); authRepository.error = const FormatException('Invalid credentials format'); final result = await authCubit!.getTokenByCredentials('invalid', 'pass'); @@ -115,7 +118,7 @@ void main() { }); test('getTokenByCredentials - проверка вызова с правильными параметрами', () async { - authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); + authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession); await authCubit!.getTokenByCredentials('user@test.com', 'securePassword123'); @@ -124,7 +127,7 @@ void main() { }); test('getTokenByCredentials - множественные вызовы', () async { - authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); + authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession); await authCubit!.getTokenByCredentials('first@test.com', 'password1'); await Future.delayed(const Duration(milliseconds: 100)); @@ -138,7 +141,7 @@ void main() { }); test('registerAnonymousPushToken - регистрирует токен если не был отправлен', () async { - authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); + authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession); when(mockPrefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent)).thenReturn(false); when(mockFirebaseApi.registerAnonymousPushToken()).thenAnswer((_) async => {}); when(mockPrefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true)).thenAnswer((_) async => {}); @@ -150,7 +153,7 @@ void main() { }); test('registerAnonymousPushToken - не регистрирует токен если уже был отправлен', () async { - authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); + authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession); when(mockPrefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent)).thenReturn(true); await authCubit!.registerAnonymousPushToken(); @@ -159,7 +162,7 @@ void main() { }); test('registerPushToken - регистрирует push токен', () async { - authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); + authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession); when(mockFirebaseApi.registerPushToken()).thenAnswer((_) async => {}); await authCubit!.registerPushToken(); @@ -168,7 +171,7 @@ void main() { }); test('registerAnonymousPushToken - обрабатывает null значение hasAnonymousTokenBeenSent', () async { - authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); + authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: fakeSession); when(mockPrefs.getBool(PrefsKeys.hasAnonymousTokenBeenSent)).thenReturn(null); when(mockFirebaseApi.registerAnonymousPushToken()).thenAnswer((_) async => {}); when(mockPrefs.setBool(PrefsKeys.hasAnonymousTokenBeenSent, true)).thenAnswer((_) async => {}); diff --git a/test/features/auth/presentation/bloc/oauth_cubit/oauth_cubit_test.dart b/test/features/auth/presentation/bloc/oauth_cubit/oauth_cubit_test.dart index 3f757bb1..443ee7e6 100644 --- a/test/features/auth/presentation/bloc/oauth_cubit/oauth_cubit_test.dart +++ b/test/features/auth/presentation/bloc/oauth_cubit/oauth_cubit_test.dart @@ -14,6 +14,7 @@ import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../../../../../fixtures/doubles/session_doubles.dart'; import '../../../../../fixtures/fixture_reader.dart'; import 'oauth_cubit_test.mocks.dart'; @@ -37,6 +38,7 @@ void main() { late MockAuthRepository mockAuthRepository; late MockPrefs mockPrefs; late _RecordingFirebaseApi firebaseApi; + late FakeSessionLifecycleService fakeSession; late TokenModel tokenFixture; late Map signInRequestFixture; late Map signUpRequestFixture; @@ -55,6 +57,7 @@ void main() { mockAuthRepository = MockAuthRepository(); mockPrefs = MockPrefs(); firebaseApi = _RecordingFirebaseApi(); + fakeSession = FakeSessionLifecycleService(); SharedPreferences.setMockInitialValues({}); @@ -87,7 +90,7 @@ void main() { test('начальное состояние - OAuthState', () { oauthCubit = OAuthCubit( authRepository: mockAuthRepository, - firebaseApi: firebaseApi, + firebaseApi: firebaseApi, sessionLifecycleService: fakeSession, prefs: mockPrefs, ); expect(oauthCubit!.state, const OAuthState()); @@ -96,7 +99,7 @@ void main() { test('signInViaSocialMedia - успешный вход через соцсеть', () async { oauthCubit = OAuthCubit( authRepository: mockAuthRepository, - firebaseApi: firebaseApi, + firebaseApi: firebaseApi, sessionLifecycleService: fakeSession, prefs: mockPrefs, ); @@ -129,7 +132,7 @@ void main() { test('signInViaSocialMedia - переход в состояние loading', () async { oauthCubit = OAuthCubit( authRepository: mockAuthRepository, - firebaseApi: firebaseApi, + firebaseApi: firebaseApi, sessionLifecycleService: fakeSession, prefs: mockPrefs, ); @@ -160,7 +163,7 @@ void main() { test('signInViaSocialMedia - обработка ошибки', () async { oauthCubit = OAuthCubit( authRepository: mockAuthRepository, - firebaseApi: firebaseApi, + firebaseApi: firebaseApi, sessionLifecycleService: fakeSession, prefs: mockPrefs, ); @@ -189,7 +192,7 @@ void main() { test('signUpViaSocialMedia - успешная регистрация через соцсеть', () async { oauthCubit = OAuthCubit( authRepository: mockAuthRepository, - firebaseApi: firebaseApi, + firebaseApi: firebaseApi, sessionLifecycleService: fakeSession, prefs: mockPrefs, ); @@ -237,8 +240,7 @@ void main() { final loadedState = oauthCubit!.state as OAuthStateLoaded; expect(loadedState.token, tokenFixture.token); expect(await TokenStorage.getTokenFromStorage(), tokenFixture.token); - expect(firebaseApi.registerPushTokenCalls, 1); - expect(firebaseApi.hadAuthToken, isTrue); + expect(fakeSession.onAuthenticatedCalls, 1); }); test( @@ -246,7 +248,7 @@ void main() { () async { oauthCubit = OAuthCubit( authRepository: mockAuthRepository, - firebaseApi: firebaseApi, + firebaseApi: firebaseApi, sessionLifecycleService: fakeSession, prefs: mockPrefs, ); @@ -289,7 +291,7 @@ void main() { test('signUpViaSocialMedia - обработка ошибки валидации email', () async { oauthCubit = OAuthCubit( authRepository: mockAuthRepository, - firebaseApi: firebaseApi, + firebaseApi: firebaseApi, sessionLifecycleService: fakeSession, prefs: mockPrefs, ); @@ -350,7 +352,7 @@ void main() { () async { oauthCubit = OAuthCubit( authRepository: mockAuthRepository, - firebaseApi: firebaseApi, + firebaseApi: firebaseApi, sessionLifecycleService: fakeSession, prefs: mockPrefs, ); @@ -379,7 +381,7 @@ void main() { test('requestOAuthVerification - обработка ошибки', () async { oauthCubit = OAuthCubit( authRepository: mockAuthRepository, - firebaseApi: firebaseApi, + firebaseApi: firebaseApi, sessionLifecycleService: fakeSession, prefs: mockPrefs, ); @@ -403,7 +405,7 @@ void main() { () async { oauthCubit = OAuthCubit( authRepository: mockAuthRepository, - firebaseApi: firebaseApi, + firebaseApi: firebaseApi, sessionLifecycleService: fakeSession, prefs: mockPrefs, ); @@ -458,15 +460,14 @@ void main() { final loadedState = oauthCubit!.state as OAuthStateLoaded; expect(loadedState.token, tokenFixture.token); expect(await TokenStorage.getTokenFromStorage(), tokenFixture.token); - expect(firebaseApi.registerPushTokenCalls, 1); - expect(firebaseApi.hadAuthToken, isTrue); + expect(fakeSession.onAuthenticatedCalls, 1); }, ); test('confirmOAuthVerification - обработка ошибки', () async { oauthCubit = OAuthCubit( authRepository: mockAuthRepository, - firebaseApi: firebaseApi, + firebaseApi: firebaseApi, sessionLifecycleService: fakeSession, prefs: mockPrefs, ); diff --git a/test/features/auth/presentation/widgets/basic_widget_test.dart b/test/features/auth/presentation/widgets/basic_widget_test.dart index 05c2e2e3..0fac7d35 100644 --- a/test/features/auth/presentation/widgets/basic_widget_test.dart +++ b/test/features/auth/presentation/widgets/basic_widget_test.dart @@ -15,6 +15,7 @@ import 'package:shared_preferences/shared_preferences.dart'; import '../../../../fixtures/doubles/auth_doubles.dart'; import '../../../../fixtures/doubles/profile_doubles.dart'; +import '../../../../fixtures/doubles/session_doubles.dart'; import '../../../../fixtures/fixture_reader.dart'; import 'basic_widget_test.mocks.dart'; @@ -36,7 +37,7 @@ void main() { mockFirebaseApi = MockFirebaseApi(); SharedPreferences.setMockInitialValues({}); - authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); + authCubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: FakeSessionLifecycleService()); }); tearDown(() { @@ -55,7 +56,7 @@ void main() { testWidgets('should create AuthCubit successfully', (WidgetTester tester) async { // Arrange & Act - final cubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi); + final cubit = AuthCubit(authRepository: authRepository, prefs: mockPrefs, profileRepository: profileRepository, firebaseApi: mockFirebaseApi, sessionLifecycleService: FakeSessionLifecycleService()); // Assert expect(cubit, isA()); diff --git a/test/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit_session_test.dart b/test/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit_session_test.dart new file mode 100644 index 00000000..0535e564 --- /dev/null +++ b/test/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit_session_test.dart @@ -0,0 +1,204 @@ +import 'dart:async'; + +import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/token_storage.dart'; +import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; +import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart'; +import 'package:eatvio_app/features/profile/models/profile_model.dart'; +import 'package:eatvio_app/features/profile/repositories/profile_repository.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mocktail/mocktail.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class MockProfileRepository extends Mock implements ProfileRepository {} + +const _profileA = ProfileModel( + id: 1, + slug: 'user-a', + name: 'User A', + email: 'a@example.com', + phoneConfirmationState: 'confirmed', + isPublicProfile: true, + isBlockedByMe: false, + isBlockedByAuthor: false, + isBlockedByUser: false, + isEntitySubscribedByMe: false, + profilePhotoUrl: 'avatars/a.png', + roles: [], + badges: [], + subscriptions: [], + subscribers: [], +); + +const _profileB = ProfileModel( + id: 2, + slug: 'user-b', + name: 'User B', + email: 'b@example.com', + phoneConfirmationState: 'confirmed', + isPublicProfile: true, + isBlockedByMe: false, + isBlockedByAuthor: false, + isBlockedByUser: false, + isEntitySubscribedByMe: false, + profilePhotoUrl: 'avatars/b.png', + roles: [], + badges: [], + subscriptions: [], + subscribers: [], +); + +const _details = ProfileDetailsModel( + firstName: 'User', + lastName: 'Test', + birthdate: '1990-01-01', + gender: 'male', + country: 'Russia', + city: 'Moscow', + lang: 'ru', + weight: 70.0, + height: 175, + chestCircumference: 100, + waistCircumference: 80, + hipCircumference: 90, + bloodPressureUp: 120, + bloodPressureDown: 80, + hasHypo: false, + hasHyper: false, + aboutAlergy: '-', + aboutTakingPils: '-', + aboutSportHobbies: '-', + favoriteFoods: '-', + unfavoriteFoods: '-', + additionalInfo: '-', + isMoodFillable: true, + isBedtimeFillable: true, + activityCoefficient: 1.5, +); + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + late MockProfileRepository repository; + ProfileAvatarCubit? cubit; + + setUp(() async { + await sl.reset(); + SharedPreferences.setMockInitialValues({'token': 'token-a'}); + TokenStorage.clearCache(); + repository = MockProfileRepository(); + }); + + tearDown(() async { + if (cubit != null && !cubit!.isClosed) { + await cubit!.close(); + } + cubit = null; + await sl.reset(); + TokenStorage.clearCache(); + }); + + void stubSuccessfulProfile(ProfileModel profile) { + when(() => repository.getMyProfile()).thenAnswer((_) async => profile); + when( + () => repository.getMyProfileDetails(), + ).thenAnswer((_) async => _details); + } + + test('reset emits loading and clears the cached profile', () async { + stubSuccessfulProfile(_profileA); + cubit = ProfileAvatarCubit(profileRepository: repository); + + await cubit!.ensureFreshForCurrentSession(); + expect(cubit!.state, isA()); + + cubit!.reset(); + expect(cubit!.state, isA()); + + await cubit!.ensureFreshForCurrentSession(); + + verify(() => repository.getMyProfile()).called(2); + expect(cubit!.state, isA()); + }); + + test('late getMyProfile completion after reset is ignored', () async { + final profileCompleter = Completer(); + when( + () => repository.getMyProfile(), + ).thenAnswer((_) => profileCompleter.future); + when( + () => repository.getMyProfileDetails(), + ).thenAnswer((_) async => _details); + + cubit = ProfileAvatarCubit(profileRepository: repository); + + final pendingRequest = cubit!.getMyProfile(); + await pumpEventQueue(); + expect(cubit!.state, isA()); + + cubit!.reset(); + profileCompleter.complete(_profileA); + await pendingRequest; + await pumpEventQueue(); + + expect(cubit!.state, isA()); + verifyNever(() => repository.getMyProfileDetails()); + }); + + test( + 'ensureFreshForCurrentSession does not refetch for the same token', + () async { + stubSuccessfulProfile(_profileA); + cubit = ProfileAvatarCubit(profileRepository: repository); + + await cubit!.ensureFreshForCurrentSession(); + await cubit!.ensureFreshForCurrentSession(); + + verify(() => repository.getMyProfile()).called(1); + expect(cubit!.state, isA()); + }, + ); + + test( + 'ensureFreshForCurrentSession refetches after the token changes', + () async { + final profiles = [_profileA, _profileB]; + when( + () => repository.getMyProfile(), + ).thenAnswer((_) async => profiles.removeAt(0)); + when( + () => repository.getMyProfileDetails(), + ).thenAnswer((_) async => _details); + + cubit = ProfileAvatarCubit(profileRepository: repository); + + await cubit!.ensureFreshForCurrentSession(); + final firstState = cubit!.state as ProfileAvatarStateLoaded; + expect(firstState.profile.id, _profileA.id); + + await TokenStorage.saveTokenInStorage('token-b'); + await cubit!.ensureFreshForCurrentSession(); + + final secondState = cubit!.state as ProfileAvatarStateLoaded; + expect(secondState.profile.id, _profileB.id); + expect(secondState.profile.profilePhotoUrl, _profileB.profilePhotoUrl); + verify(() => repository.getMyProfile()).called(2); + }, + ); + + test('errors emit ProfileAvatarStateError and do not navigate', () async { + when( + () => repository.getMyProfile(), + ).thenAnswer((_) async => throw Exception('profile request failed')); + + cubit = ProfileAvatarCubit(profileRepository: repository); + + await cubit!.getMyProfile(); + + expect(cubit!.state, isA()); + expect( + (cubit!.state as ProfileAvatarStateError).errorMessage, + contains('profile request failed'), + ); + }); +} diff --git a/test/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit_test.dart b/test/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit_test.dart index 61d515c1..981bfff4 100644 --- a/test/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit_test.dart +++ b/test/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit_test.dart @@ -1,3 +1,4 @@ +import 'package:eatvio_app/core/token_storage.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_cubit.dart'; import 'package:eatvio_app/features/bottom_navigation_bar/presentation/bloc/profile_avatar_cubit/profile_avatar_state.dart'; import 'package:eatvio_app/features/profile/models/profile_model.dart'; @@ -5,6 +6,7 @@ import 'package:eatvio_app/features/profile/repositories/profile_repository.dart import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; +import 'package:shared_preferences/shared_preferences.dart'; import 'profile_avatar_cubit_test.mocks.dart'; @@ -16,6 +18,8 @@ void main() { late MockProfileRepository mockRepository; setUp(() { + SharedPreferences.setMockInitialValues({'token': 'test-token'}); + TokenStorage.clearCache(); mockRepository = MockProfileRepository(); }); diff --git a/test/features/menu/presentation/bloc/menu_cubit_test.dart b/test/features/menu/presentation/bloc/menu_cubit_test.dart index 9cdf18d3..e7dd9a9c 100644 --- a/test/features/menu/presentation/bloc/menu_cubit_test.dart +++ b/test/features/menu/presentation/bloc/menu_cubit_test.dart @@ -4,6 +4,8 @@ import 'package:eatvio_app/features/profile/repositories/profile_repository.dart import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; + +import '../../../../fixtures/doubles/session_doubles.dart'; import 'menu_cubit_test.mocks.dart'; @GenerateNiceMocks([MockSpec()]) @@ -25,12 +27,12 @@ void main() { }); test('начальное состояние - MenuState', () { - menuCubit = MenuCubit(mockProfileRepository); + menuCubit = MenuCubit(mockProfileRepository, FakeSessionLifecycleService()); expect(menuCubit!.state, isA()); }); test('makePassKey - успешно создает passKey', () async { - menuCubit = MenuCubit(mockProfileRepository); + menuCubit = MenuCubit(mockProfileRepository, FakeSessionLifecycleService()); const expectedPassKey = 'test-pass-key'; when(mockProfileRepository.getPassKey( @@ -48,7 +50,7 @@ void main() { }); test('makePassKey - возвращает null при ошибке', () async { - menuCubit = MenuCubit(mockProfileRepository); + menuCubit = MenuCubit(mockProfileRepository, FakeSessionLifecycleService()); when(mockProfileRepository.getPassKey( routeName: 'product_nutritional_value.index', diff --git a/test/features/menu/presentation/pages/menu_screen_smoke_test.dart b/test/features/menu/presentation/pages/menu_screen_smoke_test.dart index 1facf0bb..cfc1ddf8 100644 --- a/test/features/menu/presentation/pages/menu_screen_smoke_test.dart +++ b/test/features/menu/presentation/pages/menu_screen_smoke_test.dart @@ -15,6 +15,7 @@ import 'package:go_router/go_router.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; +import '../../../../fixtures/doubles/session_doubles.dart'; import 'menu_screen_smoke_test.mocks.dart'; @GenerateNiceMocks([MockSpec()]) @@ -138,7 +139,7 @@ void main() { testWidgets('renders menu screen for user', (tester) async { final repository = MockProfileRepository(); - final cubit = MenuCubit(repository); + final cubit = MenuCubit(repository, FakeSessionLifecycleService()); when(repository.getPassKey(routeName: anyNamed('routeName'), routeParams: anyNamed('routeParams'))).thenAnswer((_) async => 'test-passkey'); when(repository.getMyProfile()).thenAnswer((_) async => mockUserProfile); @@ -155,7 +156,7 @@ void main() { testWidgets('renders menu screen for consultant', (tester) async { final repository = MockProfileRepository(); - final cubit = MenuCubit(repository); + final cubit = MenuCubit(repository, FakeSessionLifecycleService()); when(repository.getPassKey(routeName: anyNamed('routeName'), routeParams: anyNamed('routeParams'))).thenAnswer((_) async => 'test-passkey'); when(repository.getMyProfile()).thenAnswer((_) async => mockConsultantProfile); diff --git a/test/features/registration/basic_widget_test.dart b/test/features/registration/basic_widget_test.dart index 16995a82..2b0a6e29 100644 --- a/test/features/registration/basic_widget_test.dart +++ b/test/features/registration/basic_widget_test.dart @@ -9,6 +9,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; +import '../../fixtures/doubles/session_doubles.dart'; import 'basic_widget_test.mocks.dart'; @GenerateMocks([RegistrationRepository, Prefs]) @@ -19,7 +20,7 @@ void main() { setUp(() { mockRegistrationRepository = MockRegistrationRepository(); - registrationCubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs()); + registrationCubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs(), sessionLifecycleService: FakeSessionLifecycleService()); }); tearDown(() { @@ -38,7 +39,7 @@ void main() { testWidgets('should create RegistrationCubit successfully', (WidgetTester tester) async { // Arrange & Act - final cubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs()); + final cubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs(), sessionLifecycleService: FakeSessionLifecycleService()); // Assert expect(cubit, isA()); diff --git a/test/features/registration/integration_widget_test.dart b/test/features/registration/integration_widget_test.dart index e5559a80..0e35d009 100644 --- a/test/features/registration/integration_widget_test.dart +++ b/test/features/registration/integration_widget_test.dart @@ -7,6 +7,7 @@ import 'package:flutter_bloc/flutter_bloc.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; +import '../../fixtures/doubles/session_doubles.dart'; import 'integration_widget_test.mocks.dart'; @GenerateMocks([RegistrationRepository, Prefs]) @@ -17,7 +18,7 @@ void main() { setUp(() { mockRegistrationRepository = MockRegistrationRepository(); - registrationCubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs()); + registrationCubit = RegistrationCubit(registrationRepository: mockRegistrationRepository, prefs: MockPrefs(), sessionLifecycleService: FakeSessionLifecycleService()); }); tearDown(() { diff --git a/test/features/registration/presentation/bloc/registration_cubit/registration_cubit_test.dart b/test/features/registration/presentation/bloc/registration_cubit/registration_cubit_test.dart index 11bfe5d6..3f108a79 100644 --- a/test/features/registration/presentation/bloc/registration_cubit/registration_cubit_test.dart +++ b/test/features/registration/presentation/bloc/registration_cubit/registration_cubit_test.dart @@ -7,6 +7,8 @@ import 'package:eatvio_app/features/registration/repositories/registration_repos import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; +import '../../../../../fixtures/doubles/session_doubles.dart'; + class MockRegistrationRepository extends Mock implements RegistrationRepository {} class MockPrefs extends Mock implements Prefs {} @@ -26,7 +28,7 @@ void main() { setUp(() { mockRepository = MockRegistrationRepository(); mockPrefs = MockPrefs(); - cubit = RegistrationCubit(registrationRepository: mockRepository, prefs: mockPrefs); + cubit = RegistrationCubit(registrationRepository: mockRepository, prefs: mockPrefs, sessionLifecycleService: FakeSessionLifecycleService()); }); tearDown(() { diff --git a/test/features/registration/presentation/pages/registration_screen_smoke_test.dart b/test/features/registration/presentation/pages/registration_screen_smoke_test.dart index d3b60f15..4fc28966 100644 --- a/test/features/registration/presentation/pages/registration_screen_smoke_test.dart +++ b/test/features/registration/presentation/pages/registration_screen_smoke_test.dart @@ -13,6 +13,7 @@ import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; +import '../../../../fixtures/doubles/session_doubles.dart'; import 'registration_screen_smoke_test.mocks.dart'; @GenerateNiceMocks([MockSpec(), MockSpec()]) @@ -44,7 +45,7 @@ void main() { testWidgets('renders registration screen with initial step', (tester) async { final repository = MockRegistrationRepository(); final prefs = MockPrefs(); - final cubit = RegistrationCubit(registrationRepository: repository, prefs: prefs); + final cubit = RegistrationCubit(registrationRepository: repository, prefs: prefs, sessionLifecycleService: FakeSessionLifecycleService()); sl.registerLazySingleton(() => repository); sl.registerLazySingleton(() => prefs); @@ -59,7 +60,7 @@ void main() { testWidgets('renders phone screen as first step', (tester) async { final repository = MockRegistrationRepository(); final prefs = MockPrefs(); - final cubit = RegistrationCubit(registrationRepository: repository, prefs: prefs); + final cubit = RegistrationCubit(registrationRepository: repository, prefs: prefs, sessionLifecycleService: FakeSessionLifecycleService()); sl.registerLazySingleton(() => repository); sl.registerLazySingleton(() => prefs); diff --git a/test/features/registration_v2/cubit/registration_cubit_v2_test.dart b/test/features/registration_v2/cubit/registration_cubit_v2_test.dart index e2d3d8ab..98f4ac25 100644 --- a/test/features/registration_v2/cubit/registration_cubit_v2_test.dart +++ b/test/features/registration_v2/cubit/registration_cubit_v2_test.dart @@ -9,6 +9,7 @@ import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; import 'package:shared_preferences/shared_preferences.dart'; +import '../../../fixtures/doubles/session_doubles.dart'; import 'registration_cubit_v2_test.mocks.dart'; class _RecordingFirebaseApi extends Fake implements FirebaseApi { @@ -30,6 +31,7 @@ void main() { late RegistrationCubitV2 cubit; late MockRegistrationRepositoryV2 mockRepository; late _RecordingFirebaseApi firebaseApi; + late FakeSessionLifecycleService fakeSession; const MethodChannel passwordGeneratorChannel = MethodChannel( 'flutter_password_generator', ); @@ -37,9 +39,11 @@ void main() { setUp(() { mockRepository = MockRegistrationRepositoryV2(); firebaseApi = _RecordingFirebaseApi(); + fakeSession = FakeSessionLifecycleService(); cubit = RegistrationCubitV2( registrationRepository: mockRepository, firebaseApi: firebaseApi, + sessionLifecycleService: fakeSession, ); // Инициализируем SharedPreferences для тестов @@ -212,8 +216,7 @@ void main() { expect(result.$1, isTrue); expect(result.$2, isFalse); expect(await TokenStorage.getTokenFromStorage(), token); - expect(firebaseApi.registerPushTokenCalls, 1); - expect(firebaseApi.hadAuthToken, isTrue); + expect(fakeSession.onAuthenticatedCalls, 1); verify( mockRepository.register( registrationModel: anyNamed('registrationModel'), diff --git a/test/features/settings/presentation/bloc/settings_cubit_test.dart b/test/features/settings/presentation/bloc/settings_cubit_test.dart index 2733bd07..674492c4 100644 --- a/test/features/settings/presentation/bloc/settings_cubit_test.dart +++ b/test/features/settings/presentation/bloc/settings_cubit_test.dart @@ -1,3 +1,4 @@ +import 'package:eatvio_app/core/session/session_lifecycle_service.dart'; import 'package:eatvio_app/features/consultant/become_consultant/repositories/consultant_repository.dart'; import 'package:eatvio_app/features/consultations/repositories/consultations_repository.dart'; import 'package:eatvio_app/features/profile/repositories/profile_repository.dart'; @@ -7,6 +8,7 @@ import 'package:eatvio_app/features/settings/repositories/service_repository.dar import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; +import '../../../../fixtures/doubles/session_doubles.dart'; import '../../../../fixtures/settings_fixtures.dart'; import 'settings_cubit_test.mocks.dart'; @@ -23,6 +25,7 @@ void main() { late MockSettingsRepository mockSettingsRepository; late MockConsultantRepository mockConsultantRepository; late MockConsultationsRepository mockConsultationsRepository; + late FakeSessionLifecycleService fakeSession; setUp(() { @@ -30,6 +33,7 @@ void main() { mockSettingsRepository = MockSettingsRepository(); mockConsultantRepository = MockConsultantRepository(); mockConsultationsRepository = MockConsultationsRepository(); + fakeSession = FakeSessionLifecycleService(); }); tearDown(() async { @@ -46,6 +50,7 @@ void main() { settingsRepository: mockSettingsRepository, consultantRepository: mockConsultantRepository, consultationsRepository: mockConsultationsRepository, + sessionLifecycleService: fakeSession, ); expect(settingsCubit!.state, const SettingsState.loading()); }); @@ -56,6 +61,7 @@ void main() { settingsRepository: mockSettingsRepository, consultantRepository: mockConsultantRepository, consultationsRepository: mockConsultationsRepository, + sessionLifecycleService: fakeSession, ); when(mockProfileRepository.getMyProfile()) @@ -86,6 +92,7 @@ void main() { settingsRepository: mockSettingsRepository, consultantRepository: mockConsultantRepository, consultationsRepository: mockConsultationsRepository, + sessionLifecycleService: fakeSession, ); // Ошибка происходит при загрузке настроек профиля (внутри try-catch блока) @@ -114,6 +121,7 @@ void main() { settingsRepository: mockSettingsRepository, consultantRepository: mockConsultantRepository, consultationsRepository: mockConsultationsRepository, + sessionLifecycleService: fakeSession, ); when(mockProfileRepository.getMyProfile()) @@ -145,6 +153,7 @@ void main() { settingsRepository: mockSettingsRepository, consultantRepository: mockConsultantRepository, consultationsRepository: mockConsultationsRepository, + sessionLifecycleService: fakeSession, ); when(mockProfileRepository.getMyProfile()) @@ -176,6 +185,7 @@ void main() { settingsRepository: mockSettingsRepository, consultantRepository: mockConsultantRepository, consultationsRepository: mockConsultationsRepository, + sessionLifecycleService: fakeSession, ); when(mockProfileRepository.deleteAccount()) @@ -184,6 +194,8 @@ void main() { final result = await settingsCubit!.deleteAccount(); expect(result, true); + expect(fakeSession.endSessionCalls, 1); + expect(fakeSession.lastEndReason, SessionEndReason.accountDeleted); verify(mockProfileRepository.deleteAccount()).called(1); }); @@ -193,6 +205,7 @@ void main() { settingsRepository: mockSettingsRepository, consultantRepository: mockConsultantRepository, consultationsRepository: mockConsultationsRepository, + sessionLifecycleService: fakeSession, ); when(mockProfileRepository.deleteAccount()) @@ -209,6 +222,7 @@ void main() { settingsRepository: mockSettingsRepository, consultantRepository: mockConsultantRepository, consultationsRepository: mockConsultationsRepository, + sessionLifecycleService: fakeSession, ); when(mockProfileRepository.getMyProfile()) @@ -244,6 +258,7 @@ void main() { settingsRepository: mockSettingsRepository, consultantRepository: mockConsultantRepository, consultationsRepository: mockConsultationsRepository, + sessionLifecycleService: fakeSession, ); when(mockProfileRepository.getMyProfile()) diff --git a/test/features/settings/presentation/pages/change_password_screen_test.dart b/test/features/settings/presentation/pages/change_password_screen_test.dart index fe576e3b..c46d2c56 100644 --- a/test/features/settings/presentation/pages/change_password_screen_test.dart +++ b/test/features/settings/presentation/pages/change_password_screen_test.dart @@ -6,6 +6,7 @@ import 'package:eatvio_app/features/settings/presentation/bloc/settings_cubit/se import 'package:flutter/material.dart'; import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_test/flutter_test.dart'; +import '../../../../fixtures/doubles/session_doubles.dart'; import '../../../../fixtures/settings_fixtures.dart'; import '../bloc/settings_cubit_test.mocks.dart'; @@ -27,6 +28,7 @@ void main() { settingsRepository: mockSettingsRepository, consultantRepository: mockConsultantRepository, consultationsRepository: mockConsultationsRepository, + sessionLifecycleService: FakeSessionLifecycleService(), ); }); diff --git a/test/features/settings/presentation/pages/settings_screen_smoke_test.dart b/test/features/settings/presentation/pages/settings_screen_smoke_test.dart index 92cb508a..4f39560f 100644 --- a/test/features/settings/presentation/pages/settings_screen_smoke_test.dart +++ b/test/features/settings/presentation/pages/settings_screen_smoke_test.dart @@ -18,6 +18,7 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; +import '../../../../fixtures/doubles/session_doubles.dart'; import 'settings_screen_smoke_test.mocks.dart'; @GenerateNiceMocks([ @@ -57,6 +58,7 @@ void main() { settingsRepository: sl(), consultantRepository: sl(), consultationsRepository: sl(), + sessionLifecycleService: FakeSessionLifecycleService(), ), ); }); diff --git a/test/fixtures/doubles/session_doubles.dart b/test/fixtures/doubles/session_doubles.dart new file mode 100644 index 00000000..09b07b83 --- /dev/null +++ b/test/fixtures/doubles/session_doubles.dart @@ -0,0 +1,24 @@ +import 'package:eatvio_app/core/session/session_lifecycle_service.dart'; + +class FakeSessionLifecycleService implements SessionLifecycleService { + int onAuthenticatedCalls = 0; + int endSessionCalls = 0; + int logoutCalls = 0; + SessionEndReason? lastEndReason; + + @override + Future onAuthenticated() async { + onAuthenticatedCalls++; + } + + @override + Future endSession({required SessionEndReason reason}) async { + endSessionCalls++; + lastEndReason = reason; + } + + @override + Future logout() async { + logoutCalls++; + } +}