Notification parsing: - Drop the account_bindings table/DAO/repo/entity/controller/screen; account resolution now goes senderToAccount rule -> source_apps.defaultAccountId (trusted) -> global default (untrusted -> Inbox), via v1->v2 migration. - Add defaultAccountId to source_apps; per-app settings consolidated into source_app_detail_screen (/settings/parsing/apps/:pkg). - Inbox auto-learns an app default on first Confirm/CreateRule; account picker on the card instead of a disabled button; parse_error_labels extracted. Analytics: - Replace placeholder screen with fl_chart cards (chart_card, chart_theme, month_stepper, month_math domain helper); slim down habit_analysis_screen. Android: add launcher icon (adaptive foreground + colors.xml) and app_name. Tests: migration_v2, analytics (screen/month_math), AI retry, inbox visibility; update resolver/gate/inbox suites for the new resolution path. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
182 lines
7.1 KiB
Dart
182 lines
7.1 KiB
Dart
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/features/notification_parsing/domain/enums.dart';
|
||
|
||
/// Миграция v1 → v2: `account_bindings` → `source_apps.default_account_id`
|
||
/// + правила `senderToAccount` (см. `AppDatabase._migrateV1ToV2`).
|
||
///
|
||
/// Сценарий: raw-схема v1 + фикстуры связок заливаются в `setup:` (до drift),
|
||
/// `PRAGMA user_version = 1` заставляет drift выполнить onUpgrade при
|
||
/// открытии. Дальше проверяем результат уже через API v2.
|
||
|
||
const _userId = 'u1';
|
||
const _sber = 'ru.sberbankmobile';
|
||
const _tinkoff = 'com.idamob.tinkoff.android';
|
||
const _vtb = 'ru.vtb24.mobilebanking.android';
|
||
|
||
void _createV1Schema(dynamic raw) {
|
||
raw.execute('''
|
||
CREATE TABLE source_apps (
|
||
id TEXT NOT NULL PRIMARY KEY,
|
||
user_id TEXT NOT NULL,
|
||
package_name TEXT NOT NULL,
|
||
display_name TEXT,
|
||
enabled INTEGER NOT NULL DEFAULT 1,
|
||
self_merchant INTEGER NOT NULL DEFAULT 0,
|
||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||
UNIQUE (user_id, package_name)
|
||
);''');
|
||
raw.execute('''
|
||
CREATE TABLE parse_rules (
|
||
id TEXT NOT NULL PRIMARY KEY,
|
||
user_id TEXT NOT NULL,
|
||
package_name TEXT,
|
||
kind TEXT NOT NULL,
|
||
match_mode TEXT NOT NULL DEFAULT 'contains',
|
||
pattern TEXT NOT NULL,
|
||
priority INTEGER NOT NULL DEFAULT 0,
|
||
match_count INTEGER NOT NULL DEFAULT 0,
|
||
weight INTEGER NOT NULL DEFAULT 1,
|
||
last_match_at INTEGER,
|
||
tx_type TEXT,
|
||
merchant_canonical TEXT,
|
||
category_id TEXT,
|
||
account_id TEXT,
|
||
enabled INTEGER NOT NULL DEFAULT 1,
|
||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||
);''');
|
||
raw.execute('''
|
||
CREATE TABLE account_bindings (
|
||
id TEXT NOT NULL PRIMARY KEY,
|
||
user_id TEXT NOT NULL,
|
||
package_name TEXT,
|
||
bank_key TEXT,
|
||
card_last4 TEXT,
|
||
phone TEXT,
|
||
account_id TEXT NOT NULL,
|
||
is_default INTEGER NOT NULL DEFAULT 0,
|
||
match_count INTEGER NOT NULL DEFAULT 0,
|
||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now')),
|
||
UNIQUE (user_id, package_name, card_last4)
|
||
);''');
|
||
|
||
raw.execute(
|
||
'INSERT INTO source_apps (id, user_id, package_name) VALUES '
|
||
"('src-sber', '$_userId', '$_sber'), "
|
||
"('src-tinkoff', '$_userId', '$_tinkoff'), "
|
||
"('src-vtb', '$_userId', '$_vtb')");
|
||
|
||
// Сбер: default-связка по карте 1234 + вторая карта 5678 → отдельный счёт.
|
||
raw.execute(
|
||
'INSERT INTO account_bindings '
|
||
'(id, user_id, package_name, card_last4, account_id, is_default, match_count) VALUES '
|
||
"('b1', '$_userId', '$_sber', '1234', 'acc-1', 1, 3), "
|
||
"('b2', '$_userId', '$_sber', '5678', 'acc-2', 0, 7)");
|
||
// Тинькофф: единственная связка без карты и default-флага.
|
||
raw.execute(
|
||
'INSERT INTO account_bindings '
|
||
'(id, user_id, package_name, account_id) VALUES '
|
||
"('b3', '$_userId', '$_tinkoff', 'acc-3')");
|
||
// ВТБ: две карточные связки без default — дефолт не выводится, оба правила.
|
||
raw.execute(
|
||
'INSERT INTO account_bindings '
|
||
'(id, user_id, package_name, card_last4, account_id) VALUES '
|
||
"('b4', '$_userId', '$_vtb', '1111', 'acc-5'), "
|
||
"('b5', '$_userId', '$_vtb', '2222', 'acc-6')");
|
||
// Легаси phone-связка без package_name → глобальное правило.
|
||
raw.execute(
|
||
'INSERT INTO account_bindings '
|
||
'(id, user_id, phone, account_id) VALUES '
|
||
"('b6', '$_userId', '+79990001122', 'acc-4')");
|
||
|
||
raw.execute('PRAGMA user_version = 1');
|
||
}
|
||
|
||
void main() {
|
||
late AppDatabase db;
|
||
|
||
setUp(() {
|
||
db = AppDatabase.forTesting(NativeDatabase.memory(setup: _createV1Schema));
|
||
});
|
||
|
||
tearDown(() => db.close());
|
||
|
||
test('default-связка (или единственная) → source_apps.default_account_id',
|
||
() async {
|
||
final apps = await db.select(db.sourceAppsTable).get();
|
||
final byId = {for (final a in apps) a.id: a};
|
||
|
||
expect(byId['src-sber']!.defaultAccountId, 'acc-1',
|
||
reason: 'is_default=1 → дефолт приложения');
|
||
expect(byId['src-tinkoff']!.defaultAccountId, 'acc-3',
|
||
reason: 'единственная связка → дефолт приложения');
|
||
expect(byId['src-vtb']!.defaultAccountId, isNull,
|
||
reason: 'несколько связок без default — дефолт не выводится');
|
||
});
|
||
|
||
test('карточные/phone-связки → правила senderToAccount, избыточные — скип',
|
||
() async {
|
||
final rules = await db.select(db.parseRulesTable).get();
|
||
expect(rules.every((r) => r.kind == ParseRuleKind.senderToAccount), isTrue);
|
||
expect(rules.every((r) => r.matchMode == MatchMode.contains), isTrue);
|
||
expect(rules.every((r) => r.enabled), isTrue,
|
||
reason: 'SQL-дефолт enabled=1 должен примениться');
|
||
expect(rules.every((r) => r.txType == null), isTrue,
|
||
reason: 'tx_type NULL — gate пропускает проверку типа');
|
||
|
||
final byPattern = {for (final r in rules) r.pattern: r};
|
||
expect(byPattern.keys.toSet(), {'5678', '1111', '2222', '+79990001122'},
|
||
reason: '«1234» не мигрирует: счёт совпал с новым дефолтом приложения');
|
||
|
||
expect(byPattern['5678']!.packageName, _sber);
|
||
expect(byPattern['5678']!.accountId, 'acc-2');
|
||
expect(byPattern['5678']!.matchCount, 7,
|
||
reason: 'match_count переносится из связки');
|
||
|
||
expect(byPattern['1111']!.accountId, 'acc-5');
|
||
expect(byPattern['2222']!.accountId, 'acc-6');
|
||
|
||
expect(byPattern['+79990001122']!.packageName, isNull,
|
||
reason: 'phone-связка без package_name → глобальное правило (легаси)');
|
||
expect(byPattern['+79990001122']!.accountId, 'acc-4');
|
||
});
|
||
|
||
test('таблица account_bindings удалена', () async {
|
||
final left = await db
|
||
.customSelect(
|
||
"SELECT name FROM sqlite_master WHERE type = 'table' "
|
||
"AND name = 'account_bindings'",
|
||
)
|
||
.get();
|
||
expect(left, isEmpty);
|
||
});
|
||
|
||
test('свежая БД (onCreate) сразу содержит default_account_id и без bindings',
|
||
() async {
|
||
final fresh = AppDatabase.forTesting(NativeDatabase.memory());
|
||
addTearDown(fresh.close);
|
||
|
||
// Колонка есть → insert с defaultAccountId проходит.
|
||
await fresh.usersDao.insertUser(
|
||
UsersTableCompanion.insert(id: _userId, name: 'Test'),
|
||
);
|
||
await fresh.sourceAppsDao.insert(SourceAppsTableCompanion.insert(
|
||
id: 'src-1',
|
||
userId: _userId,
|
||
packageName: _sber,
|
||
));
|
||
await fresh.sourceAppsDao.setDefaultAccount('src-1', 'acc-1');
|
||
final row = await fresh.sourceAppsDao.findById('src-1');
|
||
expect(row!.defaultAccountId, 'acc-1');
|
||
|
||
final bindings = await fresh
|
||
.customSelect(
|
||
"SELECT name FROM sqlite_master WHERE type = 'table' "
|
||
"AND name = 'account_bindings'",
|
||
)
|
||
.get();
|
||
expect(bindings, isEmpty);
|
||
});
|
||
}
|