Files
OnBudget/test/core/database/migration_v2_test.dart
SandersandClaude Opus 4.8 eed9164150 Add CompanionDevice watch pairing and store raw AI responses
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>
2026-07-20 22:04:39 +03:00

214 lines
8.5 KiB
Dart
Raw Permalink 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')");
// Хвост цепочки (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);
});
}