Files
OnBudget/test/features/notification_parsing/application/inbox_controller_test.dart
T
SandersandClaude Fable 5 f2ed501ed4 Unify parse rules: one rule = condition + actions, autoApply toggle, v3 migration
- Drop ParseRuleKind from parse_rules (enum survives only in rule_candidates):
  a rule is now a condition (pattern + matchMode + optional txTypeGuard) plus
  any set of actions (merchantCanonical/categoryId/accountId) or isIgnore
- Field-wise resolution: findClassificationRule fills merchant/category,
  findAccountRule feeds the account resolver - one message may use both
- Per-rule autoApply toggle + ruleAutoApplyEnabled gate check; off -> Inbox
  with full prefill
- Dedup guard in ParseRulesRepositoryImpl.create: same condition updates or
  reactivates the existing row instead of inserting a duplicate
- ParsingPipeline.reapplyRulesToInbox: sweep app inbox cards on cached AI
  drafts after createRule/ignoreWithRule (zero tokens)
- Schema v2 -> v3 migration (drop kind, add auto_apply) + migration_v3_test
- Rework rule_editor_screen into a single unified form; update rules list,
  rule cards, source app detail, l10n strings, and tests

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-19 23:24:06 +03:00

515 lines
17 KiB
Dart

import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:new_budget/src/core/database/converters/enum_converters.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/domain/entities/parse_draft.dart';
import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_rule.dart';
import 'package:new_budget/src/features/notification_parsing/domain/entities/raw_message.dart';
import 'package:new_budget/src/features/notification_parsing/domain/entities/source_app.dart';
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
import 'package:new_budget/src/features/notification_parsing/domain/repositories/parse_rules_repository.dart';
import 'package:new_budget/src/features/notification_parsing/domain/repositories/source_apps_repository.dart';
import 'package:new_budget/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart';
import 'package:new_budget/src/features/notification_parsing/domain/repositories/rule_candidates_repository.dart';
import 'package:new_budget/src/features/transactions/application/transaction_providers.dart';
import 'package:new_budget/src/features/transactions/domain/entities/transaction.dart';
import 'package:new_budget/src/features/transactions/domain/repositories/transaction_repository.dart';
const _userId = 'u1';
final _now = DateTime(2026, 5, 29, 14, 5);
RawMessage _message() => RawMessage(
id: 'msg1',
userId: _userId,
packageName: 'ru.sberbankmobile',
body: 'Покупка 1240 ₽ Карта *3456 PYATEROCHKA',
receivedAt: _now,
dedupHash: 'h',
status: RawMessageStatus.inbox,
createdAt: _now,
);
ParseDraft _draft() => ParseDraft(
rawMessageId: 'msg1',
type: TransactionType.expense,
amount: 124000,
cardLast4: '3456',
merchantRaw: 'PYATEROCHKA',
kind: TxKind.purchase,
source: ParseSource.regex,
);
// ── Fakes ────────────────────────────────────────────────────────────────
class _FakeTransactionRepo implements TransactionRepository {
final List<Map<String, Object?>> created = [];
@override
Future<Transaction> create({
required String userId,
required String accountId,
String? categoryId,
required TransactionType type,
required int amount,
required DateTime date,
String? merchant,
String? extraInfo,
String? transferToAccountId,
SpendingObligation? obligation,
SpendingImpulse? impulse,
String? rawMessageId,
bool autoApplied = false,
String? appliedByRuleId,
}) async {
created.add({
'accountId': accountId,
'categoryId': categoryId,
'amount': amount,
'merchant': merchant,
'rawMessageId': rawMessageId,
});
return Transaction(
id: 'tx1',
userId: userId,
accountId: accountId,
categoryId: categoryId,
type: type,
amount: amount,
date: date,
merchant: merchant,
rawMessageId: rawMessageId,
autoApplied: autoApplied,
appliedByRuleId: appliedByRuleId,
createdAt: _now,
);
}
@override
dynamic noSuchMethod(Invocation invocation) =>
throw UnimplementedError(invocation.memberName.toString());
}
class _FakeRawMessagesRepo implements RawMessagesRepository {
final List<(String, String)> linked = [];
final List<(String, RawMessageStatus)> statusUpdates = [];
final List<Map<String, Object?>> afterParse = [];
final List<String> retried = [];
@override
Future<void> resetForRetry(String id) async {
retried.add(id);
}
@override
Future<void> linkTransaction(String id, String transactionId) async {
linked.add((id, transactionId));
}
@override
Future<void> updateStatus(String id, RawMessageStatus status) async {
statusUpdates.add((id, status));
}
@override
Future<void> updateAfterParse({
required String id,
required RawMessageStatus status,
String? draftJson,
int? confidenceAmount,
int? confidenceAccount,
int? confidenceType,
int? confidenceMerchant,
int? confidenceCategory,
String? lastParseError,
}) async {
afterParse.add({'id': id, 'status': status, 'draftJson': draftJson});
}
@override
dynamic noSuchMethod(Invocation invocation) =>
throw UnimplementedError(invocation.memberName.toString());
}
class _FakeParseRulesRepo implements ParseRulesRepository {
final List<Map<String, Object?>> created = [];
final List<String> deletedIds = [];
/// Правила, которые вернёт [getByUser].
List<ParseRule> existing = const [];
@override
Future<List<ParseRule>> getByUser(String userId) async => existing;
@override
Future<void> deleteById(String id) async => deletedIds.add(id);
@override
Future<List<ParseRule>> getEnabledForApp(
String userId,
String packageName,
) async =>
existing;
@override
Future<ParseRule> create({
required String userId,
required String packageName,
required MatchMode matchMode,
required String pattern,
int priority = 0,
bool isIgnore = false,
bool autoApply = true,
TransactionType? txTypeGuard,
String? merchantCanonical,
String? categoryId,
String? accountId,
}) async {
created.add({
'packageName': packageName,
'matchMode': matchMode,
'pattern': pattern,
'isIgnore': isIgnore,
'autoApply': autoApply,
'txTypeGuard': txTypeGuard,
'merchantCanonical': merchantCanonical,
'categoryId': categoryId,
});
return ParseRule(
id: 'rule1',
userId: userId,
packageName: packageName,
matchMode: matchMode,
pattern: pattern,
isIgnore: isIgnore,
autoApply: autoApply,
txTypeGuard: txTypeGuard,
merchantCanonical: merchantCanonical,
categoryId: categoryId,
accountId: accountId,
createdAt: _now,
);
}
@override
dynamic noSuchMethod(Invocation invocation) =>
throw UnimplementedError(invocation.memberName.toString());
}
class _FakeRuleCandidatesRepo implements RuleCandidatesRepository {
final List<(String, String)> deleted = [];
final List<Map<String, Object?>> observed = [];
@override
Future<void> deleteByRawValue(String userId, String rawValue) async {
deleted.add((userId, rawValue));
}
@override
Future<void> observe({
required String userId,
required ParseRuleKind kind,
required String rawValue,
required String resolvedValue,
}) async {
observed.add({
'rawValue': rawValue,
'resolvedValue': resolvedValue,
});
}
@override
dynamic noSuchMethod(Invocation invocation) =>
throw UnimplementedError(invocation.memberName.toString());
}
class _FakeSourceAppsRepo implements SourceAppsRepository {
/// Приложение-источник сообщения (null — не добавлено в allowlist).
SourceApp? app;
final List<(String, String?)> defaultsSet = [];
@override
Future<SourceApp?> findByPackageName(
String userId,
String packageName,
) async =>
app?.packageName == packageName ? app : null;
@override
Future<void> setDefaultAccount(String id, String? accountId) async {
defaultsSet.add((id, accountId));
if (app?.id == id) app = app!.copyWith(defaultAccountId: accountId);
}
@override
dynamic noSuchMethod(Invocation invocation) =>
throw UnimplementedError(invocation.memberName.toString());
}
SourceApp _sourceApp({String? defaultAccountId}) => SourceApp(
id: 'app1',
userId: _userId,
packageName: 'ru.sberbankmobile',
defaultAccountId: defaultAccountId,
createdAt: _now,
);
/// Фейк pipeline: перехватывает sweep-вызовы [reapplyRulesToInbox]
/// (createRule/ignoreWithRule), опционально кидает для проверки, что сбой
/// sweep не роняет действие.
class _FakePipeline extends ParsingPipeline {
_FakePipeline(super.ref, this.sweepCalls, {this.throwOnSweep = false});
final List<(String, String)> sweepCalls;
final bool throwOnSweep;
@override
Future<void> reapplyRulesToInbox(String userId, String packageName) async {
sweepCalls.add((userId, packageName));
if (throwOnSweep) throw StateError('sweep failed');
}
}
void main() {
late _FakeTransactionRepo txRepo;
late _FakeRawMessagesRepo rawRepo;
late _FakeParseRulesRepo rulesRepo;
late _FakeRuleCandidatesRepo candidatesRepo;
late _FakeSourceAppsRepo sourceAppsRepo;
late List<(String, String)> sweepCalls;
late bool sweepThrows;
late ProviderContainer container;
setUp(() {
txRepo = _FakeTransactionRepo();
rawRepo = _FakeRawMessagesRepo();
rulesRepo = _FakeParseRulesRepo();
candidatesRepo = _FakeRuleCandidatesRepo();
sourceAppsRepo = _FakeSourceAppsRepo()..app = _sourceApp();
sweepCalls = [];
sweepThrows = false;
container = ProviderContainer(
overrides: [
transactionRepositoryProvider.overrideWithValue(txRepo),
rawMessagesRepositoryProvider.overrideWithValue(rawRepo),
parseRulesRepositoryProvider.overrideWithValue(rulesRepo),
ruleCandidatesRepositoryProvider.overrideWithValue(candidatesRepo),
sourceAppsRepositoryProvider.overrideWithValue(sourceAppsRepo),
parsingPipelineProvider.overrideWith(
(ref) => _FakePipeline(ref, sweepCalls, throwOnSweep: sweepThrows)),
],
);
});
tearDown(() => container.dispose());
InboxController controller() =>
container.read(inboxControllerProvider.notifier);
group('createRule', () {
test('creates transaction + rule, removes candidate, links, learns default',
() async {
final learned = await controller().createRule(
userId: _userId,
message: _message(),
draft: _draft(),
accountId: 'acc1',
categoryId: 'cat1',
merchantCanonical: 'PYATEROCHKA',
pattern: 'PYATEROCHKA',
);
expect(txRepo.created, hasLength(1));
expect(txRepo.created.single['categoryId'], 'cat1');
expect(txRepo.created.single['rawMessageId'], 'msg1');
expect(rulesRepo.created, hasLength(1));
expect(rulesRepo.created.single['isIgnore'], isFalse);
expect(rulesRepo.created.single['autoApply'], isTrue);
expect(rulesRepo.created.single['categoryId'], 'cat1');
// Правило привязывается к приложению-источнику сообщения (per-app scope).
expect(rulesRepo.created.single['packageName'], 'ru.sberbankmobile');
// Тип операции фиксируется в правиле — gate-проверка typeMatchesRule.
expect(rulesRepo.created.single['txTypeGuard'], TransactionType.expense);
expect(candidatesRepo.deleted, contains((_userId, 'PYATEROCHKA')));
expect(rawRepo.linked, contains(('msg1', 'tx1')));
// Авто-обучение: дефолта не было → счёт записан, вернулось true.
expect(learned, isTrue);
expect(sourceAppsRepo.defaultsSet, [('app1', 'acc1')]);
// Sweep по инбоксу приложения — после создания правила.
expect(sweepCalls, [(_userId, 'ru.sberbankmobile')]);
expect(container.read(inboxControllerProvider).hasValue, isTrue);
});
test('autoApply=false прокидывается в правило', () async {
await controller().createRule(
userId: _userId,
message: _message(),
draft: _draft(),
accountId: 'acc1',
categoryId: 'cat1',
merchantCanonical: 'PYATEROCHKA',
pattern: 'PYATEROCHKA',
autoApply: false,
);
expect(rulesRepo.created.single['autoApply'], isFalse);
});
test('сбой sweep не роняет createRule', () async {
sweepThrows = true;
// Пересоздаём контейнер с кидающим фейком pipeline.
container.dispose();
container = ProviderContainer(
overrides: [
transactionRepositoryProvider.overrideWithValue(txRepo),
rawMessagesRepositoryProvider.overrideWithValue(rawRepo),
parseRulesRepositoryProvider.overrideWithValue(rulesRepo),
ruleCandidatesRepositoryProvider.overrideWithValue(candidatesRepo),
sourceAppsRepositoryProvider.overrideWithValue(sourceAppsRepo),
parsingPipelineProvider.overrideWith((ref) =>
_FakePipeline(ref, sweepCalls, throwOnSweep: sweepThrows)),
],
);
final learned = await controller().createRule(
userId: _userId,
message: _message(),
draft: _draft(),
accountId: 'acc1',
categoryId: 'cat1',
merchantCanonical: 'PYATEROCHKA',
pattern: 'PYATEROCHKA',
);
// Действие завершилось успешно, sweep был вызван и упал молча.
expect(learned, isTrue);
expect(sweepCalls, hasLength(1));
expect(container.read(inboxControllerProvider).hasValue, isTrue);
});
});
group('ignoreWithRule', () {
test('создаёт ignore-правило, гасит сообщение и запускает sweep', () async {
await controller().ignoreWithRule(
userId: _userId,
message: _message(),
pattern: 'PYATEROCHKA',
);
expect(rulesRepo.created, hasLength(1));
expect(rulesRepo.created.single['isIgnore'], isTrue);
expect(rulesRepo.created.single['pattern'], 'PYATEROCHKA');
expect(rawRepo.statusUpdates,
contains(('msg1', RawMessageStatus.ignored)));
expect(txRepo.created, isEmpty);
expect(sweepCalls, [(_userId, 'ru.sberbankmobile')]);
});
});
group('confirmOnce', () {
test('creates transaction without a rule, observes candidate', () async {
final learned = await controller().confirmOnce(
userId: _userId,
message: _message(),
draft: _draft(),
accountId: 'acc1',
categoryId: 'cat1',
);
expect(txRepo.created, hasLength(1));
expect(rulesRepo.created, isEmpty);
expect(rawRepo.linked, contains(('msg1', 'tx1')));
expect(candidatesRepo.observed, hasLength(1));
expect(candidatesRepo.observed.single['resolvedValue'], 'cat1');
expect(learned, isTrue);
});
});
group('авто-обучение дефолтного счёта приложения', () {
test('дефолт уже задан → не перезаписывается, false', () async {
sourceAppsRepo.app = _sourceApp(defaultAccountId: 'acc-old');
final learned = await controller().confirmOnce(
userId: _userId,
message: _message(),
draft: _draft(),
accountId: 'acc-new',
);
expect(learned, isFalse);
expect(sourceAppsRepo.defaultsSet, isEmpty);
});
test('learnAppDefault: false → пропуск обучения', () async {
final learned = await controller().confirmOnce(
userId: _userId,
message: _message(),
draft: _draft(),
accountId: 'acc1',
learnAppDefault: false,
);
expect(learned, isFalse);
expect(sourceAppsRepo.defaultsSet, isEmpty);
});
test('приложение не в allowlist → пропуск обучения', () async {
sourceAppsRepo.app = null;
final learned = await controller().confirmOnce(
userId: _userId,
message: _message(),
draft: _draft(),
accountId: 'acc1',
);
expect(learned, isFalse);
expect(sourceAppsRepo.defaultsSet, isEmpty);
});
});
group('markApplied', () {
test('links message to a transaction saved via the full form', () async {
await controller().markApplied(_message(), 'tx-9');
expect(rawRepo.linked, contains(('msg1', 'tx-9')));
// Только линковка: ни транзакций, ни правил контроллер не создаёт.
expect(txRepo.created, isEmpty);
expect(rulesRepo.created, isEmpty);
});
});
group('ignore', () {
test('sets message status to ignored, no transaction', () async {
await controller().ignore(_message());
expect(rawRepo.statusUpdates, contains(('msg1', RawMessageStatus.ignored)));
expect(txRepo.created, isEmpty);
expect(rulesRepo.created, isEmpty);
});
});
group('retry', () {
test('pendingAi message → resetForRetry (сброс попыток и в очередь)',
() async {
final stuck = _message().copyWith(
status: RawMessageStatus.pendingAi,
parseAttemptCount: 3,
lastParseError: 'network',
);
await controller().retry(stuck);
expect(rawRepo.retried, ['msg1']);
expect(txRepo.created, isEmpty);
});
});
}