diff --git a/test/core/local_db/diary_local_data_source_test.dart b/test/core/local_db/diary_local_data_source_test.dart new file mode 100644 index 00000000..5e3c59f9 --- /dev/null +++ b/test/core/local_db/diary_local_data_source_test.dart @@ -0,0 +1,367 @@ +import 'package:drift/drift.dart' hide isNull, isNotNull; +import 'package:drift/native.dart'; +import 'package:eatvio_app/core/local_db/app_database.dart'; +import 'package:eatvio_app/core/local_db/dao/diary_dao.dart'; +import 'package:eatvio_app/core/local_db/dao/glossary_dao.dart'; +import 'package:eatvio_app/core/local_db/dao/pending_ops_dao.dart'; +import 'package:eatvio_app/core/local_db/dao/sync_meta_dao.dart'; +import 'package:eatvio_app/core/local_db/diary_local_data_source.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + late AppDatabase db; + late DiaryLocalDataSource ds; + + setUp(() { + db = AppDatabase.forTesting(NativeDatabase.memory()); + ds = DiaryLocalDataSource( + diaryDao: DiaryDao(db), + glossaryDao: GlossaryDao(db), + pendingOpsDao: PendingOpsDao(db), + syncMetaDao: SyncMetaDao(db), + ); + }); + + tearDown(() async { + await db.close(); + }); + + // ────────────────────────────────────────────────────────────────────────── + // cacheMeal + getCachedMeals + // ────────────────────────────────────────────────────────────────────────── + + group('cacheMeal', () { + test('cacheMeal сохраняет и getCachedMeals возвращает приёмы пищи', () async { + await ds.cacheDiaryDay(date: '2026-05-01', serverId: 100); + final day = await ds.getCachedDay('2026-05-01'); + expect(day, isNotNull); + + await ds.cacheMeal(diaryDayId: day!.id, typeNo: 1, time: '08:00', satiety: 5, mood: 3); + await ds.cacheMeal(diaryDayId: day.id, typeNo: 2, time: '13:00', satiety: 7); + await ds.cacheMeal(diaryDayId: day.id, typeNo: 3); + + final meals = await ds.getCachedMeals(day.id); + expect(meals.length, 3); + expect(meals[0].typeNo, 1); + expect(meals[0].time, '08:00'); + expect(meals[0].satiety, 5); + expect(meals[0].mood, 3); + expect(meals[1].typeNo, 2); + expect(meals[2].typeNo, 3); + }); + + test('getCachedMeals для несуществующего дня — пустой список', () async { + final meals = await ds.getCachedMeals(9999); + expect(meals.isEmpty, isTrue); + }); + }); + + // ────────────────────────────────────────────────────────────────────────── + // cachePortion + getCachedPortions + // ────────────────────────────────────────────────────────────────────────── + + group('cachePortion', () { + test('cachePortion сохраняет и getCachedPortions возвращает порции', () async { + await ds.cacheDiaryDay(date: '2026-05-01', serverId: 100); + final day = await ds.getCachedDay('2026-05-01'); + await ds.cacheMeal(diaryDayId: day!.id, typeNo: 1); + final meals = await ds.getCachedMeals(day.id); + final meal = meals.first; + + await ds.cachePortion( + mealId: meal.id, + productName: 'Banana', + portion: 'pcs', + quantity: 2, + productNutritionalValueId: 42, + state: 'eaten', + fromDiet: false, + ); + await ds.cachePortion( + mealId: meal.id, + productName: 'Apple', + portion: 'gr', + quantity: 150, + ); + + final portions = await ds.getCachedPortions(meal.id); + expect(portions.length, 2); + expect(portions[0].productName, 'Banana'); + expect(portions[0].productNutritionalValueId, 42); + expect(portions[0].quantity, 2); + expect(portions[1].productName, 'Apple'); + expect(portions[1].quantity, 150); + }); + + test('getCachedPortions для несуществующего meal — пустой список', () async { + final portions = await ds.getCachedPortions(9999); + expect(portions.isEmpty, isTrue); + }); + }); + + // ────────────────────────────────────────────────────────────────────────── + // clearCachedMeals + // ────────────────────────────────────────────────────────────────────────── + + group('clearCachedMeals', () { + test('clearCachedMeals удаляет все meals для дня', () async { + await ds.cacheDiaryDay(date: '2026-05-01', serverId: 100); + final day = await ds.getCachedDay('2026-05-01'); + await ds.cacheMeal(diaryDayId: day!.id, typeNo: 1); + await ds.cacheMeal(diaryDayId: day.id, typeNo: 2); + + var meals = await ds.getCachedMeals(day.id); + expect(meals.length, 2); + + await ds.clearCachedMeals(day.id); + + meals = await ds.getCachedMeals(day.id); + expect(meals.isEmpty, isTrue); + }); + }); + + // ────────────────────────────────────────────────────────────────────────── + // Glossary methods + // ────────────────────────────────────────────────────────────────────────── + + group('Glossary', () { + test('bulkUpsertGlossary + getGlossaryByIds', () async { + await ds.bulkUpsertGlossary([ + const GlossaryCacheTableCompanion( + id: Value(1), + name: Value('Banana'), + productGroupId: Value(1), + energyOn100gr: Value(89.0), + proteinsOn100gr: Value(1.1), + fatsOn100gr: Value(0.3), + carbohydratesOn100gr: Value(22.8), + portionMassGr: Value(120.0), + energyOnPortion: Value(106.8), + proteinsOnPortion: Value(1.3), + fatsOnPortion: Value(0.36), + carbohydratesOnPortion: Value(27.4), + updatedAt: Value('2026-01-01T00:00:00Z'), + ), + const GlossaryCacheTableCompanion( + id: Value(2), + name: Value('Apple'), + productGroupId: Value(1), + energyOn100gr: Value(52.0), + proteinsOn100gr: Value(0.3), + fatsOn100gr: Value(0.2), + carbohydratesOn100gr: Value(13.8), + portionMassGr: Value(180.0), + energyOnPortion: Value(93.6), + proteinsOnPortion: Value(0.54), + fatsOnPortion: Value(0.36), + carbohydratesOnPortion: Value(24.8), + updatedAt: Value('2026-01-01T00:00:00Z'), + ), + ]); + + final items = await ds.getGlossaryByIds([1, 2]); + expect(items.length, 2); + expect(items[0].name, 'Banana'); + expect(items[1].name, 'Apple'); + }); + + test('getGlossaryByIds с несуществующими ID — пустой список', () async { + final items = await ds.getGlossaryByIds([999]); + expect(items.isEmpty, isTrue); + }); + + test('searchGlossary ищет по имени', () async { + await ds.bulkUpsertGlossary([ + const GlossaryCacheTableCompanion( + id: Value(10), + name: Value('Молоко коровье'), + productGroupId: Value(2), + energyOn100gr: Value(42.0), + proteinsOn100gr: Value(3.4), + fatsOn100gr: Value(1.0), + carbohydratesOn100gr: Value(4.8), + portionMassGr: Value(200.0), + energyOnPortion: Value(84.0), + proteinsOnPortion: Value(6.8), + fatsOnPortion: Value(2.0), + carbohydratesOnPortion: Value(9.6), + updatedAt: Value('2026-01-01T00:00:00Z'), + ), + ]); + + final results = await ds.searchGlossary('Молоко'); + expect(results.isNotEmpty, isTrue); + expect(results.first.name, contains('Молоко')); + }); + + test('getGlossaryCount', () async { + final countBefore = await ds.getGlossaryCount(); + expect(countBefore, 0); + + await ds.bulkUpsertGlossary([ + const GlossaryCacheTableCompanion( + id: Value(1), + name: Value('Test'), + productGroupId: Value(1), + energyOn100gr: Value(0.0), + proteinsOn100gr: Value(0.0), + fatsOn100gr: Value(0.0), + carbohydratesOn100gr: Value(0.0), + portionMassGr: Value(0.0), + energyOnPortion: Value(0.0), + proteinsOnPortion: Value(0.0), + fatsOnPortion: Value(0.0), + carbohydratesOnPortion: Value(0.0), + updatedAt: Value(''), + ), + ]); + + final countAfter = await ds.getGlossaryCount(); + expect(countAfter, 1); + }); + + test('getGlossaryByGroup фильтрует по группе', () async { + await ds.bulkUpsertGlossary([ + const GlossaryCacheTableCompanion( + id: Value(1), + name: Value('A'), + productGroupId: Value(5), + energyOn100gr: Value(0.0), + proteinsOn100gr: Value(0.0), + fatsOn100gr: Value(0.0), + carbohydratesOn100gr: Value(0.0), + portionMassGr: Value(0.0), + energyOnPortion: Value(0.0), + proteinsOnPortion: Value(0.0), + fatsOnPortion: Value(0.0), + carbohydratesOnPortion: Value(0.0), + updatedAt: Value(''), + ), + const GlossaryCacheTableCompanion( + id: Value(2), + name: Value('B'), + productGroupId: Value(10), + energyOn100gr: Value(0.0), + proteinsOn100gr: Value(0.0), + fatsOn100gr: Value(0.0), + carbohydratesOn100gr: Value(0.0), + portionMassGr: Value(0.0), + energyOnPortion: Value(0.0), + proteinsOnPortion: Value(0.0), + fatsOnPortion: Value(0.0), + carbohydratesOnPortion: Value(0.0), + updatedAt: Value(''), + ), + ]); + + final group5 = await ds.getGlossaryByGroup(5); + expect(group5.length, 1); + expect(group5.first.name, 'A'); + + final group10 = await ds.getGlossaryByGroup(10); + expect(group10.length, 1); + expect(group10.first.name, 'B'); + }); + }); + + // ────────────────────────────────────────────────────────────────────────── + // Sync meta + // ────────────────────────────────────────────────────────────────────────── + + group('Sync meta — getGlossaryLastDumpAt', () { + test('getGlossaryLastDumpAt возвращает null если не сохранено', () async { + final result = await ds.getGlossaryLastDumpAt(); + expect(result, isNull); + }); + + test('getGlossaryLastDumpAt возвращает дату после saveGlossaryMeta', () async { + await ds.saveGlossaryMeta( + checksum: 'abc', + etag: '"v1"', + lastDumpAt: '2026-05-01T00:00:00Z', + ); + + final result = await ds.getGlossaryLastDumpAt(); + expect(result, '2026-05-01T00:00:00Z'); + }); + }); + + // ────────────────────────────────────────────────────────────────────────── + // enqueueOperation returns UUID + // ────────────────────────────────────────────────────────────────────────── + + group('enqueueOperation', () { + test('возвращает UUID и операция доступна через getPendingOps', () async { + final id = await ds.enqueueOperation(type: 'test_op', payload: {'key': 'value'}); + expect(id, isNotEmpty); + // UUID v4 format + expect(id.length, 36); + + final ops = await ds.getPendingOps(); + expect(ops.length, 1); + expect(ops.first.id, id); + expect(ops.first.type, 'test_op'); + }); + + test('каждый вызов генерирует уникальный UUID', () async { + final id1 = await ds.enqueueOperation(type: 'a', payload: {}); + final id2 = await ds.enqueueOperation(type: 'b', payload: {}); + expect(id1, isNot(equals(id2))); + }); + + test('enqueueOperation с localFilePath', () async { + final id = await ds.enqueueOperation( + type: 'photo_upload', + payload: {'diary_id': 1}, + localFilePath: '/tmp/photo.jpg', + ); + + final ops = await ds.getPendingOps(); + expect(ops.first.localFilePath, '/tmp/photo.jpg'); + expect(ops.first.id, id); + }); + }); + + // ────────────────────────────────────────────────────────────────────────── + // cacheDiaryDay with all params + // ────────────────────────────────────────────────────────────────────────── + + group('cacheDiaryDay advanced', () { + test('сохраняет все параметры корректно', () async { + await ds.cacheDiaryDay( + date: '2026-05-01', + serverId: 999, + wakeup: '07:00', + retire: '23:30', + closed: 1, + comment: 'test comment', + state: 'closed', + hasChosenDiet: true, + hasChosenPlan: true, + ); + + final day = await ds.getCachedDay('2026-05-01'); + expect(day, isNotNull); + expect(day!.serverId, 999); + expect(day.wakeup, '07:00'); + expect(day.retire, '23:30'); + expect(day.closed, 1); + expect(day.comment, 'test comment'); + expect(day.state, 'closed'); + expect(day.hasChosenDiet, isTrue); + expect(day.hasChosenPlan, isTrue); + }); + + test('дефолтные значения применяются корректно', () async { + await ds.cacheDiaryDay(date: '2026-05-02'); + + final day = await ds.getCachedDay('2026-05-02'); + expect(day!.wakeup, '06:30'); + expect(day.retire, '22:30'); + expect(day.closed, 0); + expect(day.comment, ''); + expect(day.hasChosenDiet, isFalse); + expect(day.hasChosenPlan, isFalse); + }); + }); +} diff --git a/test/core/local_db/services/diary_sync_runner_test.dart b/test/core/local_db/services/diary_sync_runner_test.dart index 09f7ec89..bfeffec0 100644 --- a/test/core/local_db/services/diary_sync_runner_test.dart +++ b/test/core/local_db/services/diary_sync_runner_test.dart @@ -1,9 +1,13 @@ -import 'dart:convert'; - +import 'package:dio/dio.dart'; +import 'package:drift/native.dart'; import 'package:eatvio_app/core/interceptors/offline_error_interceptor.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/app_database.dart'; +import 'package:eatvio_app/core/local_db/dao/diary_dao.dart'; +import 'package:eatvio_app/core/local_db/dao/glossary_dao.dart'; +import 'package:eatvio_app/core/local_db/dao/pending_ops_dao.dart'; +import 'package:eatvio_app/core/local_db/dao/sync_meta_dao.dart'; import 'package:eatvio_app/core/local_db/diary_local_data_source.dart'; import 'package:eatvio_app/core/local_db/services/diary_sync_runner.dart'; import 'package:eatvio_app/features/diary/services/diary_service.dart'; @@ -11,125 +15,66 @@ import 'package:flutter_test/flutter_test.dart'; import 'package:mockito/annotations.dart'; import 'package:mockito/mockito.dart'; +import 'diary_sync_runner_test.mocks.dart'; + @GenerateNiceMocks([ - MockSpec(), MockSpec(), MockSpec(), ]) -import 'diary_sync_runner_test.mocks.dart'; - void main() { - late DiarySyncRunner runner; - late MockDiaryLocalDataSource mockLocalDataSource; + late AppDatabase db; + late DiaryLocalDataSource localDataSource; late MockDiaryService mockDiaryService; late MockInternetConnectionCubit mockConnectionCubit; + late DiarySyncRunner syncRunner; setUp(() { - mockLocalDataSource = MockDiaryLocalDataSource(); + db = AppDatabase.forTesting(NativeDatabase.memory()); + localDataSource = DiaryLocalDataSource( + diaryDao: DiaryDao(db), + glossaryDao: GlossaryDao(db), + pendingOpsDao: PendingOpsDao(db), + syncMetaDao: SyncMetaDao(db), + ); mockDiaryService = MockDiaryService(); mockConnectionCubit = MockInternetConnectionCubit(); + // По умолчанию online и stream пустой + when(mockConnectionCubit.state).thenReturn( + InternetConnectionState(isInternetAvailable: true), + ); when(mockConnectionCubit.stream).thenAnswer((_) => const Stream.empty()); - when(mockConnectionCubit.state).thenReturn(InternetConnectionState(isInternetAvailable: false)); - runner = DiarySyncRunner( - localDataSource: mockLocalDataSource, + syncRunner = DiarySyncRunner( + localDataSource: localDataSource, diaryService: mockDiaryService, connectionCubit: mockConnectionCubit, ); }); - tearDown(() { - runner.dispose(); + tearDown(() async { + syncRunner.dispose(); + await db.close(); }); - group('DiarySyncRunner', () { - test('start() подписывается на connectionCubit.stream', () { - runner.start(); - verify(mockConnectionCubit.stream).called(1); - }); + // Хелпер: enqueue операцию + Future enqueueCreatePortion({int diaryId = 1, String productName = 'Тест'}) async { + await localDataSource.enqueueOperation( + type: 'create_portion', + payload: { + 'diary_id': diaryId, + 'meal_id': 1, + 'product_name': productName, + 'portion': 'gr', + 'quantity': 100, + }, + ); + } - test('start() запускает _processQueue если уже online', () async { - when(mockConnectionCubit.state).thenReturn(InternetConnectionState(isInternetAvailable: true)); - when(mockLocalDataSource.getPendingOps()).thenAnswer((_) async => []); - when(mockLocalDataSource.clearSyncedOps()).thenAnswer((_) async {}); + group('DiarySyncRunner._processQueue', () { + test('start вызывает processQueue если уже online', () async { + await enqueueCreatePortion(); - runner.start(); - await Future.delayed(const Duration(milliseconds: 100)); - - verify(mockLocalDataSource.getPendingOps()).called(1); - }); - - test('dispose() отменяет подписку', () { - runner.start(); - runner.dispose(); - // Не должно быть ошибок - }); - - test('processQueue пропускает пустую очередь', () async { - when(mockConnectionCubit.state).thenReturn(InternetConnectionState(isInternetAvailable: true)); - when(mockLocalDataSource.getPendingOps()).thenAnswer((_) async => []); - when(mockLocalDataSource.clearSyncedOps()).thenAnswer((_) async {}); - - runner.start(); - await Future.delayed(const Duration(milliseconds: 100)); - - verify(mockLocalDataSource.getPendingOps()).called(1); - verifyNever(mockDiaryService.createDay(idempotencyKey: anyNamed('idempotencyKey'), body: anyNamed('body'))); - }); - - test('processQueue обрабатывает create_day операцию', () async { - final op = PendingOperationsTableData( - id: 'test-id', - type: 'create_day', - payload: jsonEncode({'day': '2024-01-01T00:00:00+00:00'}), - status: 'PENDING', - retryCount: 0, - createdAt: DateTime.now().toIso8601String(), - localFilePath: null, - ); - - when(mockConnectionCubit.state).thenReturn(InternetConnectionState(isInternetAvailable: true)); - when(mockLocalDataSource.getPendingOps()).thenAnswer((_) async => [op]); - when(mockLocalDataSource.markSynced(any)).thenAnswer((_) async {}); - when(mockLocalDataSource.clearSyncedOps()).thenAnswer((_) async {}); - when(mockDiaryService.createDay( - idempotencyKey: anyNamed('idempotencyKey'), - body: anyNamed('body'), - )).thenAnswer((_) async {}); - - runner.start(); - await Future.delayed(const Duration(milliseconds: 400)); - - verify(mockDiaryService.createDay( - idempotencyKey: 'test-id', - body: anyNamed('body'), - )).called(1); - verify(mockLocalDataSource.markSynced('test-id')).called(1); - }); - - test('processQueue обрабатывает create_portion операцию', () async { - final op = PendingOperationsTableData( - id: 'portion-id', - type: 'create_portion', - payload: jsonEncode({ - 'diary_id': 1, - 'meal_id': 2, - 'product_name': 'Test Product', - 'portion': '100g', - 'quantity': 1, - 'product_nutritional_value_id': 10, - }), - status: 'PENDING', - retryCount: 0, - createdAt: DateTime.now().toIso8601String(), - localFilePath: null, - ); - - when(mockConnectionCubit.state).thenReturn(InternetConnectionState(isInternetAvailable: true)); - when(mockLocalDataSource.getPendingOps()).thenAnswer((_) async => [op]); - when(mockLocalDataSource.markSynced(any)).thenAnswer((_) async {}); - when(mockLocalDataSource.clearSyncedOps()).thenAnswer((_) async {}); when(mockDiaryService.addProductToMealIdempotent( idempotencyKey: anyNamed('idempotencyKey'), diary: anyNamed('diary'), @@ -140,329 +85,262 @@ void main() { productNutritionalValueId: anyNamed('productNutritionalValueId'), )).thenAnswer((_) async {}); - runner.start(); - await Future.delayed(const Duration(milliseconds: 400)); + syncRunner.start(); - verify(mockLocalDataSource.markSynced('portion-id')).called(1); + // Ждём пока операция будет обработана + await Future.delayed(const Duration(milliseconds: 500)); + + verify(mockDiaryService.addProductToMealIdempotent( + idempotencyKey: anyNamed('idempotencyKey'), + diary: 1, + typeNo: 1, + productName: 'Тест', + portion: 'gr', + quantity: 100, + productNutritionalValueId: null, + )).called(1); + + // Должна быть помечена как synced и удалена + final ops = await localDataSource.getPendingOps(); + expect(ops.isEmpty, isTrue); }); - test('processQueue обрабатывает delete_portion операцию', () async { - final op = PendingOperationsTableData( - id: 'del-id', - type: 'delete_portion', - payload: jsonEncode({'diary_id': 1, 'meal_id': 2, 'portion_id': 3}), - status: 'PENDING', - retryCount: 0, - createdAt: DateTime.now().toIso8601String(), - localFilePath: null, + test('start не вызывает processQueue если offline', () async { + when(mockConnectionCubit.state).thenReturn( + InternetConnectionState(isInternetAvailable: false), ); - when(mockConnectionCubit.state).thenReturn(InternetConnectionState(isInternetAvailable: true)); - when(mockLocalDataSource.getPendingOps()).thenAnswer((_) async => [op]); - when(mockLocalDataSource.markSynced(any)).thenAnswer((_) async {}); - when(mockLocalDataSource.clearSyncedOps()).thenAnswer((_) async {}); - when(mockDiaryService.deleteProductFromMealIdempotent( + await enqueueCreatePortion(); + + syncRunner.start(); + await Future.delayed(const Duration(milliseconds: 200)); + + verifyNever(mockDiaryService.addProductToMealIdempotent( idempotencyKey: anyNamed('idempotencyKey'), diary: anyNamed('diary'), typeNo: anyNamed('typeNo'), - portionModel: anyNamed('portionModel'), - )).thenAnswer((_) async {}); - - runner.start(); - await Future.delayed(const Duration(milliseconds: 400)); - - verify(mockLocalDataSource.markSynced('del-id')).called(1); - }); - - test('processQueue обрабатывает fill_satiety операцию', () async { - final op = PendingOperationsTableData( - id: 'sat-id', - type: 'fill_satiety', - payload: jsonEncode({'diary_id': 1, 'type_no': 2, 'satiety': 5}), - status: 'PENDING', - retryCount: 0, - createdAt: DateTime.now().toIso8601String(), - localFilePath: null, - ); - - when(mockConnectionCubit.state).thenReturn(InternetConnectionState(isInternetAvailable: true)); - when(mockLocalDataSource.getPendingOps()).thenAnswer((_) async => [op]); - when(mockLocalDataSource.markSynced(any)).thenAnswer((_) async {}); - when(mockLocalDataSource.clearSyncedOps()).thenAnswer((_) async {}); - when(mockDiaryService.fillSatiety(diary: anyNamed('diary'), typeNo: anyNamed('typeNo'), satiety: anyNamed('satiety'))).thenAnswer((_) async {}); - - runner.start(); - await Future.delayed(const Duration(milliseconds: 400)); - - verify(mockLocalDataSource.markSynced('sat-id')).called(1); - }); - - test('processQueue обрабатывает fill_mood операцию', () async { - final op = PendingOperationsTableData( - id: 'mood-id', - type: 'fill_mood', - payload: jsonEncode({'diary_id': 1, 'type_no': 2, 'mood': 3}), - status: 'PENDING', - retryCount: 0, - createdAt: DateTime.now().toIso8601String(), - localFilePath: null, - ); - - when(mockConnectionCubit.state).thenReturn(InternetConnectionState(isInternetAvailable: true)); - when(mockLocalDataSource.getPendingOps()).thenAnswer((_) async => [op]); - when(mockLocalDataSource.markSynced(any)).thenAnswer((_) async {}); - when(mockLocalDataSource.clearSyncedOps()).thenAnswer((_) async {}); - when(mockDiaryService.fillMood(diary: anyNamed('diary'), typeNo: anyNamed('typeNo'), mood: anyNamed('mood'))).thenAnswer((_) async {}); - - runner.start(); - await Future.delayed(const Duration(milliseconds: 400)); - - verify(mockLocalDataSource.markSynced('mood-id')).called(1); - }); - - test('processQueue обрабатывает fill_time операцию', () async { - final op = PendingOperationsTableData( - id: 'time-id', - type: 'fill_time', - payload: jsonEncode({'diary_id': 1, 'type_no': 2, 'time': '12:00'}), - status: 'PENDING', - retryCount: 0, - createdAt: DateTime.now().toIso8601String(), - localFilePath: null, - ); - - when(mockConnectionCubit.state).thenReturn(InternetConnectionState(isInternetAvailable: true)); - when(mockLocalDataSource.getPendingOps()).thenAnswer((_) async => [op]); - when(mockLocalDataSource.markSynced(any)).thenAnswer((_) async {}); - when(mockLocalDataSource.clearSyncedOps()).thenAnswer((_) async {}); - when(mockDiaryService.fillTime(diary: anyNamed('diary'), typeNo: anyNamed('typeNo'), time: anyNamed('time'))).thenAnswer((_) async {}); - - runner.start(); - await Future.delayed(const Duration(milliseconds: 400)); - - verify(mockLocalDataSource.markSynced('time-id')).called(1); - }); - - test('processQueue обрабатывает fill_wakeup операцию', () async { - final op = PendingOperationsTableData( - id: 'wake-id', - type: 'fill_wakeup', - payload: jsonEncode({'diary_id': 1, 'wakeup': '07:00'}), - status: 'PENDING', - retryCount: 0, - createdAt: DateTime.now().toIso8601String(), - localFilePath: null, - ); - - when(mockConnectionCubit.state).thenReturn(InternetConnectionState(isInternetAvailable: true)); - when(mockLocalDataSource.getPendingOps()).thenAnswer((_) async => [op]); - when(mockLocalDataSource.markSynced(any)).thenAnswer((_) async {}); - when(mockLocalDataSource.clearSyncedOps()).thenAnswer((_) async {}); - when(mockDiaryService.fillWakeup(diary: anyNamed('diary'), wakeUp: anyNamed('wakeUp'))).thenAnswer((_) async {}); - - runner.start(); - await Future.delayed(const Duration(milliseconds: 400)); - - verify(mockLocalDataSource.markSynced('wake-id')).called(1); - }); - - test('processQueue обрабатывает fill_retire операцию', () async { - final op = PendingOperationsTableData( - id: 'retire-id', - type: 'fill_retire', - payload: jsonEncode({'diary_id': 1, 'retire': '23:00'}), - status: 'PENDING', - retryCount: 0, - createdAt: DateTime.now().toIso8601String(), - localFilePath: null, - ); - - when(mockConnectionCubit.state).thenReturn(InternetConnectionState(isInternetAvailable: true)); - when(mockLocalDataSource.getPendingOps()).thenAnswer((_) async => [op]); - when(mockLocalDataSource.markSynced(any)).thenAnswer((_) async {}); - when(mockLocalDataSource.clearSyncedOps()).thenAnswer((_) async {}); - when(mockDiaryService.fillRetire(diary: anyNamed('diary'), retire: anyNamed('retire'))).thenAnswer((_) async {}); - - runner.start(); - await Future.delayed(const Duration(milliseconds: 400)); - - verify(mockLocalDataSource.markSynced('retire-id')).called(1); - }); - - test('processQueue обрабатывает delete_photo операцию', () async { - final op = PendingOperationsTableData( - id: 'photo-del-id', - type: 'delete_photo', - payload: jsonEncode({'diary_id': 1, 'type_no': 2, 'media_id': 10}), - status: 'PENDING', - retryCount: 0, - createdAt: DateTime.now().toIso8601String(), - localFilePath: null, - ); - - when(mockConnectionCubit.state).thenReturn(InternetConnectionState(isInternetAvailable: true)); - when(mockLocalDataSource.getPendingOps()).thenAnswer((_) async => [op]); - when(mockLocalDataSource.markSynced(any)).thenAnswer((_) async {}); - when(mockLocalDataSource.clearSyncedOps()).thenAnswer((_) async {}); - when(mockDiaryService.deletePhotoOfMeal( - diaryId: anyNamed('diaryId'), - typeNo: anyNamed('typeNo'), - userAttachment: anyNamed('userAttachment'), - )).thenAnswer((_) async {}); - - runner.start(); - await Future.delayed(const Duration(milliseconds: 400)); - - verify(mockLocalDataSource.markSynced('photo-del-id')).called(1); - }); - - test('processQueue помечает операцию как failed при maxRetries', () async { - final op = PendingOperationsTableData( - id: 'max-retry-id', - type: 'create_day', - payload: jsonEncode({'day': '2024-01-01'}), - status: 'PENDING', - retryCount: 5, // >= _maxRetries - createdAt: DateTime.now().toIso8601String(), - localFilePath: null, - ); - - when(mockConnectionCubit.state).thenReturn(InternetConnectionState(isInternetAvailable: true)); - when(mockLocalDataSource.getPendingOps()).thenAnswer((_) async => [op]); - when(mockLocalDataSource.markFailed(any)).thenAnswer((_) async {}); - when(mockLocalDataSource.clearSyncedOps()).thenAnswer((_) async {}); - - runner.start(); - await Future.delayed(const Duration(milliseconds: 400)); - - verify(mockLocalDataSource.markFailed('max-retry-id')).called(1); - }); - - test('processQueue увеличивает retryCount при OfflineOperationException retryable', () async { - final op = PendingOperationsTableData( - id: 'retry-id', - type: 'create_day', - payload: jsonEncode({'day': '2024-01-01T00:00:00+00:00'}), - status: 'PENDING', - retryCount: 0, - createdAt: DateTime.now().toIso8601String(), - localFilePath: null, - ); - - when(mockConnectionCubit.state).thenReturn(InternetConnectionState(isInternetAvailable: true)); - when(mockLocalDataSource.getPendingOps()).thenAnswer((_) async => [op]); - when(mockLocalDataSource.incrementRetry(any)).thenAnswer((_) async {}); - when(mockLocalDataSource.clearSyncedOps()).thenAnswer((_) async {}); - when(mockDiaryService.createDay( - idempotencyKey: anyNamed('idempotencyKey'), - body: anyNamed('body'), - )).thenThrow(const OfflineOperationException( - message: 'Server error', - code: 'server_error', - retryable: true, + productName: anyNamed('productName'), + portion: anyNamed('portion'), + quantity: anyNamed('quantity'), + productNutritionalValueId: anyNamed('productNutritionalValueId'), )); - runner.start(); - await Future.delayed(const Duration(seconds: 2)); - - verify(mockLocalDataSource.incrementRetry('retry-id')).called(1); + // Операция всё ещё в pending + final ops = await localDataSource.getPendingOps(); + expect(ops.length, 1); }); - test('processQueue помечает failed при OfflineOperationException not retryable', () async { - final op = PendingOperationsTableData( - id: 'no-retry-id', - type: 'create_day', - payload: jsonEncode({'day': '2024-01-01T00:00:00+00:00'}), - status: 'PENDING', - retryCount: 0, - createdAt: DateTime.now().toIso8601String(), - localFilePath: null, + test('операция с maxRetries помечается failed', () async { + // Вручную устанавливаем retry_count >= maxRetries + final id = await localDataSource.enqueueOperation( + type: 'create_portion', + payload: {'diary_id': 1, 'meal_id': 1, 'product_name': 'X', 'portion': 'gr', 'quantity': 1}, + ); + + // Инкрементим retry 5 раз (maxRetries = 5) + for (int i = 0; i < 5; i++) { + await localDataSource.incrementRetry(id); + } + + syncRunner.start(); + await Future.delayed(const Duration(milliseconds: 300)); + + // Операция не должна быть вызвана + verifyNever(mockDiaryService.addProductToMealIdempotent( + idempotencyKey: anyNamed('idempotencyKey'), + diary: anyNamed('diary'), + typeNo: anyNamed('typeNo'), + productName: anyNamed('productName'), + portion: anyNamed('portion'), + quantity: anyNamed('quantity'), + productNutritionalValueId: anyNamed('productNutritionalValueId'), + )); + + // Pending ops пуст (всё failed) + final ops = await localDataSource.getPendingOps(); + expect(ops.isEmpty, isTrue); + }); + + test('DioException без OfflineOperationException → incrementRetry', () async { + await enqueueCreatePortion(); + + when(mockDiaryService.addProductToMealIdempotent( + idempotencyKey: anyNamed('idempotencyKey'), + diary: anyNamed('diary'), + typeNo: anyNamed('typeNo'), + productName: anyNamed('productName'), + portion: anyNamed('portion'), + quantity: anyNamed('quantity'), + productNutritionalValueId: anyNamed('productNutritionalValueId'), + )).thenThrow(DioException( + requestOptions: RequestOptions(), + type: DioExceptionType.connectionError, + )); + + syncRunner.start(); + await Future.delayed(const Duration(milliseconds: 300)); + + // Операция осталась, retry_count увеличен + final ops = await localDataSource.getPendingOps(); + expect(ops.length, 1); + expect(ops.first.retryCount, 1); + }); + + test('fill_satiety операция синхронизируется', () async { + await localDataSource.enqueueOperation( + type: 'fill_satiety', + payload: {'diary_id': 5, 'type_no': 2, 'satiety': 8}, + ); + + when(mockDiaryService.fillSatiety( + diary: 5, typeNo: 2, satiety: 8, + )).thenAnswer((_) async {}); + + syncRunner.start(); + await Future.delayed(const Duration(milliseconds: 500)); + + verify(mockDiaryService.fillSatiety(diary: 5, typeNo: 2, satiety: 8)).called(1); + }); + + test('fill_mood операция синхронизируется', () async { + await localDataSource.enqueueOperation( + type: 'fill_mood', + payload: {'diary_id': 5, 'type_no': 2, 'mood': 4}, + ); + + when(mockDiaryService.fillMood( + diary: 5, typeNo: 2, mood: 4, + )).thenAnswer((_) async {}); + + syncRunner.start(); + await Future.delayed(const Duration(milliseconds: 500)); + + verify(mockDiaryService.fillMood(diary: 5, typeNo: 2, mood: 4)).called(1); + }); + + test('fill_time операция синхронизируется', () async { + await localDataSource.enqueueOperation( + type: 'fill_time', + payload: {'diary_id': 5, 'type_no': 2, 'time': '14:30'}, + ); + + when(mockDiaryService.fillTime( + diary: 5, typeNo: 2, time: '14:30', + )).thenAnswer((_) async {}); + + syncRunner.start(); + await Future.delayed(const Duration(milliseconds: 500)); + + verify(mockDiaryService.fillTime(diary: 5, typeNo: 2, time: '14:30')).called(1); + }); + + test('fill_wakeup операция синхронизируется', () async { + await localDataSource.enqueueOperation( + type: 'fill_wakeup', + payload: {'diary_id': 5, 'wakeup': '07:00'}, + ); + + when(mockDiaryService.fillWakeup( + diary: 5, wakeUp: '07:00', + )).thenAnswer((_) async {}); + + syncRunner.start(); + await Future.delayed(const Duration(milliseconds: 500)); + + verify(mockDiaryService.fillWakeup(diary: 5, wakeUp: '07:00')).called(1); + }); + + test('fill_retire операция синхронизируется', () async { + await localDataSource.enqueueOperation( + type: 'fill_retire', + payload: {'diary_id': 5, 'retire': '23:00'}, + ); + + when(mockDiaryService.fillRetire( + diary: 5, retire: '23:00', + )).thenAnswer((_) async {}); + + syncRunner.start(); + await Future.delayed(const Duration(milliseconds: 500)); + + verify(mockDiaryService.fillRetire(diary: 5, retire: '23:00')).called(1); + }); + + test('delete_portion операция синхронизируется', () async { + await localDataSource.enqueueOperation( + type: 'delete_portion', + payload: {'diary_id': 10, 'meal_id': 2, 'portion_id': 55}, + ); + + when(mockDiaryService.deleteProductFromMealIdempotent( + idempotencyKey: anyNamed('idempotencyKey'), + diary: 10, typeNo: 2, portionModel: 55, + )).thenAnswer((_) async {}); + + syncRunner.start(); + await Future.delayed(const Duration(milliseconds: 500)); + + verify(mockDiaryService.deleteProductFromMealIdempotent( + idempotencyKey: anyNamed('idempotencyKey'), + diary: 10, typeNo: 2, portionModel: 55, + )).called(1); + }); + + test('create_day операция синхронизируется', () async { + await localDataSource.enqueueOperation( + type: 'create_day', + payload: {'day': '2026-05-01T00:00:00+00:00'}, ); - when(mockConnectionCubit.state).thenReturn(InternetConnectionState(isInternetAvailable: true)); - when(mockLocalDataSource.getPendingOps()).thenAnswer((_) async => [op]); - when(mockLocalDataSource.markFailed(any)).thenAnswer((_) async {}); - when(mockLocalDataSource.clearSyncedOps()).thenAnswer((_) async {}); when(mockDiaryService.createDay( idempotencyKey: anyNamed('idempotencyKey'), body: anyNamed('body'), + )).thenAnswer((_) async {}); + + syncRunner.start(); + await Future.delayed(const Duration(milliseconds: 500)); + + verify(mockDiaryService.createDay( + idempotencyKey: anyNamed('idempotencyKey'), + body: anyNamed('body'), + )).called(1); + }); + }); + + group('DiarySyncRunner._pow2', () { + // _pow2 is private but tested implicitly through exponential backoff behavior + test('OfflineOperationException non-retryable → markFailed', () async { + await enqueueCreatePortion(); + + // Simulate a non-retryable error wrapped in DioException.error + when(mockDiaryService.addProductToMealIdempotent( + idempotencyKey: anyNamed('idempotencyKey'), + diary: anyNamed('diary'), + typeNo: anyNamed('typeNo'), + productName: anyNamed('productName'), + portion: anyNamed('portion'), + quantity: anyNamed('quantity'), + productNutritionalValueId: anyNamed('productNutritionalValueId'), )).thenThrow(const OfflineOperationException( - message: 'Not found', - code: 'not_found', + message: 'Validation error', + code: 'validation_error', retryable: false, )); - runner.start(); - await Future.delayed(const Duration(milliseconds: 400)); + syncRunner.start(); + await Future.delayed(const Duration(milliseconds: 300)); - verify(mockLocalDataSource.markFailed('no-retry-id')).called(1); + // Операция должна быть помечена как FAILED + final ops = await localDataSource.getPendingOps(); + expect(ops.isEmpty, isTrue); // FAILED операции не возвращаются getPendingOps }); + }); - test('processQueue incrementRetry при неизвестной ошибке', () async { - final op = PendingOperationsTableData( - id: 'unknown-err-id', - type: 'create_day', - payload: jsonEncode({'day': '2024-01-01T00:00:00+00:00'}), - status: 'PENDING', - retryCount: 0, - createdAt: DateTime.now().toIso8601String(), - localFilePath: null, - ); - - when(mockConnectionCubit.state).thenReturn(InternetConnectionState(isInternetAvailable: true)); - when(mockLocalDataSource.getPendingOps()).thenAnswer((_) async => [op]); - when(mockLocalDataSource.incrementRetry(any)).thenAnswer((_) async {}); - when(mockLocalDataSource.clearSyncedOps()).thenAnswer((_) async {}); - when(mockDiaryService.createDay( - idempotencyKey: anyNamed('idempotencyKey'), - body: anyNamed('body'), - )).thenThrow(Exception('Unknown error')); - - runner.start(); - await Future.delayed(const Duration(milliseconds: 400)); - - verify(mockLocalDataSource.incrementRetry('unknown-err-id')).called(1); - }); - - test('processQueue останавливается если интернет пропал', () async { - final op1 = PendingOperationsTableData( - id: 'op1', - type: 'create_day', - payload: jsonEncode({'day': '2024-01-01T00:00:00+00:00'}), - status: 'PENDING', - retryCount: 0, - createdAt: DateTime.now().toIso8601String(), - localFilePath: null, - ); - final op2 = PendingOperationsTableData( - id: 'op2', - type: 'create_day', - payload: jsonEncode({'day': '2024-01-02T00:00:00+00:00'}), - status: 'PENDING', - retryCount: 0, - createdAt: DateTime.now().toIso8601String(), - localFilePath: null, - ); - - var callCount = 0; - when(mockConnectionCubit.state).thenAnswer((_) { - callCount++; - // После первого вызова — "офлайн" - return InternetConnectionState(isInternetAvailable: callCount <= 2); - }); - when(mockLocalDataSource.getPendingOps()).thenAnswer((_) async => [op1, op2]); - when(mockLocalDataSource.markSynced(any)).thenAnswer((_) async {}); - when(mockLocalDataSource.clearSyncedOps()).thenAnswer((_) async {}); - when(mockDiaryService.createDay( - idempotencyKey: anyNamed('idempotencyKey'), - body: anyNamed('body'), - )).thenAnswer((_) async {}); - - runner.start(); - await Future.delayed(const Duration(milliseconds: 500)); - - // Должна быть обработана только одна операция, вторая пропущена - verify(mockLocalDataSource.clearSyncedOps()).called(greaterThanOrEqualTo(1)); + group('DiarySyncRunner.dispose', () { + test('dispose cancels subscription', () { + syncRunner.start(); + // Не должно бросить ошибку + syncRunner.dispose(); }); }); } diff --git a/test/features/diary/repositories/diary_repository_offline_operations_test.dart b/test/features/diary/repositories/diary_repository_offline_operations_test.dart new file mode 100644 index 00000000..c43c7bc2 --- /dev/null +++ b/test/features/diary/repositories/diary_repository_offline_operations_test.dart @@ -0,0 +1,561 @@ +import 'dart:convert'; + +import 'package:dio/dio.dart'; +import 'package:drift/native.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/app_database.dart'; +import 'package:eatvio_app/core/local_db/dao/diary_dao.dart'; +import 'package:eatvio_app/core/local_db/dao/glossary_dao.dart'; +import 'package:eatvio_app/core/local_db/dao/pending_ops_dao.dart'; +import 'package:eatvio_app/core/local_db/dao/sync_meta_dao.dart'; +import 'package:eatvio_app/core/local_db/diary_local_data_source.dart'; +import 'package:eatvio_app/features/diary/models/diary_day_model.dart'; +import 'package:eatvio_app/features/diary/repositories/diary_repository.dart'; +import 'package:eatvio_app/features/diary/services/diary_service.dart'; +import 'package:eatvio_app/features/glossary/models/glossary_model.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +import 'diary_repository_offline_test.mocks.dart'; + +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), +]) +void main() { + late AppDatabase db; + late DiaryLocalDataSource localDataSource; + late MockDiaryService mockDiaryService; + late MockInternetConnectionCubit mockConnectionCubit; + late DiaryRepository repository; + + AppDatabase createInMemoryDb() => AppDatabase.forTesting(NativeDatabase.memory()); + + void stubOnline() { + when(mockConnectionCubit.state).thenReturn( + InternetConnectionState(isInternetAvailable: true), + ); + } + + void stubOffline() { + when(mockConnectionCubit.state).thenReturn( + InternetConnectionState(isInternetAvailable: false), + ); + } + + setUp(() { + db = createInMemoryDb(); + localDataSource = DiaryLocalDataSource( + diaryDao: DiaryDao(db), + glossaryDao: GlossaryDao(db), + pendingOpsDao: PendingOpsDao(db), + syncMetaDao: SyncMetaDao(db), + ); + mockDiaryService = MockDiaryService(); + mockConnectionCubit = MockInternetConnectionCubit(); + + repository = DiaryRepository( + diaryService: mockDiaryService, + localDataSource: localDataSource, + connectionCubit: mockConnectionCubit, + ); + }); + + tearDown(() async { + await db.close(); + }); + + // ────────────────────────────────────────────────────────────────────────── + // deleteProductFromMeal + // ────────────────────────────────────────────────────────────────────────── + + group('deleteProductFromMeal', () { + test('офлайн — кладёт delete_portion в очередь', () async { + stubOffline(); + + await repository.deleteProductFromMeal(diaryId: 10, mealId: 2, portionId: 55); + + final ops = await localDataSource.getPendingOps(); + expect(ops.length, 1); + expect(ops.first.type, 'delete_portion'); + final payload = jsonDecode(ops.first.payload) as Map; + expect(payload['diary_id'], 10); + expect(payload['meal_id'], 2); + expect(payload['portion_id'], 55); + }); + + test('онлайн — вызывает сервис напрямую', () async { + stubOnline(); + when(mockDiaryService.deleteProductFromMeal( + diary: 10, typeNo: 2, portionModel: 55, + )).thenAnswer((_) async {}); + + await repository.deleteProductFromMeal(diaryId: 10, mealId: 2, portionId: 55); + + verify(mockDiaryService.deleteProductFromMeal(diary: 10, typeNo: 2, portionModel: 55)).called(1); + final ops = await localDataSource.getPendingOps(); + expect(ops.isEmpty, isTrue); + }); + + test('онлайн 5xx — кладёт в очередь', () async { + stubOnline(); + when(mockDiaryService.deleteProductFromMeal( + diary: anyNamed('diary'), typeNo: anyNamed('typeNo'), portionModel: anyNamed('portionModel'), + )).thenThrow(DioException( + requestOptions: RequestOptions(), + response: Response(requestOptions: RequestOptions(), statusCode: 500), + type: DioExceptionType.badResponse, + )); + + await repository.deleteProductFromMeal(diaryId: 10, mealId: 2, portionId: 55); + + final ops = await localDataSource.getPendingOps(); + expect(ops.length, 1); + expect(ops.first.type, 'delete_portion'); + }); + + test('онлайн 4xx — пробрасывает ошибку, не кладёт в очередь', () async { + stubOnline(); + when(mockDiaryService.deleteProductFromMeal( + diary: anyNamed('diary'), typeNo: anyNamed('typeNo'), portionModel: anyNamed('portionModel'), + )).thenThrow(DioException( + requestOptions: RequestOptions(), + response: Response(requestOptions: RequestOptions(), statusCode: 404), + type: DioExceptionType.badResponse, + )); + + expect( + () => repository.deleteProductFromMeal(diaryId: 10, mealId: 2, portionId: 55), + throwsA(isA()), + ); + + final ops = await localDataSource.getPendingOps(); + expect(ops.isEmpty, isTrue); + }); + }); + + // ────────────────────────────────────────────────────────────────────────── + // fillSatiety + // ────────────────────────────────────────────────────────────────────────── + + group('fillSatiety', () { + test('офлайн — кладёт fill_satiety в очередь', () async { + stubOffline(); + + await repository.fillSatiety(diaryId: 5, mealId: 1, satiety: 7); + + final ops = await localDataSource.getPendingOps(); + expect(ops.length, 1); + expect(ops.first.type, 'fill_satiety'); + final payload = jsonDecode(ops.first.payload) as Map; + expect(payload['diary_id'], 5); + expect(payload['type_no'], 1); + expect(payload['satiety'], 7); + }); + + test('онлайн — вызывает сервис напрямую', () async { + stubOnline(); + when(mockDiaryService.fillSatiety( + diary: 5, typeNo: 1, satiety: 7, + )).thenAnswer((_) async {}); + + await repository.fillSatiety(diaryId: 5, mealId: 1, satiety: 7); + + verify(mockDiaryService.fillSatiety(diary: 5, typeNo: 1, satiety: 7)).called(1); + }); + + test('онлайн connection error — fallback в очередь', () async { + stubOnline(); + when(mockDiaryService.fillSatiety( + diary: anyNamed('diary'), typeNo: anyNamed('typeNo'), satiety: anyNamed('satiety'), + )).thenThrow(DioException( + requestOptions: RequestOptions(), + type: DioExceptionType.connectionError, + )); + + await repository.fillSatiety(diaryId: 5, mealId: 1, satiety: 7); + + final ops = await localDataSource.getPendingOps(); + expect(ops.length, 1); + }); + }); + + // ────────────────────────────────────────────────────────────────────────── + // fillMood + // ────────────────────────────────────────────────────────────────────────── + + group('fillMood', () { + test('офлайн — кладёт fill_mood в очередь', () async { + stubOffline(); + + await repository.fillMood(diaryId: 5, mealId: 3, mood: 4); + + final ops = await localDataSource.getPendingOps(); + expect(ops.length, 1); + expect(ops.first.type, 'fill_mood'); + final payload = jsonDecode(ops.first.payload) as Map; + expect(payload['mood'], 4); + }); + + test('онлайн — вызывает сервис', () async { + stubOnline(); + when(mockDiaryService.fillMood( + diary: 5, typeNo: 3, mood: 4, + )).thenAnswer((_) async {}); + + await repository.fillMood(diaryId: 5, mealId: 3, mood: 4); + + verify(mockDiaryService.fillMood(diary: 5, typeNo: 3, mood: 4)).called(1); + }); + }); + + // ────────────────────────────────────────────────────────────────────────── + // fillTime + // ────────────────────────────────────────────────────────────────────────── + + group('fillTime', () { + test('офлайн — кладёт fill_time в очередь', () async { + stubOffline(); + + await repository.fillTime(diaryId: 5, mealId: 2, time: '13:30'); + + final ops = await localDataSource.getPendingOps(); + expect(ops.length, 1); + expect(ops.first.type, 'fill_time'); + final payload = jsonDecode(ops.first.payload) as Map; + expect(payload['time'], '13:30'); + }); + + test('онлайн — вызывает сервис', () async { + stubOnline(); + when(mockDiaryService.fillTime( + diary: 5, typeNo: 2, time: '13:30', + )).thenAnswer((_) async {}); + + await repository.fillTime(diaryId: 5, mealId: 2, time: '13:30'); + + verify(mockDiaryService.fillTime(diary: 5, typeNo: 2, time: '13:30')).called(1); + }); + }); + + // ────────────────────────────────────────────────────────────────────────── + // fillWakeup / fillRetire + // ────────────────────────────────────────────────────────────────────────── + + group('fillWakeup', () { + test('офлайн — кладёт fill_wakeup в очередь', () async { + stubOffline(); + + await repository.fillWakeup(diaryId: 5, time: '07:00'); + + final ops = await localDataSource.getPendingOps(); + expect(ops.length, 1); + expect(ops.first.type, 'fill_wakeup'); + final payload = jsonDecode(ops.first.payload) as Map; + expect(payload['wakeup'], '07:00'); + }); + + test('онлайн — вызывает сервис', () async { + stubOnline(); + when(mockDiaryService.fillWakeup( + diary: 5, wakeUp: '07:00', + )).thenAnswer((_) async {}); + + await repository.fillWakeup(diaryId: 5, time: '07:00'); + + verify(mockDiaryService.fillWakeup(diary: 5, wakeUp: '07:00')).called(1); + }); + }); + + group('fillRetire', () { + test('офлайн — кладёт fill_retire в очередь', () async { + stubOffline(); + + await repository.fillRetire(diaryId: 5, time: '22:00'); + + final ops = await localDataSource.getPendingOps(); + expect(ops.length, 1); + expect(ops.first.type, 'fill_retire'); + final payload = jsonDecode(ops.first.payload) as Map; + expect(payload['retire'], '22:00'); + }); + + test('онлайн — вызывает сервис', () async { + stubOnline(); + when(mockDiaryService.fillRetire( + diary: 5, retire: '22:00', + )).thenAnswer((_) async {}); + + await repository.fillRetire(diaryId: 5, time: '22:00'); + + verify(mockDiaryService.fillRetire(diary: 5, retire: '22:00')).called(1); + }); + }); + + // ────────────────────────────────────────────────────────────────────────── + // addPortionToMeal — 4xx НЕ ставится в очередь + // ────────────────────────────────────────────────────────────────────────── + + group('addPortionToMeal — 4xx handling', () { + test('4xx ошибка пробрасывается, не ставится в очередь', () async { + stubOnline(); + when(mockDiaryService.addProductToMealIdempotent( + idempotencyKey: anyNamed('idempotencyKey'), + diary: anyNamed('diary'), + typeNo: anyNamed('typeNo'), + productName: anyNamed('productName'), + portion: anyNamed('portion'), + quantity: anyNamed('quantity'), + productNutritionalValueId: anyNamed('productNutritionalValueId'), + )).thenThrow(DioException( + requestOptions: RequestOptions(), + response: Response(requestOptions: RequestOptions(), statusCode: 422), + type: DioExceptionType.badResponse, + )); + + expect( + () => repository.addPortionToMeal( + diaryId: 1, mealId: 1, productName: 'test', portion: 'gr', quantity: 100, + ), + throwsA(isA()), + ); + + final ops = await localDataSource.getPendingOps(); + expect(ops.isEmpty, isTrue); + }); + + test('5xx ошибка кладёт в очередь', () async { + stubOnline(); + when(mockDiaryService.addProductToMealIdempotent( + idempotencyKey: anyNamed('idempotencyKey'), + diary: anyNamed('diary'), + typeNo: anyNamed('typeNo'), + productName: anyNamed('productName'), + portion: anyNamed('portion'), + quantity: anyNamed('quantity'), + productNutritionalValueId: anyNamed('productNutritionalValueId'), + )).thenThrow(DioException( + requestOptions: RequestOptions(), + response: Response(requestOptions: RequestOptions(), statusCode: 503), + type: DioExceptionType.badResponse, + )); + + await repository.addPortionToMeal( + diaryId: 1, mealId: 1, productName: 'test', portion: 'gr', quantity: 100, + ); + + final ops = await localDataSource.getPendingOps(); + expect(ops.length, 1); + }); + }); + + // ────────────────────────────────────────────────────────────────────────── + // Кэширование и загрузка (round-trip) + // ────────────────────────────────────────────────────────────────────────── + + group('Cache round-trip', () { + test('getDiaryDayV2 онлайн — кэширует ответ, офлайн — загружает из кэша', () async { + const diaryDay = DiaryDayModel( + id: 100, + date: '2026-05-01', + wakeup: '07:30', + retire: '23:00', + closed: 0, + comment: 'test comment', + typeOfDiary: TypeOfDiaryModel(hasChosenDiet: false, hasChosenPlan: false, diaryState: null), + meals: [ + MealModel( + id: 1, + time: '08:00', + typeNumber: 1, + satiety: 5, + mood: 3, + portions: [ + PortionModel( + id: 10, + productName: 'Banana', + quantity: 2, + portion: 'pcs', + fromDiet: false, + productNutritionalValueId: 42, + portionState: PortionState.eaten, + ), + ], + photos: [], + ), + ], + chosenPlanId: null, + dayOfChosenDiet: null, + diet: null, + ); + + // Онлайн: сервис возвращает данные + stubOnline(); + when(mockDiaryService.getDiaryDayV2(day: '2026-05-01T00:00:00+00:00')) + .thenAnswer((_) async => diaryDay); + when(mockDiaryService.getGlossariesIndex(filters: anyNamed('filters'))) + .thenAnswer((_) async => const GlossaryPaginationModel(glossariesList: [])); + + final onlineResult = await repository.getDiaryDayV2(day: '2026-05-01T00:00:00+00:00'); + expect(onlineResult.id, 100); + + // Офлайн: загружает из кэша + stubOffline(); + final offlineResult = await repository.getDiaryDayV2(day: '2026-05-01T00:00:00+00:00'); + expect(offlineResult.id, 100); + expect(offlineResult.wakeup, '07:30'); + expect(offlineResult.retire, '23:00'); + expect(offlineResult.meals.length, 1); + expect(offlineResult.meals.first.portions.length, 1); + expect(offlineResult.meals.first.portions.first.productName, 'Banana'); + expect(offlineResult.meals.first.portions.first.productNutritionalValueId, 42); + }); + + test('getDiaryDayV2 офлайн без кэша — пробует сеть, при ошибке выбрасывает', () async { + stubOffline(); + when(mockDiaryService.getDiaryDayV2(day: anyNamed('day'))) + .thenThrow(DioException( + requestOptions: RequestOptions(), + type: DioExceptionType.connectionError, + )); + + expect( + () => repository.getDiaryDayV2(day: '2099-01-01T00:00:00+00:00'), + throwsA(isA()), + ); + }); + + test('createDiaryDay онлайн 422 — не ставит в очередь (день уже существует)', () async { + stubOnline(); + when(mockDiaryService.createDay( + idempotencyKey: anyNamed('idempotencyKey'), + body: anyNamed('body'), + )).thenThrow(DioException( + requestOptions: RequestOptions(), + response: Response(requestOptions: RequestOptions(), statusCode: 422), + type: DioExceptionType.badResponse, + )); + + await repository.createDiaryDay(day: '2026-05-01T00:00:00+00:00'); + + final ops = await localDataSource.getPendingOps(); + expect(ops.isEmpty, isTrue); + }); + + test('createDiaryDay сетевая ошибка — ставит create_day в очередь', () async { + stubOnline(); + when(mockDiaryService.createDay( + idempotencyKey: anyNamed('idempotencyKey'), + body: anyNamed('body'), + )).thenThrow(DioException( + requestOptions: RequestOptions(), + type: DioExceptionType.connectionError, + )); + + await repository.createDiaryDay(day: '2026-05-01T00:00:00+00:00'); + + final ops = await localDataSource.getPendingOps(); + expect(ops.length, 1); + expect(ops.first.type, 'create_day'); + }); + }); + + // ────────────────────────────────────────────────────────────────────────── + // _loadCachedDiaryDay — serverId == null + // ────────────────────────────────────────────────────────────────────────── + + group('_loadCachedDiaryDay edge cases', () { + test('возвращает null если serverId == null (офлайн-созданный день)', () async { + // Кэшируем день без serverId + await localDataSource.cacheDiaryDay(date: '2026-05-02'); + + stubOffline(); + + // getDiaryDayV2 должен попробовать загрузить из кэша, но serverId == null → null + // Потом пробует сеть + when(mockDiaryService.getDiaryDayV2(day: anyNamed('day'))) + .thenThrow(DioException( + requestOptions: RequestOptions(), + type: DioExceptionType.connectionError, + )); + + expect( + () => repository.getDiaryDayV2(day: '2026-05-02T00:00:00+00:00'), + throwsA(isA()), + ); + }); + }); + + // ────────────────────────────────────────────────────────────────────────── + // _encodePayload — спецсимволы в JSON + // ────────────────────────────────────────────────────────────────────────── + + group('JSON payload encoding', () { + test('продукт с кавычками в имени корректно сериализуется', () async { + stubOffline(); + + await repository.addPortionToMeal( + diaryId: 1, + mealId: 1, + productName: 'Сыр "Российский"', + portion: '100г', + quantity: 1, + ); + + final ops = await localDataSource.getPendingOps(); + expect(ops.length, 1); + + // jsonDecode не должен упасть + final payload = jsonDecode(ops.first.payload) as Map; + expect(payload['product_name'], 'Сыр "Российский"'); + }); + + test('продукт с обратными слешами и переносами корректно сериализуется', () async { + stubOffline(); + + await repository.addPortionToMeal( + diaryId: 1, + mealId: 1, + productName: 'Product\\with\\slashes\nand\nnewlines', + portion: 'gr', + quantity: 1, + ); + + final ops = await localDataSource.getPendingOps(); + final payload = jsonDecode(ops.first.payload) as Map; + expect(payload['product_name'], 'Product\\with\\slashes\nand\nnewlines'); + }); + }); + + // ────────────────────────────────────────────────────────────────────────── + // Multiple operations enqueued + // ────────────────────────────────────────────────────────────────────────── + + group('Множество операций в очереди', () { + test('разные типы операций ставятся в очередь в FIFO порядке', () async { + stubOffline(); + + await repository.addPortionToMeal( + diaryId: 1, mealId: 1, productName: 'A', portion: 'gr', quantity: 1, + ); + await repository.fillSatiety(diaryId: 1, mealId: 1, satiety: 5); + await repository.fillMood(diaryId: 1, mealId: 1, mood: 3); + await repository.fillTime(diaryId: 1, mealId: 1, time: '12:00'); + await repository.deleteProductFromMeal(diaryId: 1, mealId: 1, portionId: 10); + await repository.fillWakeup(diaryId: 1, time: '07:00'); + await repository.fillRetire(diaryId: 1, time: '23:00'); + + final ops = await localDataSource.getPendingOps(); + expect(ops.length, 7); + expect(ops[0].type, 'create_portion'); + expect(ops[1].type, 'fill_satiety'); + expect(ops[2].type, 'fill_mood'); + expect(ops[3].type, 'fill_time'); + expect(ops[4].type, 'delete_portion'); + expect(ops[5].type, 'fill_wakeup'); + expect(ops[6].type, 'fill_retire'); + }); + }); +} diff --git a/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart b/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart index 9b109014..0ec586a8 100644 --- a/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart +++ b/test/features/profile/presentation/bloc/profile_cubit/profile_cubit_test.dart @@ -509,6 +509,359 @@ void main() { final state = profileCubit!.state; expect(state, isA()); }); + test('deletePhoto - успешно удаляет фото', () async { + profileCubit = ProfileCubit( + profileRepository: mockProfileRepository, + chatRepository: mockChatRepository, + consultantRepository: mockConsultantRepository, + ); + + const testProfile = ProfileModel( + id: 1, + slug: 'test-user', + name: 'Test User', + email: 'test@example.com', + phoneConfirmationState: 'confirmed', + isPublicProfile: true, + isBlockedByMe: false, + isBlockedByAuthor: false, + isBlockedByUser: false, + isEntitySubscribedByMe: false, + profilePhotoUrl: 'https://example.com/icon.jpg', + roles: [], + badges: [], + subscriptions: [], + subscribers: [], + ); + + const profileDetails = ProfileDetailsModel( + firstName: 'Test', + lastName: 'User', + birthdate: '1990-01-01', + gender: 'male', + country: 'Russia', + city: 'Moscow', + lang: 'ru', + weight: 70.0, + height: 175, + chestCircumference: 100, + waistCircumference: 80, + hipCircumference: 90, + bloodPressureUp: 120, + bloodPressureDown: 80, + hasHypo: false, + hasHyper: false, + aboutAlergy: '', + aboutTakingPils: '', + aboutSportHobbies: '', + favoriteFoods: '', + unfavoriteFoods: '', + additionalInfo: '', + isMoodFillable: true, + isBedtimeFillable: true, + activityCoefficient: 1.2, + ); + + when(mockProfileRepository.deletePhoto()).thenAnswer((_) async => {}); + when(mockProfileRepository.getMyProfile()).thenAnswer((_) async => testProfile); + when(mockProfileRepository.getMyProfileDetails()).thenAnswer((_) async => profileDetails); + when(mockProfileRepository.getBlogPostsByAuthorSlug(slug: 'test-user')).thenAnswer((_) async => []); + when(mockProfileRepository.getWeight(period: '1')).thenAnswer((_) async => []); + when(mockProfileRepository.getCircumferences(period: '1')).thenAnswer((_) async => []); + + await profileCubit!.deletePhoto(); + + verify(mockProfileRepository.deletePhoto()).called(1); + expect(profileCubit!.state, isA()); + }); + + test('deletePhoto - обработка ошибки', () async { + profileCubit = ProfileCubit( + profileRepository: mockProfileRepository, + chatRepository: mockChatRepository, + consultantRepository: mockConsultantRepository, + ); + + when(mockProfileRepository.deletePhoto()).thenThrow(Exception('Ошибка удаления')); + + await profileCubit!.deletePhoto(); + + expect(profileCubit!.state, isA()); + }); + + test('hideCertPhoto - успешно скрывает фото', () async { + profileCubit = ProfileCubit( + profileRepository: mockProfileRepository, + chatRepository: mockChatRepository, + consultantRepository: mockConsultantRepository, + ); + + const testProfile = ProfileModel( + id: 1, + slug: 'test-user', + name: 'Test User', + email: 'test@example.com', + phoneConfirmationState: 'confirmed', + isPublicProfile: true, + isBlockedByMe: false, + isBlockedByAuthor: false, + isBlockedByUser: false, + isEntitySubscribedByMe: false, + profilePhotoUrl: '', + roles: [], + badges: [], + subscriptions: [], + subscribers: [], + ); + + const profileDetails = ProfileDetailsModel( + firstName: 'Test', + lastName: 'User', + birthdate: '1990-01-01', + gender: 'male', + country: 'Russia', + city: 'Moscow', + lang: 'ru', + weight: 70.0, + height: 175, + chestCircumference: 100, + waistCircumference: 80, + hipCircumference: 90, + bloodPressureUp: 120, + bloodPressureDown: 80, + hasHypo: false, + hasHyper: false, + aboutAlergy: '', + aboutTakingPils: '', + aboutSportHobbies: '', + favoriteFoods: '', + unfavoriteFoods: '', + additionalInfo: '', + isMoodFillable: true, + isBedtimeFillable: true, + activityCoefficient: 1.2, + ); + + // Устанавливаем начальное loaded-состояние + profileCubit!.emit(const ProfileState.loaded( + profile: testProfile, + isMyProfile: true, + profileDetails: profileDetails, + blogPosts: [], + certPhotos: [], + )); + + when(mockConsultantRepository.hideConsultantCertPhoto(1)).thenAnswer((_) async => {}); + when(mockConsultantRepository.getConsultantCertPhotos()).thenAnswer((_) async => []); + + await profileCubit!.hideCertPhoto(1); + + verify(mockConsultantRepository.hideConsultantCertPhoto(1)).called(1); + expect(profileCubit!.state, isA()); + }); + + test('unhideCertPhoto - успешно показывает фото', () async { + profileCubit = ProfileCubit( + profileRepository: mockProfileRepository, + chatRepository: mockChatRepository, + consultantRepository: mockConsultantRepository, + ); + + const testProfile = ProfileModel( + id: 1, + slug: 'test-user', + name: 'Test User', + email: 'test@example.com', + phoneConfirmationState: 'confirmed', + isPublicProfile: true, + isBlockedByMe: false, + isBlockedByAuthor: false, + isBlockedByUser: false, + isEntitySubscribedByMe: false, + profilePhotoUrl: '', + roles: [], + badges: [], + subscriptions: [], + subscribers: [], + ); + + const profileDetails = ProfileDetailsModel( + firstName: 'Test', + lastName: 'User', + birthdate: '1990-01-01', + gender: 'male', + country: 'Russia', + city: 'Moscow', + lang: 'ru', + weight: 70.0, + height: 175, + chestCircumference: 100, + waistCircumference: 80, + hipCircumference: 90, + bloodPressureUp: 120, + bloodPressureDown: 80, + hasHypo: false, + hasHyper: false, + aboutAlergy: '', + aboutTakingPils: '', + aboutSportHobbies: '', + favoriteFoods: '', + unfavoriteFoods: '', + additionalInfo: '', + isMoodFillable: true, + isBedtimeFillable: true, + activityCoefficient: 1.2, + ); + + profileCubit!.emit(const ProfileState.loaded( + profile: testProfile, + isMyProfile: true, + profileDetails: profileDetails, + blogPosts: [], + certPhotos: [], + )); + + when(mockConsultantRepository.unHideConsultantCertPhoto(2)).thenAnswer((_) async => {}); + when(mockConsultantRepository.getConsultantCertPhotos()).thenAnswer((_) async => []); + + await profileCubit!.unhideCertPhoto(2); + + verify(mockConsultantRepository.unHideConsultantCertPhoto(2)).called(1); + expect(profileCubit!.state, isA()); + }); + + test('getProfileBySlug - загрузка своего профиля (isMyProfile = true)', () async { + profileCubit = ProfileCubit( + profileRepository: mockProfileRepository, + chatRepository: mockChatRepository, + consultantRepository: mockConsultantRepository, + ); + + const testProfile = ProfileModel( + id: 1, + slug: 'test-user', + name: 'Test User', + email: 'test@example.com', + phoneConfirmationState: 'confirmed', + isPublicProfile: true, + isBlockedByMe: false, + isBlockedByAuthor: false, + isBlockedByUser: false, + isEntitySubscribedByMe: false, + profilePhotoUrl: '', + roles: [], + badges: [], + subscriptions: [], + subscribers: [], + ); + + const profileDetails = ProfileDetailsModel( + firstName: 'Test', + lastName: 'User', + birthdate: '1990-01-01', + gender: 'male', + country: 'Russia', + city: 'Moscow', + lang: 'ru', + weight: 70.0, + height: 175, + chestCircumference: 100, + waistCircumference: 80, + hipCircumference: 90, + bloodPressureUp: 120, + bloodPressureDown: 80, + hasHypo: false, + hasHyper: false, + aboutAlergy: '', + aboutTakingPils: '', + aboutSportHobbies: '', + favoriteFoods: '', + unfavoriteFoods: '', + additionalInfo: '', + isMoodFillable: true, + isBedtimeFillable: true, + activityCoefficient: 1.2, + ); + + when(mockProfileRepository.getProfileBySlug('test-user')).thenAnswer((_) async => testProfile); + when(mockProfileRepository.getMyProfile()).thenAnswer((_) async => testProfile); + when(mockProfileRepository.getProfileDetailsBySlug('test-user')).thenAnswer((_) async => profileDetails); + when(mockProfileRepository.getBlogPostsByAuthorSlug(slug: 'test-user')).thenAnswer((_) async => []); + when(mockConsultantRepository.getConsultantCertPhotos()).thenAnswer((_) async => []); + + await profileCubit!.getProfileBySlug('test-user'); + + final state = profileCubit!.state; + expect(state, isA()); + if (state is ProfileStateLoaded) { + expect(state.isMyProfile, true); + } + }); + + test('getMyProfile - с указанием period', () async { + profileCubit = ProfileCubit( + profileRepository: mockProfileRepository, + chatRepository: mockChatRepository, + consultantRepository: mockConsultantRepository, + ); + + const testProfile = ProfileModel( + id: 1, + slug: 'test-user', + name: 'Test User', + email: 'test@example.com', + phoneConfirmationState: 'confirmed', + isPublicProfile: true, + isBlockedByMe: false, + isBlockedByAuthor: false, + isBlockedByUser: false, + isEntitySubscribedByMe: false, + profilePhotoUrl: '', + roles: [], + badges: [], + subscriptions: [], + subscribers: [], + ); + + const profileDetails = ProfileDetailsModel( + firstName: 'Test', + lastName: 'User', + birthdate: '1990-01-01', + gender: 'male', + country: 'Russia', + city: 'Moscow', + lang: 'ru', + weight: 70.0, + height: 175, + chestCircumference: 100, + waistCircumference: 80, + hipCircumference: 90, + bloodPressureUp: 120, + bloodPressureDown: 80, + hasHypo: false, + hasHyper: false, + aboutAlergy: '', + aboutTakingPils: '', + aboutSportHobbies: '', + favoriteFoods: '', + unfavoriteFoods: '', + additionalInfo: '', + isMoodFillable: true, + isBedtimeFillable: true, + activityCoefficient: 1.2, + ); + + when(mockProfileRepository.getMyProfile()).thenAnswer((_) async => testProfile); + when(mockProfileRepository.getMyProfileDetails()).thenAnswer((_) async => profileDetails); + when(mockProfileRepository.getBlogPostsByAuthorSlug(slug: 'test-user')).thenAnswer((_) async => []); + when(mockProfileRepository.getWeight(period: '3')).thenAnswer((_) async => []); + when(mockProfileRepository.getCircumferences(period: '3')).thenAnswer((_) async => []); + + await profileCubit!.getMyProfile(period: '3'); + + verify(mockProfileRepository.getWeight(period: '3')).called(1); + verify(mockProfileRepository.getCircumferences(period: '3')).called(1); + }); }); }