forked from eatvio/eatvio_app
feat(diary): tests
This commit is contained in:
367
test/core/local_db/diary_local_data_source_test.dart
Normal file
367
test/core/local_db/diary_local_data_source_test.dart
Normal 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);
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user