Files
OnBudget/test/core/database/migration_v2_test.dart
T
SandersandClaude Opus 4.8 4f99b80169 Replace account_bindings with per-app default account; build analytics charts
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>
2026-07-18 00:09:12 +03:00

182 lines
7.1 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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);
});
}