330 lines
10 KiB
Dart
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);
|
|
});
|
|
});
|
|
}
|