Notification parsing: - Drop the account_bindings table/DAO/repo/entity/controller/screen; account resolution now goes senderToAccount rule -> source_apps.defaultAccountId (trusted) -> global default (untrusted -> Inbox), via v1->v2 migration. - Add defaultAccountId to source_apps; per-app settings consolidated into source_app_detail_screen (/settings/parsing/apps/:pkg). - Inbox auto-learns an app default on first Confirm/CreateRule; account picker on the card instead of a disabled button; parse_error_labels extracted. Analytics: - Replace placeholder screen with fl_chart cards (chart_card, chart_theme, month_stepper, month_math domain helper); slim down habit_analysis_screen. Android: add launcher icon (adaptive foreground + colors.xml) and app_name. Tests: migration_v2, analytics (screen/month_math), AI retry, inbox visibility; update resolver/gate/inbox suites for the new resolution path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
420 lines
13 KiB
Dart
420 lines
13 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/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 ParseRuleKind kind,
|
|
required MatchMode matchMode,
|
|
required String pattern,
|
|
int priority = 0,
|
|
TransactionType? txType,
|
|
String? merchantCanonical,
|
|
String? categoryId,
|
|
String? accountId,
|
|
}) async {
|
|
created.add({
|
|
'packageName': packageName,
|
|
'kind': kind,
|
|
'matchMode': matchMode,
|
|
'pattern': pattern,
|
|
'txType': txType,
|
|
'merchantCanonical': merchantCanonical,
|
|
'categoryId': categoryId,
|
|
});
|
|
return ParseRule(
|
|
id: 'rule1',
|
|
userId: userId,
|
|
packageName: packageName,
|
|
kind: kind,
|
|
matchMode: matchMode,
|
|
pattern: pattern,
|
|
txType: txType,
|
|
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,
|
|
);
|
|
|
|
void main() {
|
|
late _FakeTransactionRepo txRepo;
|
|
late _FakeRawMessagesRepo rawRepo;
|
|
late _FakeParseRulesRepo rulesRepo;
|
|
late _FakeRuleCandidatesRepo candidatesRepo;
|
|
late _FakeSourceAppsRepo sourceAppsRepo;
|
|
late ProviderContainer container;
|
|
|
|
setUp(() {
|
|
txRepo = _FakeTransactionRepo();
|
|
rawRepo = _FakeRawMessagesRepo();
|
|
rulesRepo = _FakeParseRulesRepo();
|
|
candidatesRepo = _FakeRuleCandidatesRepo();
|
|
sourceAppsRepo = _FakeSourceAppsRepo()..app = _sourceApp();
|
|
container = ProviderContainer(
|
|
overrides: [
|
|
transactionRepositoryProvider.overrideWithValue(txRepo),
|
|
rawMessagesRepositoryProvider.overrideWithValue(rawRepo),
|
|
parseRulesRepositoryProvider.overrideWithValue(rulesRepo),
|
|
ruleCandidatesRepositoryProvider.overrideWithValue(candidatesRepo),
|
|
sourceAppsRepositoryProvider.overrideWithValue(sourceAppsRepo),
|
|
],
|
|
);
|
|
});
|
|
|
|
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['kind'], ParseRuleKind.merchantToCategory);
|
|
expect(rulesRepo.created.single['categoryId'], 'cat1');
|
|
// Правило привязывается к приложению-источнику сообщения (per-app scope).
|
|
expect(rulesRepo.created.single['packageName'], 'ru.sberbankmobile');
|
|
// Тип операции фиксируется в правиле — gate-проверка typeMatchesRule.
|
|
expect(rulesRepo.created.single['txType'], TransactionType.expense);
|
|
|
|
expect(candidatesRepo.deleted, contains((_userId, 'PYATEROCHKA')));
|
|
expect(rawRepo.linked, contains(('msg1', 'tx1')));
|
|
|
|
// Авто-обучение: дефолта не было → счёт записан, вернулось true.
|
|
expect(learned, isTrue);
|
|
expect(sourceAppsRepo.defaultsSet, [('app1', 'acc1')]);
|
|
|
|
expect(container.read(inboxControllerProvider).hasValue, isTrue);
|
|
});
|
|
});
|
|
|
|
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);
|
|
});
|
|
});
|
|
}
|