Add per-app rules, transfer pairing, self-merchant flag, two-step onboarding

Notification parsing:
- Per-app parse rules (parse_rules.packageName; getEnabledForApp, NULL = legacy global)
- Transfer pairing: transfer_pair_matcher + transfer_pairing_blocklist table/dao/repo
- source_apps.selfMerchant flag (Ozon inbox rework: default account picker, suppress
  AI category prefill + rule suggestion)
- raw_messages.diagnostics dump captured under diagnostic-mode toggle
- Inbox card / settings / log UI reworks

Onboarding:
- Two-step flow (name -> first account); UserSeeder seeds categories only, no accounts

Schema bumped to v11; drop obsolete migration + mixed-merchant tests, add new coverage.
Add ios/ platform folder.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-07-10 22:49:04 +03:00
co-authored by Claude Opus 4.8
parent 1ac68c0eb1
commit da65bf6f8e
123 changed files with 5943 additions and 1246 deletions
-89
View File
@@ -1,89 +0,0 @@
import 'dart:io';
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:new_budget/src/core/database/app_database.dart';
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
/// Тест миграции v5 → v6 (habit-tracking).
///
/// Поднимаем актуальную (v6) схему на файле, затем «откатываем» её до v5,
/// удаляя новые колонки и проставляя user_version = 5. При повторном открытии
/// срабатывает `onUpgrade(5 → 6)`, который должен снова добавить колонки.
void main() {
late File file;
setUp(() {
final dir = Directory.systemTemp.createTempSync('nb_migration_test');
file = File('${dir.path}/test.sqlite');
});
tearDown(() {
if (file.existsSync()) file.deleteSync();
final parent = file.parent;
if (parent.existsSync()) parent.deleteSync(recursive: true);
});
test('onUpgrade 5 → 6 добавляет obligation/impulse/habitTrackingEnabled',
() async {
const userId = 'user-1';
const accountId = 'account-1';
// 1. Создаём актуальную схему (v6) и наполняем FK-цепочку.
final dbV6 = AppDatabase.forTesting(NativeDatabase(file));
await dbV6.usersDao
.insertUser(UsersTableCompanion.insert(id: userId, name: 'Тест'));
await dbV6.accountsDao.insertAccount(
AccountsTableCompanion.insert(
id: accountId,
userId: userId,
name: 'Основной',
),
);
// 2. «Откатываем» схему до v5: убираем колонки v6 И артефакты v7/v8
// (иначе onUpgrade 5→8 попытается создать их повторно).
await dbV6.customStatement(
'ALTER TABLE transactions DROP COLUMN obligation');
await dbV6.customStatement('ALTER TABLE transactions DROP COLUMN impulse');
await dbV6.customStatement(
'ALTER TABLE settings DROP COLUMN habit_tracking_enabled');
await dbV6.customStatement(
'ALTER TABLE account_bindings DROP COLUMN is_default');
await dbV6.customStatement('DROP TABLE source_apps');
await dbV6.customStatement('ALTER TABLE parse_rules DROP COLUMN tx_type');
await dbV6.customStatement('PRAGMA user_version = 5');
await dbV6.close();
// 3. Повторное открытие запускает onUpgrade(5 → 6).
final dbMigrated = AppDatabase.forTesting(NativeDatabase(file));
addTearDown(dbMigrated.close);
// 4. Колонки снова доступны: запись с оценками проходит round-trip.
final repoRow = await dbMigrated.transactionsDao.findById('tx-1');
expect(repoRow, isNull);
await dbMigrated.transactionsDao.insertTransaction(
TransactionsTableCompanion.insert(
id: 'tx-1',
userId: userId,
accountId: accountId,
amount: 1000,
date: DateTime(2024, 6, 1),
obligation: const Value(SpendingObligation.required),
impulse: const Value(SpendingImpulse.impulsive),
),
);
final tx = await dbMigrated.transactionsDao.findById('tx-1');
expect(tx!.obligation, SpendingObligation.required);
expect(tx.impulse, SpendingImpulse.impulsive);
// settings.habitTrackingEnabled присутствует с дефолтом false.
await dbMigrated.settingsDao.upsertSettings(
SettingsTableCompanion.insert(userId: userId),
);
final settings = await dbMigrated.settingsDao.getSettingsByUser(userId);
expect(settings!.habitTrackingEnabled, isFalse);
});
}
-84
View File
@@ -1,84 +0,0 @@
import 'dart:io';
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:new_budget/src/core/database/app_database.dart';
/// Тест миграции v6 → v7 (account_bindings.is_default + таблица source_apps).
///
/// Поднимаем актуальную (v7) схему, «откатываем» до v6 (убираем is_default и
/// source_apps, ставим user_version = 6). Повторное открытие запускает
/// onUpgrade(6 → 7), который должен восстановить колонку и таблицу.
void main() {
late File file;
setUp(() {
final dir = Directory.systemTemp.createTempSync('nb_migration_v7_test');
file = File('${dir.path}/test.sqlite');
});
tearDown(() {
if (file.existsSync()) file.deleteSync();
final parent = file.parent;
if (parent.existsSync()) parent.deleteSync(recursive: true);
});
test('onUpgrade 6 → 7 добавляет is_default и таблицу source_apps', () async {
const userId = 'user-1';
const accountId = 'account-1';
// 1. Актуальная схема (v7) + FK-цепочка.
final dbV7 = AppDatabase.forTesting(NativeDatabase(file));
await dbV7.usersDao
.insertUser(UsersTableCompanion.insert(id: userId, name: 'Тест'));
await dbV7.accountsDao.insertAccount(
AccountsTableCompanion.insert(
id: accountId,
userId: userId,
name: 'Основной',
),
);
// 2. «Откатываем» до v6 (включая артефакт v8 — parse_rules.tx_type).
await dbV7.customStatement(
'ALTER TABLE account_bindings DROP COLUMN is_default');
await dbV7.customStatement('DROP TABLE source_apps');
await dbV7.customStatement('ALTER TABLE parse_rules DROP COLUMN tx_type');
await dbV7.customStatement('PRAGMA user_version = 6');
await dbV7.close();
// 3. Повторное открытие → onUpgrade(6 → 7).
final dbMigrated = AppDatabase.forTesting(NativeDatabase(file));
addTearDown(dbMigrated.close);
// 4a. is_default доступна с дефолтом false (round-trip привязки).
await dbMigrated.accountBindingsDao.insert(
AccountBindingsTableCompanion.insert(
id: 'b-1',
userId: userId,
accountId: accountId,
packageName: const Value('ru.sberbankmobile'),
),
);
final bindings =
await dbMigrated.accountBindingsDao.findByPackageName(
userId,
'ru.sberbankmobile',
);
expect(bindings, hasLength(1));
expect(bindings.first.isDefault, isFalse);
// 4b. Таблица source_apps существует и принимает строки.
await dbMigrated.sourceAppsDao.insert(
SourceAppsTableCompanion.insert(
id: 's-1',
userId: userId,
packageName: 'ru.sberbankmobile',
),
);
final enabled =
await dbMigrated.sourceAppsDao.watchEnabledPackages(userId).first;
expect(enabled, contains('ru.sberbankmobile'));
});
}
-73
View File
@@ -1,73 +0,0 @@
import 'dart:io';
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:new_budget/src/core/database/app_database.dart';
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
/// Тест миграции v7 → v8 (parse_rules.tx_type для gate-проверки
/// typeMatchesRule).
///
/// Поднимаем актуальную (v8) схему, «откатываем» до v7 (убираем tx_type,
/// ставим user_version = 7). Повторное открытие запускает onUpgrade(7 → 8),
/// который должен восстановить колонку.
void main() {
late File file;
setUp(() {
final dir = Directory.systemTemp.createTempSync('nb_migration_v8_test');
file = File('${dir.path}/test.sqlite');
});
tearDown(() {
if (file.existsSync()) file.deleteSync();
final parent = file.parent;
if (parent.existsSync()) parent.deleteSync(recursive: true);
});
test('onUpgrade 7 → 8 добавляет parse_rules.tx_type', () async {
const userId = 'user-1';
// 1. Актуальная схема (v8) + FK-цепочка.
final dbV8 = AppDatabase.forTesting(NativeDatabase(file));
await dbV8.usersDao
.insertUser(UsersTableCompanion.insert(id: userId, name: 'Тест'));
// 2. «Откатываем» до v7.
await dbV8.customStatement('ALTER TABLE parse_rules DROP COLUMN tx_type');
await dbV8.customStatement('PRAGMA user_version = 7');
await dbV8.close();
// 3. Повторное открытие → onUpgrade(7 → 8).
final dbMigrated = AppDatabase.forTesting(NativeDatabase(file));
addTearDown(dbMigrated.close);
// 4. Колонка снова доступна: правило с txType проходит round-trip,
// легаси-правило без txType читается с null.
await dbMigrated.parseRulesDao.insert(
ParseRulesTableCompanion.insert(
id: 'r-typed',
userId: userId,
kind: ParseRuleKind.merchantToCategory,
pattern: 'LENTA',
txType: const Value(TransactionType.expense),
),
);
await dbMigrated.parseRulesDao.insert(
ParseRulesTableCompanion.insert(
id: 'r-legacy',
userId: userId,
kind: ParseRuleKind.merchantToCategory,
pattern: 'OZON',
),
);
final typed = await dbMigrated.parseRulesDao.findById('r-typed');
expect(typed!.txType, TransactionType.expense);
final legacy = await dbMigrated.parseRulesDao.findById('r-legacy');
expect(legacy!.txType, isNull);
});
}
@@ -84,15 +84,25 @@ void main() {
expect(filtered.map((t) => t.id), unorderedEquals(['2', '3']));
});
test('фильтр по обязательности (мульти) — required + unnecessary', () {
test('фильтр по обязательности — одиночный выбор unnecessary', () {
final c = makeContainer();
addTearDown(c.dispose);
c
.read(habitObligationFilterProvider.notifier)
.select(SpendingObligation.unnecessary);
final filtered = c.read(habitFilteredTransactionsProvider(userId));
expect(filtered.map((t) => t.id), ['2']);
});
test('select(null) сбрасывает фильтр обязательности на «Все»', () {
final c = makeContainer();
addTearDown(c.dispose);
final notifier = c.read(habitObligationFilterProvider.notifier);
notifier.toggle(SpendingObligation.required);
notifier.toggle(SpendingObligation.unnecessary);
notifier.select(SpendingObligation.optional);
notifier.select(null);
final filtered = c.read(habitFilteredTransactionsProvider(userId));
expect(filtered.map((t) => t.id), unorderedEquals(['1', '2', '4']));
expect(c.read(habitFilteredTransactionsProvider(userId)).length, 5);
});
test('фильтры комбинируются (impulsive AND unnecessary)', () {
@@ -103,7 +113,7 @@ void main() {
.select(SpendingImpulse.impulsive);
c
.read(habitObligationFilterProvider.notifier)
.toggle(SpendingObligation.unnecessary);
.select(SpendingObligation.unnecessary);
final filtered = c.read(habitFilteredTransactionsProvider(userId));
expect(filtered.map((t) => t.id), ['2']);
@@ -114,9 +124,51 @@ void main() {
addTearDown(c.dispose);
c
.read(habitObligationFilterProvider.notifier)
.toggle(SpendingObligation.required);
.select(SpendingObligation.required);
final filtered = c.read(habitFilteredTransactionsProvider(userId));
expect(filtered.map((t) => t.id), unorderedEquals(['1', '4']));
});
test('выбор импульса сбрасывает обязательность=required (каскад)', () {
final c = makeContainer();
addTearDown(c.dispose);
c
.read(habitObligationFilterProvider.notifier)
.select(SpendingObligation.required);
c
.read(habitImpulseFilterProvider.notifier)
.select(SpendingImpulse.impulsive);
expect(c.read(habitObligationFilterProvider), isNull);
final filtered = c.read(habitFilteredTransactionsProvider(userId));
expect(filtered.map((t) => t.id), unorderedEquals(['2', '3']));
});
test('выбор импульса не трогает обязательность ≠ required', () {
final c = makeContainer();
addTearDown(c.dispose);
c
.read(habitObligationFilterProvider.notifier)
.select(SpendingObligation.optional);
c
.read(habitImpulseFilterProvider.notifier)
.select(SpendingImpulse.impulsive);
expect(c.read(habitObligationFilterProvider), SpendingObligation.optional);
});
test('habitSumByObligation учитывает активный импульс-фильтр', () {
final c = makeContainer();
addTearDown(c.dispose);
c
.read(habitImpulseFilterProvider.notifier)
.select(SpendingImpulse.impulsive);
final map = c.read(habitSumByObligationProvider(userId));
expect(map[SpendingObligation.unnecessary], 2000);
expect(map[SpendingObligation.optional], 500);
expect(map.containsKey(SpendingObligation.required), isFalse);
expect(map.containsKey(null), isFalse);
});
}
@@ -0,0 +1,146 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:new_budget/l10n/app_localizations.dart';
import 'package:new_budget/src/app/theme/app_theme.dart';
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
import 'package:new_budget/src/features/accounts/application/accounts_controller.dart';
import 'package:new_budget/src/features/accounts/domain/entities/account.dart';
import 'package:new_budget/src/features/analytics/application/habit_analysis_providers.dart';
import 'package:new_budget/src/features/analytics/presentation/screens/habit_analysis_screen.dart';
import 'package:new_budget/src/features/categories/application/categories_controller.dart';
import 'package:new_budget/src/features/categories/domain/entities/category.dart';
import 'package:new_budget/src/features/transactions/domain/entities/transaction.dart';
import 'package:new_budget/src/features/user/application/active_user_controller.dart';
import 'package:new_budget/src/features/user/domain/entities/user.dart';
// ─── Fakes / данные ──────────────────────────────────────────────────────────
const _userId = 'test-uid';
class FakeActiveUserController extends ActiveUserController {
@override
Future<User?> build() async =>
User(id: _userId, name: 'Test', createdAt: DateTime(2024));
}
Transaction _tx(
String id,
int amount, {
SpendingObligation? obligation,
SpendingImpulse? impulse,
}) =>
Transaction(
id: id,
userId: _userId,
accountId: 'a',
type: TransactionType.expense,
amount: amount,
date: DateTime(2026, 6, 10),
obligation: obligation,
impulse: impulse,
createdAt: DateTime(2026, 6, 10),
);
final _txs = [
_tx('1', 1000, obligation: SpendingObligation.required),
_tx('2', 2000,
obligation: SpendingObligation.unnecessary,
impulse: SpendingImpulse.impulsive),
_tx('3', 500,
obligation: SpendingObligation.optional,
impulse: SpendingImpulse.considered),
];
Widget _buildScreen() => ProviderScope(
overrides: [
activeUserControllerProvider
.overrideWith(() => FakeActiveUserController()),
habitMonthTransactionsProvider(_userId).overrideWithValue(_txs),
categoriesStreamProvider(_userId)
.overrideWith((ref) => Stream<List<Category>>.value(const [])),
accountsStreamProvider(_userId)
.overrideWith((ref) => Stream<List<Account>>.value(const [])),
],
child: MaterialApp(
theme: AppTheme.light(),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const HabitAnalysisScreen(),
),
);
ProviderContainer _container(WidgetTester tester) =>
ProviderScope.containerOf(tester.element(find.byType(HabitAnalysisScreen)));
/// Сегмент «Impulse» в строке фильтров (первый — пилюли в списке ниже).
Finder _impulseSegment() => find.text('Impulse').first;
// ─── Tests ───────────────────────────────────────────────────────────────────
void main() {
setUpAll(() {
GoogleFonts.config.allowRuntimeFetching = false;
});
testWidgets('по умолчанию: оба «All» активны, чип «Necessary» виден',
(tester) async {
await tester.pumpWidget(_buildScreen());
await tester.pump(); // activeUserControllerProvider разрешается
// «All» в обеих строках фильтров.
expect(find.text('All'), findsNWidgets(2));
// Чип обязательности + пилюля tx1 в списке.
expect(find.text('Necessary'), findsNWidgets(2));
});
testWidgets('выбор «Impulse» скрывает чип «Necessary»', (tester) async {
await tester.pumpWidget(_buildScreen());
await tester.pump();
await tester.tap(_impulseSegment());
// AnimatedSize — доигрываем анимацию.
await tester.pumpAndSettle();
// Чип скрыт, tx1 отфильтрована — «Necessary» нет нигде.
expect(find.text('Necessary'), findsNothing);
// Возврат на «All» импульсивности возвращает чип.
await tester.tap(find.text('All').first);
await tester.pumpAndSettle();
expect(find.text('Necessary'), findsNWidgets(2));
});
testWidgets('выбор «Impulse» сбрасывает выбранную «Necessary» (каскад)',
(tester) async {
await tester.pumpWidget(_buildScreen());
await tester.pump();
await tester.tap(find.text('Necessary').first);
await tester.pumpAndSettle();
final container = _container(tester);
expect(
container.read(habitObligationFilterProvider), SpendingObligation.required);
await tester.tap(_impulseSegment());
await tester.pumpAndSettle();
expect(container.read(habitObligationFilterProvider), isNull);
});
testWidgets('повторный тап по чипу обязательности снимает фильтр',
(tester) async {
await tester.pumpWidget(_buildScreen());
await tester.pump();
await tester.tap(find.text('Unnecessary').first);
await tester.pumpAndSettle();
final container = _container(tester);
expect(container.read(habitObligationFilterProvider),
SpendingObligation.unnecessary);
await tester.tap(find.text('Unnecessary').first);
await tester.pumpAndSettle();
expect(container.read(habitObligationFilterProvider), isNull);
});
}
@@ -3,7 +3,6 @@ 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/data/parser/draft_codec.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';
@@ -130,8 +129,7 @@ class _FakeParseRulesRepo implements ParseRulesRepository {
final List<Map<String, Object?>> created = [];
final List<String> deletedIds = [];
/// Правила, которые вернёт [getByUser] (для проверки удаления существующего
/// merchant→category правила при пометке мерчанта как mixed).
/// Правила, которые вернёт [getByUser].
List<ParseRule> existing = const [];
@override
@@ -140,9 +138,17 @@ class _FakeParseRulesRepo implements ParseRulesRepository {
@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,
@@ -153,6 +159,7 @@ class _FakeParseRulesRepo implements ParseRulesRepository {
String? accountId,
}) async {
created.add({
'packageName': packageName,
'kind': kind,
'matchMode': matchMode,
'pattern': pattern,
@@ -163,6 +170,7 @@ class _FakeParseRulesRepo implements ParseRulesRepository {
return ParseRule(
id: 'rule1',
userId: userId,
packageName: packageName,
kind: kind,
matchMode: matchMode,
pattern: pattern,
@@ -296,6 +304,8 @@ void main() {
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);
@@ -352,54 +362,14 @@ void main() {
});
});
group('markMerchantMixed', () {
test('creates mixedMerchant rule, drops candidate, re-caches without '
'suggestion', () async {
await controller().markMerchantMixed(
userId: _userId,
message: _message(),
bundle: DraftBundle(draft: _draft()),
merchantCanonical: 'PYATEROCHKA',
);
group('markApplied', () {
test('links message to a transaction saved via the full form', () async {
await controller().markApplied(_message(), 'tx-9');
expect(rulesRepo.created, hasLength(1));
expect(rulesRepo.created.single['kind'], ParseRuleKind.mixedMerchant);
expect(rulesRepo.created.single['pattern'], 'PYATEROCHKA');
// Кандидат снят, чтобы не предлагать правило снова.
expect(candidatesRepo.deleted, contains((_userId, 'PYATEROCHKA')));
// draftJson переписан без suggestion → карточка переключится на confirm.
expect(rawRepo.afterParse, hasLength(1));
final draftJson = rawRepo.afterParse.single['draftJson'] as String;
expect(decodeDraftBundle(draftJson)?.suggestion, isNull);
// Нет транзакции — пользователь категоризует вручную.
expect(rawRepo.linked, contains(('msg1', 'tx-9')));
// Только линковка: ни транзакций, ни правил контроллер не создаёт.
expect(txRepo.created, isEmpty);
});
test('removes an existing merchant→category rule for the merchant',
() async {
rulesRepo.existing = [
ParseRule(
id: 'old-rule',
userId: _userId,
kind: ParseRuleKind.merchantToCategory,
matchMode: MatchMode.contains,
pattern: 'PYATEROCHKA',
categoryId: 'cat1',
createdAt: _now,
),
];
await controller().markMerchantMixed(
userId: _userId,
message: _message(),
bundle: DraftBundle(draft: _draft()),
merchantCanonical: 'PYATEROCHKA',
);
expect(rulesRepo.deletedIds, contains('old-rule'));
expect(rulesRepo.created, isEmpty);
});
});
@@ -0,0 +1,212 @@
import 'dart:async';
import 'dart:convert';
import 'package:drift/native.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:new_budget/src/core/database/app_database.dart';
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
import 'package:new_budget/src/core/providers/database_provider.dart';
import 'package:new_budget/src/features/accounts/application/account_providers.dart';
import 'package:new_budget/src/features/notification_parsing/application/ai_providers.dart';
import 'package:new_budget/src/features/notification_parsing/application/notification_parsing_providers.dart';
import 'package:new_budget/src/features/notification_parsing/application/parsing_settings_controller.dart';
import 'package:new_budget/src/features/notification_parsing/application/parsing_worker.dart';
import 'package:new_budget/src/features/notification_parsing/data/deepseek/deepseek_client.dart';
import 'package:new_budget/src/features/notification_parsing/data/parser/ai_parser.dart';
import 'package:new_budget/src/features/notification_parsing/data/parser/draft_codec.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/raw_messages_repository.dart';
/// Сквозные тесты per-app скоупа правил (parse_rules.packageName): правило,
/// созданное для одного приложения-источника, не должно срабатывать на
/// сообщениях другого. AI замокан (без сети) и отдаёт фиксированный draft-JSON.
const _userId = 'u1';
const _accountId = 'acc-default';
const _bankA = 'ru.sberbankmobile';
const _bankB = 'com.idamob.tinkoff.android';
AiParser _fakeAiParser({required String merchantRaw, required num amount}) {
final mock = MockClient((req) async {
final content = jsonEncode({
'type': 'expense',
'kind': 'purchase',
'amount': amount,
'currency': 'RUB',
'merchantRaw': merchantRaw,
});
return http.Response(
jsonEncode({
'choices': [
{
'message': {'content': content},
}
],
'usage': {'total_tokens': 42},
}),
200,
headers: {'content-type': 'application/json'},
);
});
return AiParser(DeepSeekClient(client: mock, apiKey: 'k'));
}
Future<void> _seed(AppDatabase db) async {
await db.usersDao.insertUser(
UsersTableCompanion.insert(id: _userId, name: 'Test'),
);
await db.accountsDao.insertAccount(
AccountsTableCompanion.insert(id: _accountId, userId: _userId, name: 'Main'),
);
// Оба банка в allowlist — до правил доходят сообщения обоих.
await db.sourceAppsDao.insert(SourceAppsTableCompanion.insert(
id: 'src-a',
userId: _userId,
packageName: _bankA,
));
await db.sourceAppsDao.insert(SourceAppsTableCompanion.insert(
id: 'src-b',
userId: _userId,
packageName: _bankB,
));
}
void _activateWorker(ProviderContainer c) {
c.read(parsingWorkerProvider(_userId));
}
Future<RawMessage> _waitTerminal(
RawMessagesRepository repo,
String id, {
Duration timeout = const Duration(seconds: 10),
}) async {
const transient = {
RawMessageStatus.pending,
RawMessageStatus.parsing,
RawMessageStatus.pendingAi,
};
final completer = Completer<RawMessage>();
late final StreamSubscription<List<RawMessage>> sub;
sub = repo.watchAll(_userId).listen((list) {
for (final m in list) {
if (m.id == id && !transient.contains(m.status)) {
if (!completer.isCompleted) completer.complete(m);
return;
}
}
});
try {
return await completer.future.timeout(timeout);
} finally {
await sub.cancel();
}
}
void main() {
late AppDatabase db;
late ProviderContainer container;
late RawMessagesRepository repo;
Future<void> bootstrap() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
await _seed(db);
container = ProviderContainer(overrides: [
appDatabaseProvider.overrideWithValue(db),
isOnlineProvider.overrideWith((ref) => Stream<bool>.value(true)),
aiParserProvider.overrideWith(
(ref) async => _fakeAiParser(merchantRaw: 'LENTA', amount: 1500)),
]);
repo = container.read(rawMessagesRepositoryProvider);
final settings = container.read(parsingSettingsControllerProvider.notifier);
await container.read(parsingSettingsControllerProvider.future);
await settings.setAiConsent(true);
await container
.read(accountRepositoryProvider)
.setDefault(_accountId, _userId);
}
tearDown(() async {
container.dispose();
await db.close();
});
test('ignore-правило bankA: bankA → ignored, то же тело от bankB → inbox',
() async {
await bootstrap();
await container.read(parseRulesRepositoryProvider).create(
userId: _userId,
packageName: _bankA,
kind: ParseRuleKind.ignore,
matchMode: MatchMode.contains,
pattern: 'Доставлен заказ',
);
_activateWorker(container);
const body = 'Доставлен заказ №123 на сумму 1500 руб';
final fromA = await repo.insertIncoming(
userId: _userId,
packageName: _bankA,
body: body,
receivedAt: DateTime(2026, 7, 1, 12),
);
expect((await _waitTerminal(repo, fromA.id)).status,
RawMessageStatus.ignored,
reason: 'ignore-правило действует в своём приложении (pre-AI путь)');
final fromB = await repo.insertIncoming(
userId: _userId,
packageName: _bankB,
body: body,
receivedAt: DateTime(2026, 7, 1, 13),
);
expect(
(await _waitTerminal(repo, fromB.id)).status, RawMessageStatus.inbox,
reason: 'ignore-правило bankA не должно протекать в bankB');
});
test('merchant-правило bankA: тот же мерчант от bankB → inbox с suggestion',
() async {
await bootstrap();
await db.categoriesDao.insertCategory(CategoriesTableCompanion.insert(
id: 'cat-1',
userId: _userId,
name: 'Продукты',
));
await container.read(parseRulesRepositoryProvider).create(
userId: _userId,
packageName: _bankA,
kind: ParseRuleKind.merchantToCategory,
matchMode: MatchMode.contains,
pattern: 'LENTA',
txType: TransactionType.expense,
categoryId: 'cat-1',
);
_activateWorker(container);
final fromB = await repo.insertIncoming(
userId: _userId,
packageName: _bankB,
body: 'Payment of 1500 RUB at LENTA',
receivedAt: DateTime(2026, 7, 1, 12),
);
final msg = await _waitTerminal(repo, fromB.id);
expect(msg.status, RawMessageStatus.inbox,
reason: 'правило bankA не видно в bankB → нет auto-apply');
// Мерчант «незнакомый» в скоупе bankB → pipeline предлагает создать правило.
final bundle = decodeDraftBundle(msg.draftJson);
expect(bundle, isNotNull);
expect(bundle!.suggestion, isNotNull,
reason: 'для незнакомого в этом приложении мерчанта нужна suggestion');
expect(bundle.suggestion!.merchantCanonical, 'LENTA');
// Транзакция не создана — правило чужого приложения не применилось.
final txns = await db.select(db.transactionsTable).get();
expect(txns, isEmpty);
});
}
@@ -1,6 +1,7 @@
import 'dart:async';
import 'dart:convert';
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
@@ -20,16 +21,21 @@ import 'package:new_budget/src/features/notification_parsing/domain/entities/raw
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
import 'package:new_budget/src/features/notification_parsing/domain/repositories/raw_messages_repository.dart';
/// Сквозные тесты подавления предложения правила для mixed-мерчанта (§9.1):
/// знакомый мерчант без маркера → Inbox с suggestion; с маркером
/// `mixedMerchant` → Inbox без suggestion (карточка покажет «подтвердить
/// разово»). AI замокан (без сети) и отдаёт фиксированный draft-JSON.
/// Сквозные тесты источников с флагом «мерчант — само приложение»
/// (source_apps.selfMerchant): без флага → Inbox с предложением правила;
/// с флагом → Inbox без suggestion и без AI-подсказки категории (карточка
/// покажет ручной выбор категории). AI замокан (без сети) и отдаёт
/// фиксированный draft-JSON.
const _userId = 'u1';
const _accountId = 'acc-default';
const _bank = 'ru.sberbankmobile';
AiParser _fakeAiParser({required String merchantRaw, required num amount}) {
AiParser _fakeAiParser({
required String merchantRaw,
required num amount,
String? categorySuggestion,
}) {
final mock = MockClient((req) async {
final content = jsonEncode({
'type': 'expense',
@@ -37,6 +43,7 @@ AiParser _fakeAiParser({required String merchantRaw, required num amount}) {
'amount': amount,
'currency': 'RUB',
'merchantRaw': merchantRaw,
'categorySuggestion': ?categorySuggestion,
});
return http.Response(
jsonEncode({
@@ -54,7 +61,7 @@ AiParser _fakeAiParser({required String merchantRaw, required num amount}) {
return AiParser(DeepSeekClient(client: mock, apiKey: 'k'));
}
Future<void> _seed(AppDatabase db) async {
Future<void> _seed(AppDatabase db, {bool selfMerchant = false}) async {
await db.usersDao.insertUser(
UsersTableCompanion.insert(id: _userId, name: 'Test'),
);
@@ -63,12 +70,15 @@ Future<void> _seed(AppDatabase db) async {
);
await db.sourceAppsDao.insert(
SourceAppsTableCompanion.insert(
id: 'src-1', userId: _userId, packageName: _bank),
id: 'src-1',
userId: _userId,
packageName: _bank,
selfMerchant: Value(selfMerchant),
),
);
}
void _activateWorker(ProviderContainer c) {
c.listen(pendingMessagesProvider(_userId), (_, _) {}, fireImmediately: true);
c.read(parsingWorkerProvider(_userId));
}
@@ -104,14 +114,19 @@ void main() {
late ProviderContainer container;
late RawMessagesRepository repo;
Future<void> bootstrap() async {
Future<void> bootstrap({
bool selfMerchant = false,
String? categorySuggestion,
}) async {
db = AppDatabase.forTesting(NativeDatabase.memory());
await _seed(db);
await _seed(db, selfMerchant: selfMerchant);
container = ProviderContainer(overrides: [
appDatabaseProvider.overrideWithValue(db),
isOnlineProvider.overrideWith((ref) => Stream<bool>.value(true)),
aiParserProvider.overrideWith(
(ref) async => _fakeAiParser(merchantRaw: 'OZON', amount: 1500)),
aiParserProvider.overrideWith((ref) async => _fakeAiParser(
merchantRaw: 'OZON',
amount: 1500,
categorySuggestion: categorySuggestion)),
]);
repo = container.read(rawMessagesRepositoryProvider);
final settings = container.read(parsingSettingsControllerProvider.notifier);
@@ -127,8 +142,7 @@ void main() {
await db.close();
});
test('знакомый мерчант без mixed-маркера → Inbox с предложением правила',
() async {
test('источник без флага → Inbox с предложением правила', () async {
await bootstrap();
_activateWorker(container);
@@ -145,20 +159,12 @@ void main() {
final bundle = decodeDraftBundle(msg.draftJson);
expect(bundle, isNotNull);
expect(bundle!.suggestion, isNotNull,
reason: 'без маркера должно быть предложение правила');
reason: 'без флага должно быть предложение правила');
expect(bundle.suggestion!.merchantCanonical, 'OZON');
});
test('mixedMerchant-маркер → Inbox без предложения правила (suggestion null)',
() async {
await bootstrap();
// Пользователь пометил мерчанта как «категории различаются».
await container.read(parseRulesRepositoryProvider).create(
userId: _userId,
kind: ParseRuleKind.mixedMerchant,
matchMode: MatchMode.contains,
pattern: 'OZON',
);
test('selfMerchant-источник → Inbox без предложения правила', () async {
await bootstrap(selfMerchant: true);
_activateWorker(container);
final inserted = await repo.insertIncoming(
@@ -169,15 +175,37 @@ void main() {
);
final msg = await _waitTerminal(repo, inserted.id);
// Маркер не применяет действий → не авто-применяется, остаётся в Inbox.
// Правила нет → не авто-применяется, остаётся в Inbox.
expect(msg.status, RawMessageStatus.inbox);
final bundle = decodeDraftBundle(msg.draftJson);
expect(bundle, isNotNull);
expect(bundle!.suggestion, isNull,
reason: 'маркер mixedMerchant должен подавлять предложение правила');
reason: 'флаг selfMerchant должен подавлять предложение правила');
// Транзакция не создаётся — пользователь категоризует вручную.
final txns = await db.select(db.transactionsTable).get();
expect(txns, isEmpty);
});
test('selfMerchant-источник глушит AI-подсказку категории в бандле',
() async {
await bootstrap(selfMerchant: true, categorySuggestion: 'Продукты');
_activateWorker(container);
final inserted = await repo.insertIncoming(
userId: _userId,
packageName: _bank,
body: 'Покупка 1500 RUB OZON',
receivedAt: DateTime(2026, 5, 31, 12),
);
final msg = await _waitTerminal(repo, inserted.id);
expect(msg.status, RawMessageStatus.inbox);
final bundle = decodeDraftBundle(msg.draftJson);
expect(bundle, isNotNull);
expect(bundle!.draft.categorySuggestion, isNull,
reason: 'AI-подсказка категории должна глушиться для selfMerchant');
expect(bundle.suggestion, isNull);
});
}
@@ -73,7 +73,6 @@ Future<void> _seed(AppDatabase db) async {
}
void _activateWorker(ProviderContainer c) {
c.listen(pendingMessagesProvider(_userId), (_, _) {}, fireImmediately: true);
c.read(parsingWorkerProvider(_userId));
}
@@ -177,6 +176,7 @@ void main() {
// Ignore-правило: «Доставлен заказ» пропускать.
await container.read(parseRulesRepositoryProvider).create(
userId: _userId,
packageName: _bank,
kind: ParseRuleKind.ignore,
matchMode: MatchMode.contains,
pattern: 'Доставлен заказ',
@@ -229,6 +229,7 @@ void main() {
));
await container.read(parseRulesRepositoryProvider).create(
userId: _userId,
packageName: _bank,
kind: ParseRuleKind.merchantToCategory,
matchMode: MatchMode.contains,
pattern: 'LENTA',
@@ -0,0 +1,434 @@
import 'dart:async';
import 'dart:convert';
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:new_budget/src/core/database/app_database.dart';
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
import 'package:new_budget/src/core/providers/database_provider.dart';
import 'package:new_budget/src/features/notification_parsing/application/ai_providers.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/application/parsing_pipeline.dart';
import 'package:new_budget/src/features/notification_parsing/application/parsing_settings_controller.dart';
import 'package:new_budget/src/features/notification_parsing/application/parsing_worker.dart';
import 'package:new_budget/src/features/notification_parsing/data/deepseek/deepseek_client.dart';
import 'package:new_budget/src/features/notification_parsing/data/parser/ai_parser.dart';
import 'package:new_budget/src/features/notification_parsing/data/parser/draft_codec.dart';
import 'package:new_budget/src/features/notification_parsing/data/parser/transfer_pair_matcher.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/raw_messages_repository.dart';
import 'package:new_budget/src/features/transactions/application/transaction_providers.dart';
/// Сквозные тесты склейки переводов (transfer pairing): waitingPair → sweep →
/// merged-карточка / доклейка / релиз по таймауту / расклейка. AI замокан:
/// по телу сообщения возвращает transfer_out («Вы перевели …») либо
/// transfer_in («Пополнение …»).
const _userId = 'u1';
const _accA = 'acc-a';
const _accB = 'acc-b';
const _bankA = 'ru.sberbankmobile';
const _bankB = 'com.idamob.tinkoff.android';
const _outBody = 'Вы перевели 5000 ₽ на счёт в другом банке';
const _inBody = 'Пополнение 5000 ₽ переводом из банка';
const _amountMinor = 500000;
AiParser _pairAiParser() {
final mock = MockClient((req) async {
// Матчим ПОЛНЫЙ текст уведомления: system-промпт сам содержит слова
// «перевели»/«Пополнение» (правила выбора kind), по подстроке нельзя.
final isOut = req.body.contains(_outBody);
final content = jsonEncode({
'type': isOut ? 'expense' : 'income',
'kind': isOut ? 'transfer_out' : 'transfer_in',
'amount': 5000,
'currency': 'RUB',
});
return http.Response(
jsonEncode({
'choices': [
{
'message': {'content': content},
}
],
'usage': {'total_tokens': 42},
}),
200,
headers: {'content-type': 'application/json'},
);
});
return AiParser(DeepSeekClient(client: mock, apiKey: 'k'));
}
Future<void> _seed(AppDatabase db) async {
await db.usersDao.insertUser(
UsersTableCompanion.insert(id: _userId, name: 'Test'),
);
await db.accountsDao.insertAccount(
AccountsTableCompanion.insert(id: _accA, userId: _userId, name: 'A'),
);
await db.accountsDao.insertAccount(
AccountsTableCompanion.insert(id: _accB, userId: _userId, name: 'B'),
);
await db.sourceAppsDao.insert(SourceAppsTableCompanion.insert(
id: 'src-a',
userId: _userId,
packageName: _bankA,
));
await db.sourceAppsDao.insert(SourceAppsTableCompanion.insert(
id: 'src-b',
userId: _userId,
packageName: _bankB,
));
}
void _activateWorker(ProviderContainer c) {
c.read(parsingWorkerProvider(_userId));
}
/// Ждёт, пока сообщение [id] не пройдёт [test] (по стриму watchAll).
Future<RawMessage> _waitFor(
RawMessagesRepository repo,
String id,
bool Function(RawMessage) test, {
Duration timeout = const Duration(seconds: 10),
}) async {
final completer = Completer<RawMessage>();
late final StreamSubscription<List<RawMessage>> sub;
sub = repo.watchAll(_userId).listen((list) {
for (final m in list) {
if (m.id == id && test(m)) {
if (!completer.isCompleted) completer.complete(m);
return;
}
}
});
try {
return await completer.future.timeout(
timeout,
onTimeout: () async {
final m = await repo.findById(id);
throw TimeoutException(
'message $id stuck: status=${m?.status}, paired=${m?.pairedWithId}');
},
);
} finally {
await sub.cancel();
}
}
void main() {
late AppDatabase db;
late ProviderContainer container;
late RawMessagesRepository repo;
Future<void> bootstrap() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
await _seed(db);
container = ProviderContainer(overrides: [
appDatabaseProvider.overrideWithValue(db),
isOnlineProvider.overrideWith((ref) => Stream<bool>.value(true)),
aiParserProvider.overrideWith((ref) async => _pairAiParser()),
]);
repo = container.read(rawMessagesRepositoryProvider);
final settings = container.read(parsingSettingsControllerProvider.notifier);
await container.read(parsingSettingsControllerProvider.future);
await settings.setAiConsent(true);
// Однозначные привязки: bankA → accA, bankB → accB (resolver #3, trusted).
final bindings = container.read(accountBindingsRepositoryProvider);
await bindings.create(
userId: _userId, packageName: _bankA, accountId: _accA);
await bindings.create(
userId: _userId, packageName: _bankB, accountId: _accB);
}
tearDown(() async {
container.dispose();
await db.close();
});
Future<void> forceDeadlinePast(String id) => (db.update(db.rawMessagesTable)
..where((t) => t.id.equals(id)))
.write(RawMessagesTableCompanion(
pairDeadline: Value(DateTime.now().subtract(const Duration(minutes: 1))),
));
/// out (bankA) + in (bankB) в окне → склейка воркером.
Future<(RawMessage out, RawMessage inn)> mergePairFlow() async {
final t0 = DateTime(2026, 7, 1, 12);
final out = await repo.insertIncoming(
userId: _userId,
packageName: _bankA,
body: _outBody,
receivedAt: t0,
);
final inn = await repo.insertIncoming(
userId: _userId,
packageName: _bankB,
body: _inBody,
receivedAt: t0.add(const Duration(minutes: 1)),
);
final mergedOut = await _waitFor(
repo,
out.id,
(m) => m.status == RawMessageStatus.inbox && m.pairedWithId == inn.id,
);
final pairedIn = await _waitFor(
repo,
inn.id,
(m) => m.status == RawMessageStatus.paired,
);
return (mergedOut, pairedIn);
}
test('склейка: out+in в окне → primary inbox (merged draft), secondary paired',
() async {
await bootstrap();
_activateWorker(container);
final (out, inn) = await mergePairFlow();
final bundle = decodeDraftBundle(out.draftJson)!;
expect(bundle.pairedRawMessageId, inn.id);
expect(bundle.draft.type, TransactionType.transfer);
expect(bundle.draft.accountId, _accA);
expect(bundle.draft.transferToAccountId, _accB);
expect(bundle.draft.categoryId, isNull);
expect(bundle.preMerge?['type'], 'expense',
reason: 'снапшот исходного типа для расклейки');
expect(inn.pairedWithId, out.id);
final innBundle = decodeDraftBundle(inn.draftJson)!;
expect(innBundle.draft.kind, TxKind.transferIn,
reason: 'одиночный draft secondary не перезаписывается');
expect(innBundle.pairedRawMessageId, isNull);
// Транзакций нет — склеенная пара всегда ждёт подтверждения.
expect(await db.select(db.transactionsTable).get(), isEmpty);
});
test('confirmPair → одна transfer-транзакция, обе половинки applied',
() async {
await bootstrap();
_activateWorker(container);
final (out, _) = await mergePairFlow();
final bundle = decodeDraftBundle(out.draftJson)!;
await container.read(inboxControllerProvider.notifier).confirmPair(
userId: _userId,
message: out,
draft: bundle.draft,
secondaryId: bundle.pairedRawMessageId!,
fromAccountId: _accA,
toAccountId: _accB,
);
final txns = await db.select(db.transactionsTable).get();
expect(txns, hasLength(1));
expect(txns.single.type, TransactionType.transfer);
expect(txns.single.accountId, _accA);
expect(txns.single.transferToAccountId, _accB);
expect(txns.single.amount, _amountMinor);
expect(txns.single.rawMessageId, out.id);
final outAfter = await repo.findById(out.id);
final innAfter = await repo.findById(bundle.pairedRawMessageId!);
expect(outAfter!.status, RawMessageStatus.applied);
expect(innAfter!.status, RawMessageStatus.applied);
expect(outAfter.transactionId, txns.single.id);
expect(innAfter.transactionId, txns.single.id);
});
test('таймаут: релиз в Inbox одиночным черновиком, draft не затёрт',
() async {
await bootstrap();
_activateWorker(container);
final out = await repo.insertIncoming(
userId: _userId,
packageName: _bankA,
body: _outBody,
receivedAt: DateTime(2026, 7, 1, 12),
);
final waiting = await _waitFor(
repo, out.id, (m) => m.status == RawMessageStatus.waitingPair);
expect(waiting.pairDeadline, isNotNull);
await forceDeadlinePast(out.id);
await container.read(parsingPipelineProvider).sweepPairs(_userId);
final released = await _waitFor(
repo, out.id, (m) => m.status == RawMessageStatus.inbox);
final bundle = decodeDraftBundle(released.draftJson)!;
expect(bundle.draft.kind, TxKind.transferOut);
expect(bundle.draft.amount, _amountMinor);
expect(bundle.pairedRawMessageId, isNull);
expect(released.confidenceAmount, isNotNull,
reason: 'релиз не должен затирать confidence-оценки');
});
test('inbox-контрпартнёр: релизнутая половинка склеивается со второй',
() async {
await bootstrap();
_activateWorker(container);
final t0 = DateTime(2026, 7, 1, 12);
final out = await repo.insertIncoming(
userId: _userId,
packageName: _bankA,
body: _outBody,
receivedAt: t0,
);
await _waitFor(repo, out.id, (m) => m.status == RawMessageStatus.waitingPair);
await forceDeadlinePast(out.id);
await container.read(parsingPipelineProvider).sweepPairs(_userId);
await _waitFor(repo, out.id, (m) => m.status == RawMessageStatus.inbox);
// Вторая половинка приходит в окне receivedAt первой (которая уже в Inbox).
final inn = await repo.insertIncoming(
userId: _userId,
packageName: _bankB,
body: _inBody,
receivedAt: t0.add(const Duration(minutes: 4)),
);
final mergedOut = await _waitFor(
repo,
out.id,
(m) => m.status == RawMessageStatus.inbox && m.pairedWithId == inn.id,
);
final pairedIn = await _waitFor(
repo, inn.id, (m) => m.status == RawMessageStatus.paired);
expect(pairedIn.pairedWithId, out.id);
final bundle = decodeDraftBundle(mergedOut.draftJson)!;
expect(bundle.draft.type, TransactionType.transfer);
expect(bundle.draft.transferToAccountId, _accB);
});
test('доклейка: applied-контрпартнёр конвертируется в transfer + откат',
() async {
await bootstrap();
final settings = container.read(parsingSettingsControllerProvider.notifier);
// Первая половинка проходит обычным путём (пейринг выключен) и
// подтверждается пользователем как обычный расход.
await settings.setTransferPairingEnabled(false);
_activateWorker(container);
final t0 = DateTime(2026, 7, 1, 12);
final out = await repo.insertIncoming(
userId: _userId,
packageName: _bankA,
body: _outBody,
receivedAt: t0,
);
final outInbox = await _waitFor(
repo, out.id, (m) => m.status == RawMessageStatus.inbox);
final outBundle = decodeDraftBundle(outInbox.draftJson)!;
await container.read(inboxControllerProvider.notifier).confirmOnce(
userId: _userId,
message: outInbox,
draft: outBundle.draft,
accountId: _accA,
);
await _waitFor(repo, out.id,
(m) => m.status == RawMessageStatus.applied && m.transactionId != null);
await settings.setTransferPairingEnabled(true);
final inn = await repo.insertIncoming(
userId: _userId,
packageName: _bankB,
body: _inBody,
receivedAt: t0.add(const Duration(minutes: 2)),
);
final merged = await _waitFor(
repo,
inn.id,
(m) => m.status == RawMessageStatus.applied && m.transactionId != null,
);
final txRepo = container.read(transactionRepositoryProvider);
final tx = (await txRepo.findById(merged.transactionId!))!;
expect(tx.type, TransactionType.transfer);
expect(tx.accountId, _accA);
expect(tx.transferToAccountId, _accB);
expect(tx.categoryId, isNull);
final innBundle = decodeDraftBundle(merged.draftJson)!;
expect(innBundle.mergeUndo, isNotNull);
expect(innBundle.mergeUndo!['prevType'], 'expense');
expect(merged.pairedWithId, out.id);
// Откат доклейки из журнала.
await container.read(inboxControllerProvider.notifier).unmergeApplied(
userId: _userId,
message: merged,
);
final restoredTx = (await txRepo.findById(tx.id))!;
expect(restoredTx.type, TransactionType.expense);
expect(restoredTx.transferToAccountId, isNull);
final innAfter = (await repo.findById(inn.id))!;
expect(innAfter.status, RawMessageStatus.inbox);
expect(innAfter.transactionId, isNull);
expect(innAfter.pairedWithId, isNull);
expect(decodeDraftBundle(innAfter.draftJson)!.mergeUndo, isNull);
final blocked = await container
.read(transferPairingBlocklistRepositoryProvider)
.contains(_userId, pairSignature(out.id, inn.id));
expect(blocked, isTrue);
});
test('расклейка из Inbox: preMerge восстановлен, повторный матч заблокирован',
() async {
await bootstrap();
_activateWorker(container);
final (out, inn) = await mergePairFlow();
await container.read(inboxControllerProvider.notifier).unpair(
userId: _userId,
message: out,
);
final outAfter = (await repo.findById(out.id))!;
expect(outAfter.status, RawMessageStatus.inbox);
expect(outAfter.pairedWithId, isNull);
final outBundle = decodeDraftBundle(outAfter.draftJson)!;
expect(outBundle.pairedRawMessageId, isNull);
expect(outBundle.draft.type, TransactionType.expense,
reason: 'тип восстановлен из preMerge');
expect(outBundle.draft.transferToAccountId, isNull);
final innAfter = (await repo.findById(inn.id))!;
expect(innAfter.status, RawMessageStatus.inbox);
expect(innAfter.pairedWithId, isNull);
expect(decodeDraftBundle(innAfter.draftJson)!.draft.kind,
TxKind.transferIn);
final blocked = await container
.read(transferPairingBlocklistRepositoryProvider)
.contains(_userId, pairSignature(out.id, inn.id));
expect(blocked, isTrue);
// Повторный матч заблокирован: возвращаем secondary в ожидание пары —
// sweep не должен склеить её с primary, стоящим в Inbox.
await repo.holdForPairing(
id: inn.id,
draftJson: innAfter.draftJson!,
deadline: DateTime.now().add(const Duration(minutes: 5)),
);
await container.read(parsingPipelineProvider).sweepPairs(_userId);
final stillWaiting = (await repo.findById(inn.id))!;
expect(stillWaiting.status, RawMessageStatus.waitingPair,
reason: 'blocklist не даёт склеить ту же пару снова');
});
}
@@ -0,0 +1,119 @@
import 'package:drift/drift.dart' hide isNull, isNotNull;
import 'package:drift/native.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:new_budget/src/core/database/app_database.dart';
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
import 'package:new_budget/src/features/notification_parsing/data/drift/daos/raw_messages_dao.dart';
import 'package:new_budget/src/features/notification_parsing/data/repositories/raw_messages_repository_impl.dart';
/// `watchTopCategoryIds` — данные для чипов «частых категорий» на карточке
/// Inbox: категории подтверждённых транзакций из уведомлений одного
/// приложения, по числу транзакций (при равенстве — по свежести), максимум 4.
const _userId = 'u1';
const _accountId = 'acc1';
const _ozon = 'ru.ozon.app.android';
const _bank = 'ru.sberbankmobile';
void main() {
late AppDatabase db;
late RawMessagesRepositoryImpl repo;
var seq = 0;
setUp(() async {
db = AppDatabase.forTesting(NativeDatabase.memory());
repo = RawMessagesRepositoryImpl(RawMessagesDao(db));
seq = 0;
// FK-цепочка: user → account → categories.
await db.usersDao.insertUser(
UsersTableCompanion.insert(id: _userId, name: 'Test'),
);
await db.accountsDao.insertAccount(
AccountsTableCompanion.insert(id: _accountId, userId: _userId, name: 'Main'),
);
for (final id in ['cat-a', 'cat-b', 'cat-c', 'cat-d', 'cat-e']) {
await db.categoriesDao.insertCategory(
CategoriesTableCompanion.insert(id: id, userId: _userId, name: id),
);
}
});
tearDown(() => db.close());
/// Транзакция, «подтверждённая» из уведомления [packageName]
/// (rawMessageId != null); при packageName == null — ручная, без сообщения.
Future<void> confirmTx({
String? packageName = _ozon,
String? categoryId,
required DateTime date,
TransactionType type = TransactionType.expense,
}) async {
final n = seq++;
String? msgId;
if (packageName != null) {
final msg = await repo.insertIncoming(
userId: _userId,
packageName: packageName,
body: 'Покупка №$n',
receivedAt: date,
);
msgId = msg.id;
}
await db.into(db.transactionsTable).insert(
TransactionsTableCompanion.insert(
id: 'tx-$n',
userId: _userId,
accountId: _accountId,
categoryId: Value(categoryId),
type: Value(type),
amount: 100,
date: date,
rawMessageId: Value(msgId),
),
);
}
test('порядок: по частоте, при равенстве — по свежести; лимит 4', () async {
DateTime day(int d) => DateTime(2026, 6, d);
// cat-a ×3, cat-b ×2; cat-c/d/e ×1 — свежесть e > c > d.
for (final d in [1, 2, 3]) {
await confirmTx(categoryId: 'cat-a', date: day(d));
}
for (final d in [4, 5]) {
await confirmTx(categoryId: 'cat-b', date: day(d));
}
await confirmTx(categoryId: 'cat-d', date: day(6));
await confirmTx(categoryId: 'cat-c', date: day(7));
await confirmTx(categoryId: 'cat-e', date: day(8));
final top = await repo
.watchTopCategoryIds(_userId, _ozon, TransactionType.expense)
.first;
// cat-d (5-я по рангу) не влезла в лимит 4.
expect(top, ['cat-a', 'cat-b', 'cat-e', 'cat-c']);
});
test('фильтры: тип, пакет, ручные транзакции и null-категории не считаются',
() async {
final date = DateTime(2026, 6, 10);
await confirmTx(categoryId: 'cat-a', date: date);
// Не должны попасть в выборку:
await confirmTx(categoryId: 'cat-b', date: date, type: TransactionType.income);
await confirmTx(categoryId: 'cat-c', date: date, packageName: _bank);
await confirmTx(categoryId: 'cat-d', date: date, packageName: null);
await confirmTx(categoryId: null, date: date);
final top = await repo
.watchTopCategoryIds(_userId, _ozon, TransactionType.expense)
.first;
expect(top, ['cat-a']);
// Income-выборка того же пакета видит только income-транзакцию.
final topIncome = await repo
.watchTopCategoryIds(_userId, _ozon, TransactionType.income)
.first;
expect(topIncome, ['cat-b']);
});
}
@@ -103,12 +103,9 @@ Future<void> _seed(AppDatabase db, {List<String> categories = const []}) async {
}
}
/// Активирует воркер: помимо чтения самого провайдера держим прямого слушателя
/// на его входном потоке [pendingMessagesProvider]. Без внешнего слушателя
/// autoDispose-стрим не подписывается на Drift и `ref.listen` внутри воркера не
/// получает событий (в приложении эту роль играет `HomeScreen`).
/// Активирует воркер: его входы — прямые подписки на Drift-стримы репозитория,
/// поэтому одного чтения провайдера достаточно.
void _activateWorker(ProviderContainer c) {
c.listen(pendingMessagesProvider(_userId), (_, _) {}, fireImmediately: true);
c.read(parsingWorkerProvider(_userId));
}
@@ -63,5 +63,56 @@ void main() {
expect(decodeDraftBundle(null), isNull);
expect(decodeDraftBundle(''), isNull);
});
test('поля пейринга (pairedRawMessageId/preMerge/mergeUndo) round-trip',
() {
final draft = ParseDraft(
rawMessageId: 'm1',
type: TransactionType.transfer,
amount: 500000,
kind: TxKind.transferOut,
accountId: 'a1',
transferToAccountId: 'a2',
source: ParseSource.ai,
);
final decoded = decodeDraftBundle(encodeDraftBundle(
draft,
null,
accountTrusted: true,
pairedRawMessageId: 'm2',
preMerge: {'type': 'expense', 'categoryId': 'c1'},
mergeUndo: {
'txId': 't1',
'prevType': 'income',
'prevAccountId': 'a2',
'prevCategoryId': null,
'prevTransferToAccountId': null,
},
));
expect(decoded, isNotNull);
expect(decoded!.accountTrusted, isTrue);
expect(decoded.pairedRawMessageId, 'm2');
expect(decoded.preMerge, {'type': 'expense', 'categoryId': 'c1'});
expect(decoded.mergeUndo!['txId'], 't1');
expect(decoded.mergeUndo!['prevType'], 'income');
expect(decoded.mergeUndo!['prevAccountId'], 'a2');
expect(decoded.draft.transferToAccountId, 'a2');
});
test('старый JSON без полей пейринга декодится с дефолтами', () {
final draft = ParseDraft(
rawMessageId: 'm1',
type: TransactionType.expense,
amount: 100,
source: ParseSource.ai,
);
// encode без новых аргументов = формат до transfer pairing.
final decoded = decodeDraftBundle(encodeDraftBundle(draft, null));
expect(decoded, isNotNull);
expect(decoded!.accountTrusted, isFalse);
expect(decoded.pairedRawMessageId, isNull);
expect(decoded.preMerge, isNull);
expect(decoded.mergeUndo, isNull);
});
});
}
@@ -154,40 +154,6 @@ void main() {
});
});
group('findMixedMerchantRule', () {
const body = 'Покупка OZON 1240';
test('finds enabled mixedMerchant rule matching the merchant', () {
final r = findMixedMerchantRule(
[
_rule(
id: 'm',
pattern: 'OZON',
kind: ParseRuleKind.mixedMerchant,
categoryId: null),
],
body: body,
);
expect(r?.id, 'm');
});
test('ignores merchantToCategory and disabled rules', () {
final r = findMixedMerchantRule(
[
_rule(id: 'a', pattern: 'OZON'),
_rule(
id: 'b',
pattern: 'OZON',
kind: ParseRuleKind.mixedMerchant,
enabled: false,
categoryId: null),
],
body: body,
);
expect(r, isNull);
});
});
group('findIgnoreRule', () {
test('finds enabled ignore rule', () {
final r = findIgnoreRule(
@@ -0,0 +1,145 @@
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/data/parser/transfer_pair_matcher.dart';
import 'package:new_budget/src/features/notification_parsing/domain/entities/parse_draft.dart';
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
ParseDraft _draft({
String id = 'm',
TxKind? kind,
int amount = 500000,
String currency = 'RUB',
String? accountId,
}) =>
ParseDraft(
rawMessageId: id,
type: kind == TxKind.transferOut
? TransactionType.expense
: TransactionType.income,
amount: amount,
currency: currency,
kind: kind,
accountId: accountId,
source: ParseSource.ai,
);
void main() {
final t0 = DateTime(2026, 7, 1, 12);
group('isTransferHalf', () {
test('transferOut/transferIn — да, остальное — нет', () {
expect(isTransferHalf(_draft(kind: TxKind.transferOut)), isTrue);
expect(isTransferHalf(_draft(kind: TxKind.transferIn)), isTrue);
expect(isTransferHalf(_draft(kind: TxKind.purchase)), isFalse);
expect(isTransferHalf(_draft(kind: TxKind.fee)), isFalse);
expect(isTransferHalf(_draft(kind: null)), isFalse);
});
});
group('isPair', () {
test('противоположные kind, равные суммы, окно 5 мин → пара', () {
expect(
isPair(
a: _draft(kind: TxKind.transferOut, accountId: 'a1'),
aReceivedAt: t0,
b: _draft(kind: TxKind.transferIn, accountId: 'a2'),
bReceivedAt: t0.add(const Duration(minutes: 3)),
),
isTrue,
);
});
test('одинаковые kind → не пара', () {
expect(
isPair(
a: _draft(kind: TxKind.transferOut),
aReceivedAt: t0,
b: _draft(kind: TxKind.transferOut),
bReceivedAt: t0,
),
isFalse,
);
});
test('kind=null не участвует', () {
expect(
isPair(
a: _draft(kind: null),
aReceivedAt: t0,
b: _draft(kind: TxKind.transferIn),
bReceivedAt: t0,
),
isFalse,
);
});
test('разные суммы или валюты → не пара', () {
expect(
isPair(
a: _draft(kind: TxKind.transferOut, amount: 500000),
aReceivedAt: t0,
b: _draft(kind: TxKind.transferIn, amount: 500001),
bReceivedAt: t0,
),
isFalse,
);
expect(
isPair(
a: _draft(kind: TxKind.transferOut, currency: 'RUB'),
aReceivedAt: t0,
b: _draft(kind: TxKind.transferIn, currency: 'USD'),
bReceivedAt: t0,
),
isFalse,
);
});
test('вне окна 5 минут → не пара', () {
expect(
isPair(
a: _draft(kind: TxKind.transferOut),
aReceivedAt: t0,
b: _draft(kind: TxKind.transferIn),
bReceivedAt: t0.add(const Duration(minutes: 5, seconds: 1)),
),
isFalse,
);
});
test('оба счёта разрешены и совпали → не пара; один null → пара', () {
expect(
isPair(
a: _draft(kind: TxKind.transferOut, accountId: 'a1'),
aReceivedAt: t0,
b: _draft(kind: TxKind.transferIn, accountId: 'a1'),
bReceivedAt: t0,
),
isFalse,
);
expect(
isPair(
a: _draft(kind: TxKind.transferOut, accountId: 'a1'),
aReceivedAt: t0,
b: _draft(kind: TxKind.transferIn, accountId: null),
bReceivedAt: t0,
),
isTrue,
);
});
});
group('pickNearest', () {
test('выбирает ближайшего по receivedAt', () {
final near = t0.add(const Duration(minutes: 1));
final far = t0.add(const Duration(minutes: 4));
expect(pickNearest([('far', far), ('near', near)], t0), 'near');
});
});
group('pairSignature', () {
test('порядконезависима', () {
expect(pairSignature('b', 'a'), pairSignature('a', 'b'));
expect(pairSignature('a', 'b'), 'a|b');
});
});
}
@@ -1,10 +1,14 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:go_router/go_router.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:new_budget/l10n/app_localizations.dart';
import 'package:new_budget/src/app/theme/app_theme.dart';
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
import 'package:new_budget/src/features/accounts/application/accounts_controller.dart';
import 'package:new_budget/src/features/accounts/domain/entities/account.dart';
import 'package:new_budget/src/features/categories/domain/entities/category.dart';
import 'package:new_budget/src/features/notification_parsing/application/inbox_controller.dart';
import 'package:new_budget/src/features/notification_parsing/data/parser/draft_codec.dart';
@@ -13,6 +17,7 @@ import 'package:new_budget/src/features/notification_parsing/domain/entities/raw
import 'package:new_budget/src/features/notification_parsing/domain/entities/rule_suggestion.dart';
import 'package:new_budget/src/features/notification_parsing/domain/enums.dart';
import 'package:new_budget/src/features/notification_parsing/presentation/widgets/inbox_card.dart';
import 'package:new_budget/src/features/transactions/presentation/screens/transaction_form_screen.dart';
final _now = DateTime(2026, 5, 29, 14, 5);
@@ -20,10 +25,11 @@ class FakeInboxController extends InboxController {
int createRuleCalls = 0;
int confirmOnceCalls = 0;
int ignoreCalls = 0;
int markMixedCalls = 0;
String? lastCreateRuleCategory;
String? lastCreateRuleAccount;
String? lastConfirmCategory;
String? lastMarkMixedMerchant;
String? lastConfirmAccount;
final List<(String, String)> appliedCalls = [];
@override
AsyncValue<void> build() => const AsyncData(null);
@@ -42,6 +48,7 @@ class FakeInboxController extends InboxController {
}) async {
createRuleCalls++;
lastCreateRuleCategory = categoryId;
lastCreateRuleAccount = accountId;
}
@override
@@ -55,17 +62,12 @@ class FakeInboxController extends InboxController {
}) async {
confirmOnceCalls++;
lastConfirmCategory = categoryId;
lastConfirmAccount = accountId;
}
@override
Future<void> markMerchantMixed({
required String userId,
required RawMessage message,
required DraftBundle bundle,
required String merchantCanonical,
}) async {
markMixedCalls++;
lastMarkMixedMerchant = merchantCanonical;
Future<void> markApplied(RawMessage message, String transactionId) async {
appliedCalls.add((message.id, transactionId));
}
@override
@@ -107,7 +109,7 @@ RawMessage _recognizedMessage() {
}
/// Уведомление без мерчанта (только сумма) → нет suggestion → карточка
/// показывает «подтвердить разово» без кнопки «Создать правило».
/// показывает «Подтвердить» без кнопки «Создать правило».
/// [categorySuggestion] — AI-подсказка категории (по имени), которую карточка
/// должна предзаполнить в пикере.
RawMessage _amountOnlyMessage({String? categorySuggestion}) {
@@ -132,14 +134,44 @@ RawMessage _amountOnlyMessage({String? categorySuggestion}) {
);
}
const _categoryById = {
'cat1': Category(
id: 'cat1',
userId: 'u1',
name: 'Продукты',
type: CategoryType.expense,
archived: false,
),
};
/// Герметичные overrides по умолчанию: чипы «частых категорий» — пустой стрим
/// (иначе провайдер полез бы в реальную БД).
List<Override> _baseOverrides(
FakeInboxController fake, {
List<String> topCategories = const [],
}) {
return [
inboxControllerProvider.overrideWith(() => fake),
topConfirmCategoriesProvider(
'u1', 'com.example.wallet', TransactionType.expense)
.overrideWith((ref) => Stream.value(topCategories)),
topConfirmCategoriesProvider(
'u1', 'ru.sberbankmobile', TransactionType.expense)
.overrideWith((ref) => Stream.value(topCategories)),
];
}
Widget _host(
FakeInboxController fake,
RawMessage message, {
String? defaultAccountId = 'acc1',
List<String> topCategories = const [],
List<Override> extraOverrides = const [],
}) {
return ProviderScope(
overrides: [
inboxControllerProvider.overrideWith(() => fake),
..._baseOverrides(fake, topCategories: topCategories),
...extraOverrides,
],
child: MaterialApp(
theme: AppTheme.light(),
@@ -151,15 +183,7 @@ Widget _host(
message: message,
userId: 'u1',
defaultAccountId: defaultAccountId,
categoryById: const {
'cat1': Category(
id: 'cat1',
userId: 'u1',
name: 'Продукты',
type: CategoryType.expense,
archived: false,
),
},
categoryById: _categoryById,
),
),
),
@@ -197,7 +221,7 @@ void main() {
expect(fake.lastCreateRuleCategory, 'cat1');
});
testWidgets('tap confirm-once calls controller', (tester) async {
testWidgets('tap confirm calls controller', (tester) async {
await tester.pumpWidget(_host(fake, _recognizedMessage()));
await tester.pump();
@@ -223,18 +247,23 @@ void main() {
await tester.pumpWidget(_host(fake, _amountOnlyMessage()));
await tester.pump();
// Нет мерчанта → кнопки «Создать правило» и «Категории различаются» нет.
// Нет мерчанта → кнопки «Создать правило» нет, есть «Подтвердить».
expect(find.textContaining('Создать правило'), findsNothing);
expect(find.text(l10n.inboxMarkMixed), findsNothing);
// Есть основная кнопка «Подтвердить».
expect(find.text(l10n.inboxConfirm), findsOneWidget);
});
testWidgets('confirm inactive without category, hint is shown',
(tester) async {
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
await tester.pumpWidget(_host(fake, _amountOnlyMessage()));
await tester.pump();
// Категория не выбрана → подсказка видна, тап по «Подтвердить» — ничего.
expect(find.text(l10n.inboxCategoryRequiredHint), findsOneWidget);
await tester.tap(find.text(l10n.inboxConfirm));
await tester.pump();
expect(fake.confirmOnceCalls, 1);
expect(fake.createRuleCalls, 0);
expect(fake.confirmOnceCalls, 0);
});
testWidgets('confirm-once pre-fills category from AI hint and passes it',
@@ -246,6 +275,8 @@ void main() {
// AI-подсказка «Продукты» сматчена на cat1 и показана в пикере.
expect(find.text('Продукты'), findsOneWidget);
// Категория есть → подсказки «выберите категорию» нет.
expect(find.text(l10n.inboxCategoryRequiredHint), findsNothing);
await tester.tap(find.text(l10n.inboxConfirm));
await tester.pump();
@@ -254,38 +285,204 @@ void main() {
expect(fake.lastConfirmCategory, 'cat1');
});
testWidgets('confirm disabled when no account resolves', (tester) async {
testWidgets('category chip selects category and is passed to confirmOnce',
(tester) async {
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
// Нет дефолтного счёта и draft.accountId == null → подтвердить нельзя.
await tester.pumpWidget(
_host(fake, _amountOnlyMessage(), defaultAccountId: null));
await tester.pumpWidget(_host(
fake,
_amountOnlyMessage(),
topCategories: const ['cat1'],
));
await tester.pump();
final button = tester.widget<FilledButton>(
find.ancestor(
of: find.text(l10n.inboxConfirm),
matching: find.byType(FilledButton),
),
);
expect(button.onPressed, isNull);
// Чип «Продукты» виден; категория ещё не выбрана.
expect(find.text('Продукты'), findsOneWidget);
expect(find.text(l10n.inboxCategoryRequiredHint), findsOneWidget);
await tester.tap(find.text('Продукты').first);
await tester.pump();
// Выбор чипа активирует «Подтвердить».
expect(find.text(l10n.inboxCategoryRequiredHint), findsNothing);
await tester.tap(find.text(l10n.inboxConfirm));
await tester.pump();
expect(fake.confirmOnceCalls, 0);
expect(fake.confirmOnceCalls, 1);
expect(fake.lastConfirmCategory, 'cat1');
});
testWidgets('"categories vary" marks merchant mixed', (tester) async {
testWidgets('create rule without account opens the account picker',
(tester) async {
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
await tester.pumpWidget(_host(fake, _recognizedMessage()));
final account = Account(
id: 'acc-x',
userId: 'u1',
name: 'Основной',
type: AccountType.card,
currency: 'RUB',
initialBalance: 0,
archived: false,
createdAt: _now,
);
// Нет дефолтного счёта и draft.accountId == null → «Создать правило»
// открывает пикер счёта вместо молчаливого no-op.
await tester.pumpWidget(_host(
fake,
_recognizedMessage(),
defaultAccountId: null,
extraOverrides: [
accountsStreamProvider('u1')
.overrideWith((ref) => Stream.value([account])),
accountBalanceProvider('acc-x')
.overrideWith((ref) => Stream.value(0)),
],
));
await tester.pump();
expect(find.text(l10n.inboxMarkMixed), findsOneWidget);
await tester.tap(find.text(l10n.inboxCreateRule('PYATEROCHKA', 'Продукты')));
await tester.pumpAndSettle();
await tester.tap(find.text(l10n.inboxMarkMixed));
// Открылся пикер счёта, правило ещё не создано.
expect(find.text(l10n.pickerAccountTitle), findsOneWidget);
expect(fake.createRuleCalls, 0);
// Имя счёта в тайле пикера встречается дважды (название + подпись).
await tester.tap(find.text('Основной').first);
await tester.pumpAndSettle();
expect(fake.createRuleCalls, 1);
expect(fake.lastCreateRuleAccount, 'acc-x');
expect(fake.lastCreateRuleCategory, 'cat1');
});
testWidgets('create rule: cancelling the account picker does nothing',
(tester) async {
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
await tester.pumpWidget(_host(
fake,
_recognizedMessage(),
defaultAccountId: null,
extraOverrides: [
accountsStreamProvider('u1')
.overrideWith((ref) => Stream.value(const [])),
],
));
await tester.pump();
expect(fake.markMixedCalls, 1);
expect(fake.lastMarkMixedMerchant, 'PYATEROCHKA');
await tester.tap(find.text(l10n.inboxCreateRule('PYATEROCHKA', 'Продукты')));
await tester.pumpAndSettle();
expect(find.text(l10n.pickerAccountTitle), findsOneWidget);
// Тап по барьеру закрывает пикер без выбора.
await tester.tapAt(const Offset(20, 20));
await tester.pumpAndSettle();
expect(fake.createRuleCalls, 0);
});
testWidgets('confirm without account opens the account picker',
(tester) async {
final l10n = await AppLocalizations.delegate.load(const Locale('ru'));
final account = Account(
id: 'acc-x',
userId: 'u1',
name: 'Основной',
type: AccountType.card,
currency: 'RUB',
initialBalance: 0,
archived: false,
createdAt: _now,
);
// Нет дефолтного счёта и draft.accountId == null → «Подтвердить» открывает
// пикер счёта; выбор в нём завершает подтверждение.
await tester.pumpWidget(_host(
fake,
_amountOnlyMessage(categorySuggestion: 'Продукты'),
defaultAccountId: null,
extraOverrides: [
accountsStreamProvider('u1')
.overrideWith((ref) => Stream.value([account])),
accountBalanceProvider('acc-x')
.overrideWith((ref) => Stream.value(0)),
],
));
await tester.pump();
await tester.tap(find.text(l10n.inboxConfirm));
await tester.pumpAndSettle();
// Открылся пикер счёта.
expect(find.text(l10n.pickerAccountTitle), findsOneWidget);
expect(fake.confirmOnceCalls, 0);
// Имя счёта в тайле пикера встречается дважды (название + подпись).
await tester.tap(find.text('Основной').first);
await tester.pumpAndSettle();
expect(fake.confirmOnceCalls, 1);
expect(fake.lastConfirmAccount, 'acc-x');
});
testWidgets('pencil opens full form with prefill and links the saved tx',
(tester) async {
TransactionFormPrefill? capturedPrefill;
final router = GoRouter(
routes: [
GoRoute(
path: '/',
builder: (context, state) => Scaffold(
body: InboxCard(
message: _amountOnlyMessage(),
userId: 'u1',
defaultAccountId: 'acc1',
categoryById: _categoryById,
),
),
),
GoRoute(
path: '/transactions/new',
builder: (context, state) {
capturedPrefill = state.extra as TransactionFormPrefill?;
return Scaffold(
body: TextButton(
onPressed: () => context.pop('tx-9'),
child: const Text('SAVE-STUB'),
),
);
},
),
],
);
await tester.pumpWidget(ProviderScope(
overrides: _baseOverrides(fake),
child: MaterialApp.router(
routerConfig: router,
theme: AppTheme.light(),
locale: const Locale('ru'),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
),
));
await tester.pump();
// Карандаш активен даже без категории.
await tester.tap(find.byIcon(Icons.edit_outlined));
await tester.pumpAndSettle();
expect(capturedPrefill, isNotNull);
expect(capturedPrefill!.amountMinor, 50000);
expect(capturedPrefill!.type, TransactionType.expense);
expect(capturedPrefill!.accountId, 'acc1');
expect(capturedPrefill!.rawMessageId, 'msg2');
// «Сохранение» в форме возвращает id → сообщение линкуется.
await tester.tap(find.text('SAVE-STUB'));
await tester.pumpAndSettle();
expect(fake.appliedCalls, contains(('msg2', 'tx-9')));
expect(fake.confirmOnceCalls, 0);
});
testWidgets('unrecognized message shows manual-add fallback', (tester) async {
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_riverpod/misc.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:new_budget/l10n/app_localizations.dart';
@@ -50,6 +51,7 @@ class TxCall {
required this.date,
this.merchant,
this.transferToAccountId,
this.rawMessageId,
});
final String userId;
final String accountId;
@@ -59,6 +61,7 @@ class TxCall {
final DateTime date;
final String? merchant;
final String? transferToAccountId;
final String? rawMessageId;
}
class FakeTransactionsController extends TransactionsController {
@@ -93,6 +96,7 @@ class FakeTransactionsController extends TransactionsController {
date: date,
merchant: merchant,
transferToAccountId: transferToAccountId,
rawMessageId: rawMessageId,
));
return Transaction(
id: 'tx-1',
@@ -118,9 +122,7 @@ class _UnusedTransactionRepo implements TransactionRepository {
// ─── Helper ──────────────────────────────────────────────────────────────────
Widget _buildForm(FakeTransactionsController fakeCtrl) {
return ProviderScope(
overrides: [
List<Override> _formOverrides(FakeTransactionsController fakeCtrl) => [
activeUserControllerProvider.overrideWith(() => FakeActiveUserController()),
settingsControllerProvider('test-uid')
.overrideWith(() => FakeSettingsController()),
@@ -139,12 +141,19 @@ Widget _buildForm(FakeTransactionsController fakeCtrl) {
categoriesByTypeStreamProvider('test-uid', CategoryType.income).overrideWith(
(ref) => Stream<List<Category>>.value(const []),
),
],
];
Widget _buildForm(
FakeTransactionsController fakeCtrl, {
TransactionFormPrefill? prefill,
}) {
return ProviderScope(
overrides: _formOverrides(fakeCtrl),
child: MaterialApp(
theme: AppTheme.light(),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: const TransactionFormScreen(),
home: TransactionFormScreen(prefill: prefill),
),
);
}
@@ -346,4 +355,86 @@ void main() {
expect(fakeCtrl.calls, hasLength(1));
expect(fakeCtrl.calls.first.merchant, isNull);
});
// ── Префилл из Inbox ──────────────────────────────────────────────────────
testWidgets('Префилл гидратирует поля; save передаёт rawMessageId',
(tester) async {
final testDate = DateTime(2026, 5, 29, 14, 5);
await tester.pumpWidget(_buildForm(
fakeCtrl,
prefill: TransactionFormPrefill(
type: TransactionType.expense,
amountMinor: 50000,
date: testDate,
accountId: 'a-1',
categoryId: 'cat-1',
merchant: 'Ozon',
rawMessageId: 'msg-2',
),
));
await tester.pump(); // activeUser разрешается
await tester.pump(); // post-frame гидрация черновика
// Сумма из префилла видна в поле ввода.
expect(find.text('500'), findsOneWidget);
expect(find.text('Ozon'), findsOneWidget);
await _tapSave(tester);
expect(fakeCtrl.calls, hasLength(1));
final call = fakeCtrl.calls.first;
expect(call.amount, 50000);
expect(call.accountId, 'a-1');
expect(call.categoryId, 'cat-1');
expect(call.date, testDate);
expect(call.merchant, 'Ozon');
expect(call.rawMessageId, 'msg-2');
});
testWidgets('Создание возвращает id транзакции через Navigator.pop',
(tester) async {
String? poppedId;
await tester.pumpWidget(ProviderScope(
overrides: _formOverrides(fakeCtrl),
child: MaterialApp(
theme: AppTheme.light(),
localizationsDelegates: AppLocalizations.localizationsDelegates,
supportedLocales: AppLocalizations.supportedLocales,
home: Builder(
builder: (context) => Scaffold(
body: TextButton(
onPressed: () async {
poppedId = await Navigator.of(context).push<String?>(
MaterialPageRoute(
builder: (_) => TransactionFormScreen(
prefill: TransactionFormPrefill(
type: TransactionType.expense,
amountMinor: 50000,
date: DateTime(2026, 5, 29),
accountId: 'a-1',
categoryId: 'cat-1',
rawMessageId: 'msg-2',
),
),
),
);
},
child: const Text('OPEN'),
),
),
),
),
));
await tester.tap(find.text('OPEN'));
await tester.pumpAndSettle();
await _tapSave(tester);
await tester.pumpAndSettle();
// Форма закрылась и вернула id созданной транзакции (fake отдаёт 'tx-1').
expect(poppedId, 'tx-1');
expect(find.text('OPEN'), findsOneWidget);
});
}
+147 -154
View File
@@ -6,6 +6,9 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:google_fonts/google_fonts.dart';
import 'package:new_budget/l10n/app_localizations.dart';
import 'package:new_budget/src/app/theme/app_theme.dart';
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
import 'package:new_budget/src/features/accounts/application/accounts_controller.dart';
import 'package:new_budget/src/features/accounts/domain/entities/account.dart';
import 'package:new_budget/src/features/user/application/active_user_controller.dart';
import 'package:new_budget/src/features/user/application/users_controller.dart';
import 'package:new_budget/src/features/user/domain/entities/user.dart';
@@ -13,10 +16,9 @@ import 'package:new_budget/src/features/user/presentation/screens/onboarding_scr
// ─── Fakes ────────────────────────────────────────────────────────────────────
//
// FakeUsersController и FakeActiveUserController расширяют реальные контроллеры
// и переопределяют только те методы, которые касаются БД. Riverpod создаёт их
// через фабрику в overrideWith, поэтому ref.watch/ref.read внутри build()
// никогда не вызываются.
// Fake-контроллеры расширяют реальные контроллеры и переопределяют только те
// методы, которые касаются БД. Riverpod создаёт их через фабрику в overrideWith,
// поэтому ref.watch/ref.read внутри build() никогда не вызываются.
class FakeUsersController extends UsersController {
/// Имена, с которыми вызывался createUser.
@@ -40,6 +42,44 @@ class FakeUsersController extends UsersController {
}
}
class FakeAccountsController extends AccountsController {
final List<({String userId, String name, AccountType type})> created = [];
final List<(String? id, String userId)> setDefaultCalls = [];
@override
AsyncValue<void> build() => const AsyncData(null);
@override
Future<Account> createAccount({
required String userId,
required String name,
required AccountType type,
required String currency,
int initialBalance = 0,
int? iconCode,
int? colorValue,
}) async {
created.add((userId: userId, name: name, type: type));
return Account(
id: 'acc-${created.length}',
userId: userId,
name: name,
type: type,
currency: currency,
initialBalance: initialBalance,
iconCode: iconCode,
colorValue: colorValue,
archived: false,
createdAt: DateTime(2024, 1, 1),
);
}
@override
Future<void> setDefaultAccount(String? id, String userId) async {
setDefaultCalls.add((id, userId));
}
}
class FakeActiveUserController extends ActiveUserController {
/// Пользователи, переданные в setActiveUser.
final List<User> activatedUsers = [];
@@ -59,11 +99,13 @@ class FakeActiveUserController extends ActiveUserController {
Widget _buildOnboarding({
required FakeUsersController fakeUsers,
required FakeAccountsController fakeAccounts,
required FakeActiveUserController fakeActive,
}) {
return ProviderScope(
overrides: [
usersControllerProvider.overrideWith(() => fakeUsers),
accountsControllerProvider.overrideWith(() => fakeAccounts),
activeUserControllerProvider.overrideWith(() => fakeActive),
],
child: MaterialApp(
@@ -75,6 +117,14 @@ Widget _buildOnboarding({
);
}
/// Заполняет имя на шаге 1 и переходит на шаг создания счёта.
Future<void> _advanceToAccountStep(WidgetTester tester, String name) async {
await tester.enterText(find.byType(TextField), name);
await tester.pump();
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
}
// ─── Tests ────────────────────────────────────────────────────────────────────
void main() {
@@ -85,202 +135,145 @@ void main() {
group('OnboardingScreen', () {
late FakeUsersController fakeUsers;
late FakeAccountsController fakeAccounts;
late FakeActiveUserController fakeActive;
setUp(() {
fakeUsers = FakeUsersController();
fakeAccounts = FakeAccountsController();
fakeActive = FakeActiveUserController();
});
// ── Рендеринг ──────────────────────────────────────────────────────────
Widget build() => _buildOnboarding(
fakeUsers: fakeUsers,
fakeAccounts: fakeAccounts,
fakeActive: fakeActive,
);
testWidgets('отображает заголовок, подзаголовок, поле имени и кнопку',
// ── Шаг 1: имя ───────────────────────────────────────────────────────────
testWidgets('шаг 1: отображает заголовок, поле имени и кнопку',
(tester) async {
await tester.pumpWidget(
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
);
await tester.pumpWidget(build());
await tester.pump();
// Локализация EN: "Welcome" / "Tell us your name to get started."
expect(find.text('Welcome'), findsOneWidget);
expect(find.text('Tell us your name to get started.'), findsOneWidget);
expect(find.byType(TextField), findsOneWidget);
expect(find.text('Your name'), findsOneWidget);
expect(find.text('Continue'), findsOneWidget);
});
testWidgets('TextField имеет лейбл и хинт', (tester) async {
await tester.pumpWidget(
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
);
testWidgets('шаг 1: пустое имя — кнопка Continue задизейблена',
(tester) async {
await tester.pumpWidget(build());
await tester.pump();
expect(find.text('Your name'), findsOneWidget);
final button = tester.widget<FilledButton>(find.byType(FilledButton));
expect(button.onPressed, isNull);
});
// ── Валидация пустого имени ─────────────────────────────────────────────
testWidgets('нажатие Continue с пустым полем не вызывает createUser',
testWidgets('шаг 1: Continue не создаёт пользователя — только переход',
(tester) async {
await tester.pumpWidget(
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
);
await tester.pumpWidget(build());
await tester.pump();
await tester.tap(find.text('Continue'));
await tester.pump();
await _advanceToAccountStep(tester, 'Alice');
// Перешли на шаг счёта; пользователь ещё НЕ создан (записей в БД нет).
expect(find.text('Your first account'), findsOneWidget);
expect(fakeUsers.createdNames, isEmpty);
expect(fakeActive.activatedUsers, isEmpty);
});
testWidgets('нажатие Continue с пробелами не вызывает createUser',
// ── Шаг 2: создание первого счёта ────────────────────────────────────────
testWidgets('шаг 2: назад возвращает на шаг имени', (tester) async {
await tester.pumpWidget(build());
await tester.pump();
await _advanceToAccountStep(tester, 'Alice');
await tester.tap(find.byIcon(Icons.arrow_back));
await tester.pumpAndSettle();
expect(find.text('Welcome'), findsOneWidget);
// Имя сохранилось.
expect(find.text('Alice'), findsOneWidget);
});
testWidgets('шаг 2: пустое имя счёта — кнопка задизейблена',
(tester) async {
await tester.pumpWidget(
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
);
await tester.pump();
await tester.enterText(find.byType(TextField), ' ');
await tester.tap(find.text('Continue'));
await tester.pumpWidget(build());
await tester.pump();
await _advanceToAccountStep(tester, 'Alice');
final button = tester.widget<FilledButton>(find.byType(FilledButton));
expect(button.onPressed, isNull);
expect(fakeUsers.createdNames, isEmpty);
});
// ── Happy path ─────────────────────────────────────────────────────────
testWidgets('валидное имя: createUser вызывается с trim-значением',
testWidgets(
'шаг 2: сабмит создаёт пользователя, счёт (умолчательный) и активирует',
(tester) async {
await tester.pumpWidget(
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
);
await tester.pumpWidget(build());
await tester.pump();
await _advanceToAccountStep(tester, ' Alice ');
await tester.enterText(find.byType(TextField), ' Alice ');
await tester.enterText(find.byType(TextField).first, ' Кошелёк ');
await tester.pump();
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
// Не pumpAndSettle: на успехе _submitting остаётся true (в реальном
// приложении редирект уводит с экрана), а бесконечный спиннер не даёт
// pumpAndSettle завершиться.
for (var i = 0; i < 5; i++) {
await tester.pump(const Duration(milliseconds: 10));
}
// Пользователь создан с trim-именем.
expect(fakeUsers.createdNames, ['Alice']);
// Счёт создан для этого пользователя.
expect(fakeAccounts.created, hasLength(1));
expect(fakeAccounts.created.single.name, 'Кошелёк');
expect(fakeAccounts.created.single.userId, 'test-uid');
// Счёт помечен умолчательным.
expect(fakeAccounts.setDefaultCalls, hasLength(1));
expect(fakeAccounts.setDefaultCalls.single, ('acc-1', 'test-uid'));
// Пользователь активирован.
expect(fakeActive.activatedUsers, hasLength(1));
expect(fakeActive.activatedUsers.single.id, 'test-uid');
});
testWidgets(
'после createUser вызывается setActiveUser с возвращённым пользователем',
(tester) async {
await tester.pumpWidget(
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
);
await tester.pump();
// ── Состояние загрузки на финальном сабмите ──────────────────────────────
await tester.enterText(find.byType(TextField), 'Bob');
testWidgets('во время сабмита кнопка показывает спиннер и задизейблена',
(tester) async {
final completer = Completer<User>();
fakeUsers.freezeNextCreate(completer);
await tester.pumpWidget(build());
await tester.pump();
await _advanceToAccountStep(tester, 'Alice');
await tester.enterText(find.byType(TextField).first, 'Кошелёк');
await tester.pump();
await tester.tap(find.text('Continue'));
await tester.pumpAndSettle();
await tester.pump(); // тап + setState(_submitting = true)
expect(find.byType(CircularProgressIndicator), findsOneWidget);
final button = tester.widget<FilledButton>(find.byType(FilledButton));
expect(button.onPressed, isNull);
completer.complete(
User(id: 'test-uid', name: 'Alice', createdAt: DateTime(2024, 1, 1)),
);
// Спиннер остаётся (редиректа в тесте нет) — pumpAndSettle не завершится.
for (var i = 0; i < 5; i++) {
await tester.pump(const Duration(milliseconds: 10));
}
expect(fakeActive.activatedUsers, hasLength(1));
expect(fakeActive.activatedUsers.first.name, 'Bob');
});
// ── Отправка через клавиатуру ──────────────────────────────────────────
testWidgets('TextInputAction.done также запускает submit', (tester) async {
await tester.pumpWidget(
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
);
await tester.pump();
await tester.enterText(find.byType(TextField), 'Carol');
await tester.testTextInput.receiveAction(TextInputAction.done);
await tester.pumpAndSettle();
expect(fakeUsers.createdNames, ['Carol']);
expect(fakeActive.activatedUsers, isNotEmpty);
});
// ── Состояние загрузки ─────────────────────────────────────────────────
testWidgets(
'во время отправки кнопка показывает CircularProgressIndicator',
(tester) async {
final completer = Completer<User>();
fakeUsers.freezeNextCreate(completer);
await tester.pumpWidget(
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
);
await tester.pump();
await tester.enterText(find.byType(TextField), 'Dave');
await tester.tap(find.text('Continue'));
await tester.pump(); // Обрабатываем тап + setState(_submitting=true)
// Пока awaiting: должен быть спиннер, кнопка «Continue» не видна.
expect(find.byType(CircularProgressIndicator), findsOneWidget);
expect(find.text('Continue'), findsNothing);
// Разблокируем async-операцию.
completer.complete(
User(id: 'u-dave', name: 'Dave', createdAt: DateTime(2024, 1, 1)),
);
await tester.pumpAndSettle();
// После завершения: спиннер исчез, кнопка снова видна.
expect(find.byType(CircularProgressIndicator), findsNothing);
expect(find.text('Continue'), findsOneWidget);
});
testWidgets('во время отправки кнопка задизейблена (onPressed == null)',
(tester) async {
final completer = Completer<User>();
fakeUsers.freezeNextCreate(completer);
await tester.pumpWidget(
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
);
await tester.pump();
await tester.enterText(find.byType(TextField), 'Eve');
await tester.tap(find.text('Continue'));
await tester.pump();
final button = tester.widget<FilledButton>(find.byType(FilledButton));
expect(button.onPressed, isNull,
reason: 'Кнопка должна быть disabled во время отправки');
completer.complete(
User(id: 'u-eve', name: 'Eve', createdAt: DateTime(2024, 1, 1)),
);
await tester.pumpAndSettle();
final buttonAfter =
tester.widget<FilledButton>(find.byType(FilledButton));
expect(buttonAfter.onPressed, isNotNull,
reason: 'После завершения кнопка снова активна');
});
testWidgets('повторный тап во время отправки не создаёт второго пользователя',
(tester) async {
final completer = Completer<User>();
fakeUsers.freezeNextCreate(completer);
await tester.pumpWidget(
_buildOnboarding(fakeUsers: fakeUsers, fakeActive: fakeActive),
);
await tester.pump();
await tester.enterText(find.byType(TextField), 'Frank');
await tester.tap(find.text('Continue'));
await tester.pump(); // Первый тап, _submitting = true
// Пробуем тапнуть снова (кнопка задизейблена, но try programmatic).
await tester.tap(find.byType(FilledButton), warnIfMissed: false);
await tester.pump();
// createUser должен был быть вызван только один раз.
expect(fakeUsers.createdNames, hasLength(1));
completer.complete(
User(id: 'u-frank', name: 'Frank', createdAt: DateTime(2024, 1, 1)),
);
await tester.pumpAndSettle();
});
});
}
+157
View File
@@ -0,0 +1,157 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:new_budget/src/core/database/converters/enum_converters.dart';
import 'package:new_budget/src/features/accounts/domain/entities/account.dart';
import 'package:new_budget/src/features/accounts/domain/repositories/account_repository.dart';
import 'package:new_budget/src/features/categories/domain/entities/category.dart';
import 'package:new_budget/src/features/categories/domain/repositories/category_repository.dart';
import 'package:new_budget/src/features/transactions/domain/repositories/transaction_repository.dart';
import 'package:new_budget/src/features/user/application/user_seeder.dart';
/// `seedForNewUser` засевает только категории. Счета больше не создаются
/// автоматически — первый счёт пользователь создаёт сам на втором шаге
/// онбординга (и там же помечает его умолчательным).
const _userId = 'u1';
final _now = DateTime(2026, 6, 1);
class FakeAccountRepository implements AccountRepository {
final List<Account> created = [];
final List<(String? id, String userId)> setDefaultCalls = [];
@override
Future<Account> create({
required String userId,
required String name,
required AccountType type,
required String currency,
int initialBalance = 0,
int? iconCode,
int? colorValue,
}) async {
final account = Account(
id: 'acc-${created.length}',
userId: userId,
name: name,
type: type,
currency: currency,
initialBalance: initialBalance,
iconCode: iconCode,
colorValue: colorValue,
archived: false,
createdAt: _now,
);
created.add(account);
return account;
}
@override
Future<void> setDefault(String? id, String userId) async {
setDefaultCalls.add((id, userId));
}
@override
Stream<List<Account>> watchByUser(String userId) =>
throw UnimplementedError();
@override
Future<Account?> findById(String id) => throw UnimplementedError();
@override
Future<Account> update(Account account) => throw UnimplementedError();
@override
Future<void> archive(String id) => throw UnimplementedError();
@override
Stream<Account?> watchDefault(String userId) => throw UnimplementedError();
@override
Stream<int> watchBalance(String accountId) => throw UnimplementedError();
}
class FakeCategoryRepository implements CategoryRepository {
final List<Category> created = [];
@override
Future<Category> create({
required String userId,
required String name,
required CategoryType type,
int? iconCode,
int? colorValue,
String? parentId,
}) async {
final category = Category(
id: 'cat-${created.length}',
userId: userId,
name: name,
type: type,
iconCode: iconCode,
colorValue: colorValue,
parentId: parentId,
archived: false,
);
created.add(category);
return category;
}
@override
Stream<List<Category>> watchByUser(String userId) =>
throw UnimplementedError();
@override
Stream<List<Category>> watchByType(String userId, CategoryType type) =>
throw UnimplementedError();
@override
Future<Category?> findById(String id) => throw UnimplementedError();
@override
Future<Category> update(Category category) => throw UnimplementedError();
@override
Future<void> archive(String id) => throw UnimplementedError();
}
/// Демо-транзакции при `seedForNewUser` не создаются — любой вызов = ошибка.
class _UnusedTransactionRepository implements TransactionRepository {
@override
dynamic noSuchMethod(Invocation invocation) =>
throw StateError('seedForNewUser не должен трогать транзакции');
}
void main() {
late FakeAccountRepository accountRepo;
late FakeCategoryRepository categoryRepo;
late UserSeeder seeder;
setUp(() {
accountRepo = FakeAccountRepository();
categoryRepo = FakeCategoryRepository();
seeder = UserSeeder(
accountRepo: accountRepo,
categoryRepo: categoryRepo,
txRepo: _UnusedTransactionRepository(),
);
});
test('seedForNewUser не создаёт счета и не трогает умолчательный флаг',
() async {
await seeder.seedForNewUser(_userId);
expect(accountRepo.created, isEmpty);
expect(accountRepo.setDefaultCalls, isEmpty);
});
test('seedForNewUser сидит категории обоих типов, но не транзакции', () async {
await seeder.seedForNewUser(_userId);
// Базовые категории обоих типов; счетов и транзакций нет.
expect(categoryRepo.created, isNotEmpty);
expect(
categoryRepo.created.map((c) => c.type).toSet(),
{CategoryType.expense, CategoryType.income},
);
// Транзакций нет — _UnusedTransactionRepository бросил бы StateError.
});
}