Files
OnBudget/test/features/notification_parsing/application/inbox_controller_test.dart
T
2026-05-30 00:18:06 +03:00

330 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,
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,
String? merchantCanonical,
String? categoryId,
String? accountId,
}) async {
created.add({
'kind': kind,
'matchMode': matchMode,
'pattern': pattern,
'merchantCanonical': merchantCanonical,
'categoryId': categoryId,
});
return ParseRule(
id: 'rule1',
userId: userId,
kind: kind,
matchMode: matchMode,
pattern: pattern,
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');
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);
});
});
}