diff --git a/docs/database.md b/docs/database.md new file mode 100644 index 0000000..ade8654 --- /dev/null +++ b/docs/database.md @@ -0,0 +1,80 @@ +# База данных — схема и миграции + +SQLite-база, путь задаётся в `config.ini` (`db/dbPath`). Управляется через `DatabaseManager` (singleton) + DAO-слой. + +--- + +## Инициализация схемы + +При каждом запуске приложения вызывается `DatabaseManager::initSchema()` (`database/DatabaseManager.cpp`). +Именно здесь происходят все миграции — до открытия главного окна. + +При **обновлении из меню** приложение скачивает новый `.exe`, применяет его и перезапускается, поэтому `initSchema()` тоже выполняется заново на новой версии. + +--- + +## Что применяется автоматически при обновлении + +| Изменение | Применяется? | Как | +|-----------|-------------|-----| +| Новая таблица | Да | `CREATE TABLE IF NOT EXISTS` | +| Новая колонка | Да | `ALTER TABLE ... ADD COLUMN` | +| Удаление таблицы | Да | `DROP TABLE IF EXISTS` | +| Удаление колонки | Нет | SQLite не поддерживает | +| Переименование колонки | Нет | Нужна пересборка таблицы | +| Изменение типа колонки | Нет | Нужна пересборка таблицы | +| Изменение DEFAULT у существующей колонки | Нет | `CREATE TABLE IF NOT EXISTS` не выполняется повторно | + +--- + +## Паттерн добавления колонки + +```cpp +// 1. Добавить колонку в CREATE TABLE IF NOT EXISTS (для новых установок) +q.exec(R"(CREATE TABLE IF NOT EXISTS my_table ( + ... + new_col TEXT DEFAULT '' +))"); + +// 2. Добавить ALTER TABLE для существующих БД (ошибка "column already exists" игнорируется) +q.exec("ALTER TABLE my_table ADD COLUMN new_col TEXT DEFAULT ''"); +``` + +Оба шага обязательны: `CREATE TABLE` покрывает свежие установки, `ALTER TABLE` — обновление существующей БД. + +--- + +## Сложные миграции (изменение типа, переименование) + +Для изменений, которые SQLite не поддерживает напрямую, используется ручная миграция с пересозданием таблицы и `PRAGMA user_version` для версионирования: + +```cpp +QSqlQuery q(db); +q.exec("PRAGMA user_version"); +q.next(); +int version = q.value(0).toInt(); + +if (version < 2) { + // пересоздаём таблицу + q.exec("ALTER TABLE my_table RENAME TO my_table_old"); + q.exec("CREATE TABLE my_table (...)"); + q.exec("INSERT INTO my_table SELECT ... FROM my_table_old"); + q.exec("DROP TABLE my_table_old"); + q.exec("PRAGMA user_version = 2"); +} +``` + +--- + +## Таблицы + +| Таблица | Описание | +|---------|----------| +| `devices` | Android-устройства, подключённые по ADB | +| `bank_profiles` | Банковские профили (приложение + устройство) | +| `materials` | Карты/счета профиля | +| `transactions` | Транзакции по картам | +| `events` | Задачи из очереди API | +| `banks` | Справочник банков (alias, name, nspk_name) | +| `general_logs` | Общий лог приложения | +| `settings` | Настройки key-value | \ No newline at end of file diff --git a/tests/mock_server/test_monitoring_log_format.py b/tests/mock_server/test_monitoring_log_format.py new file mode 100644 index 0000000..1e28025 --- /dev/null +++ b/tests/mock_server/test_monitoring_log_format.py @@ -0,0 +1,201 @@ +#!/usr/bin/env python3 +""" +Test: monitoring log format for task_success / task_error. + +Starts the mock server in a subprocess, sends POST /monitoring/log +requests that replicate what ARC C++ sends, then fetches the logs via +/_admin/monitoring_logs and asserts the JSON format. + +Usage: + python3 test_monitoring_log_format.py + +Requirements: flask (same as mock_server.py), requests + pip install flask requests +""" + +import json +import os +import subprocess +import sys +import time + +import requests + +PORT = 18889 +BASE = f"http://127.0.0.1:{PORT}" + +TX = { + "amount": 5000, + "bank_participator_id": "test-participator", + "bank_transaction_id": "dushanbe_2026-06-03T18:34:18Z", + "created_at": "2026-06-03T18:34:18Z", + "currency": 972, + "extra": {"fee": 0}, + "real_created_at": "2026-06-03T18:34:34Z", + "status": "completed", + "transaction_type": "bank", +} + + +def build_task_success_message(tx: dict, *, pretty: bool) -> str: + """Build the monitoring message the way C++ sendTaskSuccess does. + + pretty=True — new format (after the fix): 'JSON: ' + indented JSON + pretty=False — old format (before the fix): compact JSON on one line + """ + lines = [ + "[TASK_SUCCESS] [FETCH_TRANSACTIONS] [DUSHANBE_CITY_BANK]", + "Desktop: f7ff2724-78bd-4783-876d-faece1106c6a", + "DesktopVersion: 1.0.118", + "Profile: 4b9a2bbd-7201-49cb-beb3-7839f21acc2e", + "Material: bb537b06-155b-4e39-82ff-4b14f5b7c327", + "Phone: +992919275251", + "AppVersion: 3.2.41", + ] + if pretty: + lines.append("JSON: " + json.dumps(tx, indent=2, ensure_ascii=False)) + else: + lines.append(json.dumps(tx, ensure_ascii=False, separators=(",", ":"))) + return "\n".join(lines) + + +def build_task_error_message(tx: dict) -> str: + """Build the monitoring message the way C++ sendTaskError does.""" + lines = [ + "[TASK_ERROR] [FETCH_TRANSACTIONS] [DUSHANBE_CITY_BANK]", + "Desktop: f7ff2724-78bd-4783-876d-faece1106c6a", + "DesktopVersion: 1.0.118", + "Profile: 4b9a2bbd-7201-49cb-beb3-7839f21acc2e", + "Material: bb537b06-155b-4e39-82ff-4b14f5b7c327", + "Phone: +992919275251", + "AppVersion: 3.2.41", + "Error: some error description", + "JSON: " + json.dumps(tx, indent=2, ensure_ascii=False), + ] + return "\n".join(lines) + + +def post_log(msg: str, event: str = "task_success") -> None: + resp = requests.post( + f"{BASE}/monitoring/log", + data={"type": "info", "event": event, "message": msg}, + timeout=5, + ) + resp.raise_for_status() + + +def get_logs(event: str) -> list: + resp = requests.get(f"{BASE}/_admin/monitoring_logs", params={"event": event}, timeout=5) + resp.raise_for_status() + return resp.json() + + +# ------------------------------------------------------------------ tests + +PASS = "\033[32mPASS\033[0m" +FAIL = "\033[31mFAIL\033[0m" + + +def assert_eq(a, b, label): + if a == b: + print(f" {PASS}: {label}") + else: + print(f" {FAIL}: {label}") + print(f" expected: {b!r}") + print(f" got: {a!r}") + sys.exit(1) + + +def assert_true(cond, label): + assert_eq(bool(cond), True, label) + + +def test_new_format_is_pretty(): + msg = build_task_success_message(TX, pretty=True) + post_log(msg, event="task_success") + + logs = get_logs("task_success") + assert_true(len(logs) >= 1, "at least one task_success log received") + + latest = logs[-1]["message"] + + assert_true("JSON: " in latest, "message contains 'JSON: ' prefix") + + json_part = latest.split("JSON: ", 1)[1] + parsed = json.loads(json_part) + assert_eq(parsed["amount"], 5000, "JSON 'amount' parsed correctly") + assert_eq(parsed["status"], "completed", "JSON 'status' parsed correctly") + + assert_true("\n" in json_part, "JSON is multi-line (indented)") + assert_true(" " in json_part, "JSON has two-space indentation") + + compact = json.dumps(TX, ensure_ascii=False, separators=(",", ":")) + assert_true(compact not in latest, "compact JSON not present (was replaced by pretty)") + + +def test_new_format_matches_error_style(): + success_msg = build_task_success_message(TX, pretty=True) + error_msg = build_task_error_message(TX) + + success_json = success_msg.split("JSON: ", 1)[1] + error_json = error_msg.split("JSON: ", 1)[1] + + assert_eq(success_json, error_json, "task_success JSON block equals task_error JSON block") + + +def test_old_format_is_compact(): + """Sanity check: confirm old format had no 'JSON: ' prefix and was compact.""" + msg = build_task_success_message(TX, pretty=False) + assert_true("JSON: " not in msg, "old format has no 'JSON: ' prefix") + assert_true( + json.dumps(TX, ensure_ascii=False, separators=(",", ":")) in msg, + "old format contains compact JSON", + ) + + +# ------------------------------------------------------------------ runner + +def wait_for_server(timeout=10): + deadline = time.time() + timeout + while time.time() < deadline: + try: + requests.get(f"{BASE}/_admin/state", timeout=1) + return True + except Exception: + time.sleep(0.2) + return False + + +def main(): + here = os.path.dirname(os.path.abspath(__file__)) + server = subprocess.Popen( + [sys.executable, os.path.join(here, "mock_server.py"), "--port", str(PORT)], + stdout=subprocess.DEVNULL, + stderr=subprocess.DEVNULL, + ) + try: + if not wait_for_server(): + print("ERROR: mock server did not start in time") + sys.exit(1) + + print("Running tests against mock server...") + print() + + print("test_old_format_is_compact") + test_old_format_is_compact() + + print("test_new_format_is_pretty") + test_new_format_is_pretty() + + print("test_new_format_matches_error_style") + test_new_format_matches_error_style() + + print() + print("All tests passed.") + finally: + server.terminate() + server.wait() + + +if __name__ == "__main__": + main()