Files
OnBudget/test/features/notification_parsing/integration/ai_processing_integration_test.dart
T
SandersandClaude Opus 4.8 ab66aebc5e Switch AI notification parsing from OpenRouter to DeepSeek API
Replace the OpenRouter HTTP client with a DeepSeek client:
- data/openrouter/ -> data/deepseek/; OpenRouterClient -> DeepSeekClient
  (+ DeepSeekCompletion / DeepSeek*Exception)
- base URL api.deepseek.com, drop OpenRouter-only headers
- DeepSeek has no strict json_schema: use response_format json_object
  and rely on the prompt for shape (ai_schema.dart removed)
- secure-storage key openrouter_api_key -> deepseek_api_key
- default model deepseek/deepseek-v4-flash -> deepseek-chat
- l10n strings, test dart-defines (DEEPSEEK_API_KEY/_TEST_MODEL), docs

Also bundles in-progress findBodyIgnoreRule (body-level ignore rules
applied before the AI call) already present in the working tree.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 00:23:38 +03:00

312 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,
),
);
}
}
/// Активирует воркер: помимо чтения самого провайдера держим прямого слушателя
/// на его входном потоке [pendingMessagesProvider]. Без внешнего слушателя
/// autoDispose-стрим не подписывается на Drift и `ref.listen` внутри воркера не
/// получает событий (в приложении эту роль играет `HomeScreen`).
void _activateWorker(ProviderContainer c) {
c.listen(pendingMessagesProvider(_userId), (_, _) {}, fireImmediately: true);
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 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);
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 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);
expect(settings.aiConsentGiven, isFalse,
reason: 'auth failure must disable AI consent');
},
timeout: netTimeout,
);
});
}