1
0
forked from BRT/arc

Add mock server for local ARC backend integration tests, update config.ini for local API usage.

This commit is contained in:
slava 2026-04-12 12:11:39 +07:00
parent d235508c70
commit 23c3f366b1
87 changed files with 2442 additions and 31 deletions

View File

@ -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;

View File

@ -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);
// Целевая дата в таймзоне устройства для поиска заголовка дня в списке

View File

@ -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

View File

@ -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());
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));

View File

@ -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<EventInfo> cancelWaitingByBank(const QString &deviceId, const QString &bankName);

View File

@ -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) {
QJsonObject errorData;
errorData["status"] = QStringLiteral("error");
errorData["description"] = description;
sendTaskResult(type, materialId, bankName, errorData);
if (!screenshot.isEmpty()) {
const QString message = QStringLiteral("[%1] bank=%2 material=%3: %4")
.arg(type, bankName, materialId, description);
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();
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);

316
tests/mock_server/README.md Normal file
View File

@ -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-уведомления банков
- Сетевые задержки/таймауты

View File

@ -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 <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
```

View File

@ -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
}
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View File

@ -0,0 +1 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint=""><node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint=""><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint=""><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.widget.ImageView" package="tj.dc.next1" content-desc="Успешный платеж&#10;Получатель: &#10;DCWallet*WDC0000**TAJIKISTAN&#10;Сумма: &#10;-1&#10;Дата: &#10;05.04.2026&#10;Время: &#10;21:31:21&#10;Назад" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="true" long-clickable="false" password="false" selected="false" bounds="[0,615][1080,2400]" drawing-order="0" hint="" /></node></node><node index="1" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="Закрыть" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="" /></node></node></node></node></node></node></node></hierarchy>

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View File

@ -0,0 +1 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint=""><node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint=""><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint=""><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.widget.ImageView" package="tj.dc.next1" content-desc="Успешный платеж&#10;Получатель: &#10;DCWallet*WDC0000**TAJIKISTAN&#10;Сумма: &#10;-1&#10;Дата: &#10;05.04.2026&#10;Время: &#10;21:25:48&#10;Назад" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="true" long-clickable="false" password="false" selected="false" bounds="[0,615][1080,2400]" drawing-order="0" hint="" /></node></node><node index="1" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="Закрыть" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="" /></node></node></node></node></node></node></node></hierarchy>

Binary file not shown.

After

Width:  |  Height:  |  Size: 144 KiB

View File

@ -0,0 +1 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint=""><node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint=""><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint=""><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.widget.ImageView" package="tj.dc.next1" content-desc="Успешный платеж&#10;Получатель: &#10;DCWallet*WDC0000**TAJIKISTAN&#10;Сумма: &#10;-1&#10;Дата: &#10;05.04.2026&#10;Время: &#10;18:36:06&#10;Назад" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="true" long-clickable="false" password="false" selected="false" bounds="[0,615][1080,2400]" drawing-order="0" hint="" /></node></node><node index="1" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="Закрыть" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="" /></node></node></node></node></node></node></node></hierarchy>

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View File

@ -0,0 +1 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint=""><node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint=""><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint=""><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.widget.ImageView" package="tj.dc.next1" content-desc="Успешный платеж&#10;Получатель: &#10;DCWallet*WDC0000**TAJIKISTAN&#10;Сумма: &#10;-1&#10;Дата: &#10;05.04.2026&#10;Время: &#10;18:19:43&#10;Назад" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="true" long-clickable="false" password="false" selected="false" bounds="[0,615][1080,2400]" drawing-order="0" hint="" /></node></node><node index="1" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="Закрыть" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="" /></node></node></node></node></node></node></node></hierarchy>

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

View File

@ -0,0 +1 @@
<?xml version='1.0' encoding='UTF-8' standalone='yes' ?><hierarchy rotation="0"><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.widget.LinearLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint=""><node index="0" text="" resource-id="android:id/content" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="2" hint=""><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint=""><node index="0" text="" resource-id="" class="android.widget.FrameLayout" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="1" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="" checkable="false" checked="false" clickable="false" enabled="true" focusable="false" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint=""><node index="0" text="" resource-id="" class="android.widget.ImageView" package="tj.dc.next1" content-desc="Успешный платеж&#10;Получатель: &#10;DCWallet*WDC0000**TAJIKISTAN&#10;Сумма: &#10;1&#10;Дата: &#10;03.04.2026&#10;Время: &#10;20:52:06&#10;Назад" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="true" long-clickable="false" password="false" selected="false" bounds="[0,615][1080,2400]" drawing-order="0" hint="" /></node></node><node index="1" text="" resource-id="" class="android.view.View" package="tj.dc.next1" content-desc="Закрыть" checkable="false" checked="false" clickable="true" enabled="true" focusable="true" focused="false" scrollable="false" long-clickable="false" password="false" selected="false" bounds="[0,0][1080,2400]" drawing-order="0" hint="" /></node></node></node></node></node></node></node></hierarchy>

Binary file not shown.

After

Width:  |  Height:  |  Size: 297 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 77 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 90 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 76 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 222 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 47 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 74 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 53 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 73 KiB

File diff suppressed because one or more lines are too long

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

File diff suppressed because one or more lines are too long

View File

@ -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'<node\s[^>]*?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'<node\s[^>]*?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("&#10;")
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("&#10;")]
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())

View File

@ -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())

8
tests/mock_server/inject.sh Executable file
View File

@ -0,0 +1,8 @@
#!/usr/bin/env bash
# Usage: ./inject.sh scenarios/fetch_profile_ozon.json
set -euo pipefail
FILE="${1:?usage: inject.sh <scenario.json>}"
curl -fsS http://127.0.0.1:8888/_admin/inject \
-H 'Content-Type: application/json' \
--data-binary @"$FILE"
echo

View File

@ -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())

View File

@ -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/<desktop_id>")
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/<api_id>")
def device_patch(api_id):
return ok({"id": api_id})
@app.delete("/api/v1/device/<api_id>")
def device_delete(api_id):
return ok({"id": api_id})
@app.post("/api/v1/device/add_screenshot/<api_id>")
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/<bp_id>/<state>")
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/<mat_id>/<state>")
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'<section class="group">'
f'<h2><span class="dev">{_html.escape(label)}</span>'
f'<span class="g-stats">'
f'<span class="g-done">{g_done}</span>'
f' / <span class="g-total">{len(gentries)}</span>'
f' <span class="muted">(ожидают: {g_pend})</span>'
f'</span></h2>{rows_html}</section>'
)
body = "\n".join(blocks) or '<div class="empty">Отчёт пуст</div>'
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("&", "&amp;").replace("<", "&lt;")
.replace(">", "&gt;").replace('"', "&quot;"))
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 '<span class="muted">—</span>'
text = json.dumps(obj, ensure_ascii=False, indent=2)
return f'<pre>{_html.escape(text)}</pre>'
result_summary = ""
if result and isinstance(result.get("data"), dict):
d = result["data"]
if d.get("status") == "error":
result_summary = f'<div class="err">ERROR: {_html.escape(str(d.get("description", "")))}</div>'
elif result and isinstance(result.get("data"), list):
result_summary = f'<div class="muted">{len(result["data"])} items</div>'
return f"""
<div class="entry {status}">
<div class="head">
<span class="badge {status}">{status}</span>
<span class="type">{ttype}</span>
<span class="bank">{bank}</span>
<span class="mid">{mid}</span>
<span class="src">{src}</span>
<span class="times">inj {injected_at} res {result_at}</span>
</div>
<div class="panes">
<div class="pane task">
<div class="label">Task JSON</div>
{json_block(task)}
</div>
<div class="pane result">
<div class="label">Result JSON</div>
{result_summary}
{json_block(result)}
</div>
</div>
</div>
"""
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"""
<section class="group">
<h2>
<span class="dev">{_html.escape(label)}</span>
<span class="g-stats">
<span class="g-done">{g_done}</span>
/ <span class="g-total">{g_total}</span>
<span class="muted">(ожидают: {g_pend})</span>
</span>
</h2>
{rows_html}
</section>
""")
body_rows = "\n".join(group_blocks) or '<div class="empty">Отчёт пуст — бутстрап ещё не сработал или результаты ещё не пришли.</div>'
return f"""<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8">
<title>ARC mock отчёт</title>
<style>
:root {{
--bg: #0f1115;
--panel: #1a1d25;
--muted: #7d8594;
--text: #e6e8ec;
--task: #1f2430;
--result: #1b2420;
--pending: #c9a227;
--completed: #4caf50;
--unsolicited: #d04343;
--border: #2a2f3a;
}}
* {{ box-sizing: border-box; }}
body {{
margin: 0; padding: 20px;
background: var(--bg); color: var(--text);
font-family: -apple-system, "SF Pro Text", Helvetica, Arial, sans-serif;
font-size: 13px;
}}
h1 {{ margin: 0 0 8px; font-size: 18px; }}
.summary {{
display: flex; gap: 12px; flex-wrap: wrap;
margin-bottom: 16px; font-size: 13px;
}}
.summary .cell {{
background: var(--panel); padding: 6px 12px;
border: 1px solid var(--border); border-radius: 4px;
}}
.summary .cell .n {{ font-size: 16px; font-weight: bold; }}
.summary .pending .n {{ color: var(--pending); }}
.summary .completed .n {{ color: var(--completed); }}
.summary .unsolicited .n {{ color: var(--unsolicited); }}
.entry {{
background: var(--panel);
border: 1px solid var(--border);
border-radius: 6px;
margin-bottom: 12px;
overflow: hidden;
}}
.entry.completed {{ border-left: 4px solid var(--completed); }}
.entry.pending {{ border-left: 4px solid var(--pending); }}
.entry.unsolicited {{ border-left: 4px solid var(--unsolicited); }}
.head {{
display: flex; align-items: center; gap: 10px;
padding: 8px 12px; background: #161921; font-size: 12px;
flex-wrap: wrap;
}}
.badge {{
padding: 2px 8px; border-radius: 10px; font-weight: 600;
text-transform: uppercase; font-size: 10px;
}}
.badge.pending {{ background: var(--pending); color: #1a1600; }}
.badge.completed {{ background: var(--completed); color: #0a1a0a; }}
.badge.unsolicited {{ background: var(--unsolicited); color: #200; }}
.type {{ font-weight: 600; color: #9cdcfe; }}
.bank {{ color: #ffb86c; }}
.mid {{ color: var(--muted); font-family: monospace; }}
.src {{ color: var(--muted); font-style: italic; }}
.times {{ color: var(--muted); margin-left: auto; font-family: monospace; }}
.panes {{ display: grid; grid-template-columns: 1fr 1fr; gap: 1px; background: var(--border); }}
.pane {{ padding: 10px 12px; }}
.pane.task {{ background: var(--task); }}
.pane.result {{ background: var(--result); }}
.pane .label {{
font-size: 10px; text-transform: uppercase; letter-spacing: 0.05em;
color: var(--muted); margin-bottom: 4px;
}}
pre {{
margin: 0; white-space: pre-wrap; word-break: break-word;
font-family: "SF Mono", Menlo, Consolas, monospace; font-size: 12px;
line-height: 1.45;
}}
.muted {{ color: var(--muted); font-style: italic; }}
.err {{ color: #ff6b6b; font-weight: 600; margin-bottom: 4px; }}
.empty {{ color: var(--muted); text-align: center; padding: 40px; }}
a {{ color: #9cdcfe; }}
.group {{
margin-bottom: 24px;
border: 1px solid var(--border);
border-radius: 8px;
background: #13161d;
padding: 12px 16px;
}}
.group h2 {{
margin: 0 0 12px;
font-size: 15px;
display: flex;
align-items: baseline;
gap: 10px;
padding-bottom: 8px;
border-bottom: 1px solid var(--border);
}}
.group h2 .dev {{ color: #ffb86c; font-weight: 700; }}
.group h2 .g-stats {{ margin-left: auto; font-family: monospace; font-size: 13px; }}
.group h2 .g-done {{ color: var(--completed); font-weight: 700; }}
.group h2 .g-total {{ color: var(--muted); }}
.updating {{ opacity: 0.6; }}
</style>
</head>
<body>
<h1>ARC mock отчёт <span class="muted" id="refresh-status">(автообновление 2 сек)</span></h1>
<div class="summary">
<div class="cell"><div class="n">{summary['total']}</div>всего</div>
<div class="cell completed"><div class="n">{summary['completed']}</div>выполнено</div>
<div class="cell pending"><div class="n">{summary['pending']}</div>в очереди</div>
<div class="cell unsolicited"><div class="n">{summary['unsolicited']}</div>незапрошенные</div>
</div>
<div id="report-body">
{body_rows}
</div>
<script>
(function() {{
const REFRESH_MS = 2000;
const bodyEl = document.getElementById('report-body');
const statusEl = document.getElementById('refresh-status');
const summaryEls = {{
total: document.querySelector('.summary .cell:nth-child(1) .n'),
completed: document.querySelector('.summary .cell.completed .n'),
pending: document.querySelector('.summary .cell.pending .n'),
unsolicited: document.querySelector('.summary .cell.unsolicited .n'),
}};
async function tick() {{
try {{
const resp = await fetch('/_admin/report/fragment', {{cache: 'no-store'}});
if (!resp.ok) throw new Error(resp.status);
const data = await resp.json();
bodyEl.innerHTML = data.html;
summaryEls.total.textContent = data.summary.total;
summaryEls.completed.textContent = data.summary.completed;
summaryEls.pending.textContent = data.summary.pending;
summaryEls.unsolicited.textContent = data.summary.unsolicited;
statusEl.textContent = '(обновлено ' + new Date().toLocaleTimeString() + ')';
}} catch (e) {{
statusEl.textContent = '(ошибка обновления: ' + e + ')';
}}
}}
setInterval(tick, REFRESH_MS);
}})();
</script>
</body>
</html>
"""
@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)

5
tests/mock_server/run.sh Executable file
View File

@ -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 "$@"

View File

@ -0,0 +1,6 @@
{
"bank_name": "dushanbe_city_bank",
"material_id": "61d967d1-8d3e-4d05-ba01-8c8ab327b945",
"type": "FETCH_TRANSACTIONS",
"body": {}
}

View File

@ -0,0 +1,6 @@
{
"bank_name": "ozon",
"material_id": "30bc106c-6935-4f4b-85f3-f1a06adc5c13",
"type": "FETCH_TRANSACTIONS",
"body": {}
}

View File

@ -0,0 +1,6 @@
{
"bank_name": "dushanbe_city_bank",
"material_id": "61d967d1-8d3e-4d05-ba01-8c8ab327b945",
"type": "FETCH_PROFILE",
"body": {}
}

View File

@ -0,0 +1,6 @@
{
"bank_name": "dushanbe_city_bank",
"material_id": "61d967d1-8d3e-4d05-ba01-8c8ab327b945",
"type": "FETCH_TRANSACTIONS",
"body": {}
}

View File

@ -0,0 +1,6 @@
{
"bank_name": "ozon",
"material_id": "30bc106c-6935-4f4b-85f3-f1a06adc5c13",
"type": "FETCH_PROFILE",
"body": {}
}

View File

@ -0,0 +1,6 @@
{
"bank_name": "ozon",
"material_id": "30bc106c-6935-4f4b-85f3-f1a06adc5c13",
"type": "FETCH_TRANSACTIONS",
"body": {}
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -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"
}

View File

@ -0,0 +1,6 @@
{
"bank_name": "ozon",
"material_id": "49001bb5-3b24-478d-b913-c2242aea0732",
"type": "FETCH_PROFILE",
"body": {}
}

View File

@ -0,0 +1,6 @@
{
"bank_name": "ozon",
"material_id": "49001bb5-3b24-478d-b913-c2242aea0732",
"type": "FETCH_TRANSACTIONS",
"body": {}
}

View File

@ -0,0 +1,6 @@
{
"bank_name": "ozon",
"material_id": "49001bb5-3b24-478d-b913-c2242aea0732",
"type": "FETCH_TRANSACTIONS",
"body": {}
}

View File

@ -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
}
]
}
]
}

View File

@ -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);
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() {