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

351 lines
14 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.
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);
});
}