diff --git a/analysis_options.yaml b/analysis_options.yaml index 63d4f990..d352ade8 100644 --- a/analysis_options.yaml +++ b/analysis_options.yaml @@ -4,7 +4,8 @@ linter: rules: prefer_const_constructors: true prefer_const_declarations: true - prefer_const_literals_to_create_immutables: true + prefer_const_literals_to_create_immutables: true + use_null_aware_elements: false analyzer: exclude: diff --git a/lib/features/diary/presentation/bloc/diary_cubit.dart b/lib/features/diary/presentation/bloc/diary_cubit.dart index de24b818..af3e9490 100644 --- a/lib/features/diary/presentation/bloc/diary_cubit.dart +++ b/lib/features/diary/presentation/bloc/diary_cubit.dart @@ -1,9 +1,11 @@ +import 'dart:async'; import 'dart:developer'; import 'dart:io'; import 'package:dio/dio.dart'; import 'package:eatvio_app/core/image_compressing/image_compress_utility.dart'; import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart'; +import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_state.dart'; import 'package:eatvio_app/core/local_db/diary_local_data_source.dart'; import 'package:eatvio_app/core/utils/date_time_utils.dart'; import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; @@ -41,6 +43,9 @@ class DiaryCubit extends Cubit { // Трек офлайн-режима bool _lastFetchWasOffline = false; + // Подписка на изменения connectivity + StreamSubscription? _connectivitySubscription; + DiaryCubit({ required this.chatRepository, required this.diaryRepository, @@ -56,6 +61,13 @@ class DiaryCubit extends Cubit { diaryRepository.forceFetchStream.listen((_) { fetchDiaryV2(needLoading: true, diaryId: diaryId); }); + // При переходе offline→online перезагружаем дневник + _connectivitySubscription = connectionCubit.stream.listen((connState) { + if (connState.isInternetAvailable && _lastFetchWasOffline) { + log('[DiaryCubit] Internet restored, reloading diary...'); + fetchDiaryV2(needLoading: false, diaryId: diaryId); + } + }); } String comment = ''; @@ -149,22 +161,23 @@ class DiaryCubit extends Cubit { Future getDiary() async { _lastFetchWasOffline = false; final dayStr = selectedDay.toISO8601WithTimeZoneOffset(); - log('[getDiary] START: day=$dayStr, isOnline=${connectionCubit.state.isInternetAvailable}'); + final isOnline = connectionCubit.state.isInternetAvailable; + log('[getDiary] START: day=$dayStr, isOnline=$isOnline'); - // Офлайн: СРАЗУ в кэш, не трогаем сеть - if (!connectionCubit.state.isInternetAvailable) { - _lastFetchWasOffline = true; + // Офлайн: сначала пробуем кэш + if (!isOnline) { try { final result = await diaryRepository.getDiaryDayV2(day: dayStr); log('[getDiary] OFFLINE: loaded from cache OK'); + _lastFetchWasOffline = true; return result; - } catch (e) { - log('[getDiary] OFFLINE: no cache: $e'); - return null; // Нет кэша — вернём null, UI покажет offline-заглушку + } catch (_) { + // Кэша нет — попробуем сеть на случай ложного "offline" + log('[getDiary] OFFLINE: no cache, trying network as fallback...'); } } - // Онлайн: создаём день + грузим с сервера + // Онлайн (или fallback после неудачного offline): создаём день + грузим с сервера try { log('[getDiary] ONLINE: creating diary day...'); await diaryRepository.createDiaryDay(day: dayStr); @@ -592,6 +605,12 @@ class DiaryCubit extends Cubit { this.comment = comment; commentNeedsToBeFilled = comment.isNotEmpty && comment.length < 3; } + + @override + Future close() { + _connectivitySubscription?.cancel(); + return super.close(); + } } enum DiaryFetchingErrors { canNotCreateFutureDays, dayAlreadyExists } diff --git a/lib/features/diary/repositories/diary_repository.dart b/lib/features/diary/repositories/diary_repository.dart index 72105887..d7578e43 100644 --- a/lib/features/diary/repositories/diary_repository.dart +++ b/lib/features/diary/repositories/diary_repository.dart @@ -265,25 +265,27 @@ class DiaryRepository { /// Cache-Aside: сначала сеть, при ошибке → возврат из локального кэша. Future getDiaryDayV2({required String day}) async { final dateOnly = day.substring(0, 10); // YYYY-MM-DD - if (_isOnline) { - try { - final diaryDay = await diaryService.getDiaryDayV2(day: day); - final enriched = await setCPFCToDiary(diaryDay); - // Кэшируем успешный ответ локально - await _cacheDiaryDayModel(dateOnly, enriched); - return enriched; - } catch (e, s) { - await Sentry.captureException(e, stackTrace: s); - // Fallback на локальный кэш - final cached = await _loadCachedDiaryDay(dateOnly); - if (cached != null) return cached; - rethrow; - } - } else { - // Офлайн — сразу из кэша + + // Офлайн: пробуем кэш, если нет — пробуем сеть (connectivity может ошибаться) + if (!_isOnline) { final cached = await _loadCachedDiaryDay(dateOnly); if (cached != null) return cached; - throw Exception('Нет соединения и нет кэша для $dateOnly'); + // Fallthrough: кэша нет, попробуем сеть на случай ложного offline + } + + // Онлайн (или fallback из offline) + try { + final diaryDay = await diaryService.getDiaryDayV2(day: day); + final enriched = await setCPFCToDiary(diaryDay); + // Кэшируем успешный ответ локально + await _cacheDiaryDayModel(dateOnly, enriched); + return enriched; + } catch (e, s) { + await Sentry.captureException(e, stackTrace: s); + // Fallback на локальный кэш + final cached = await _loadCachedDiaryDay(dateOnly); + if (cached != null) return cached; + rethrow; } } @@ -293,26 +295,19 @@ class DiaryRepository { // Всегда кэшируем локально await localDataSource.cacheDiaryDay(date: dateOnly); - if (_isOnline) { - try { - final idempotencyKey = _uuid.v4(); - await diaryService.createDay( - idempotencyKey: idempotencyKey, - body: CreateDayModel(day: day), - ); - } catch (e, s) { - if (e is DioException && e.response?.statusCode == 422) { - return; // День уже существует — ОК - } - await Sentry.captureException(e, stackTrace: s); - // Добавляем в очередь для повторной отправки - await localDataSource.enqueueOperation( - type: 'create_day', - payload: {'day': day}, - ); + // Всегда пробуем сеть (connectivity может ошибаться) + try { + final idempotencyKey = _uuid.v4(); + await diaryService.createDay( + idempotencyKey: idempotencyKey, + body: CreateDayModel(day: day), + ); + } catch (e, s) { + if (e is DioException && e.response?.statusCode == 422) { + return; // День уже существует — ОК } - } else { - // Офлайн — просто складываем в очередь + await Sentry.captureException(e, stackTrace: s); + // Сетевая ошибка → добавляем в очередь для повторной отправки await localDataSource.enqueueOperation( type: 'create_day', payload: {'day': day}, diff --git a/test/core/app_initializer_test.dart b/test/core/app_initializer_test.dart index 2197ad60..f32c7b70 100644 --- a/test/core/app_initializer_test.dart +++ b/test/core/app_initializer_test.dart @@ -1,5 +1,8 @@ +import 'dart:io'; + import 'package:eatvio_app/core/app_initializer.dart'; import 'package:eatvio_app/core/locator_service.dart'; +import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_dotenv/flutter_dotenv.dart'; import 'package:shared_preferences/shared_preferences.dart'; @@ -52,6 +55,62 @@ FIREBASE_IOS_BUNDLE_ID=com.test.app SharedPreferences.setMockInitialValues({}); // Reset service locator before each test await sl.reset(); + + // Мокируем нативные каналы, которые не работают в тестовом окружении + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + (MethodCall call) async { + if (call.method == 'getTemporaryDirectory' || + call.method == 'getApplicationDocumentsDirectory' || + call.method == 'getApplicationSupportDirectory') { + return Directory.systemTemp.path; + } + return null; + }, + ); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('dev.fluttercommunity.plus/connectivity'), + (MethodCall call) async { + if (call.method == 'check') { + return ['wifi']; // Эмулируем наличие WiFi + } + return null; + }, + ); + + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider_macos'), + (MethodCall call) async { + if (call.method == 'getTemporaryDirectory' || + call.method == 'getApplicationDocumentsDirectory' || + call.method == 'getApplicationSupportDirectory') { + return Directory.systemTemp.path; + } + return null; + }, + ); + }); + + tearDown(() { + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider'), + null, + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('dev.fluttercommunity.plus/connectivity'), + null, + ); + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger + .setMockMethodCallHandler( + const MethodChannel('plugins.flutter.io/path_provider_macos'), + null, + ); }); test('is a class', () { diff --git a/test/features/diary/presentation/bloc/diary_cubit_test.dart b/test/features/diary/presentation/bloc/diary_cubit_test.dart index 4f593dfd..23f7cc1b 100644 --- a/test/features/diary/presentation/bloc/diary_cubit_test.dart +++ b/test/features/diary/presentation/bloc/diary_cubit_test.dart @@ -1,4 +1,7 @@ import 'package:dio/dio.dart'; +import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart'; +import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_state.dart'; +import 'package:eatvio_app/core/local_db/diary_local_data_source.dart'; import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; import 'package:eatvio_app/features/diary/entities/diary_future_diet_day_entity.dart'; import 'package:eatvio_app/features/diary/models/diary_day_model.dart'; @@ -25,6 +28,8 @@ import 'diary_cubit_test.mocks.dart'; MockSpec(), MockSpec(), MockSpec(), + MockSpec(), + MockSpec(), ]) void main() { group('DiaryCubit', () { @@ -35,6 +40,8 @@ void main() { late MockDietRepository mockDietRepository; late MockChatRepository mockChatRepository; late MockSettingsRepository mockSettingsRepository; + late MockInternetConnectionCubit mockConnectionCubit; + late MockDiaryLocalDataSource mockLocalDataSource; const testDiaryDay = DiaryDayModel( id: 1, @@ -60,6 +67,13 @@ void main() { mockDietRepository = MockDietRepository(); mockChatRepository = MockChatRepository(); mockSettingsRepository = MockSettingsRepository(); + mockConnectionCubit = MockInternetConnectionCubit(); + mockLocalDataSource = MockDiaryLocalDataSource(); + // По умолчанию считаем, что интернет есть + when(mockConnectionCubit.state).thenReturn( + InternetConnectionState(isInternetAvailable: true), + ); + when(mockConnectionCubit.stream).thenAnswer((_) => const Stream.empty()); }); tearDown(() async { @@ -78,6 +92,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); expect(diaryCubit!.state, const DiaryState.loading()); }); @@ -90,6 +106,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); when(mockDiaryRepository.createDiaryDay(day: anyNamed('day'))).thenAnswer((_) async => Future.value()); when(mockDiaryRepository.getDiaryDayV2(day: anyNamed('day'))).thenAnswer((_) async => testDiaryDay); @@ -119,6 +137,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); when(mockDiaryRepository.getDiaryDay(any)).thenAnswer((_) async => testDiaryDay); when(mockDiaryRepository.getNutritionData(any)).thenAnswer((_) async => testNutrition); @@ -165,6 +185,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); final result = await diaryCubit!.fetchDiaryV2(needLoading: true); @@ -181,6 +203,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); when(mockDiaryRepository.createDiaryDay(day: anyNamed('day'))).thenAnswer((_) async => Future.value()); when(mockDiaryRepository.getDiaryDayV2(day: anyNamed('day'))).thenAnswer((_) async => testDiaryDay); @@ -211,6 +235,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); const diet = Diet( id: 1, @@ -259,6 +285,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); final result = await diaryCubit!.setDiaryForDietOnly( const DiaryFutureDietDayEntity( @@ -281,6 +309,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); when( mockDiaryRepository.finishDiaryDay(diaryId: anyNamed('diaryId'), wakeUp: anyNamed('wakeUp'), retire: anyNamed('retire'), comment: anyNamed('comment')), @@ -311,6 +341,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); when( mockDiaryRepository.addPortionToMeal( @@ -348,6 +380,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); when( mockDiaryRepository.markEaten(diaryId: anyNamed('diaryId'), mealId: anyNamed('mealId'), portionId: anyNamed('portionId')), @@ -378,6 +412,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); when(mockGlossaryRepository.getGlossaryListByQuery(any)).thenAnswer( (_) async => [ @@ -412,6 +448,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); when( mockDiaryRepository.fillSatiety(diaryId: anyNamed('diaryId'), mealId: anyNamed('mealId'), satiety: anyNamed('satiety')), @@ -442,6 +480,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); when( mockDiaryRepository.fillMood(diaryId: anyNamed('diaryId'), mealId: anyNamed('mealId'), mood: anyNamed('mood')), @@ -472,6 +512,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); diaryCubit!.fillComment('Хороший день'); expect(diaryCubit!.comment, 'Хороший день'); @@ -485,6 +527,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); when(mockDiaryRepository.getDiaryDayV2(day: anyNamed('day'))).thenAnswer((_) async => testDiaryDay); when(mockDiaryRepository.getNutritionData(any)).thenAnswer((_) async => testNutrition); @@ -511,6 +555,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); when(mockDiaryRepository.getDiaryDayV2(day: anyNamed('day'))).thenThrow(Exception('Ошибка')); @@ -526,6 +572,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); when(mockDiaryRepository.getNutritionData(any)).thenAnswer((_) async => testNutrition); @@ -543,6 +591,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); when( mockDiaryRepository.updateProductInMeal( @@ -602,6 +652,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); when( mockDiaryRepository.unmarkEaten(diaryId: anyNamed('diaryId'), mealId: anyNamed('mealId'), portionId: anyNamed('portionId')), @@ -633,6 +685,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); when( mockDiaryRepository.restore(diaryId: anyNamed('diaryId'), mealId: anyNamed('mealId'), portionId: anyNamed('portionId')), @@ -664,6 +718,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); when( mockDiaryRepository.deleteProductFromMeal(diaryId: anyNamed('diaryId'), mealId: anyNamed('mealId'), portionId: anyNamed('portionId')), @@ -695,6 +751,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); const moderationModel = GlossaryModerationModel( glossary: GlossaryModel( @@ -735,6 +793,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); when( mockDiaryRepository.sendProductToModeration( @@ -759,6 +819,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); when( mockDiaryRepository.fillTime(diaryId: anyNamed('diaryId'), mealId: anyNamed('mealId'), time: anyNamed('time')), @@ -790,6 +852,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); when(mockDiaryRepository.fillWakeup(diaryId: anyNamed('diaryId'), time: anyNamed('time'))).thenAnswer((_) async => Future.value()); when(mockDiaryRepository.getDiaryDayV2(day: anyNamed('day'))).thenAnswer((_) async => testDiaryDay); @@ -819,6 +883,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); when(mockDiaryRepository.fillRetire(diaryId: anyNamed('diaryId'), time: anyNamed('time'))).thenAnswer((_) async => Future.value()); when(mockDiaryRepository.getDiaryDayV2(day: anyNamed('day'))).thenAnswer((_) async => testDiaryDay); @@ -848,6 +914,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); when(mockDiaryRepository.createDiaryDay(day: anyNamed('day'))).thenAnswer((_) async => Future.value()); when(mockDiaryRepository.getDiaryDayV2(day: anyNamed('day'))).thenAnswer((_) async => testDiaryDay); @@ -867,6 +935,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); when(mockDiaryRepository.createDiaryDay(day: anyNamed('day'))).thenThrow( DioException( @@ -891,6 +961,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); when(mockDiaryRepository.createDiaryDay(day: anyNamed('day'))).thenThrow( DioException( @@ -898,6 +970,10 @@ void main() { response: Response(requestOptions: RequestOptions(path: ''), statusCode: 500), ), ); + // После 500-ошибки getDiary пытается cache fallback — тоже кидаем ошибку + when(mockDiaryRepository.getDiaryDayV2(day: anyNamed('day'))).thenThrow( + Exception('No cache'), + ); final result = await diaryCubit!.getDiary(); @@ -916,6 +992,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); when(mockDiaryRepository.createDiaryDay(day: anyNamed('day'))).thenThrow( DioException( @@ -923,6 +1001,9 @@ void main() { response: Response(requestOptions: RequestOptions(path: ''), statusCode: 500), ), ); + when(mockDiaryRepository.getDiaryDayV2(day: anyNamed('day'))).thenThrow( + Exception('No cache'), + ); final result = await diaryCubit!.fetchDiaryV2(needLoading: false); @@ -940,6 +1021,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); const photo = DiaryPhotoModel(id: 1, originalName: 'test.jpg', path: 'path/to/test.jpg', size: 100); when( @@ -974,6 +1057,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); when(mockDiaryRepository.createDiaryDay(day: anyNamed('day'))).thenThrow( DioException( @@ -981,6 +1066,9 @@ void main() { response: Response(requestOptions: RequestOptions(path: ''), statusCode: 500), ), ); + when(mockDiaryRepository.getDiaryDayV2(day: anyNamed('day'))).thenThrow( + Exception('No cache'), + ); final result = await diaryCubit!.setSelectedDay(DateTime(2024, 1, 20)); @@ -997,6 +1085,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); // toEntity() возвращает null когда id == null const diaryDayWithoutEntity = DiaryDayModel( @@ -1029,6 +1119,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); // id должен быть null, чтобы toEntity() вернул null и код пошел по ветке setDiaryForDietOnly const diaryDayWithDiet = DiaryDayModel( diff --git a/test/features/diary/presentation/widgets/basic_widget_test.dart b/test/features/diary/presentation/widgets/basic_widget_test.dart index aa9c23f5..0e0968e2 100644 --- a/test/features/diary/presentation/widgets/basic_widget_test.dart +++ b/test/features/diary/presentation/widgets/basic_widget_test.dart @@ -1,5 +1,8 @@ import 'dart:async'; +import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart'; +import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_state.dart'; +import 'package:eatvio_app/core/local_db/diary_local_data_source.dart'; import 'package:eatvio_app/core/theme/theme_helper.dart'; import 'package:eatvio_app/features/chat/repositories/chat_repository.dart'; import 'package:eatvio_app/features/diary/entities/diary_day_entity.dart'; @@ -35,6 +38,8 @@ import 'basic_widget_test.mocks.dart'; MockSpec(), MockSpec(), MockSpec(), + MockSpec(), + MockSpec(), ]) void main() { group('Diary Basic Widget Tests', () { @@ -46,6 +51,8 @@ void main() { late MockSettingsRepository mockSettingsRepository; late DiaryCubit diaryCubit; late ConsultantCommentsCubit consultantCommentsCubit; + late MockInternetConnectionCubit mockConnectionCubit; + late MockDiaryLocalDataSource mockLocalDataSource; final testDiaryEntity = DiaryDayEntity( id: 1, @@ -79,6 +86,13 @@ void main() { mockDietRepository = MockDietRepository(); mockChatRepository = MockChatRepository(); mockSettingsRepository = MockSettingsRepository(); + mockConnectionCubit = MockInternetConnectionCubit(); + mockLocalDataSource = MockDiaryLocalDataSource(); + + when(mockConnectionCubit.state).thenReturn( + InternetConnectionState(isInternetAvailable: true), + ); + when(mockConnectionCubit.stream).thenAnswer((_) => const Stream.empty()); // Настраиваем stream для forceFetchStream when(mockDiaryRepository.forceFetchStream).thenAnswer((_) => const Stream.empty()); @@ -90,6 +104,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); consultantCommentsCubit = ConsultantCommentsCubit(diaryRepository: mockDiaryRepository); @@ -134,6 +150,8 @@ void main() { dietRepository: mockDietRepository, chatRepository: mockChatRepository, settingsRepository: mockSettingsRepository, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, ); // Assert diff --git a/test/features/diary/repositories/diary_repository_offline_test.dart b/test/features/diary/repositories/diary_repository_offline_test.dart index 49412ebe..5aabdf1d 100644 --- a/test/features/diary/repositories/diary_repository_offline_test.dart +++ b/test/features/diary/repositories/diary_repository_offline_test.dart @@ -33,16 +33,16 @@ void main() { // ─── Helpers ──────────────────────────────────────────────────────────────── /// Создаём in-memory БД для каждого теста (изолированно). - AppDatabase _createInMemoryDb() => AppDatabase.forTesting(NativeDatabase.memory()); + AppDatabase createInMemoryDb() => AppDatabase.forTesting(NativeDatabase.memory()); /// Создаём стаб состояния интернет-соединения. - void _stubOnline() { + void stubOnline() { when(mockConnectionCubit.state).thenReturn( InternetConnectionState(isInternetAvailable: true), ); } - void _stubOffline() { + void stubOffline() { when(mockConnectionCubit.state).thenReturn( InternetConnectionState(isInternetAvailable: false), ); @@ -51,7 +51,7 @@ void main() { // ─── Сценарии DioException для OfflineErrorInterceptor ───────────────────── /// Создаём DioException с Envelope body от сервера. - DioException _makeServerError({ + DioException makeServerError({ required int statusCode, required String code, required bool retryable, @@ -77,7 +77,7 @@ void main() { } setUp(() { - db = _createInMemoryDb(); + db = createInMemoryDb(); localDataSource = DiaryLocalDataSource( diaryDao: DiaryDao(db), glossaryDao: GlossaryDao(db), @@ -148,7 +148,7 @@ void main() { }); test('парсит 4xx Envelope с retryable=false', () { - final err = _makeServerError( + final err = makeServerError( statusCode: 422, code: 'validation_error', retryable: false, @@ -168,7 +168,7 @@ void main() { }); test('парсит 4xx Envelope с retryable=true', () { - final err = _makeServerError( + final err = makeServerError( statusCode: 409, code: 'conflict', retryable: true, @@ -209,7 +209,7 @@ void main() { group('Write-Behind Queue', () { test('addPortionToMeal в офлайн кладёт операцию в pending_operations', () async { - _stubOffline(); + stubOffline(); await repository.addPortionToMeal( diaryId: 42, @@ -229,7 +229,7 @@ void main() { }); test('addPortionToMeal онлайн отправляет в сеть и не ставит в очередь', () async { - _stubOnline(); + stubOnline(); when(mockDiaryService.addProductToMealIdempotent( idempotencyKey: anyNamed('idempotencyKey'), diary: 42, @@ -253,7 +253,7 @@ void main() { }); test('сетевая ошибка онлайн → операция уходит в очередь (retryable fallback)', () async { - _stubOnline(); + stubOnline(); when(mockDiaryService.addProductToMealIdempotent( idempotencyKey: anyNamed('idempotencyKey'), diary: 42, @@ -280,7 +280,7 @@ void main() { }); test('getPendingCount возвращает правильное число операций', () async { - _stubOffline(); + stubOffline(); await repository.addPortionToMeal( diaryId: 1, mealId: 1, productName: 'A', portion: '100г', quantity: 1, @@ -300,7 +300,7 @@ void main() { group('PendingOpsDao lifecycle', () { test('markSynced убирает операцию из pending', () async { - _stubOffline(); + stubOffline(); await repository.addPortionToMeal( diaryId: 1, mealId: 1, productName: 'Яблоко', portion: '100г', quantity: 1, @@ -316,7 +316,7 @@ void main() { }); test('markFailed убирает операцию из pending → не возвращается к воркеру', () async { - _stubOffline(); + stubOffline(); await repository.addPortionToMeal( diaryId: 1, mealId: 1, productName: 'Яблоко', portion: '100г', quantity: 1, @@ -330,7 +330,7 @@ void main() { }); test('incrementRetry увеличивает счётчик попыток', () async { - _stubOffline(); + stubOffline(); await repository.addPortionToMeal( diaryId: 1, mealId: 1, productName: 'Яблоко', portion: '100г', quantity: 1, @@ -347,7 +347,7 @@ void main() { }); test('clearSyncedOps удаляет только SYNCED операции', () async { - _stubOffline(); + stubOffline(); // Создаём 2 операции await repository.addPortionToMeal( @@ -475,14 +475,4 @@ class _FakeInterceptorHandler extends ErrorInterceptorHandler { void next(DioException err) => onNext(err); } -/// Расширение DioException для копирования с новым error. -extension _DioExceptionCopy on DioException { - DioException copyWith({Object? error}) { - return DioException( - requestOptions: requestOptions, - response: response, - type: type, - error: error ?? this.error, - ); - } -} + diff --git a/test/features/diary/repositories/diary_repository_test.dart b/test/features/diary/repositories/diary_repository_test.dart index 1c39dd6e..3421f3bc 100644 --- a/test/features/diary/repositories/diary_repository_test.dart +++ b/test/features/diary/repositories/diary_repository_test.dart @@ -1,4 +1,7 @@ import 'dart:io'; +import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart'; +import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_state.dart'; +import 'package:eatvio_app/core/local_db/diary_local_data_source.dart'; import 'package:eatvio_app/features/diary/models/consultant_comment_model.dart'; import 'package:eatvio_app/features/diary/models/consultant_review_model.dart'; import 'package:eatvio_app/features/diary/models/diary_day_model.dart'; @@ -12,15 +15,30 @@ import 'package:mockito/mockito.dart'; import '../../../fixtures/fixture_reader.dart'; import 'diary_repository_test.mocks.dart'; -@GenerateNiceMocks([MockSpec()]) +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), + MockSpec(), +]) void main() { group('DiaryRepository', () { late DiaryRepository diaryRepository; late MockDiaryService mockDiaryService; + late MockInternetConnectionCubit mockConnectionCubit; + late MockDiaryLocalDataSource mockLocalDataSource; setUp(() { mockDiaryService = MockDiaryService(); - diaryRepository = DiaryRepository(diaryService: mockDiaryService); + mockConnectionCubit = MockInternetConnectionCubit(); + mockLocalDataSource = MockDiaryLocalDataSource(); + when(mockConnectionCubit.state).thenReturn( + InternetConnectionState(isInternetAvailable: true), + ); + diaryRepository = DiaryRepository( + diaryService: mockDiaryService, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, + ); }); test('getDiaryIndexByDate - возвращает список дневников по дате', () async { @@ -224,7 +242,8 @@ void main() { const quantity = 2; const productNutritionalValueId = 10; - when(mockDiaryService.addProductToMeal( + when(mockDiaryService.addProductToMealIdempotent( + idempotencyKey: anyNamed('idempotencyKey'), diary: diaryId, typeNo: mealId, productName: productName, @@ -242,7 +261,8 @@ void main() { productNutritionalValueId: productNutritionalValueId, ); - verify(mockDiaryService.addProductToMeal( + verify(mockDiaryService.addProductToMealIdempotent( + idempotencyKey: anyNamed('idempotencyKey'), diary: diaryId, typeNo: mealId, productName: productName, @@ -796,7 +816,7 @@ void main() { test('createDiaryDay - создает новый день дневника', () async { const day = '2024-01-15'; - final diaryList = [ + final _ = [ const DiaryDayModel( id: 1, date: day, @@ -814,15 +834,12 @@ void main() { ), ]; - when(mockDiaryService.createDay(body: anyNamed('body'))) + when(mockDiaryService.createDay(idempotencyKey: anyNamed('idempotencyKey'), body: anyNamed('body'))) .thenAnswer((_) async => Future.value()); - when(mockDiaryService.getDiaryIndexByDate(date: day)) - .thenAnswer((_) async => diaryList); await diaryRepository.createDiaryDay(day: day); - verify(mockDiaryService.createDay(body: anyNamed('body'))).called(1); - verify(mockDiaryService.getDiaryIndexByDate(date: day)).called(1); + verify(mockDiaryService.createDay(idempotencyKey: anyNamed('idempotencyKey'), body: anyNamed('body'))).called(1); }); test('forceFetchStream - возвращает поток событий', () { diff --git a/test/features/diary/services/diary_service_test.dart b/test/features/diary/services/diary_service_test.dart index 68a462c2..dbb6a55e 100644 --- a/test/features/diary/services/diary_service_test.dart +++ b/test/features/diary/services/diary_service_test.dart @@ -646,12 +646,12 @@ void main() { test('createDay - создает новый день дневника', () async { final createDayModel = CreateDayModel(day: '2024-01-15'); - when(mockDiaryService.createDay(body: createDayModel)) + when(mockDiaryService.createDay(idempotencyKey: anyNamed('idempotencyKey'), body: createDayModel)) .thenAnswer((_) async => Future.value()); - await mockDiaryService.createDay(body: createDayModel); + await mockDiaryService.createDay(idempotencyKey: 'test-key', body: createDayModel); - verify(mockDiaryService.createDay(body: createDayModel)).called(1); + verify(mockDiaryService.createDay(idempotencyKey: anyNamed('idempotencyKey'), body: createDayModel)).called(1); }); test('getDiaryDayV2 - получает день дневника v2', () async { diff --git a/test/features/glossary/repositories/glossary_repository_test.dart b/test/features/glossary/repositories/glossary_repository_test.dart index 588d2c14..ccec78ed 100644 --- a/test/features/glossary/repositories/glossary_repository_test.dart +++ b/test/features/glossary/repositories/glossary_repository_test.dart @@ -1,3 +1,6 @@ +import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart'; +import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_state.dart'; +import 'package:eatvio_app/core/local_db/diary_local_data_source.dart'; import 'package:eatvio_app/features/glossary/models/glossary_model.dart'; import 'package:eatvio_app/features/glossary/repositories/glossary_repository.dart'; import 'package:eatvio_app/features/glossary/services/glossary_service.dart'; @@ -5,15 +8,28 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mocktail/mocktail.dart'; class MockGlossaryService extends Mock implements GlossaryService {} +class MockInternetConnectionCubit extends Mock implements InternetConnectionCubit {} +class MockDiaryLocalDataSource extends Mock implements DiaryLocalDataSource {} void main() { group('GlossaryRepository', () { late GlossaryRepository repository; late MockGlossaryService mockService; + late MockInternetConnectionCubit mockConnectionCubit; + late MockDiaryLocalDataSource mockLocalDataSource; setUp(() { mockService = MockGlossaryService(); - repository = GlossaryRepository(glossaryService: mockService); + mockConnectionCubit = MockInternetConnectionCubit(); + mockLocalDataSource = MockDiaryLocalDataSource(); + when(() => mockConnectionCubit.state).thenReturn( + InternetConnectionState(isInternetAvailable: true), + ); + repository = GlossaryRepository( + glossaryService: mockService, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, + ); }); test('getGlossaryListByQuery - успешный запрос', () async { @@ -35,6 +51,8 @@ void main() { when(() => mockService.getGlossaryByQuery(query: query)) .thenThrow(Exception('Error')); + when(() => mockLocalDataSource.searchGlossary(query)) + .thenThrow(Exception('Local error')); expect(() => repository.getGlossaryListByQuery(query), throwsException); }); diff --git a/test/features/notifications/models/notification_model_test.dart b/test/features/notifications/models/notification_model_test.dart index d7f809e0..163f9585 100644 --- a/test/features/notifications/models/notification_model_test.dart +++ b/test/features/notifications/models/notification_model_test.dart @@ -124,12 +124,13 @@ void main() { }); test('UnknownNotification uses default createdAt when not provided', () { + final before = DateTime.now(); final notification = UnknownNotification(type: NotificationType.unknown, createdAt: DateTime.now()); expect(notification.type, NotificationType.unknown); expect(notification.isRead, true); - // createdAt is initialized with epoch when not provided - expect(notification.createdAt.isBefore(DateTime.now()), isTrue); + // createdAt is initialized with DateTime.now() — should not be after current time + expect(notification.createdAt.isAfter(before.add(const Duration(seconds: 1))), isFalse); }); test('ConsultantAssignedNotification.fromJson parses all fields', () { diff --git a/test/fixtures/doubles/create_diet_doubles.dart b/test/fixtures/doubles/create_diet_doubles.dart index c5addea6..ab3c92e9 100644 --- a/test/fixtures/doubles/create_diet_doubles.dart +++ b/test/fixtures/doubles/create_diet_doubles.dart @@ -1,6 +1,9 @@ import 'dart:io'; import 'package:dio/dio.dart'; +import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart'; +import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_state.dart'; +import 'package:eatvio_app/core/local_db/diary_local_data_source.dart'; import 'package:eatvio_app/features/create_diet/repositories/create_diet_repository.dart'; import 'package:eatvio_app/features/create_diet/services/create_diet_service.dart'; import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; @@ -138,8 +141,11 @@ class FixtureCreateDietRepository extends CreateDietRepository { class FixtureGlossaryRepository extends GlossaryRepository { FixtureGlossaryRepository() - : super(glossaryService: _NoopGlossaryService()); - + : super( + glossaryService: _NoopGlossaryService(), + localDataSource: _NoopDiaryLocalDataSource(), + connectionCubit: _AlwaysOnlineConnectionCubit(), + ); @override Future> getGlossaryListByQuery(String query) async { final results = FixtureReader.readAsMap('create_diet/glossary_search_response.json')['results'] as List; @@ -188,3 +194,17 @@ class _NoopGlossaryService implements GlossaryService { @override noSuchMethod(Invocation invocation) => throw UnimplementedError('GlossaryService call ${invocation.memberName}'); } + +class _NoopDiaryLocalDataSource implements DiaryLocalDataSource { + @override + noSuchMethod(Invocation invocation) => throw UnimplementedError('DiaryLocalDataSource call ${invocation.memberName}'); +} + +class _AlwaysOnlineConnectionCubit implements InternetConnectionCubit { + @override + InternetConnectionState get state => InternetConnectionState(isInternetAvailable: true); + + @override + noSuchMethod(Invocation invocation) => throw UnimplementedError('InternetConnectionCubit call ${invocation.memberName}'); +} +