Files
OnBudget/test/features/notification_parsing/application/inbox_controller_test.dart
T
SandersandClaude Opus 4.8 76f301b758 Add native notification ingest and source-app allowlist
- Android NotificationListenerService plugin + queue store, MethodChannel
  bridge, and Flutter ingest worker / access controller
- source_apps allowlist (DAO, repo, settings + source_apps screen)
- schema v8 migration + decision_gate / dedup / draft_codec updates
- l10n strings and tests

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 00:08:18 +03:00

337 lines
10 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/account_binding.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/enums.dart';
import 'package:new_budget/src/features/notification_parsing/domain/repositories/account_bindings_repository.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/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 = [];
@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
dynamic noSuchMethod(Invocation invocation) =>
throw UnimplementedError(invocation.memberName.toString());
}
class _FakeParseRulesRepo implements ParseRulesRepository {
final List<Map<String, Object?>> created = [];
@override
Future<ParseRule> create({
required String userId,
required ParseRuleKind kind,
required MatchMode matchMode,
required String pattern,
int priority = 0,
TransactionType? txType,
String? merchantCanonical,
String? categoryId,
String? accountId,
}) async {
created.add({
'kind': kind,
'matchMode': matchMode,
'pattern': pattern,
'txType': txType,
'merchantCanonical': merchantCanonical,
'categoryId': categoryId,
});
return ParseRule(
id: 'rule1',
userId: userId,
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 _FakeAccountBindingsRepo implements AccountBindingsRepository {
AccountBinding? existing;
final List<Map<String, Object?>> created = [];
@override
Future<AccountBinding?> findByPackageAndCard(
String userId,
String packageName,
String cardLast4,
) async =>
existing;
@override
Future<AccountBinding> create({
required String userId,
String? packageName,
String? bankKey,
String? cardLast4,
String? phone,
required String accountId,
}) async {
created.add({
'packageName': packageName,
'cardLast4': cardLast4,
'accountId': accountId,
});
return AccountBinding(
id: 'b1',
userId: userId,
packageName: packageName,
cardLast4: cardLast4,
accountId: accountId,
createdAt: _now,
);
}
@override
dynamic noSuchMethod(Invocation invocation) =>
throw UnimplementedError(invocation.memberName.toString());
}
void main() {
late _FakeTransactionRepo txRepo;
late _FakeRawMessagesRepo rawRepo;
late _FakeParseRulesRepo rulesRepo;
late _FakeRuleCandidatesRepo candidatesRepo;
late _FakeAccountBindingsRepo bindingsRepo;
late ProviderContainer container;
setUp(() {
txRepo = _FakeTransactionRepo();
rawRepo = _FakeRawMessagesRepo();
rulesRepo = _FakeParseRulesRepo();
candidatesRepo = _FakeRuleCandidatesRepo();
bindingsRepo = _FakeAccountBindingsRepo();
container = ProviderContainer(
overrides: [
transactionRepositoryProvider.overrideWithValue(txRepo),
rawMessagesRepositoryProvider.overrideWithValue(rawRepo),
parseRulesRepositoryProvider.overrideWithValue(rulesRepo),
ruleCandidatesRepositoryProvider.overrideWithValue(candidatesRepo),
accountBindingsRepositoryProvider.overrideWithValue(bindingsRepo),
],
);
});
tearDown(() => container.dispose());
InboxController controller() =>
container.read(inboxControllerProvider.notifier);
group('createRule', () {
test('creates transaction + rule, removes candidate, links, binds', () async {
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');
// Тип операции фиксируется в правиле — gate-проверка typeMatchesRule.
expect(rulesRepo.created.single['txType'], TransactionType.expense);
expect(candidatesRepo.deleted, contains((_userId, 'PYATEROCHKA')));
expect(rawRepo.linked, contains(('msg1', 'tx1')));
// _maybeBind: no existing binding → creates one (card *3456 → acc1).
expect(bindingsRepo.created, hasLength(1));
expect(bindingsRepo.created.single['cardLast4'], '3456');
expect(bindingsRepo.created.single['accountId'], 'acc1');
expect(container.read(inboxControllerProvider).hasValue, isTrue);
});
test('does not re-create binding when one already exists', () async {
bindingsRepo.existing = AccountBinding(
id: 'old',
userId: _userId,
packageName: 'ru.sberbankmobile',
cardLast4: '3456',
accountId: 'acc1',
createdAt: _now,
);
await controller().createRule(
userId: _userId,
message: _message(),
draft: _draft(),
accountId: 'acc1',
categoryId: 'cat1',
merchantCanonical: 'PYATEROCHKA',
pattern: 'PYATEROCHKA',
);
expect(bindingsRepo.created, isEmpty);
});
});
group('confirmOnce', () {
test('creates transaction without a rule, observes candidate', () async {
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');
});
});
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);
});
});
}