diff --git a/android/dushanbe/GetLastTransactionsScript.cpp b/android/dushanbe/GetLastTransactionsScript.cpp index b1f004d..a5a7b7f 100644 --- a/android/dushanbe/GetLastTransactionsScript.cpp +++ b/android/dushanbe/GetLastTransactionsScript.cpp @@ -358,7 +358,11 @@ void GetLastTransactionsScript::doStart() { const QString tzName = AdbUtils::getDeviceTimezone(m_deviceId); QTimeZone tjTz(tzName.toUtf8()); if (!tjTz.isValid()) tjTz = QTimeZone("Asia/Dushanbe"); - const int maxScrolls = 10; + const int maxScrolls = 4; + + auto interrupted = []() { + return QThread::currentThread()->isInterruptionRequested(); + }; struct VypiskaItem { double amount = 0.0; @@ -368,6 +372,11 @@ void GetLastTransactionsScript::doStart() { }; for (int scroll = 0; scroll <= maxScrolls; ++scroll) { + if (interrupted()) { + m_error = "Interrupted"; + emit finishedWithResult(m_error); + return; + } xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); // 1. Собираем список дат-групп + извлекаем "встроенную" верхнюю транзакцию, @@ -507,6 +516,23 @@ void GetLastTransactionsScript::doStart() { << (matchedX100 ? "(scaled x100)" : "(direct)") << "date:" << tx.date << "time:" << tx.time; + // Pre-filter по ДАТЕ: если дата ряда не совпадает с датой поиска (±1 день), + // пропускаем без тапа-в-детали. Это отсекает большинство "нематчащих" строк + // быстрее, чем 10-секундный цикл tap→parse→goBack. + if (m_searchTime.isValid() && !tx.date.isEmpty()) { + const QDate listDate = QDate::fromString(tx.date, "dd.MM.yyyy"); + const QDate searchLocalDate = m_searchTime.toTimeZone(tjTz).date(); + if (listDate.isValid() && searchLocalDate.isValid()) { + const qint64 dayDiff = qAbs(listDate.daysTo(searchLocalDate)); + if (dayDiff > 1) { + qDebug() << "[Dushanbe::GetLastTransactions] Date pre-filter skip:" + << tx.date << "vs" << searchLocalDate.toString("dd.MM.yyyy") + << "diff=" << dayDiff << "days"; + continue; + } + } + } + // Проверка времени по HH:MM:SS (±1 час, с учётом wraparound полуночи) if (m_searchTime.isValid() && !tx.time.isEmpty()) { const QTime listTime = QTime::fromString(tx.time, "HH:mm:ss"); @@ -531,11 +557,13 @@ void GetLastTransactionsScript::doStart() { AdbUtils::makeTap(m_deviceId, txNode.x(), txNode.y()); QThread::msleep(3000); + if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; } // Ждём экран деталей Выписки — ImageView с content-desc начинающимся с "Успешный платеж" OcrTransactionDetail detail; bool detailLoaded = false; for (int attempt = 0; attempt < 10; ++attempt) { + if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; } xmlScreenParser.parseAndSaveXml(AdbUtils::getScreenDumpAsXml(m_deviceId, ++m_counter)); for (const Node &n : xmlScreenParser.findAllNodes()) { if (!n.contentDesc.contains(QString::fromUtf8("Успешный платеж"))) continue; @@ -658,6 +686,7 @@ void GetLastTransactionsScript::doStart() { // Скроллим вниз if (scroll < maxScrolls) { + if (interrupted()) { m_error = "Interrupted"; emit finishedWithResult(m_error); return; } const int x = device.screenWidth / 2; const int fromY = device.screenHeight * 3 / 4; const int toY = device.screenHeight / 4; diff --git a/android/ozon/GetLastTransactionsScript.cpp b/android/ozon/GetLastTransactionsScript.cpp index 4a87e6f..5192288 100644 --- a/android/ozon/GetLastTransactionsScript.cpp +++ b/android/ozon/GetLastTransactionsScript.cpp @@ -209,7 +209,7 @@ void GetLastTransactionsScript::searchTransaction(const DeviceInfo &device, int const QString tzName = AdbUtils::getDeviceTimezone(m_deviceId); QTimeZone deviceTz(tzName.toUtf8()); if (!deviceTz.isValid()) deviceTz = QTimeZone("Europe/Moscow"); - const int maxScrolls = 30; + const int maxScrolls = 8; const int navBarTop = device.screenHeight - (device.screenHeight / 10); // Целевая дата в таймзоне устройства для поиска заголовка дня в списке diff --git a/config.ini b/config.ini index 3e01cd8..785f64c 100644 --- a/config.ini +++ b/config.ini @@ -9,7 +9,8 @@ version=0.0.1 dbPath=~/CLionProjects/ARCDeskProject/ARCDeskProject/database/app_data.db [net] -apiBase=http://93.183.75.134:8005 +#apiBase=http://93.183.75.134:8005 +apiBase=http://127.0.0.1:8888 pathAuthLogin=/api/v1/auth/login pathAuthRefresh=/api/v1/auth/refresh_token pathBankProfile=/api/v1/profile/bank_profile diff --git a/database/dao/EventDAO.cpp b/database/dao/EventDAO.cpp index 9265d9a..cb5973a 100644 --- a/database/dao/EventDAO.cpp +++ b/database/dao/EventDAO.cpp @@ -272,12 +272,20 @@ bool EventDAO::existsActiveByExternalId(const QString &externalId) { return query.next(); } -bool EventDAO::cancelWaitingByType(const QString &deviceId, EventType type) { +bool EventDAO::cancelWaitingByType(const QString &deviceId, EventType type, const QString &bankName) { QSqlQuery query(DatabaseManager::instance().database()); - query.prepare(R"( - UPDATE events SET status = :cancelled, comment = 'Cancelled: replaced by new task' - WHERE device_id = :device_id AND type = :type AND status = :wait - )"); + if (bankName.isEmpty()) { + query.prepare(R"( + UPDATE events SET status = :cancelled, comment = 'Cancelled: replaced by new task' + WHERE device_id = :device_id AND type = :type AND status = :wait + )"); + } else { + query.prepare(R"( + UPDATE events SET status = :cancelled, comment = 'Cancelled: replaced by new task' + WHERE device_id = :device_id AND type = :type AND status = :wait AND bank_name = :bank_name + )"); + query.bindValue(":bank_name", bankName); + } query.bindValue(":cancelled", eventStatusToString(EventStatus::Error)); query.bindValue(":device_id", deviceId); query.bindValue(":type", eventTypeToString(type)); diff --git a/database/dao/EventDAO.h b/database/dao/EventDAO.h index ed2228e..06f414b 100644 --- a/database/dao/EventDAO.h +++ b/database/dao/EventDAO.h @@ -19,7 +19,7 @@ public: [[nodiscard]] static bool existsActiveByExternalId(const QString &externalId); - [[nodiscard]] static bool cancelWaitingByType(const QString &deviceId, EventType type); + [[nodiscard]] static bool cancelWaitingByType(const QString &deviceId, EventType type, const QString &bankName = {}); [[nodiscard]] static QList cancelWaitingByBank(const QString &deviceId, const QString &bankName); diff --git a/services/EventHandler.cpp b/services/EventHandler.cpp index 8e84d52..87ed40b 100644 --- a/services/EventHandler.cpp +++ b/services/EventHandler.cpp @@ -31,8 +31,9 @@ #include "dushanbe/PayByCardScript.h" #include "dushanbe/PayByPhoneScript.h" -static void sendTaskResult(const QString &type, const QString &materialId, const QString &bankName, const QJsonValue &data); +static void sendTaskResult(const QString &type, const QString &materialId, const QString &bankName, const QJsonValue &data, int eventId = -1); static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName, const QString &description, const QByteArray &screenshot = {}); +static void sendMonitoringLog(const QString &type, const QString &event, const QString &message, const QByteArray &image = {}); EventHandler::EventHandler(QObject *parent) : QObject(parent) {} @@ -110,9 +111,9 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) { continue; } - // FETCH_PROFILE: отменяем старые ожидающие задачи такого же типа на этом устройстве + // FETCH_PROFILE: отменяем старые ожидающие задачи такого же типа на этом устройстве+банк if (eventType == EventType::FetchProfile) { - EventDAO::cancelWaitingByType(account.deviceId, EventType::FetchProfile); + EventDAO::cancelWaitingByType(account.deviceId, EventType::FetchProfile, bankName); } EventInfo event; @@ -161,7 +162,7 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) { } static void sendTaskResult(const QString &type, const QString &materialId, const QString &bankName, - const QJsonValue &data, int eventId = -1) { + const QJsonValue &data, int eventId) { QJsonObject obj; obj["type"] = type; obj["material_id"] = materialId; // UUID string @@ -192,24 +193,20 @@ static void sendTaskResult(const QString &type, const QString &materialId, const static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName, const QString &description, const QByteArray &screenshot) { - const QString message = QStringLiteral("[%1] bank=%2 material=%3: %4") - .arg(type, bankName, materialId, description); + QJsonObject errorData; + errorData["status"] = QStringLiteral("error"); + errorData["description"] = description; + sendTaskResult(type, materialId, bankName, errorData); - auto *thread = new QThread; - auto *networkService = new NetworkService; - networkService->moveToThread(thread); - QObject::connect(thread, &QThread::started, networkService, [networkService, message, screenshot]() { - networkService->postMonitoringLog("error", "task_error", message, screenshot); - emit networkService->finished(); - }); - QObject::connect(networkService, &NetworkService::finished, thread, &QThread::quit); - QObject::connect(networkService, &NetworkService::finished, networkService, &QObject::deleteLater); - QObject::connect(thread, &QThread::finished, thread, &QThread::deleteLater); - thread->start(); + if (!screenshot.isEmpty()) { + const QString message = QStringLiteral("[%1] bank=%2 material=%3: %4") + .arg(type, bankName, materialId, description); + sendMonitoringLog("error", "task_error", message, screenshot); + } } static void sendMonitoringLog(const QString &type, const QString &event, const QString &message, - const QByteArray &image = {}) { + const QByteArray &image) { auto *thread = new QThread; auto *networkService = new NetworkService; networkService->moveToThread(thread); diff --git a/tests/mock_server/README.md b/tests/mock_server/README.md new file mode 100644 index 0000000..09407b1 --- /dev/null +++ b/tests/mock_server/README.md @@ -0,0 +1,316 @@ +# Интеграционные тесты задач — mock-сервер + +ARC запускается как обычно (реальные Android-устройства через ADB), но +подключается к локальному Flask-моку вместо боевого бэкенда. Мок раздаёт +задачи по одной на material, собирает результаты и показывает отчёт в браузере. + +--- + +## 1. Требования + +| Компонент | macOS | Windows | +|---|---|---| +| Python | `brew install python` или встроенный | [python.org](https://python.org), галочка «Add to PATH» | +| Flask | `pip install flask` | `pip install flask` | +| ADB | `brew install android-platform-tools` | Используется из `tools\adb\windows\` | +| ARC | `cmake --build build` | `cmake --build build --config Release` | +| Устройства | `adb devices` → `device` | `tools\adb\windows\adb.exe devices` | + +--- + +## 2. Генерация тестовых данных из БД + +Перед первым запуском (или при смене устройств/аккаунтов) нужно сгенерировать +мок-данные из локальной базы. Скрипты читают `config.ini → [db] dbPath`. + +### 2.1 Стартовые данные: профиль + FETCH_PROFILE + +```bash +# macOS (из корня проекта) +python3 tests/mock_server/gen_mock_data.py + +# Windows +python tests\mock_server\gen_mock_data.py +``` + +Скрипт: +- Находит `config.ini` в текущей директории (или `build/Release/config.ini`) +- Открывает БД, читает активные `devices → bank_profiles → materials` +- Генерирует: + - `tests/mock_server/default_profile.json` — профиль для мока + - `tests/mock_server/scenarios/*_fetch_profile.json` — по одному на материал + - `tests/mock_server/scenarios/*_fetch_transactions.json` — для ручных прогонов + +Пример вывода: +``` +config: config.ini +database: database/app_data.db + wrote default_profile.json (3 bank_profiles) + wrote scenarios/pixel_7_ozon_fetch_profile.json + wrote scenarios/pixel_7_dushanbe_city_bank_fetch_profile.json + wrote scenarios/tecno_km4_ozon_fetch_profile.json + +Devices (2): + pixel_7: ozon (30bc106c…), dushanbe_city_bank (61d967d1…) + tecno_km4: ozon (49001bb5…) +``` + +Флаги: `--config C:\path\to\config.ini` — если config не в текущей директории. + +### 2.2 Пул FETCH_TRANSACTIONS с живого устройства + +```bash +# macOS +python3 tests/mock_server/gen_fetch_transactions.py + +# Windows +python tests\mock_server\gen_fetch_transactions.py +``` + +Скрипт: +- Читает БД → находит активные материалы +- Находит подключённые ADB-устройства → матчит по `adb_serial` +- Показывает меню: + ``` + Available materials (3): + 1. Pixel 7 / ozon / Галина Терехина (material 30bc106c…) + 2. Pixel 7 / dushanbe_city_bank / Мехрубон (material 61d967d1…) + 3. TECNO KM4 / ozon / Владислав Касаткин (material 49001bb5…) + Pick [1-3] or 'all': + ``` +- После выбора просит открыть список транзакций на устройстве → Enter +- Проходит по каждой транзакции: тап → дамп деталей → назад + (перед каждым тапом проверяет, что мы на списке) +- Сохраняет `scenarios/pool/*_ft_*.json` с реальными `amount` (в копейках) + и `created_at` (UTC, из таймзоны устройства) +- Если мок запущен — сразу инжектит задачи + +Флаги: +- `--serial 148697059H000712` — явный ADB-серийник +- `--kopecks 100` — множитель суммы (по умолчанию 100) +- `--config path` — путь к config.ini + +--- + +## 3. Переключение ARC на мок + +В `config.ini` → `[net]`: + +```ini +[net] +apiBase=http://127.0.0.1:8888 +``` + +Остальные `path*` оставить как есть. + +> На Windows: `config.ini` обычно лежит рядом с `ARC.exe` в `build\Release\`. +> Совет: держите копию `config.ini.mock` и `config.ini.prod`. + +--- + +## 4. Запуск теста + +### Терминал 1 — mock-сервер + +```bash +# macOS +cd tests/mock_server && ./run.sh + +# Windows +cd tests\mock_server +python mock_server.py --port 8888 +``` + +Логи в консоли: +``` +[mock] state cleared — starting fresh +[mock] loaded default_profile.json: 3 bank_profiles, 3 materials +[mock] loaded pool: 19 FETCH_TRANSACTIONS across 3 materials + * Running on http://127.0.0.1:8888 +``` + +Флаги: +- `--ft-per-material 3` — сколько FT на материал при бутстрапе (default 3) +- `--seed 42` — фиксированный RNG для воспроизводимости + +### Терминал 2 — ARC + +```bash +# macOS +./build/ARC + +# Windows +build\Release\ARC.exe +``` + +При логине мок автоматически ставит задачи: +- 1 × FETCH_PROFILE на каждый материал +- N × FETCH_TRANSACTIONS (случайные из pool) + +Задачи раздаются **по 1 на material за поллинг** — следующая только после +получения результата предыдущей. Это предотвращает expire задач в очереди. + +### Терминал 3 — отчёт + +```bash +# Браузер с авто-обновлением (задачи группируются по устройствам) +open http://127.0.0.1:8888/_admin/report/html # macOS +start http://127.0.0.1:8888/_admin/report/html # Windows + +# Счётчики в реальном времени +watch -n 2 'curl -s http://127.0.0.1:8888/_admin/state' # macOS + +# Windows (PowerShell) +while ($true) { cls; (iwr http://127.0.0.1:8888/_admin/state).Content; sleep 2 } +``` + +--- + +## 5. HTML-отчёт + +`http://127.0.0.1:8888/_admin/report/html` — веб-страница с: +- Сводкой: всего / выполнено / в очереди +- Группировкой по устройствам (Pixel 7, TECNO KM4, …) +- Для каждой задачи: Task JSON слева ↔ Result JSON справа +- Цветовой индикацией: зелёный = completed, жёлтый = pending, красный = error +- Авто-обновлением каждые 2 секунды (без сброса скролла) + +Также доступен JSON: +```bash +curl -s http://127.0.0.1:8888/_admin/report | jq # полный +curl -s http://127.0.0.1:8888/_admin/report/pending | jq # только pending +``` + +--- + +## 6. Ручной инжект задач + +```bash +# Одна задача +./inject.sh scenarios/pixel_7_ozon_fetch_profile.json # macOS +curl -X POST http://127.0.0.1:8888/_admin/inject ^ # Windows + -H "Content-Type: application/json" ^ + --data-binary @scenarios\pixel_7_ozon_fetch_profile.json + +# N случайных из пула +python inject_random.py --count 5 + +# Повторный бутстрап (без перезапуска мока) +curl -X POST http://127.0.0.1:8888/_admin/bootstrap + +# Сброс (очистка очереди + результатов + in-flight) +curl -X POST http://127.0.0.1:8888/_admin/reset +``` + +--- + +## 7. Админ-эндпойнты мока + +| Метод | Путь | Назначение | +|---|---|---| +| POST | `/_admin/inject` | Положить задачу (или массив) в очередь | +| POST | `/_admin/bootstrap` | Повторный бутстрап (1 FP + N FT на material) | +| POST | `/_admin/seed_profile` | Подменить ответ `current_profile` | +| POST | `/_admin/seed_devices` | Подменить список устройств | +| GET | `/_admin/report` | JSON: полный отчёт задачи ↔ результаты | +| GET | `/_admin/report/html` | HTML-отчёт с авто-обновлением | +| GET | `/_admin/report/pending` | Только незавершённые задачи | +| GET | `/_admin/report/fragment` | JSON-фрагмент для JS-обновления | +| GET | `/_admin/state` | Счётчики | +| POST | `/_admin/reset` | Очистить всё, сбросить бутстрап | + +--- + +## 8. Полный цикл с нуля (новая машина / новые устройства) + +```bash +# 1. Установка +pip install flask + +# 2. Собрать ARC, запустить с боевым сервером → залогиниться → устройства, +# банковские профили и материалы появятся в БД + +# 3. Переключить config.ini на мок +# [net] +# apiBase=http://127.0.0.1:8888 + +# 4. Сгенерировать стартовые данные из БД +python tests/mock_server/gen_mock_data.py + +# 5. Открыть на устройстве банковское приложение → список транзакций +# Сгенерировать пул задач +python tests/mock_server/gen_fetch_transactions.py + +# 6. Запустить мок +cd tests/mock_server && python mock_server.py --port 8888 + +# 7. Запустить ARC (в отдельном терминале) +./build/ARC # macOS +build\Release\ARC.exe # Windows + +# 8. Открыть отчёт +open http://127.0.0.1:8888/_admin/report/html + +# 9. Дождаться completed = всего в отчёте + +# 10. Вернуть config.ini на прод +# [net] +# apiBase=http://93.183.75.134:8005 +``` + +--- + +## 9. Структура файлов + +``` +tests/mock_server/ +├── mock_server.py # Flask mock-сервер +├── run.sh # запуск (macOS/Linux) +├── gen_mock_data.py # генерация default_profile + scenarios из БД +├── gen_fetch_transactions.py # генерация pool из живого устройства +├── inject.sh # curl-обёртка для инжекта +├── inject_random.py # рандомный инжект из pool +├── default_profile.json # авто-генерированный профиль для мока +├── seed_profile.example.json # пример ручного засева +├── README.md # эта инструкция +├── WINDOWS.md # Windows-специфичные нюансы +├── scenarios/ # одиночные задачи (FETCH_PROFILE / FT с пустым body) +│ ├── pixel_7_ozon_fetch_profile.json +│ ├── pixel_7_ozon_fetch_transactions.json +│ ├── tecno_km4_ozon_fetch_profile.json +│ └── ... +├── scenarios/pool/ # пул FETCH_TRANSACTIONS с реальными amount + created_at +│ ├── pixel7_ozon_ft_01_evgeny_plus_100.json +│ ├── tecno_km4_ozon_ft_01_galina_plus_100.json +│ └── ... +└── fixtures/ # XML/PNG дампы экранов (справочно) + ├── tecno_ozon/ + ├── pixel7_ozon/ + └── pixel7_dushanbe/ +``` + +--- + +## 10. Типовые проблемы + +| Симптом | Причина | Решение | +|---|---|---| +| `config.ini not found` | Скрипт запущен не из корня проекта | `cd` в корень или `--config path` | +| `No active materials` | БД пустая или профили не active | Запустить ARC с боевым сервером, залогиниться | +| `No ADB devices match` | Устройства не подключены или serial не совпадает | `adb devices`, проверить USB | +| Задача `pending` навсегда | material в in-flight (результат не пришёл) | `/_admin/reset` и перезапустить ARC | +| `Task expired: waited Ns` | Предыдущая задача выполнялась >5 мин | Мок отдаёт по 1 задаче — это уже фикс; убедиться, что `maxScrolls` разумный | +| `Transaction not found` | Транзакции на устройстве не совпадают с pool | Перегенерировать: `gen_fetch_transactions.py` | +| `Cancelled: replaced` | Норма для FETCH_PROFILE — дедупликация по device+bank | Не баг | +| Мок не получает результат | `sendTaskError` шёл в monitoring вместо task_result | Обновить ARC (фикс в EventHandler.cpp) | +| Кракозябры в консоли (Win) | Кодировка | `chcp 65001` перед запуском | +| Порт занят | Другой процесс на 8888 | `--port 9999` + поменять `apiBase` | + +--- + +## 11. Что мок НЕ эмулирует + +- Валидацию тел запросов (всё `{success: true}`) +- Хранение скриншотов +- Webhook-уведомления банков +- Сетевые задержки/таймауты diff --git a/tests/mock_server/WINDOWS.md b/tests/mock_server/WINDOWS.md new file mode 100644 index 0000000..5b9ac59 --- /dev/null +++ b/tests/mock_server/WINDOWS.md @@ -0,0 +1,166 @@ +# Запуск тестов на Windows + +--- + +## 1. Установка зависимостей + +```powershell +# Python 3.9+ (если нет — скачать с python.org, при установке включить «Add to PATH») +python --version + +# Flask +pip install flask +``` + +--- + +## 2. Подготовка config.ini + +В `config.ini` (рядом с `ARC.exe`) переключить `apiBase`: + +```ini +[net] +apiBase=http://127.0.0.1:8888 +``` + +> Совет: держите отдельную копию `config.ini.mock`, а перед тестом +> `copy config.ini.mock config.ini`. + +--- + +## 3. Запуск mock-сервера + +```powershell +cd tests\mock_server +python mock_server.py --port 8888 +``` + +Должны увидеть: +``` +[mock] state cleared — starting fresh +[mock] loaded default_profile.json: 3 bank_profiles, 3 materials +[mock] loaded pool: 19 FETCH_TRANSACTIONS across 3 materials + * Running on http://127.0.0.1:8888 +``` + +Флаги: +- `--ft-per-material 3` — сколько FETCH_TRANSACTIONS на материал (default 3) +- `--seed 42` — фиксированный RNG для воспроизводимости + +--- + +## 4. Запуск ARC + +В отдельном терминале (cmd/PowerShell): + +```powershell +cd build\Release +ARC.exe +``` + +Или через CLion: Run → ARC. + +При логине мок автоматически поставит задачи (1 FETCH_PROFILE + 3 FETCH_TRANSACTIONS +на каждый материал). Задачи раздаются по 1 на material за поллинг — следующая +только после результата предыдущей. + +--- + +## 5. Мониторинг и отчёт + +```powershell +# Открыть отчёт в браузере (авто-обновление каждые 2 сек) +start http://127.0.0.1:8888/_admin/report/html + +# Счётчики +curl -s http://127.0.0.1:8888/_admin/state + +# Полный JSON-отчёт +curl -s http://127.0.0.1:8888/_admin/report +``` + +Если `curl` нет, используйте PowerShell: + +```powershell +# Счётчики +(Invoke-WebRequest http://127.0.0.1:8888/_admin/state).Content | ConvertFrom-Json + +# Полный отчёт в файл +Invoke-WebRequest http://127.0.0.1:8888/_admin/report -OutFile report.json +``` + +--- + +## 6. Ручной инжект задач + +```powershell +# Одна задача +curl -X POST http://127.0.0.1:8888/_admin/inject ^ + -H "Content-Type: application/json" ^ + --data-binary @scenarios\tecno_ozon_fetch_profile.json + +# 5 случайных из пула +python inject_random.py --count 5 + +# Повторный бутстрап +curl -X POST http://127.0.0.1:8888/_admin/bootstrap + +# Сброс (очистка очереди + результатов) +curl -X POST http://127.0.0.1:8888/_admin/reset +``` + +--- + +## 7. ADB на Windows + +ARC использует ADB из `tools\adb\windows\`. Убедитесь, что: + +```powershell +tools\adb\windows\adb.exe devices +``` + +показывает устройства в статусе `device`. + +Если ADB занят другим процессом (Android Studio, scrcpy), закройте его: +```powershell +tools\adb\windows\adb.exe kill-server +tools\adb\windows\adb.exe start-server +tools\adb\windows\adb.exe devices +``` + +--- + +## 8. Типовые проблемы (Windows-специфичные) + +- **`python` не найден** → использовать `py` или `python3`. Или добавить + Python в PATH. +- **Порт 8888 занят** → `netstat -ano | findstr :8888`, затем + `taskkill /PID /F`. Или запустить мок на другом порту: + `python mock_server.py --port 9999` и поменять `apiBase` в `config.ini`. +- **`curl` нет** → использовать `Invoke-WebRequest` (PowerShell) или + установить curl через `winget install curl`. +- **Firewall блокирует** → разрешить `python.exe` в Windows Firewall + (или отключить на время теста). Мок слушает только `127.0.0.1` — внешний + доступ не нужен. +- **ARC не видит устройства** → проверить USB-драйверы, включить + «USB debugging» на телефоне, подтвердить RSA-ключ. +- **Кодировка в консоли** → `chcp 65001` перед запуском мока (UTF-8), + иначе русские символы в логах могут быть кракозябрами. +- **SSL ошибки при сборке ARC** → убедиться, что OpenSSL DLL скопированы + в директорию сборки (`cmake --build` делает это автоматически через + post-build commands в CMakeLists.txt). + +--- + +## 9. Быстрый чеклист + +``` +[ ] Python 3.9+ установлен, pip install flask +[ ] config.ini: apiBase=http://127.0.0.1:8888 +[ ] Устройства в adb devices → device +[ ] Терминал 1: python mock_server.py --port 8888 +[ ] Терминал 2: ARC.exe +[ ] Браузер: http://127.0.0.1:8888/_admin/report/html +[ ] Дождаться completed = 12 в отчёте +[ ] Вернуть config.ini: apiBase=http://93.183.75.134:8005 +``` diff --git a/tests/mock_server/default_profile.json b/tests/mock_server/default_profile.json new file mode 100644 index 0000000..76c9a8c --- /dev/null +++ b/tests/mock_server/default_profile.json @@ -0,0 +1,64 @@ +{ + "bank_profiles": [ + { + "id": "dd0ba55d-0a02-40ab-8b31-cf6a7529269f", + "bank_name": "dushanbe_city_bank", + "state": "enabled", + "phone_number": "+992185555519", + "first_name": "Мехрубон", + "middle_name": null, + "last_name": "Имомкулзода", + "currency": 972, + "device_id": "986cb259-dfec-4421-8735-4fb126cd1917", + "device_label": "Pixel 7", + "materials": [ + { + "id": "61d967d1-8d3e-4d05-ba01-8c8ab327b945", + "state": "enabled", + "card_number": "9762000207113258", + "currency": 972 + } + ] + }, + { + "id": "86bcdee7-1c64-40e3-bc8f-132d153a9d28", + "bank_name": "ozon", + "state": "enabled", + "phone_number": "+79538038318", + "first_name": "Галина", + "middle_name": null, + "last_name": "Терехина", + "currency": 643, + "device_id": "986cb259-dfec-4421-8735-4fb126cd1917", + "device_label": "Pixel 7", + "materials": [ + { + "id": "30bc106c-6935-4f4b-85f3-f1a06adc5c13", + "state": "enabled", + "card_number": "2204321035207919", + "currency": 643 + } + ] + }, + { + "id": "e8c5f3b2-c710-421c-b304-93353ccfbfd5", + "bank_name": "ozon", + "state": "enabled", + "phone_number": "+79855532896", + "first_name": "Владислав", + "middle_name": null, + "last_name": "Касаткин", + "currency": 643, + "device_id": "ec6397fb-89b1-41e1-9b6b-303d6f1e943f", + "device_label": "TECNO KM4", + "materials": [ + { + "id": "49001bb5-3b24-478d-b913-c2242aea0732", + "state": "enabled", + "card_number": "2204320439130982", + "currency": 643 + } + ] + } + ] +} diff --git a/tests/mock_server/fixtures/pixel7_dushanbe/details/01_minus_213121.png b/tests/mock_server/fixtures/pixel7_dushanbe/details/01_minus_213121.png new file mode 100644 index 0000000..c24558c Binary files /dev/null and b/tests/mock_server/fixtures/pixel7_dushanbe/details/01_minus_213121.png differ diff --git a/tests/mock_server/fixtures/pixel7_dushanbe/details/01_minus_213121.xml b/tests/mock_server/fixtures/pixel7_dushanbe/details/01_minus_213121.xml new file mode 100644 index 0000000..35c91fc --- /dev/null +++ b/tests/mock_server/fixtures/pixel7_dushanbe/details/01_minus_213121.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/mock_server/fixtures/pixel7_dushanbe/details/02_minus_212548.png b/tests/mock_server/fixtures/pixel7_dushanbe/details/02_minus_212548.png new file mode 100644 index 0000000..ec54977 Binary files /dev/null and b/tests/mock_server/fixtures/pixel7_dushanbe/details/02_minus_212548.png differ diff --git a/tests/mock_server/fixtures/pixel7_dushanbe/details/02_minus_212548.xml b/tests/mock_server/fixtures/pixel7_dushanbe/details/02_minus_212548.xml new file mode 100644 index 0000000..5a52c33 --- /dev/null +++ b/tests/mock_server/fixtures/pixel7_dushanbe/details/02_minus_212548.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/mock_server/fixtures/pixel7_dushanbe/details/03_minus_183606.png b/tests/mock_server/fixtures/pixel7_dushanbe/details/03_minus_183606.png new file mode 100644 index 0000000..f2bd2aa Binary files /dev/null and b/tests/mock_server/fixtures/pixel7_dushanbe/details/03_minus_183606.png differ diff --git a/tests/mock_server/fixtures/pixel7_dushanbe/details/03_minus_183606.xml b/tests/mock_server/fixtures/pixel7_dushanbe/details/03_minus_183606.xml new file mode 100644 index 0000000..4f8da23 --- /dev/null +++ b/tests/mock_server/fixtures/pixel7_dushanbe/details/03_minus_183606.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/mock_server/fixtures/pixel7_dushanbe/details/04_minus_181943.png b/tests/mock_server/fixtures/pixel7_dushanbe/details/04_minus_181943.png new file mode 100644 index 0000000..206f89e Binary files /dev/null and b/tests/mock_server/fixtures/pixel7_dushanbe/details/04_minus_181943.png differ diff --git a/tests/mock_server/fixtures/pixel7_dushanbe/details/04_minus_181943.xml b/tests/mock_server/fixtures/pixel7_dushanbe/details/04_minus_181943.xml new file mode 100644 index 0000000..8e77139 --- /dev/null +++ b/tests/mock_server/fixtures/pixel7_dushanbe/details/04_minus_181943.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/mock_server/fixtures/pixel7_dushanbe/details/05_plus_205206.png b/tests/mock_server/fixtures/pixel7_dushanbe/details/05_plus_205206.png new file mode 100644 index 0000000..6123805 Binary files /dev/null and b/tests/mock_server/fixtures/pixel7_dushanbe/details/05_plus_205206.png differ diff --git a/tests/mock_server/fixtures/pixel7_dushanbe/details/05_plus_205206.xml b/tests/mock_server/fixtures/pixel7_dushanbe/details/05_plus_205206.xml new file mode 100644 index 0000000..063d0de --- /dev/null +++ b/tests/mock_server/fixtures/pixel7_dushanbe/details/05_plus_205206.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/mock_server/fixtures/pixel7_dushanbe/transactions_list.png b/tests/mock_server/fixtures/pixel7_dushanbe/transactions_list.png new file mode 100644 index 0000000..bea3e05 Binary files /dev/null and b/tests/mock_server/fixtures/pixel7_dushanbe/transactions_list.png differ diff --git a/tests/mock_server/fixtures/pixel7_dushanbe/transactions_list.xml b/tests/mock_server/fixtures/pixel7_dushanbe/transactions_list.xml new file mode 100644 index 0000000..0fcc5e4 --- /dev/null +++ b/tests/mock_server/fixtures/pixel7_dushanbe/transactions_list.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/mock_server/fixtures/pixel7_ozon/details/01_evgeny_plus_100.png b/tests/mock_server/fixtures/pixel7_ozon/details/01_evgeny_plus_100.png new file mode 100644 index 0000000..939cd3c Binary files /dev/null and b/tests/mock_server/fixtures/pixel7_ozon/details/01_evgeny_plus_100.png differ diff --git a/tests/mock_server/fixtures/pixel7_ozon/details/01_evgeny_plus_100.xml b/tests/mock_server/fixtures/pixel7_ozon/details/01_evgeny_plus_100.xml new file mode 100644 index 0000000..2dd9a78 --- /dev/null +++ b/tests/mock_server/fixtures/pixel7_ozon/details/01_evgeny_plus_100.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/mock_server/fixtures/pixel7_ozon/details/02_vlad_minus_100.png b/tests/mock_server/fixtures/pixel7_ozon/details/02_vlad_minus_100.png new file mode 100644 index 0000000..f1fa5e3 Binary files /dev/null and b/tests/mock_server/fixtures/pixel7_ozon/details/02_vlad_minus_100.png differ diff --git a/tests/mock_server/fixtures/pixel7_ozon/details/02_vlad_minus_100.xml b/tests/mock_server/fixtures/pixel7_ozon/details/02_vlad_minus_100.xml new file mode 100644 index 0000000..9fd926a --- /dev/null +++ b/tests/mock_server/fixtures/pixel7_ozon/details/02_vlad_minus_100.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/mock_server/fixtures/pixel7_ozon/details/03_vlad_minus_300.png b/tests/mock_server/fixtures/pixel7_ozon/details/03_vlad_minus_300.png new file mode 100644 index 0000000..582867a Binary files /dev/null and b/tests/mock_server/fixtures/pixel7_ozon/details/03_vlad_minus_300.png differ diff --git a/tests/mock_server/fixtures/pixel7_ozon/details/03_vlad_minus_300.xml b/tests/mock_server/fixtures/pixel7_ozon/details/03_vlad_minus_300.xml new file mode 100644 index 0000000..8d2dbaa --- /dev/null +++ b/tests/mock_server/fixtures/pixel7_ozon/details/03_vlad_minus_300.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/mock_server/fixtures/pixel7_ozon/details/04_vlad_plus_100.png b/tests/mock_server/fixtures/pixel7_ozon/details/04_vlad_plus_100.png new file mode 100644 index 0000000..8f517e8 Binary files /dev/null and b/tests/mock_server/fixtures/pixel7_ozon/details/04_vlad_plus_100.png differ diff --git a/tests/mock_server/fixtures/pixel7_ozon/details/04_vlad_plus_100.xml b/tests/mock_server/fixtures/pixel7_ozon/details/04_vlad_plus_100.xml new file mode 100644 index 0000000..5ec90f4 --- /dev/null +++ b/tests/mock_server/fixtures/pixel7_ozon/details/04_vlad_plus_100.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/mock_server/fixtures/pixel7_ozon/details/05_vlad_plus_100_2.png b/tests/mock_server/fixtures/pixel7_ozon/details/05_vlad_plus_100_2.png new file mode 100644 index 0000000..b774d68 Binary files /dev/null and b/tests/mock_server/fixtures/pixel7_ozon/details/05_vlad_plus_100_2.png differ diff --git a/tests/mock_server/fixtures/pixel7_ozon/details/05_vlad_plus_100_2.xml b/tests/mock_server/fixtures/pixel7_ozon/details/05_vlad_plus_100_2.xml new file mode 100644 index 0000000..870b219 --- /dev/null +++ b/tests/mock_server/fixtures/pixel7_ozon/details/05_vlad_plus_100_2.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/mock_server/fixtures/pixel7_ozon/details/06_evgeny_plus_100_2.png b/tests/mock_server/fixtures/pixel7_ozon/details/06_evgeny_plus_100_2.png new file mode 100644 index 0000000..6eac52f Binary files /dev/null and b/tests/mock_server/fixtures/pixel7_ozon/details/06_evgeny_plus_100_2.png differ diff --git a/tests/mock_server/fixtures/pixel7_ozon/details/06_evgeny_plus_100_2.xml b/tests/mock_server/fixtures/pixel7_ozon/details/06_evgeny_plus_100_2.xml new file mode 100644 index 0000000..c44c5db --- /dev/null +++ b/tests/mock_server/fixtures/pixel7_ozon/details/06_evgeny_plus_100_2.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/mock_server/fixtures/pixel7_ozon/transactions_list.png b/tests/mock_server/fixtures/pixel7_ozon/transactions_list.png new file mode 100644 index 0000000..4937794 Binary files /dev/null and b/tests/mock_server/fixtures/pixel7_ozon/transactions_list.png differ diff --git a/tests/mock_server/fixtures/pixel7_ozon/transactions_list.xml b/tests/mock_server/fixtures/pixel7_ozon/transactions_list.xml new file mode 100644 index 0000000..bda729e --- /dev/null +++ b/tests/mock_server/fixtures/pixel7_ozon/transactions_list.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/mock_server/fixtures/tecno_ozon/details/01_galina_plus_100.png b/tests/mock_server/fixtures/tecno_ozon/details/01_galina_plus_100.png new file mode 100644 index 0000000..f6d4641 Binary files /dev/null and b/tests/mock_server/fixtures/tecno_ozon/details/01_galina_plus_100.png differ diff --git a/tests/mock_server/fixtures/tecno_ozon/details/01_galina_plus_100.xml b/tests/mock_server/fixtures/tecno_ozon/details/01_galina_plus_100.xml new file mode 100644 index 0000000..8d7269e --- /dev/null +++ b/tests/mock_server/fixtures/tecno_ozon/details/01_galina_plus_100.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/mock_server/fixtures/tecno_ozon/details/02_galina_plus_300.png b/tests/mock_server/fixtures/tecno_ozon/details/02_galina_plus_300.png new file mode 100644 index 0000000..2fa49ab Binary files /dev/null and b/tests/mock_server/fixtures/tecno_ozon/details/02_galina_plus_300.png differ diff --git a/tests/mock_server/fixtures/tecno_ozon/details/02_galina_plus_300.xml b/tests/mock_server/fixtures/tecno_ozon/details/02_galina_plus_300.xml new file mode 100644 index 0000000..311c63b --- /dev/null +++ b/tests/mock_server/fixtures/tecno_ozon/details/02_galina_plus_300.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/mock_server/fixtures/tecno_ozon/details/03_vlad_minus_100.png b/tests/mock_server/fixtures/tecno_ozon/details/03_vlad_minus_100.png new file mode 100644 index 0000000..9f1ec77 Binary files /dev/null and b/tests/mock_server/fixtures/tecno_ozon/details/03_vlad_minus_100.png differ diff --git a/tests/mock_server/fixtures/tecno_ozon/details/03_vlad_minus_100.xml b/tests/mock_server/fixtures/tecno_ozon/details/03_vlad_minus_100.xml new file mode 100644 index 0000000..49be16a --- /dev/null +++ b/tests/mock_server/fixtures/tecno_ozon/details/03_vlad_minus_100.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/mock_server/fixtures/tecno_ozon/details/04_vlad_minus_100_2.png b/tests/mock_server/fixtures/tecno_ozon/details/04_vlad_minus_100_2.png new file mode 100644 index 0000000..01dcfce Binary files /dev/null and b/tests/mock_server/fixtures/tecno_ozon/details/04_vlad_minus_100_2.png differ diff --git a/tests/mock_server/fixtures/tecno_ozon/details/04_vlad_minus_100_2.xml b/tests/mock_server/fixtures/tecno_ozon/details/04_vlad_minus_100_2.xml new file mode 100644 index 0000000..24919f5 --- /dev/null +++ b/tests/mock_server/fixtures/tecno_ozon/details/04_vlad_minus_100_2.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/mock_server/fixtures/tecno_ozon/details/05_galina_minus_100.png b/tests/mock_server/fixtures/tecno_ozon/details/05_galina_minus_100.png new file mode 100644 index 0000000..aca00f4 Binary files /dev/null and b/tests/mock_server/fixtures/tecno_ozon/details/05_galina_minus_100.png differ diff --git a/tests/mock_server/fixtures/tecno_ozon/details/05_galina_minus_100.xml b/tests/mock_server/fixtures/tecno_ozon/details/05_galina_minus_100.xml new file mode 100644 index 0000000..fa803c6 --- /dev/null +++ b/tests/mock_server/fixtures/tecno_ozon/details/05_galina_minus_100.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/mock_server/fixtures/tecno_ozon/details/06_galina_minus_100_2.png b/tests/mock_server/fixtures/tecno_ozon/details/06_galina_minus_100_2.png new file mode 100644 index 0000000..70ed009 Binary files /dev/null and b/tests/mock_server/fixtures/tecno_ozon/details/06_galina_minus_100_2.png differ diff --git a/tests/mock_server/fixtures/tecno_ozon/details/06_galina_minus_100_2.xml b/tests/mock_server/fixtures/tecno_ozon/details/06_galina_minus_100_2.xml new file mode 100644 index 0000000..2b0f93f --- /dev/null +++ b/tests/mock_server/fixtures/tecno_ozon/details/06_galina_minus_100_2.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/mock_server/fixtures/tecno_ozon/details/07_vlad_plus_550.png b/tests/mock_server/fixtures/tecno_ozon/details/07_vlad_plus_550.png new file mode 100644 index 0000000..f940b01 Binary files /dev/null and b/tests/mock_server/fixtures/tecno_ozon/details/07_vlad_plus_550.png differ diff --git a/tests/mock_server/fixtures/tecno_ozon/details/07_vlad_plus_550.xml b/tests/mock_server/fixtures/tecno_ozon/details/07_vlad_plus_550.xml new file mode 100644 index 0000000..69fa14b --- /dev/null +++ b/tests/mock_server/fixtures/tecno_ozon/details/07_vlad_plus_550.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/mock_server/fixtures/tecno_ozon/transactions_list.png b/tests/mock_server/fixtures/tecno_ozon/transactions_list.png new file mode 100644 index 0000000..2df87b4 Binary files /dev/null and b/tests/mock_server/fixtures/tecno_ozon/transactions_list.png differ diff --git a/tests/mock_server/fixtures/tecno_ozon/transactions_list.xml b/tests/mock_server/fixtures/tecno_ozon/transactions_list.xml new file mode 100644 index 0000000..b0a7101 --- /dev/null +++ b/tests/mock_server/fixtures/tecno_ozon/transactions_list.xml @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/tests/mock_server/gen_fetch_transactions.py b/tests/mock_server/gen_fetch_transactions.py new file mode 100755 index 0000000..4bfcc2d --- /dev/null +++ b/tests/mock_server/gen_fetch_transactions.py @@ -0,0 +1,428 @@ +#!/usr/bin/env python3 +""" +Generate FETCH_TRANSACTIONS pool tasks from a live device screen. + +Reads config.ini to find the DB, queries active materials, shows a menu +to pick which device+bank to scan, then dumps the transactions list from +the real device, taps into each detail, and generates pool JSON files. + +Run from project root while the transactions list is open on the device: + + python tests/mock_server/gen_fetch_transactions.py + +Or specify config and serial: + + python tests/mock_server/gen_fetch_transactions.py --config config.ini -s 148697059H000712 + +On Windows: + + python tests\\mock_server\\gen_fetch_transactions.py +""" + +import argparse +import configparser +import json +import os +import re +import sqlite3 +import subprocess +import sys +import time +from datetime import datetime, timedelta, timezone +from pathlib import Path + +HERE = Path(__file__).parent +POOL_DIR = HERE / "scenarios" / "pool" +BOUNDS_RE = re.compile(r"\[(\d+),(\d+)\]\[(\d+),(\d+)\]") + +# ---------------------------------------------------------------- config/db + +def resolve_db_path(config_path: Path) -> Path: + config = configparser.ConfigParser() + config.read(str(config_path), encoding="utf-8") + raw = config.get("db", "dbPath", fallback="") + if not raw: + print(f"ERROR: [db] dbPath not found in {config_path}", file=sys.stderr) + sys.exit(1) + expanded = os.path.expanduser(raw) + p = Path(expanded) + if not p.is_absolute(): + p = config_path.parent / p + return p.resolve() + + +def find_config(hint): + if hint: + p = Path(hint) + if p.exists(): + return p + print(f"ERROR: config not found at {p}", file=sys.stderr) + sys.exit(1) + for c in [Path("config.ini"), Path("build/Release/config.ini"), + Path("build/config.ini"), Path("build/Debug/config.ini")]: + if c.exists(): + return c.resolve() + print("ERROR: config.ini not found. Run from project root or use --config.", file=sys.stderr) + sys.exit(1) + + +def query_active_materials(db_path: Path): + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + rows = conn.execute(""" + SELECT d.id AS device_id, d.api_id, d.name AS device_name, + d.adb_serial, + bp.code AS bank_name, bp.bank_profile_id, bp.full_name, + m.material_id, m.last_numbers, m.card_number + FROM devices d + JOIN bank_profiles bp ON bp.device_id = d.id + JOIN materials m ON m.device_id = d.id AND m.app_code = bp.code + WHERE bp.status = 'active' AND m.status = 'active' + AND m.material_id != '' AND bp.bank_profile_id != '' + ORDER BY d.name, bp.code + """).fetchall() + conn.close() + return [dict(r) for r in rows] + + +# ---------------------------------------------------------------- ADB helpers + +def adb(serial, *args): + cmd = ["adb"] + if serial: + cmd += ["-s", serial] + cmd += list(args) + return subprocess.run(cmd, check=True, capture_output=True) + + +def dump_xml(serial) -> str: + adb(serial, "shell", "uiautomator", "dump", "/sdcard/_gen_ft.xml") + r = adb(serial, "exec-out", "cat", "/sdcard/_gen_ft.xml") + return r.stdout.decode("utf-8", errors="replace") + + +def get_device_tz(serial) -> timezone: + r = adb(serial, "shell", "date", "+%z") + s = r.stdout.decode().strip() + try: + sign = 1 if s[0] == "+" else -1 + return timezone(timedelta(hours=sign * int(s[1:3]), minutes=sign * int(s[3:5]))) + except (ValueError, IndexError): + print(f" warning: can't parse tz '{s}', assuming UTC+7") + return timezone(timedelta(hours=7)) + + +def get_adb_serials(): + r = subprocess.run(["adb", "devices"], check=True, capture_output=True) + serials = [] + for line in r.stdout.decode().splitlines()[1:]: + parts = line.split() + if len(parts) >= 2 and parts[1] == "device": + serials.append(parts[0]) + return serials + + +def tap(serial, x, y): + adb(serial, "shell", "input", "tap", str(x), str(y)) + + +def back(serial): + adb(serial, "shell", "input", "keyevent", "KEYCODE_BACK") + + +# ---------------------------------------------------------------- Ozon + +def parse_ozon_rows(xml): + rows = [] + for m in re.finditer( + r']*?text="([^"]*(?:Пополнени|Переводы)[^"]*)"' + r'[^>]*?bounds="(\[\d+,\d+\]\[\d+,\d+\])"', xml): + text, bounds = m.group(1), m.group(2) + bm = BOUNDS_RE.match(bounds) + if not bm: + continue + x1, y1, x2, y2 = map(int, bm.groups()) + if (y2 - y1) < 50 or "Расход" in text or "Доход" in text: + continue + am = re.search(r"([+\-−])\s*(\d[\d\s\u202f]*)", text) + if not am: + continue + sign = -1 if am.group(1) in ("-", "−") else 1 + amount = sign * int(re.sub(r"[\s\u202f]", "", am.group(2))) + rows.append({"text": text, "cx": (x1+x2)//2, "cy": (y1+y2)//2, "amount": amount}) + rows.sort(key=lambda r: r["cy"]) + return rows + + +def is_ozon_list(xml): + return "Операции" in xml and ("Пополнени" in xml or "Переводы" in xml) + + +def extract_ozon_detail_time(xml): + for m in re.finditer(r'text="([^"]+)"', xml): + dm = re.match(r"(\d{2}\.\d{2}\.\d{4}),?\s+(\d{2}:\d{2})", m.group(1)) + if dm: + return dm.group(1), dm.group(2) + ":00" + return None, None + + +# ---------------------------------------------------------------- Dushanbe + +def parse_dushanbe_rows(xml): + rows = [] + for m in re.finditer( + r']*?content-desc="([^"]*часть P2P/операции[^"]*)"' + r'[^>]*?bounds="(\[(\d+),(\d+)\]\[(\d+),(\d+)\])"', xml): + cd = m.group(1) + x1, y1, x2, y2 = int(m.group(3)), int(m.group(4)), int(m.group(5)), int(m.group(6)) + h = y2 - y1 + if h < 80 or h > 400: + continue + parts = cd.split("\n") if "\n" in cd else cd.split(" ") + amount, time_str = None, None + for p in parts: + p = p.strip() + try: + amount = int(float(p)) + except ValueError: + pass + if re.match(r"^\s*\d{1,2}:\d{2}(:\d{2})?\s*$", p): + time_str = p.strip() + rows.append({"cx": (x1+x2)//2, "cy": (y1+y2)//2, "amount": amount, "time": time_str}) + rows.sort(key=lambda r: r["cy"]) + return rows + + +def is_dushanbe_list(xml): + return "Операции" in xml and "часть P2P" in xml + + +def extract_dushanbe_detail(xml): + m = re.search(r'content-desc="([^"]{30,})"', xml) + if not m: + return None, None, None + parts = [x.strip() for x in m.group(1).split(" ")] + info = {} + for i, x in enumerate(parts): + if x.endswith(":") and i + 1 < len(parts): + info[x] = parts[i + 1] + return info.get("Сумма:"), info.get("Дата:"), info.get("Время:") + + +# ---------------------------------------------------------------- Main + +def short_label(amount, time_str): + sign = "plus" if amount >= 0 else "minus" + t = time_str.replace(":", "") if time_str else "unk" + return f"{sign}_{abs(amount)}_{t}" + + +def collect_tasks(serial, bank, mid, device_tz, kopecks, prefix): + xml = dump_xml(serial) + is_ozon = bank == "ozon" or is_ozon_list(xml) + is_dush = "dushanbe" in bank or is_dushanbe_list(xml) + + if not is_ozon and not is_dush: + print("ERROR: not on a recognized transactions list", file=sys.stderr) + return [] + + tasks = [] + seen = set() + + if is_ozon: + print("Detected: Ozon") + baseline = parse_ozon_rows(xml) + print(f"rows: {len(baseline)}") + for i, r in enumerate(baseline, 1): + print(f" {i}. ({r['cx']},{r['cy']}) {r['amount']:+d} ₽ | {r['text'][:50]}") + + for idx in range(1, len(baseline) + 1): + xml = dump_xml(serial) + if not is_ozon_list(xml): + print(f"[{idx}] left list, stop"); break + rows = parse_ozon_rows(xml) + if idx > len(rows): + break + row = rows[idx - 1] + print(f"\n[{idx}] tap ({row['cx']},{row['cy']}) {row['amount']:+d}") + tap(serial, row["cx"], row["cy"]) + time.sleep(2.5) + d_xml = dump_xml(serial) + date_s, time_s = extract_ozon_detail_time(d_xml) + print(f" {date_s} {time_s}") + back(serial); time.sleep(1.3) + if not date_s or not time_s: + print(" SKIP"); continue + dt = datetime.strptime(f"{date_s} {time_s}", "%d.%m.%Y %H:%M:%S").replace(tzinfo=device_tz) + cat = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + label = short_label(row["amount"], time_s) + while label in seen: label += "_2" + seen.add(label) + tasks.append({"bank_name": bank, "material_id": mid, "type": "FETCH_TRANSACTIONS", + "body": {"amount": row["amount"] * kopecks, "created_at": cat}, + "_tag": f"{prefix}_{idx:02d}_{label}"}) + + elif is_dush: + print("Detected: Dushanbe") + baseline = parse_dushanbe_rows(xml) + print(f"rows: {len(baseline)}") + for i, r in enumerate(baseline, 1): + print(f" {i}. ({r['cx']},{r['cy']}) amt={r['amount']} time={r['time']}") + + for idx in range(1, len(baseline) + 1): + xml = dump_xml(serial) + if not is_dushanbe_list(xml): + print(f"[{idx}] left list, stop"); break + rows = parse_dushanbe_rows(xml) + if idx > len(rows): + break + row = rows[idx - 1] + print(f"\n[{idx}] tap ({row['cx']},{row['cy']}) amt={row['amount']}") + tap(serial, row["cx"], row["cy"]) + time.sleep(2.5) + d_xml = dump_xml(serial) + amt_s, date_s, time_s = extract_dushanbe_detail(d_xml) + print(f" amt={amt_s} {date_s} {time_s}") + back(serial); time.sleep(1.3) + if not date_s or not time_s: + print(" SKIP"); continue + amount = int(float(amt_s)) if amt_s else (row["amount"] or 0) + fmt = "%d.%m.%Y %H:%M:%S" if len(time_s) > 5 else "%d.%m.%Y %H:%M" + dt = datetime.strptime(f"{date_s} {time_s}", fmt).replace(tzinfo=device_tz) + cat = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ") + label = short_label(amount, time_s) + while label in seen: label += "_2" + seen.add(label) + tasks.append({"bank_name": bank, "material_id": mid, "type": "FETCH_TRANSACTIONS", + "body": {"amount": amount * kopecks, "created_at": cat}, + "_tag": f"{prefix}_{idx:02d}_{label}"}) + + return tasks + + +def main(): + parser = argparse.ArgumentParser(description="Generate FETCH_TRANSACTIONS pool from live device") + parser.add_argument("--config", "-c", default=None, help="Path to config.ini") + parser.add_argument("--serial", "-s", default=None, help="ADB serial (prompted if multiple)") + parser.add_argument("--kopecks", type=int, default=100, help="Amount multiplier (default 100)") + parser.add_argument("--mock-host", default="127.0.0.1") + parser.add_argument("--mock-port", type=int, default=8888) + args = parser.parse_args() + + # Find config and DB + for c in ([Path(args.config)] if args.config else + [Path("config.ini"), Path("build/Release/config.ini"), + Path("build/config.ini")]): + if c.exists(): + config_path = c.resolve(); break + else: + print("ERROR: config.ini not found", file=sys.stderr); return 1 + + db_path = resolve_db_path(config_path) + print(f"config: {config_path}") + print(f"db: {db_path}") + + materials = query_active_materials(db_path) + if not materials: + print("No active materials in DB"); return 1 + + # Find connected ADB devices + serials = get_adb_serials() + if not serials: + print("ERROR: no ADB devices connected"); return 1 + + # Match DB devices to connected ADB serials + options = [] + for mat in materials: + adb_serial = mat["adb_serial"] + if adb_serial not in serials: + continue + dev_label = re.sub(r"-[a-f0-9]{10,}$", "", mat["device_name"]) + options.append({**mat, "serial": adb_serial, "label": dev_label}) + + if not options: + print("No active materials match connected ADB devices.") + print(f" DB serials: {[m['adb_serial'] for m in materials]}") + print(f" ADB serials: {serials}") + return 1 + + # Menu + print(f"\nAvailable materials ({len(options)}):") + for i, o in enumerate(options, 1): + print(f" {i}. {o['label']} / {o['bank_name']} / {o['full_name'] or '?'} " + f"(material {o['material_id'][:8]}… serial={o['serial']})") + + if len(options) == 1: + choice = 0 + print(f"\n→ auto-selected: {options[0]['label']} / {options[0]['bank_name']}") + else: + try: + inp = input(f"\nPick [1-{len(options)}] or 'all': ").strip() + except (EOFError, KeyboardInterrupt): + return 0 + if inp.lower() == "all": + choice = -1 + else: + choice = int(inp) - 1 + if choice < 0 or choice >= len(options): + print("Invalid choice"); return 1 + + to_scan = options if choice == -1 else [options[choice]] + all_tasks = [] + + for o in to_scan: + serial = args.serial or o["serial"] + bank = o["bank_name"] + mid = o["material_id"] + prefix = f"{o['label'].lower().replace(' ', '_')}_{bank}" + device_tz = get_device_tz(serial) + print(f"\n{'='*60}") + print(f"Scanning: {o['label']} / {bank} ({mid[:8]}…)") + print(f"Serial: {serial} TZ: UTC{device_tz.utcoffset(None)}") + print(f"{'='*60}") + + input(f"\nOpen {bank} transactions list on {o['label']} and press Enter...") + + tasks = collect_tasks(serial, bank, mid, device_tz, args.kopecks, prefix) + all_tasks.extend(tasks) + + if not all_tasks: + print("\nNo tasks generated"); return 1 + + # Write pool + POOL_DIR.mkdir(parents=True, exist_ok=True) + # Remove old files for scanned prefixes + scanned_prefixes = set() + for o in to_scan: + scanned_prefixes.add(f"{o['label'].lower().replace(' ', '_')}_{o['bank_name']}") + for old in POOL_DIR.glob("*.json"): + for pfx in scanned_prefixes: + if old.name.startswith(pfx + "_ft_"): + old.unlink(); break + + for i, t in enumerate(all_tasks, 1): + tag = t["_tag"] + fname = f"{tag.rsplit('_', 1)[0]}_ft_{i:02d}.json" + (POOL_DIR / fname).write_text(json.dumps(t, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + + print(f"\n{len(all_tasks)} tasks written to {POOL_DIR}/") + for t in all_tasks: + b = t["body"] + print(f" {t['_tag']}: amount={b['amount']:+d} created_at={b['created_at']}") + + # Inject into mock if running + try: + import urllib.request + url = f"http://{args.mock_host}:{args.mock_port}/_admin/inject" + req = urllib.request.Request(url, data=json.dumps(all_tasks).encode(), + headers={"Content-Type": "application/json"}, method="POST") + with urllib.request.urlopen(req, timeout=3) as resp: + print(f"\nInjected {len(all_tasks)} tasks into mock -> {resp.status}") + except Exception as e: + print(f"\nMock not reachable ({e}) — tasks saved to files only") + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/mock_server/gen_mock_data.py b/tests/mock_server/gen_mock_data.py new file mode 100755 index 0000000..614b100 --- /dev/null +++ b/tests/mock_server/gen_mock_data.py @@ -0,0 +1,222 @@ +#!/usr/bin/env python3 +""" +Generate mock test data from the local ARC database. + +Reads config.ini (from project root or --config) to find the DB path, +queries active devices/profiles/materials, and generates: + - default_profile.json (seeded into mock at startup) + - scenarios/*_fetch_profile.json (one FETCH_PROFILE per material) + - Cleans old scenarios that no longer match the DB + +Run from project root: + python tests/mock_server/gen_mock_data.py + +Or specify config explicitly: + python tests\\mock_server\\gen_mock_data.py --config C:\\path\\to\\config.ini + +On Windows: + python tests\mock_server\gen_mock_data.py +""" + +import argparse +import configparser +import json +import os +import re +import sqlite3 +import sys +from pathlib import Path + + +def resolve_db_path(config_path: Path) -> Path: + config = configparser.ConfigParser() + config.read(str(config_path), encoding="utf-8") + raw = config.get("db", "dbPath", fallback="") + if not raw: + print(f"ERROR: [db] dbPath not found in {config_path}", file=sys.stderr) + sys.exit(1) + # Expand ~ and make absolute relative to config dir + expanded = os.path.expanduser(raw) + p = Path(expanded) + if not p.is_absolute(): + p = config_path.parent / p + return p.resolve() + + +def find_config(hint: str | None) -> Path: + if hint: + p = Path(hint) + if p.exists(): + return p + print(f"ERROR: config not found at {p}", file=sys.stderr) + sys.exit(1) + # Try common locations relative to CWD + candidates = [ + Path("config.ini"), + Path("build/Release/config.ini"), + Path("build/config.ini"), + Path("build/Debug/config.ini"), + ] + for c in candidates: + if c.exists(): + return c.resolve() + print("ERROR: config.ini not found. Run from project root or use --config.", file=sys.stderr) + sys.exit(1) + + +def query_active_materials(db_path: Path): + """Returns list of dicts with device/profile/material info.""" + if not db_path.exists(): + print(f"ERROR: database not found at {db_path}", file=sys.stderr) + sys.exit(1) + + conn = sqlite3.connect(str(db_path)) + conn.row_factory = sqlite3.Row + rows = conn.execute(""" + SELECT + d.id AS device_id, + d.api_id AS device_api_id, + d.name AS device_name, + bp.code AS bank_name, + bp.bank_profile_id, + bp.full_name, + bp.phone, + bp.currency AS bp_currency, + m.material_id, + m.last_numbers, + m.card_number, + m.currency AS mat_currency + FROM devices d + JOIN bank_profiles bp ON bp.device_id = d.id + JOIN materials m ON m.device_id = d.id AND m.app_code = bp.code + WHERE bp.status = 'active' + AND m.status = 'active' + AND m.material_id != '' + AND bp.bank_profile_id != '' + ORDER BY d.name, bp.code + """).fetchall() + conn.close() + return [dict(r) for r in rows] + + +CURRENCY_MAP = { + "RUB": 643, "USD": 840, "KRW": 410, "AZN": 944, + "ARS": 32, "TJS": 972, "EUR": 978, "GBP": 826, + "CNY": 156, "JPY": 392, "TRY": 949, +} + + +def device_label(name: str) -> str: + """TECNO KM4-c7369f1959ffec79 → TECNO KM4""" + m = re.match(r"^(.+?)-[a-f0-9]{10,}$", name) + return m.group(1) if m else name + + +def generate(materials: list, out_dir: Path): + out_dir.mkdir(parents=True, exist_ok=True) + scenarios_dir = out_dir / "scenarios" + scenarios_dir.mkdir(exist_ok=True) + + # --- default_profile.json --- + # Group by (device_api_id, bank_name) → bank_profile with materials + bp_map: dict = {} + for row in materials: + key = (row["device_api_id"], row["bank_name"]) + if key not in bp_map: + bp_map[key] = { + "id": row["bank_profile_id"], + "bank_name": row["bank_name"], + "state": "enabled", + "phone_number": row["phone"] or "", + "first_name": (row["full_name"] or "").split()[0] if row["full_name"] else "", + "middle_name": None, + "last_name": " ".join((row["full_name"] or "").split()[1:]) if row["full_name"] else "", + "currency": CURRENCY_MAP.get(row["bp_currency"], 0), + "device_id": row["device_api_id"], + "device_label": device_label(row["device_name"]), + "materials": [], + } + bp_map[key]["materials"].append({ + "id": row["material_id"], + "state": "enabled", + "card_number": row["card_number"] or "", + "currency": CURRENCY_MAP.get(row["mat_currency"], 0), + }) + + profile = {"bank_profiles": list(bp_map.values())} + profile_path = out_dir / "default_profile.json" + profile_path.write_text(json.dumps(profile, indent=2, ensure_ascii=False) + "\n", encoding="utf-8") + print(f" wrote {profile_path.name} ({len(bp_map)} bank_profiles)") + + # --- FETCH_PROFILE scenarios --- + # Remove old fetch_profile scenarios + for old in scenarios_dir.glob("*_fetch_profile.json"): + old.unlink() + + devices_seen = {} + for row in materials: + dev_short = device_label(row["device_name"]).lower().replace(" ", "_") + bank = row["bank_name"] + fname = f"{dev_short}_{bank}_fetch_profile.json" + task = { + "bank_name": bank, + "material_id": row["material_id"], + "type": "FETCH_PROFILE", + "body": {}, + } + (scenarios_dir / fname).write_text( + json.dumps(task, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + print(f" wrote scenarios/{fname}") + + # Also write a fetch_transactions scenario (empty body, for manual use) + ft_fname = f"{dev_short}_{bank}_fetch_transactions.json" + ft_task = { + "bank_name": bank, + "material_id": row["material_id"], + "type": "FETCH_TRANSACTIONS", + "body": {}, + } + (scenarios_dir / ft_fname).write_text( + json.dumps(ft_task, indent=2, ensure_ascii=False) + "\n", encoding="utf-8" + ) + + devices_seen.setdefault(dev_short, []).append( + f"{bank} ({row['material_id'][:8]}…)" + ) + + # Summary + print(f"\nDevices ({len(devices_seen)}):") + for dev, banks in devices_seen.items(): + print(f" {dev}: {', '.join(banks)}") + print(f"\nTotal: {len(materials)} active materials") + print(f"Files in: {out_dir.resolve()}") + + +def main(): + parser = argparse.ArgumentParser( + description="Generate mock test data from local ARC database" + ) + parser.add_argument("--config", "-c", default=None, + help="Path to config.ini (default: auto-detect from CWD)") + args = parser.parse_args() + + config_path = find_config(args.config) + print(f"config: {config_path}") + + db_path = resolve_db_path(config_path) + print(f"database: {db_path}") + + materials = query_active_materials(db_path) + if not materials: + print("No active materials found in database.", file=sys.stderr) + return 1 + + # Output dir = tests/mock_server (same dir as this script) + out_dir = Path(__file__).parent + generate(materials, out_dir) + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/mock_server/inject.sh b/tests/mock_server/inject.sh new file mode 100755 index 0000000..92a1a22 --- /dev/null +++ b/tests/mock_server/inject.sh @@ -0,0 +1,8 @@ +#!/usr/bin/env bash +# Usage: ./inject.sh scenarios/fetch_profile_ozon.json +set -euo pipefail +FILE="${1:?usage: inject.sh }" +curl -fsS http://127.0.0.1:8888/_admin/inject \ + -H 'Content-Type: application/json' \ + --data-binary @"$FILE" +echo diff --git a/tests/mock_server/inject_random.py b/tests/mock_server/inject_random.py new file mode 100755 index 0000000..9ce72fb --- /dev/null +++ b/tests/mock_server/inject_random.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +""" +Pick N random scenario JSONs from a pool and inject them into the mock server. + +Usage: + ./inject_random.py # 5 random from scenarios/pool/ + ./inject_random.py --count 3 + ./inject_random.py --dir scenarios/pool --count 5 --host 127.0.0.1 --port 8888 + +Each injected task is tagged with a sequential `_tag` field (if present) so +you can match the `postTaskResult` responses back to which pool entry ran. +""" +import argparse +import json +import random +import sys +import urllib.request +from pathlib import Path + + +def main() -> int: + here = Path(__file__).parent + parser = argparse.ArgumentParser() + parser.add_argument("--dir", default=str(here / "scenarios" / "pool"), + help="Directory with scenario JSON files") + parser.add_argument("--count", type=int, default=5, + help="How many scenarios to pick randomly") + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8888) + parser.add_argument("--seed", type=int, default=None, + help="Optional RNG seed for reproducibility") + args = parser.parse_args() + + pool_dir = Path(args.dir) + files = sorted(pool_dir.glob("*.json")) + if not files: + print(f"no scenarios in {pool_dir}", file=sys.stderr) + return 1 + if args.count > len(files): + print(f"asked for {args.count} but pool has only {len(files)}", + file=sys.stderr) + return 1 + + if args.seed is not None: + random.seed(args.seed) + picked = random.sample(files, args.count) + + tasks = [] + for p in picked: + with p.open() as f: + tasks.append(json.load(f)) + print(f" picked: {p.name}") + + url = f"http://{args.host}:{args.port}/_admin/inject" + req = urllib.request.Request( + url, + data=json.dumps(tasks).encode(), + headers={"Content-Type": "application/json"}, + method="POST", + ) + with urllib.request.urlopen(req, timeout=5) as resp: + print(f"injected {len(tasks)} tasks -> {resp.status} {resp.read().decode()}") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/tests/mock_server/mock_server.py b/tests/mock_server/mock_server.py new file mode 100644 index 0000000..31db30f --- /dev/null +++ b/tests/mock_server/mock_server.py @@ -0,0 +1,800 @@ +#!/usr/bin/env python3 +""" +Mock ARC backend for task-delivery integration tests. + +Stubs all endpoints NetworkService hits so a real ARC desktop app can connect, +run real devices, and process tasks that you inject from the outside. + +At startup loads: + - default_profile.json → served as /current_profile (seed bank profiles + with their materials so ARC's sync links them + to real local device/material rows) + - scenarios/pool/*.json → pool of FETCH_TRANSACTIONS tasks, grouped + by material_id for auto-bootstrap + +On the FIRST /auth/login of a run the mock bootstraps the task queue: + - 1 × FETCH_PROFILE per material seen in default_profile.json + - N × FETCH_TRANSACTIONS per material, picked randomly from the pool + (controlled by --ft-per-material, default 3) + +Every injected task gets a unique `_task_id`. Results posted via +POST /task/task_result are paired (FIFO per material+type) to the matching +injected task. GET /_admin/report returns the full list of pairs so you can +see exactly which task JSON went out and what the app returned. + +Usage: + pip install flask + python3 mock_server.py --port 8888 + + # Point ARC at it: in config.ini set + # [net] + # apiBase=http://127.0.0.1:8888 + + # Start ARC → it logs in, mock auto-queues bootstrap tasks, ARC processes + # them on real devices and posts results back. + + # Inspect the full report of task ↔ result pairs: + curl -s http://127.0.0.1:8888/_admin/report | jq +""" + +import argparse +import copy +import json +import random +import threading +import time +import uuid +from collections import defaultdict, deque +from pathlib import Path + +from flask import Flask, Response, jsonify, request + +app = Flask(__name__) +app.config["JSON_SORT_KEYS"] = False + +HERE = Path(__file__).parent + +_lock = threading.Lock() + +_task_queue: deque = deque() +# _injected: list of {id, task, injected_at, result, result_at, status} +# status: "pending" | "completed" +_injected: list = [] +# Per (material_id, type) → FIFO of _injected indices still waiting for a result +_pending_by_key: dict = defaultdict(deque) + +_profile_id = "11111111-1111-1111-1111-111111111111" +_desktop_id = "22222222-2222-2222-2222-222222222222" + +_bank_profiles: list = [] # seeded from default_profile.json +_devices_by_desktop: dict = {} +_pool_by_material: dict = defaultdict(list) +_device_label_by_material: dict = {} # material_id -> "TECNO KM4" etc. +_in_flight: set = set() # material_ids with a delivered-but-not-yet-resulted task +_bootstrap_done = False +_bootstrap_ft_count = 3 + + +def ok(data=None): + return jsonify({"success": True, "data": data if data is not None else {}}) + + +def log(tag, obj): + text = json.dumps(obj, ensure_ascii=False) + if len(text) > 400: + text = text[:400] + "…" + print(f"[{time.strftime('%H:%M:%S')}] {tag}: {text}") + + +# ---------------------------------------------------------------- startup load +def load_default_profile(): + global _bank_profiles + p = HERE / "default_profile.json" + if not p.exists(): + print(f"[mock] no {p.name} found — profile starts empty") + return + data = json.loads(p.read_text()) + _bank_profiles = data.get("bank_profiles", []) + mats = sum(len(bp.get("materials", [])) for bp in _bank_profiles) + + # Build material_id → device_label map for report grouping + _device_label_by_material.clear() + for bp in _bank_profiles: + label = bp.get("device_label") or (bp.get("device_id", "")[:8] + "…") + for mat in bp.get("materials", []): + mid = mat.get("id") + if mid: + _device_label_by_material[mid] = label + + print(f"[mock] loaded {p.name}: {len(_bank_profiles)} bank_profiles, {mats} materials") + + +def load_pool(): + pool_dir = HERE / "scenarios" / "pool" + if not pool_dir.exists(): + print(f"[mock] pool dir {pool_dir} missing — bootstrap disabled") + return + count = 0 + for f in sorted(pool_dir.glob("*.json")): + try: + task = json.loads(f.read_text()) + except Exception as e: + print(f"[mock] skipping {f.name}: {e}") + continue + if task.get("type") != "FETCH_TRANSACTIONS": + continue + mid = task.get("material_id") + if not mid: + continue + task["_source"] = f.name + _pool_by_material[mid].append(task) + count += 1 + print(f"[mock] loaded pool: {count} FETCH_TRANSACTIONS across " + f"{len(_pool_by_material)} materials") + for mid, lst in _pool_by_material.items(): + print(f" {mid}: {len(lst)} tasks") + + +def enqueue_task(task: dict) -> dict: + """Attach _task_id, store in queue, track in _injected.""" + task = copy.deepcopy(task) + tid = str(uuid.uuid4()) + task["_task_id"] = tid + entry = { + "id": tid, + "task": task, + "injected_at": time.time(), + "result": None, + "result_at": None, + "status": "pending", + } + _injected.append(entry) + idx = len(_injected) - 1 + key = (task.get("material_id"), task.get("type")) + _pending_by_key[key].append(idx) + _task_queue.append(task) + return entry + + +def bootstrap_tasks(): + """Queue 1 FETCH_PROFILE + N FETCH_TRANSACTIONS per seeded material.""" + global _bootstrap_done + if _bootstrap_done: + return + queued = 0 + for bp in _bank_profiles: + bank = bp.get("bank_name") + for mat in bp.get("materials", []): + mid = mat.get("id") + if not mid: + continue + # 1 × FETCH_PROFILE + enqueue_task({ + "bank_name": bank, + "material_id": mid, + "type": "FETCH_PROFILE", + "body": {}, + "_source": "bootstrap", + }) + queued += 1 + + # N × random FETCH_TRANSACTIONS from pool + pool = _pool_by_material.get(mid, []) + if not pool: + print(f"[mock] bootstrap: no pool tasks for {mid} ({bank})") + continue + pick = random.sample(pool, min(_bootstrap_ft_count, len(pool))) + for t in pick: + enqueue_task(t) + queued += 1 + _bootstrap_done = True + print(f"[mock] bootstrap queued {queued} tasks") + + +# ---------------------------------------------------------------- auth +@app.post("/api/v1/auth/login") +def auth_login(): + body = request.get_json(silent=True) or {} + log("POST /auth/login", body) + with _lock: + bootstrap_tasks() + return ok({ + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_in": 3600, + }) + + +@app.post("/api/v1/auth/refresh_token") +def auth_refresh(): + return ok({ + "access_token": "mock-access-token", + "refresh_token": "mock-refresh-token", + "expires_in": 3600, + }) + + +# ---------------------------------------------------------------- desktop +@app.get("/api/v1/desktop/") +def desktop_list(): + return ok([{ + "id": _desktop_id, + "profile_id": _profile_id, + "hidden": False, + "name": "mock-desktop", + }]) + + +@app.post("/api/v1/desktop/") +def desktop_create(): + body = request.get_json(silent=True) or {} + log("POST /desktop/", body) + return ok({"id": _desktop_id, "profile_id": _profile_id}) + + +# ---------------------------------------------------------------- device +@app.get("/api/v1/device/") +def device_list(desktop_id): + return ok(_devices_by_desktop.get(desktop_id, [])) + + +@app.post("/api/v1/device/") +def device_create(): + body = request.get_json(silent=True) or {} + log("POST /device/", body) + api_id = str(uuid.uuid4()) + dev = { + "id": api_id, + "name": body.get("name", "unknown"), + "status": body.get("status", "OFFLINE"), + "screen_width": body.get("screen_width", 0), + "screen_height": body.get("screen_height", 0), + "battery": body.get("battery", 0), + } + _devices_by_desktop.setdefault(_desktop_id, []).append(dev) + return ok(dev) + + +@app.patch("/api/v1/device/") +def device_patch(api_id): + return ok({"id": api_id}) + + +@app.delete("/api/v1/device/") +def device_delete(api_id): + return ok({"id": api_id}) + + +@app.post("/api/v1/device/add_screenshot/") +def device_screenshot(api_id): + return ok({"id": api_id}) + + +# ---------------------------------------------------------------- profile +@app.get("/api/v1/profile/current_profile") +def current_profile(): + return ok({ + "id": _profile_id, + "bank_profiles": _bank_profiles, + }) + + +@app.post("/api/v1/profile/bank_profile") +def bank_profile_create(): + body = request.get_json(silent=True) or {} + bp = dict(body) + bp["id"] = bp.get("id") or str(uuid.uuid4()) + bp.setdefault("materials", []) + _bank_profiles.append(bp) + return ok(bp) + + +@app.patch("/api/v1/profile/bank_profile//") +def bank_profile_toggle(bp_id, state): + return ok({"id": bp_id, "state": state}) + + +@app.post("/api/v1/profile/material") +def material_create(): + body = request.get_json(silent=True) or {} + mat = dict(body) + mat["id"] = mat.get("id") or str(uuid.uuid4()) + return ok(mat) + + +@app.patch("/api/v1/profile/material//") +def material_toggle(mat_id, state): + return ok({"id": mat_id, "state": state}) + + +# ---------------------------------------------------------------- tasks +@app.get("/api/v1/task/get_tasks") +def get_tasks(): + """Deliver at most 1 task per material per poll. + + A material is blocked until the previous task's result arrives via + POST /task/task_result. This prevents the 5-min expire window in + EventHandler from killing tasks that were just waiting in queue. + """ + material_ids = request.args.getlist("materials_id") + with _lock: + if not _task_queue: + return ok([]) + delivered = [] + delivered_mats = set() + kept = deque() + while _task_queue: + t = _task_queue.popleft() + mid = t.get("material_id") + # Filter by requested materials + if material_ids and mid not in material_ids: + kept.append(t) + continue + # Skip if this material already has an in-flight task + if mid in _in_flight or mid in delivered_mats: + kept.append(t) + continue + delivered.append(t) + delivered_mats.add(mid) + _in_flight.add(mid) + _task_queue.extend(kept) + if delivered: + log("GET /task/get_tasks -> delivered", + [{"type": t.get("type"), "material_id": t.get("material_id")} + for t in delivered]) + return ok(delivered) + + +@app.post("/api/v1/task/task_result") +def task_result(): + body = request.get_json(silent=True) or {} + mid = body.get("material_id") + log("POST /task/task_result", + {"type": body.get("type"), "material_id": mid}) + + with _lock: + # Unblock this material for the next task delivery + _in_flight.discard(mid) + + key = (mid, body.get("type")) + idx = None + if _pending_by_key[key]: + idx = _pending_by_key[key].popleft() + if idx is not None: + _injected[idx]["result"] = body + _injected[idx]["result_at"] = time.time() + _injected[idx]["status"] = "completed" + else: + _injected.append({ + "id": str(uuid.uuid4()), + "task": None, + "injected_at": None, + "result": body, + "result_at": time.time(), + "status": "unsolicited", + }) + return ok({}) + + +# ---------------------------------------------------------------- monitoring +@app.post("/monitoring/log") +def monitoring_log(): + return ok({}) + + +# ---------------------------------------------------------------- admin +@app.post("/_admin/inject") +def admin_inject(): + body = request.get_json(silent=True) + if body is None: + return jsonify({"error": "json body required"}), 400 + tasks = body if isinstance(body, list) else [body] + with _lock: + for t in tasks: + enqueue_task(t) + log("_admin/inject", {"queued": len(tasks)}) + return ok({"queued": len(tasks)}) + + +@app.post("/_admin/seed_profile") +def admin_seed_profile(): + body = request.get_json(silent=True) or {} + global _bank_profiles + _bank_profiles = body.get("bank_profiles", []) + return ok({"bank_profiles": len(_bank_profiles)}) + + +@app.post("/_admin/seed_devices") +def admin_seed_devices(): + body = request.get_json(silent=True) or [] + _devices_by_desktop[_desktop_id] = body + return ok({"devices": len(body)}) + + +@app.post("/_admin/bootstrap") +def admin_bootstrap(): + """Re-run bootstrap manually (useful if ARC was already logged in).""" + global _bootstrap_done + with _lock: + _bootstrap_done = False + bootstrap_tasks() + return ok({"queued": sum(1 for e in _injected if e["status"] == "pending")}) + + +@app.get("/_admin/report") +def admin_report(): + """Full task ↔ result pairing report.""" + with _lock: + summary = { + "total": len(_injected), + "pending": sum(1 for e in _injected if e["status"] == "pending"), + "completed": sum(1 for e in _injected if e["status"] == "completed"), + "unsolicited": sum(1 for e in _injected if e["status"] == "unsolicited"), + } + return jsonify({ + "summary": summary, + "entries": _injected, + }) + + +@app.get("/_admin/report/pending") +def admin_report_pending(): + with _lock: + return jsonify([e for e in _injected if e["status"] == "pending"]) + + +def _snapshot_entries_and_summary(): + entries = list(_injected) + summary = { + "total": len(entries), + "pending": sum(1 for e in entries if e["status"] == "pending"), + "completed": sum(1 for e in entries if e["status"] == "completed"), + "unsolicited": sum(1 for e in entries if e["status"] == "unsolicited"), + } + return entries, summary + + +@app.get("/_admin/report/html") +def admin_report_html(): + """Pretty HTML view of task ↔ result pairs. Auto-refreshes via JS.""" + with _lock: + entries, summary = _snapshot_entries_and_summary() + return Response(render_report_html(entries, summary), mimetype="text/html") + + +@app.get("/_admin/report/fragment") +def admin_report_fragment(): + """JSON fragment for JS auto-refresh: rendered body HTML + summary counts.""" + with _lock: + entries, summary = _snapshot_entries_and_summary() + # Render just the grouped body (without the full page shell) + from collections import defaultdict as _dd + import html as _html + groups = _dd(list) + for e in entries: + mid = (e.get("task") or {}).get("material_id") + label = _device_label_by_material.get(mid) or ( + "(без устройства)" if mid is None else f"{mid[:8]}…" + ) + groups[label].append(e) + + blocks = [] + for label in sorted(groups.keys()): + gentries = groups[label] + g_done = sum(1 for e in gentries if e["status"] == "completed") + g_pend = sum(1 for e in gentries if e["status"] == "pending") + rows_html = "\n".join(render_entry_html(e) for e in gentries) + blocks.append( + f'
' + f'

{_html.escape(label)}' + f'' + f'{g_done}' + f' / {len(gentries)}' + f' (ожидают: {g_pend})' + f'

{rows_html}
' + ) + body = "\n".join(blocks) or '
Отчёт пуст
' + return jsonify({"html": body, "summary": summary}) + + +def _fmt_ts(ts): + if not ts: + return "—" + return time.strftime("%H:%M:%S", time.localtime(ts)) + + +def _esc(s: str) -> str: + return (s.replace("&", "&").replace("<", "<") + .replace(">", ">").replace('"', """)) + + +def render_entry_html(e) -> str: + import html as _html + status = e["status"] + task = e.get("task") or {} + result = e.get("result") + ttype = _html.escape(task.get("type", "?")) + mid = _html.escape((task.get("material_id") or "")[:8]) + src = _html.escape(task.get("_source", "")) + bank = _html.escape(task.get("bank_name", "")) + injected_at = _fmt_ts(e.get("injected_at")) + result_at = _fmt_ts(e.get("result_at")) + + def json_block(obj): + if obj is None: + return '' + text = json.dumps(obj, ensure_ascii=False, indent=2) + return f'
{_html.escape(text)}
' + + result_summary = "" + if result and isinstance(result.get("data"), dict): + d = result["data"] + if d.get("status") == "error": + result_summary = f'
ERROR: {_html.escape(str(d.get("description", "")))}
' + elif result and isinstance(result.get("data"), list): + result_summary = f'
{len(result["data"])} items
' + + return f""" +
+
+ {status} + {ttype} + {bank} + {mid}… + {src} + inj {injected_at} → res {result_at} +
+
+
+
Task JSON
+ {json_block(task)} +
+
+
Result JSON
+ {result_summary} + {json_block(result)} +
+
+
+ """ + + +def render_report_html(entries, summary) -> str: + import html as _html + + # Group by device label + groups: dict = defaultdict(list) + for e in entries: + mid = (e.get("task") or {}).get("material_id") + label = _device_label_by_material.get(mid) or ( + "(без устройства)" if mid is None else f"{mid[:8]}…" + ) + groups[label].append(e) + + group_blocks = [] + for label in sorted(groups.keys()): + gentries = groups[label] + g_total = len(gentries) + g_done = sum(1 for e in gentries if e["status"] == "completed") + g_pend = sum(1 for e in gentries if e["status"] == "pending") + rows_html = "\n".join(render_entry_html(e) for e in gentries) + group_blocks.append(f""" +
+

+ {_html.escape(label)} + + {g_done} + / {g_total} + (ожидают: {g_pend}) + +

+ {rows_html} +
+ """) + + body_rows = "\n".join(group_blocks) or '
Отчёт пуст — бутстрап ещё не сработал или результаты ещё не пришли.
' + + return f""" + + + +ARC mock — отчёт + + + +

ARC mock — отчёт (автообновление 2 сек)

+
+
{summary['total']}
всего
+
{summary['completed']}
выполнено
+
{summary['pending']}
в очереди
+
{summary['unsolicited']}
незапрошенные
+
+
+ {body_rows} +
+ + + +""" + + +@app.post("/_admin/reset") +def admin_reset(): + global _bootstrap_done + with _lock: + _task_queue.clear() + _injected.clear() + _pending_by_key.clear() + _in_flight.clear() + _bootstrap_done = False + return ok({}) + + +@app.get("/_admin/state") +def admin_state(): + with _lock: + return jsonify({ + "queued_tasks": len(_task_queue), + "injected": len(_injected), + "completed": sum(1 for e in _injected if e["status"] == "completed"), + "pending": sum(1 for e in _injected if e["status"] == "pending"), + "bank_profiles": len(_bank_profiles), + "pool_materials": len(_pool_by_material), + "bootstrap_done": _bootstrap_done, + }) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser() + parser.add_argument("--host", default="127.0.0.1") + parser.add_argument("--port", type=int, default=8888) + parser.add_argument("--ft-per-material", type=int, default=3, + help="How many random FETCH_TRANSACTIONS to bootstrap per material") + parser.add_argument("--seed", type=int, default=None, + help="RNG seed for reproducible bootstrap picks") + args = parser.parse_args() + + _bootstrap_ft_count = args.ft_per_material + if args.seed is not None: + random.seed(args.seed) + + # Fresh start: drop any leftover state from a previous run in this process. + _task_queue.clear() + _injected.clear() + _pending_by_key.clear() + _in_flight.clear() + _bootstrap_done = False + print("[mock] state cleared — starting fresh") + + load_default_profile() + load_pool() + + app.run(host=args.host, port=args.port, threaded=True) diff --git a/tests/mock_server/run.sh b/tests/mock_server/run.sh new file mode 100755 index 0000000..ee62597 --- /dev/null +++ b/tests/mock_server/run.sh @@ -0,0 +1,5 @@ +#!/usr/bin/env bash +# Start the mock ARC backend. Requires `flask` in the active Python env. +set -euo pipefail +cd "$(dirname "$0")" +exec python3 mock_server.py --host 127.0.0.1 --port 8888 "$@" diff --git a/tests/mock_server/scenarios/pixel7_dushanbe_fetch_transactions.json b/tests/mock_server/scenarios/pixel7_dushanbe_fetch_transactions.json new file mode 100644 index 0000000..1fc8e2b --- /dev/null +++ b/tests/mock_server/scenarios/pixel7_dushanbe_fetch_transactions.json @@ -0,0 +1,6 @@ +{ + "bank_name": "dushanbe_city_bank", + "material_id": "61d967d1-8d3e-4d05-ba01-8c8ab327b945", + "type": "FETCH_TRANSACTIONS", + "body": {} +} diff --git a/tests/mock_server/scenarios/pixel7_ozon_fetch_transactions.json b/tests/mock_server/scenarios/pixel7_ozon_fetch_transactions.json new file mode 100644 index 0000000..b0e1c9f --- /dev/null +++ b/tests/mock_server/scenarios/pixel7_ozon_fetch_transactions.json @@ -0,0 +1,6 @@ +{ + "bank_name": "ozon", + "material_id": "30bc106c-6935-4f4b-85f3-f1a06adc5c13", + "type": "FETCH_TRANSACTIONS", + "body": {} +} diff --git a/tests/mock_server/scenarios/pixel_7_dushanbe_city_bank_fetch_profile.json b/tests/mock_server/scenarios/pixel_7_dushanbe_city_bank_fetch_profile.json new file mode 100644 index 0000000..8c93290 --- /dev/null +++ b/tests/mock_server/scenarios/pixel_7_dushanbe_city_bank_fetch_profile.json @@ -0,0 +1,6 @@ +{ + "bank_name": "dushanbe_city_bank", + "material_id": "61d967d1-8d3e-4d05-ba01-8c8ab327b945", + "type": "FETCH_PROFILE", + "body": {} +} diff --git a/tests/mock_server/scenarios/pixel_7_dushanbe_city_bank_fetch_transactions.json b/tests/mock_server/scenarios/pixel_7_dushanbe_city_bank_fetch_transactions.json new file mode 100644 index 0000000..1fc8e2b --- /dev/null +++ b/tests/mock_server/scenarios/pixel_7_dushanbe_city_bank_fetch_transactions.json @@ -0,0 +1,6 @@ +{ + "bank_name": "dushanbe_city_bank", + "material_id": "61d967d1-8d3e-4d05-ba01-8c8ab327b945", + "type": "FETCH_TRANSACTIONS", + "body": {} +} diff --git a/tests/mock_server/scenarios/pixel_7_ozon_fetch_profile.json b/tests/mock_server/scenarios/pixel_7_ozon_fetch_profile.json new file mode 100644 index 0000000..6d2cca9 --- /dev/null +++ b/tests/mock_server/scenarios/pixel_7_ozon_fetch_profile.json @@ -0,0 +1,6 @@ +{ + "bank_name": "ozon", + "material_id": "30bc106c-6935-4f4b-85f3-f1a06adc5c13", + "type": "FETCH_PROFILE", + "body": {} +} diff --git a/tests/mock_server/scenarios/pixel_7_ozon_fetch_transactions.json b/tests/mock_server/scenarios/pixel_7_ozon_fetch_transactions.json new file mode 100644 index 0000000..b0e1c9f --- /dev/null +++ b/tests/mock_server/scenarios/pixel_7_ozon_fetch_transactions.json @@ -0,0 +1,6 @@ +{ + "bank_name": "ozon", + "material_id": "30bc106c-6935-4f4b-85f3-f1a06adc5c13", + "type": "FETCH_TRANSACTIONS", + "body": {} +} diff --git a/tests/mock_server/scenarios/pool/pixel7_dushanbe_ft_01_embedded_minus_1.json b/tests/mock_server/scenarios/pool/pixel7_dushanbe_ft_01_embedded_minus_1.json new file mode 100644 index 0000000..0566385 --- /dev/null +++ b/tests/mock_server/scenarios/pool/pixel7_dushanbe_ft_01_embedded_minus_1.json @@ -0,0 +1,10 @@ +{ + "bank_name": "dushanbe_city_bank", + "material_id": "61d967d1-8d3e-4d05-ba01-8c8ab327b945", + "type": "FETCH_TRANSACTIONS", + "body": { + "amount": -100, + "created_at": "2026-04-10T09:39:15Z" + }, + "_tag": "pixel7_dushanbe_01_embedded_minus_1" +} diff --git a/tests/mock_server/scenarios/pool/pixel7_dushanbe_ft_02_minus_1_2131.json b/tests/mock_server/scenarios/pool/pixel7_dushanbe_ft_02_minus_1_2131.json new file mode 100644 index 0000000..26e54d0 --- /dev/null +++ b/tests/mock_server/scenarios/pool/pixel7_dushanbe_ft_02_minus_1_2131.json @@ -0,0 +1,10 @@ +{ + "bank_name": "dushanbe_city_bank", + "material_id": "61d967d1-8d3e-4d05-ba01-8c8ab327b945", + "type": "FETCH_TRANSACTIONS", + "body": { + "amount": -100, + "created_at": "2026-04-05T14:31:21Z" + }, + "_tag": "pixel7_dushanbe_02_minus_1_2131" +} diff --git a/tests/mock_server/scenarios/pool/pixel7_dushanbe_ft_03_minus_1_2125.json b/tests/mock_server/scenarios/pool/pixel7_dushanbe_ft_03_minus_1_2125.json new file mode 100644 index 0000000..f74ba7e --- /dev/null +++ b/tests/mock_server/scenarios/pool/pixel7_dushanbe_ft_03_minus_1_2125.json @@ -0,0 +1,10 @@ +{ + "bank_name": "dushanbe_city_bank", + "material_id": "61d967d1-8d3e-4d05-ba01-8c8ab327b945", + "type": "FETCH_TRANSACTIONS", + "body": { + "amount": -100, + "created_at": "2026-04-05T14:25:48Z" + }, + "_tag": "pixel7_dushanbe_03_minus_1_2125" +} diff --git a/tests/mock_server/scenarios/pool/pixel7_dushanbe_ft_04_minus_1_1836.json b/tests/mock_server/scenarios/pool/pixel7_dushanbe_ft_04_minus_1_1836.json new file mode 100644 index 0000000..112d97b --- /dev/null +++ b/tests/mock_server/scenarios/pool/pixel7_dushanbe_ft_04_minus_1_1836.json @@ -0,0 +1,10 @@ +{ + "bank_name": "dushanbe_city_bank", + "material_id": "61d967d1-8d3e-4d05-ba01-8c8ab327b945", + "type": "FETCH_TRANSACTIONS", + "body": { + "amount": -100, + "created_at": "2026-04-05T11:36:06Z" + }, + "_tag": "pixel7_dushanbe_04_minus_1_1836" +} diff --git a/tests/mock_server/scenarios/pool/pixel7_dushanbe_ft_05_minus_1_1819.json b/tests/mock_server/scenarios/pool/pixel7_dushanbe_ft_05_minus_1_1819.json new file mode 100644 index 0000000..7ec7da9 --- /dev/null +++ b/tests/mock_server/scenarios/pool/pixel7_dushanbe_ft_05_minus_1_1819.json @@ -0,0 +1,10 @@ +{ + "bank_name": "dushanbe_city_bank", + "material_id": "61d967d1-8d3e-4d05-ba01-8c8ab327b945", + "type": "FETCH_TRANSACTIONS", + "body": { + "amount": -100, + "created_at": "2026-04-05T11:19:43Z" + }, + "_tag": "pixel7_dushanbe_05_minus_1_1819" +} diff --git a/tests/mock_server/scenarios/pool/pixel7_dushanbe_ft_06_plus_1_2052.json b/tests/mock_server/scenarios/pool/pixel7_dushanbe_ft_06_plus_1_2052.json new file mode 100644 index 0000000..62a1bdf --- /dev/null +++ b/tests/mock_server/scenarios/pool/pixel7_dushanbe_ft_06_plus_1_2052.json @@ -0,0 +1,10 @@ +{ + "bank_name": "dushanbe_city_bank", + "material_id": "61d967d1-8d3e-4d05-ba01-8c8ab327b945", + "type": "FETCH_TRANSACTIONS", + "body": { + "amount": 100, + "created_at": "2026-04-03T13:52:06Z" + }, + "_tag": "pixel7_dushanbe_06_plus_1_2052" +} diff --git a/tests/mock_server/scenarios/pool/pixel7_ozon_ft_01_evgeny_plus_100.json b/tests/mock_server/scenarios/pool/pixel7_ozon_ft_01_evgeny_plus_100.json new file mode 100644 index 0000000..8391848 --- /dev/null +++ b/tests/mock_server/scenarios/pool/pixel7_ozon_ft_01_evgeny_plus_100.json @@ -0,0 +1,10 @@ +{ + "bank_name": "ozon", + "material_id": "30bc106c-6935-4f4b-85f3-f1a06adc5c13", + "type": "FETCH_TRANSACTIONS", + "body": { + "amount": 10000, + "created_at": "2026-04-11T10:39:00Z" + }, + "_tag": "pixel7_ozon_01_evgeny_plus_100" +} diff --git a/tests/mock_server/scenarios/pool/pixel7_ozon_ft_02_vlad_minus_100.json b/tests/mock_server/scenarios/pool/pixel7_ozon_ft_02_vlad_minus_100.json new file mode 100644 index 0000000..40612bb --- /dev/null +++ b/tests/mock_server/scenarios/pool/pixel7_ozon_ft_02_vlad_minus_100.json @@ -0,0 +1,10 @@ +{ + "bank_name": "ozon", + "material_id": "30bc106c-6935-4f4b-85f3-f1a06adc5c13", + "type": "FETCH_TRANSACTIONS", + "body": { + "amount": -10000, + "created_at": "2026-04-10T09:40:00Z" + }, + "_tag": "pixel7_ozon_02_vlad_minus_100" +} diff --git a/tests/mock_server/scenarios/pool/pixel7_ozon_ft_03_vlad_minus_300.json b/tests/mock_server/scenarios/pool/pixel7_ozon_ft_03_vlad_minus_300.json new file mode 100644 index 0000000..f753eef --- /dev/null +++ b/tests/mock_server/scenarios/pool/pixel7_ozon_ft_03_vlad_minus_300.json @@ -0,0 +1,10 @@ +{ + "bank_name": "ozon", + "material_id": "30bc106c-6935-4f4b-85f3-f1a06adc5c13", + "type": "FETCH_TRANSACTIONS", + "body": { + "amount": -30000, + "created_at": "2026-04-08T08:34:00Z" + }, + "_tag": "pixel7_ozon_03_vlad_minus_300" +} diff --git a/tests/mock_server/scenarios/pool/pixel7_ozon_ft_04_vlad_plus_100.json b/tests/mock_server/scenarios/pool/pixel7_ozon_ft_04_vlad_plus_100.json new file mode 100644 index 0000000..6619834 --- /dev/null +++ b/tests/mock_server/scenarios/pool/pixel7_ozon_ft_04_vlad_plus_100.json @@ -0,0 +1,10 @@ +{ + "bank_name": "ozon", + "material_id": "30bc106c-6935-4f4b-85f3-f1a06adc5c13", + "type": "FETCH_TRANSACTIONS", + "body": { + "amount": 10000, + "created_at": "2026-04-08T08:14:00Z" + }, + "_tag": "pixel7_ozon_04_vlad_plus_100" +} diff --git a/tests/mock_server/scenarios/pool/pixel7_ozon_ft_05_vlad_plus_100_b.json b/tests/mock_server/scenarios/pool/pixel7_ozon_ft_05_vlad_plus_100_b.json new file mode 100644 index 0000000..cb38b8b --- /dev/null +++ b/tests/mock_server/scenarios/pool/pixel7_ozon_ft_05_vlad_plus_100_b.json @@ -0,0 +1,10 @@ +{ + "bank_name": "ozon", + "material_id": "30bc106c-6935-4f4b-85f3-f1a06adc5c13", + "type": "FETCH_TRANSACTIONS", + "body": { + "amount": 10000, + "created_at": "2026-04-08T08:02:00Z" + }, + "_tag": "pixel7_ozon_05_vlad_plus_100_b" +} diff --git a/tests/mock_server/scenarios/pool/pixel7_ozon_ft_06_evgeny_plus_100_b.json b/tests/mock_server/scenarios/pool/pixel7_ozon_ft_06_evgeny_plus_100_b.json new file mode 100644 index 0000000..ca93087 --- /dev/null +++ b/tests/mock_server/scenarios/pool/pixel7_ozon_ft_06_evgeny_plus_100_b.json @@ -0,0 +1,10 @@ +{ + "bank_name": "ozon", + "material_id": "30bc106c-6935-4f4b-85f3-f1a06adc5c13", + "type": "FETCH_TRANSACTIONS", + "body": { + "amount": 10000, + "created_at": "2026-04-06T13:54:00Z" + }, + "_tag": "pixel7_ozon_06_evgeny_plus_100_b" +} diff --git a/tests/mock_server/scenarios/pool/tecno_ozon_ft_01_galina_plus_100.json b/tests/mock_server/scenarios/pool/tecno_ozon_ft_01_galina_plus_100.json new file mode 100644 index 0000000..26988ec --- /dev/null +++ b/tests/mock_server/scenarios/pool/tecno_ozon_ft_01_galina_plus_100.json @@ -0,0 +1,10 @@ +{ + "bank_name": "ozon", + "material_id": "49001bb5-3b24-478d-b913-c2242aea0732", + "type": "FETCH_TRANSACTIONS", + "body": { + "amount": 10000, + "created_at": "2026-04-10T09:40:00Z" + }, + "_tag": "tecno_ozon_01_galina_plus_100" +} diff --git a/tests/mock_server/scenarios/pool/tecno_ozon_ft_02_galina_plus_300.json b/tests/mock_server/scenarios/pool/tecno_ozon_ft_02_galina_plus_300.json new file mode 100644 index 0000000..31c0474 --- /dev/null +++ b/tests/mock_server/scenarios/pool/tecno_ozon_ft_02_galina_plus_300.json @@ -0,0 +1,10 @@ +{ + "bank_name": "ozon", + "material_id": "49001bb5-3b24-478d-b913-c2242aea0732", + "type": "FETCH_TRANSACTIONS", + "body": { + "amount": 30000, + "created_at": "2026-04-08T08:34:00Z" + }, + "_tag": "tecno_ozon_02_galina_plus_300" +} diff --git a/tests/mock_server/scenarios/pool/tecno_ozon_ft_03_vlad_minus_100_a.json b/tests/mock_server/scenarios/pool/tecno_ozon_ft_03_vlad_minus_100_a.json new file mode 100644 index 0000000..71857e0 --- /dev/null +++ b/tests/mock_server/scenarios/pool/tecno_ozon_ft_03_vlad_minus_100_a.json @@ -0,0 +1,10 @@ +{ + "bank_name": "ozon", + "material_id": "49001bb5-3b24-478d-b913-c2242aea0732", + "type": "FETCH_TRANSACTIONS", + "body": { + "amount": -10000, + "created_at": "2026-04-08T08:32:00Z" + }, + "_tag": "tecno_ozon_03_vlad_minus_100_a" +} diff --git a/tests/mock_server/scenarios/pool/tecno_ozon_ft_04_vlad_minus_100_b.json b/tests/mock_server/scenarios/pool/tecno_ozon_ft_04_vlad_minus_100_b.json new file mode 100644 index 0000000..f9fb7cb --- /dev/null +++ b/tests/mock_server/scenarios/pool/tecno_ozon_ft_04_vlad_minus_100_b.json @@ -0,0 +1,10 @@ +{ + "bank_name": "ozon", + "material_id": "49001bb5-3b24-478d-b913-c2242aea0732", + "type": "FETCH_TRANSACTIONS", + "body": { + "amount": -10000, + "created_at": "2026-04-08T08:23:00Z" + }, + "_tag": "tecno_ozon_04_vlad_minus_100_b" +} diff --git a/tests/mock_server/scenarios/pool/tecno_ozon_ft_05_galina_minus_100_a.json b/tests/mock_server/scenarios/pool/tecno_ozon_ft_05_galina_minus_100_a.json new file mode 100644 index 0000000..5455a40 --- /dev/null +++ b/tests/mock_server/scenarios/pool/tecno_ozon_ft_05_galina_minus_100_a.json @@ -0,0 +1,10 @@ +{ + "bank_name": "ozon", + "material_id": "49001bb5-3b24-478d-b913-c2242aea0732", + "type": "FETCH_TRANSACTIONS", + "body": { + "amount": -10000, + "created_at": "2026-04-08T08:14:00Z" + }, + "_tag": "tecno_ozon_05_galina_minus_100_a" +} diff --git a/tests/mock_server/scenarios/pool/tecno_ozon_ft_06_galina_minus_100_b.json b/tests/mock_server/scenarios/pool/tecno_ozon_ft_06_galina_minus_100_b.json new file mode 100644 index 0000000..37b938b --- /dev/null +++ b/tests/mock_server/scenarios/pool/tecno_ozon_ft_06_galina_minus_100_b.json @@ -0,0 +1,10 @@ +{ + "bank_name": "ozon", + "material_id": "49001bb5-3b24-478d-b913-c2242aea0732", + "type": "FETCH_TRANSACTIONS", + "body": { + "amount": -10000, + "created_at": "2026-04-08T08:02:00Z" + }, + "_tag": "tecno_ozon_06_galina_minus_100_b" +} diff --git a/tests/mock_server/scenarios/pool/tecno_ozon_ft_07_vlad_plus_550.json b/tests/mock_server/scenarios/pool/tecno_ozon_ft_07_vlad_plus_550.json new file mode 100644 index 0000000..4d08d38 --- /dev/null +++ b/tests/mock_server/scenarios/pool/tecno_ozon_ft_07_vlad_plus_550.json @@ -0,0 +1,10 @@ +{ + "bank_name": "ozon", + "material_id": "49001bb5-3b24-478d-b913-c2242aea0732", + "type": "FETCH_TRANSACTIONS", + "body": { + "amount": 55000, + "created_at": "2026-03-20T14:36:00Z" + }, + "_tag": "tecno_ozon_07_vlad_plus_550" +} diff --git a/tests/mock_server/scenarios/tecno_km4_ozon_fetch_profile.json b/tests/mock_server/scenarios/tecno_km4_ozon_fetch_profile.json new file mode 100644 index 0000000..c5858fb --- /dev/null +++ b/tests/mock_server/scenarios/tecno_km4_ozon_fetch_profile.json @@ -0,0 +1,6 @@ +{ + "bank_name": "ozon", + "material_id": "49001bb5-3b24-478d-b913-c2242aea0732", + "type": "FETCH_PROFILE", + "body": {} +} diff --git a/tests/mock_server/scenarios/tecno_km4_ozon_fetch_transactions.json b/tests/mock_server/scenarios/tecno_km4_ozon_fetch_transactions.json new file mode 100644 index 0000000..23b91ee --- /dev/null +++ b/tests/mock_server/scenarios/tecno_km4_ozon_fetch_transactions.json @@ -0,0 +1,6 @@ +{ + "bank_name": "ozon", + "material_id": "49001bb5-3b24-478d-b913-c2242aea0732", + "type": "FETCH_TRANSACTIONS", + "body": {} +} diff --git a/tests/mock_server/scenarios/tecno_ozon_fetch_transactions.json b/tests/mock_server/scenarios/tecno_ozon_fetch_transactions.json new file mode 100644 index 0000000..23b91ee --- /dev/null +++ b/tests/mock_server/scenarios/tecno_ozon_fetch_transactions.json @@ -0,0 +1,6 @@ +{ + "bank_name": "ozon", + "material_id": "49001bb5-3b24-478d-b913-c2242aea0732", + "type": "FETCH_TRANSACTIONS", + "body": {} +} diff --git a/tests/mock_server/seed_profile.example.json b/tests/mock_server/seed_profile.example.json new file mode 100644 index 0000000..db9bf5c --- /dev/null +++ b/tests/mock_server/seed_profile.example.json @@ -0,0 +1,23 @@ +{ + "bank_profiles": [ + { + "id": "bp-ozon-0001", + "bank_name": "ozon", + "state": "active", + "phone_number": "+79990000000", + "first_name": "Ivan", + "middle_name": "", + "last_name": "Petrov", + "currency": 643, + "device_id": "REPLACE_WITH_DEVICE_API_ID", + "materials": [ + { + "id": "c340cd2e-cefd-48d6-a813-c4be097ae493", + "state": "active", + "bank_account_id": "40817810000000000001", + "currency": 643 + } + ] + } + ] +} diff --git a/views/bank/BankSetupWizard.cpp b/views/bank/BankSetupWizard.cpp index 6d0b331..cf29c5a 100644 --- a/views/bank/BankSetupWizard.cpp +++ b/views/bank/BankSetupWizard.cpp @@ -328,11 +328,23 @@ void BankSetupWizard::setProgress(int value, const QString &text) { } void BankSetupWizard::stopWorkerThread() { - if (m_workerThread) { - m_workerThread->quit(); - m_workerThread->wait(3000); - m_workerThread = nullptr; + if (!m_workerThread) return; + + QThread *t = m_workerThread; + m_workerThread = nullptr; + + t->requestInterruption(); + t->quit(); + if (t->wait(3000)) { + t->deleteLater(); + return; } + // Воркер залип (скорее всего в AdbUtils::startProcess / QProcess::waitForFinished). + // Отсоединяем QThread от виджета, чтобы ~QWidget не удалил работающий поток + // (это триггерит qFatal в ~QThread), и откладываем удаление до естественного завершения. + qWarning() << "[BankSetupWizard] worker thread still running after 3s, detaching"; + t->setParent(nullptr); + connect(t, &QThread::finished, t, &QObject::deleteLater); } void BankSetupWizard::startVerification() {