440 lines
17 KiB
Dart
440 lines
17 KiB
Dart
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:integration_test/integration_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 'package:budget_app/models/ai_exceptions.dart';
|
||
|
||
import '../test_config.dart';
|
||
import '../mocks/fake_repositories.dart';
|
||
import '../helpers/test_data.dart';
|
||
import '../helpers/test_objects.dart';
|
||
|
||
void main() async {
|
||
IntegrationTestWidgetsFlutterBinding.ensureInitialized();
|
||
await TestConfig.initialize();
|
||
|
||
group('AI Processing Integration Tests', () {
|
||
late AiTransactionProcessingService service;
|
||
late FakeRepositories fakeRepositories;
|
||
|
||
setUp(() {
|
||
fakeRepositories = FakeRepositories();
|
||
fakeRepositories.loadTestData();
|
||
});
|
||
|
||
tearDown(() {
|
||
fakeRepositories.clearAll();
|
||
});
|
||
|
||
group('API Diagnostics', () {
|
||
test('should validate OpenRouter API connection', () async {
|
||
if (!TestConfig.shouldRunIntegrationTests()) {
|
||
print('🚫 Skipping API diagnostic: Integration tests disabled');
|
||
return;
|
||
}
|
||
|
||
if (!TestConfig.isAiConfigured()) {
|
||
print('🚫 Skipping API diagnostic: AI not configured');
|
||
return;
|
||
}
|
||
|
||
final aiSettings = TestConfig.getTestAiSettings();
|
||
final aiService = OpenRouterAiService(aiSettings);
|
||
|
||
print('🔍 API Diagnostic Test:');
|
||
print(' API Key: ${TestConfig.getTestApiKey()?.substring(0, 20)}...');
|
||
print(' Base URL: ${aiSettings.baseUrl}');
|
||
print(' Model: ${aiSettings.defaultModel}');
|
||
print(' Timeout: ${aiSettings.timeoutSeconds}s');
|
||
|
||
// Тест 1: Проверка health
|
||
print('🌡️ Testing API health...');
|
||
final isHealthy = await aiService.checkHealth();
|
||
print(' Health check result: ${isHealthy ? '✅ OK' : '❌ FAILED'}');
|
||
|
||
if (!isHealthy) {
|
||
fail('❌ OpenRouter API health check failed. Проверьте API ключ и сеть.');
|
||
}
|
||
|
||
// Тест 2: Простое сообщение
|
||
print('💬 Testing simple message...');
|
||
try {
|
||
final response = await aiService.sendMessage('Hello, this is a test message.');
|
||
print(' Simple message result: ✅ OK (${response.length} chars)');
|
||
|
||
if (response.trim().isEmpty) {
|
||
fail('❌ OpenRouter returned empty response for simple message');
|
||
}
|
||
|
||
// Проверяем что ответ не является error message
|
||
if (response.toLowerCase().contains('error') ||
|
||
response.toLowerCase().contains('not a json response')) {
|
||
fail('❌ OpenRouter returned error response: $response');
|
||
}
|
||
|
||
} catch (e) {
|
||
fail('❌ OpenRouter simple message test failed: $e');
|
||
}
|
||
|
||
print('✅ API diagnostic passed - OpenRouter is working correctly');
|
||
});
|
||
});
|
||
|
||
group('With Real AI Service', () {
|
||
bool shouldSkip = false;
|
||
|
||
setUp(() {
|
||
// Проверяем после инициализации dotenv
|
||
shouldSkip = !TestConfig.shouldRunIntegrationTests() || !TestConfig.isAiConfigured();
|
||
|
||
if (shouldSkip) {
|
||
return;
|
||
}
|
||
|
||
final aiSettings = TestConfig.getTestAiSettings();
|
||
final aiService = OpenRouterAiService(aiSettings);
|
||
|
||
service = AiTransactionProcessingService(
|
||
aiService: aiService,
|
||
prefilledRepository: fakeRepositories.prefilledTransactionRepository,
|
||
categoryRepository: fakeRepositories.categoryRepository,
|
||
aiRuleRepository: fakeRepositories.aiRuleRepository,
|
||
);
|
||
});
|
||
|
||
test('should process real bank transaction SMS', () async {
|
||
if (shouldSkip) {
|
||
print('Skipping integration test: AI not configured or tests disabled');
|
||
return;
|
||
}
|
||
|
||
// Act & Assert - с новыми exceptions тест будет падать автоматически при ошибках
|
||
print('🚀 Testing SMS processing: ${TestSmsData.bankTransactionSms}');
|
||
final result = await service.processSmsByAi(
|
||
TestSmsData.bankTransactionSms,
|
||
'integration_test_001',
|
||
);
|
||
|
||
// Если мы дошли сюда, значит AI успешно обработал SMS
|
||
expect(result.smsMessageId, equals('integration_test_001'));
|
||
print('✅ AI successfully processed bank transaction SMS');
|
||
|
||
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);
|
||
});
|
||
|
||
test('should process promotional SMS as non-transaction', () async {
|
||
if (shouldSkip) {
|
||
print('Skipping integration test: AI not configured or tests disabled');
|
||
return;
|
||
}
|
||
|
||
// Act - промо SMS может либо быть обработан, либо вызвать exception
|
||
print('🚀 Testing promotional SMS processing: ${TestSmsData.promotionalSms}');
|
||
|
||
try {
|
||
final result = await service.processSmsByAi(
|
||
TestSmsData.promotionalSms,
|
||
'integration_test_002',
|
||
);
|
||
|
||
// Если обработало успешно, проверяем результат
|
||
print('✓ AI processed promotional SMS');
|
||
if (result.amount == null) {
|
||
expect(result.exclusionRegex, isNotNull, reason: 'Non-transaction SMS should have exclusionRegex');
|
||
expect(result.salesPoint, isNull, reason: 'Non-transaction SMS should not have salesPoint');
|
||
print('✅ Correctly identified as non-transaction: ${result.exclusionRegex}');
|
||
} else {
|
||
print('⚠️ AI identified promotional SMS as transaction - this may need prompt tuning');
|
||
}
|
||
|
||
} catch (e) {
|
||
// Промо SMS может вызывать ошибки - это нормально
|
||
print('⚠️ Promotional SMS caused exception: $e');
|
||
print('ℹ️ This may be expected behavior if AI cannot determine SMS type');
|
||
}
|
||
});
|
||
|
||
test('should create AI rules from processed transactions', () async {
|
||
if (shouldSkip) {
|
||
print('Skipping integration test: AI not configured or tests disabled');
|
||
return;
|
||
}
|
||
|
||
// Arrange: Сначала обрабатываем SMS (с exceptions тест автоматически упадет при ошибках)
|
||
print('🚀 Testing rule creation from SMS: ${TestSmsData.onlinePaymentSms}');
|
||
final prefilled = await service.processSmsByAi(
|
||
TestSmsData.onlinePaymentSms,
|
||
'integration_test_003',
|
||
);
|
||
|
||
// Если мы дошли сюда, SMS был успешно обработан
|
||
print('✅ SMS processed successfully for rule creation');
|
||
|
||
// 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}');
|
||
}
|
||
});
|
||
|
||
test('should handle multiple SMS in sequence', () async {
|
||
if (shouldSkip) {
|
||
print('Skipping integration test: AI not configured or tests disabled');
|
||
return;
|
||
}
|
||
|
||
final testSms = [
|
||
TestSmsData.atmWithdrawalSms,
|
||
TestSmsData.bankAdvertisingSms,
|
||
TestSmsData.securitySms,
|
||
];
|
||
|
||
final results = <String, dynamic>{};
|
||
final errors = <String, String>{};
|
||
int successCount = 0;
|
||
|
||
print('🚀 Testing batch SMS processing (${testSms.length} messages)...');
|
||
|
||
// Act: Обрабатываем несколько SMS подряд с timeout
|
||
for (int i = 0; i < testSms.length; i++) {
|
||
final smsId = 'batch_test_${i + 1}';
|
||
final sms = testSms[i];
|
||
|
||
print('🔄 Starting iteration $i/${testSms.length - 1} for $smsId');
|
||
print('💬 Processing $smsId: ${sms.substring(0, 50)}...');
|
||
|
||
try {
|
||
// С новыми exceptions - либо успех, либо exception
|
||
final result = await service.processSmsByAi(sms, smsId)
|
||
.timeout(Duration(seconds: 45));
|
||
|
||
// Если мы дошли сюда - это успех
|
||
results[smsId] = result;
|
||
successCount++;
|
||
print('✅ $smsId: Success');
|
||
|
||
} catch (e) {
|
||
// Любая ошибка теперь является exception
|
||
errors[smsId] = e.toString();
|
||
results[smsId] = null;
|
||
print('❌ $smsId: Exception - ${e.runtimeType}: $e');
|
||
|
||
// Проверяем тип ошибки
|
||
if (e.toString().contains('OpenRouterApiException') ||
|
||
e.toString().contains('AiConfigurationException')) {
|
||
print('⚠️ Critical API error detected - this indicates API/config issues');
|
||
}
|
||
}
|
||
|
||
print('✅ Iteration $i completed, moving to next');
|
||
}
|
||
|
||
print('🎯 Loop completed - analyzing results...');
|
||
|
||
// Assert: Анализируем результаты
|
||
int transactionCount = 0;
|
||
int nonTransactionCount = 0;
|
||
int failureCount = errors.length;
|
||
|
||
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');
|
||
}
|
||
}
|
||
}
|
||
|
||
// Показываем детали ошибок
|
||
if (errors.isNotEmpty) {
|
||
print('❌ Failed SMS processing errors:');
|
||
for (final entry in errors.entries) {
|
||
print(' - ${entry.key}: ${entry.value}');
|
||
}
|
||
}
|
||
|
||
print('📊 Batch processing summary:');
|
||
print(' ✅ Transactions: $transactionCount');
|
||
print(' 🚫 Non-transactions: $nonTransactionCount');
|
||
print(' ❌ Failures: $failureCount');
|
||
print(' 📈 Success count: $successCount/${testSms.length}');
|
||
print(' 🏆 Success rate: ${((successCount / testSms.length) * 100).toInt()}%');
|
||
|
||
// Новая логика: если все SMS падают с API ошибками - это проблема с API
|
||
if (successCount == 0) {
|
||
// Проверяем типы ошибок
|
||
final hasApiErrors = errors.values.any((error) =>
|
||
error.contains('OpenRouterApiException') ||
|
||
error.contains('AiConfigurationException'));
|
||
|
||
if (hasApiErrors) {
|
||
fail('❌ Критическая проблема: все SMS падают с API ошибками. '
|
||
'Проверьте OpenRouter API ключ, модель и сеть. Errors: $errors');
|
||
} else {
|
||
fail('❌ Ни одно SMS не было обработано успешно. Errors: $errors');
|
||
}
|
||
}
|
||
|
||
// Мягкая проверка: минимум 1 SMS должен быть обработан успешно
|
||
if (successCount >= 1) {
|
||
print('✅ At least one SMS processed successfully - test passed');
|
||
}
|
||
|
||
// Проверяем что результаты сохранились
|
||
final allPrefilled = await fakeRepositories.prefilledTransactionRepository.getAll();
|
||
final expectedMinCount = successCount + 2; // Исходные + новые успешные
|
||
expect(allPrefilled.length, greaterThanOrEqualTo(expectedMinCount),
|
||
reason: 'Repository should contain at least $expectedMinCount records');
|
||
|
||
print('✅ Batch test passed: ${allPrefilled.length} records in repository');
|
||
});
|
||
});
|
||
|
||
group('Performance Tests', () {
|
||
bool shouldSkip = false;
|
||
|
||
setUp(() {
|
||
shouldSkip = !TestConfig.shouldRunIntegrationTests() || !TestConfig.isAiConfigured();
|
||
|
||
if (shouldSkip) {
|
||
return;
|
||
}
|
||
|
||
final aiSettings = TestConfig.getTestAiSettings();
|
||
final aiService = OpenRouterAiService(aiSettings);
|
||
|
||
service = AiTransactionProcessingService(
|
||
aiService: aiService,
|
||
prefilledRepository: fakeRepositories.prefilledTransactionRepository,
|
||
categoryRepository: fakeRepositories.categoryRepository,
|
||
aiRuleRepository: fakeRepositories.aiRuleRepository,
|
||
);
|
||
});
|
||
|
||
test('should handle batch processing within reasonable time', () async {
|
||
if (shouldSkip) {
|
||
print('Skipping performance test: AI not configured or tests disabled');
|
||
return;
|
||
}
|
||
|
||
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', () {
|
||
test('should gracefully handle AI service errors', () 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 & Assert
|
||
expect(
|
||
() async => await service.processSmsByAi(
|
||
TestSmsData.bankTransactionSms,
|
||
'error_test_001',
|
||
),
|
||
throwsA(isA<AiException>()),
|
||
reason: 'Должно выбрасываться исключение AiException при ошибке сервиса',
|
||
);
|
||
|
||
print('✓ Gracefully handled AI service error by throwing exception');
|
||
|
||
// Проверяем что ничего не сохранилось
|
||
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}');
|
||
}
|
||
} |