Two fixes for the notification-parsing pipeline: - CompanionDeviceManager pairing (DEVICE_PROFILE_WATCH -> COMPANION_DEVICE_WATCH role -> RECEIVE_SENSITIVE_NOTIFICATIONS) via a new platform channel in MainActivity.kt, companion_device_channel.dart and companion_access_controller, surfaced as a card in parsing settings. Lifts the system "Confidential" redaction that hid VTB/T-Bank notification text from the listener. - raw_messages.ai_response: the model's raw completion content is now persisted (schema v4 + migration) and shown in the parsing log detail panel. Unlike draftJson it survives inbox sweeps and is filled even for partial/ignored outcomes. flutter analyze: no issues. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
214 lines
8.5 KiB
Dart
214 lines
8.5 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')");
|
||
|
||
// Хвост цепочки (v3→v4) добавляет колонку в raw_messages — в реальной
|
||
// v1-БД таблица есть, здесь достаточно её минимальной схемы.
|
||
raw.execute('''
|
||
CREATE TABLE raw_messages (
|
||
id TEXT NOT NULL PRIMARY KEY,
|
||
user_id TEXT NOT NULL,
|
||
package_name TEXT NOT NULL,
|
||
title TEXT,
|
||
body TEXT NOT NULL,
|
||
received_at INTEGER NOT NULL,
|
||
dedup_hash TEXT NOT NULL,
|
||
status TEXT NOT NULL DEFAULT 'pending',
|
||
parse_attempt_count INTEGER NOT NULL DEFAULT 0,
|
||
last_parse_error TEXT,
|
||
draft_json TEXT,
|
||
diagnostics TEXT,
|
||
confidence_amount INTEGER,
|
||
confidence_account INTEGER,
|
||
confidence_type INTEGER,
|
||
confidence_merchant INTEGER,
|
||
confidence_category INTEGER,
|
||
transaction_id TEXT,
|
||
paired_with_id TEXT,
|
||
pair_deadline INTEGER,
|
||
created_at INTEGER NOT NULL DEFAULT (strftime('%s','now'))
|
||
);''');
|
||
|
||
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-связки → правила выбора счёта, избыточные — скип',
|
||
() async {
|
||
// Открытие с user_version=1 гонит цепочку v1→v2→v3: вставленные в v2
|
||
// senderToAccount-строки доезжают до единой модели как «правила со счётом».
|
||
final rules = await db.select(db.parseRulesTable).get();
|
||
expect(rules.every((r) => r.accountId != null && !r.isIgnore), isTrue,
|
||
reason: 'после v3 вид определяется действиями: задан только счёт');
|
||
expect(rules.every((r) => r.autoApply), isTrue,
|
||
reason: 'SQL-дефолт auto_apply=1 должен примениться');
|
||
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.txTypeGuard == 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);
|
||
});
|
||
}
|