Files
OnBudget/test/features/notification_parsing/integration/ai_processing_integration_test.dart
T
2026-07-20 23:04:03 +03:00

325 lines
13 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
@Tags(['integration'])
library;
import 'dart:async';
import 'package:drift/native.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:new_budget/src/core/database/app_database.dart';
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
import 'package:new_budget/src/core/providers/database_provider.dart';
import 'package:new_budget/src/features/notification_parsing/application/ai_providers.dart';
import 'package:new_budget/src/features/notification_parsing/application/notification_parsing_providers.dart';
import 'package:new_budget/src/features/notification_parsing/application/parsing_settings_controller.dart';
import 'package:new_budget/src/features/notification_parsing/application/parsing_worker.dart';
import 'package:new_budget/src/features/notification_parsing/data/parser/draft_codec.dart';
import 'package:new_budget/src/features/notification_parsing/data/secure/ai_key_store.dart';
import 'package:new_budget/src/features/notification_parsing/domain/entities/raw_message.dart';
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
import 'package:new_budget/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart';
/// Интеграционные тесты AI-обработки уведомлений (§7), бьющие в РЕАЛЬНЫЙ
/// DeepSeek API. Проверяют, что после получения сообщения оно
/// автоматически подхватывается [ParsingWorker] и проходит pipeline
/// AI → терминальный статус (inbox / ignored / failed).
///
/// Ключ НЕ хардкодится: берётся из --dart-define=DEEPSEEK_API_KEY=...
/// Без ключа сетевые тесты помечаются skip (плоский `flutter test` остаётся
/// зелёным и офлайн). Тест с заведомо плохим ключом сети не требует и идёт
/// всегда.
///
/// flutter test test/features/notification_parsing/integration/ai_processing_integration_test.dart \
/// --tags integration -p vm \
/// --dart-define=DEEPSEEK_API_KEY=sk-...
const _apiKey = String.fromEnvironment('DEEPSEEK_API_KEY');
const _model =
String.fromEnvironment('DEEPSEEK_TEST_MODEL', defaultValue: kDefaultAiModel);
final Object _skip =
_apiKey.isEmpty ? 'set --dart-define=DEEPSEEK_API_KEY to run' : false;
const _userId = 'user-1';
const _accountId = 'acc-1';
/// Подменяет secure storage, отдавая ключ из dart-define. Так собирается
/// НАСТОЯЩИЙ [aiParserProvider] поверх реального httpClient/DeepSeekClient —
/// без обращения к платформенному FlutterSecureStorage.
class _FakeKeyStore extends AiKeyStore {
const _FakeKeyStore(this.key) : super(const FlutterSecureStorage());
final String key;
@override
Future<String?> getApiKey() async => key;
@override
Future<bool> hasKey() async => key.isNotEmpty;
}
/// Создаёт контейнер с реальной БД и AI-обвязкой на заданном ключе.
ProviderContainer _container(AppDatabase db, String apiKey) {
return ProviderContainer(
overrides: [
appDatabaseProvider.overrideWithValue(db),
aiKeyStoreProvider.overrideWithValue(_FakeKeyStore(apiKey)),
// connectivity_plus не имеет платформенного биндинга под flutter test —
// всегда «онлайн», иначе воркер уведёт сообщение в pending_ai.
isOnlineProvider.overrideWith((ref) => Stream<bool>.value(true)),
],
);
}
Future<void> _seed(AppDatabase db, {List<String> categories = const []}) async {
await db.usersDao.insertUser(
UsersTableCompanion.insert(id: _userId, name: 'Тест'),
);
await db.accountsDao.insertAccount(
AccountsTableCompanion.insert(
id: _accountId,
userId: _userId,
name: 'Основной',
),
);
// Allowlist: тестовый пакет должен быть включён, иначе воркер пометит
// сообщение `ignored` ещё до AI (§A).
await db.sourceAppsDao.insert(
SourceAppsTableCompanion.insert(
id: 'src-1',
userId: _userId,
packageName: 'com.example.bank',
),
);
var i = 0;
for (final name in categories) {
await db.categoriesDao.insertCategory(
CategoriesTableCompanion.insert(
id: 'cat-${i++}',
userId: _userId,
name: name,
),
);
}
}
/// Активирует воркер: его входы — прямые подписки на Drift-стримы репозитория,
/// поэтому одного чтения провайдера достаточно.
void _activateWorker(ProviderContainer c) {
c.read(parsingWorkerProvider(_userId));
}
/// Ждёт, пока сообщение [id] не выйдет из «промежуточных» статусов
/// (pending / parsing / pending_ai) в терминальный.
Future<RawMessage> _waitTerminal(
RawMessagesRepository repo,
String id, {
Duration timeout = const Duration(seconds: 60),
}) async {
const transient = {
RawMessageStatus.pending,
RawMessageStatus.parsing,
RawMessageStatus.pendingAi,
};
final completer = Completer<RawMessage>();
late final StreamSubscription<List<RawMessage>> sub;
sub = repo.watchAll(_userId).listen((list) {
for (final m in list) {
if (m.id == id && !transient.contains(m.status)) {
if (!completer.isCompleted) completer.complete(m);
return;
}
}
});
try {
return await completer.future.timeout(timeout);
} finally {
await sub.cancel();
}
}
void main() {
const netTimeout = Timeout(Duration(seconds: 90));
// ── AI-обработка через реальный DeepSeek (требует ключ) ──────────────────
group('AI auto-processing (real DeepSeek)', () {
late AppDatabase db;
late ProviderContainer container;
late RawMessagesRepository repo;
setUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
await _seed(db, categories: const ['Продукты', 'Кафе и рестораны']);
container = _container(db, _apiKey);
repo = container.read(rawMessagesRepositoryProvider);
// Согласие на AI (по умолчанию false → AI не вызывается). Модель — из
// dart-define/дефолта приложения.
final settings = container.read(parsingSettingsControllerProvider.notifier);
await container.read(parsingSettingsControllerProvider.future);
// Парсинг выключен по умолчанию — включаем явно.
await container
.read(parsingSettingsControllerProvider.notifier)
.setEnabled(true);
await settings.setAiConsent(true);
await settings.setAiModel(_model);
});
tearDown(() async {
container.dispose();
await db.close();
});
test(
'английское уведомление о покупке → AI → Inbox с draft',
() async {
// Активируем воркер — он сам слушает pending и тянет pipeline.
_activateWorker(container);
final inserted = await repo.insertIncoming(
userId: _userId,
packageName: 'com.example.bank',
body: 'Payment of 1500 RUB at LENTA supermarket, card ending 7777',
receivedAt: DateTime(2026, 5, 31, 12, 0),
);
final msg = await _waitTerminal(repo, inserted.id);
expect(msg.status, RawMessageStatus.inbox,
reason: 'lastParseError=${msg.lastParseError}');
final bundle = decodeDraftBundle(msg.draftJson);
expect(bundle, isNotNull, reason: 'draftJson must hold the AI draft');
final draft = bundle!.draft;
expect(draft.source, ParseSource.ai);
expect(draft.type, TransactionType.expense);
// Сумма в минорных единицах (~1500.00 ₽). Допускаем небольшой разброс.
expect(draft.amount, greaterThanOrEqualTo(140000));
expect(draft.amount, lessThanOrEqualTo(160000));
expect(draft.merchantRaw, isNotNull);
expect(draft.merchantRaw!.toUpperCase(), contains('LENTA'));
// Токены учтены в дневном счётчике.
final settings =
await container.read(parsingSettingsControllerProvider.future);
// Парсинг выключен по умолчанию — включаем явно.
await container
.read(parsingSettingsControllerProvider.notifier)
.setEnabled(true);
expect(settings.tokensUsedToday, greaterThan(0));
},
timeout: netTimeout,
skip: _skip,
);
test(
'не-транзакция (баланс/реклама) → AI помечает ignored',
() async {
_activateWorker(container);
final inserted = await repo.insertIncoming(
userId: _userId,
packageName: 'com.example.bank',
body: 'Your card balance is 5000 RUB. Thank you for banking with us.',
receivedAt: DateTime(2026, 5, 31, 12, 5),
);
final msg = await _waitTerminal(repo, inserted.id);
expect(msg.status, RawMessageStatus.ignored,
reason: 'lastParseError=${msg.lastParseError}');
},
timeout: netTimeout,
skip: _skip,
);
test(
'покупка в продуктовом → AI предлагает категорию из списка пользователя',
() async {
_activateWorker(container);
final inserted = await repo.insertIncoming(
userId: _userId,
packageName: 'com.example.bank',
body: 'Purchase 980 RUB at PYATEROCHKA grocery store, card 1234',
receivedAt: DateTime(2026, 5, 31, 12, 10),
);
final msg = await _waitTerminal(repo, inserted.id);
expect(msg.status, RawMessageStatus.inbox,
reason: 'lastParseError=${msg.lastParseError}');
final bundle = decodeDraftBundle(msg.draftJson);
expect(bundle, isNotNull);
final draft = bundle!.draft;
// Модель должна предложить категорию (имя — из списка пользователя).
expect(draft.categorySuggestion, isNotNull);
// Если предложение правила подставило категорию — это одна из seed-категорий.
final suggestion = bundle.suggestion;
if (suggestion?.categoryName != null) {
expect(
const ['Продукты', 'Кафе и рестораны'],
contains(suggestion!.categoryName),
);
expect(suggestion.categoryId, isNotNull);
}
},
timeout: netTimeout,
skip: _skip,
);
});
// ── Деградация при неверном ключе (сеть не нужна, идёт всегда) ────────────
group('AI auth failure handling', () {
late AppDatabase db;
late ProviderContainer container;
late RawMessagesRepository repo;
setUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
await _seed(db);
container = _container(db, 'sk-invalid-key-for-test');
repo = container.read(rawMessagesRepositoryProvider);
final settings = container.read(parsingSettingsControllerProvider.notifier);
await container.read(parsingSettingsControllerProvider.future);
// Парсинг выключен по умолчанию — включаем явно.
await container
.read(parsingSettingsControllerProvider.notifier)
.setEnabled(true);
await settings.setAiConsent(true);
await settings.setAiModel(_model);
});
tearDown(() async {
container.dispose();
await db.close();
});
test(
'неверный ключ (401/403) → сообщение failed, согласие на AI снимается',
() async {
_activateWorker(container);
final inserted = await repo.insertIncoming(
userId: _userId,
packageName: 'com.example.bank',
body: 'Payment of 1500 RUB at LENTA supermarket, card ending 7777',
receivedAt: DateTime(2026, 5, 31, 12, 0),
);
final msg = await _waitTerminal(repo, inserted.id);
expect(msg.status, RawMessageStatus.failed);
final settings =
await container.read(parsingSettingsControllerProvider.future);
// Парсинг выключен по умолчанию — включаем явно.
await container
.read(parsingSettingsControllerProvider.notifier)
.setEnabled(true);
expect(settings.aiConsentGiven, isFalse,
reason: 'auth failure must disable AI consent');
},
timeout: netTimeout,
);
});
}