feat(diary): tests

This commit is contained in:
mso7nueh
2026-05-05 19:40:28 +03:00
parent 48af13e37c
commit 2859607f4d
4 changed files with 1556 additions and 397 deletions

View File

@@ -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);
});
});
}

View File

@@ -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<DiaryLocalDataSource>(),
MockSpec<DiaryService>(),
MockSpec<InternetConnectionCubit>(),
])
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<void> 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();
});
});
}

View File

@@ -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<DiaryService>(),
MockSpec<InternetConnectionCubit>(),
])
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<String, dynamic>;
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<DioException>()),
);
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<String, dynamic>;
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<String, dynamic>;
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<String, dynamic>;
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<String, dynamic>;
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<String, dynamic>;
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<DioException>()),
);
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<DioException>()),
);
});
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<DioException>()),
);
});
});
// ──────────────────────────────────────────────────────────────────────────
// _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<String, dynamic>;
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<String, dynamic>;
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');
});
});
}

View File

@@ -509,6 +509,359 @@ void main() {
final state = profileCubit!.state;
expect(state, isA<ProfileStateError>());
});
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<ProfileStateLoaded>());
});
test('deletePhoto - обработка ошибки', () async {
profileCubit = ProfileCubit(
profileRepository: mockProfileRepository,
chatRepository: mockChatRepository,
consultantRepository: mockConsultantRepository,
);
when(mockProfileRepository.deletePhoto()).thenThrow(Exception('Ошибка удаления'));
await profileCubit!.deletePhoto();
expect(profileCubit!.state, isA<ProfileStateError>());
});
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<ProfileStateLoaded>());
});
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<ProfileStateLoaded>());
});
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<ProfileStateLoaded>());
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);
});
});
}