diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 11c5850..b273d97 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -4,7 +4,8 @@ "Bash(flutter gen-l10n:*)", "Bash(flutter analyze:*)", "Bash(mkdir:*)", - "Bash(flutter packages pub run build_runner build:*)" + "Bash(flutter packages pub run build_runner build:*)", + "Bash(flutter pub:*)" ], "deny": [] } diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2acf30c --- /dev/null +++ b/.env.example @@ -0,0 +1,14 @@ +# AI Service Configuration for Tests +# Copy this file to .env and fill in your actual values + +# OpenRouter API Key for integration tests +# Get your key from https://openrouter.ai/ +OPENROUTER_API_KEY=your_openrouter_api_key_here + +# Alternative AI services (optional) +OPENAI_API_KEY=your_openai_api_key_here +ANTHROPIC_API_KEY=your_anthropic_api_key_here + +# Test configuration +RUN_INTEGRATION_TESTS=false +TEST_AI_TIMEOUT_SECONDS=30 \ No newline at end of file diff --git a/lib/data/repositories/hive_prefilled_transaction_repository.dart b/lib/data/repositories/hive_prefilled_transaction_repository.dart index 9928052..592ef93 100644 --- a/lib/data/repositories/hive_prefilled_transaction_repository.dart +++ b/lib/data/repositories/hive_prefilled_transaction_repository.dart @@ -25,7 +25,8 @@ class HivePrefilledTransactionRepository @override Future add(PrefilledTransaction transaction) async { try { - await _box.put(transaction.transactionId, transaction); + // Используем smsMessageId как ключ, поскольку transactionId может быть null + await _box.put(transaction.smsMessageId, transaction); } catch (e) { throw Exception( 'Ошибка добавления предварительно заполненной транзакции: $e', @@ -33,6 +34,18 @@ class HivePrefilledTransactionRepository } } + @override + Future update(PrefilledTransaction transaction) async { + try { + // Используем smsMessageId как ключ, поскольку transactionId может быть null + await _box.put(transaction.smsMessageId, transaction); + } catch (e) { + throw Exception( + 'Ошибка обновления предварительно заполненной транзакции: $e', + ); + } + } + @override Future delete(String transactionId) async { try { diff --git a/lib/data/repositories/interfaces/iprefilled_transaction_repository.dart b/lib/data/repositories/interfaces/iprefilled_transaction_repository.dart index 697eb12..0605fb7 100644 --- a/lib/data/repositories/interfaces/iprefilled_transaction_repository.dart +++ b/lib/data/repositories/interfaces/iprefilled_transaction_repository.dart @@ -8,6 +8,9 @@ abstract class IPrefilledTransactionRepository { /// Добавляет новую предварительно заполненную транзакцию. Future add(PrefilledTransaction transaction); + /// Обновляет существующую предварительно заполненную транзакцию. + Future update(PrefilledTransaction transaction); + /// Удаляет предварительно заполненную транзакцию по ее ID. Future delete(String transactionId); } diff --git a/lib/injection_container.dart b/lib/injection_container.dart index 6e8517c..1becca4 100644 --- a/lib/injection_container.dart +++ b/lib/injection_container.dart @@ -37,6 +37,7 @@ import 'services/openrouter_ai_service.dart'; import 'services/sms_service.dart'; import 'services/sms_transaction_service.dart'; import 'services/user_service.dart'; +import 'services/ai_transaction_processing_service.dart'; import 'services/interfaces/iai_service.dart'; import 'services/interfaces/iuser_service.dart'; @@ -177,6 +178,19 @@ Future initUserSpecificDependencies(String userId) async { return OpenRouterAiService(aiSettings); }); + // AI Transaction Processing Service + if (getIt.isRegistered()) { + await getIt.unregister(); + } + getIt.registerLazySingleton(() => + AiTransactionProcessingService( + aiService: getIt(), + prefilledRepository: getIt(), + categoryRepository: getIt(), + aiRuleRepository: getIt(), + ) + ); + // --- Обновление UserCubit новыми репозиториями --- // Мы не пересоздаем UserCubit, а просто обновляем его зависимости. final userCubit = getIt(); @@ -290,4 +304,7 @@ Future resetUserSpecificDependencies() async { if (getIt.isRegistered()) { await getIt.unregister(); } + if (getIt.isRegistered()) { + await getIt.unregister(); + } } diff --git a/lib/services/ai_transaction_processing_service.dart b/lib/services/ai_transaction_processing_service.dart new file mode 100644 index 0000000..b99ea46 --- /dev/null +++ b/lib/services/ai_transaction_processing_service.dart @@ -0,0 +1,353 @@ +import 'dart:convert'; +import 'package:budget_app/data/repositories/interfaces/icategory_repository.dart'; +import 'package:budget_app/data/repositories/interfaces/iprefilled_transaction_repository.dart'; +import 'package:budget_app/data/repositories/interfaces/iai_rule_repository.dart'; +import 'package:budget_app/models/prefilled_transaction.dart'; +import 'package:budget_app/models/transaction_record.dart'; +import 'package:budget_app/models/ai_rule.dart'; +import 'package:budget_app/models/category.dart'; +import 'package:budget_app/services/interfaces/iai_service.dart'; +import 'package:logger/logger.dart'; + +/// Сервис для обработки SMS с помощью ИИ и создания транзакций +class AiTransactionProcessingService { + final IAiService _aiService; + final IPrefilledTransactionRepository _prefilledRepository; + final ICategoryRepository _categoryRepository; + final IAiRuleRepository _aiRuleRepository; + final Logger _logger = Logger(); + + AiTransactionProcessingService({ + required IAiService aiService, + required IPrefilledTransactionRepository prefilledRepository, + required ICategoryRepository categoryRepository, + required IAiRuleRepository aiRuleRepository, + }) : _aiService = aiService, + _prefilledRepository = prefilledRepository, + _categoryRepository = categoryRepository, + _aiRuleRepository = aiRuleRepository; + + /// Создает промпт для ИИ анализа SMS сообщения + String _buildAiPrompt(String smsBody) { + return ''' +Проанализируй SMS сообщение и извлеки информацию о финансовой транзакции. + +SMS: "$smsBody" + +Верни ответ ТОЛЬКО в виде JSON без дополнительных комментариев: + +Если это финансовая транзакция: +{ + "isTransaction": true, + "amount": сумма (число, положительное для доходов, отрицательное для расходов), + "vendor": "название продавца/получателя", + "confidence": процент уверенности от 0 до 1, + "suggestedCategory": "предполагаемая категория (food, transport, shopping, etc.)", + "exclusionRegex": null +} + +Если это НЕ финансовая транзакция (реклама, уведомления, спам и т.д.): +{ + "isTransaction": false, + "amount": null, + "vendor": null, + "confidence": процент уверенности от 0 до 1, + "suggestedCategory": null, + "exclusionRegex": "регулярное выражение для исключения похожих SMS в будущем" +} + +ВАЖНО: exclusionRegex нужен только для НЕ-транзакционных сообщений, чтобы автоматически исключать похожие SMS в будущем. +'''; + } + + /// Анализирует SMS с помощью ИИ и создает PrefilledTransaction + Future processSmsByAi(String smsBody, String smsMessageId) async { + try { + // Валидация входных данных + if (smsBody.trim().isEmpty) { + _logger.w('Пустое SMS сообщение'); + return null; + } + + if (smsMessageId.trim().isEmpty) { + _logger.w('Пустой ID SMS сообщения'); + return null; + } + + if (!_aiService.isConfigured()) { + _logger.w('ИИ сервис не настроен'); + return null; + } + + final prompt = _buildAiPrompt(smsBody); + final aiResponse = await _aiService.sendMessage(prompt); + + if (aiResponse.trim().isEmpty) { + _logger.w('Пустой ответ от ИИ'); + return null; + } + + final aiData = _parseAiResponse(aiResponse); + if (aiData == null) { + _logger.w('Не удалось распарсить ответ ИИ'); + return null; + } + + // Проверяем тип сообщения + final isTransaction = aiData['isTransaction'] == true; + + if (isTransaction) { + // Дополнительная валидация данных для транзакций + if (!_validateTransactionData(aiData)) { + _logger.w('Некорректные данные транзакции от ИИ: $aiData'); + return null; + } + } else { + // Валидация данных для не-транзакционных SMS + if (!_validateNonTransactionData(aiData)) { + _logger.w('Некорректные данные не-транзакции от ИИ: $aiData'); + return null; + } + _logger.i('ИИ определил не-транзакционное SMS с exclusion regex'); + } + + return await _createPrefilledFromAiResponse(aiData, smsMessageId); + } catch (e) { + _logger.e('Ошибка при обработке SMS с помощью ИИ: $e'); + return null; + } + } + + /// Парсит ответ ИИ из JSON + Map? _parseAiResponse(String aiResponse) { + try { + // Очищаем ответ от возможных лишних символов и находим JSON + final cleanResponse = aiResponse.trim(); + int jsonStart = cleanResponse.indexOf('{'); + int jsonEnd = cleanResponse.lastIndexOf('}'); + + if (jsonStart == -1 || jsonEnd == -1) { + _logger.w('JSON не найден в ответе ИИ: $aiResponse'); + return null; + } + + final jsonString = cleanResponse.substring(jsonStart, jsonEnd + 1); + return jsonDecode(jsonString) as Map; + } catch (e) { + _logger.e('Ошибка парсинга ответа ИИ: $e, ответ: $aiResponse'); + return null; + } + } + + /// Создает PrefilledTransaction из ответа ИИ + Future _createPrefilledFromAiResponse( + Map aiData, + String smsMessageId + ) async { + Category? suggestedCategory; + + if (aiData['suggestedCategory'] != null) { + final categoryName = aiData['suggestedCategory'] as String; + final categories = await _categoryRepository.getAll(); + + // Ищем категорию по названию (приблизительное совпадение) + final matchingCategories = categories.where((cat) => + cat.name.toLowerCase().contains(categoryName.toLowerCase()) || + categoryName.toLowerCase().contains(cat.name.toLowerCase()) + ); + + if (matchingCategories.isNotEmpty) { + suggestedCategory = matchingCategories.first; + } + } + + final prefilled = PrefilledTransaction( + smsMessageId: smsMessageId, + transactionId: null, // Будет создан позже + amount: (aiData['amount'] as num?)?.toDouble(), + salesPoint: aiData['vendor'] as String?, + calculatedCategory: suggestedCategory, + confidence: ((aiData['confidence'] as num?)?.toDouble() ?? 0.0) * 100, // Конвертируем в проценты + exclusionRegex: aiData['exclusionRegex'] as String?, + ); + + // Сохраняем в репозиторий + await _prefilledRepository.add(prefilled); + return prefilled; + } + + /// Преобразует PrefilledTransaction в TransactionRecord + Future convertToTransactionRecord( + PrefilledTransaction prefilled, + {String? categoryId, String? tagId} + ) async { + if (prefilled.amount == null || prefilled.salesPoint == null) { + throw ArgumentError('PrefilledTransaction должен содержать amount и salesPoint'); + } + + final record = TransactionRecord( + amount: prefilled.amount!, + dateTime: DateTime.now(), // В реальной реализации можно попытаться извлечь дату из SMS + vendor: prefilled.salesPoint!, + currency: 'RUB', // По умолчанию, можно сделать настраиваемым + categoryId: categoryId ?? prefilled.calculatedCategory?.id ?? 'unknown', + tagId: tagId, + ); + + // Обновляем prefilled с ID созданной транзакции + prefilled.transactionId = record.id; + await _prefilledRepository.update(prefilled); + + return record; + } + + /// Создает AiRule из PrefilledTransaction для автоматизации в будущем + Future createAiRuleFromPrefilled( + PrefilledTransaction prefilled, + AiRuleType ruleType, + {String? customName} + ) async { + AiRule rule; + + switch (ruleType) { + case AiRuleType.pointOfSale: + // Для правил точки продаж нужны salesPoint и calculatedCategory + if (prefilled.salesPoint == null || prefilled.calculatedCategory == null) { + _logger.w('Недостаточно данных для создания правила точки продаж: salesPoint или calculatedCategory отсутствуют'); + return null; + } + + rule = AiRule( + name: customName ?? 'Автоправило для ${prefilled.salesPoint}', + type: AiRuleType.pointOfSale, + merchantPattern: _createMerchantPattern(prefilled.salesPoint!), + categoryId: prefilled.calculatedCategory!.id, + confidencePercentage: prefilled.confidence.round(), + ); + break; + + case AiRuleType.skipTemplate: + // Для skip правил нужен только exclusionRegex + if (prefilled.exclusionRegex == null || prefilled.exclusionRegex!.trim().isEmpty) { + _logger.w('Недостаточно данных для создания skip правила: exclusionRegex отсутствует'); + return null; + } + + // Определяем имя для skip правила + final ruleName = customName ?? + (prefilled.salesPoint != null + ? 'Пропуск для ${prefilled.salesPoint}' + : 'Автопропуск не-транзакционных SMS'); + + rule = AiRule( + name: ruleName, + type: AiRuleType.skipTemplate, + skipRegex: prefilled.exclusionRegex, + confidencePercentage: prefilled.confidence.round(), + ); + break; + } + + await _aiRuleRepository.add(rule); + return rule; + } + + /// Валидирует данные транзакции от ИИ + bool _validateTransactionData(Map aiData) { + // Проверяем обязательные поля для транзакции + if (aiData['amount'] == null) { + _logger.w('Отсутствует поле amount в ответе ИИ для транзакции'); + return false; + } + + if (aiData['vendor'] == null || (aiData['vendor'] as String).trim().isEmpty) { + _logger.w('Отсутствует или пустое поле vendor в ответе ИИ для транзакции'); + return false; + } + + // Проверяем корректность суммы + final amount = aiData['amount']; + if (amount is! num || amount == 0) { + _logger.w('Некорректная сумма в ответе ИИ: $amount'); + return false; + } + + // Проверяем уверенность + final confidence = aiData['confidence']; + if (confidence != null && (confidence is! num || confidence < 0 || confidence > 1)) { + _logger.w('Некорректное значение confidence в ответе ИИ: $confidence'); + return false; + } + + // Для транзакций exclusionRegex должен быть null + if (aiData['exclusionRegex'] != null) { + _logger.w('Для транзакций exclusionRegex должен быть null'); + return false; + } + + return true; + } + + /// Валидирует данные не-транзакционного SMS от ИИ + bool _validateNonTransactionData(Map aiData) { + // Проверяем уверенность + final confidence = aiData['confidence']; + if (confidence != null && (confidence is! num || confidence < 0 || confidence > 1)) { + _logger.w('Некорректное значение confidence в ответе ИИ: $confidence'); + return false; + } + + // Проверяем наличие exclusionRegex для не-транзакций + if (aiData['exclusionRegex'] == null || (aiData['exclusionRegex'] as String).trim().isEmpty) { + _logger.w('Отсутствует или пустое поле exclusionRegex для не-транзакции'); + return false; + } + + // Для не-транзакций amount и vendor должны быть null + if (aiData['amount'] != null) { + _logger.w('Для не-транзакций amount должен быть null'); + return false; + } + + if (aiData['vendor'] != null) { + _logger.w('Для не-транзакций vendor должен быть null'); + return false; + } + + return true; + } + + /// Создает паттерн для поиска продавца в SMS + String _createMerchantPattern(String salesPoint) { + // Простой паттерн - ищем точное совпадение или частичное + final escaped = RegExp.escape(salesPoint); + return '.*$escaped.*'; + } + + // === Методы для тестирования (доступ к приватным методам) === + + /// Публичный метод для тестирования _buildAiPrompt + String buildAiPromptForTesting(String smsBody) { + return _buildAiPrompt(smsBody); + } + + /// Публичный метод для тестирования _validateTransactionData + bool validateTransactionDataForTesting(Map aiData) { + return _validateTransactionData(aiData); + } + + /// Публичный метод для тестирования _validateNonTransactionData + bool validateNonTransactionDataForTesting(Map aiData) { + return _validateNonTransactionData(aiData); + } + + /// Публичный метод для тестирования _parseAiResponse + Map? parseAiResponseForTesting(String aiResponse) { + return _parseAiResponse(aiResponse); + } + + /// Публичный метод для тестирования _createMerchantPattern + String createMerchantPatternForTesting(String salesPoint) { + return _createMerchantPattern(salesPoint); + } +} \ No newline at end of file diff --git a/lib/services/custom_sms_functions.dart b/lib/services/custom_sms_functions.dart index d8b6a1f..3e6b294 100644 --- a/lib/services/custom_sms_functions.dart +++ b/lib/services/custom_sms_functions.dart @@ -1,5 +1,8 @@ import 'package:budget_app/models/transaction_record.dart'; +import 'package:budget_app/services/ai_transaction_processing_service.dart'; +import 'package:get_it/get_it.dart'; +import 'package:logger/logger.dart'; /// Псевдоним для типа функции обработки СМС typedef SmsProcessingFunction = Future Function(String smsBody); @@ -33,23 +36,45 @@ class CustomSmsFunctions { // --- Реализации кастомных функций --- /// Обработчик для функции "Запрос к ИИ". - /// Это заглушка, которая в будущем будет анализировать текст СМС с помощью ИИ. + /// Анализирует текст СМС с помощью ИИ и создает транзакцию. static Future _aiRequestHandler(String smsBody) async { - // TODO: Реализовать логику запроса к ИИ для анализа СМС - // Сейчас здесь просто заглушка, которая создает тестовую транзакцию. + final logger = Logger(); + + try { + logger.i('--- AI Request Handler ---'); + logger.i('SMS Body: $smsBody'); - print('--- AI Request Handler ---'); - print('SMS Body: $smsBody'); - print('--- End AI Request Handler ---'); + // Получаем сервис обработки ИИ транзакций + final aiProcessingService = GetIt.instance(); + + // Создаем уникальный ID для SMS (в реальности будет передаваться извне) + final smsMessageId = DateTime.now().millisecondsSinceEpoch.toString(); + + // Обрабатываем SMS с помощью ИИ + final prefilledTransaction = await aiProcessingService.processSmsByAi( + smsBody, + smsMessageId + ); + + if (prefilledTransaction == null) { + logger.i('ИИ не смог обработать SMS или не нашел транзакцию'); + return null; + } - // Пример создания транзакции. В реальной реализации эти данные - // будут извлечены из СМС с помощью ИИ. - return TransactionRecord( - amount: 123.45, // Пример суммы - dateTime: DateTime.now(), - vendor: 'AI Vendor', - currency: 'RUB', - categoryId: 'ai_category', - ); + logger.i('ИИ создал PrefilledTransaction: ${prefilledTransaction.salesPoint}, ${prefilledTransaction.amount}'); + + // Конвертируем PrefilledTransaction в TransactionRecord + final transactionRecord = await aiProcessingService.convertToTransactionRecord( + prefilledTransaction + ); + + logger.i('Создана транзакция: ${transactionRecord.vendor}, ${transactionRecord.amount}'); + logger.i('--- End AI Request Handler ---'); + + return transactionRecord; + } catch (e) { + logger.e('Ошибка в AI Request Handler: $e'); + return null; + } } } diff --git a/pubspec.lock b/pubspec.lock index e6d4f32..bbff800 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -334,6 +334,14 @@ packages: url: "https://pub.dev" source: hosted version: "1.1.0" + flutter_dotenv: + dependency: "direct dev" + description: + name: flutter_dotenv + sha256: b7c7be5cd9f6ef7a78429cabd2774d3c4af50e79cb2b7593e3d5d763ef95c61b + url: "https://pub.dev" + source: hosted + version: "5.2.1" flutter_iconpicker: dependency: "direct main" description: @@ -632,6 +640,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.0.0" + mockito: + dependency: "direct dev" + description: + name: mockito + sha256: "4546eac99e8967ea91bae633d2ca7698181d008e95fa4627330cf903d573277a" + url: "https://pub.dev" + source: hosted + version: "5.4.6" native_synchronization_temp: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 3041c84..0e621cb 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -64,6 +64,8 @@ dev_dependencies: flutter_lints: ^5.0.0 # Возвращено к совместимой версии freezed: ^3.1.0 json_serializable: ^6.7.1 + mockito: ^5.4.4 + flutter_dotenv: ^5.1.0 # For information on the generic Dart part of this file, see the diff --git a/test/README.md b/test/README.md new file mode 100644 index 0000000..d326fb7 --- /dev/null +++ b/test/README.md @@ -0,0 +1,156 @@ +# Тесты для AiTransactionProcessingService + +Данная тестовая система покрывает функциональность обработки SMS сообщений с помощью ИИ. + +## Структура тестов + +### Unit тесты (`test/services/`) +- **ai_transaction_processing_service_test.dart** - основные unit тесты + - Тестирование валидации данных от ИИ + - Проверка парсинга ответов + - Создание правил из обработанных транзакций + - Обработка ошибок и граничных случаев + +### Интеграционные тесты (`test/integration/`) +- **ai_processing_integration_test.dart** - тесты с реальным ИИ + - Обработка реальных SMS с помощью OpenRouter + - Тестирование производительности + - Обработка ошибок API + +### Вспомогательные файлы (`test/helpers/`) +- **test_data.dart** - примеры SMS сообщений +- **ai_responses.dart** - заготовленные ответы ИИ +- **test_objects.dart** - фабрика тестовых объектов + +### Моки и фейки (`test/mocks/`) +- **mocks.dart** - автогенерированные моки +- **fake_ai_service.dart** - настраиваемый фейковый AI сервис +- **fake_repositories.dart** - in-memory репозитории + +## Настройка для запуска + +### 1. Установка зависимостей +```bash +flutter pub get +flutter packages pub run build_runner build +``` + +### 2. Конфигурация AI токенов (опционально) +Скопируйте `.env.example` в `.env` и заполните: +```bash +cp .env.example .env +``` + +Добавьте ваш API ключ: +``` +OPENROUTER_API_KEY=your_api_key_here +RUN_INTEGRATION_TESTS=true +``` + +### 3. Запуск тестов + +#### Unit тесты (быстрые, не требуют AI): +```bash +flutter test test/services/ +``` + +#### Все тесты включая интеграционные: +```bash +flutter test +``` + +#### Только интеграционные тесты: +```bash +flutter test test/integration/ +``` + +## Типы тестов + +### 🏃‍♂️ Unit тесты (всегда запускаются) +- Используют моки и фейки +- Быстрые (< 1 секунды) +- Не требуют внешних зависимостей +- Покрывают всю логику валидации и парсинга + +### 🌐 Интеграционные тесты (требуют настройки) +- Используют реальный AI сервис +- Медленные (могут занимать минуты) +- Требуют AI API ключ +- Тестируют реальное взаимодействие с OpenRouter + +## Конфигурация CI/CD + +### GitHub Actions +Добавьте в секреты репозитория: +``` +AI_API_KEY=your_openrouter_key +RUN_INTEGRATION_TESTS=true +``` + +### Локальная разработка +```bash +# Только unit тесты +flutter test test/services/ + +# С интеграционными (если настроен .env) +RUN_INTEGRATION_TESTS=true flutter test +``` + +## Тестовые данные + +### SMS сообщения (`TestSmsData`) +- **Транзакционные**: покупки, снятие наличных, переводы +- **Не-транзакционные**: реклама, уведомления, коды подтверждения +- **Граничные случаи**: пустые сообщения, эмодзи, иностранные языки + +### Ответы ИИ (`TestAiResponses`) +- Корректные ответы для каждого типа SMS +- Некорректные форматы для тестирования обработки ошибок +- Автоматическое сопоставление SMS -> ответ ИИ + +### Тестовые объекты (`TestObjects`) +- Готовые категории, транзакции, правила +- Фабричные методы для создания объектов +- Консистентные данные для всех тестов + +## Отладка тестов + +### Включить подробные логи: +```bash +VERBOSE_TESTS=true flutter test +``` + +### Запустить конкретный тест: +```bash +flutter test test/services/ai_transaction_processing_service_test.dart -n "should process transaction SMS" +``` + +### Пропустить интеграционные тесты: +```bash +RUN_INTEGRATION_TESTS=false flutter test +``` + +## Покрытие кода + +Тесты покрывают: +- ✅ Парсинг ответов ИИ +- ✅ Валидация данных транзакций и не-транзакций +- ✅ Создание PrefilledTransaction объектов +- ✅ Генерация AI правил +- ✅ Обработка ошибок и исключений +- ✅ Интеграция с репозиториями +- ✅ Реальные запросы к ИИ (в интеграционных тестах) + +## Добавление новых тестов + +1. **Unit тесты**: добавьте в `ai_transaction_processing_service_test.dart` +2. **Новые SMS примеры**: обновите `test_data.dart` +3. **Новые ответы ИИ**: обновите `ai_responses.dart` +4. **Интеграционные сценарии**: добавьте в `ai_processing_integration_test.dart` + +## Известные ограничения + +- Интеграционные тесты требуют рабочего интернета +- API ключи не должны коммититься в репозиторий +- Некоторые тесты могут быть медленными из-за ограничений AI API +- В CI окружении интеграционные тесты могут быть отключены \ No newline at end of file diff --git a/test/ci_config.dart b/test/ci_config.dart new file mode 100644 index 0000000..dea6368 --- /dev/null +++ b/test/ci_config.dart @@ -0,0 +1,148 @@ +import 'dart:io'; + +/// Конфигурация для CI/CD окружения +class CiConfig { + /// Проверить, запускаются ли тесты в CI окружении + static bool get isRunningInCi { + return Platform.environment['CI'] == 'true' || + Platform.environment['GITHUB_ACTIONS'] == 'true' || + Platform.environment['GITLAB_CI'] == 'true' || + Platform.environment['JENKINS_URL'] != null; + } + + /// Проверить, должны ли запускаться интеграционные тесты в CI + static bool get shouldRunIntegrationTestsInCi { + // В CI интеграционные тесты запускаются только если есть токен + if (!isRunningInCi) return true; + + return Platform.environment['AI_API_KEY'] != null && + Platform.environment['AI_API_KEY']!.isNotEmpty; + } + + /// Получить AI API ключ для CI + static String? get ciAiApiKey { + return Platform.environment['AI_API_KEY']; + } + + /// Получить таймаут для тестов в CI (обычно больше чем локально) + static int get ciTestTimeoutSeconds { + if (!isRunningInCi) return 30; + + final timeout = Platform.environment['TEST_TIMEOUT_SECONDS']; + return int.tryParse(timeout ?? '60') ?? 60; + } + + /// Проверить, включены ли подробные логи + static bool get isVerboseLoggingEnabled { + return Platform.environment['VERBOSE_TESTS'] == 'true'; + } + + /// Получить уровень параллелизма для тестов + static int get testConcurrency { + if (!isRunningInCi) return 1; + + final concurrency = Platform.environment['TEST_CONCURRENCY']; + return int.tryParse(concurrency ?? '2') ?? 2; + } + + /// Настройки для различных CI провайдеров + static Map get ciProviderSettings { + if (Platform.environment['GITHUB_ACTIONS'] == 'true') { + return { + 'provider': 'GitHub Actions', + 'timeout': 300, // 5 минут + 'retries': 2, + }; + } + + if (Platform.environment['GITLAB_CI'] == 'true') { + return { + 'provider': 'GitLab CI', + 'timeout': 240, // 4 минуты + 'retries': 1, + }; + } + + return { + 'provider': 'Unknown CI', + 'timeout': 180, // 3 минуты + 'retries': 1, + }; + } + + /// Вывести информацию о CI окружении + static void printCiInfo() { + if (!isRunningInCi) { + print('Running tests locally'); + return; + } + + final settings = ciProviderSettings; + print('=== CI Environment Info ==='); + print('Provider: ${settings['provider']}'); + print('Timeout: ${settings['timeout']}s'); + print('Retries: ${settings['retries']}'); + print('AI configured: ${ciAiApiKey != null}'); + print('Integration tests: ${shouldRunIntegrationTestsInCi ? 'enabled' : 'disabled'}'); + print('Test concurrency: $testConcurrency'); + print('Verbose logging: ${isVerboseLoggingEnabled ? 'enabled' : 'disabled'}'); + print('==========================='); + } +} + +/// Вспомогательные функции для настройки тестов в CI +class CiTestHelpers { + /// Получить максимальное время ожидания для теста + static Duration getTestTimeout() { + return Duration(seconds: CiConfig.ciTestTimeoutSeconds); + } + + /// Получить задержку между запросами к AI в CI + static Duration getAiRequestDelay() { + return CiConfig.isRunningInCi + ? Duration(milliseconds: 1000) // 1 секунда в CI + : Duration(milliseconds: 500); // 0.5 секунды локально + } + + /// Проверить, нужно ли пропустить тест в CI + static bool shouldSkipTest({ + bool requiresAi = false, + bool requiresNetwork = false, + bool isSlowTest = false, + }) { + if (!CiConfig.isRunningInCi) return false; + + // Пропускаем AI тесты если нет токена + if (requiresAi && !CiConfig.shouldRunIntegrationTestsInCi) { + return true; + } + + // Пропускаем медленные тесты в некоторых CI окружениях + if (isSlowTest && CiConfig.ciProviderSettings['provider'] == 'GitLab CI') { + return true; + } + + return false; + } + + /// Вывести информацию о пропуске теста + static void printSkipReason({ + bool requiresAi = false, + bool requiresNetwork = false, + bool isSlowTest = false, + }) { + final reasons = []; + + if (requiresAi && !CiConfig.shouldRunIntegrationTestsInCi) { + reasons.add('AI not configured'); + } + + if (isSlowTest && CiConfig.ciProviderSettings['provider'] == 'GitLab CI') { + reasons.add('slow test in GitLab CI'); + } + + if (reasons.isNotEmpty) { + print('Skipping test: ${reasons.join(', ')}'); + } + } +} \ No newline at end of file diff --git a/test/helpers/ai_responses.dart b/test/helpers/ai_responses.dart new file mode 100644 index 0000000..08f75b5 --- /dev/null +++ b/test/helpers/ai_responses.dart @@ -0,0 +1,203 @@ +/// Предустановленные ответы AI для различных тестовых сценариев + +class TestAiResponses { + // Ответы для транзакционных SMS + static const String bankTransactionResponse = ''' +{ + "isTransaction": true, + "amount": -1250.0, + "vendor": "METRO CASH & CARRY", + "confidence": 0.95, + "suggestedCategory": "shopping", + "exclusionRegex": null +} +'''; + + static const String atmWithdrawalResponse = ''' +{ + "isTransaction": true, + "amount": -5000.0, + "vendor": "Банкомат СБЕРБАНК", + "confidence": 0.9, + "suggestedCategory": "cash", + "exclusionRegex": null +} +'''; + + static const String onlinePaymentResponse = ''' +{ + "isTransaction": true, + "amount": -890.5, + "vendor": "Яндекс.Такси", + "confidence": 0.98, + "suggestedCategory": "transport", + "exclusionRegex": null +} +'''; + + static const String incomeTransferResponse = ''' +{ + "isTransaction": true, + "amount": 50000.0, + "vendor": "РАБОТОДАТЕЛЬ ООО", + "confidence": 0.99, + "suggestedCategory": "salary", + "exclusionRegex": null +} +'''; + + // Ответы для не-транзакционных SMS + static const String bankAdvertisingResponse = ''' +{ + "isTransaction": false, + "amount": null, + "vendor": null, + "confidence": 0.85, + "suggestedCategory": null, + "exclusionRegex": ".*Специальное предложение.*кредит.*\\\\d+.*годовых.*" +} +'''; + + static const String promotionalResponse = ''' +{ + "isTransaction": false, + "amount": null, + "vendor": null, + "confidence": 0.9, + "suggestedCategory": null, + "exclusionRegex": ".*МЕГАФОН.*подарок.*БЕСПЛАТНО.*" +} +'''; + + static const String securityCodeResponse = ''' +{ + "isTransaction": false, + "amount": null, + "vendor": null, + "confidence": 0.95, + "suggestedCategory": null, + "exclusionRegex": ".*Код подтверждения.*\\\\d{4,6}.*" +} +'''; + + static const String cardBlockedResponse = ''' +{ + "isTransaction": false, + "amount": null, + "vendor": null, + "confidence": 0.88, + "suggestedCategory": null, + "exclusionRegex": ".*карта.*заблокирована.*безопасности.*" +} +'''; + + static const String spamResponse = ''' +{ + "isTransaction": false, + "amount": null, + "vendor": null, + "confidence": 0.92, + "suggestedCategory": null, + "exclusionRegex": ".*выиграли.*\\\\d+.*рублей.*ПРИЗ.*" +} +'''; + + // Некорректные ответы для тестирования обработки ошибок + static const String invalidJsonResponse = ''' +{ + "isTransaction": true, + "amount": -100.0, + "vendor": "Test Vendor" + // Missing closing brace and comma +'''; + + static const String incompleteTransactionResponse = ''' +{ + "isTransaction": true, + "vendor": "Test Vendor", + "confidence": 0.8 +} +'''; + + static const String incompleteNonTransactionResponse = ''' +{ + "isTransaction": false, + "confidence": 0.7 +} +'''; + + static const String wrongTypeResponse = ''' +{ + "isTransaction": "yes", + "amount": "not a number", + "vendor": 123, + "confidence": "high" +} +'''; + + static const String emptyResponse = ''; + + static const String nonJsonResponse = 'This is not a JSON response'; + + // Маппинг SMS к ожидаемым ответам AI + static final Map smsToAiResponse = { + // Используем части SMS как ключи для удобства поиска + 'METRO CASH & CARRY': bankTransactionResponse, + 'Банкомат СБЕРБАНК': atmWithdrawalResponse, + 'Яндекс.Такси': onlinePaymentResponse, + 'РАБОТОДАТЕЛЬ ООО': incomeTransferResponse, + 'Специальное предложение': bankAdvertisingResponse, + 'МЕГАФОН': promotionalResponse, + 'Код подтверждения': securityCodeResponse, + 'карта *1234 заблокирована': cardBlockedResponse, + 'выиграли 1,000,000': spamResponse, + }; + + /// Получить ответ AI для SMS сообщения + static String getResponseForSms(String smsBody) { + for (final entry in smsToAiResponse.entries) { + if (smsBody.contains(entry.key)) { + return entry.value; + } + } + + // Возвращаем ответ по умолчанию для неизвестных SMS + return ''' +{ + "isTransaction": false, + "amount": null, + "vendor": null, + "confidence": 0.5, + "suggestedCategory": null, + "exclusionRegex": ".*неизвестное сообщение.*" +} +'''; + } + + // Список всех корректных ответов для транзакций + static final List validTransactionResponses = [ + bankTransactionResponse, + atmWithdrawalResponse, + onlinePaymentResponse, + incomeTransferResponse, + ]; + + // Список всех корректных ответов для не-транзакций + static final List validNonTransactionResponses = [ + bankAdvertisingResponse, + promotionalResponse, + securityCodeResponse, + cardBlockedResponse, + spamResponse, + ]; + + // Список некорректных ответов для тестирования обработки ошибок + static final List invalidResponses = [ + invalidJsonResponse, + incompleteTransactionResponse, + incompleteNonTransactionResponse, + wrongTypeResponse, + emptyResponse, + nonJsonResponse, + ]; +} \ No newline at end of file diff --git a/test/helpers/test_data.dart b/test/helpers/test_data.dart new file mode 100644 index 0000000..57dc053 --- /dev/null +++ b/test/helpers/test_data.dart @@ -0,0 +1,125 @@ +/// Тестовые данные для SMS сообщений и ответов AI + +class TestSmsData { + // Транзакционные SMS + static const String bankTransactionSms = ''' +Карта *1234 +Покупка 1,250.00 RUB +METRO CASH & CARRY +Баланс: 15,750.00 RUB +01.12.23 14:30 +'''; + + static const String atmWithdrawalSms = ''' +Карта *5678 +Снятие 5,000.00 RUB +Банкомат СБЕРБАНК +г.Москва ул.Тверская 10 +01.12.23 18:45 +Баланс: 10,750.00 RUB +'''; + + static const String onlinePaymentSms = ''' +Оплата 890.50 RUB +Яндекс.Такси +Карта *9012 +01.12.23 20:15 +Баланс: 9,859.50 RUB +'''; + + static const String incomeTransferSms = ''' +Поступление 50,000.00 RUB +От: РАБОТОДАТЕЛЬ ООО +На карту *1234 +01.12.23 10:00 +Баланс: 65,609.50 RUB +'''; + + // Не-транзакционные SMS + static const String bankAdvertisingSms = ''' +🏦 Специальное предложение от Банка! +Оформите кредит до 2,000,000 руб под 5.9% годовых. +Быстрое решение за 30 минут. +Подробности: банк.рф/kredit +Отписаться: СТОП на 900 +'''; + + static const String promotionalSms = ''' +🎁 МЕГАФОН: У вас подарок! +500 минут и 10 ГБ интернета БЕСПЛАТНО! +Активируйте до 15.12.23 +Отправьте ДА на номер 0500 +'''; + + static const String securitySms = ''' +Код подтверждения: 123456 +Никому не сообщайте этот код! +Банк никогда не запрашивает коды по телефону. +Действует 5 минут. +'''; + + static const String notificationSms = ''' +Уважаемый клиент! +Ваша карта *1234 заблокирована по соображениям безопасности. +Для разблокировки обратитесь в отделение банка +или по телефону 8-800-555-0001 +'''; + + static const String spamSms = ''' +СРОЧНО! Вы выиграли 1,000,000 рублей! +Для получения приза отправьте ПРИЗ на номер 9999 +Стоимость SMS 150 руб/сут +Подробности на сайте лотерея-победа.рф +'''; + + // Граничные случаи + static const String emptyMessage = ''; + + static const String veryLongMessage = ''' +Lorem ipsum dolor sit amet, consectetur adipiscing elit. Sed do eiusmod tempor incididunt ut labore et dolore magna aliqua. Ut enim ad minim veniam, quis nostrud exercitation ullamco laboris nisi ut aliquip ex ea commodo consequat. Duis aute irure dolor in reprehenderit in voluptate velit esse cillum dolore eu fugiat nulla pariatur. Excepteur sint occaecat cupidatat non proident, sunt in culpa qui officia deserunt mollit anim id est laborum. Sed ut perspiciatis unde omnis iste natus error sit voluptatem accusantium doloremque laudantium, totam rem aperiam, eaque ipsa quae ab illo inventore veritatis et quasi architecto beatae vitae dicta sunt explicabo. +'''; + + static const String messageWithEmojis = ''' +🎉 Покупка 250.50 RUB 🛒 +OZON Маркетплейс 📦 +Карта *4567 💳 +Спасибо за покупку! ❤️ +'''; + + static const String foreignLanguageSms = ''' +Payment 45.67 USD +AMAZON.COM +Card *1234 +Balance: 234.56 USD +Transaction ID: TXN123456 +'''; + + // Список всех тестовых SMS + static final List allTransactionSms = [ + bankTransactionSms, + atmWithdrawalSms, + onlinePaymentSms, + incomeTransferSms, + ]; + + static final List allNonTransactionSms = [ + bankAdvertisingSms, + promotionalSms, + securitySms, + notificationSms, + spamSms, + ]; + + static final List allEdgeCaseSms = [ + emptyMessage, + veryLongMessage, + messageWithEmojis, + foreignLanguageSms, + ]; + + static final List allSms = [ + ...allTransactionSms, + ...allNonTransactionSms, + ...allEdgeCaseSms, + ]; +} \ No newline at end of file diff --git a/test/helpers/test_objects.dart b/test/helpers/test_objects.dart new file mode 100644 index 0000000..8ec684c --- /dev/null +++ b/test/helpers/test_objects.dart @@ -0,0 +1,262 @@ +import 'package:budget_app/models/category.dart'; +import 'package:flutter/material.dart'; +import 'package:budget_app/models/prefilled_transaction.dart'; +import 'package:budget_app/models/ai_rule.dart'; +import 'package:budget_app/models/ai_settings.dart'; + +/// Фабрика для создания тестовых объектов + +class TestObjects { + // Тестовые категории + static Category createTestCategory({ + String? id, + String name = 'Test Category', + String color = '#FF0000', + String icon = 'shopping_cart', + bool isIncome = false, + }) { + return Category( + id: id ?? 'test_category_id', + name: name, + color: Color(int.parse(color.replaceFirst('#', '0xFF'))), + icon: const IconData(0xe59c, fontFamily: 'MaterialIcons'), // shopping_cart icon + isIncome: isIncome, + ); + } + + static final Category shoppingCategory = createTestCategory( + id: 'shopping_cat', + name: 'Покупки', + color: '#4CAF50', + isIncome: false, + ); + + static final Category transportCategory = createTestCategory( + id: 'transport_cat', + name: 'Транспорт', + color: '#2196F3', + isIncome: false, + ); + + static final Category cashCategory = createTestCategory( + id: 'cash_cat', + name: 'Наличные', + color: '#FF9800', + isIncome: false, + ); + + static final Category salaryCategory = createTestCategory( + id: 'salary_cat', + name: 'Зарплата', + color: '#8BC34A', + isIncome: true, + ); + + // Тестовые PrefilledTransaction + static PrefilledTransaction createTransactionPrefilled({ + String? smsMessageId, + String? transactionId, + double? amount, + String? salesPoint, + Category? calculatedCategory, + double confidence = 90.0, + String? exclusionRegex, + }) { + return PrefilledTransaction( + smsMessageId: smsMessageId ?? 'test_sms_${DateTime.now().millisecondsSinceEpoch}', + transactionId: transactionId, + amount: amount ?? -1250.0, + salesPoint: salesPoint ?? 'METRO CASH & CARRY', + calculatedCategory: calculatedCategory ?? shoppingCategory, + confidence: confidence, + exclusionRegex: exclusionRegex, + ); + } + + static PrefilledTransaction createNonTransactionPrefilled({ + String? smsMessageId, + double confidence = 85.0, + String? exclusionRegex, + }) { + return PrefilledTransaction( + smsMessageId: smsMessageId ?? 'test_non_trans_${DateTime.now().millisecondsSinceEpoch}', + transactionId: null, + amount: null, + salesPoint: null, + calculatedCategory: null, + confidence: confidence, + exclusionRegex: exclusionRegex ?? '.*Специальное предложение.*кредит.*', + ); + } + + // Конкретные примеры PrefilledTransaction + static final PrefilledTransaction metroTransaction = createTransactionPrefilled( + smsMessageId: 'metro_sms_001', + amount: -1250.0, + salesPoint: 'METRO CASH & CARRY', + calculatedCategory: shoppingCategory, + confidence: 95.0, + ); + + static final PrefilledTransaction yandexTaxiTransaction = createTransactionPrefilled( + smsMessageId: 'taxi_sms_001', + amount: -890.5, + salesPoint: 'Яндекс.Такси', + calculatedCategory: transportCategory, + confidence: 98.0, + ); + + static final PrefilledTransaction salaryIncome = createTransactionPrefilled( + smsMessageId: 'salary_sms_001', + amount: 50000.0, + salesPoint: 'РАБОТОДАТЕЛЬ ООО', + calculatedCategory: salaryCategory, + confidence: 99.0, + ); + + static final PrefilledTransaction bankAdvertising = createNonTransactionPrefilled( + smsMessageId: 'bank_ad_001', + confidence: 85.0, + exclusionRegex: '.*Специальное предложение.*кредит.*\\d+.*годовых.*', + ); + + static final PrefilledTransaction megafonPromo = createNonTransactionPrefilled( + smsMessageId: 'megafon_promo_001', + confidence: 90.0, + exclusionRegex: '.*МЕГАФОН.*подарок.*БЕСПЛАТНО.*', + ); + + // Тестовые AiRule + static AiRule createPointOfSaleRule({ + String? id, + String? name, + String? merchantPattern, + String? categoryId, + int confidence = 80, + bool isActive = true, + }) { + return AiRule( + id: id, + name: name ?? 'Автоправило для METRO', + type: AiRuleType.pointOfSale, + merchantPattern: merchantPattern ?? '.*METRO.*', + categoryId: categoryId ?? shoppingCategory.id, + confidencePercentage: confidence, + isActive: isActive, + ); + } + + static AiRule createSkipRule({ + String? id, + String? name, + String? skipRegex, + int confidence = 80, + bool isActive = true, + }) { + return AiRule( + id: id, + name: name ?? 'Пропуск рекламы банка', + type: AiRuleType.skipTemplate, + skipRegex: skipRegex ?? '.*Специальное предложение.*кредит.*', + confidencePercentage: confidence, + isActive: isActive, + ); + } + + // Конкретные примеры AiRule + static final AiRule metroRule = createPointOfSaleRule( + id: 'metro_rule_001', + name: 'Автоправило для METRO CASH & CARRY', + merchantPattern: '.*METRO CASH & CARRY.*', + categoryId: shoppingCategory.id, + confidence: 95, + ); + + static final AiRule taxiRule = createPointOfSaleRule( + id: 'taxi_rule_001', + name: 'Автоправило для Яндекс.Такси', + merchantPattern: '.*Яндекс\\.Такси.*', + categoryId: transportCategory.id, + confidence: 98, + ); + + static final AiRule bankAdSkipRule = createSkipRule( + id: 'bank_ad_skip_001', + name: 'Пропуск рекламы кредитов', + skipRegex: '.*Специальное предложение.*кредит.*\\d+.*годовых.*', + confidence: 85, + ); + + static final AiRule promoSkipRule = createSkipRule( + id: 'promo_skip_001', + name: 'Пропуск промо от МЕГАФОН', + skipRegex: '.*МЕГАФОН.*подарок.*БЕСПЛАТНО.*', + confidence: 90, + ); + + // Тестовые настройки AI + static AiSettings createTestAiSettings({ + String? apiKey, + String baseUrl = 'https://openrouter.ai/api/v1', + String defaultModel = 'openai/gpt-3.5-turbo', + int timeoutSeconds = 30, + double temperature = 0.1, + int maxTokens = 500, + bool isEnabled = true, + }) { + return AiSettings( + apiKey: apiKey ?? 'test_api_key_12345', + baseUrl: baseUrl, + defaultModel: defaultModel, + timeoutSeconds: timeoutSeconds, + temperature: temperature, + maxTokens: maxTokens, + isEnabled: isEnabled, + ); + } + + static final AiSettings defaultTestAiSettings = createTestAiSettings(); + + static final AiSettings disabledAiSettings = createTestAiSettings( + isEnabled: false, + ); + + static final AiSettings unconfiguredAiSettings = createTestAiSettings( + apiKey: null, + isEnabled: false, + ); + + // Списки объектов для массовых тестов + static final List testCategories = [ + shoppingCategory, + transportCategory, + cashCategory, + salaryCategory, + ]; + + static final List testTransactionPrefilledList = [ + metroTransaction, + yandexTaxiTransaction, + salaryIncome, + ]; + + static final List testNonTransactionPrefilledList = [ + bankAdvertising, + megafonPromo, + ]; + + static final List testPointOfSaleRules = [ + metroRule, + taxiRule, + ]; + + static final List testSkipRules = [ + bankAdSkipRule, + promoSkipRule, + ]; + + static final List testAllRules = [ + ...testPointOfSaleRules, + ...testSkipRules, + ]; +} \ No newline at end of file diff --git a/test/integration/ai_processing_integration_test.dart b/test/integration/ai_processing_integration_test.dart new file mode 100644 index 0000000..5165946 --- /dev/null +++ b/test/integration/ai_processing_integration_test.dart @@ -0,0 +1,299 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:budget_app/services/ai_transaction_processing_service.dart'; +import 'package:budget_app/services/openrouter_ai_service.dart'; +import 'package:budget_app/models/ai_rule.dart'; + +import '../test_config.dart'; +import '../mocks/fake_repositories.dart'; +import '../helpers/test_data.dart'; +import '../helpers/test_objects.dart'; + +void main() { + group('AI Processing Integration Tests', () { + late AiTransactionProcessingService service; + late FakeRepositories fakeRepositories; + + setUpAll(() async { + await TestConfig.initialize(); + }); + + setUp(() { + fakeRepositories = FakeRepositories(); + fakeRepositories.loadTestData(); + }); + + tearDown(() { + fakeRepositories.clearAll(); + }); + + group('With Real AI Service', () { + setUp(() { + // Пропускаем тесты если AI не настроен + if (!TestConfig.shouldRunIntegrationTests() || !TestConfig.isAiConfigured()) { + return; + } + + final aiSettings = TestConfig.getTestAiSettings(); + final aiService = OpenRouterAiService(aiSettings); + + service = AiTransactionProcessingService( + aiService: aiService, + prefilledRepository: fakeRepositories.prefilledTransactionRepository, + categoryRepository: fakeRepositories.categoryRepository, + aiRuleRepository: fakeRepositories.aiRuleRepository, + ); + }); + + testWidgets('should process real bank transaction SMS', (tester) async { + if (!TestConfig.shouldRunIntegrationTests() || !TestConfig.isAiConfigured()) { + print('Skipping integration test: AI not configured or tests disabled'); + return; + } + + // Act + final result = await service.processSmsByAi( + TestSmsData.bankTransactionSms, + 'integration_test_001', + ); + + // Assert + expect(result, isNotNull, reason: 'AI should process bank transaction SMS'); + expect(result!.smsMessageId, equals('integration_test_001')); + + if (result.amount != null) { + // Это транзакция + expect(result.amount, isNot(equals(0.0))); + expect(result.salesPoint, isNotNull); + expect(result.exclusionRegex, isNull); + print('✓ Processed as transaction: ${result.salesPoint}, ${result.amount}'); + } else { + // Это не-транзакция + expect(result.exclusionRegex, isNotNull); + print('✓ Processed as non-transaction with regex: ${result.exclusionRegex}'); + } + + // Проверяем что сохранилось в репозиторий + final saved = fakeRepositories.prefilledTransactionRepository + .findBySmsId('integration_test_001'); + expect(saved, isNotNull); + }); + + testWidgets('should process promotional SMS as non-transaction', (tester) async { + if (!TestConfig.shouldRunIntegrationTests() || !TestConfig.isAiConfigured()) { + print('Skipping integration test: AI not configured or tests disabled'); + return; + } + + // Act + final result = await service.processSmsByAi( + TestSmsData.promotionalSms, + 'integration_test_002', + ); + + // Assert + if (result != null) { + print('✓ Processed promotional SMS'); + + if (result.amount == null) { + // Должно быть обработано как не-транзакция + expect(result.exclusionRegex, isNotNull); + expect(result.salesPoint, isNull); + print('✓ Correctly identified as non-transaction with regex: ${result.exclusionRegex}'); + } else { + print('! AI identified promotional SMS as transaction (may need prompt tuning)'); + } + } else { + print('! AI did not process promotional SMS (may be expected behavior)'); + } + }); + + testWidgets('should create AI rules from processed transactions', (tester) async { + if (!TestConfig.shouldRunIntegrationTests() || !TestConfig.isAiConfigured()) { + print('Skipping integration test: AI not configured or tests disabled'); + return; + } + + // Arrange: Сначала обрабатываем SMS + final prefilled = await service.processSmsByAi( + TestSmsData.onlinePaymentSms, + 'integration_test_003', + ); + + if (prefilled == null) { + print('! Could not process SMS for rule creation test'); + return; + } + + // Act: Создаем правила на основе обработанной транзакции + if (prefilled.amount != null && prefilled.salesPoint != null) { + // Создаем правило для точки продаж + final pointOfSaleRule = await service.createAiRuleFromPrefilled( + prefilled, + AiRuleType.pointOfSale, + ); + + // Assert + expect(pointOfSaleRule, isNotNull); + expect(pointOfSaleRule!.type, equals(AiRuleType.pointOfSale)); + expect(pointOfSaleRule.merchantPattern, isNotNull); + expect(pointOfSaleRule.categoryId, isNotNull); + print('✓ Created point of sale rule: ${pointOfSaleRule.name}'); + + // Проверяем что правило сохранилось + final savedRules = fakeRepositories.aiRuleRepository.getPointOfSaleRules(); + expect(savedRules.length, greaterThan(2)); // Исходные + новое + } + + if (prefilled.exclusionRegex != null) { + // Создаем правило пропуска + final skipRule = await service.createAiRuleFromPrefilled( + prefilled, + AiRuleType.skipTemplate, + ); + + // Assert + expect(skipRule, isNotNull); + expect(skipRule!.type, equals(AiRuleType.skipTemplate)); + expect(skipRule.skipRegex, equals(prefilled.exclusionRegex)); + print('✓ Created skip rule: ${skipRule.name}'); + } + }); + + testWidgets('should handle multiple SMS in sequence', (tester) async { + if (!TestConfig.shouldRunIntegrationTests() || !TestConfig.isAiConfigured()) { + print('Skipping integration test: AI not configured or tests disabled'); + return; + } + + final testSms = [ + TestSmsData.atmWithdrawalSms, + TestSmsData.bankAdvertisingSms, + TestSmsData.securitySms, + ]; + + final results = {}; + + // Act: Обрабатываем несколько SMS подряд + for (int i = 0; i < testSms.length; i++) { + final smsId = 'batch_test_${i + 1}'; + final result = await service.processSmsByAi(testSms[i], smsId); + results[smsId] = result; + + // Небольшая пауза между запросами + await Future.delayed(Duration(milliseconds: 500)); + } + + // Assert: Проверяем результаты + int transactionCount = 0; + int nonTransactionCount = 0; + + for (final entry in results.entries) { + final result = entry.value; + if (result != null) { + if (result.amount != null) { + transactionCount++; + print('✓ ${entry.key}: Transaction (${result.salesPoint})'); + } else { + nonTransactionCount++; + print('✓ ${entry.key}: Non-transaction'); + } + } else { + print('! ${entry.key}: Not processed'); + } + } + + print('Summary: $transactionCount transactions, $nonTransactionCount non-transactions'); + + // Проверяем что все сохранилось + final allPrefilled = await fakeRepositories.prefilledTransactionRepository.getAll(); + expect(allPrefilled.length, greaterThanOrEqualTo(2)); // Исходные + новые + }); + }); + + group('Performance Tests', () { + testWidgets('should handle batch processing within reasonable time', (tester) async { + if (!TestConfig.shouldRunIntegrationTests() || !TestConfig.isAiConfigured()) { + print('Skipping performance test: AI not configured or tests disabled'); + return; + } + + final aiSettings = TestConfig.getTestAiSettings(); + final aiService = OpenRouterAiService(aiSettings); + + service = AiTransactionProcessingService( + aiService: aiService, + prefilledRepository: fakeRepositories.prefilledTransactionRepository, + categoryRepository: fakeRepositories.categoryRepository, + aiRuleRepository: fakeRepositories.aiRuleRepository, + ); + + final stopwatch = Stopwatch()..start(); + + // Act: Обрабатываем несколько SMS + final futures = []; + for (int i = 0; i < 3; i++) { + futures.add(service.processSmsByAi( + TestSmsData.allTransactionSms[i % TestSmsData.allTransactionSms.length], + 'perf_test_$i', + )); + } + + await Future.wait(futures); + stopwatch.stop(); + + // Assert: Проверяем время выполнения + final elapsed = stopwatch.elapsedMilliseconds; + print('Batch processing took: ${elapsed}ms'); + + // Разумное время: меньше 30 секунд для 3 запросов + expect(elapsed, lessThan(30000), + reason: 'Batch processing should complete within 30 seconds'); + }); + }); + + group('Error Handling Integration', () { + testWidgets('should gracefully handle AI service errors', (tester) async { + if (!TestConfig.shouldRunIntegrationTests()) { + print('Skipping error handling test: Integration tests disabled'); + return; + } + + // Создаем сервис с неправильным API ключом + final badAiSettings = TestConfig.getTestAiSettings() + ..apiKey = 'invalid_api_key_123'; + final badAiService = OpenRouterAiService(badAiSettings); + + service = AiTransactionProcessingService( + aiService: badAiService, + prefilledRepository: fakeRepositories.prefilledTransactionRepository, + categoryRepository: fakeRepositories.categoryRepository, + aiRuleRepository: fakeRepositories.aiRuleRepository, + ); + + // Act + final result = await service.processSmsByAi( + TestSmsData.bankTransactionSms, + 'error_test_001', + ); + + // Assert: Должно вернуть null без исключений + expect(result, isNull); + print('✓ Gracefully handled AI service error'); + + // Проверяем что ничего не сохранилось + final saved = fakeRepositories.prefilledTransactionRepository + .findBySmsId('error_test_001'); + expect(saved, isNull); + }); + }); + }); +} + +/// Helper для вывода статистики тестов +void printTestStats(FakeRepositories repositories) { + final stats = repositories.getStats(); + print('Test repositories stats:'); + for (final entry in stats.entries) { + print(' ${entry.key}: ${entry.value}'); + } +} \ No newline at end of file diff --git a/test/mocks/fake_ai_service.dart b/test/mocks/fake_ai_service.dart new file mode 100644 index 0000000..9082a02 --- /dev/null +++ b/test/mocks/fake_ai_service.dart @@ -0,0 +1,182 @@ +import 'package:budget_app/services/interfaces/iai_service.dart'; +import '../helpers/ai_responses.dart'; + +/// Фейковый AI сервис для тестирования с предустановленными ответами +class FakeAiService implements IAiService { + final Map _responses = {}; + bool _isConfigured = true; + String? _apiKey = 'fake_api_key'; + bool _shouldThrow = false; + String? _exceptionMessage; + + /// Конструктор с опциональными предустановленными ответами + FakeAiService({Map? responses}) { + if (responses != null) { + _responses.addAll(responses); + } + // Добавляем стандартные ответы из TestAiResponses + _initializeDefaultResponses(); + } + + void _initializeDefaultResponses() { + for (final entry in TestAiResponses.smsToAiResponse.entries) { + _responses[entry.key] = entry.value; + } + } + + /// Установить ответ для конкретного сообщения (или части сообщения) + void setResponse(String messageKey, String response) { + _responses[messageKey] = response; + } + + /// Очистить все ответы + void clearResponses() { + _responses.clear(); + } + + /// Настроить, чтобы сервис выбрасывал исключение + void setThrowException(String message) { + _shouldThrow = true; + _exceptionMessage = message; + } + + /// Отключить выбрасывание исключений + void disableExceptions() { + _shouldThrow = false; + _exceptionMessage = null; + } + + /// Настроить статус конфигурации + void setConfigured(bool configured) { + _isConfigured = configured; + if (!configured) { + _apiKey = null; + } else { + _apiKey = 'fake_api_key'; + } + } + + @override + Future sendMessage(String message) async { + _callCount++; + + if (_shouldThrow) { + throw Exception(_exceptionMessage ?? 'Fake AI service error'); + } + + if (!_isConfigured) { + throw Exception('AI service not configured'); + } + + // Добавляем небольшую задержку для имитации реального сервиса + await Future.delayed(Duration(milliseconds: 10)); + + // Ищем ответ по ключевым словам в сообщении + for (final entry in _responses.entries) { + if (message.contains(entry.key)) { + return entry.value; + } + } + + // Если ответ не найден, возвращаем стандартный ответ + return TestAiResponses.getResponseForSms(message); + } + + @override + Future sendMessageWithParams({ + required String message, + String? model, + double? temperature, + int? maxTokens, + }) async { + // Для тестов параметры игнорируем, просто вызываем sendMessage + return sendMessage(message); + } + + @override + Future checkHealth() async { + if (_shouldThrow) { + throw Exception(_exceptionMessage ?? 'Fake health check error'); + } + return _isConfigured; + } + + @override + Future> getAvailableModels() async { + if (_shouldThrow) { + throw Exception(_exceptionMessage ?? 'Fake models error'); + } + + if (!_isConfigured) { + throw Exception('AI service not configured'); + } + + return [ + 'fake/test-model-1', + 'fake/test-model-2', + 'openai/gpt-3.5-turbo', + ]; + } + + @override + void setApiKey(String apiKey) { + _apiKey = apiKey; + _isConfigured = apiKey.isNotEmpty; + } + + @override + bool isConfigured() { + return _isConfigured && _apiKey != null && _apiKey!.isNotEmpty; + } + + /// Получить количество вызовов sendMessage (для тестирования) + int get callCount => _callCount; + int _callCount = 0; + + + /// Сбросить счетчик вызовов + void resetCallCount() { + _callCount = 0; + } +} + +/// Специализированные фейковые сервисы для конкретных тестовых сценариев + +/// Сервис, который всегда возвращает ошибки +class FailingAiService extends FakeAiService { + final String errorMessage; + + FailingAiService(this.errorMessage) { + setThrowException(errorMessage); + } +} + +/// Сервис, который возвращает некорректные JSON ответы +class InvalidResponseAiService extends FakeAiService { + @override + Future sendMessage(String message) async { + _callCount++; + return TestAiResponses.invalidJsonResponse; + } +} + +/// Сервис с таймаутами +class TimeoutAiService extends FakeAiService { + final Duration timeout; + + TimeoutAiService({this.timeout = const Duration(seconds: 5)}); + + @override + Future sendMessage(String message) async { + _callCount++; + await Future.delayed(timeout); + throw Exception('Request timeout'); + } +} + +/// Сервис, который не настроен +class UnconfiguredAiService extends FakeAiService { + UnconfiguredAiService() { + setConfigured(false); + } +} \ No newline at end of file diff --git a/test/mocks/fake_repositories.dart b/test/mocks/fake_repositories.dart new file mode 100644 index 0000000..49742d7 --- /dev/null +++ b/test/mocks/fake_repositories.dart @@ -0,0 +1,270 @@ +import 'package:budget_app/data/repositories/interfaces/iprefilled_transaction_repository.dart'; +import 'package:budget_app/data/repositories/interfaces/icategory_repository.dart'; +import 'package:budget_app/data/repositories/interfaces/iai_rule_repository.dart'; +import 'package:budget_app/models/prefilled_transaction.dart'; +import 'package:budget_app/models/category.dart'; +import 'package:budget_app/models/ai_rule.dart'; +import '../helpers/test_objects.dart'; + +/// In-memory реализация IPrefilledTransactionRepository для тестов +class FakePrefilledTransactionRepository implements IPrefilledTransactionRepository { + final Map _transactions = {}; + + @override + Future> getAll() async { + return _transactions.values.toList(); + } + + @override + Future add(PrefilledTransaction transaction) async { + _transactions[transaction.smsMessageId] = transaction; + } + + @override + Future update(PrefilledTransaction transaction) async { + _transactions[transaction.smsMessageId] = transaction; + } + + @override + Future delete(String transactionId) async { + _transactions.removeWhere((key, value) => value.smsMessageId == transactionId); + } + + /// Дополнительные методы для тестирования + void clear() { + _transactions.clear(); + } + + int get count => _transactions.length; + + PrefilledTransaction? findBySmsId(String smsMessageId) { + return _transactions[smsMessageId]; + } + + List findByTransactionId(String? transactionId) { + return _transactions.values + .where((t) => t.transactionId == transactionId) + .toList(); + } +} + +/// In-memory реализация ICategoryRepository для тестов +class FakeCategoryRepository implements ICategoryRepository { + final Map _categories = {}; + + FakeCategoryRepository() { + // Добавляем стандартные тестовые категории + for (final category in TestObjects.testCategories) { + _categories[category.id] = category; + } + } + + @override + Future> getAll() async { + return _categories.values.toList(); + } + + @override + Future add(Category category) async { + _categories[category.id] = category; + } + + @override + Future update(Category category) async { + _categories[category.id] = category; + } + + @override + Future delete(String categoryId) async { + _categories.remove(categoryId); + } + + @override + Future getById(String id) async { + return _categories[id]; + } + + @override + Future> getIncomeCategories() async { + return _categories.values.where((cat) => cat.isIncome).toList(); + } + + @override + Future> getExpenseCategories() async { + return _categories.values.where((cat) => !cat.isIncome).toList(); + } + + @override + Future addAll(List categories) async { + for (final category in categories) { + _categories[category.id] = category; + } + } + + /// Дополнительные методы для тестирования + void clear() { + _categories.clear(); + } + + int get count => _categories.length; + + void addTestCategories() { + for (final category in TestObjects.testCategories) { + _categories[category.id] = category; + } + } + + Category? findByName(String name) { + return _categories.values + .where((c) => c.name.toLowerCase().contains(name.toLowerCase())) + .firstOrNull; + } +} + +/// In-memory реализация IAiRuleRepository для тестов +class FakeAiRuleRepository implements IAiRuleRepository { + final Map _rules = {}; + + @override + Future> getAll() async { + return _rules.values.toList(); + } + + @override + Future add(AiRule rule) async { + _rules[rule.id] = rule; + } + + @override + Future update(AiRule rule) async { + _rules[rule.id] = rule; + } + + @override + Future delete(String ruleId) async { + _rules.remove(ruleId); + } + + @override + Future getById(String id) async { + return _rules[id]; + } + + @override + Future> getByType(AiRuleType type) async { + return _rules.values.where((rule) => rule.type == type).toList(); + } + + @override + Future> getActiveRules() async { + return _rules.values.where((rule) => rule.isActive).toList(); + } + + @override + Future> getByStatus(ProcessingStatus status) async { + return _rules.values.where((rule) => rule.processingStatus == status).toList(); + } + + @override + Future> getByPriority() async { + final rulesList = _rules.values.toList(); + rulesList.sort((a, b) => b.confidencePercentage.compareTo(a.confidencePercentage)); + return rulesList; + } + + @override + Future addAll(List rules) async { + for (final rule in rules) { + _rules[rule.id] = rule; + } + } + + @override + Future updateStatus(String id, ProcessingStatus status) async { + final rule = _rules[id]; + if (rule != null) { + _rules[id] = rule.copyWith(processingStatus: status); + } + } + + @override + Future toggleActive(String id) async { + final rule = _rules[id]; + if (rule != null) { + _rules[id] = rule.copyWith(isActive: !rule.isActive); + } + } + + /// Дополнительные методы для тестирования + void clear() { + _rules.clear(); + } + + int get count => _rules.length; + + void addTestRules() { + for (final rule in TestObjects.testAllRules) { + _rules[rule.id] = rule; + } + } + + List findByName(String name) { + return _rules.values + .where((r) => r.name.toLowerCase().contains(name.toLowerCase())) + .toList(); + } + + List getPointOfSaleRules() { + return _rules.values + .where((rule) => rule.type == AiRuleType.pointOfSale) + .toList(); + } + + List getSkipRules() { + return _rules.values + .where((rule) => rule.type == AiRuleType.skipTemplate) + .toList(); + } +} + +/// Композитный класс для удобного управления всеми фейковыми репозиториями +class FakeRepositories { + final FakePrefilledTransactionRepository prefilledTransactionRepository; + final FakeCategoryRepository categoryRepository; + final FakeAiRuleRepository aiRuleRepository; + + FakeRepositories() + : prefilledTransactionRepository = FakePrefilledTransactionRepository(), + categoryRepository = FakeCategoryRepository(), + aiRuleRepository = FakeAiRuleRepository(); + + /// Очистить все репозитории + void clearAll() { + prefilledTransactionRepository.clear(); + categoryRepository.clear(); + aiRuleRepository.clear(); + } + + /// Загрузить тестовые данные во все репозитории + void loadTestData() { + categoryRepository.addTestCategories(); + aiRuleRepository.addTestRules(); + + // Добавляем несколько тестовых prefilled транзакций + for (final transaction in TestObjects.testTransactionPrefilledList) { + prefilledTransactionRepository.add(transaction); + } + for (final transaction in TestObjects.testNonTransactionPrefilledList) { + prefilledTransactionRepository.add(transaction); + } + } + + /// Получить общую статистику + Map getStats() { + return { + 'categories': categoryRepository.count, + 'prefilledTransactions': prefilledTransactionRepository.count, + 'aiRules': aiRuleRepository.count, + }; + } +} \ No newline at end of file diff --git a/test/mocks/mocks.dart b/test/mocks/mocks.dart new file mode 100644 index 0000000..f2af829 --- /dev/null +++ b/test/mocks/mocks.dart @@ -0,0 +1,17 @@ +import 'package:mockito/annotations.dart'; +import 'package:budget_app/services/interfaces/iai_service.dart'; +import 'package:budget_app/data/repositories/interfaces/iprefilled_transaction_repository.dart'; +import 'package:budget_app/data/repositories/interfaces/icategory_repository.dart'; +import 'package:budget_app/data/repositories/interfaces/iai_rule_repository.dart'; + +// Генерируем моки для интерфейсов +@GenerateMocks([ + IAiService, + IPrefilledTransactionRepository, + ICategoryRepository, + IAiRuleRepository, +]) +void main() {} + +// После добавления этого файла нужно запустить: +// flutter packages pub run build_runner build \ No newline at end of file diff --git a/test/mocks/mocks.mocks.dart b/test/mocks/mocks.mocks.dart new file mode 100644 index 0000000..1254a59 --- /dev/null +++ b/test/mocks/mocks.mocks.dart @@ -0,0 +1,349 @@ +// Mocks generated by Mockito 5.4.6 from annotations +// in budget_app/test/mocks/mocks.dart. +// Do not manually edit this file. + +// ignore_for_file: no_leading_underscores_for_library_prefixes +import 'dart:async' as _i3; + +import 'package:budget_app/data/repositories/interfaces/iai_rule_repository.dart' + as _i9; +import 'package:budget_app/data/repositories/interfaces/icategory_repository.dart' + as _i7; +import 'package:budget_app/data/repositories/interfaces/iprefilled_transaction_repository.dart' + as _i5; +import 'package:budget_app/models/ai_rule.dart' as _i10; +import 'package:budget_app/models/category.dart' as _i8; +import 'package:budget_app/models/prefilled_transaction.dart' as _i6; +import 'package:budget_app/services/interfaces/iai_service.dart' as _i2; +import 'package:mockito/mockito.dart' as _i1; +import 'package:mockito/src/dummies.dart' as _i4; + +// ignore_for_file: type=lint +// ignore_for_file: avoid_redundant_argument_values +// ignore_for_file: avoid_setters_without_getters +// ignore_for_file: comment_references +// ignore_for_file: deprecated_member_use +// ignore_for_file: deprecated_member_use_from_same_package +// ignore_for_file: implementation_imports +// ignore_for_file: invalid_use_of_visible_for_testing_member +// ignore_for_file: must_be_immutable +// ignore_for_file: prefer_const_constructors +// ignore_for_file: unnecessary_parenthesis +// ignore_for_file: camel_case_types +// ignore_for_file: subtype_of_sealed_class + +/// A class which mocks [IAiService]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockIAiService extends _i1.Mock implements _i2.IAiService { + MockIAiService() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future sendMessage(String? message) => + (super.noSuchMethod( + Invocation.method(#sendMessage, [message]), + returnValue: _i3.Future.value( + _i4.dummyValue( + this, + Invocation.method(#sendMessage, [message]), + ), + ), + ) + as _i3.Future); + + @override + _i3.Future sendMessageWithParams({ + required String? message, + String? model, + double? temperature, + int? maxTokens, + }) => + (super.noSuchMethod( + Invocation.method(#sendMessageWithParams, [], { + #message: message, + #model: model, + #temperature: temperature, + #maxTokens: maxTokens, + }), + returnValue: _i3.Future.value( + _i4.dummyValue( + this, + Invocation.method(#sendMessageWithParams, [], { + #message: message, + #model: model, + #temperature: temperature, + #maxTokens: maxTokens, + }), + ), + ), + ) + as _i3.Future); + + @override + _i3.Future checkHealth() => + (super.noSuchMethod( + Invocation.method(#checkHealth, []), + returnValue: _i3.Future.value(false), + ) + as _i3.Future); + + @override + _i3.Future> getAvailableModels() => + (super.noSuchMethod( + Invocation.method(#getAvailableModels, []), + returnValue: _i3.Future>.value([]), + ) + as _i3.Future>); + + @override + void setApiKey(String? apiKey) => super.noSuchMethod( + Invocation.method(#setApiKey, [apiKey]), + returnValueForMissingStub: null, + ); + + @override + bool isConfigured() => + (super.noSuchMethod( + Invocation.method(#isConfigured, []), + returnValue: false, + ) + as bool); +} + +/// A class which mocks [IPrefilledTransactionRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockIPrefilledTransactionRepository extends _i1.Mock + implements _i5.IPrefilledTransactionRepository { + MockIPrefilledTransactionRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future> getAll() => + (super.noSuchMethod( + Invocation.method(#getAll, []), + returnValue: _i3.Future>.value( + <_i6.PrefilledTransaction>[], + ), + ) + as _i3.Future>); + + @override + _i3.Future add(_i6.PrefilledTransaction? transaction) => + (super.noSuchMethod( + Invocation.method(#add, [transaction]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future update(_i6.PrefilledTransaction? transaction) => + (super.noSuchMethod( + Invocation.method(#update, [transaction]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future delete(String? transactionId) => + (super.noSuchMethod( + Invocation.method(#delete, [transactionId]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); +} + +/// A class which mocks [ICategoryRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockICategoryRepository extends _i1.Mock + implements _i7.ICategoryRepository { + MockICategoryRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future> getAll() => + (super.noSuchMethod( + Invocation.method(#getAll, []), + returnValue: _i3.Future>.value(<_i8.Category>[]), + ) + as _i3.Future>); + + @override + _i3.Future<_i8.Category?> getById(String? id) => + (super.noSuchMethod( + Invocation.method(#getById, [id]), + returnValue: _i3.Future<_i8.Category?>.value(), + ) + as _i3.Future<_i8.Category?>); + + @override + _i3.Future add(_i8.Category? category) => + (super.noSuchMethod( + Invocation.method(#add, [category]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future update(_i8.Category? category) => + (super.noSuchMethod( + Invocation.method(#update, [category]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future delete(String? id) => + (super.noSuchMethod( + Invocation.method(#delete, [id]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future> getIncomeCategories() => + (super.noSuchMethod( + Invocation.method(#getIncomeCategories, []), + returnValue: _i3.Future>.value(<_i8.Category>[]), + ) + as _i3.Future>); + + @override + _i3.Future> getExpenseCategories() => + (super.noSuchMethod( + Invocation.method(#getExpenseCategories, []), + returnValue: _i3.Future>.value(<_i8.Category>[]), + ) + as _i3.Future>); + + @override + _i3.Future addAll(List<_i8.Category>? categories) => + (super.noSuchMethod( + Invocation.method(#addAll, [categories]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); +} + +/// A class which mocks [IAiRuleRepository]. +/// +/// See the documentation for Mockito's code generation for more information. +class MockIAiRuleRepository extends _i1.Mock implements _i9.IAiRuleRepository { + MockIAiRuleRepository() { + _i1.throwOnMissingStub(this); + } + + @override + _i3.Future> getAll() => + (super.noSuchMethod( + Invocation.method(#getAll, []), + returnValue: _i3.Future>.value(<_i10.AiRule>[]), + ) + as _i3.Future>); + + @override + _i3.Future<_i10.AiRule?> getById(String? id) => + (super.noSuchMethod( + Invocation.method(#getById, [id]), + returnValue: _i3.Future<_i10.AiRule?>.value(), + ) + as _i3.Future<_i10.AiRule?>); + + @override + _i3.Future add(_i10.AiRule? rule) => + (super.noSuchMethod( + Invocation.method(#add, [rule]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future update(_i10.AiRule? rule) => + (super.noSuchMethod( + Invocation.method(#update, [rule]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future delete(String? id) => + (super.noSuchMethod( + Invocation.method(#delete, [id]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future> getByType(_i10.AiRuleType? type) => + (super.noSuchMethod( + Invocation.method(#getByType, [type]), + returnValue: _i3.Future>.value(<_i10.AiRule>[]), + ) + as _i3.Future>); + + @override + _i3.Future> getActiveRules() => + (super.noSuchMethod( + Invocation.method(#getActiveRules, []), + returnValue: _i3.Future>.value(<_i10.AiRule>[]), + ) + as _i3.Future>); + + @override + _i3.Future> getByStatus(_i10.ProcessingStatus? status) => + (super.noSuchMethod( + Invocation.method(#getByStatus, [status]), + returnValue: _i3.Future>.value(<_i10.AiRule>[]), + ) + as _i3.Future>); + + @override + _i3.Future> getByPriority() => + (super.noSuchMethod( + Invocation.method(#getByPriority, []), + returnValue: _i3.Future>.value(<_i10.AiRule>[]), + ) + as _i3.Future>); + + @override + _i3.Future addAll(List<_i10.AiRule>? rules) => + (super.noSuchMethod( + Invocation.method(#addAll, [rules]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future updateStatus(String? id, _i10.ProcessingStatus? status) => + (super.noSuchMethod( + Invocation.method(#updateStatus, [id, status]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); + + @override + _i3.Future toggleActive(String? id) => + (super.noSuchMethod( + Invocation.method(#toggleActive, [id]), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) + as _i3.Future); +} diff --git a/test/services/ai_transaction_processing_service_test.dart b/test/services/ai_transaction_processing_service_test.dart new file mode 100644 index 0000000..8ceac8c --- /dev/null +++ b/test/services/ai_transaction_processing_service_test.dart @@ -0,0 +1,563 @@ +import 'package:flutter_test/flutter_test.dart'; +import 'package:mockito/mockito.dart'; +import 'package:budget_app/services/ai_transaction_processing_service.dart'; +import 'package:budget_app/models/prefilled_transaction.dart'; +import 'package:budget_app/models/ai_rule.dart'; + +import '../mocks/mocks.mocks.dart'; +import '../mocks/fake_ai_service.dart'; +import '../mocks/fake_repositories.dart'; +import '../helpers/test_data.dart'; +import '../helpers/ai_responses.dart'; +import '../helpers/test_objects.dart'; + +void main() { + group('AiTransactionProcessingService', () { + late AiTransactionProcessingService service; + late MockIAiService mockAiService; + late FakeRepositories fakeRepositories; + + setUp(() { + mockAiService = MockIAiService(); + fakeRepositories = FakeRepositories(); + + service = AiTransactionProcessingService( + aiService: mockAiService, + prefilledRepository: fakeRepositories.prefilledTransactionRepository, + categoryRepository: fakeRepositories.categoryRepository, + aiRuleRepository: fakeRepositories.aiRuleRepository, + ); + + fakeRepositories.loadTestData(); + }); + + tearDown(() { + fakeRepositories.clearAll(); + }); + + group('_buildAiPrompt', () { + test('should build correct prompt for SMS analysis', () { + final prompt = service.buildAiPromptForTesting(TestSmsData.bankTransactionSms); + + expect(prompt, contains('Проанализируй SMS сообщение')); + expect(prompt, contains(TestSmsData.bankTransactionSms)); + expect(prompt, contains('\"isTransaction\": true')); + expect(prompt, contains('\"isTransaction\": false')); + expect(prompt, contains('exclusionRegex')); + expect(prompt, contains('ВАЖНО: exclusionRegex нужен только для НЕ-транзакционных')); + }); + }); + + group('processSmsByAi - Transaction SMS', () { + test('should process transaction SMS successfully', () async { + // Arrange + when(mockAiService.isConfigured()).thenReturn(true); + when(mockAiService.sendMessage(any)) + .thenAnswer((_) async => TestAiResponses.bankTransactionResponse); + + // Act + final result = await service.processSmsByAi( + TestSmsData.bankTransactionSms, + 'test_sms_001', + ); + + // Assert + expect(result, isNotNull, reason: 'Should process bank transaction SMS'); + if (result != null) { + expect(result.smsMessageId, equals('test_sms_001')); + expect(result.amount, equals(-1250.0)); + expect(result.salesPoint, equals('METRO CASH & CARRY')); + expect(result.exclusionRegex, isNull); + expect(result.confidence, equals(95.0)); + expect(result.calculatedCategory, isNotNull); + expect(result.calculatedCategory!.name, contains('Покупки')); + + // Verify it was saved to repository + final saved = fakeRepositories.prefilledTransactionRepository + .findBySmsId('test_sms_001'); + expect(saved, isNotNull); + } + }); + + test('should handle AI service not configured', () async { + // Arrange + when(mockAiService.isConfigured()).thenReturn(false); + + // Act + final result = await service.processSmsByAi( + TestSmsData.bankTransactionSms, + 'test_sms_002', + ); + + // Assert + expect(result, isNull); + verify(mockAiService.isConfigured()); + verifyNever(mockAiService.sendMessage(any)); + }); + + test('should handle invalid transaction data from AI', () async { + // Arrange + when(mockAiService.isConfigured()).thenReturn(true); + when(mockAiService.sendMessage(any)) + .thenAnswer((_) async => TestAiResponses.incompleteTransactionResponse); + + // Act + final result = await service.processSmsByAi( + TestSmsData.bankTransactionSms, + 'test_sms_003', + ); + + // Assert + expect(result, isNull); + }); + + test('should handle AI service exception', () async { + // Arrange + when(mockAiService.isConfigured()).thenReturn(true); + when(mockAiService.sendMessage(any)) + .thenThrow(Exception('AI service error')); + + // Act + final result = await service.processSmsByAi( + TestSmsData.bankTransactionSms, + 'test_sms_004', + ); + + // Assert + expect(result, isNull); + }); + }); + + group('processSmsByAi - Non-Transaction SMS', () { + test('should process non-transaction SMS successfully', () async { + // Arrange + when(mockAiService.isConfigured()).thenReturn(true); + when(mockAiService.sendMessage(any)) + .thenAnswer((_) async => TestAiResponses.bankAdvertisingResponse); + + // Act + final result = await service.processSmsByAi( + TestSmsData.bankAdvertisingSms, + 'test_spam_001', + ); + + // Assert + expect(result, isNotNull); + expect(result!.smsMessageId, equals('test_spam_001')); + expect(result.amount, isNull); + expect(result.salesPoint, isNull); + expect(result.calculatedCategory, isNull); + expect(result.exclusionRegex, isNotNull); + expect(result.exclusionRegex, contains('Специальное предложение')); + expect(result.confidence, equals(85.0)); + + // Verify it was saved to repository + final saved = fakeRepositories.prefilledTransactionRepository + .findBySmsId('test_spam_001'); + expect(saved, isNotNull); + }); + + test('should handle invalid non-transaction data from AI', () async { + // Arrange + when(mockAiService.isConfigured()).thenReturn(true); + when(mockAiService.sendMessage(any)) + .thenAnswer((_) async => TestAiResponses.incompleteNonTransactionResponse); + + // Act + final result = await service.processSmsByAi( + TestSmsData.bankAdvertisingSms, + 'test_spam_002', + ); + + // Assert + expect(result, isNull); + }); + }); + + group('_validateTransactionData', () { + test('should validate correct transaction data', () { + final data = { + 'isTransaction': true, + 'amount': -1250.0, + 'vendor': 'METRO CASH & CARRY', + 'confidence': 0.95, + 'suggestedCategory': 'shopping', + 'exclusionRegex': null, + }; + + final result = service.validateTransactionDataForTesting(data); + expect(result, isTrue); + }); + + test('should reject transaction data with missing amount', () { + final data = { + 'isTransaction': true, + 'vendor': 'METRO CASH & CARRY', + 'confidence': 0.95, + }; + + final result = service.validateTransactionDataForTesting(data); + expect(result, isFalse); + }); + + test('should reject transaction data with missing vendor', () { + final data = { + 'isTransaction': true, + 'amount': -1250.0, + 'confidence': 0.95, + }; + + final result = service.validateTransactionDataForTesting(data); + expect(result, isFalse); + }); + + test('should reject transaction data with exclusionRegex', () { + final data = { + 'isTransaction': true, + 'amount': -1250.0, + 'vendor': 'METRO CASH & CARRY', + 'confidence': 0.95, + 'exclusionRegex': 'some regex', + }; + + final result = service.validateTransactionDataForTesting(data); + expect(result, isFalse); + }); + + test('should reject transaction data with zero amount', () { + final data = { + 'isTransaction': true, + 'amount': 0.0, + 'vendor': 'METRO CASH & CARRY', + 'confidence': 0.95, + }; + + final result = service.validateTransactionDataForTesting(data); + expect(result, isFalse); + }); + + test('should reject transaction data with invalid confidence', () { + final data = { + 'isTransaction': true, + 'amount': -1250.0, + 'vendor': 'METRO CASH & CARRY', + 'confidence': 1.5, // Invalid: > 1 + }; + + final result = service.validateTransactionDataForTesting(data); + expect(result, isFalse); + }); + }); + + group('_validateNonTransactionData', () { + test('should validate correct non-transaction data', () { + final data = { + 'isTransaction': false, + 'amount': null, + 'vendor': null, + 'confidence': 0.85, + 'exclusionRegex': '.*spam.*', + }; + + final result = service.validateNonTransactionDataForTesting(data); + expect(result, isTrue); + }); + + test('should reject non-transaction data with missing exclusionRegex', () { + final data = { + 'isTransaction': false, + 'amount': null, + 'vendor': null, + 'confidence': 0.85, + }; + + final result = service.validateNonTransactionDataForTesting(data); + expect(result, isFalse); + }); + + test('should reject non-transaction data with amount', () { + final data = { + 'isTransaction': false, + 'amount': -100.0, + 'vendor': null, + 'confidence': 0.85, + 'exclusionRegex': '.*spam.*', + }; + + final result = service.validateNonTransactionDataForTesting(data); + expect(result, isFalse); + }); + + test('should reject non-transaction data with vendor', () { + final data = { + 'isTransaction': false, + 'amount': null, + 'vendor': 'Some Vendor', + 'confidence': 0.85, + 'exclusionRegex': '.*spam.*', + }; + + final result = service.validateNonTransactionDataForTesting(data); + expect(result, isFalse); + }); + }); + + group('createAiRuleFromPrefilled', () { + test('should create pointOfSale rule from transaction prefilled', () async { + // Arrange + final prefilled = TestObjects.metroTransaction; + + // Act + final result = await service.createAiRuleFromPrefilled( + prefilled, + AiRuleType.pointOfSale, + ); + + // Assert + expect(result, isNotNull); + expect(result!.type, equals(AiRuleType.pointOfSale)); + expect(result.name, contains('METRO CASH & CARRY')); + expect(result.merchantPattern, contains('METRO CASH & CARRY')); + expect(result.categoryId, equals(TestObjects.shoppingCategory.id)); + expect(result.confidencePercentage, equals(95)); + + // Verify it was saved to repository + final saved = fakeRepositories.aiRuleRepository.findByName('METRO'); + expect(saved.isNotEmpty, isTrue); + }); + + test('should create skipTemplate rule from non-transaction prefilled', () async { + // Arrange + final prefilled = TestObjects.bankAdvertising; + + // Act + final result = await service.createAiRuleFromPrefilled( + prefilled, + AiRuleType.skipTemplate, + ); + + // Assert + expect(result, isNotNull); + expect(result!.type, equals(AiRuleType.skipTemplate)); + expect(result.name, contains('Автопропуск')); + expect(result.skipRegex, equals(prefilled.exclusionRegex)); + expect(result.confidencePercentage, equals(85)); + + // Verify it was saved to repository + final saved = fakeRepositories.aiRuleRepository.getSkipRules(); + expect(saved.length, greaterThan(2)); // Существующие + новое правило + }); + + test('should fail to create pointOfSale rule without required data', () async { + // Arrange + final prefilled = TestObjects.bankAdvertising; // Не имеет salesPoint + + // Act + final result = await service.createAiRuleFromPrefilled( + prefilled, + AiRuleType.pointOfSale, + ); + + // Assert + expect(result, isNull); + }); + + test('should fail to create skipTemplate rule without exclusionRegex', () async { + // Arrange + final prefilled = TestObjects.createTransactionPrefilled( + exclusionRegex: null, // Нет exclusionRegex + ); + + // Act + final result = await service.createAiRuleFromPrefilled( + prefilled, + AiRuleType.skipTemplate, + ); + + // Assert + expect(result, isNull); + }); + + test('should create skipTemplate rule with custom name', () async { + // Arrange + final prefilled = TestObjects.bankAdvertising; + const customName = 'Мое кастомное правило пропуска'; + + // Act + final result = await service.createAiRuleFromPrefilled( + prefilled, + AiRuleType.skipTemplate, + customName: customName, + ); + + // Assert + expect(result, isNotNull); + expect(result!.name, equals(customName)); + }); + }); + + group('convertToTransactionRecord', () { + test('should convert prefilled transaction to transaction record', () async { + // Arrange + final prefilled = TestObjects.metroTransaction; + + // Act + final result = await service.convertToTransactionRecord(prefilled); + + // Assert + expect(result.amount, equals(prefilled.amount)); + expect(result.vendor, equals(prefilled.salesPoint)); + expect(result.categoryId, equals(prefilled.calculatedCategory!.id)); + expect(result.currency, equals('RUB')); + + // Verify prefilled was updated with transaction ID + expect(prefilled.transactionId, equals(result.id)); + }); + + test('should fail to convert prefilled without required fields', () async { + // Arrange + final prefilled = TestObjects.createTransactionPrefilled( + amount: null, // Missing amount + ); + + // Act & Assert + expect( + () => service.convertToTransactionRecord(prefilled), + throwsA(isA()), + ); + }); + + test('should use provided categoryId over calculated category', () async { + // Arrange + final prefilled = TestObjects.metroTransaction; + const providedCategoryId = 'custom_category_id'; + + // Act + final result = await service.convertToTransactionRecord( + prefilled, + categoryId: providedCategoryId, + ); + + // Assert + expect(result.categoryId, equals(providedCategoryId)); + }); + }); + + group('Edge Cases and Error Handling', () { + test('should handle empty SMS message', () async { + // Arrange + when(mockAiService.isConfigured()).thenReturn(true); + + // Act + final result = await service.processSmsByAi('', 'empty_sms'); + + // Assert + expect(result, isNull); + verifyNever(mockAiService.sendMessage(any)); + }); + + test('should handle empty SMS message ID', () async { + // Arrange + when(mockAiService.isConfigured()).thenReturn(true); + + // Act + final result = await service.processSmsByAi( + TestSmsData.bankTransactionSms, + '', + ); + + // Assert + expect(result, isNull); + verifyNever(mockAiService.sendMessage(any)); + }); + + test('should handle invalid JSON response from AI', () async { + // Arrange + when(mockAiService.isConfigured()).thenReturn(true); + when(mockAiService.sendMessage(any)) + .thenAnswer((_) async => TestAiResponses.invalidJsonResponse); + + // Act + final result = await service.processSmsByAi( + TestSmsData.bankTransactionSms, + 'invalid_json_test', + ); + + // Assert + expect(result, isNull); + }); + + test('should handle empty response from AI', () async { + // Arrange + when(mockAiService.isConfigured()).thenReturn(true); + when(mockAiService.sendMessage(any)) + .thenAnswer((_) async => ''); + + // Act + final result = await service.processSmsByAi( + TestSmsData.bankTransactionSms, + 'empty_response_test', + ); + + // Assert + expect(result, isNull); + }); + + test('should handle non-JSON response from AI', () async { + // Arrange + when(mockAiService.isConfigured()).thenReturn(true); + when(mockAiService.sendMessage(any)) + .thenAnswer((_) async => TestAiResponses.nonJsonResponse); + + // Act + final result = await service.processSmsByAi( + TestSmsData.bankTransactionSms, + 'non_json_test', + ); + + // Assert + expect(result, isNull); + }); + }); + + group('Additional validation and parsing tests', () { + test('should parse valid AI response correctly', () { + final response = TestAiResponses.bankTransactionResponse; + final result = service.parseAiResponseForTesting(response); + + expect(result, isNotNull); + expect(result!['isTransaction'], isTrue); + expect(result['amount'], equals(-1250.0)); + expect(result['vendor'], equals('METRO CASH & CARRY')); + }); + + test('should handle malformed JSON in AI response', () { + final response = TestAiResponses.invalidJsonResponse; + final result = service.parseAiResponseForTesting(response); + + expect(result, isNull); + }); + + test('should create correct merchant pattern', () { + const salesPoint = 'METRO CASH & CARRY'; + final pattern = service.createMerchantPatternForTesting(salesPoint); + + expect(pattern, contains('METRO CASH & CARRY')); + expect(pattern, startsWith('.*')); + expect(pattern, endsWith('.*')); + }); + + test('should escape special regex characters in merchant pattern', () { + const salesPoint = 'Test (Store) [Bracket] +Plus'; + final pattern = service.createMerchantPatternForTesting(salesPoint); + + // Проверяем что специальные символы экранированы + expect(pattern, contains('\\(')); + expect(pattern, contains('\\)')); + expect(pattern, contains('\\[')); + expect(pattern, contains('\\]')); + expect(pattern, contains('\\+')); + }); + }); + }); +} \ No newline at end of file diff --git a/test/test_config.dart b/test/test_config.dart new file mode 100644 index 0000000..03369c4 --- /dev/null +++ b/test/test_config.dart @@ -0,0 +1,54 @@ +import 'package:flutter_dotenv/flutter_dotenv.dart'; +import 'package:budget_app/models/ai_settings.dart'; + +/// Конфигурация для тестовой среды +class TestConfig { + static bool _initialized = false; + + /// Инициализация тестовой среды + static Future initialize() async { + if (_initialized) return; + + try { + await dotenv.load(fileName: '.env'); + } catch (e) { + // .env файл не найден, используем значения по умолчанию + print('Warning: .env file not found, using default test configuration'); + } + + _initialized = true; + } + + /// Получить настройки AI для тестов + static AiSettings getTestAiSettings() { + final apiKey = dotenv.env['OPENROUTER_API_KEY']; + final timeout = int.tryParse(dotenv.env['TEST_AI_TIMEOUT_SECONDS'] ?? '30') ?? 30; + + return AiSettings( + apiKey: apiKey, + baseUrl: 'https://openrouter.ai/api/v1', + defaultModel: 'openai/gpt-3.5-turbo', + timeoutSeconds: timeout, + temperature: 0.1, // Низкая температура для более предсказуемых результатов + maxTokens: 500, + isEnabled: apiKey != null && apiKey.isNotEmpty, + ); + } + + /// Проверить, должны ли запускаться интеграционные тесты + static bool shouldRunIntegrationTests() { + final runTests = dotenv.env['RUN_INTEGRATION_TESTS']?.toLowerCase(); + return runTests == 'true' || runTests == '1'; + } + + /// Проверить, настроен ли AI для тестов + static bool isAiConfigured() { + final apiKey = dotenv.env['OPENROUTER_API_KEY']; + return apiKey != null && apiKey.isNotEmpty; + } + + /// Получить тестовый API ключ + static String? getTestApiKey() { + return dotenv.env['OPENROUTER_API_KEY']; + } +} \ No newline at end of file