diff --git a/.gitea/workflows/test.yml b/.gitea/workflows/test.yml index b27aed6c..66844dff 100644 --- a/.gitea/workflows/test.yml +++ b/.gitea/workflows/test.yml @@ -175,7 +175,16 @@ jobs: if (file ~ /\.(g|freezed|gr|mocks)\.dart$/) { is_generated = 1 generated_files++ - } else if (file ~ /\/(build|\.dart_tool)\//) { + } else if (file ~ /\/(build|\.dart_tool|generated)\//) { + is_generated = 1 + generated_files++ + } else if (file ~ /\/l10n\.dart$/) { + is_generated = 1 + generated_files++ + } else if (file ~ /\/dev_tools\//) { + is_generated = 1 + generated_files++ + } else if (file ~ /(firebase_api|firebase_options)\.dart$/) { is_generated = 1 generated_files++ } else { diff --git a/test/core/error_reporting/error_report_policy_test.dart b/test/core/error_reporting/error_report_policy_test.dart index 52bbc6e5..f8287ced 100644 --- a/test/core/error_reporting/error_report_policy_test.dart +++ b/test/core/error_reporting/error_report_policy_test.dart @@ -3,112 +3,129 @@ import 'dart:io'; import 'package:dio/dio.dart'; import 'package:eatvio_app/core/error_reporting/error_report_policy.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:sentry_flutter/sentry_flutter.dart'; void main() { group('ErrorReportPolicy.shouldReport', () { - test('returns false for null', () { + test('null → false', () { expect(ErrorReportPolicy.shouldReport(null), false); }); - test('returns false for String', () { - expect(ErrorReportPolicy.shouldReport('user message'), false); + test('String → false', () { + expect(ErrorReportPolicy.shouldReport('error string'), false); }); - test('returns false for SocketException', () { - expect(ErrorReportPolicy.shouldReport(const SocketException('offline')), false); + test('SocketException → false', () { + expect(ErrorReportPolicy.shouldReport(const SocketException('failed')), false); }); - test('returns false for cancelled Dio request', () { + test('DioException cancel → false', () { final e = DioException( - requestOptions: RequestOptions(path: '/x'), type: DioExceptionType.cancel, + requestOptions: RequestOptions(path: '/'), ); expect(ErrorReportPolicy.shouldReport(e), false); }); - test('returns false for Dio timeouts', () { - for (final type in [ - DioExceptionType.connectionTimeout, - DioExceptionType.sendTimeout, - DioExceptionType.receiveTimeout, - ]) { - final e = DioException( - requestOptions: RequestOptions(path: '/x'), - type: type, - ); - expect(ErrorReportPolicy.shouldReport(e), false, reason: '$type'); - } - }); - - test('returns false for HTTP 4xx with response', () { + test('DioException connectionTimeout → false', () { final e = DioException( - requestOptions: RequestOptions(path: '/x'), - response: Response(statusCode: 422, requestOptions: RequestOptions(path: '/x')), - type: DioExceptionType.badResponse, + type: DioExceptionType.connectionTimeout, + requestOptions: RequestOptions(path: '/'), ); expect(ErrorReportPolicy.shouldReport(e), false); }); - test('returns false for HTTP 5xx (handled by sentry_dio failed-request)', () { + test('DioException sendTimeout → false', () { final e = DioException( - requestOptions: RequestOptions(path: '/x'), - response: Response(statusCode: 503, requestOptions: RequestOptions(path: '/x')), - type: DioExceptionType.badResponse, + type: DioExceptionType.sendTimeout, + requestOptions: RequestOptions(path: '/'), ); expect(ErrorReportPolicy.shouldReport(e), false); }); - test('returns false for Dio connectionError / offline-style', () { + test('DioException receiveTimeout → false', () { + final e = DioException( + type: DioExceptionType.receiveTimeout, + requestOptions: RequestOptions(path: '/'), + ); + expect(ErrorReportPolicy.shouldReport(e), false); + }); + + test('DioException connectionError → false', () { final e = DioException( - requestOptions: RequestOptions(path: '/x'), type: DioExceptionType.connectionError, - error: const SocketException('offline'), + requestOptions: RequestOptions(path: '/'), ); expect(ErrorReportPolicy.shouldReport(e), false); }); - test('returns true for arbitrary exceptions', () { - expect(ErrorReportPolicy.shouldReport(StateError('x')), true); - }); - }); - - group('ErrorReportPolicy.allowSentryEvent', () { - final hint = Hint(); - - test('allows 5xx Dio so sentry_dio events are not dropped in beforeSend', () { + test('DioException 5xx → false', () { final e = DioException( - requestOptions: RequestOptions(path: '/x'), - response: Response(statusCode: 503, requestOptions: RequestOptions(path: '/x')), type: DioExceptionType.badResponse, + requestOptions: RequestOptions(path: '/'), + response: Response( + requestOptions: RequestOptions(path: '/'), + statusCode: 500, + ), ); - final event = SentryEvent(throwable: e); - expect(ErrorReportPolicy.allowSentryEvent(event, hint), true); + expect(ErrorReportPolicy.shouldReport(e), false); }); - test('drops 4xx Dio from SDK', () { + test('DioException 502 → false', () { final e = DioException( - requestOptions: RequestOptions(path: '/x'), - response: Response(statusCode: 422, requestOptions: RequestOptions(path: '/x')), type: DioExceptionType.badResponse, + requestOptions: RequestOptions(path: '/'), + response: Response( + requestOptions: RequestOptions(path: '/'), + statusCode: 502, + ), ); - final event = SentryEvent(throwable: e); - expect(ErrorReportPolicy.allowSentryEvent(event, hint), false); + expect(ErrorReportPolicy.shouldReport(e), false); }); - test('drops transient Dio from SDK (connectionError)', () { + test('DioException 4xx → false', () { final e = DioException( - requestOptions: RequestOptions(path: '/x'), - type: DioExceptionType.connectionError, - error: const SocketException('reset'), + type: DioExceptionType.badResponse, + requestOptions: RequestOptions(path: '/'), + response: Response( + requestOptions: RequestOptions(path: '/'), + statusCode: 422, + ), ); - final event = SentryEvent(throwable: e); - expect(ErrorReportPolicy.allowSentryEvent(event, hint), false); + expect(ErrorReportPolicy.shouldReport(e), false); }); - test('drops SocketException from SDK when not wrapped in Dio', () { - final event = SentryEvent(throwable: const SocketException('x')); - expect(ErrorReportPolicy.allowSentryEvent(event, hint), false); + test('DioException unknown type with SocketException error → false', () { + final e = DioException( + type: DioExceptionType.unknown, + requestOptions: RequestOptions(path: '/'), + error: const SocketException('socket'), + ); + expect(ErrorReportPolicy.shouldReport(e), false); + }); + + test('DioException unknown type without SocketException → true', () { + final e = DioException( + type: DioExceptionType.unknown, + requestOptions: RequestOptions(path: '/'), + error: 'something else', + ); + expect(ErrorReportPolicy.shouldReport(e), true); + }); + + test('regular Exception → true', () { + expect(ErrorReportPolicy.shouldReport(Exception('error')), true); + }); + + test('TypeError → true', () { + expect(ErrorReportPolicy.shouldReport(TypeError()), true); + }); + + test('DioException badCertificate → true', () { + final e = DioException( + type: DioExceptionType.badCertificate, + requestOptions: RequestOptions(path: '/'), + ); + expect(ErrorReportPolicy.shouldReport(e), true); }); }); } diff --git a/test/core/interceptors/offline_error_interceptor_test.dart b/test/core/interceptors/offline_error_interceptor_test.dart new file mode 100644 index 00000000..83559271 --- /dev/null +++ b/test/core/interceptors/offline_error_interceptor_test.dart @@ -0,0 +1,262 @@ +import 'package:dio/dio.dart'; +import 'package:eatvio_app/core/interceptors/offline_error_interceptor.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('OfflineOperationException', () { + test('stores fields correctly', () { + const e = OfflineOperationException( + message: 'Test message', + code: 'test_code', + retryable: true, + ); + expect(e.message, 'Test message'); + expect(e.code, 'test_code'); + expect(e.retryable, true); + }); + + test('code can be null', () { + const e = OfflineOperationException(message: 'msg', retryable: false); + expect(e.code, isNull); + }); + + test('toString contains all fields', () { + const e = OfflineOperationException( + message: 'Test', + code: 'code', + retryable: true, + ); + final str = e.toString(); + expect(str, contains('retryable: true')); + expect(str, contains('code: code')); + expect(str, contains('message: Test')); + }); + + test('retryable false', () { + const e = OfflineOperationException( + message: 'fail', + code: 'err', + retryable: false, + ); + expect(e.retryable, false); + expect(e.toString(), contains('retryable: false')); + }); + + test('implements Exception', () { + const e = OfflineOperationException(message: 'm', retryable: true); + expect(e, isA()); + }); + }); + + group('OfflineErrorInterceptor', () { + late OfflineErrorInterceptor interceptor; + late _TestErrorHandler handler; + + setUp(() { + interceptor = OfflineErrorInterceptor(); + handler = _TestErrorHandler(); + }); + + test('wraps connectionTimeout as retryable OfflineOperationException', () { + final err = DioException( + type: DioExceptionType.connectionTimeout, + requestOptions: RequestOptions(path: '/test'), + ); + interceptor.onError(err, handler); + expect(handler.nextCalled, true); + final wrappedError = handler.lastError!.error; + expect(wrappedError, isA()); + expect((wrappedError as OfflineOperationException).retryable, true); + expect(wrappedError.code, 'connection_error'); + }); + + test('wraps receiveTimeout as retryable', () { + final err = DioException( + type: DioExceptionType.receiveTimeout, + requestOptions: RequestOptions(path: '/test'), + ); + interceptor.onError(err, handler); + expect(handler.nextCalled, true); + final wrappedError = handler.lastError!.error; + expect(wrappedError, isA()); + expect((wrappedError as OfflineOperationException).retryable, true); + }); + + test('wraps sendTimeout as retryable', () { + final err = DioException( + type: DioExceptionType.sendTimeout, + requestOptions: RequestOptions(path: '/test'), + ); + interceptor.onError(err, handler); + final wrappedError = handler.lastError!.error; + expect(wrappedError, isA()); + expect((wrappedError as OfflineOperationException).retryable, true); + }); + + test('wraps connectionError as retryable', () { + final err = DioException( + type: DioExceptionType.connectionError, + requestOptions: RequestOptions(path: '/test'), + ); + interceptor.onError(err, handler); + final wrappedError = handler.lastError!.error; + expect(wrappedError, isA()); + expect((wrappedError as OfflineOperationException).retryable, true); + }); + + test('wraps 5xx as retryable server error', () { + final err = DioException( + type: DioExceptionType.badResponse, + requestOptions: RequestOptions(path: '/test'), + response: Response( + requestOptions: RequestOptions(path: '/test'), + statusCode: 500, + ), + ); + interceptor.onError(err, handler); + final wrappedError = handler.lastError!.error; + expect(wrappedError, isA()); + expect((wrappedError as OfflineOperationException).retryable, true); + expect(wrappedError.code, 'server_error'); + }); + + test('wraps 503 as retryable server error', () { + final err = DioException( + type: DioExceptionType.badResponse, + requestOptions: RequestOptions(path: '/test'), + response: Response( + requestOptions: RequestOptions(path: '/test'), + statusCode: 503, + ), + ); + interceptor.onError(err, handler); + final wrappedError = handler.lastError!.error; + expect(wrappedError, isA()); + expect((wrappedError as OfflineOperationException).retryable, true); + }); + + test('parses 4xx error envelope with retryable=false', () { + final err = DioException( + type: DioExceptionType.badResponse, + requestOptions: RequestOptions(path: '/test'), + response: Response( + requestOptions: RequestOptions(path: '/test'), + statusCode: 400, + data: { + 'message': 'Bad request', + 'error': { + 'code': 'validation_error', + 'message': 'Invalid field', + 'retryable': false, + }, + }, + ), + ); + interceptor.onError(err, handler); + final wrappedError = handler.lastError!.error; + expect(wrappedError, isA()); + expect((wrappedError as OfflineOperationException).retryable, false); + expect(wrappedError.code, 'validation_error'); + expect(wrappedError.message, 'Invalid field'); + }); + + test('parses 4xx error envelope with retryable=true', () { + final err = DioException( + type: DioExceptionType.badResponse, + requestOptions: RequestOptions(path: '/test'), + response: Response( + requestOptions: RequestOptions(path: '/test'), + statusCode: 429, + data: { + 'message': 'Rate limited', + 'error': { + 'code': 'rate_limit', + 'message': 'Too many requests', + 'retryable': true, + }, + }, + ), + ); + interceptor.onError(err, handler); + final wrappedError = handler.lastError!.error; + expect(wrappedError, isA()); + expect((wrappedError as OfflineOperationException).retryable, true); + }); + + test('passes through 4xx without error envelope', () { + final err = DioException( + type: DioExceptionType.badResponse, + requestOptions: RequestOptions(path: '/test'), + response: Response( + requestOptions: RequestOptions(path: '/test'), + statusCode: 404, + data: 'Not found', + ), + ); + interceptor.onError(err, handler); + expect(handler.nextCalled, true); + // Should pass through as-is, no OfflineOperationException wrapping + expect(handler.lastError!.error, isNot(isA())); + }); + + test('passes through when response is null', () { + final err = DioException( + type: DioExceptionType.unknown, + requestOptions: RequestOptions(path: '/test'), + ); + interceptor.onError(err, handler); + expect(handler.nextCalled, true); + }); + + test('parses envelope with missing retryable field (defaults to false)', () { + final err = DioException( + type: DioExceptionType.badResponse, + requestOptions: RequestOptions(path: '/test'), + response: Response( + requestOptions: RequestOptions(path: '/test'), + statusCode: 422, + data: { + 'message': 'Unprocessable', + 'error': { + 'code': 'validation', + 'message': 'Field required', + }, + }, + ), + ); + interceptor.onError(err, handler); + final wrappedError = handler.lastError!.error; + expect(wrappedError, isA()); + expect((wrappedError as OfflineOperationException).retryable, false); + }); + + test('handles 4xx with error field but malformed data', () { + final err = DioException( + type: DioExceptionType.badResponse, + requestOptions: RequestOptions(path: '/test'), + response: Response( + requestOptions: RequestOptions(path: '/test'), + statusCode: 400, + data: { + 'error': 'just a string, not a map', + }, + ), + ); + interceptor.onError(err, handler); + expect(handler.nextCalled, true); + // Error caught, passed through as-is + }); + }); +} + +/// Custom test handler that captures the error without throwing +class _TestErrorHandler extends ErrorInterceptorHandler { + DioException? lastError; + bool nextCalled = false; + + @override + void next(DioException err) { + lastError = err; + nextCalled = true; + } +} diff --git a/test/core/local_db/services/diary_sync_runner_test.dart b/test/core/local_db/services/diary_sync_runner_test.dart new file mode 100644 index 00000000..09f7ec89 --- /dev/null +++ b/test/core/local_db/services/diary_sync_runner_test.dart @@ -0,0 +1,468 @@ +import 'dart:convert'; + +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/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'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), + MockSpec(), +]) +import 'diary_sync_runner_test.mocks.dart'; + +void main() { + late DiarySyncRunner runner; + late MockDiaryLocalDataSource mockLocalDataSource; + late MockDiaryService mockDiaryService; + late MockInternetConnectionCubit mockConnectionCubit; + + setUp(() { + mockLocalDataSource = MockDiaryLocalDataSource(); + mockDiaryService = MockDiaryService(); + mockConnectionCubit = MockInternetConnectionCubit(); + + when(mockConnectionCubit.stream).thenAnswer((_) => const Stream.empty()); + when(mockConnectionCubit.state).thenReturn(InternetConnectionState(isInternetAvailable: false)); + + runner = DiarySyncRunner( + localDataSource: mockLocalDataSource, + diaryService: mockDiaryService, + connectionCubit: mockConnectionCubit, + ); + }); + + tearDown(() { + runner.dispose(); + }); + + group('DiarySyncRunner', () { + test('start() подписывается на connectionCubit.stream', () { + runner.start(); + verify(mockConnectionCubit.stream).called(1); + }); + + test('start() запускает _processQueue если уже online', () 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); + }); + + 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'), + typeNo: anyNamed('typeNo'), + productName: anyNamed('productName'), + portion: anyNamed('portion'), + quantity: anyNamed('quantity'), + productNutritionalValueId: anyNamed('productNutritionalValueId'), + )).thenAnswer((_) async {}); + + runner.start(); + await Future.delayed(const Duration(milliseconds: 400)); + + verify(mockLocalDataSource.markSynced('portion-id')).called(1); + }); + + 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, + ); + + 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( + 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, + )); + + runner.start(); + await Future.delayed(const Duration(seconds: 2)); + + verify(mockLocalDataSource.incrementRetry('retry-id')).called(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, + ); + + 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'), + )).thenThrow(const OfflineOperationException( + message: 'Not found', + code: 'not_found', + retryable: false, + )); + + runner.start(); + await Future.delayed(const Duration(milliseconds: 400)); + + verify(mockLocalDataSource.markFailed('no-retry-id')).called(1); + }); + + 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)); + }); + }); +} diff --git a/test/core/local_db/services/glossary_models_test.dart b/test/core/local_db/services/glossary_models_test.dart new file mode 100644 index 00000000..4f2f8f72 --- /dev/null +++ b/test/core/local_db/services/glossary_models_test.dart @@ -0,0 +1,205 @@ +import 'package:eatvio_app/core/local_db/services/glossary_dump_response.dart'; +import 'package:eatvio_app/core/local_db/services/glossary_meta_model.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('GlossaryDumpItem', () { + test('fromJson parses all fields', () { + final json = { + 'id': 1, + 'name': 'Яблоко', + 'product_group_id': 5, + 'energy_on_100gr': 52.0, + 'proteins_on_100gr': 0.3, + 'fats_on_100gr': 0.2, + 'carbohydrates_on_100gr': 14.0, + 'portion_mass_gr': 150.0, + 'energy_on_portion': 78.0, + 'proteins_on_portion': 0.45, + 'fats_on_portion': 0.3, + 'carbohydrates_on_portion': 21.0, + 'updated_at': '2024-01-01T12:00:00Z', + }; + final item = GlossaryDumpItem.fromJson(json); + expect(item.id, 1); + expect(item.name, 'Яблоко'); + expect(item.productGroupId, 5); + expect(item.energyOn100gr, 52.0); + expect(item.proteinsOn100gr, 0.3); + expect(item.fatsOn100gr, 0.2); + expect(item.carbohydratesOn100gr, 14.0); + expect(item.portionMassGr, 150.0); + expect(item.energyOnPortion, 78.0); + expect(item.proteinsOnPortion, 0.45); + expect(item.fatsOnPortion, 0.3); + expect(item.carbohydratesOnPortion, 21.0); + expect(item.updatedAt, '2024-01-01T12:00:00Z'); + }); + + test('fromJson uses default values for portion fields', () { + final json = { + 'id': 2, + 'name': 'Banana', + 'product_group_id': 5, + 'energy_on_100gr': 89.0, + 'proteins_on_100gr': 1.1, + 'fats_on_100gr': 0.3, + 'carbohydrates_on_100gr': 22.8, + 'updated_at': '2024-01-01T12:00:00Z', + }; + final item = GlossaryDumpItem.fromJson(json); + expect(item.portionMassGr, 0.0); + expect(item.energyOnPortion, 0.0); + expect(item.proteinsOnPortion, 0.0); + expect(item.fatsOnPortion, 0.0); + expect(item.carbohydratesOnPortion, 0.0); + }); + + test('toJson serializes all fields', () { + const item = GlossaryDumpItem( + id: 1, + name: 'Apple', + productGroupId: 5, + energyOn100gr: 52.0, + proteinsOn100gr: 0.3, + fatsOn100gr: 0.2, + carbohydratesOn100gr: 14.0, + portionMassGr: 150.0, + energyOnPortion: 78.0, + updatedAt: '2024-01-01T12:00:00Z', + ); + final json = item.toJson(); + expect(json['id'], 1); + expect(json['name'], 'Apple'); + expect(json['product_group_id'], 5); + expect(json['energy_on_100gr'], 52.0); + expect(json['portion_mass_gr'], 150.0); + }); + + test('constructor works with default values', () { + const item = GlossaryDumpItem( + id: 1, + name: 'Test', + productGroupId: 1, + energyOn100gr: 100.0, + proteinsOn100gr: 10.0, + fatsOn100gr: 5.0, + carbohydratesOn100gr: 20.0, + updatedAt: '2024-01-01', + ); + expect(item.portionMassGr, 0.0); + expect(item.energyOnPortion, 0.0); + }); + }); + + group('GlossaryDumpResponse', () { + test('fromJson parses response', () { + final json = { + 'total': 2, + 'updated_at': '2024-01-01T12:00:00Z', + 'data': [ + { + 'id': 1, + 'name': 'Apple', + 'product_group_id': 5, + 'energy_on_100gr': 52.0, + 'proteins_on_100gr': 0.3, + 'fats_on_100gr': 0.2, + 'carbohydrates_on_100gr': 14.0, + 'updated_at': '2024-01-01T12:00:00Z', + }, + { + 'id': 2, + 'name': 'Banana', + 'product_group_id': 5, + 'energy_on_100gr': 89.0, + 'proteins_on_100gr': 1.1, + 'fats_on_100gr': 0.3, + 'carbohydrates_on_100gr': 22.8, + 'updated_at': '2024-01-01T12:00:00Z', + }, + ], + }; + final response = GlossaryDumpResponse.fromJson(json); + expect(response.total, 2); + expect(response.updatedAt, '2024-01-01T12:00:00Z'); + expect(response.data.length, 2); + expect(response.data[0].name, 'Apple'); + expect(response.data[1].name, 'Banana'); + }); + + test('toJson serializes response', () { + const response = GlossaryDumpResponse( + total: 1, + updatedAt: '2024-01-01T12:00:00Z', + data: [ + GlossaryDumpItem( + id: 1, + name: 'Apple', + productGroupId: 5, + energyOn100gr: 52.0, + proteinsOn100gr: 0.3, + fatsOn100gr: 0.2, + carbohydratesOn100gr: 14.0, + updatedAt: '2024-01-01T12:00:00Z', + ), + ], + ); + final json = response.toJson(); + expect(json['total'], 1); + expect(json['updated_at'], '2024-01-01T12:00:00Z'); + expect(json['data'], isA()); + expect((json['data'] as List).length, 1); + }); + + test('handles empty data list', () { + final json = { + 'total': 0, + 'updated_at': '2024-01-01T12:00:00Z', + 'data': [], + }; + final response = GlossaryDumpResponse.fromJson(json); + expect(response.total, 0); + expect(response.data, isEmpty); + }); + }); + + group('GlossaryMetaModel', () { + test('fromJson parses all fields', () { + final json = { + 'total_count': 200000, + 'last_updated_at': '2024-06-15T10:30:00Z', + 'checksum': 'abc123def456', + }; + final meta = GlossaryMetaModel.fromJson(json); + expect(meta.totalCount, 200000); + expect(meta.lastUpdatedAt, '2024-06-15T10:30:00Z'); + expect(meta.checksum, 'abc123def456'); + }); + + test('toJson serializes all fields', () { + const meta = GlossaryMetaModel( + totalCount: 100, + lastUpdatedAt: '2024-01-01T00:00:00Z', + checksum: 'hash123', + ); + final json = meta.toJson(); + expect(json['total_count'], 100); + expect(json['last_updated_at'], '2024-01-01T00:00:00Z'); + expect(json['checksum'], 'hash123'); + }); + + test('roundtrip fromJson -> toJson', () { + const original = GlossaryMetaModel( + totalCount: 50, + lastUpdatedAt: '2024-06-01T12:00:00Z', + checksum: 'xyz', + ); + final json = original.toJson(); + final restored = GlossaryMetaModel.fromJson(json); + expect(restored.totalCount, original.totalCount); + expect(restored.lastUpdatedAt, original.lastUpdatedAt); + expect(restored.checksum, original.checksum); + }); + }); +} diff --git a/test/core/utils/validation_utils_test.dart b/test/core/utils/validation_utils_test.dart index 30c4adf1..94e7f5ea 100644 --- a/test/core/utils/validation_utils_test.dart +++ b/test/core/utils/validation_utils_test.dart @@ -1,270 +1,125 @@ -import 'package:eatvio_app/core/constants/app_constants.dart'; import 'package:eatvio_app/core/utils/validation_utils.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { group('ValidationUtils', () { group('isValidEmail', () { - test('returns true for valid email', () { - expect(ValidationUtils.isValidEmail('test@example.com'), isTrue); - expect(ValidationUtils.isValidEmail('user.name@domain.co.uk'), isTrue); - }); - - test('returns false for invalid email', () { - expect(ValidationUtils.isValidEmail(''), isFalse); - expect(ValidationUtils.isValidEmail('invalid'), isFalse); - expect(ValidationUtils.isValidEmail('@example.com'), isFalse); - expect(ValidationUtils.isValidEmail('test@'), isFalse); - }); + test('empty → false', () => expect(ValidationUtils.isValidEmail(''), false)); + test('valid email', () => expect(ValidationUtils.isValidEmail('test@test.com'), true)); + test('invalid email', () => expect(ValidationUtils.isValidEmail('not-an-email'), false)); + test('email with dots', () => expect(ValidationUtils.isValidEmail('user.name@domain.co.uk'), true)); }); group('isValidPhone', () { - test('returns true for valid phone', () { - expect(ValidationUtils.isValidPhone('+79991234567'), isTrue); - }); - - test('returns false for invalid phone', () { - expect(ValidationUtils.isValidPhone(''), isFalse); - expect(ValidationUtils.isValidPhone('abc'), isFalse); - expect(ValidationUtils.isValidPhone('0123456789'), isFalse); // Starts with 0 - }); + test('empty → false', () => expect(ValidationUtils.isValidPhone(''), false)); + test('valid phone +7', () => expect(ValidationUtils.isValidPhone('+79001234567'), true)); + test('valid phone 8', () => expect(ValidationUtils.isValidPhone('89001234567'), true)); }); group('normalizePhone', () { - test('normalizes phone starting with +7', () { - expect(ValidationUtils.normalizePhone('+7 (915) 062 61 51'), '+79150626151'); - }); - - test('normalizes phone starting with 8', () { - expect(ValidationUtils.normalizePhone('89150626151'), '+79150626151'); - }); - - test('normalizes 10-digit phone', () { - expect(ValidationUtils.normalizePhone('9150626151'), '+79150626151'); - }); - - test('returns empty string for empty input', () { - expect(ValidationUtils.normalizePhone(''), ''); - }); - - test('returns empty string for input with no digits', () { - expect(ValidationUtils.normalizePhone('abc'), ''); + test('empty → empty', () => expect(ValidationUtils.normalizePhone(''), '')); + test('starts with 8 → +7', () => expect(ValidationUtils.normalizePhone('89001234567'), '+79001234567')); + test('10 digits → +7 prefix', () => expect(ValidationUtils.normalizePhone('9001234567'), '+79001234567')); + test('already +7', () => expect(ValidationUtils.normalizePhone('+79001234567'), '+79001234567')); + test('with spaces and dashes', () { + final result = ValidationUtils.normalizePhone('+7 (900) 123-45-67'); + expect(result, '+79001234567'); }); + test('only non-digit chars → empty', () => expect(ValidationUtils.normalizePhone('abc'), '')); }); group('isValidUrl', () { - test('returns true for valid URL', () { - expect(ValidationUtils.isValidUrl('https://example.com'), isTrue); - expect(ValidationUtils.isValidUrl('http://example.com'), isTrue); - }); - - test('returns false for invalid URL', () { - expect(ValidationUtils.isValidUrl(''), isFalse); - expect(ValidationUtils.isValidUrl('not a url'), isFalse); - }); + test('empty → false', () => expect(ValidationUtils.isValidUrl(''), false)); + test('valid URL', () => expect(ValidationUtils.isValidUrl('https://example.com'), true)); + test('invalid URL', () => expect(ValidationUtils.isValidUrl('not a url'), false)); }); group('isValidTextLength', () { - test('returns true for text within max length', () { - expect(ValidationUtils.isValidTextLength('short text'), isTrue); - expect(ValidationUtils.isValidTextLength('text', maxLength: 10), isTrue); - }); - - test('returns false for text exceeding max length', () { - final longText = 'a' * (AppConstants.maxTextLength + 1); - expect(ValidationUtils.isValidTextLength(longText), isFalse); - expect(ValidationUtils.isValidTextLength('very long text', maxLength: 5), isFalse); + test('short text → true', () => expect(ValidationUtils.isValidTextLength('Hello'), true)); + test('with custom maxLength', () { + expect(ValidationUtils.isValidTextLength('Hi', maxLength: 5), true); + expect(ValidationUtils.isValidTextLength('Hello World', maxLength: 5), false); }); }); group('isValidTitleLength', () { - test('returns true for valid title', () { - expect(ValidationUtils.isValidTitleLength('Valid Title'), isTrue); - expect(ValidationUtils.isValidTitleLength('A'), isTrue); - }); - - test('returns false for invalid title', () { - expect(ValidationUtils.isValidTitleLength(''), isFalse); - final longTitle = 'a' * (AppConstants.maxTitleLength + 1); - expect(ValidationUtils.isValidTitleLength(longTitle), isFalse); - }); + test('empty → false', () => expect(ValidationUtils.isValidTitleLength(''), false)); + test('non-empty → true', () => expect(ValidationUtils.isValidTitleLength('Title'), true)); }); group('isValidDescriptionLength', () { - test('returns true for valid description', () { - expect(ValidationUtils.isValidDescriptionLength('Valid description'), isTrue); - expect(ValidationUtils.isValidDescriptionLength(''), isTrue); - }); - - test('returns false for description exceeding max length', () { - final longDescription = 'a' * (AppConstants.maxDescriptionLength + 1); - expect(ValidationUtils.isValidDescriptionLength(longDescription), isFalse); - }); + test('short description → true', () => expect(ValidationUtils.isValidDescriptionLength('Desc'), true)); }); group('isValidPassword', () { - test('returns true for valid password', () { - expect(ValidationUtils.isValidPassword('password123'), isTrue); - expect(ValidationUtils.isValidPassword('Pass1234'), isTrue); - }); - - test('returns false for invalid password', () { - expect(ValidationUtils.isValidPassword(''), isFalse); - expect(ValidationUtils.isValidPassword('short'), isFalse); - expect(ValidationUtils.isValidPassword('onlyletters'), isFalse); - expect(ValidationUtils.isValidPassword('12345678'), isFalse); - }); + test('too short → false', () => expect(ValidationUtils.isValidPassword('Ab1'), false)); + test('no digits → false', () => expect(ValidationUtils.isValidPassword('abcdefgh'), false)); + test('no letters → false', () => expect(ValidationUtils.isValidPassword('12345678'), false)); + test('valid password', () => expect(ValidationUtils.isValidPassword('abcdef12'), true)); }); group('isValidUsername', () { - test('returns true for valid username', () { - expect(ValidationUtils.isValidUsername('user123'), isTrue); - expect(ValidationUtils.isValidUsername('user_name'), isTrue); - expect(ValidationUtils.isValidUsername('abc'), isTrue); - }); - - test('returns false for invalid username', () { - expect(ValidationUtils.isValidUsername(''), isFalse); - expect(ValidationUtils.isValidUsername('ab'), isFalse); - expect(ValidationUtils.isValidUsername('a' * 21), isFalse); - expect(ValidationUtils.isValidUsername('user-name'), isFalse); - expect(ValidationUtils.isValidUsername('user name'), isFalse); - }); + test('too short → false', () => expect(ValidationUtils.isValidUsername('ab'), false)); + test('too long → false', () => expect(ValidationUtils.isValidUsername('a' * 21), false)); + test('valid username', () => expect(ValidationUtils.isValidUsername('user_123'), true)); + test('special chars → false', () => expect(ValidationUtils.isValidUsername('user@name'), false)); }); group('getValidationErrorMessage', () { - test('returns error for null value', () { - expect(ValidationUtils.getValidationErrorMessage(null, ValidationType.email), 'Поле не может быть пустым'); - expect(ValidationUtils.getValidationErrorMessage(null, ValidationType.phone), 'Поле не может быть пустым'); - expect(ValidationUtils.getValidationErrorMessage(null, ValidationType.url), 'Поле не может быть пустым'); + test('null value → error', () { + expect(ValidationUtils.getValidationErrorMessage(null, ValidationType.email), isNotNull); }); - test('returns error for empty value', () { - expect(ValidationUtils.getValidationErrorMessage('', ValidationType.email), 'Поле не может быть пустым'); - expect(ValidationUtils.getValidationErrorMessage('', ValidationType.password), 'Поле не может быть пустым'); - expect(ValidationUtils.getValidationErrorMessage('', ValidationType.username), 'Поле не может быть пустым'); + test('empty value → error', () { + expect(ValidationUtils.getValidationErrorMessage('', ValidationType.email), isNotNull); }); - test('returns null for valid email', () { - expect(ValidationUtils.getValidationErrorMessage('test@example.com', ValidationType.email), isNull); + test('valid email → null', () { + expect(ValidationUtils.getValidationErrorMessage('test@test.com', ValidationType.email), isNull); }); - test('returns error for invalid email', () { - expect(ValidationUtils.getValidationErrorMessage('invalid', ValidationType.email), 'Введите корректный email'); + test('invalid email → error msg', () { + expect(ValidationUtils.getValidationErrorMessage('not-email', ValidationType.email), isNotNull); }); - test('returns null for valid phone', () { - expect(ValidationUtils.getValidationErrorMessage('+79991234567', ValidationType.phone), isNull); + test('valid phone → null', () { + expect(ValidationUtils.getValidationErrorMessage('+79001234567', ValidationType.phone), isNull); }); - test('returns error for invalid phone', () { - expect(ValidationUtils.getValidationErrorMessage('abc', ValidationType.phone), 'Введите корректный номер телефона'); + test('invalid phone → error msg', () { + expect(ValidationUtils.getValidationErrorMessage('abc', ValidationType.phone), isNotNull); }); - test('returns null for valid URL', () { + test('valid url → null', () { expect(ValidationUtils.getValidationErrorMessage('https://example.com', ValidationType.url), isNull); }); - test('returns error for invalid URL', () { - expect(ValidationUtils.getValidationErrorMessage('not a url', ValidationType.url), 'Введите корректный URL'); + test('valid password → null', () { + expect(ValidationUtils.getValidationErrorMessage('password1', ValidationType.password), isNull); }); - test('returns null for valid password', () { - expect(ValidationUtils.getValidationErrorMessage('password123', ValidationType.password), isNull); + test('invalid password → error msg', () { + expect(ValidationUtils.getValidationErrorMessage('short', ValidationType.password), isNotNull); }); - test('returns error for invalid password', () { - expect(ValidationUtils.getValidationErrorMessage('short', ValidationType.password), 'Пароль должен содержать минимум 8 символов, включая буквы и цифры'); + test('valid username → null', () { + expect(ValidationUtils.getValidationErrorMessage('user_123', ValidationType.username), isNull); }); - test('returns null for valid username', () { - expect(ValidationUtils.getValidationErrorMessage('user123', ValidationType.username), isNull); + test('invalid username → error msg', () { + expect(ValidationUtils.getValidationErrorMessage('ab', ValidationType.username), isNotNull); }); - test('returns error for invalid username', () { - expect(ValidationUtils.getValidationErrorMessage('ab', ValidationType.username), 'Имя пользователя должно содержать от 3 до 20 символов (буквы, цифры, подчеркивания)'); + test('valid title → null', () { + expect(ValidationUtils.getValidationErrorMessage('Title', ValidationType.title), isNull); }); - test('returns null for valid title', () { - expect(ValidationUtils.getValidationErrorMessage('Valid Title', ValidationType.title), isNull); + test('valid description → null', () { + expect(ValidationUtils.getValidationErrorMessage('Description', ValidationType.description), isNull); }); - test('returns error for invalid title', () { - expect(ValidationUtils.getValidationErrorMessage('', ValidationType.title), 'Поле не может быть пустым'); - final longTitle = 'a' * (AppConstants.maxTitleLength + 1); - expect(ValidationUtils.getValidationErrorMessage(longTitle, ValidationType.title), 'Заголовок не может быть пустым и должен содержать не более ${AppConstants.maxTitleLength} символов'); - }); - - test('returns null for valid description', () { - expect(ValidationUtils.getValidationErrorMessage('Valid description', ValidationType.description), isNull); - expect(ValidationUtils.getValidationErrorMessage('', ValidationType.description), 'Поле не может быть пустым'); - }); - - test('returns error for invalid description', () { - final longDescription = 'a' * (AppConstants.maxDescriptionLength + 1); - expect(ValidationUtils.getValidationErrorMessage(longDescription, ValidationType.description), 'Описание не может содержать более ${AppConstants.maxDescriptionLength} символов'); - }); - - test('returns null for valid text', () { - expect(ValidationUtils.getValidationErrorMessage('Valid text', ValidationType.text), isNull); - }); - - test('returns error for invalid text', () { - final longText = 'a' * (AppConstants.maxTextLength + 1); - expect(ValidationUtils.getValidationErrorMessage(longText, ValidationType.text), 'Текст не может содержать более ${AppConstants.maxTextLength} символов'); - }); - }); - - group('ValidationType enum', () { - test('has all expected values', () { - expect(ValidationType.values.length, 8); - expect(ValidationType.values, contains(ValidationType.email)); - expect(ValidationType.values, contains(ValidationType.phone)); - expect(ValidationType.values, contains(ValidationType.url)); - expect(ValidationType.values, contains(ValidationType.password)); - expect(ValidationType.values, contains(ValidationType.username)); - expect(ValidationType.values, contains(ValidationType.title)); - expect(ValidationType.values, contains(ValidationType.description)); - expect(ValidationType.values, contains(ValidationType.text)); - }); - - test('can access by name', () { - expect(ValidationType.values.byName('email'), ValidationType.email); - expect(ValidationType.values.byName('phone'), ValidationType.phone); - expect(ValidationType.values.byName('url'), ValidationType.url); - }); - }); - - group('Edge cases', () { - test('isValidEmail handles special characters', () { - expect(ValidationUtils.isValidEmail('test+tag@example.com'), isTrue); - expect(ValidationUtils.isValidEmail('test.email@example.co.uk'), isTrue); - }); - - test('isValidPhone handles various formats', () { - expect(ValidationUtils.isValidPhone('+1234567890'), isTrue); - expect(ValidationUtils.isValidPhone('1234567890'), isTrue); - expect(ValidationUtils.isValidPhone('+123'), isTrue); - }); - - test('isValidUrl handles various protocols', () { - expect(ValidationUtils.isValidUrl('https://example.com'), isTrue); - expect(ValidationUtils.isValidUrl('http://example.com'), isTrue); - expect(ValidationUtils.isValidUrl('https://www.example.com/path?query=value'), isTrue); - }); - - test('isValidPassword handles edge cases', () { - expect(ValidationUtils.isValidPassword('Password1'), isTrue); - expect(ValidationUtils.isValidPassword('PASSWORD123'), isTrue); - expect(ValidationUtils.isValidPassword('password1'), isTrue); - expect(ValidationUtils.isValidPassword('1234567a'), isTrue); - }); - - test('isValidUsername handles edge cases', () { - expect(ValidationUtils.isValidUsername('a' * 3), isTrue); - expect(ValidationUtils.isValidUsername('a' * 20), isTrue); - expect(ValidationUtils.isValidUsername('user_123'), isTrue); - expect(ValidationUtils.isValidUsername('123user'), isTrue); + test('valid text → null', () { + expect(ValidationUtils.getValidationErrorMessage('Some text', ValidationType.text), isNull); }); }); }); diff --git a/test/features/diary/presentation/bloc/glossary_sync_cubit_test.dart b/test/features/diary/presentation/bloc/glossary_sync_cubit_test.dart new file mode 100644 index 00000000..563ebe49 --- /dev/null +++ b/test/features/diary/presentation/bloc/glossary_sync_cubit_test.dart @@ -0,0 +1,206 @@ +import 'dart:async'; + +import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_cubit.dart'; +import 'package:eatvio_app/core/internet_connection_cubit/internet_connection_state.dart'; +import 'package:eatvio_app/core/local_db/services/glossary_sync_runner.dart'; +import 'package:eatvio_app/features/diary/presentation/bloc/glossary_sync_cubit.dart'; +import 'package:eatvio_app/features/diary/presentation/bloc/glossary_sync_state.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), +]) +import 'glossary_sync_cubit_test.mocks.dart'; + +void main() { + late MockGlossarySyncService mockSyncService; + late MockInternetConnectionCubit mockConnectionCubit; + + setUp(() { + mockSyncService = MockGlossarySyncService(); + mockConnectionCubit = MockInternetConnectionCubit(); + when(mockConnectionCubit.stream).thenAnswer((_) => const Stream.empty()); + when(mockConnectionCubit.state).thenReturn(InternetConnectionState(isInternetAvailable: false)); + }); + + GlossarySyncCubit buildCubit() => GlossarySyncCubit( + syncService: mockSyncService, + connectionCubit: mockConnectionCubit, + ); + + group('GlossarySyncCubit', () { + test('initial state is idle', () { + final cubit = buildCubit(); + expect(cubit.state, const GlossarySyncState.idle()); + cubit.close(); + }); + + test('syncNow emits completed with count when sync returns > 0', () async { + when(mockSyncService.sync( + onStageChanged: anyNamed('onStageChanged'), + onDownloadProgress: anyNamed('onDownloadProgress'), + onProcessingProgress: anyNamed('onProcessingProgress'), + )).thenAnswer((_) async => 100); + + final cubit = buildCubit(); + final states = []; + final sub = cubit.stream.listen(states.add); + + await cubit.syncNow(); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(states.last, isA()); + await sub.cancel(); + await cubit.close(); + }); + + test('syncNow emits upToDate when sync returns 0', () async { + when(mockSyncService.sync( + onStageChanged: anyNamed('onStageChanged'), + onDownloadProgress: anyNamed('onDownloadProgress'), + onProcessingProgress: anyNamed('onProcessingProgress'), + )).thenAnswer((_) async => 0); + + final cubit = buildCubit(); + final states = []; + final sub = cubit.stream.listen(states.add); + + await cubit.syncNow(); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(states.last, const GlossarySyncState.upToDate()); + await sub.cancel(); + await cubit.close(); + }); + + test('syncNow emits error on exception', () async { + when(mockSyncService.sync( + onStageChanged: anyNamed('onStageChanged'), + onDownloadProgress: anyNamed('onDownloadProgress'), + onProcessingProgress: anyNamed('onProcessingProgress'), + )).thenThrow(Exception('Test error')); + + final cubit = buildCubit(); + final states = []; + final sub = cubit.stream.listen(states.add); + + await cubit.syncNow(); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(states.isNotEmpty, true); + await sub.cancel(); + await cubit.close(); + }); + + test('syncNow emits error with socket message', () async { + when(mockSyncService.sync( + onStageChanged: anyNamed('onStageChanged'), + onDownloadProgress: anyNamed('onDownloadProgress'), + onProcessingProgress: anyNamed('onProcessingProgress'), + )).thenThrow(Exception('SocketException: connection refused')); + + final cubit = buildCubit(); + final states = []; + final sub = cubit.stream.listen(states.add); + + await cubit.syncNow(); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(states.isNotEmpty, true); + await sub.cancel(); + await cubit.close(); + }); + + test('syncNow emits error with timeout message', () async { + when(mockSyncService.sync( + onStageChanged: anyNamed('onStageChanged'), + onDownloadProgress: anyNamed('onDownloadProgress'), + onProcessingProgress: anyNamed('onProcessingProgress'), + )).thenThrow(TimeoutException('timeout')); + + final cubit = buildCubit(); + final states = []; + final sub = cubit.stream.listen(states.add); + + await cubit.syncNow(); + await Future.delayed(const Duration(milliseconds: 50)); + + expect(states.isNotEmpty, true); + await sub.cancel(); + await cubit.close(); + }); + + test('start() listens to connectionCubit', () { + final cubit = buildCubit(); + cubit.start(); + verify(mockConnectionCubit.stream).called(1); + cubit.close(); + }); + + test('duplicate syncNow calls are ignored', () async { + final completer = Completer(); + when(mockSyncService.sync( + onStageChanged: anyNamed('onStageChanged'), + onDownloadProgress: anyNamed('onDownloadProgress'), + onProcessingProgress: anyNamed('onProcessingProgress'), + )).thenAnswer((_) => completer.future); + + final cubit = buildCubit(); + cubit.syncNow(); + cubit.syncNow(); // Should be ignored + + completer.complete(5); + await Future.delayed(const Duration(milliseconds: 100)); + + verify(mockSyncService.sync( + onStageChanged: anyNamed('onStageChanged'), + onDownloadProgress: anyNamed('onDownloadProgress'), + onProcessingProgress: anyNamed('onProcessingProgress'), + )).called(1); + + await cubit.close(); + }); + + test('stage callbacks emit correct states', () async { + when(mockSyncService.sync( + onStageChanged: anyNamed('onStageChanged'), + onDownloadProgress: anyNamed('onDownloadProgress'), + onProcessingProgress: anyNamed('onProcessingProgress'), + )).thenAnswer((invocation) async { + final stageCallback = invocation.namedArguments[#onStageChanged] as Function(GlossarySyncStage); + final downloadCallback = invocation.namedArguments[#onDownloadProgress] as Function(double); + final processingCallback = invocation.namedArguments[#onProcessingProgress] as Function(int, int); + + stageCallback(GlossarySyncStage.checking); + stageCallback(GlossarySyncStage.downloading); + downloadCallback(0.5); + stageCallback(GlossarySyncStage.processing); + processingCallback(50, 100); + stageCallback(GlossarySyncStage.saving); + + return 100; + }); + + final cubit = buildCubit(); + final states = []; + final sub = cubit.stream.listen(states.add); + + await cubit.syncNow(); + await Future.delayed(const Duration(milliseconds: 100)); + + expect(states.length, greaterThanOrEqualTo(4)); + await sub.cancel(); + await cubit.close(); + }); + + test('close cancels subscriptions', () async { + final cubit = buildCubit(); + cubit.start(); + await cubit.close(); + // Should not crash + }); + }); +} diff --git a/test/features/diary/presentation/widgets/glossary_sync_banner_widget_test.dart b/test/features/diary/presentation/widgets/glossary_sync_banner_widget_test.dart new file mode 100644 index 00000000..23afc159 --- /dev/null +++ b/test/features/diary/presentation/widgets/glossary_sync_banner_widget_test.dart @@ -0,0 +1,97 @@ +import 'package:eatvio_app/features/diary/presentation/bloc/glossary_sync_cubit.dart'; +import 'package:eatvio_app/features/diary/presentation/bloc/glossary_sync_state.dart'; +import 'package:eatvio_app/features/diary/presentation/widgets/glossary_sync_banner_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +@GenerateNiceMocks([MockSpec()]) +import 'glossary_sync_banner_widget_test.mocks.dart'; + +void main() { + late MockGlossarySyncCubit mockCubit; + + setUp(() { + mockCubit = MockGlossarySyncCubit(); + when(mockCubit.stream).thenAnswer((_) => const Stream.empty()); + when(mockCubit.close()).thenAnswer((_) async {}); + }); + + Widget buildWidget() { + return MaterialApp( + home: BlocProvider.value( + value: mockCubit, + child: const Scaffold( + body: GlossarySyncBannerWidget(), + ), + ), + ); + } + + group('GlossarySyncBannerWidget', () { + testWidgets('idle state shows nothing', (tester) async { + when(mockCubit.state).thenReturn(const GlossarySyncState.idle()); + await tester.pumpWidget(buildWidget()); + await tester.pump(); + expect(find.byType(GlossarySyncBannerWidget), findsOneWidget); + }); + + testWidgets('upToDate state shows nothing', (tester) async { + when(mockCubit.state).thenReturn(const GlossarySyncState.upToDate()); + await tester.pumpWidget(buildWidget()); + await tester.pump(); + expect(find.byType(GlossarySyncBannerWidget), findsOneWidget); + }); + + testWidgets('checking state shows checking text', (tester) async { + when(mockCubit.state).thenReturn(const GlossarySyncState.checking()); + await tester.pumpWidget(buildWidget()); + await tester.pump(); + expect(find.textContaining('Проверяю'), findsOneWidget); + }); + + testWidgets('downloading with progress shows percentage', (tester) async { + when(mockCubit.state).thenReturn(const GlossarySyncState.downloading(progress: 0.45)); + await tester.pumpWidget(buildWidget()); + await tester.pump(); + expect(find.textContaining('45%'), findsOneWidget); + }); + + testWidgets('downloading without progress shows dots', (tester) async { + when(mockCubit.state).thenReturn(const GlossarySyncState.downloading()); + await tester.pumpWidget(buildWidget()); + await tester.pump(); + expect(find.textContaining('Загрузка'), findsOneWidget); + }); + + testWidgets('processing shows progress', (tester) async { + when(mockCubit.state).thenReturn(const GlossarySyncState.processing(processed: 12500, total: 200000)); + await tester.pumpWidget(buildWidget()); + await tester.pump(); + expect(find.textContaining('Обработка'), findsOneWidget); + }); + + testWidgets('processing with 0 total shows generic text', (tester) async { + when(mockCubit.state).thenReturn(const GlossarySyncState.processing(processed: 0, total: 0)); + await tester.pumpWidget(buildWidget()); + await tester.pump(); + expect(find.textContaining('Обработка данных'), findsOneWidget); + }); + + testWidgets('completed shows count', (tester) async { + when(mockCubit.state).thenReturn(const GlossarySyncState.completed(syncedCount: 200000)); + await tester.pumpWidget(buildWidget()); + await tester.pump(); + expect(find.textContaining('Синхронизировано'), findsOneWidget); + }); + + testWidgets('error shows error message', (tester) async { + when(mockCubit.state).thenReturn(const GlossarySyncState.error(message: 'Ошибка синхронизации')); + await tester.pumpWidget(buildWidget()); + await tester.pump(); + expect(find.textContaining('Ошибка'), findsOneWidget); + }); + }); +} diff --git a/test/features/diary/presentation/widgets/offline_banner_widget_test.dart b/test/features/diary/presentation/widgets/offline_banner_widget_test.dart new file mode 100644 index 00000000..57d8d51d --- /dev/null +++ b/test/features/diary/presentation/widgets/offline_banner_widget_test.dart @@ -0,0 +1,124 @@ +import 'package:eatvio_app/features/diary/entities/diary_day_entity.dart'; +import 'package:eatvio_app/features/diary/models/diary_day_model.dart'; +import 'package:eatvio_app/features/diary/presentation/bloc/diary_cubit.dart'; +import 'package:eatvio_app/features/diary/presentation/bloc/diary_state.dart'; +import 'package:eatvio_app/features/diary/presentation/widgets/offline_banner_widget.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_bloc/flutter_bloc.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; + +@GenerateNiceMocks([MockSpec()]) +import 'offline_banner_widget_test.mocks.dart'; + +DiaryDayEntity _makeDiary() => DiaryDayEntity( + id: 1, + date: '2025-01-01', + wakeup: '06:30:00', + retire: '22:30:00', + closed: 0, + comment: '', + typeOfDiary: const TypeOfDiaryModel(hasChosenDiet: false, hasChosenPlan: false, diaryState: null), + meals: [ + const MealModel(id: 1, time: '08:00', typeNumber: 1, satiety: 0, mood: 0, portions: [], photos: []), + ], + chosenPlanId: null, + dayOfChosenDiet: null, + diet: null, + ); + +void main() { + late MockDiaryCubit mockDiaryCubit; + + setUp(() { + mockDiaryCubit = MockDiaryCubit(); + when(mockDiaryCubit.stream).thenAnswer((_) => const Stream.empty()); + when(mockDiaryCubit.close()).thenAnswer((_) async {}); + }); + + Widget buildWidget() { + return MaterialApp( + home: BlocProvider.value( + value: mockDiaryCubit, + child: const Scaffold( + body: OfflineBannerWidget(), + ), + ), + ); + } + + group('OfflineBannerWidget', () { + testWidgets('shows nothing when online and no pending ops', (tester) async { + when(mockDiaryCubit.state).thenReturn( + DiaryState.loaded( + diary: _makeDiary(), + selectedDay: DateTime(2025, 1, 1), + selectedMeal: 1, + isOfflineMode: false, + pendingOpsCount: 0, + ), + ); + await tester.pumpWidget(buildWidget()); + await tester.pump(); + // SizedBox.shrink should be rendered — no banner text visible + expect(find.textContaining('Офлайн'), findsNothing); + expect(find.textContaining('Синхронизация'), findsNothing); + }); + + testWidgets('shows offline banner with pending ops', (tester) async { + when(mockDiaryCubit.state).thenReturn( + DiaryState.loaded( + diary: _makeDiary(), + selectedDay: DateTime(2025, 1, 1), + selectedMeal: 1, + isOfflineMode: true, + pendingOpsCount: 3, + ), + ); + await tester.pumpWidget(buildWidget()); + await tester.pump(); + expect(find.textContaining('Офлайн'), findsOneWidget); + expect(find.textContaining('3'), findsWidgets); + }); + + testWidgets('shows offline without pending ops', (tester) async { + when(mockDiaryCubit.state).thenReturn( + DiaryState.loaded( + diary: _makeDiary(), + selectedDay: DateTime(2025, 1, 1), + selectedMeal: 1, + isOfflineMode: true, + pendingOpsCount: 0, + ), + ); + await tester.pumpWidget(buildWidget()); + await tester.pump(); + expect(find.textContaining('кэша'), findsOneWidget); + }); + + testWidgets('shows sync banner when online with pending ops', (tester) async { + when(mockDiaryCubit.state).thenReturn( + DiaryState.loaded( + diary: _makeDiary(), + selectedDay: DateTime(2025, 1, 1), + selectedMeal: 1, + isOfflineMode: false, + pendingOpsCount: 5, + ), + ); + await tester.pumpWidget(buildWidget()); + await tester.pump(); + expect(find.textContaining('Синхронизация'), findsOneWidget); + }); + + testWidgets('hides when state is not loaded', (tester) async { + when(mockDiaryCubit.state).thenReturn(const DiaryState.loading()); + await tester.pumpWidget(buildWidget()); + await tester.pump(); + // SizedBox.shrink should be rendered + expect(find.textContaining('Офлайн'), findsNothing); + expect(find.textContaining('Синхронизация'), findsNothing); + }); + }); +} diff --git a/test/features/glossary/repositories/glossary_repository_test.dart b/test/features/glossary/repositories/glossary_repository_test.dart index ccec78ed..8bf5a45b 100644 --- a/test/features/glossary/repositories/glossary_repository_test.dart +++ b/test/features/glossary/repositories/glossary_repository_test.dart @@ -1,93 +1,102 @@ 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/diary_local_data_source.dart'; import 'package:eatvio_app/features/glossary/models/glossary_model.dart'; import 'package:eatvio_app/features/glossary/repositories/glossary_repository.dart'; import 'package:eatvio_app/features/glossary/services/glossary_service.dart'; import 'package:flutter_test/flutter_test.dart'; -import 'package:mocktail/mocktail.dart'; +import 'package:mockito/annotations.dart'; +import 'package:mockito/mockito.dart'; -class MockGlossaryService extends Mock implements GlossaryService {} -class MockInternetConnectionCubit extends Mock implements InternetConnectionCubit {} -class MockDiaryLocalDataSource extends Mock implements DiaryLocalDataSource {} +@GenerateNiceMocks([ + MockSpec(), + MockSpec(), + MockSpec(), +]) +import 'glossary_repository_test.mocks.dart'; void main() { + late GlossaryRepository repository; + late MockGlossaryService mockService; + late MockDiaryLocalDataSource mockLocalDataSource; + late MockInternetConnectionCubit mockConnectionCubit; + + setUp(() { + mockService = MockGlossaryService(); + mockLocalDataSource = MockDiaryLocalDataSource(); + mockConnectionCubit = MockInternetConnectionCubit(); + repository = GlossaryRepository( + glossaryService: mockService, + localDataSource: mockLocalDataSource, + connectionCubit: mockConnectionCubit, + ); + }); + group('GlossaryRepository', () { - late GlossaryRepository repository; - late MockGlossaryService mockService; - late MockInternetConnectionCubit mockConnectionCubit; - late MockDiaryLocalDataSource mockLocalDataSource; + test('offline → searches local cache', () async { + when(mockConnectionCubit.state).thenReturn(InternetConnectionState(isInternetAvailable: false)); + when(mockLocalDataSource.searchGlossary(any)).thenAnswer((_) async => [ + const GlossaryCacheTableData( + id: 1, + name: 'Яблоко', + productGroupId: 5, + energyOn100gr: 52.0, + proteinsOn100gr: 0.3, + fatsOn100gr: 0.2, + carbohydratesOn100gr: 14.0, + portionMassGr: 150.0, + energyOnPortion: 78.0, + proteinsOnPortion: 0.45, + fatsOnPortion: 0.3, + carbohydratesOnPortion: 21.0, + updatedAt: '2024-01-01', + ), + ]); - setUp(() { - mockService = MockGlossaryService(); - mockConnectionCubit = MockInternetConnectionCubit(); - mockLocalDataSource = MockDiaryLocalDataSource(); - when(() => mockConnectionCubit.state).thenReturn( - InternetConnectionState(isInternetAvailable: true), - ); - repository = GlossaryRepository( - glossaryService: mockService, - localDataSource: mockLocalDataSource, - connectionCubit: mockConnectionCubit, - ); + final result = await repository.getGlossaryListByQuery('яблоко'); + expect(result.length, 1); + expect(result[0].name, 'Яблоко'); + expect(result[0].energyOn100Gr, 52.0); + expect(result[0].grInPortion, 150); + verifyNever(mockService.getGlossaryByQuery(query: anyNamed('query'))); }); - test('getGlossaryListByQuery - успешный запрос', () async { - const query = 'test'; - final mockGlossaries = []; - final mockPagination = GlossaryPaginationModel(glossariesList: mockGlossaries); + test('online → uses network service', () async { + when(mockConnectionCubit.state).thenReturn(InternetConnectionState(isInternetAvailable: true)); + when(mockService.getGlossaryByQuery(query: anyNamed('query'))).thenAnswer( + (_) async => const GlossaryPaginationModel( + glossariesList: [ + GlossaryModel( + id: 1, + name: 'Яблоко', + grInPortion: 150, + energyOn100Gr: 52.0, + proteinsOn100Gr: 0.3, + fatsOn100Gr: 0.2, + carbohydratesOn100Gr: 14.0, + energyOnPortion: 78.0, + proteinsOnPortion: 0.45, + fatsOnPortion: 0.3, + carbohydratesOnPortion: 21.0, + ), + ], + ), + ); - when(() => mockService.getGlossaryByQuery(query: query)) - .thenAnswer((_) async => mockPagination); - - final result = await repository.getGlossaryListByQuery(query); - - expect(result, mockGlossaries); - verify(() => mockService.getGlossaryByQuery(query: query)).called(1); + final result = await repository.getGlossaryListByQuery('яблоко'); + expect(result.length, 1); + expect(result[0].name, 'Яблоко'); }); - test('getGlossaryListByQuery - пробрасывает ошибку', () async { - const query = 'test'; + test('online with network error → falls back to local cache', () async { + when(mockConnectionCubit.state).thenReturn(InternetConnectionState(isInternetAvailable: true)); + when(mockService.getGlossaryByQuery(query: anyNamed('query'))).thenThrow(Exception('Network error')); + when(mockLocalDataSource.searchGlossary(any)).thenAnswer((_) async => []); - when(() => mockService.getGlossaryByQuery(query: query)) - .thenThrow(Exception('Error')); - when(() => mockLocalDataSource.searchGlossary(query)) - .thenThrow(Exception('Local error')); - - expect(() => repository.getGlossaryListByQuery(query), throwsException); + final result = await repository.getGlossaryListByQuery('яблоко'); + expect(result, isEmpty); + verify(mockLocalDataSource.searchGlossary('яблоко')).called(1); }); }); } - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/test/features/recipe/models/my_recipe_model_test.dart b/test/features/recipe/models/my_recipe_model_test.dart index c192af12..76f5dc6f 100644 --- a/test/features/recipe/models/my_recipe_model_test.dart +++ b/test/features/recipe/models/my_recipe_model_test.dart @@ -1,228 +1,176 @@ import 'package:eatvio_app/features/recipe/models/my_recipe_model.dart'; import 'package:eatvio_app/features/feed/models/recipes_model.dart'; +import 'package:eatvio_app/features/feed/models/diets_pagination_model.dart'; import 'package:flutter_test/flutter_test.dart'; void main() { + group('MyRecipeModel', () { + const model = MyRecipeModel( + id: 1, + image: 'https://example.com/img.jpg', + state: 'active', + cookingTime: 30, + activeCookingTime: 15, + description: '# Recipe\nDelicious', + steps: [Step(image: 'step.jpg', description: 'Step 1')], + videoUrl: 'https://youtube.com/watch?v=123', + title: 'Test Recipe', + slug: 'test-recipe', + calories: 250.5, + likesCount: 10, + viewsCount: 100, + ); + + test('fields are accessible', () { + expect(model.id, 1); + expect(model.image, 'https://example.com/img.jpg'); + expect(model.state, 'active'); + expect(model.cookingTime, 30); + expect(model.activeCookingTime, 15); + expect(model.description, '# Recipe\nDelicious'); + expect(model.videoUrl, 'https://youtube.com/watch?v=123'); + expect(model.title, 'Test Recipe'); + expect(model.slug, 'test-recipe'); + expect(model.calories, 250.5); + expect(model.likesCount, 10); + expect(model.viewsCount, 100); + expect(model.steps?.length, 1); + }); + + test('fromJson parses correctly', () { + final json = { + 'id': 2, + 'image': 'img.jpg', + 'state': 'draft', + 'cooking_time': 60, + 'active_time': 20, + 'description_md': 'Desc', + 'steps': [{'image': 's.jpg', 'description_md': 'S1'}], + 'video_url': null, + 'title': 'Title', + 'slug': 'slug', + 'energy_per_serving': 300.0, + 'total_likes': 5, + 'total_views': 50, + }; + final parsed = MyRecipeModel.fromJson(json); + expect(parsed.id, 2); + expect(parsed.cookingTime, 60); + expect(parsed.calories, 300.0); + }); + + test('toRecipeModel converts correctly', () { + final recipeModel = model.toRecipeModel(); + expect(recipeModel.title, 'Test Recipe'); + expect(recipeModel.description, '# Recipe\nDelicious'); + expect(recipeModel.totalCookingTime, 30); + expect(recipeModel.activeCookTime, 15); + expect(recipeModel.energyPerServing, 250); + expect(recipeModel.state, 'active'); + }); + + test('toRecipe converts correctly', () { + final recipe = model.toRecipe(); + expect(recipe.id, 1); + expect(recipe.image, 'https://example.com/img.jpg'); + expect(recipe.state, 'active'); + expect(recipe.cookingTime, 30); + expect(recipe.slug, 'test-recipe'); + expect(recipe.calories, 250.5); + expect(recipe.likesCount, 10); + expect(recipe.type, 'recipe'); + expect(recipe.commentsCount, 0); + expect(recipe.wasLikedByMe, false); + }); + + test('toRecipeModel with null steps', () { + const noSteps = MyRecipeModel( + id: 1, image: '', state: 'active', cookingTime: 10, + activeCookingTime: 5, description: '', steps: null, + videoUrl: null, title: null, slug: 'slug', calories: 0, + likesCount: 0, viewsCount: 0, + ); + final rm = noSteps.toRecipeModel(); + expect(rm.steps, isNull); + }); + }); + group('MyRecipePaginationModel', () { - test('creates MyRecipePaginationModel with empty list', () { - const model = MyRecipePaginationModel(data: []); - - expect(model.data, isEmpty); - }); - - test('creates MyRecipePaginationModel with recipes', () { - final recipes = [ - const MyRecipeModel( - id: 1, - image: 'image1.jpg', - state: 'published', - cookingTime: 30, - activeCookingTime: 15, - description: 'Test recipe 1', - steps: null, - videoUrl: null, - title: 'Recipe 1', - slug: 'recipe-1', - calories: 250.0, - likesCount: 10, - viewsCount: 100, - ), - const MyRecipeModel( - id: 2, - image: 'image2.jpg', - state: 'draft', - cookingTime: 45, - activeCookingTime: 20, - description: 'Test recipe 2', - steps: null, - videoUrl: 'video.mp4', - title: 'Recipe 2', - slug: 'recipe-2', - calories: 300.0, - likesCount: 5, - viewsCount: 50, - ), - ]; - - final model = MyRecipePaginationModel(data: recipes); - - expect(model.data.length, 2); - expect(model.data[0].id, 1); - expect(model.data[0].title, 'Recipe 1'); - expect(model.data[1].id, 2); - expect(model.data[1].title, 'Recipe 2'); - }); - - test('creates MyRecipePaginationModel from JSON', () { + test('fromJson parses correctly', () { final json = { 'data': [ { 'id': 1, - 'image': 'image1.jpg', - 'state': 'published', + 'image': 'img.jpg', + 'state': 'active', 'cooking_time': 30, 'active_time': 15, - 'description_md': 'Test recipe', + 'description_md': 'Desc', 'steps': null, 'video_url': null, - 'title': 'Recipe 1', - 'slug': 'recipe-1', - 'energy_per_serving': 250.0, - 'total_likes': 10, - 'total_views': 100, + 'title': 'Title', + 'slug': 'slug', + 'energy_per_serving': 100.0, + 'total_likes': 0, + 'total_views': 0, }, ], }; - - final model = MyRecipePaginationModel.fromJson(json); - - expect(model.data.length, 1); - expect(model.data[0].id, 1); - expect(model.data[0].title, 'Recipe 1'); + final parsed = MyRecipePaginationModel.fromJson(json); + expect(parsed.data.length, 1); + expect(parsed.data[0].title, 'Title'); }); }); - group('MyRecipeModel', () { - test('creates MyRecipeModel with all parameters', () { - const recipe = MyRecipeModel( + group('RecipeToRecipeModelX', () { + test('Recipe.toRecipeModel converts correctly', () { + const recipe = Recipe( id: 1, - image: 'image.jpg', - state: 'published', - cookingTime: 30, - activeCookingTime: 15, - description: 'Test recipe', - steps: null, + image: 'img.jpg', + state: 'active', + cookingTime: 45, + activeCookingTime: 20, + ingredients: [ + Ingredient(name: 'Flour', portion: '200g', productId: 1), + ], + description: 'Recipe description', + steps: [Step(image: 's.jpg', description: 'Step')], videoUrl: null, - title: 'Recipe 1', - slug: 'recipe-1', - calories: 250.0, - likesCount: 10, - viewsCount: 100, + title: 'My Recipe', + type: 'recipe', + slug: 'my-recipe', + commentsCount: 3, + wasLikedByMe: true, + isFavorite: false, + calories: 350.0, + likesCount: 20, + viewsCount: 200, + tags: [Tag(name: 'healthy')], + author: Author(authorSlug: 'user1', authorName: 'User', authorProfileIcon: '', authorIsMe: true), ); - expect(recipe.id, 1); - expect(recipe.image, 'image.jpg'); - expect(recipe.state, 'published'); - expect(recipe.cookingTime, 30); - expect(recipe.activeCookingTime, 15); - expect(recipe.description, 'Test recipe'); - expect(recipe.steps, null); - expect(recipe.videoUrl, null); - expect(recipe.title, 'Recipe 1'); - expect(recipe.slug, 'recipe-1'); - expect(recipe.calories, 250.0); - expect(recipe.likesCount, 10); - expect(recipe.viewsCount, 100); + final rm = recipe.toRecipeModel(); + expect(rm.title, 'My Recipe'); + expect(rm.totalCookingTime, 45); + expect(rm.activeCookTime, 20); + expect(rm.energyPerServing, 350); + expect(rm.state, 'active'); + expect(rm.ingredients?.length, 1); + expect(rm.tags?.length, 1); }); - test('creates MyRecipeModel with steps and videoUrl', () { - final steps = [ - const Step(image: 'step1.jpg', description: 'Step 1'), - const Step(image: 'step2.jpg', description: 'Step 2'), - ]; - - final recipe = MyRecipeModel( - id: 1, - image: 'image.jpg', - state: 'published', - cookingTime: 30, - activeCookingTime: 15, - description: 'Test recipe', - steps: steps, - videoUrl: 'video.mp4', - title: 'Recipe 1', - slug: 'recipe-1', - calories: 250.0, - likesCount: 10, - viewsCount: 100, + test('Recipe.toRecipeModel with null ingredients', () { + const recipe = Recipe( + id: 1, image: '', state: 'active', cookingTime: 10, + activeCookingTime: 5, ingredients: null, + description: '', steps: null, videoUrl: null, + title: 'T', type: 'recipe', slug: 's', + commentsCount: 0, wasLikedByMe: false, isFavorite: false, + calories: 0, likesCount: 0, viewsCount: 0, + tags: [], author: Author(authorSlug: '', authorName: '', authorProfileIcon: '', authorIsMe: false), ); - - expect(recipe.steps, isNotNull); - expect(recipe.steps!.length, 2); - expect(recipe.videoUrl, 'video.mp4'); - }); - - test('creates MyRecipeModel from JSON', () { - final json = { - 'id': 1, - 'image': 'image.jpg', - 'state': 'published', - 'cooking_time': 30, - 'active_time': 15, - 'description_md': 'Test recipe', - 'steps': null, - 'video_url': null, - 'title': 'Recipe 1', - 'slug': 'recipe-1', - 'energy_per_serving': 250.0, - 'total_likes': 10, - 'total_views': 100, - }; - - final recipe = MyRecipeModel.fromJson(json); - - expect(recipe.id, 1); - expect(recipe.title, 'Recipe 1'); - expect(recipe.cookingTime, 30); - expect(recipe.calories, 250.0); - }); - - test('toJson serializes MyRecipeModel correctly', () { - const recipe = MyRecipeModel( - id: 1, - image: 'image.jpg', - state: 'published', - cookingTime: 30, - activeCookingTime: 15, - description: 'Test recipe', - steps: null, - videoUrl: null, - title: 'Recipe 1', - slug: 'recipe-1', - calories: 250.0, - likesCount: 10, - viewsCount: 100, - ); - - final json = recipe.toJson(); - - expect(json['id'], 1); - expect(json['title'], 'Recipe 1'); - expect(json['slug'], 'recipe-1'); - expect(json['cooking_time'], 30); - expect(json['active_time'], 15); - expect(json['energy_per_serving'], 250.0); - expect(json['total_likes'], 10); - expect(json['total_views'], 100); - }); - }); - - group('MyRecipePaginationModel', () { - test('toJson serializes MyRecipePaginationModel correctly', () { - final recipes = [ - const MyRecipeModel( - id: 1, - image: 'image1.jpg', - state: 'published', - cookingTime: 30, - activeCookingTime: 15, - description: 'Test recipe 1', - steps: null, - videoUrl: null, - title: 'Recipe 1', - slug: 'recipe-1', - calories: 250.0, - likesCount: 10, - viewsCount: 100, - ), - ]; - - final model = MyRecipePaginationModel(data: recipes); - - final json = model.toJson(); - - expect(json.containsKey('data'), isTrue); - expect((json['data'] as List).length, 1); + final rm = recipe.toRecipeModel(); + expect(rm.ingredients, isEmpty); }); }); } diff --git a/test/features/registration/registration_error_message_test.dart b/test/features/registration/registration_error_message_test.dart new file mode 100644 index 00000000..74f50e9b --- /dev/null +++ b/test/features/registration/registration_error_message_test.dart @@ -0,0 +1,110 @@ +import 'package:dio/dio.dart'; +import 'package:eatvio_app/features/registration/registration_error_message.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + group('registrationErrorMessage', () { + test('returns string when error is String', () { + expect(registrationErrorMessage('Some error'), 'Some error'); + }); + + test('returns message from 422 DioException with message field', () { + final error = DioException( + requestOptions: RequestOptions(path: ''), + response: Response( + requestOptions: RequestOptions(path: ''), + statusCode: 422, + data: {'message': 'Validation failed'}, + ), + ); + expect(registrationErrorMessage(error), 'Validation failed'); + }); + + test('returns errors from 422 DioException with errors field', () { + final error = DioException( + requestOptions: RequestOptions(path: ''), + response: Response( + requestOptions: RequestOptions(path: ''), + statusCode: 422, + data: { + 'message': 'Validation error', + 'errors': { + 'email': ['Email уже используется'], + 'name': ['Имя обязательно'], + }, + }, + ), + ); + final result = registrationErrorMessage(error); + expect(result, contains('Validation error')); + expect(result, contains('Email уже используется')); + expect(result, contains('Имя обязательно')); + }); + + test('returns data string for 422 with unknown format', () { + final error = DioException( + requestOptions: RequestOptions(path: ''), + response: Response( + requestOptions: RequestOptions(path: ''), + statusCode: 422, + data: {'unknown_key': 'value'}, + ), + ); + expect(registrationErrorMessage(error), contains('Ошибка регистрации')); + }); + + test('returns DioException message for non-422 status', () { + final error = DioException( + requestOptions: RequestOptions(path: ''), + message: 'Connection timeout', + response: Response( + requestOptions: RequestOptions(path: ''), + statusCode: 500, + ), + ); + expect(registrationErrorMessage(error), 'Connection timeout'); + }); + + test('returns DioException toString when message is null', () { + final error = DioException( + requestOptions: RequestOptions(path: ''), + ); + expect(registrationErrorMessage(error), contains('DioException')); + }); + + test('returns toString for unknown error type', () { + expect(registrationErrorMessage(42), '42'); + expect(registrationErrorMessage(Exception('Test')), contains('Test')); + }); + + test('handles 422 with empty errors list', () { + final error = DioException( + requestOptions: RequestOptions(path: ''), + response: Response( + requestOptions: RequestOptions(path: ''), + statusCode: 422, + data: { + 'message': 'Error', + 'errors': { + 'field': [], + }, + }, + ), + ); + expect(registrationErrorMessage(error), 'Error'); + }); + + test('handles 422 with non-Map response data', () { + final error = DioException( + requestOptions: RequestOptions(path: ''), + response: Response( + requestOptions: RequestOptions(path: ''), + statusCode: 422, + data: 'plain string error', + ), + ); + // Not a map, falls through to DioException handling + expect(registrationErrorMessage(error), isNotEmpty); + }); + }); +} diff --git a/test/features/registration_v2/cubit/registration_cubit_test.dart b/test/features/registration_v2/cubit/registration_cubit_test.dart new file mode 100644 index 00000000..b6e3a546 --- /dev/null +++ b/test/features/registration_v2/cubit/registration_cubit_test.dart @@ -0,0 +1,221 @@ +import 'package:eatvio_app/features/registration_v2/cubit/registration_cubit.dart'; +import 'package:eatvio_app/features/registration_v2/cubit/registration_state.dart'; +import 'package:eatvio_app/features/registration_v2/repositories/registration_repository_v2.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/annotations.dart'; + +@GenerateNiceMocks([MockSpec()]) +import 'registration_cubit_test.mocks.dart'; + +void main() { + late RegistrationCubitV2 cubit; + late MockRegistrationRepositoryV2 mockRepo; + + setUp(() { + mockRepo = MockRegistrationRepositoryV2(); + cubit = RegistrationCubitV2(registrationRepository: mockRepo); + }); + + tearDown(() => cubit.close()); + + group('RegistrationCubitV2', () { + test('initial state', () { + expect(cubit.state, const RegistrationStateV2()); + }); + + group('setters', () { + test('setFirstName updates firstName', () { + cubit.setFirstName('Иван'); + expect(cubit.state.firstName, 'Иван'); + }); + + test('setLastName updates lastName', () { + cubit.setLastName('Иванов'); + expect(cubit.state.lastName, 'Иванов'); + }); + + test('setEmail updates email', () { + cubit.setEmail('test@test.com'); + expect(cubit.state.email, 'test@test.com'); + }); + + test('setPhone updates phone', () { + cubit.setPhone('+79001234567'); + expect(cubit.state.phone, '+79001234567'); + }); + + test('setBirthday updates birthday', () { + final date = DateTime(1990, 1, 1); + cubit.setBirthday(date); + expect(cubit.state.birthday, date); + }); + + test('setGender updates gender', () { + cubit.setGender('male'); + expect(cubit.state.gender, 'male'); + }); + + test('setWeight updates weight', () { + cubit.setWeight(70); + expect(cubit.state.weight, 70); + }); + + test('setHeight updates height', () { + cubit.setHeight(175); + expect(cubit.state.height, 175); + }); + + test('setActivityCoefficient updates activityCoefficient', () { + cubit.setActivityCoefficient(1.2); + expect(cubit.state.activityCoefficient, 1.2); + }); + + test('setPassword updates password', () { + cubit.setPassword('secret123'); + expect(cubit.state.password, 'secret123'); + }); + }); + + group('validators', () { + test('validateFirstName returns false for empty', () { + expect(cubit.validateFirstName(), false); + expect(cubit.state.isFirstNameValid, false); + }); + + test('validateFirstName returns true for non-empty', () { + cubit.setFirstName('Иван'); + expect(cubit.validateFirstName(), true); + expect(cubit.state.isFirstNameValid, true); + }); + + test('validateLastName returns false for empty', () { + expect(cubit.validateLastName(), false); + expect(cubit.state.isLastNameValid, false); + }); + + test('validateLastName returns true for non-empty', () { + cubit.setLastName('Иванов'); + expect(cubit.validateLastName(), true); + expect(cubit.state.isLastNameValid, true); + }); + + test('validateEmail returns false for empty', () { + expect(cubit.validateEmail(), false); + expect(cubit.state.isEmailValid, false); + }); + + test('validateEmail returns true for non-empty', () { + cubit.setEmail('test@test.com'); + expect(cubit.validateEmail(), true); + expect(cubit.state.isEmailValid, true); + }); + + test('validatePhone returns false for empty', () { + expect(cubit.validatePhone(), false); + expect(cubit.state.isPhoneValid, false); + }); + + test('validatePhone returns true for non-empty', () { + cubit.setPhone('+79001234567'); + expect(cubit.validatePhone(), true); + expect(cubit.state.isPhoneValid, true); + }); + + test('validateGender returns false for empty', () { + expect(cubit.validateGender(), false); + expect(cubit.state.isGenderValid, false); + }); + + test('validateGender returns true for non-empty', () { + cubit.setGender('male'); + expect(cubit.validateGender(), true); + expect(cubit.state.isGenderValid, true); + }); + + test('validateWeight returns false for 0', () { + expect(cubit.validateWeight(), false); + expect(cubit.state.isWeightValid, false); + }); + + test('validateWeight returns true for non-zero', () { + cubit.setWeight(70); + expect(cubit.validateWeight(), true); + expect(cubit.state.isWeightValid, true); + }); + + test('validateHeight returns false for 0', () { + expect(cubit.validateHeight(), false); + expect(cubit.state.isHeightValid, false); + }); + + test('validateHeight returns true for non-zero', () { + cubit.setHeight(175); + expect(cubit.validateHeight(), true); + expect(cubit.state.isHeightValid, true); + }); + + test('validateActivityCoefficient returns false for 0', () { + // Set to 0 explicitly since default is 1.2 + cubit.setActivityCoefficient(0); + expect(cubit.validateActivityCoefficient(), false); + expect(cubit.state.isActivityCoefficientValid, false); + }); + + test('validateActivityCoefficient returns true for non-zero', () { + cubit.setActivityCoefficient(1.5); + expect(cubit.validateActivityCoefficient(), true); + expect(cubit.state.isActivityCoefficientValid, true); + }); + + test('validateBirthday returns false for null', () { + expect(cubit.validateBirthday(), false); + expect(cubit.state.isBirthdayValid, false); + }); + + test('validateBirthday returns true for non-null', () { + cubit.setBirthday(DateTime(1990, 1, 1)); + expect(cubit.validateBirthday(), true); + expect(cubit.state.isBirthdayValid, true); + }); + }); + + group('navigation', () { + test('nextStep sets step to 1', () { + cubit.nextStep(); + expect(cubit.state.step, 1); + }); + + test('prevStep sets step to 0', () { + cubit.nextStep(); + cubit.prevStep(); + expect(cubit.state.step, 0); + }); + }); + + group('validateAllFields', () { + test('returns false when firstName empty', () { + expect(cubit.validateAllFields(), false); + }); + + test('returns true when all fields valid', () { + cubit.setFirstName('Иван'); + cubit.setLastName('Иванов'); + cubit.setEmail('test@test.com'); + cubit.setPhone('+79001234567'); + cubit.setGender('male'); + cubit.setWeight(70); + cubit.setHeight(175); + cubit.setActivityCoefficient(1.2); + cubit.setBirthday(DateTime(1990, 1, 1)); + expect(cubit.validateAllFields(), true); + }); + }); + + group('register', () { + test('returns (false, true) when validation fails', () async { + final result = await cubit.register(); + expect(result, (false, true)); + }); + }); + }); +}