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/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/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/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/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/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 { 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(); 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++; + } +}