feat(diary): tests

This commit is contained in:
mso7nueh
2026-05-04 17:36:48 +03:00
parent ea107ff07e
commit 91c8300da7
13 changed files with 2067 additions and 536 deletions

View File

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

View File

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

View File

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

View File

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

View File

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