351 lines
14 KiB
Dart
351 lines
14 KiB
Dart
import 'dart:convert';
|
||
|
||
import 'package:drift/native.dart';
|
||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||
import 'package:flutter_test/flutter_test.dart';
|
||
import 'package:http/http.dart' as http;
|
||
import 'package:http/testing.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/inbox_controller.dart';
|
||
import 'package:new_budget/src/features/notification_parsing/application/notification_parsing_providers.dart';
|
||
import 'package:new_budget/src/features/notification_parsing/application/parsing_pipeline.dart';
|
||
import 'package:new_budget/src/features/notification_parsing/application/parsing_settings_controller.dart';
|
||
import 'package:new_budget/src/features/notification_parsing/data/deepseek/deepseek_client.dart';
|
||
import 'package:new_budget/src/features/notification_parsing/data/parser/ai_parser.dart';
|
||
import 'package:new_budget/src/features/notification_parsing/data/parser/decision_gate.dart';
|
||
import 'package:new_budget/src/features/notification_parsing/data/parser/draft_codec.dart';
|
||
import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_draft.dart';
|
||
import 'package:new_budget/src/features/notification_parsing/domain/entities/raw_message.dart';
|
||
import 'package:new_budget/src/features/notification_parsing/domain/entities/rule_suggestion.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';
|
||
|
||
/// Sweep по инбоксу ([ParsingPipeline.reapplyRulesToInbox]) после
|
||
/// createRule/ignoreWithRule: реальная БД, реальные InboxController + Pipeline.
|
||
/// AI замокан — sweep работает на кешированных draft'ах (ноль токенов),
|
||
/// мок нужен только для сквозного сценария через `pipeline.process`.
|
||
|
||
const _userId = 'u1';
|
||
const _accountId = 'acc-1';
|
||
const _categoryId = 'cat-1';
|
||
const _bank = 'ru.sberbankmobile';
|
||
|
||
AiParser _fakeAiParser({required String merchantRaw, required num amount}) {
|
||
final mock = MockClient((req) async {
|
||
final content = jsonEncode({
|
||
'type': 'expense',
|
||
'kind': 'purchase',
|
||
'amount': amount,
|
||
'currency': 'RUB',
|
||
'merchantRaw': merchantRaw,
|
||
});
|
||
return http.Response(
|
||
jsonEncode({
|
||
'choices': [
|
||
{
|
||
'message': {'content': content},
|
||
}
|
||
],
|
||
'usage': {'total_tokens': 42},
|
||
}),
|
||
200,
|
||
headers: {'content-type': 'application/json'},
|
||
);
|
||
});
|
||
return AiParser(DeepSeekClient(client: mock, apiKey: 'k'));
|
||
}
|
||
|
||
void main() {
|
||
late AppDatabase db;
|
||
late ProviderContainer container;
|
||
late RawMessagesRepository repo;
|
||
|
||
Future<void> bootstrap() async {
|
||
db = AppDatabase.forTesting(NativeDatabase.memory());
|
||
await db.usersDao.insertUser(
|
||
UsersTableCompanion.insert(id: _userId, name: 'Test'),
|
||
);
|
||
await db.accountsDao.insertAccount(
|
||
AccountsTableCompanion.insert(
|
||
id: _accountId, userId: _userId, name: 'Main'),
|
||
);
|
||
await db.categoriesDao.insertCategory(CategoriesTableCompanion.insert(
|
||
id: _categoryId,
|
||
userId: _userId,
|
||
name: 'Продукты',
|
||
));
|
||
await db.sourceAppsDao.insert(SourceAppsTableCompanion.insert(
|
||
id: 'src-1',
|
||
userId: _userId,
|
||
packageName: _bank,
|
||
));
|
||
container = ProviderContainer(overrides: [
|
||
appDatabaseProvider.overrideWithValue(db),
|
||
isOnlineProvider.overrideWith((ref) => Stream<bool>.value(true)),
|
||
aiParserProvider.overrideWith(
|
||
(ref) async => _fakeAiParser(merchantRaw: 'LENTA', amount: 1700)),
|
||
]);
|
||
repo = container.read(rawMessagesRepositoryProvider);
|
||
// Прогреваем настройки (autoApplyEnabled = true по умолчанию).
|
||
await container.read(parsingSettingsControllerProvider.future);
|
||
// Парсинг выключен по умолчанию — включаем явно.
|
||
await container
|
||
.read(parsingSettingsControllerProvider.notifier)
|
||
.setEnabled(true);
|
||
}
|
||
|
||
tearDown(() async {
|
||
container.dispose();
|
||
await db.close();
|
||
});
|
||
|
||
ParseDraft draftFor({
|
||
required String rawMessageId,
|
||
required int amount,
|
||
String? merchantRaw = 'LENTA',
|
||
TxKind kind = TxKind.purchase,
|
||
}) =>
|
||
ParseDraft(
|
||
rawMessageId: rawMessageId,
|
||
type: TransactionType.expense,
|
||
amount: amount,
|
||
currency: 'RUB',
|
||
merchantRaw: merchantRaw,
|
||
kind: kind,
|
||
source: ParseSource.ai,
|
||
);
|
||
|
||
/// Inbox-карточка с кешированным AI-draft, как её оставил pipeline.
|
||
Future<RawMessage> insertInboxCard({
|
||
required String body,
|
||
required int amount,
|
||
String? merchantRaw = 'LENTA',
|
||
TxKind kind = TxKind.purchase,
|
||
String? pairedRawMessageId,
|
||
}) async {
|
||
final msg = await repo.insertIncoming(
|
||
userId: _userId,
|
||
packageName: _bank,
|
||
body: body,
|
||
receivedAt: DateTime(2026, 7, 10, 12),
|
||
);
|
||
final draft =
|
||
draftFor(rawMessageId: msg.id, amount: amount, merchantRaw: merchantRaw, kind: kind);
|
||
final suggestion = merchantRaw == null
|
||
? null
|
||
: RuleSuggestion(merchantRaw: merchantRaw, merchantCanonical: merchantRaw);
|
||
await repo.updateAfterParse(
|
||
id: msg.id,
|
||
status: RawMessageStatus.inbox,
|
||
draftJson: encodeDraftBundle(draft, suggestion,
|
||
pairedRawMessageId: pairedRawMessageId),
|
||
);
|
||
return (await repo.findById(msg.id))!;
|
||
}
|
||
|
||
InboxController controller() =>
|
||
container.read(inboxControllerProvider.notifier);
|
||
|
||
Future<void> createLentaRule(RawMessage message, {bool autoApply = true}) =>
|
||
controller().createRule(
|
||
userId: _userId,
|
||
message: message,
|
||
draft: decodeDraftBundle(message.draftJson)!.draft,
|
||
accountId: _accountId,
|
||
categoryId: _categoryId,
|
||
merchantCanonical: 'LENTA',
|
||
pattern: 'LENTA',
|
||
autoApply: autoApply,
|
||
);
|
||
|
||
test(
|
||
'createRule по первой карточке: вторая (тот же мерчант, сумма в теле) '
|
||
'auto-applied, третья (другой мерчант) не применена', () async {
|
||
await bootstrap();
|
||
final msg1 = await insertInboxCard(
|
||
body: 'Payment of 1500 RUB at LENTA', amount: 150000);
|
||
final msg2 = await insertInboxCard(
|
||
body: 'Payment of 2300 RUB at LENTA', amount: 230000);
|
||
final msg3 = await insertInboxCard(
|
||
body: 'Payment of 900 RUB at OZON', amount: 90000, merchantRaw: 'OZON');
|
||
|
||
await createLentaRule(msg1);
|
||
|
||
final after2 = (await repo.findById(msg2.id))!;
|
||
expect(after2.status, RawMessageStatus.applied);
|
||
|
||
final txns = await db.select(db.transactionsTable).get();
|
||
final tx2 =
|
||
txns.where((t) => t.rawMessageId == msg2.id).single;
|
||
expect(tx2.autoApplied, isTrue);
|
||
expect(tx2.appliedByRuleId, isNotNull);
|
||
expect(tx2.accountId, _accountId,
|
||
reason: 'счёт из выученного дефолта приложения (trusted)');
|
||
expect(tx2.categoryId, _categoryId, reason: 'категория из правила');
|
||
|
||
// matchCount: sweep — первое срабатывание правила (создание не в счёт).
|
||
final rule = (await container
|
||
.read(parseRulesRepositoryProvider)
|
||
.getByUser(_userId))
|
||
.single;
|
||
expect(tx2.appliedByRuleId, rule.id);
|
||
expect(rule.matchCount, 1);
|
||
|
||
// Третья карточка — другой мерчант: осталась в Inbox без транзакции.
|
||
final after3 = (await repo.findById(msg3.id))!;
|
||
expect(after3.status, RawMessageStatus.inbox);
|
||
expect(txns.where((t) => t.rawMessageId == msg3.id), isEmpty);
|
||
});
|
||
|
||
test('gate не прошёл (суммы нет в теле) → карточка обновлена, без транзакции',
|
||
() async {
|
||
await bootstrap();
|
||
final msg1 = await insertInboxCard(
|
||
body: 'Payment of 1500 RUB at LENTA', amount: 150000);
|
||
// Сумма draft (2300) не встречается в теле → amountVerifiedInBody падает.
|
||
final msg2 = await insertInboxCard(
|
||
body: 'Payment at LENTA, amount hidden', amount: 230000);
|
||
|
||
await createLentaRule(msg1);
|
||
|
||
final after2 = (await repo.findById(msg2.id))!;
|
||
expect(after2.status, RawMessageStatus.inbox);
|
||
final bundle = decodeDraftBundle(after2.draftJson)!;
|
||
expect(bundle.draft.categoryId, _categoryId,
|
||
reason: 'категория из правила подставлена в draft');
|
||
expect(bundle.suggestion, isNull,
|
||
reason: 'мерчант теперь знакомый — suggestion исчезает');
|
||
expect(bundle.failedChecks, contains(AutoApplyCheck.amountVerifiedInBody));
|
||
|
||
final txns = await db.select(db.transactionsTable).get();
|
||
expect(txns.where((t) => t.rawMessageId == msg2.id), isEmpty);
|
||
});
|
||
|
||
test('глобальный autoApplyEnabled=false → только обновление карточки',
|
||
() async {
|
||
await bootstrap();
|
||
await container
|
||
.read(parsingSettingsControllerProvider.notifier)
|
||
.setAutoApplyEnabled(false);
|
||
final msg1 = await insertInboxCard(
|
||
body: 'Payment of 1500 RUB at LENTA', amount: 150000);
|
||
final msg2 = await insertInboxCard(
|
||
body: 'Payment of 2300 RUB at LENTA', amount: 230000);
|
||
|
||
await createLentaRule(msg1);
|
||
|
||
final after2 = (await repo.findById(msg2.id))!;
|
||
expect(after2.status, RawMessageStatus.inbox);
|
||
final bundle = decodeDraftBundle(after2.draftJson)!;
|
||
expect(bundle.draft.categoryId, _categoryId);
|
||
expect(bundle.suggestion, isNull);
|
||
|
||
final txns = await db.select(db.transactionsTable).get();
|
||
expect(txns.where((t) => t.rawMessageId == msg2.id), isEmpty);
|
||
});
|
||
|
||
test(
|
||
'правило с autoApply=false → карточка обновлена, транзакции нет, '
|
||
'failedChecks содержит ruleAutoApplyEnabled', () async {
|
||
await bootstrap();
|
||
final msg1 = await insertInboxCard(
|
||
body: 'Payment of 1500 RUB at LENTA', amount: 150000);
|
||
final msg2 = await insertInboxCard(
|
||
body: 'Payment of 2300 RUB at LENTA', amount: 230000);
|
||
|
||
await createLentaRule(msg1, autoApply: false);
|
||
|
||
final after2 = (await repo.findById(msg2.id))!;
|
||
expect(after2.status, RawMessageStatus.inbox);
|
||
final bundle = decodeDraftBundle(after2.draftJson)!;
|
||
expect(bundle.draft.categoryId, _categoryId,
|
||
reason: 'полный префилл: категория из правила');
|
||
expect(bundle.failedChecks,
|
||
contains(AutoApplyCheck.ruleAutoApplyEnabled));
|
||
|
||
final txns = await db.select(db.transactionsTable).get();
|
||
expect(txns.where((t) => t.rawMessageId == msg2.id), isEmpty);
|
||
});
|
||
|
||
test('ignoreWithRule → совпавшие карточки тоже ignored', () async {
|
||
await bootstrap();
|
||
final msg1 = await insertInboxCard(
|
||
body: 'Доставлен заказ №1 от LENTA', amount: 150000);
|
||
final msg2 = await insertInboxCard(
|
||
body: 'Доставлен заказ №2 от LENTA', amount: 230000);
|
||
final msg3 = await insertInboxCard(
|
||
body: 'Payment of 900 RUB at OZON', amount: 90000, merchantRaw: 'OZON');
|
||
|
||
await controller().ignoreWithRule(
|
||
userId: _userId,
|
||
message: msg1,
|
||
pattern: 'Доставлен заказ',
|
||
);
|
||
|
||
expect((await repo.findById(msg1.id))!.status, RawMessageStatus.ignored);
|
||
expect((await repo.findById(msg2.id))!.status, RawMessageStatus.ignored,
|
||
reason: 'sweep прогнал ignore-правило по остальным карточкам');
|
||
expect((await repo.findById(msg3.id))!.status, RawMessageStatus.inbox);
|
||
expect(await db.select(db.transactionsTable).get(), isEmpty);
|
||
});
|
||
|
||
test('transfer-половинка и merged-карточка sweep\'ом не трогаются', () async {
|
||
await bootstrap();
|
||
final msg1 = await insertInboxCard(
|
||
body: 'Payment of 1500 RUB at LENTA', amount: 150000);
|
||
// Релизнутая по таймауту transfer-половинка (kind=transferOut).
|
||
final half = await insertInboxCard(
|
||
body: 'Перевод 2300 RUB LENTA', amount: 230000, kind: TxKind.transferOut);
|
||
// Склеенный перевод (pairedRawMessageId != null).
|
||
final merged = await insertInboxCard(
|
||
body: 'Перевод 500 RUB LENTA',
|
||
amount: 50000,
|
||
pairedRawMessageId: 'someone-else');
|
||
|
||
final halfJson = (await repo.findById(half.id))!.draftJson;
|
||
final mergedJson = (await repo.findById(merged.id))!.draftJson;
|
||
|
||
await createLentaRule(msg1);
|
||
|
||
final afterHalf = (await repo.findById(half.id))!;
|
||
final afterMerged = (await repo.findById(merged.id))!;
|
||
expect(afterHalf.status, RawMessageStatus.inbox);
|
||
expect(afterHalf.draftJson, halfJson, reason: 'draft не перезаписан');
|
||
expect(afterMerged.status, RawMessageStatus.inbox);
|
||
expect(afterMerged.draftJson, mergedJson);
|
||
});
|
||
|
||
test(
|
||
'сквозной: после createRule новое сообщение того же мерчанта '
|
||
'auto-apply через pipeline.process', () async {
|
||
await bootstrap();
|
||
// AI-консент — для пути process → _extractViaAi (мок-модель, без сети).
|
||
await container
|
||
.read(parsingSettingsControllerProvider.notifier)
|
||
.setAiConsent(true);
|
||
final msg1 = await insertInboxCard(
|
||
body: 'Payment of 1500 RUB at LENTA', amount: 150000);
|
||
await createLentaRule(msg1);
|
||
|
||
final fresh = await repo.insertIncoming(
|
||
userId: _userId,
|
||
packageName: _bank,
|
||
body: 'Payment of 1700 RUB at LENTA',
|
||
receivedAt: DateTime(2026, 7, 11, 12),
|
||
);
|
||
await container.read(parsingPipelineProvider).process(_userId, fresh);
|
||
|
||
final after = (await repo.findById(fresh.id))!;
|
||
expect(after.status, RawMessageStatus.applied);
|
||
final tx = (await db.select(db.transactionsTable).get())
|
||
.where((t) => t.rawMessageId == fresh.id)
|
||
.single;
|
||
expect(tx.autoApplied, isTrue);
|
||
expect(tx.accountId, _accountId);
|
||
expect(tx.categoryId, _categoryId);
|
||
});
|
||
}
|