325 lines
13 KiB
Dart
325 lines
13 KiB
Dart
@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,
|
||
);
|
||
});
|
||
}
|