From a13b623ca265598a3948eccbedd27063a26b00ac Mon Sep 17 00:00:00 2001 From: misha Date: Wed, 1 Apr 2026 10:37:59 +0300 Subject: [PATCH] feat(feed): fixed comments state updating in feed, feat(consultantRegistration): fixed registration flow for users with unverified phone, feat(tests): fixed tests coverage --- lib/core/app_router.dart | 5 +- .../comments_cubit/comments_cubit.dart | 43 +- .../phone_verify_error_interceptor.dart | 22 +- lib/core/routing/main_navigation_routes.dart | 4 +- .../presentation/pages/blog_post_screen.dart | 16 +- .../register_consultant_cubit.dart | 69 +-- .../pages/become_consultant_screen.dart | 23 +- .../bloc/feed_cubit/feed_cubit.dart | 11 + .../feed/presentation/pages/feed_screen.dart | 11 +- .../presentation/cubit/about_plan_cubit.dart | 97 ++-- .../presentation/pages/about_plan_screen.dart | 4 +- test/core/app_router_test.dart | 38 ++ .../pages/blog_post_screen_smoke_test.dart | 49 ++ .../reporitories/recipe_repository_test.dart | 459 ++++++++++++------ .../dev_tools_screen_smoke_test.dart | 26 + .../dev_tools/test_cases_bulk_test.dart | 107 ++++ ...notification_model_fromjson_bulk_test.dart | 121 +++++ .../cubit/about_plan_cubit_test.dart | 38 +- .../pages/about_plan_screen_smoke_test.dart | 92 +++- .../pages/about_recipe_screen_smoke_test.dart | 57 +++ 20 files changed, 949 insertions(+), 343 deletions(-) create mode 100644 test/features/dev_tools/test_cases_bulk_test.dart create mode 100644 test/features/notifications/models/notification_model_fromjson_bulk_test.dart diff --git a/lib/core/app_router.dart b/lib/core/app_router.dart index 6b5808f5..13f139a1 100644 --- a/lib/core/app_router.dart +++ b/lib/core/app_router.dart @@ -74,7 +74,10 @@ class AppRouter { FlutterNativeSplash.remove(); return '/about-diet-screen?slug=$contentIdentifier'; } - final initialTabIndex = hidePlans ? 3 : 4; + const dietTabIndexWithHiddenPlansTab = 3; + const dietTabIndexWithoutHiddenPlansTab = 4; + + final initialTabIndex = hidePlans ? dietTabIndexWithHiddenPlansTab : dietTabIndexWithoutHiddenPlansTab; return '${RouteConstants.feedScreen}?initialTabIndex=$initialTabIndex'; } diff --git a/lib/core/comments/comments_cubit/comments_cubit.dart b/lib/core/comments/comments_cubit/comments_cubit.dart index 7e5df141..a65a9c6e 100644 --- a/lib/core/comments/comments_cubit/comments_cubit.dart +++ b/lib/core/comments/comments_cubit/comments_cubit.dart @@ -10,10 +10,7 @@ class CommentsCubit extends Cubit { final FeedRepository feedRepository; final CommentsRepository commentsRepository; - CommentsCubit({ - required this.feedRepository, - required this.commentsRepository, - }) : super(const CommentsState()); + CommentsCubit({required this.feedRepository, required this.commentsRepository}) : super(const CommentsState()); void setReplyId(int? replyId) { emit((state as CommentsStateLoaded).copyWith(replyId: replyId)); @@ -41,46 +38,27 @@ class CommentsCubit extends Cubit { } } - Future sendComment({ - required String id, - required String commentText, - required CommentSourceType commentSourceType, - int? parentCommentId, - }) async { + Future sendComment({required String id, required String commentText, required CommentSourceType commentSourceType, int? parentCommentId}) async { try { switch (commentSourceType) { case CommentSourceType.blogPost: - await feedRepository.sendBlogPostComment( - id: id, - commentText: commentText, - parentCommentId: parentCommentId, - ); + await feedRepository.sendBlogPostComment(id: id, commentText: commentText, parentCommentId: parentCommentId); break; case CommentSourceType.diet: - await feedRepository.sendDietsComment( - id: id, - commentText: commentText, - parentCommentId: parentCommentId, - ); + await feedRepository.sendDietsComment(id: id, commentText: commentText, parentCommentId: parentCommentId); break; case CommentSourceType.recipe: - await feedRepository.sendRecipesComment( - id: id, - commentText: commentText, - parentCommentId: parentCommentId, - ); + await feedRepository.sendRecipesComment(id: id, commentText: commentText, parentCommentId: parentCommentId); break; case CommentSourceType.plan: - await feedRepository.sendPlansComment( - slug: id, - commentText: commentText, - parentCommentId: parentCommentId, - ); + await feedRepository.sendPlansComment(slug: id, commentText: commentText, parentCommentId: parentCommentId); break; } getComments(id, commentSourceType); + return true; } catch (e) { emit(CommentsState.error(errorMessage: e.toString())); + return false; } } @@ -101,8 +79,9 @@ class CommentsCubit extends Cubit { Future updateComment({required int id, required String body}) async { final commentModel = (state as CommentsStateLoaded).comment; - final updatedCommentModel = - commentModel.copyWith(data: commentModel.data.map((comment) => comment.id == id ? comment.copyWith(body: body) : comment).toList()); + final updatedCommentModel = commentModel.copyWith( + data: commentModel.data.map((comment) => comment.id == id ? comment.copyWith(body: body) : comment).toList(), + ); try { await commentsRepository.updateComment(id: id, body: body); emit(CommentsState.loaded(comment: updatedCommentModel)); diff --git a/lib/core/interceptors/phone_verify_error_interceptor.dart b/lib/core/interceptors/phone_verify_error_interceptor.dart index 4073cb85..0649561a 100644 --- a/lib/core/interceptors/phone_verify_error_interceptor.dart +++ b/lib/core/interceptors/phone_verify_error_interceptor.dart @@ -1,6 +1,7 @@ import 'package:dio/dio.dart'; import 'package:eatvio_app/core/app_router.dart'; import 'package:eatvio_app/core/locator_service.dart'; +import 'package:eatvio_app/core/routing/route_constants.dart'; import 'package:eatvio_app/core/widgets/show_error_dialog.dart'; import 'package:eatvio_app/features/profile/repositories/profile_repository.dart'; import 'package:go_router/go_router.dart'; @@ -12,18 +13,21 @@ class PhoneVerifyErrorInterceptor extends Interceptor { final data = err.response?.data; if (data is Map && data['type'] == 'unverificated_phone') { - final String phone = await sl().getMyPhone(); + final String phone = await sl().getMyPhone(); final String passKey = await sl().getPassKey(routeName: 'user_detailz.change-phone-verify', routeParams: {"phone": phone}); - await showErrorDialog(AppRouter.navigatorKey.currentContext!, - title: 'Привяжите номер телефона', description: 'Для того, что бы выполнить это действие необходимо привязать номер телефона'); + await showErrorDialog( + AppRouter.navigatorKey.currentContext!, + title: 'Привяжите номер телефона', + description: 'Для того, что бы выполнить это действие необходимо привязать номер телефона', + ); + final currentContext = AppRouter.navigatorKey.currentContext; + + if (currentContext != null && currentContext.mounted && GoRouter.of(currentContext).state.path == RouteConstants.stepperBecomeConsultantScreen) { + GoRouter.of(currentContext).pop(); + } AppRouter.navigatorKey.currentContext?.pushNamed('verifyScreen', extra: passKey); return handler.reject( - DioException( - requestOptions: err.requestOptions, - response: err.response, - error: 'Phone number not verified', - type: DioExceptionType.badResponse, - ), + DioException(requestOptions: err.requestOptions, response: err.response, error: 'Phone number not verified', type: DioExceptionType.badResponse), ); } } diff --git a/lib/core/routing/main_navigation_routes.dart b/lib/core/routing/main_navigation_routes.dart index 5875f7e1..7d06147f 100644 --- a/lib/core/routing/main_navigation_routes.dart +++ b/lib/core/routing/main_navigation_routes.dart @@ -47,8 +47,8 @@ class MainNavigationRoutes { path: RouteConstants.feedScreen, name: 'feedScreen', pageBuilder: (context, state) { - final initialTabIndex = int.tryParse(state.uri.queryParameters['initialTabIndex'] ?? '0'); - return NoTransitionPage(child: FeedScreen(initialTabIndex: initialTabIndex ?? 0)); + final initialTabIndex = int.tryParse(state.uri.queryParameters['initialTabIndex'] ?? ''); + return NoTransitionPage(child: FeedScreen(initialTabIndex: initialTabIndex)); }, redirect: (context, state) async { try { diff --git a/lib/features/blog_post/presentation/pages/blog_post_screen.dart b/lib/features/blog_post/presentation/pages/blog_post_screen.dart index bf50518a..ce691fb9 100644 --- a/lib/features/blog_post/presentation/pages/blog_post_screen.dart +++ b/lib/features/blog_post/presentation/pages/blog_post_screen.dart @@ -78,9 +78,7 @@ class _BlogPostScreenState extends State { return Scaffold( resizeToAvoidBottomInset: true, bottomSheet: Container( - padding: EdgeInsets.only( - bottom: MediaQuery.of(context).padding.bottom + 8, - ), + padding: EdgeInsets.only(bottom: MediaQuery.of(context).padding.bottom + 8), decoration: BoxDecoration( color: Colors.white, border: Border(top: BorderSide(color: Colors.black.withValues(alpha: 0.1))), @@ -147,14 +145,18 @@ class _BlogPostScreenState extends State { ), const SizedBox(width: 8), GestureDetector( - onTap: () { + onTap: () async { if (textEditingController.text.length > 2) { - commentsCubit.sendComment( + final isSent = await commentsCubit.sendComment( id: widget.id.toString(), commentText: textEditingController.text, parentCommentId: replyId, commentSourceType: CommentSourceType.blogPost, ); + if (!context.mounted) return; + + if (isSent) context.read().incrementCommentsState(widget.id); + context.read().setReplyId(null); setState(() { textEditingController.clear(); @@ -237,9 +239,7 @@ class _BlogPostScreenState extends State { if (blogPostState.blogPostModel?.description.isNotEmpty ?? false) Padding( padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - child: MarkdownHtmlWidget( - markdownText: blogPostState.blogPostModel!.description, - ), + child: MarkdownHtmlWidget(markdownText: blogPostState.blogPostModel!.description), ), const SizedBox(height: 8), if (blogPostState.blogPostModel?.image.isNotEmpty ?? false) diff --git a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart index 6cc5e417..76c2fd69 100644 --- a/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart +++ b/lib/features/consultant/become_consultant/presentation/bloc/register_consultant_cubit/register_consultant_cubit.dart @@ -1,3 +1,4 @@ +import 'dart:developer'; import 'dart:io'; import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart'; import 'package:eatvio_app/features/consultant/become_consultant/models/consultant_profile_model.dart'; @@ -20,45 +21,53 @@ class RegisterConsultantCubit extends Cubit { ); Future getMyRequestStatus(String slug) async { - _update = true; - final myRequestStatus = await consultantRepository.getMyRequestStatus(slug); - final certPhotos = await consultantRepository.getConsultantCertPhotos(); + try { + _update = true; + final myRequestStatus = await consultantRepository.getMyRequestStatus(slug); + final certPhotos = await consultantRepository.getConsultantCertPhotos(); - List? certificates = myRequestStatus.educationModel.completedCourses?.map((courseMap) { - final course = courseMap.values.first; + List? certificates = myRequestStatus.educationModel.completedCourses?.map((courseMap) { + final course = courseMap.values.first; - return CertificatesModel(title: course.title, date: course.date); - }).toList(); + return CertificatesModel(title: course.title, date: course.date); + }).toList(); - String? sanitizeLink(String? link) { - return (link != null && link.isNotEmpty) ? link : null; - } + String? sanitizeLink(String? link) { + return (link != null && link.isNotEmpty) ? link : null; + } - emit( - RegisterConsultantState( - consultant: ConsultantProfileModel( - certificates: certificates, - socialLinks: SocialLinksModel( - vkLink: sanitizeLink(myRequestStatus.educationModel.vkLink), - tgLink: sanitizeLink(myRequestStatus.educationModel.tgLink), - fbLink: sanitizeLink(myRequestStatus.educationModel.fbLink), - instaLink: sanitizeLink(myRequestStatus.educationModel.instaLink), - personalSiteLink: sanitizeLink(myRequestStatus.educationModel.personalSiteLink), + emit( + RegisterConsultantState( + consultant: ConsultantProfileModel( + certificates: certificates, + socialLinks: SocialLinksModel( + vkLink: sanitizeLink(myRequestStatus.educationModel.vkLink), + tgLink: sanitizeLink(myRequestStatus.educationModel.tgLink), + fbLink: sanitizeLink(myRequestStatus.educationModel.fbLink), + instaLink: sanitizeLink(myRequestStatus.educationModel.instaLink), + personalSiteLink: sanitizeLink(myRequestStatus.educationModel.personalSiteLink), + ), + hasMedicalEducation: myRequestStatus.educationModel.hasMedicalEducation == 1, ), - hasMedicalEducation: myRequestStatus.educationModel.hasMedicalEducation == 1, + acceptOffer: false, + loading: false, + requestModel: myRequestStatus, + certPhotos: certPhotos, + isUpdating: _update, ), - acceptOffer: false, - loading: false, - requestModel: myRequestStatus, - certPhotos: certPhotos, - isUpdating: _update - ), - ); + ); + } catch (e) { + log(e.toString()); + } } Future disableLoading() async { - final certPhotos = await consultantRepository.getConsultantCertPhotos(); - emit(state.copyWith(loading: false, certPhotos: certPhotos)); + try { + final certPhotos = await consultantRepository.getConsultantCertPhotos(); + emit(state.copyWith(loading: false, certPhotos: certPhotos)); + } catch (e) { + log(e.toString()); + } } void updateConsultantProfileModel(ConsultantProfileModel consultant, [bool acceptOffer = false]) { diff --git a/lib/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart b/lib/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart index ef9f5cec..40d4a352 100644 --- a/lib/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart +++ b/lib/features/consultant/become_consultant/presentation/pages/become_consultant_screen.dart @@ -17,18 +17,13 @@ class _BecomeConsultantScreenState extends State { title: const Text('Стать консультантом'), automaticallyImplyLeading: false, centerTitle: false, - leading: IconButton( - onPressed: () => GoRouter.of(context).pop(), - icon: const Icon(Icons.close), - ), + leading: IconButton(onPressed: () => GoRouter.of(context).pop(), icon: const Icon(Icons.close)), ), body: const Padding( padding: EdgeInsets.all(16), child: Text( 'Чтобы стать консультантом необходимо пройти короткую регистрацию. Подготовьте свои дипломы и/или сертификаты в формате jpg, они Вам пригодятся для проверки и подтверждения. Также необходимо будет указать ссылки на Ваши соц. сети и указать тип лица (физ. лицо или юр. лицо). На данный момент доступна регистрация в качестве физ. лица.\n\nВнутри Итвио разница между обычным пользователем и консультантом заключается в дополнительных возможностях последнего создавать публичные диеты, рецепты и планы питания; участвовать в ежемесячных рейтингах.', - style: TextStyle( - fontSize: 16, - ), + style: TextStyle(fontSize: 16), ), ), bottomSheet: _startRegistrationButton(), @@ -37,24 +32,16 @@ class _BecomeConsultantScreenState extends State { InkWell _startRegistrationButton() { return InkWell( - onTap: () => GoRouter.of(context).pushReplacementNamed('stepperBecomeConsultantScreen'), + onTap: () => GoRouter.of(context).pushReplacementNamed('stepperBecomeConsultantScreen', extra: {'slug': null}), borderRadius: BorderRadius.circular(20), child: Ink( height: 52, width: MediaQuery.of(context).size.width - 32, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(20), - color: appTheme.blueA400, - ), + decoration: BoxDecoration(borderRadius: BorderRadius.circular(20), color: appTheme.blueA400), child: const Center( child: Text( 'Начать регистрацию', - style: TextStyle( - color: Colors.white, - height: 1, - fontWeight: FontWeight.w600, - fontSize: 18, - ), + style: TextStyle(color: Colors.white, height: 1, fontWeight: FontWeight.w600, fontSize: 18), ), ), ), 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 d1bf399f..c749f6d4 100644 --- a/lib/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart +++ b/lib/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart @@ -26,6 +26,17 @@ class FeedCubit extends Cubit { } } + void incrementCommentsState(int id) { + final List feedBlogPostList = [...(state as FeedStateLoaded).feedBlogPostList]; + final int index = feedBlogPostList.indexWhere((e) => e.id == id); + if (index == -1) return; + final current = feedBlogPostList[index]; + feedBlogPostList[index] = current.copyWith(totalComments: current.totalComments + 1); + if (!isClosed) { + emit(FeedState.loaded(feedBlogPostList: feedBlogPostList, isLoading: isLoading)); + } + } + Future loadFeedBlogPostList({String? query}) async { try { _query = query; diff --git a/lib/features/feed/presentation/pages/feed_screen.dart b/lib/features/feed/presentation/pages/feed_screen.dart index f8c47cf5..5eb4f454 100644 --- a/lib/features/feed/presentation/pages/feed_screen.dart +++ b/lib/features/feed/presentation/pages/feed_screen.dart @@ -33,7 +33,7 @@ import '../widgets/recipes.dart'; class FeedScreen extends StatelessWidget { const FeedScreen({super.key, this.initialTabIndex = 0}); - final int initialTabIndex; + final int? initialTabIndex; @override Widget build(BuildContext context) { return MultiBlocProvider( @@ -51,7 +51,7 @@ class FeedScreen extends StatelessWidget { class FeedScreenContent extends StatefulWidget { const FeedScreenContent({super.key, required this.initialTabIndex}); - final int initialTabIndex; + final int? initialTabIndex; @override State createState() => _FeedScreenContentState(); } @@ -72,7 +72,7 @@ class _FeedScreenContentState extends State { final consultantsScrollController = ScrollController(); @override void initState() { - _selectedChipIndex = widget.initialTabIndex; + _selectedChipIndex = widget.initialTabIndex ?? 0; chipsLabelList = hidePlans ? ['Все', 'Консультанты', 'Рецепты', 'Диеты'] : ['Все', 'Планы питания', 'Консультанты', 'Рецепты', 'Диеты']; consultantsScrollController.addListener(_onScrollConsultants); _checkRegistrationDate(context); @@ -92,8 +92,11 @@ class _FeedScreenContentState extends State { @override void didUpdateWidget(FeedScreenContent oldWidget) { super.didUpdateWidget(oldWidget); + final initialTabIndex = widget.initialTabIndex; setState(() { - _selectedChipIndex = widget.initialTabIndex; + if (initialTabIndex != null) { + _selectedChipIndex = initialTabIndex; + } }); } diff --git a/lib/features/plans/presentation/cubit/about_plan_cubit.dart b/lib/features/plans/presentation/cubit/about_plan_cubit.dart index a45b3bc1..85d26b0f 100644 --- a/lib/features/plans/presentation/cubit/about_plan_cubit.dart +++ b/lib/features/plans/presentation/cubit/about_plan_cubit.dart @@ -13,9 +13,12 @@ class AboutPlanCubit extends Cubit { final PlanRepository planRepository; AboutPlanCubit({required this.planRepository}) : super(AboutPlanState.initial()); - Future getPlan(String slug) async { + Future getPlan({required String slug, int? index}) async { try { final plan = await planRepository.getPlan(slug); + if (index != null) { + planRepository.emitCommentsStateToFeed(CommentsState(index: index, totalComments: plan.totalComments)); + } emit(state.copyWith(planModel: plan, error: null)); } catch (e, stackTrace) { log(e.toString(), stackTrace: stackTrace); @@ -27,13 +30,7 @@ class AboutPlanCubit extends Cubit { try { emit(state.copyWith(isLoading: true)); final plan = await planRepository.getPlan(slug); - emit( - state.copyWith( - planModel: plan, - error: null, - isLoading: false, - ), - ); + emit(state.copyWith(planModel: plan, error: null, isLoading: false)); } catch (e, stackTrace) { log(e.toString(), stackTrace: stackTrace); emit(state.copyWith(error: e.toString(), isLoading: false)); @@ -59,21 +56,17 @@ class AboutPlanCubit extends Cubit { } } - EasyDebounce.debounce( - 'like_plan_$slug', - const Duration(seconds: 1), - () async { - try { - await planRepository.likePlan(slug); - } catch (e) { - log(e.toString()); - if (index != null) { - planRepository.emitLikesStateToFeed(LikesState(wasLikedByMe: currentPlan.wasLikedByMe, totalLikes: currentPlan.totalLikes, index: index)); - } - emit(state.copyWith(planModel: currentPlan, error: null)); + EasyDebounce.debounce('like_plan_$slug', const Duration(seconds: 1), () async { + try { + await planRepository.likePlan(slug); + } catch (e) { + log(e.toString()); + if (index != null) { + planRepository.emitLikesStateToFeed(LikesState(wasLikedByMe: currentPlan.wasLikedByMe, totalLikes: currentPlan.totalLikes, index: index)); } - }, - ); + emit(state.copyWith(planModel: currentPlan, error: null)); + } + }); } Future addToFavorite({required String slug, required int? index}) async { @@ -84,22 +77,18 @@ class AboutPlanCubit extends Cubit { if (index != null) { planRepository.emitFavoriteStateToFeed(FavoriteState(isFavorite: true, index: index)); } - EasyDebounce.debounce( - 'add_or_remove_plan_to_favorites', - const Duration(seconds: 1), - () async { - try { - await planRepository.addToFavorite(slug); - } catch (e) { - log(e.toString()); - showErrorToast('Произошла ошибка. Попробуйте ещё раз через некоторое время'); - if (index != null) { - planRepository.emitFavoriteStateToFeed(FavoriteState(isFavorite: currentPlan.isFavorite, index: index)); - } - emit(state.copyWith(planModel: currentPlan, error: null)); + EasyDebounce.debounce('add_or_remove_plan_to_favorites', const Duration(seconds: 1), () async { + try { + await planRepository.addToFavorite(slug); + } catch (e) { + log(e.toString()); + showErrorToast('Произошла ошибка. Попробуйте ещё раз через некоторое время'); + if (index != null) { + planRepository.emitFavoriteStateToFeed(FavoriteState(isFavorite: currentPlan.isFavorite, index: index)); } - }, - ); + emit(state.copyWith(planModel: currentPlan, error: null)); + } + }); } Future removeFromFavorite({required String slug, required int? index}) async { @@ -110,22 +99,18 @@ class AboutPlanCubit extends Cubit { if (index != null) { planRepository.emitFavoriteStateToFeed(FavoriteState(isFavorite: false, index: index)); } - EasyDebounce.debounce( - 'add_or_remove_plan_to_favorites', - const Duration(seconds: 1), - () async { - try { - await planRepository.removeFromFavorite(slug); - } catch (e) { - log(e.toString()); - showErrorToast('Произошла ошибка. Попробуйте ещё раз через некоторое время'); - if (index != null) { - planRepository.emitFavoriteStateToFeed(FavoriteState(isFavorite: currentPlan.isFavorite, index: index)); - } - emit(state.copyWith(planModel: currentPlan, error: null)); + EasyDebounce.debounce('add_or_remove_plan_to_favorites', const Duration(seconds: 1), () async { + try { + await planRepository.removeFromFavorite(slug); + } catch (e) { + log(e.toString()); + showErrorToast('Произошла ошибка. Попробуйте ещё раз через некоторое время'); + if (index != null) { + planRepository.emitFavoriteStateToFeed(FavoriteState(isFavorite: currentPlan.isFavorite, index: index)); } - }, - ); + emit(state.copyWith(planModel: currentPlan, error: null)); + } + }); } Future<(ChosenPlanModel?, String?)> chosePlan() async { @@ -149,8 +134,8 @@ class AboutPlanCubit extends Cubit { } String _getOptionNameFromIndex(int index) => switch (index) { - 1 => 'options2', - 2 => 'options3', - _ => 'options1', - }; + 1 => 'options2', + 2 => 'options3', + _ => 'options1', + }; } diff --git a/lib/features/plans/presentation/pages/about_plan_screen.dart b/lib/features/plans/presentation/pages/about_plan_screen.dart index 283ef0fc..2dcbd91b 100644 --- a/lib/features/plans/presentation/pages/about_plan_screen.dart +++ b/lib/features/plans/presentation/pages/about_plan_screen.dart @@ -154,7 +154,7 @@ class _AboutPlanScreenState extends State { const SizedBox(width: 16), ], ), - body: DataErrorWidget(onReload: () => context.read().getPlan(widget.slug)), + body: DataErrorWidget(onReload: () => context.read().getPlan(slug: widget.slug)), ); } return Scaffold( @@ -295,7 +295,7 @@ class _AboutPlanScreenState extends State { Future.delayed(const Duration(milliseconds: 500), () { if (context.mounted) { - context.read().getPlan(widget.slug); + context.read().getPlan(slug: widget.slug, index: widget.index); } }); FocusManager.instance.primaryFocus?.unfocus(); diff --git a/test/core/app_router_test.dart b/test/core/app_router_test.dart index 407145e8..19c61dab 100644 --- a/test/core/app_router_test.dart +++ b/test/core/app_router_test.dart @@ -112,5 +112,43 @@ void main() { expect(() => AppRouter.navigateToRegisterViaSocial(authResult: authResult, provider: 'vk'), returnsNormally); expect(() => AppRouter.navigateToRegisterViaSocial(authResult: authResult, provider: 'google'), returnsNormally); }); + + test('navigateByDeepLinkUrl handles empty and invalid urls', () { + expect(() => AppRouter.navigateByDeepLinkUrl(''), returnsNormally); + expect(() => AppRouter.navigateByDeepLinkUrl('https://unknown-host.example/foo'), returnsNormally); + expect(() => AppRouter.navigateByDeepLinkUrl('%%%'), returnsNormally); + }); + + test('navigateByDeepLinkUrl handles supported recipe and blog links', () { + expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/recipe/test-recipe'), returnsNormally); + expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.com/en/blog_post/123'), returnsNormally); + }); + + test('navigateByDeepLinkUrl handles plan links', () { + expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/plan/plan-slug'), returnsNormally); + expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/plan/42'), returnsNormally); + }); + + test('navigateByDeepLinkUrl handles diet links', () { + expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/diet/99'), returnsNormally); + expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/diet/diet-slug'), returnsNormally); + expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/diet'), returnsNormally); + }); + + test('navigateByDeepLinkUrl handles chat links', () { + expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/chat/uuid-1'), returnsNormally); + expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/chat/show/uuid-2'), returnsNormally); + expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/chat'), returnsNormally); + }); + + test('navigateByDeepLinkUrl handles profile and misc links', () { + expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/profile/some-user'), returnsNormally); + expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/profile'), returnsNormally); + expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/badge/1'), returnsNormally); + expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/diary'), returnsNormally); + expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/consultant_diary_comment/7'), returnsNormally); + expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/consultations/status/11'), returnsNormally); + expect(() => AppRouter.navigateByDeepLinkUrl('https://eatvio.ru/ru/consultations'), returnsNormally); + }); }); } diff --git a/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart b/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart index 027ae523..f194ea0c 100644 --- a/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart +++ b/test/features/blog_post/presentation/pages/blog_post_screen_smoke_test.dart @@ -205,5 +205,54 @@ void main() { expect(find.text('Test Post Title'), findsOneWidget); }); }); + + testWidgets('loaded state tapping like invokes toggleLike', (tester) async { + await mockNetworkImagesFor(() async { + final blogPostCubit = MockBlogPostCubit(); + final feedCubit = MockFeedCubit(); + final commentsCubit = MockCommentsCubit(); + final blogPostModel = FeedBlogPostModel( + id: 1, + title: 'Test Post Title', + description: 'Test post description', + image: 'https://example.com/image.jpg', + state: 'published', + author: const Author(authorSlug: 'author-1', authorName: 'Author Name', authorProfileIcon: 'https://example.com/icon.jpg', authorIsMe: false), + videoUrl: '', + createdAt: DateTime(2024, 1, 15), + updatedAt: DateTime(2024, 1, 15), + totalLikes: 10, + totalViews: 100, + totalComments: 5, + wasLikedByMe: false, + ); + final state = BlogPostState(isLoading: false, blogPostModel: blogPostModel, error: null); + + when(blogPostCubit.state).thenReturn(state); + when(blogPostCubit.stream).thenAnswer((_) => Stream.value(state)); + when(blogPostCubit.close()).thenAnswer((_) async => Future.value()); + when(blogPostCubit.getBlogPost(any)).thenAnswer((_) async => Future.value()); + when(blogPostCubit.toggleLike()).thenAnswer((_) async => Future.value()); + when(feedCubit.viewPost(any)).thenAnswer((_) async => Future.value()); + when(feedCubit.stream).thenAnswer((_) => const Stream.empty()); + when(feedCubit.close()).thenAnswer((_) async => Future.value()); + when(feedCubit.updateFeedBlogPostList(any, any)).thenReturn(null); + when(commentsCubit.state).thenReturn(const CommentsState()); + when(commentsCubit.stream).thenAnswer((_) => const Stream.empty()); + when(commentsCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future.value()); + + registerMocks(blogPostCubit, commentsCubit); + + await tester.pumpWidget(wrap(blogPostCubit, feedCubit, commentsCubit, const BlogPostScreen(id: 1))); + await tester.pump(); + await tester.pumpAndSettle(); + + await tester.tap(find.byIcon(Icons.favorite_border_rounded)); + await tester.pump(); + + verify(blogPostCubit.toggleLike()).called(1); + }); + }); }); } diff --git a/test/features/create_recipe/reporitories/recipe_repository_test.dart b/test/features/create_recipe/reporitories/recipe_repository_test.dart index 8392d3b7..c4d57c22 100644 --- a/test/features/create_recipe/reporitories/recipe_repository_test.dart +++ b/test/features/create_recipe/reporitories/recipe_repository_test.dart @@ -1,4 +1,9 @@ +import 'dart:io'; + import 'package:dio/dio.dart'; +import 'package:eatvio_app/core/app_config.dart'; +import 'package:eatvio_app/core/app_initializer.dart'; +import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/models/pagination_model.dart'; import 'package:eatvio_app/features/create_recipe/models/ingredient.dart'; import 'package:eatvio_app/features/create_recipe/models/recipe_model.dart'; @@ -8,31 +13,123 @@ import 'package:eatvio_app/features/create_recipe/models/step.dart'; import 'package:eatvio_app/features/create_recipe/reporitories/recipe_repository.dart'; import 'package:eatvio_app/features/create_recipe/services/recipe_service.dart'; import 'package:flutter_test/flutter_test.dart'; +import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:mocktail/mocktail.dart'; import 'package:retrofit/retrofit.dart'; class MockCreateRecipeService extends Mock implements CreateRecipeService {} -final mockHttpResponse = HttpResponse( - null, - Response(requestOptions: RequestOptions(path: ''), statusCode: 200), -); +final mockHttpResponse = HttpResponse(null, Response(requestOptions: RequestOptions(path: ''), statusCode: 200)); void main() { setUpAll(() { + dotenv.loadFromString(envString: 'DEV_BASE_URL=http://localhost\nBASE_URL=http://localhost'); + if (sl.isRegistered()) { + sl.unregister(); + } + sl.registerSingleton(AppConfig(buildType: AppBuildType.tests)); registerFallbackValue(FormData()); }); group('RecipeRepository', () { late RecipeRepository repository; late MockCreateRecipeService mockService; + late Directory tempDir; + late File recipeImage; + late File stepImage; setUp(() { mockService = MockCreateRecipeService(); repository = RecipeRepository(recipeService: mockService); }); + setUp(() async { + tempDir = await Directory.systemTemp.createTemp('eatvio_recipe_repo_test_'); + recipeImage = File('${tempDir.path}/recipe.png'); + stepImage = File('${tempDir.path}/step.png'); + + // Valid 1x1 transparent PNG to satisfy ImageCompressUtility decoding. + const png1x1 = [ + 0x89, + 0x50, + 0x4E, + 0x47, + 0x0D, + 0x0A, + 0x1A, + 0x0A, + 0x00, + 0x00, + 0x00, + 0x0D, + 0x49, + 0x48, + 0x44, + 0x52, + 0x00, + 0x00, + 0x00, + 0x01, + 0x00, + 0x00, + 0x00, + 0x01, + 0x08, + 0x06, + 0x00, + 0x00, + 0x00, + 0x1F, + 0x15, + 0xC4, + 0x89, + 0x00, + 0x00, + 0x00, + 0x0A, + 0x49, + 0x44, + 0x41, + 0x54, + 0x78, + 0x9C, + 0x63, + 0x00, + 0x01, + 0x00, + 0x00, + 0x05, + 0x00, + 0x01, + 0x0D, + 0x0A, + 0x2D, + 0xB4, + 0x00, + 0x00, + 0x00, + 0x00, + 0x49, + 0x45, + 0x4E, + 0x44, + 0xAE, + 0x42, + 0x60, + 0x82, + ]; + + await recipeImage.writeAsBytes(png1x1, flush: true); + await stepImage.writeAsBytes(png1x1, flush: true); + }); + + tearDown(() async { + if (tempDir.existsSync()) { + await tempDir.delete(recursive: true); + } + }); + test('inputProduct - возвращает Map с продуктами', () async { const name = 'test-product'; const expected = { @@ -177,12 +274,12 @@ void main() { test('updateRecipe - вызывает updateRecipe с правильными параметрами', () async { const slug = 'recipe-slug'; - const recipe = RecipeModel( + final recipe = RecipeModel( type: 'main', title: 'Updated Recipe', description: 'Updated description', videoUrl: null, - image: '/test/path/image.jpg', + image: recipeImage.path, ingredients: null, steps: null, tags: null, @@ -192,27 +289,29 @@ void main() { energyPerServing: 300, ); - // Мокируем ImageCompressUtility.compressFile - // Это сложно, так как это статический метод, но мы можем проверить, что вызывается updateRecipe - when(() => mockService.updateRecipe(slug: slug, formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value(mockHttpResponse)); + when( + () => mockService.updateRecipe( + slug: slug, + formData: any(named: 'formData'), + ), + ).thenAnswer((_) async => Future.value(mockHttpResponse)); - // Этот тест может упасть из-за ImageCompressUtility, но мы проверяем логику - try { - await repository.publishRecipe(recipe, true, slug); - verify(() => mockService.updateRecipe(slug: slug, formData: any(named: 'formData'))).called(1); - } catch (e) { - // Игнорируем ошибки, связанные с ImageCompressUtility в тестах - } + await repository.publishRecipe(recipe, true, slug); + verify( + () => mockService.updateRecipe( + slug: slug, + formData: any(named: 'formData'), + ), + ).called(1); }); test('publishRecipe - обрабатывает рецепт с тегами', () async { - const recipe = RecipeModel( + final recipe = RecipeModel( type: 'main', title: 'Test Recipe', description: 'Test description', videoUrl: null, - image: '/test/path/image.jpg', + image: recipeImage.path, ingredients: null, steps: null, tags: ['tag1', 'tag2'], @@ -222,24 +321,19 @@ void main() { energyPerServing: 300, ); - when(() => mockService.publishRecipe(formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value(mockHttpResponse)); + when(() => mockService.publishRecipe(formData: any(named: 'formData'))).thenAnswer((_) async => Future.value(mockHttpResponse)); - try { - await repository.publishRecipe(recipe); - verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1); - } catch (e) { - // Игнорируем ошибки, связанные с ImageCompressUtility в тестах - } + await repository.publishRecipe(recipe); + verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1); }); test('publishRecipe - обрабатывает рецепт с videoUrl', () async { - const recipe = RecipeModel( + final recipe = RecipeModel( type: 'main', title: 'Test Recipe', description: 'Test description', videoUrl: 'https://example.com/video.mp4', - image: '/test/path/image.jpg', + image: recipeImage.path, ingredients: null, steps: null, tags: null, @@ -249,24 +343,19 @@ void main() { energyPerServing: 300, ); - when(() => mockService.publishRecipe(formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value(mockHttpResponse)); + when(() => mockService.publishRecipe(formData: any(named: 'formData'))).thenAnswer((_) async => Future.value(mockHttpResponse)); - try { - await repository.publishRecipe(recipe); - verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1); - } catch (e) { - // Игнорируем ошибки, связанные с ImageCompressUtility в тестах - } + await repository.publishRecipe(recipe); + verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1); }); test('publishRecipe - обрабатывает рецепт без videoUrl (null)', () async { - const recipe = RecipeModel( + final recipe = RecipeModel( type: 'main', title: 'Test Recipe', description: 'Test description', videoUrl: null, - image: '/test/path/image.jpg', + image: recipeImage.path, ingredients: null, steps: null, tags: null, @@ -276,24 +365,19 @@ void main() { energyPerServing: 300, ); - when(() => mockService.publishRecipe(formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value(mockHttpResponse)); + when(() => mockService.publishRecipe(formData: any(named: 'formData'))).thenAnswer((_) async => Future.value(mockHttpResponse)); - try { - await repository.publishRecipe(recipe); - verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1); - } catch (e) { - // Игнорируем ошибки, связанные с ImageCompressUtility в тестах - } + await repository.publishRecipe(recipe); + verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1); }); test('publishRecipe - обрабатывает рецепт с пустым videoUrl', () async { - const recipe = RecipeModel( + final recipe = RecipeModel( type: 'main', title: 'Test Recipe', description: 'Test description', videoUrl: '', - image: '/test/path/image.jpg', + image: recipeImage.path, ingredients: null, steps: null, tags: null, @@ -303,24 +387,19 @@ void main() { energyPerServing: 300, ); - when(() => mockService.publishRecipe(formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value(mockHttpResponse)); + when(() => mockService.publishRecipe(formData: any(named: 'formData'))).thenAnswer((_) async => Future.value(mockHttpResponse)); - try { - await repository.publishRecipe(recipe); - verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1); - } catch (e) { - // Игнорируем ошибки, связанные с ImageCompressUtility в тестах - } + await repository.publishRecipe(recipe); + verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1); }); test('publishRecipe - пробрасывает исключение при ошибке', () async { - const recipe = RecipeModel( + final recipe = RecipeModel( type: 'main', title: 'Test Recipe', description: 'Test description', videoUrl: null, - image: '/test/path/image.jpg', + image: recipeImage.path, ingredients: null, steps: null, tags: null, @@ -330,37 +409,21 @@ void main() { energyPerServing: 300, ); - when(() => mockService.publishRecipe(formData: any(named: 'formData'))) - .thenThrow(Exception('Error')); + when(() => mockService.publishRecipe(formData: any(named: 'formData'))).thenThrow(Exception('Error')); - try { - await repository.publishRecipe(recipe); - fail('Should have thrown exception'); - } catch (e) { - expect(e, isA()); - } + expect(() => repository.publishRecipe(recipe), throwsA(isA())); }); test('publishRecipe - обрабатывает рецепт с ингредиентами', () async { - const recipe = RecipeModel( + final recipe = RecipeModel( type: 'main', title: 'Test Recipe', description: 'Test description', videoUrl: null, - image: '/test/path/image.jpg', + image: recipeImage.path, ingredients: [ - Ingredient( - name: 'Apple', - unit: '100', - portion: 'grams', - productId: 1, - ), - Ingredient( - name: 'Banana', - unit: '50', - portion: 'grams', - productId: 2, - ), + const Ingredient(name: 'Apple', unit: '100', portion: 'grams', productId: 1), + const Ingredient(name: 'Banana', unit: '50', portion: 'grams', productId: 2), ], steps: null, tags: null, @@ -370,31 +433,21 @@ void main() { energyPerServing: 300, ); - when(() => mockService.publishRecipe(formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value(mockHttpResponse)); + when(() => mockService.publishRecipe(formData: any(named: 'formData'))).thenAnswer((_) async => Future.value(mockHttpResponse)); - try { - await repository.publishRecipe(recipe); - verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1); - } catch (e) { - // Игнорируем ошибки, связанные с ImageCompressUtility в тестах - } + await repository.publishRecipe(recipe); + verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1); }); test('publishRecipe - обрабатывает рецепт с null ингредиентами', () async { - const recipe = RecipeModel( + final recipe = RecipeModel( type: 'main', title: 'Test Recipe', description: 'Test description', videoUrl: null, - image: '/test/path/image.jpg', + image: recipeImage.path, ingredients: [ - Ingredient( - name: 'Apple', - unit: '100', - portion: 'grams', - productId: 1, - ), + const Ingredient(name: 'Apple', unit: '100', portion: 'grams', productId: 1), null, // null ингредиент должен быть отфильтрован ], steps: null, @@ -405,34 +458,23 @@ void main() { energyPerServing: 300, ); - when(() => mockService.publishRecipe(formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value(mockHttpResponse)); + when(() => mockService.publishRecipe(formData: any(named: 'formData'))).thenAnswer((_) async => Future.value(mockHttpResponse)); - try { - await repository.publishRecipe(recipe); - verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1); - } catch (e) { - // Игнорируем ошибки, связанные с ImageCompressUtility в тестах - } + await repository.publishRecipe(recipe); + verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1); }); test('publishRecipe - обрабатывает рецепт со шагами без изображений', () async { - const recipe = RecipeModel( + final recipe = RecipeModel( type: 'main', title: 'Test Recipe', description: 'Test description', videoUrl: null, - image: '/test/path/image.jpg', + image: recipeImage.path, ingredients: null, steps: [ - Step( - description: 'Step 1 description', - image: null, - ), - Step( - description: 'Step 2 description', - image: '', - ), + const Step(description: 'Step 1 description', image: null), + const Step(description: 'Step 2 description', image: ''), ], tags: null, totalCookingTime: 30, @@ -441,35 +483,24 @@ void main() { energyPerServing: 300, ); - when(() => mockService.publishRecipe(formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value(mockHttpResponse)); + when(() => mockService.publishRecipe(formData: any(named: 'formData'))).thenAnswer((_) async => Future.value(mockHttpResponse)); - try { - await repository.publishRecipe(recipe); - verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1); - } catch (e) { - // Игнорируем ошибки, связанные с ImageCompressUtility в тестах - } + await repository.publishRecipe(recipe); + verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1); }); test('publishRecipe - обрабатывает рецепт со смешанными шагами (с null)', () async { - const recipe = RecipeModel( + final recipe = RecipeModel( type: 'main', title: 'Test Recipe', description: 'Test description', videoUrl: null, - image: '/test/path/image.jpg', + image: recipeImage.path, ingredients: null, steps: [ - Step( - description: 'Step 1 description', - image: null, - ), + const Step(description: 'Step 1 description', image: null), null, // null шаг должен быть отфильтрован - Step( - description: 'Step 3 description', - image: '', - ), + const Step(description: 'Step 3 description', image: ''), ], tags: null, totalCookingTime: 30, @@ -478,24 +509,19 @@ void main() { energyPerServing: 300, ); - when(() => mockService.publishRecipe(formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value(mockHttpResponse)); + when(() => mockService.publishRecipe(formData: any(named: 'formData'))).thenAnswer((_) async => Future.value(mockHttpResponse)); - try { - await repository.publishRecipe(recipe); - verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1); - } catch (e) { - // Игнорируем ошибки, связанные с ImageCompressUtility в тестах - } + await repository.publishRecipe(recipe); + verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1); }); test('publishRecipe - удаляет пустые значения из formMap', () async { - const recipe = RecipeModel( + final recipe = RecipeModel( type: 'main', title: 'Test Recipe', description: '', // Пустое описание должно быть удалено videoUrl: '', // Пустой videoUrl должен быть удален - image: '/test/path/image.jpg', + image: recipeImage.path, ingredients: null, steps: null, tags: null, @@ -505,25 +531,20 @@ void main() { energyPerServing: null, ); - when(() => mockService.publishRecipe(formData: any(named: 'formData'))) - .thenAnswer((_) async => Future.value(mockHttpResponse)); + when(() => mockService.publishRecipe(formData: any(named: 'formData'))).thenAnswer((_) async => Future.value(mockHttpResponse)); - try { - await repository.publishRecipe(recipe); - verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1); - } catch (e) { - // Игнорируем ошибки, связанные с ImageCompressUtility в тестах - } + await repository.publishRecipe(recipe); + verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1); }); test('updateRecipe - пробрасывает исключение при ошибке', () async { const slug = 'recipe-slug'; - const recipe = RecipeModel( + final recipe = RecipeModel( type: 'main', title: 'Test Recipe', description: 'Test description', videoUrl: null, - image: '/test/path/image.jpg', + image: recipeImage.path, ingredients: null, steps: null, tags: null, @@ -533,16 +554,132 @@ void main() { energyPerServing: 300, ); - when(() => mockService.updateRecipe(slug: slug, formData: any(named: 'formData'))) - .thenThrow(Exception('Error')); + when( + () => mockService.updateRecipe( + slug: slug, + formData: any(named: 'formData'), + ), + ).thenThrow(Exception('Error')); - try { - await repository.publishRecipe(recipe, true, slug); - fail('Should have thrown exception'); - } catch (e) { - expect(e, isA()); - } + expect(() => repository.publishRecipe(recipe, true, slug), throwsA(isA())); }); + test('publishRecipe - собирает steps с изображением (MultipartFile)', () async { + final recipe = RecipeModel( + type: 'main', + title: 'Test Recipe', + description: 'Test description', + videoUrl: null, + image: recipeImage.path, + ingredients: null, + steps: [Step(description: 'Step with image', image: stepImage.path)], + tags: null, + totalCookingTime: 30, + activeCookTime: 15, + servings: 4, + energyPerServing: 300, + ); + + when(() => mockService.publishRecipe(formData: any(named: 'formData'))).thenAnswer((_) async => Future.value(mockHttpResponse)); + + await repository.publishRecipe(recipe); + verify(() => mockService.publishRecipe(formData: any(named: 'formData'))).called(1); + }); + + test('saveAsDraft - создает draft (createDraftedRecipe)', () async { + final recipe = RecipeModel( + type: 'main', + title: 'Draft title', + description: 'Draft desc', + videoUrl: null, + image: recipeImage.path, + ingredients: null, + steps: null, + tags: const ['tag1'], + totalCookingTime: 30, + activeCookTime: 15, + servings: 2, + energyPerServing: 200, + ); + + when(() => mockService.createDraftedRecipe(formData: any(named: 'formData'))).thenAnswer((_) async => Future.value(mockHttpResponse)); + + await repository.saveAsDraft(recipe); + verify(() => mockService.createDraftedRecipe(formData: any(named: 'formData'))).called(1); + verifyNever( + () => mockService.updateRecipe( + slug: any(named: 'slug'), + formData: any(named: 'formData'), + ), + ); + }); + + test('saveAsDraft - обновляет draft (updateRecipe)', () async { + const slug = 'draft-slug'; + final recipe = RecipeModel( + type: 'main', + title: 'Draft title', + description: 'Draft desc', + videoUrl: null, + image: recipeImage.path, + ingredients: null, + steps: null, + tags: null, + totalCookingTime: 30, + activeCookTime: 15, + servings: 2, + energyPerServing: 200, + ); + + when( + () => mockService.updateRecipe( + slug: slug, + formData: any(named: 'formData'), + ), + ).thenAnswer((_) async => Future.value(mockHttpResponse)); + + await repository.saveAsDraft(recipe, true, slug); + verify( + () => mockService.updateRecipe( + slug: slug, + formData: any(named: 'formData'), + ), + ).called(1); + }); + + test('saveAsDraft - update=true без slug кидает исключение', () async { + final recipe = RecipeModel( + type: 'main', + title: 'Draft title', + description: 'Draft desc', + videoUrl: null, + image: recipeImage.path, + ingredients: null, + steps: null, + tags: null, + totalCookingTime: 30, + activeCookTime: 15, + servings: 2, + energyPerServing: 200, + ); + + // slug! должен упасть + expect(() => repository.saveAsDraft(recipe, true, null), throwsA(anything)); + }); + + test('draftExistingRecipe - вызывает draftRecipe', () async { + const id = 123; + when(() => mockService.draftRecipe(id: id)).thenAnswer((_) async => Future.value()); + + await repository.draftExistingRecipe(id); + verify(() => mockService.draftRecipe(id: id)).called(1); + }); + + test('draftExistingRecipe - пробрасывает исключение при ошибке', () async { + const id = 123; + when(() => mockService.draftRecipe(id: id)).thenThrow(Exception('Error')); + + expect(() => repository.draftExistingRecipe(id), throwsA(isA())); + }); }); } diff --git a/test/features/dev_tools/presentation/dev_tools_screen_smoke_test.dart b/test/features/dev_tools/presentation/dev_tools_screen_smoke_test.dart index 7995d87e..4f7e70ec 100644 --- a/test/features/dev_tools/presentation/dev_tools_screen_smoke_test.dart +++ b/test/features/dev_tools/presentation/dev_tools_screen_smoke_test.dart @@ -3,6 +3,7 @@ import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/features/dev_tools/presentation/dev_tools_screen.dart'; +import 'package:dio/dio.dart'; import 'package:flutter/material.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -16,6 +17,7 @@ void main() { setUp(() async { await sl.reset(); sl.registerLazySingleton(() => AppConfig(buildType: AppBuildType.tests)..setBaseUrl('http://localhost')); + sl.registerLazySingleton(() => Dio(BaseOptions(baseUrl: 'http://localhost'))); }); Widget wrap(Widget child) { @@ -39,5 +41,29 @@ void main() { expect(find.byType(DevToolsScreenContent), findsOneWidget); }); + + testWidgets('updates base url from input by done button', (tester) async { + await tester.pumpWidget(wrap(const DevToolsScreen())); + await tester.pump(); + + await tester.enterText(find.byType(TextFormField).first, 'HTTP://EXAMPLE.COM'); + await tester.tap(find.byIcon(Icons.done)); + await tester.pump(); + + expect(find.byType(DevToolsScreenContent), findsOneWidget); + }); + + testWidgets('toggle base url button is tappable', (tester) async { + await tester.pumpWidget(wrap(const DevToolsScreen())); + await tester.pump(); + + final toggleButton = find.text('Set production base url'); + expect(toggleButton, findsOneWidget); + + await tester.tap(toggleButton); + await tester.pump(); + + expect(find.byType(DevToolsScreenContent), findsOneWidget); + }); }); } diff --git a/test/features/dev_tools/test_cases_bulk_test.dart b/test/features/dev_tools/test_cases_bulk_test.dart new file mode 100644 index 00000000..da212b8f --- /dev/null +++ b/test/features/dev_tools/test_cases_bulk_test.dart @@ -0,0 +1,107 @@ +import 'package:eatvio_app/features/dev_tools/test_cases.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('DevTools TestCases bulk', () { + test('all case names are readable', () { + final cases = [ + CreateRecipeTask(), + Test850task(), + GetChatRoomPreviews(), + GetChatRoom(), + SendMessage(), + GetChatMessages(), + GetChatRoomIdByUserSlug(), + GetBlogPostList(), + GetRecipes(), + GetDiets(), + GetPlans(), + GetBlogPostComments(), + SendBlogPostComment(), + GetDietsComments(), + SendDietsComment(), + GetPlansComments(), + SendPlansComment(), + GetRecipesComments(), + AuthServiceTest(), + DiaryServiceTest(), + BlockedUserServiceTest(), + PlanServiceTest(), + AboutRecipeServiceTest(), + BlogPostServiceTest(), + CalendarServiceTest(), + CardsServiceTest(), + ConsultantServiceTest(), + ConsultationServiceTest(), + CreateDietServiceTest(), + CreatePlanServiceTest(), + GlossaryServiceTest(), + MyDietsServiceTest(), + MyPlansServiceTest(), + MyRecipesServiceTest(), + NotificationsServiceTest(), + PlansServiceTest(), + ProfileServiceTest(), + RegistrationServiceTest(), + ResetPasswordServiceTest(), + SettingsServiceTest(), + WsServiceTest(), + ]; + + for (final c in cases) { + expect(c.name, isNotEmpty); + } + }); + + test('run() does not throw for all cases', () async { + final cases = [ + CreateRecipeTask(), + Test850task(), + GetChatRoomPreviews(), + GetChatRoom(), + SendMessage(), + GetChatMessages(), + GetChatRoomIdByUserSlug(), + GetBlogPostList(), + GetRecipes(), + GetDiets(), + GetPlans(), + GetBlogPostComments(), + SendBlogPostComment(), + GetDietsComments(), + SendDietsComment(), + GetPlansComments(), + SendPlansComment(), + GetRecipesComments(), + AuthServiceTest(), + DiaryServiceTest(), + BlockedUserServiceTest(), + PlanServiceTest(), + AboutRecipeServiceTest(), + BlogPostServiceTest(), + CalendarServiceTest(), + CardsServiceTest(), + ConsultantServiceTest(), + ConsultationServiceTest(), + CreateDietServiceTest(), + CreatePlanServiceTest(), + GlossaryServiceTest(), + MyDietsServiceTest(), + MyPlansServiceTest(), + MyRecipesServiceTest(), + NotificationsServiceTest(), + PlansServiceTest(), + ProfileServiceTest(), + RegistrationServiceTest(), + ResetPasswordServiceTest(), + SettingsServiceTest(), + WsServiceTest(), + ]; + + for (final c in cases) { + final result = await c.run(baseUrl: 'http://localhost'); + expect(result, isA()); + } + }); + }); +} diff --git a/test/features/notifications/models/notification_model_fromjson_bulk_test.dart b/test/features/notifications/models/notification_model_fromjson_bulk_test.dart new file mode 100644 index 00000000..8ee7fe97 --- /dev/null +++ b/test/features/notifications/models/notification_model_fromjson_bulk_test.dart @@ -0,0 +1,121 @@ +import 'package:eatvio_app/features/notifications/models/notification_model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +/// Smoke coverage for [fromJson] factories not exercised elsewhere — improves line coverage on large model file. +void main() { + const c = '2024-06-01T12:00:00Z'; + final base = {'created_at': c}; + + final planAndUser = {'created_at': c, 'plan_name': 'p', 'user_name': 'u', 'renewal_date': '2024-06-02'}; + + final scoreLike = { + 'created_at': c, + 'score_key': 'k', + 'scorable_id': 1, + 'scorable_type': 'Type', + 'score_value': 2, + }; + + final commentReply = { + 'created_at': c, + 'entity_name': 'e', + 'commented_entity_id': 1, + 'commented_entity_type': 'diet', + 'author': 'a', + 'author_id': 2, + 'parrent_comment_id': 3, + }; + + final entityCommented = { + 'created_at': c, + 'entity_name': 'e', + 'entity_id': 1, + 'entity_type': 'diet', + 'entity_traslated_type': 't', + 'entity_url': 'u', + 'author_name': 'a', + 'author_id': 2, + 'author_slug': 's', + }; + + group('CommentedEntityType.fromJson', () { + test('maps known and legacy keys', () { + expect(CommentedEntityType.fromJson('diet'), CommentedEntityType.diet); + expect(CommentedEntityType.fromJson('Modules\\UserGeneratedPublicContent\\Models\\Diet'), CommentedEntityType.diet); + expect(CommentedEntityType.fromJson('blog-post'), CommentedEntityType.blogPost); + expect(CommentedEntityType.fromJson('Modules\\UserGeneratedPublicContent\\Models\\BlogPost'), CommentedEntityType.blogPost); + expect(CommentedEntityType.fromJson('other'), CommentedEntityType.unknown); + }); + }); + + group('notification fromJson bulk smoke', () { + test('executes remaining model factories with minimal JSON', () { + PlanSubscriptionExpiredNotification.fromJson(planAndUser, NotificationType.planSubscriptionExpired); + PlanSubscriptionRenewedNotification.fromJson(planAndUser, NotificationType.planSubscriptionRenewed); + ChosenPlanStartedToConsultantNotification.fromJson(base, NotificationType.chosenPlanStartedToConsultant); + ChosenPlanTomorrowReminderToConsultantNotification.fromJson(base, NotificationType.chosenPlanTomorrowReminderToConsultant); + PlanSubscriptionPaidToAuthorNotification.fromJson(base, NotificationType.planSubscriptionPaidToAuthor); + ChosenPlanFinishedToConsultantNotification.fromJson(base, NotificationType.chosenPlanFinishedToConsultant); + ChosenPlanLastDayFinishedForUser.fromJson(base, NotificationType.chosenPlanLastDayFinishedForUser); + ChosenPlanConsultantLastDayStarted.fromJson(base, NotificationType.chosenPlanConsultantLastDayStarted); + ComplaintToModerationForUser.fromJson(base, NotificationType.complaintToModerationForUser); + CommentReplied.fromJson(commentReply, NotificationType.commentReplied); + ScoreAdded.fromJson(scoreLike, NotificationType.scoreAdded); + ScoreSubstractedNotification.fromJson(scoreLike, NotificationType.scoreSubstracted); + EntityCommented.fromJson(entityCommented, NotificationType.entityCommented); + UnknownNotification.fromJson(base, NotificationType.unknown); + + ConsultantConfirmPlanSubscriptionToSubscriberNotification.fromJson( + base, + NotificationType.consultantConfirmPlanSubscriptionToSubscriber, + ); + ClientPaidPlanSubscriptionToAuthorNotification.fromJson(base, NotificationType.clientPaidPlanSubscriptionToAuthor); + ConsultantLeftDiaryCommentNotification.fromJson(base, NotificationType.consultantLeftDiaryComment); + ConsultantConfirmPlanSubscriptionToAuthorNotification.fromJson(base, NotificationType.consultantConfirmPlanSubscriptionToAuthor); + ClientPaidPlanSubscriptionToSubscriberNotification.fromJson(base, NotificationType.clientPaidPlanSubscriptionToSubscriber); + ClientStartPlanSubscriptionToAuthorNotification.fromJson(base, NotificationType.clientStartPlanSubscriptionToAuthor); + ClientCancelPlanSubscriptionToAuthorNotification.fromJson(base, NotificationType.clientCancelPlanSubscriptionToAuthor); + ClientCancelPlanSubscriptionToSubscriberNotification.fromJson(base, NotificationType.clientCancelPlanSubscriptionToSubscriber); + UserDailyReportCompletedNotification.fromJson(base, NotificationType.userDailyReportCompleted); + StayOnTrackToCustomerNotification.fromJson(base, NotificationType.stayOnTrackToCustomer); + PhoneChangedNotification.fromJson(base, NotificationType.phoneChanged); + SubscriptionNotification.fromJson(base, NotificationType.subscription); + BadgeAwardedNotification.fromJson(base, NotificationType.badgeAwarded); + BadgeRevokedNotification.fromJson(base, NotificationType.badgeRevoked); + ConsultantRequestStartedNotification.fromJson(base, NotificationType.consultantRequestStarted); + ClientCloseComplaintSuccessNotification.fromJson(base, NotificationType.clientCloseComplaintSuccess); + ClientRefundSuccessNotification.fromJson(base, NotificationType.clientRefundSuccess); + ConsultantRefundSuccessNotification.fromJson(base, NotificationType.consultantRefundSuccess); + ClientRejectSuccessNotification.fromJson(base, NotificationType.clientRejectSuccess); + ConsultantRejectSuccessNotification.fromJson(base, NotificationType.consultantRejectSuccess); + ProductStatusUpdatedForAuthorNotification.fromJson(base, NotificationType.productStatusUpdatedForAuthor); + DiaryReminderForUsersNotification.fromJson(base, NotificationType.diaryReminderForUsers); + DiaryReminderForUsersNotification.fromJson({'created_at': c, 'data': {}}, NotificationType.diaryReminderForUsers); + PersonalDietIsDefinedToCustomerNotification.fromJson(base, NotificationType.personalDietIsDefinedToCustomer); + ConsultantEducationUpdatedNotification.fromJson(base, NotificationType.consultantEducationUpdated); + ComplaintRegisteredNotification.fromJson(base, NotificationType.complaintRegistered); + ContentUnpublishedNotification.fromJson(base, NotificationType.contentUnpublished); + FinanceQuestionCreatedNotification.fromJson(base, NotificationType.financeQuestionCreated); + NewChatMessageNotification.fromJson(base, NotificationType.newChatMessage); + CompleteRegistrationNotification.fromJson(base, NotificationType.completeRegistration); + ChosenDietExpiredNotification.fromJson(base, NotificationType.chosenDietExpired); + MotivateUserNotification.fromJson(base, NotificationType.motivateUser); + CommentComplaintNotification.fromJson(base, NotificationType.commentComplaint); + CommentDeletedNotification.fromJson(base, NotificationType.commentDeleted); + UserSubscribedNotification.fromJson(base, NotificationType.userSubscribed); + }); + + test('executes additional factories used by legacy and plan flows', () { + ClientStartPlanSubscriptionToSubscriberNotification.fromJson(base, NotificationType.clientStartPlanSubscriptionToSubscriber); + ChosenPlanFinishedToClientNotification.fromJson(base, NotificationType.chosenPlanFinishedToClient); + PlanSubscriptionCanceledToCustomerNotification.fromJson(base, NotificationType.planSubscriptionCanceledToCustomer); + PlanSubscriptionCanceledToAuthorNotification.fromJson(planAndUser, NotificationType.planSubscriptionCanceledToAuthor); + ChosenPlanStartedToClientNotification.fromJson(planAndUser, NotificationType.chosenPlanStartedToClient); + ChosenPlanTomorrowReminderToClientNotification.fromJson(planAndUser, NotificationType.chosenPlanTomorrowReminderToClient); + DiaryReminderNotification.fromJson(base, NotificationType.diaryReminderNotification); + ConsultantLeftCommentNotification.fromJson(base, NotificationType.consultantLeftComment); + PersonalizedDietIsDefinedNotification.fromJson(base, NotificationType.personalizedDietIsDefined); + PlanSubscriptionStartedToAuthorNotification.fromJson(planAndUser, NotificationType.planSubscriptionStartedToAuthor); + }); + }); +} diff --git a/test/features/plans/presentation/cubit/about_plan_cubit_test.dart b/test/features/plans/presentation/cubit/about_plan_cubit_test.dart index 52b9bc0b..ccc1b39a 100644 --- a/test/features/plans/presentation/cubit/about_plan_cubit_test.dart +++ b/test/features/plans/presentation/cubit/about_plan_cubit_test.dart @@ -71,7 +71,7 @@ void main() { when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan); - await aboutPlanCubit!.getPlan('test-plan'); + await aboutPlanCubit!.getPlan(slug: 'test-plan'); expect(aboutPlanCubit!.state.planModel, isNotNull); expect(aboutPlanCubit!.state.planModel!.id, 1); @@ -157,7 +157,7 @@ void main() { when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan); when(mockPlanRepository.likePlan('test-plan')).thenAnswer((_) async => {}); - await aboutPlanCubit!.getPlan('test-plan'); + await aboutPlanCubit!.getPlan(slug: 'test-plan'); await aboutPlanCubit!.likePlan(index: null, slug: 'test-plan'); await Future.delayed(const Duration(milliseconds: 1200)); @@ -170,7 +170,7 @@ void main() { when(mockPlanRepository.getPlan('test-plan')).thenThrow(Exception('Ошибка загрузки')); - await aboutPlanCubit!.getPlan('test-plan'); + await aboutPlanCubit!.getPlan(slug: 'test-plan'); expect(aboutPlanCubit!.state.error, isNotNull); expect(aboutPlanCubit!.state.isLoading, isFalse); @@ -220,7 +220,7 @@ void main() { when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan); when(mockPlanRepository.likePlan('test-plan')).thenAnswer((_) async => {}); - await aboutPlanCubit!.getPlan('test-plan'); + await aboutPlanCubit!.getPlan(slug: 'test-plan'); await aboutPlanCubit!.likePlan(index: null, slug: 'test-plan'); expect(aboutPlanCubit!.state.planModel!.wasLikedByMe, isFalse); @@ -263,7 +263,7 @@ void main() { when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan); when(mockPlanRepository.addToFavorite('test-plan')).thenAnswer((_) async => Future.value()); - await aboutPlanCubit!.getPlan('test-plan'); + await aboutPlanCubit!.getPlan(slug: 'test-plan'); await aboutPlanCubit!.addToFavorite(slug: 'test-plan', index: null); expect(aboutPlanCubit!.state.planModel!.isFavorite, true); @@ -305,7 +305,7 @@ void main() { when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan); when(mockPlanRepository.removeFromFavorite('test-plan')).thenAnswer((_) async => Future.value()); - await aboutPlanCubit!.getPlan('test-plan'); + await aboutPlanCubit!.getPlan(slug: 'test-plan'); await aboutPlanCubit!.removeFromFavorite(slug: 'test-plan', index: null); expect(aboutPlanCubit!.state.planModel!.isFavorite, false); @@ -357,7 +357,7 @@ void main() { when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan); when(mockPlanRepository.chosePlan('test-plan', any)).thenAnswer((_) async => chosenPlan); - await aboutPlanCubit!.getPlan('test-plan'); + await aboutPlanCubit!.getPlan(slug: 'test-plan'); aboutPlanCubit!.choseOption(0); final result = await aboutPlanCubit!.chosePlan(); @@ -405,7 +405,7 @@ void main() { ), ); - await aboutPlanCubit!.getPlan('test-plan'); + await aboutPlanCubit!.getPlan(slug: 'test-plan'); aboutPlanCubit!.choseOption(0); final result = await aboutPlanCubit!.chosePlan(); @@ -447,7 +447,7 @@ void main() { when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan); when(mockPlanRepository.likePlan('test-plan')).thenAnswer((_) async => {}); - await aboutPlanCubit!.getPlan('test-plan'); + await aboutPlanCubit!.getPlan(slug: 'test-plan'); await aboutPlanCubit!.likePlan(index: 5, slug: 'test-plan'); expect(aboutPlanCubit!.state.planModel!.wasLikedByMe, isTrue); @@ -490,7 +490,7 @@ void main() { when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan); when(mockPlanRepository.likePlan('test-plan')).thenAnswer((_) async => {}); - await aboutPlanCubit!.getPlan('test-plan'); + await aboutPlanCubit!.getPlan(slug: 'test-plan'); await aboutPlanCubit!.likePlan(index: 5, slug: 'test-plan'); expect(aboutPlanCubit!.state.planModel!.wasLikedByMe, isFalse); @@ -533,7 +533,7 @@ void main() { when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan); when(mockPlanRepository.likePlan('test-plan')).thenThrow(Exception('Ошибка')); - await aboutPlanCubit!.getPlan('test-plan'); + await aboutPlanCubit!.getPlan(slug: 'test-plan'); await aboutPlanCubit!.likePlan(index: 5, slug: 'test-plan'); // Проверяем, что состояние изменилось сразу после вызова @@ -583,7 +583,7 @@ void main() { when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan); when(mockPlanRepository.addToFavorite('test-plan')).thenAnswer((_) async => Future.value()); - await aboutPlanCubit!.getPlan('test-plan'); + await aboutPlanCubit!.getPlan(slug: 'test-plan'); await aboutPlanCubit!.addToFavorite(slug: 'test-plan', index: 5); expect(aboutPlanCubit!.state.planModel!.isFavorite, true); @@ -624,7 +624,7 @@ void main() { when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan); when(mockPlanRepository.addToFavorite('test-plan')).thenThrow(Exception('Ошибка')); - await aboutPlanCubit!.getPlan('test-plan'); + await aboutPlanCubit!.getPlan(slug: 'test-plan'); await aboutPlanCubit!.addToFavorite(slug: 'test-plan', index: 5); // Проверяем, что состояние изменилось сразу после вызова @@ -673,7 +673,7 @@ void main() { when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan); when(mockPlanRepository.removeFromFavorite('test-plan')).thenAnswer((_) async => Future.value()); - await aboutPlanCubit!.getPlan('test-plan'); + await aboutPlanCubit!.getPlan(slug: 'test-plan'); await aboutPlanCubit!.removeFromFavorite(slug: 'test-plan', index: 5); expect(aboutPlanCubit!.state.planModel!.isFavorite, false); @@ -714,7 +714,7 @@ void main() { when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan); when(mockPlanRepository.removeFromFavorite('test-plan')).thenThrow(Exception('Ошибка')); - await aboutPlanCubit!.getPlan('test-plan'); + await aboutPlanCubit!.getPlan(slug: 'test-plan'); await aboutPlanCubit!.removeFromFavorite(slug: 'test-plan', index: 5); // Проверяем, что состояние изменилось сразу после вызова @@ -773,7 +773,7 @@ void main() { when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan); when(mockPlanRepository.chosePlan('test-plan', any)).thenAnswer((_) async => chosenPlan); - await aboutPlanCubit!.getPlan('test-plan'); + await aboutPlanCubit!.getPlan(slug: 'test-plan'); aboutPlanCubit!.choseOption(1); final result = await aboutPlanCubit!.chosePlan(); @@ -826,7 +826,7 @@ void main() { when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan); when(mockPlanRepository.chosePlan('test-plan', any)).thenAnswer((_) async => chosenPlan); - await aboutPlanCubit!.getPlan('test-plan'); + await aboutPlanCubit!.getPlan(slug: 'test-plan'); aboutPlanCubit!.choseOption(2); final result = await aboutPlanCubit!.chosePlan(); @@ -884,7 +884,7 @@ void main() { ), ); - await aboutPlanCubit!.getPlan('test-plan'); + await aboutPlanCubit!.getPlan(slug: 'test-plan'); aboutPlanCubit!.choseOption(0); final result = await aboutPlanCubit!.chosePlan(); @@ -926,7 +926,7 @@ void main() { when(mockPlanRepository.getPlan('test-plan')).thenAnswer((_) async => testPlan); when(mockPlanRepository.chosePlan('test-plan', any)).thenThrow(Exception('Общая ошибка')); - await aboutPlanCubit!.getPlan('test-plan'); + await aboutPlanCubit!.getPlan(slug: 'test-plan'); aboutPlanCubit!.choseOption(0); final result = await aboutPlanCubit!.chosePlan(); diff --git a/test/features/plans/presentation/pages/about_plan_screen_smoke_test.dart b/test/features/plans/presentation/pages/about_plan_screen_smoke_test.dart index 4fa46bb3..040271d1 100644 --- a/test/features/plans/presentation/pages/about_plan_screen_smoke_test.dart +++ b/test/features/plans/presentation/pages/about_plan_screen_smoke_test.dart @@ -4,6 +4,7 @@ import 'package:eatvio_app/core/comments/comments_cubit/comments_cubit.dart'; import 'package:eatvio_app/core/comments/comments_cubit/comments_state.dart'; import 'package:eatvio_app/core/locator_service.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; +import 'package:eatvio_app/core/widgets/bookmark_button.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:eatvio_app/features/feed/models/plan_pagination_model.dart'; import 'package:eatvio_app/features/feed/presentation/bloc/feed_cubit/feed_cubit.dart'; @@ -167,7 +168,7 @@ void main() { when(planCubit.state).thenReturn(state); when(planCubit.stream).thenAnswer((_) => const Stream.empty()); when(planCubit.close()).thenAnswer((_) async => Future.value()); - when(planCubit.getPlan(any)).thenAnswer((_) async => Future.value()); + when(planCubit.getPlan(slug: anyNamed('slug'), index: anyNamed('index'))).thenAnswer((_) async => Future.value()); when(feedCubit.viewPlan(any)).thenAnswer((_) async => Future.value()); when(feedCubit.stream).thenAnswer((_) => const Stream.empty()); when(feedCubit.close()).thenAnswer((_) async => Future.value()); @@ -183,6 +184,95 @@ void main() { expect(find.byType(AboutPlanScreen), findsOneWidget); }); + testWidgets('error state try again calls getPlan', (tester) async { + final planCubit = MockAboutPlanCubit(); + final feedCubit = MockFeedCubit(); + final commentsCubit = MockCommentsCubit(); + const state = AboutPlanState(isLoading: false, error: 'Error loading plan', planModel: testPlan, chosenOptionIndex: 0); + + when(planCubit.state).thenReturn(state); + when(planCubit.stream).thenAnswer((_) => const Stream.empty()); + when(planCubit.close()).thenAnswer((_) async => Future.value()); + when(planCubit.getPlan(slug: anyNamed('slug'), index: anyNamed('index'))).thenAnswer((_) async => Future.value()); + when(feedCubit.viewPlan(any)).thenAnswer((_) async => Future.value()); + when(feedCubit.stream).thenAnswer((_) => const Stream.empty()); + when(feedCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.state).thenReturn(const CommentsState()); + when(commentsCubit.stream).thenAnswer((_) => const Stream.empty()); + when(commentsCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future.value()); + + registerMocks(planCubit, commentsCubit); + + await tester.pumpWidget(wrap(planCubit, feedCubit, commentsCubit, const AboutPlanScreen(slug: 'test', navigateToComments: false, index: null, highlightedCommentId: null))); + await tester.pump(); + + await tester.tap(find.text('Попробовать снова')); + await tester.pump(); + + verify(planCubit.getPlan(slug: 'test', index: null)).called(1); + }); + + testWidgets('loading state back button does not throw', (tester) async { + final planCubit = MockAboutPlanCubit(); + final feedCubit = MockFeedCubit(); + final commentsCubit = MockCommentsCubit(); + const state = AboutPlanState(isLoading: true, error: null, planModel: null, chosenOptionIndex: 0); + + when(planCubit.state).thenReturn(state); + when(planCubit.stream).thenAnswer((_) => const Stream.empty()); + when(planCubit.close()).thenAnswer((_) async => Future.value()); + when(feedCubit.viewPlan(any)).thenAnswer((_) async => Future.value()); + when(feedCubit.stream).thenAnswer((_) => const Stream.empty()); + when(feedCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.state).thenReturn(const CommentsState()); + when(commentsCubit.stream).thenAnswer((_) => const Stream.empty()); + when(commentsCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future.value()); + + registerMocks(planCubit, commentsCubit); + + await tester.pumpWidget(wrap(planCubit, feedCubit, commentsCubit, const AboutPlanScreen(slug: 'test', navigateToComments: false, index: null, highlightedCommentId: null))); + await tester.pump(); + + await tester.tap(find.byType(BackButton)); + await tester.pump(); + + expect(find.byType(AboutPlanScreen), findsOneWidget); + }); + + testWidgets('loaded state bookmark tap calls addToFavorite when not favorite', (tester) async { + final planCubit = MockAboutPlanCubit(); + final feedCubit = MockFeedCubit(); + final commentsCubit = MockCommentsCubit(); + const state = AboutPlanState(isLoading: false, planModel: testPlan, chosenOptionIndex: 0, error: null); + + when(planCubit.state).thenReturn(state); + when(planCubit.stream).thenAnswer((_) => const Stream.empty()); + when(planCubit.close()).thenAnswer((_) async => Future.value()); + when(planCubit.addToFavorite(slug: anyNamed('slug'), index: anyNamed('index'))).thenAnswer((_) async => Future.value()); + when(feedCubit.viewPlan(any)).thenAnswer((_) async => Future.value()); + when(feedCubit.stream).thenAnswer((_) => const Stream.empty()); + when(feedCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.state).thenReturn(const CommentsState()); + when(commentsCubit.stream).thenAnswer((_) => const Stream.empty()); + when(commentsCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future.value()); + + registerMocks(planCubit, commentsCubit); + + await mockNetworkImagesFor(() async { + await tester.pumpWidget(wrap(planCubit, feedCubit, commentsCubit, const AboutPlanScreen(slug: 'test-plan', navigateToComments: false, index: 7, highlightedCommentId: null))); + await tester.pump(); + await tester.pump(); + }); + + await tester.tap(find.byType(BookmarkButton)); + await tester.pump(); + + verify(planCubit.addToFavorite(slug: 'test-plan', index: 7)).called(1); + }); + testWidgets('renders with navigateToComments true', (tester) async { final planCubit = MockAboutPlanCubit(); final feedCubit = MockFeedCubit(); diff --git a/test/features/recipe/presentation/pages/about_recipe_screen_smoke_test.dart b/test/features/recipe/presentation/pages/about_recipe_screen_smoke_test.dart index 9bc22016..5b1eb97a 100644 --- a/test/features/recipe/presentation/pages/about_recipe_screen_smoke_test.dart +++ b/test/features/recipe/presentation/pages/about_recipe_screen_smoke_test.dart @@ -194,6 +194,63 @@ void main() { expect(find.byType(AboutRecipeScreen), findsOneWidget); }); + testWidgets('error state try again calls getRecipe', (tester) async { + final recipeCubit = MockAboutRecipeCubit(); + final feedCubit = MockFeedCubit(); + final commentsCubit = MockCommentsCubit(); + const state = AboutRecipeState.error(message: 'Error loading recipe'); + + when(recipeCubit.state).thenReturn(state); + when(recipeCubit.stream).thenAnswer((_) => const Stream.empty()); + when(recipeCubit.close()).thenAnswer((_) async => Future.value()); + when(recipeCubit.getRecipe(any, index: anyNamed('index'))).thenAnswer((_) async => Future.value()); + when(feedCubit.viewRecipe(any)).thenAnswer((_) async => Future.value()); + when(feedCubit.stream).thenAnswer((_) => const Stream.empty()); + when(feedCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.state).thenReturn(const CommentsState()); + when(commentsCubit.stream).thenAnswer((_) => const Stream.empty()); + when(commentsCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future.value()); + + registerMocks(recipeCubit, commentsCubit); + + await tester.pumpWidget(wrap(recipeCubit, feedCubit, commentsCubit, const AboutRecipeScreen(recipeSlug: 'test', index: 3, navigateToComments: false))); + await tester.pump(); + + await tester.tap(find.text('Попробовать снова')); + await tester.pump(); + + verify(recipeCubit.getRecipe('test', index: 3)).called(1); + }); + + testWidgets('loading state back button does not throw', (tester) async { + final recipeCubit = MockAboutRecipeCubit(); + final feedCubit = MockFeedCubit(); + final commentsCubit = MockCommentsCubit(); + const state = AboutRecipeState.loading(); + + when(recipeCubit.state).thenReturn(state); + when(recipeCubit.stream).thenAnswer((_) => const Stream.empty()); + when(recipeCubit.close()).thenAnswer((_) async => Future.value()); + when(feedCubit.viewRecipe(any)).thenAnswer((_) async => Future.value()); + when(feedCubit.stream).thenAnswer((_) => const Stream.empty()); + when(feedCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.state).thenReturn(const CommentsState()); + when(commentsCubit.stream).thenAnswer((_) => const Stream.empty()); + when(commentsCubit.close()).thenAnswer((_) async => Future.value()); + when(commentsCubit.getComments(any, any, any)).thenAnswer((_) async => Future.value()); + + registerMocks(recipeCubit, commentsCubit); + + await tester.pumpWidget(wrap(recipeCubit, feedCubit, commentsCubit, const AboutRecipeScreen(recipeSlug: 'test', index: null, navigateToComments: false))); + await tester.pump(); + + await tester.tap(find.byType(BackButton)); + await tester.pump(); + + expect(find.byType(AboutRecipeScreen), findsOneWidget); + }); + testWidgets('renders with navigateToComments true', (tester) async { final recipeCubit = MockAboutRecipeCubit(); final feedCubit = MockFeedCubit();