Files
OnBudget/test/features/notification_parsing/application/inbox_controller_test.dart
T
SandersandClaude Opus 4.8 4f99b80169 Replace account_bindings with per-app default account; build analytics charts
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>
2026-07-18 00:09:12 +03:00

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);
});
});
}