This commit is contained in:
2025-07-21 00:23:59 +03:00
parent dd468c3246
commit ea9c31b782
21 changed files with 3089 additions and 17 deletions
+2 -1
View File
@@ -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": []
}
+14
View File
@@ -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
@@ -25,7 +25,8 @@ class HivePrefilledTransactionRepository
@override
Future<void> 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<void> update(PrefilledTransaction transaction) async {
try {
// Используем smsMessageId как ключ, поскольку transactionId может быть null
await _box.put(transaction.smsMessageId, transaction);
} catch (e) {
throw Exception(
'Ошибка обновления предварительно заполненной транзакции: $e',
);
}
}
@override
Future<void> delete(String transactionId) async {
try {
@@ -8,6 +8,9 @@ abstract class IPrefilledTransactionRepository {
/// Добавляет новую предварительно заполненную транзакцию.
Future<void> add(PrefilledTransaction transaction);
/// Обновляет существующую предварительно заполненную транзакцию.
Future<void> update(PrefilledTransaction transaction);
/// Удаляет предварительно заполненную транзакцию по ее ID.
Future<void> delete(String transactionId);
}
+17
View File
@@ -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<void> initUserSpecificDependencies(String userId) async {
return OpenRouterAiService(aiSettings);
});
// AI Transaction Processing Service
if (getIt.isRegistered<AiTransactionProcessingService>()) {
await getIt.unregister<AiTransactionProcessingService>();
}
getIt.registerLazySingleton<AiTransactionProcessingService>(() =>
AiTransactionProcessingService(
aiService: getIt<IAiService>(),
prefilledRepository: getIt<IPrefilledTransactionRepository>(),
categoryRepository: getIt<ICategoryRepository>(),
aiRuleRepository: getIt<IAiRuleRepository>(),
)
);
// --- Обновление UserCubit новыми репозиториями ---
// Мы не пересоздаем UserCubit, а просто обновляем его зависимости.
final userCubit = getIt<UserCubit>();
@@ -290,4 +304,7 @@ Future<void> resetUserSpecificDependencies() async {
if (getIt.isRegistered<IAiService>()) {
await getIt.unregister<IAiService>();
}
if (getIt.isRegistered<AiTransactionProcessingService>()) {
await getIt.unregister<AiTransactionProcessingService>();
}
}
@@ -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<PrefilledTransaction?> 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<String, dynamic>? _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<String, dynamic>;
} catch (e) {
_logger.e('Ошибка парсинга ответа ИИ: $e, ответ: $aiResponse');
return null;
}
}
/// Создает PrefilledTransaction из ответа ИИ
Future<PrefilledTransaction> _createPrefilledFromAiResponse(
Map<String, dynamic> 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<TransactionRecord> 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<AiRule?> 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<String, dynamic> 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<String, dynamic> 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<String, dynamic> aiData) {
return _validateTransactionData(aiData);
}
/// Публичный метод для тестирования _validateNonTransactionData
bool validateNonTransactionDataForTesting(Map<String, dynamic> aiData) {
return _validateNonTransactionData(aiData);
}
/// Публичный метод для тестирования _parseAiResponse
Map<String, dynamic>? parseAiResponseForTesting(String aiResponse) {
return _parseAiResponse(aiResponse);
}
/// Публичный метод для тестирования _createMerchantPattern
String createMerchantPatternForTesting(String salesPoint) {
return _createMerchantPattern(salesPoint);
}
}
+40 -15
View File
@@ -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<TransactionRecord?> Function(String smsBody);
@@ -33,23 +36,45 @@ class CustomSmsFunctions {
// --- Реализации кастомных функций ---
/// Обработчик для функции "Запрос к ИИ".
/// Это заглушка, которая в будущем будет анализировать текст СМС с помощью ИИ.
/// Анализирует текст СМС с помощью ИИ и создает транзакцию.
static Future<TransactionRecord?> _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<AiTransactionProcessingService>();
// Создаем уникальный 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;
}
}
}
+16
View File
@@ -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:
+2
View File
@@ -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
+156
View File
@@ -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 окружении интеграционные тесты могут быть отключены
+148
View File
@@ -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<String, dynamic> 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 = <String>[];
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(', ')}');
}
}
}
+203
View File
@@ -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<String, String> 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<String> validTransactionResponses = [
bankTransactionResponse,
atmWithdrawalResponse,
onlinePaymentResponse,
incomeTransferResponse,
];
// Список всех корректных ответов для не-транзакций
static final List<String> validNonTransactionResponses = [
bankAdvertisingResponse,
promotionalResponse,
securityCodeResponse,
cardBlockedResponse,
spamResponse,
];
// Список некорректных ответов для тестирования обработки ошибок
static final List<String> invalidResponses = [
invalidJsonResponse,
incompleteTransactionResponse,
incompleteNonTransactionResponse,
wrongTypeResponse,
emptyResponse,
nonJsonResponse,
];
}
+125
View File
@@ -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<String> allTransactionSms = [
bankTransactionSms,
atmWithdrawalSms,
onlinePaymentSms,
incomeTransferSms,
];
static final List<String> allNonTransactionSms = [
bankAdvertisingSms,
promotionalSms,
securitySms,
notificationSms,
spamSms,
];
static final List<String> allEdgeCaseSms = [
emptyMessage,
veryLongMessage,
messageWithEmojis,
foreignLanguageSms,
];
static final List<String> allSms = [
...allTransactionSms,
...allNonTransactionSms,
...allEdgeCaseSms,
];
}
+262
View File
@@ -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<Category> testCategories = [
shoppingCategory,
transportCategory,
cashCategory,
salaryCategory,
];
static final List<PrefilledTransaction> testTransactionPrefilledList = [
metroTransaction,
yandexTaxiTransaction,
salaryIncome,
];
static final List<PrefilledTransaction> testNonTransactionPrefilledList = [
bankAdvertising,
megafonPromo,
];
static final List<AiRule> testPointOfSaleRules = [
metroRule,
taxiRule,
];
static final List<AiRule> testSkipRules = [
bankAdSkipRule,
promoSkipRule,
];
static final List<AiRule> testAllRules = [
...testPointOfSaleRules,
...testSkipRules,
];
}
@@ -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 = <String, dynamic>{};
// 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 = <Future>[];
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}');
}
}
+182
View File
@@ -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<String, String> _responses = {};
bool _isConfigured = true;
String? _apiKey = 'fake_api_key';
bool _shouldThrow = false;
String? _exceptionMessage;
/// Конструктор с опциональными предустановленными ответами
FakeAiService({Map<String, String>? 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<String> 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<String> sendMessageWithParams({
required String message,
String? model,
double? temperature,
int? maxTokens,
}) async {
// Для тестов параметры игнорируем, просто вызываем sendMessage
return sendMessage(message);
}
@override
Future<bool> checkHealth() async {
if (_shouldThrow) {
throw Exception(_exceptionMessage ?? 'Fake health check error');
}
return _isConfigured;
}
@override
Future<List<String>> 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<String> sendMessage(String message) async {
_callCount++;
return TestAiResponses.invalidJsonResponse;
}
}
/// Сервис с таймаутами
class TimeoutAiService extends FakeAiService {
final Duration timeout;
TimeoutAiService({this.timeout = const Duration(seconds: 5)});
@override
Future<String> sendMessage(String message) async {
_callCount++;
await Future.delayed(timeout);
throw Exception('Request timeout');
}
}
/// Сервис, который не настроен
class UnconfiguredAiService extends FakeAiService {
UnconfiguredAiService() {
setConfigured(false);
}
}
+270
View File
@@ -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<String, PrefilledTransaction> _transactions = {};
@override
Future<List<PrefilledTransaction>> getAll() async {
return _transactions.values.toList();
}
@override
Future<void> add(PrefilledTransaction transaction) async {
_transactions[transaction.smsMessageId] = transaction;
}
@override
Future<void> update(PrefilledTransaction transaction) async {
_transactions[transaction.smsMessageId] = transaction;
}
@override
Future<void> 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<PrefilledTransaction> findByTransactionId(String? transactionId) {
return _transactions.values
.where((t) => t.transactionId == transactionId)
.toList();
}
}
/// In-memory реализация ICategoryRepository для тестов
class FakeCategoryRepository implements ICategoryRepository {
final Map<String, Category> _categories = {};
FakeCategoryRepository() {
// Добавляем стандартные тестовые категории
for (final category in TestObjects.testCategories) {
_categories[category.id] = category;
}
}
@override
Future<List<Category>> getAll() async {
return _categories.values.toList();
}
@override
Future<void> add(Category category) async {
_categories[category.id] = category;
}
@override
Future<void> update(Category category) async {
_categories[category.id] = category;
}
@override
Future<void> delete(String categoryId) async {
_categories.remove(categoryId);
}
@override
Future<Category?> getById(String id) async {
return _categories[id];
}
@override
Future<List<Category>> getIncomeCategories() async {
return _categories.values.where((cat) => cat.isIncome).toList();
}
@override
Future<List<Category>> getExpenseCategories() async {
return _categories.values.where((cat) => !cat.isIncome).toList();
}
@override
Future<void> addAll(List<Category> 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<String, AiRule> _rules = {};
@override
Future<List<AiRule>> getAll() async {
return _rules.values.toList();
}
@override
Future<void> add(AiRule rule) async {
_rules[rule.id] = rule;
}
@override
Future<void> update(AiRule rule) async {
_rules[rule.id] = rule;
}
@override
Future<void> delete(String ruleId) async {
_rules.remove(ruleId);
}
@override
Future<AiRule?> getById(String id) async {
return _rules[id];
}
@override
Future<List<AiRule>> getByType(AiRuleType type) async {
return _rules.values.where((rule) => rule.type == type).toList();
}
@override
Future<List<AiRule>> getActiveRules() async {
return _rules.values.where((rule) => rule.isActive).toList();
}
@override
Future<List<AiRule>> getByStatus(ProcessingStatus status) async {
return _rules.values.where((rule) => rule.processingStatus == status).toList();
}
@override
Future<List<AiRule>> getByPriority() async {
final rulesList = _rules.values.toList();
rulesList.sort((a, b) => b.confidencePercentage.compareTo(a.confidencePercentage));
return rulesList;
}
@override
Future<void> addAll(List<AiRule> rules) async {
for (final rule in rules) {
_rules[rule.id] = rule;
}
}
@override
Future<void> updateStatus(String id, ProcessingStatus status) async {
final rule = _rules[id];
if (rule != null) {
_rules[id] = rule.copyWith(processingStatus: status);
}
}
@override
Future<void> 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<AiRule> findByName(String name) {
return _rules.values
.where((r) => r.name.toLowerCase().contains(name.toLowerCase()))
.toList();
}
List<AiRule> getPointOfSaleRules() {
return _rules.values
.where((rule) => rule.type == AiRuleType.pointOfSale)
.toList();
}
List<AiRule> 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<String, int> getStats() {
return {
'categories': categoryRepository.count,
'prefilledTransactions': prefilledTransactionRepository.count,
'aiRules': aiRuleRepository.count,
};
}
}
+17
View File
@@ -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
+349
View File
@@ -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<String> sendMessage(String? message) =>
(super.noSuchMethod(
Invocation.method(#sendMessage, [message]),
returnValue: _i3.Future<String>.value(
_i4.dummyValue<String>(
this,
Invocation.method(#sendMessage, [message]),
),
),
)
as _i3.Future<String>);
@override
_i3.Future<String> 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<String>.value(
_i4.dummyValue<String>(
this,
Invocation.method(#sendMessageWithParams, [], {
#message: message,
#model: model,
#temperature: temperature,
#maxTokens: maxTokens,
}),
),
),
)
as _i3.Future<String>);
@override
_i3.Future<bool> checkHealth() =>
(super.noSuchMethod(
Invocation.method(#checkHealth, []),
returnValue: _i3.Future<bool>.value(false),
)
as _i3.Future<bool>);
@override
_i3.Future<List<String>> getAvailableModels() =>
(super.noSuchMethod(
Invocation.method(#getAvailableModels, []),
returnValue: _i3.Future<List<String>>.value(<String>[]),
)
as _i3.Future<List<String>>);
@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<List<_i6.PrefilledTransaction>> getAll() =>
(super.noSuchMethod(
Invocation.method(#getAll, []),
returnValue: _i3.Future<List<_i6.PrefilledTransaction>>.value(
<_i6.PrefilledTransaction>[],
),
)
as _i3.Future<List<_i6.PrefilledTransaction>>);
@override
_i3.Future<void> add(_i6.PrefilledTransaction? transaction) =>
(super.noSuchMethod(
Invocation.method(#add, [transaction]),
returnValue: _i3.Future<void>.value(),
returnValueForMissingStub: _i3.Future<void>.value(),
)
as _i3.Future<void>);
@override
_i3.Future<void> update(_i6.PrefilledTransaction? transaction) =>
(super.noSuchMethod(
Invocation.method(#update, [transaction]),
returnValue: _i3.Future<void>.value(),
returnValueForMissingStub: _i3.Future<void>.value(),
)
as _i3.Future<void>);
@override
_i3.Future<void> delete(String? transactionId) =>
(super.noSuchMethod(
Invocation.method(#delete, [transactionId]),
returnValue: _i3.Future<void>.value(),
returnValueForMissingStub: _i3.Future<void>.value(),
)
as _i3.Future<void>);
}
/// 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<List<_i8.Category>> getAll() =>
(super.noSuchMethod(
Invocation.method(#getAll, []),
returnValue: _i3.Future<List<_i8.Category>>.value(<_i8.Category>[]),
)
as _i3.Future<List<_i8.Category>>);
@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<void> add(_i8.Category? category) =>
(super.noSuchMethod(
Invocation.method(#add, [category]),
returnValue: _i3.Future<void>.value(),
returnValueForMissingStub: _i3.Future<void>.value(),
)
as _i3.Future<void>);
@override
_i3.Future<void> update(_i8.Category? category) =>
(super.noSuchMethod(
Invocation.method(#update, [category]),
returnValue: _i3.Future<void>.value(),
returnValueForMissingStub: _i3.Future<void>.value(),
)
as _i3.Future<void>);
@override
_i3.Future<void> delete(String? id) =>
(super.noSuchMethod(
Invocation.method(#delete, [id]),
returnValue: _i3.Future<void>.value(),
returnValueForMissingStub: _i3.Future<void>.value(),
)
as _i3.Future<void>);
@override
_i3.Future<List<_i8.Category>> getIncomeCategories() =>
(super.noSuchMethod(
Invocation.method(#getIncomeCategories, []),
returnValue: _i3.Future<List<_i8.Category>>.value(<_i8.Category>[]),
)
as _i3.Future<List<_i8.Category>>);
@override
_i3.Future<List<_i8.Category>> getExpenseCategories() =>
(super.noSuchMethod(
Invocation.method(#getExpenseCategories, []),
returnValue: _i3.Future<List<_i8.Category>>.value(<_i8.Category>[]),
)
as _i3.Future<List<_i8.Category>>);
@override
_i3.Future<void> addAll(List<_i8.Category>? categories) =>
(super.noSuchMethod(
Invocation.method(#addAll, [categories]),
returnValue: _i3.Future<void>.value(),
returnValueForMissingStub: _i3.Future<void>.value(),
)
as _i3.Future<void>);
}
/// 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<List<_i10.AiRule>> getAll() =>
(super.noSuchMethod(
Invocation.method(#getAll, []),
returnValue: _i3.Future<List<_i10.AiRule>>.value(<_i10.AiRule>[]),
)
as _i3.Future<List<_i10.AiRule>>);
@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<void> add(_i10.AiRule? rule) =>
(super.noSuchMethod(
Invocation.method(#add, [rule]),
returnValue: _i3.Future<void>.value(),
returnValueForMissingStub: _i3.Future<void>.value(),
)
as _i3.Future<void>);
@override
_i3.Future<void> update(_i10.AiRule? rule) =>
(super.noSuchMethod(
Invocation.method(#update, [rule]),
returnValue: _i3.Future<void>.value(),
returnValueForMissingStub: _i3.Future<void>.value(),
)
as _i3.Future<void>);
@override
_i3.Future<void> delete(String? id) =>
(super.noSuchMethod(
Invocation.method(#delete, [id]),
returnValue: _i3.Future<void>.value(),
returnValueForMissingStub: _i3.Future<void>.value(),
)
as _i3.Future<void>);
@override
_i3.Future<List<_i10.AiRule>> getByType(_i10.AiRuleType? type) =>
(super.noSuchMethod(
Invocation.method(#getByType, [type]),
returnValue: _i3.Future<List<_i10.AiRule>>.value(<_i10.AiRule>[]),
)
as _i3.Future<List<_i10.AiRule>>);
@override
_i3.Future<List<_i10.AiRule>> getActiveRules() =>
(super.noSuchMethod(
Invocation.method(#getActiveRules, []),
returnValue: _i3.Future<List<_i10.AiRule>>.value(<_i10.AiRule>[]),
)
as _i3.Future<List<_i10.AiRule>>);
@override
_i3.Future<List<_i10.AiRule>> getByStatus(_i10.ProcessingStatus? status) =>
(super.noSuchMethod(
Invocation.method(#getByStatus, [status]),
returnValue: _i3.Future<List<_i10.AiRule>>.value(<_i10.AiRule>[]),
)
as _i3.Future<List<_i10.AiRule>>);
@override
_i3.Future<List<_i10.AiRule>> getByPriority() =>
(super.noSuchMethod(
Invocation.method(#getByPriority, []),
returnValue: _i3.Future<List<_i10.AiRule>>.value(<_i10.AiRule>[]),
)
as _i3.Future<List<_i10.AiRule>>);
@override
_i3.Future<void> addAll(List<_i10.AiRule>? rules) =>
(super.noSuchMethod(
Invocation.method(#addAll, [rules]),
returnValue: _i3.Future<void>.value(),
returnValueForMissingStub: _i3.Future<void>.value(),
)
as _i3.Future<void>);
@override
_i3.Future<void> updateStatus(String? id, _i10.ProcessingStatus? status) =>
(super.noSuchMethod(
Invocation.method(#updateStatus, [id, status]),
returnValue: _i3.Future<void>.value(),
returnValueForMissingStub: _i3.Future<void>.value(),
)
as _i3.Future<void>);
@override
_i3.Future<void> toggleActive(String? id) =>
(super.noSuchMethod(
Invocation.method(#toggleActive, [id]),
returnValue: _i3.Future<void>.value(),
returnValueForMissingStub: _i3.Future<void>.value(),
)
as _i3.Future<void>);
}
@@ -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<ArgumentError>()),
);
});
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('\\+'));
});
});
});
}
+54
View File
@@ -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<void> 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'];
}
}