- 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>
515 lines
17 KiB
Dart
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);
|
|
});
|
|
});
|
|
}
|