Add mock server for local ARC backend integration tests, update config.ini for local API usage.
@ -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;
|
||||
|
||||
@ -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);
|
||||
|
||||
// Целевая дата в таймзоне устройства для поиска заголовка дня в списке
|
||||
|
||||
@ -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
|
||||
|
||||
@ -272,12 +272,20 @@ bool EventDAO::existsActiveByExternalId(const QString &externalId) {
|
||||
return query.next();
|
||||
}
|
||||
|
||||
bool EventDAO::cancelWaitingByType(const QString &deviceId, EventType type) {
|
||||
bool EventDAO::cancelWaitingByType(const QString &deviceId, EventType type, const QString &bankName) {
|
||||
QSqlQuery query(DatabaseManager::instance().database());
|
||||
query.prepare(R"(
|
||||
UPDATE events SET status = :cancelled, comment = 'Cancelled: replaced by new task'
|
||||
WHERE device_id = :device_id AND type = :type AND status = :wait
|
||||
)");
|
||||
if (bankName.isEmpty()) {
|
||||
query.prepare(R"(
|
||||
UPDATE events SET status = :cancelled, comment = 'Cancelled: replaced by new task'
|
||||
WHERE device_id = :device_id AND type = :type AND status = :wait
|
||||
)");
|
||||
} else {
|
||||
query.prepare(R"(
|
||||
UPDATE events SET status = :cancelled, comment = 'Cancelled: replaced by new task'
|
||||
WHERE device_id = :device_id AND type = :type AND status = :wait AND bank_name = :bank_name
|
||||
)");
|
||||
query.bindValue(":bank_name", bankName);
|
||||
}
|
||||
query.bindValue(":cancelled", eventStatusToString(EventStatus::Error));
|
||||
query.bindValue(":device_id", deviceId);
|
||||
query.bindValue(":type", eventTypeToString(type));
|
||||
|
||||
@ -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);
|
||||
|
||||
|
||||
@ -31,8 +31,9 @@
|
||||
#include "dushanbe/PayByCardScript.h"
|
||||
#include "dushanbe/PayByPhoneScript.h"
|
||||
|
||||
static void sendTaskResult(const QString &type, const QString &materialId, const QString &bankName, const QJsonValue &data);
|
||||
static void sendTaskResult(const QString &type, const QString &materialId, const QString &bankName, const QJsonValue &data, int eventId = -1);
|
||||
static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName, const QString &description, const QByteArray &screenshot = {});
|
||||
static void sendMonitoringLog(const QString &type, const QString &event, const QString &message, const QByteArray &image = {});
|
||||
|
||||
EventHandler::EventHandler(QObject *parent) : QObject(parent) {}
|
||||
|
||||
@ -110,9 +111,9 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// FETCH_PROFILE: отменяем старые ожидающие задачи такого же типа на этом устройстве
|
||||
// FETCH_PROFILE: отменяем старые ожидающие задачи такого же типа на этом устройстве+банк
|
||||
if (eventType == EventType::FetchProfile) {
|
||||
EventDAO::cancelWaitingByType(account.deviceId, EventType::FetchProfile);
|
||||
EventDAO::cancelWaitingByType(account.deviceId, EventType::FetchProfile, bankName);
|
||||
}
|
||||
|
||||
EventInfo event;
|
||||
@ -161,7 +162,7 @@ void EventHandler::onTasksReceived(const QJsonArray &tasks) {
|
||||
}
|
||||
|
||||
static void sendTaskResult(const QString &type, const QString &materialId, const QString &bankName,
|
||||
const QJsonValue &data, int eventId = -1) {
|
||||
const QJsonValue &data, int eventId) {
|
||||
QJsonObject obj;
|
||||
obj["type"] = type;
|
||||
obj["material_id"] = materialId; // UUID string
|
||||
@ -192,24 +193,20 @@ static void sendTaskResult(const QString &type, const QString &materialId, const
|
||||
|
||||
static void sendTaskError(const QString &type, const QString &materialId, const QString &bankName,
|
||||
const QString &description, const QByteArray &screenshot) {
|
||||
const QString message = QStringLiteral("[%1] bank=%2 material=%3: %4")
|
||||
.arg(type, bankName, materialId, description);
|
||||
QJsonObject errorData;
|
||||
errorData["status"] = QStringLiteral("error");
|
||||
errorData["description"] = description;
|
||||
sendTaskResult(type, materialId, bankName, errorData);
|
||||
|
||||
auto *thread = new QThread;
|
||||
auto *networkService = new NetworkService;
|
||||
networkService->moveToThread(thread);
|
||||
QObject::connect(thread, &QThread::started, networkService, [networkService, message, screenshot]() {
|
||||
networkService->postMonitoringLog("error", "task_error", message, screenshot);
|
||||
emit networkService->finished();
|
||||
});
|
||||
QObject::connect(networkService, &NetworkService::finished, thread, &QThread::quit);
|
||||
QObject::connect(networkService, &NetworkService::finished, networkService, &QObject::deleteLater);
|
||||
QObject::connect(thread, &QThread::finished, thread, &QThread::deleteLater);
|
||||
thread->start();
|
||||
if (!screenshot.isEmpty()) {
|
||||
const QString message = QStringLiteral("[%1] bank=%2 material=%3: %4")
|
||||
.arg(type, bankName, materialId, description);
|
||||
sendMonitoringLog("error", "task_error", message, screenshot);
|
||||
}
|
||||
}
|
||||
|
||||
static void sendMonitoringLog(const QString &type, const QString &event, const QString &message,
|
||||
const QByteArray &image = {}) {
|
||||
const QByteArray &image) {
|
||||
auto *thread = new QThread;
|
||||
auto *networkService = new NetworkService;
|
||||
networkService->moveToThread(thread);
|
||||
|
||||
316
tests/mock_server/README.md
Normal 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-уведомления банков
|
||||
- Сетевые задержки/таймауты
|
||||
166
tests/mock_server/WINDOWS.md
Normal 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
|
||||
```
|
||||
64
tests/mock_server/default_profile.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 142 KiB |
@ -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="Успешный платеж Получатель: DCWallet*WDC0000**TAJIKISTAN Сумма: -1 Дата: 05.04.2026 Время: 21:31:21 Назад" 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>
|
||||
|
After Width: | Height: | Size: 142 KiB |
@ -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="Успешный платеж Получатель: DCWallet*WDC0000**TAJIKISTAN Сумма: -1 Дата: 05.04.2026 Время: 21:25:48 Назад" 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>
|
||||
|
After Width: | Height: | Size: 144 KiB |
@ -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="Успешный платеж Получатель: DCWallet*WDC0000**TAJIKISTAN Сумма: -1 Дата: 05.04.2026 Время: 18:36:06 Назад" 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>
|
||||
|
After Width: | Height: | Size: 142 KiB |
@ -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="Успешный платеж Получатель: DCWallet*WDC0000**TAJIKISTAN Сумма: -1 Дата: 05.04.2026 Время: 18:19:43 Назад" 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>
|
||||
|
After Width: | Height: | Size: 142 KiB |
@ -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="Успешный платеж Получатель: DCWallet*WDC0000**TAJIKISTAN Сумма: 1 Дата: 03.04.2026 Время: 20:52:06 Назад" 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>
|
||||
BIN
tests/mock_server/fixtures/pixel7_dushanbe/transactions_list.png
Normal file
|
After Width: | Height: | Size: 297 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 88 KiB |
|
After Width: | Height: | Size: 90 KiB |
|
After Width: | Height: | Size: 79 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 76 KiB |
BIN
tests/mock_server/fixtures/pixel7_ozon/transactions_list.png
Normal file
|
After Width: | Height: | Size: 222 KiB |
|
After Width: | Height: | Size: 47 KiB |
|
After Width: | Height: | Size: 49 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 74 KiB |
|
After Width: | Height: | Size: 53 KiB |
|
After Width: | Height: | Size: 54 KiB |
|
After Width: | Height: | Size: 73 KiB |
BIN
tests/mock_server/fixtures/tecno_ozon/transactions_list.png
Normal file
|
After Width: | Height: | Size: 149 KiB |
428
tests/mock_server/gen_fetch_transactions.py
Executable 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(" ")
|
||||
amount, time_str = None, None
|
||||
for p in parts:
|
||||
p = p.strip()
|
||||
try:
|
||||
amount = int(float(p))
|
||||
except ValueError:
|
||||
pass
|
||||
if re.match(r"^\s*\d{1,2}:\d{2}(:\d{2})?\s*$", p):
|
||||
time_str = p.strip()
|
||||
rows.append({"cx": (x1+x2)//2, "cy": (y1+y2)//2, "amount": amount, "time": time_str})
|
||||
rows.sort(key=lambda r: r["cy"])
|
||||
return rows
|
||||
|
||||
|
||||
def is_dushanbe_list(xml):
|
||||
return "Операции" in xml and "часть P2P" in xml
|
||||
|
||||
|
||||
def extract_dushanbe_detail(xml):
|
||||
m = re.search(r'content-desc="([^"]{30,})"', xml)
|
||||
if not m:
|
||||
return None, None, None
|
||||
parts = [x.strip() for x in m.group(1).split(" ")]
|
||||
info = {}
|
||||
for i, x in enumerate(parts):
|
||||
if x.endswith(":") and i + 1 < len(parts):
|
||||
info[x] = parts[i + 1]
|
||||
return info.get("Сумма:"), info.get("Дата:"), info.get("Время:")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- Main
|
||||
|
||||
def short_label(amount, time_str):
|
||||
sign = "plus" if amount >= 0 else "minus"
|
||||
t = time_str.replace(":", "") if time_str else "unk"
|
||||
return f"{sign}_{abs(amount)}_{t}"
|
||||
|
||||
|
||||
def collect_tasks(serial, bank, mid, device_tz, kopecks, prefix):
|
||||
xml = dump_xml(serial)
|
||||
is_ozon = bank == "ozon" or is_ozon_list(xml)
|
||||
is_dush = "dushanbe" in bank or is_dushanbe_list(xml)
|
||||
|
||||
if not is_ozon and not is_dush:
|
||||
print("ERROR: not on a recognized transactions list", file=sys.stderr)
|
||||
return []
|
||||
|
||||
tasks = []
|
||||
seen = set()
|
||||
|
||||
if is_ozon:
|
||||
print("Detected: Ozon")
|
||||
baseline = parse_ozon_rows(xml)
|
||||
print(f"rows: {len(baseline)}")
|
||||
for i, r in enumerate(baseline, 1):
|
||||
print(f" {i}. ({r['cx']},{r['cy']}) {r['amount']:+d} ₽ | {r['text'][:50]}")
|
||||
|
||||
for idx in range(1, len(baseline) + 1):
|
||||
xml = dump_xml(serial)
|
||||
if not is_ozon_list(xml):
|
||||
print(f"[{idx}] left list, stop"); break
|
||||
rows = parse_ozon_rows(xml)
|
||||
if idx > len(rows):
|
||||
break
|
||||
row = rows[idx - 1]
|
||||
print(f"\n[{idx}] tap ({row['cx']},{row['cy']}) {row['amount']:+d}")
|
||||
tap(serial, row["cx"], row["cy"])
|
||||
time.sleep(2.5)
|
||||
d_xml = dump_xml(serial)
|
||||
date_s, time_s = extract_ozon_detail_time(d_xml)
|
||||
print(f" {date_s} {time_s}")
|
||||
back(serial); time.sleep(1.3)
|
||||
if not date_s or not time_s:
|
||||
print(" SKIP"); continue
|
||||
dt = datetime.strptime(f"{date_s} {time_s}", "%d.%m.%Y %H:%M:%S").replace(tzinfo=device_tz)
|
||||
cat = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
label = short_label(row["amount"], time_s)
|
||||
while label in seen: label += "_2"
|
||||
seen.add(label)
|
||||
tasks.append({"bank_name": bank, "material_id": mid, "type": "FETCH_TRANSACTIONS",
|
||||
"body": {"amount": row["amount"] * kopecks, "created_at": cat},
|
||||
"_tag": f"{prefix}_{idx:02d}_{label}"})
|
||||
|
||||
elif is_dush:
|
||||
print("Detected: Dushanbe")
|
||||
baseline = parse_dushanbe_rows(xml)
|
||||
print(f"rows: {len(baseline)}")
|
||||
for i, r in enumerate(baseline, 1):
|
||||
print(f" {i}. ({r['cx']},{r['cy']}) amt={r['amount']} time={r['time']}")
|
||||
|
||||
for idx in range(1, len(baseline) + 1):
|
||||
xml = dump_xml(serial)
|
||||
if not is_dushanbe_list(xml):
|
||||
print(f"[{idx}] left list, stop"); break
|
||||
rows = parse_dushanbe_rows(xml)
|
||||
if idx > len(rows):
|
||||
break
|
||||
row = rows[idx - 1]
|
||||
print(f"\n[{idx}] tap ({row['cx']},{row['cy']}) amt={row['amount']}")
|
||||
tap(serial, row["cx"], row["cy"])
|
||||
time.sleep(2.5)
|
||||
d_xml = dump_xml(serial)
|
||||
amt_s, date_s, time_s = extract_dushanbe_detail(d_xml)
|
||||
print(f" amt={amt_s} {date_s} {time_s}")
|
||||
back(serial); time.sleep(1.3)
|
||||
if not date_s or not time_s:
|
||||
print(" SKIP"); continue
|
||||
amount = int(float(amt_s)) if amt_s else (row["amount"] or 0)
|
||||
fmt = "%d.%m.%Y %H:%M:%S" if len(time_s) > 5 else "%d.%m.%Y %H:%M"
|
||||
dt = datetime.strptime(f"{date_s} {time_s}", fmt).replace(tzinfo=device_tz)
|
||||
cat = dt.astimezone(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
||||
label = short_label(amount, time_s)
|
||||
while label in seen: label += "_2"
|
||||
seen.add(label)
|
||||
tasks.append({"bank_name": bank, "material_id": mid, "type": "FETCH_TRANSACTIONS",
|
||||
"body": {"amount": amount * kopecks, "created_at": cat},
|
||||
"_tag": f"{prefix}_{idx:02d}_{label}"})
|
||||
|
||||
return tasks
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Generate FETCH_TRANSACTIONS pool from live device")
|
||||
parser.add_argument("--config", "-c", default=None, help="Path to config.ini")
|
||||
parser.add_argument("--serial", "-s", default=None, help="ADB serial (prompted if multiple)")
|
||||
parser.add_argument("--kopecks", type=int, default=100, help="Amount multiplier (default 100)")
|
||||
parser.add_argument("--mock-host", default="127.0.0.1")
|
||||
parser.add_argument("--mock-port", type=int, default=8888)
|
||||
args = parser.parse_args()
|
||||
|
||||
# Find config and DB
|
||||
for c in ([Path(args.config)] if args.config else
|
||||
[Path("config.ini"), Path("build/Release/config.ini"),
|
||||
Path("build/config.ini")]):
|
||||
if c.exists():
|
||||
config_path = c.resolve(); break
|
||||
else:
|
||||
print("ERROR: config.ini not found", file=sys.stderr); return 1
|
||||
|
||||
db_path = resolve_db_path(config_path)
|
||||
print(f"config: {config_path}")
|
||||
print(f"db: {db_path}")
|
||||
|
||||
materials = query_active_materials(db_path)
|
||||
if not materials:
|
||||
print("No active materials in DB"); return 1
|
||||
|
||||
# Find connected ADB devices
|
||||
serials = get_adb_serials()
|
||||
if not serials:
|
||||
print("ERROR: no ADB devices connected"); return 1
|
||||
|
||||
# Match DB devices to connected ADB serials
|
||||
options = []
|
||||
for mat in materials:
|
||||
adb_serial = mat["adb_serial"]
|
||||
if adb_serial not in serials:
|
||||
continue
|
||||
dev_label = re.sub(r"-[a-f0-9]{10,}$", "", mat["device_name"])
|
||||
options.append({**mat, "serial": adb_serial, "label": dev_label})
|
||||
|
||||
if not options:
|
||||
print("No active materials match connected ADB devices.")
|
||||
print(f" DB serials: {[m['adb_serial'] for m in materials]}")
|
||||
print(f" ADB serials: {serials}")
|
||||
return 1
|
||||
|
||||
# Menu
|
||||
print(f"\nAvailable materials ({len(options)}):")
|
||||
for i, o in enumerate(options, 1):
|
||||
print(f" {i}. {o['label']} / {o['bank_name']} / {o['full_name'] or '?'} "
|
||||
f"(material {o['material_id'][:8]}… serial={o['serial']})")
|
||||
|
||||
if len(options) == 1:
|
||||
choice = 0
|
||||
print(f"\n→ auto-selected: {options[0]['label']} / {options[0]['bank_name']}")
|
||||
else:
|
||||
try:
|
||||
inp = input(f"\nPick [1-{len(options)}] or 'all': ").strip()
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
return 0
|
||||
if inp.lower() == "all":
|
||||
choice = -1
|
||||
else:
|
||||
choice = int(inp) - 1
|
||||
if choice < 0 or choice >= len(options):
|
||||
print("Invalid choice"); return 1
|
||||
|
||||
to_scan = options if choice == -1 else [options[choice]]
|
||||
all_tasks = []
|
||||
|
||||
for o in to_scan:
|
||||
serial = args.serial or o["serial"]
|
||||
bank = o["bank_name"]
|
||||
mid = o["material_id"]
|
||||
prefix = f"{o['label'].lower().replace(' ', '_')}_{bank}"
|
||||
device_tz = get_device_tz(serial)
|
||||
print(f"\n{'='*60}")
|
||||
print(f"Scanning: {o['label']} / {bank} ({mid[:8]}…)")
|
||||
print(f"Serial: {serial} TZ: UTC{device_tz.utcoffset(None)}")
|
||||
print(f"{'='*60}")
|
||||
|
||||
input(f"\nOpen {bank} transactions list on {o['label']} and press Enter...")
|
||||
|
||||
tasks = collect_tasks(serial, bank, mid, device_tz, args.kopecks, prefix)
|
||||
all_tasks.extend(tasks)
|
||||
|
||||
if not all_tasks:
|
||||
print("\nNo tasks generated"); return 1
|
||||
|
||||
# Write pool
|
||||
POOL_DIR.mkdir(parents=True, exist_ok=True)
|
||||
# Remove old files for scanned prefixes
|
||||
scanned_prefixes = set()
|
||||
for o in to_scan:
|
||||
scanned_prefixes.add(f"{o['label'].lower().replace(' ', '_')}_{o['bank_name']}")
|
||||
for old in POOL_DIR.glob("*.json"):
|
||||
for pfx in scanned_prefixes:
|
||||
if old.name.startswith(pfx + "_ft_"):
|
||||
old.unlink(); break
|
||||
|
||||
for i, t in enumerate(all_tasks, 1):
|
||||
tag = t["_tag"]
|
||||
fname = f"{tag.rsplit('_', 1)[0]}_ft_{i:02d}.json"
|
||||
(POOL_DIR / fname).write_text(json.dumps(t, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
||||
|
||||
print(f"\n{len(all_tasks)} tasks written to {POOL_DIR}/")
|
||||
for t in all_tasks:
|
||||
b = t["body"]
|
||||
print(f" {t['_tag']}: amount={b['amount']:+d} created_at={b['created_at']}")
|
||||
|
||||
# Inject into mock if running
|
||||
try:
|
||||
import urllib.request
|
||||
url = f"http://{args.mock_host}:{args.mock_port}/_admin/inject"
|
||||
req = urllib.request.Request(url, data=json.dumps(all_tasks).encode(),
|
||||
headers={"Content-Type": "application/json"}, method="POST")
|
||||
with urllib.request.urlopen(req, timeout=3) as resp:
|
||||
print(f"\nInjected {len(all_tasks)} tasks into mock -> {resp.status}")
|
||||
except Exception as e:
|
||||
print(f"\nMock not reachable ({e}) — tasks saved to files only")
|
||||
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
222
tests/mock_server/gen_mock_data.py
Executable 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
@ -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
|
||||
67
tests/mock_server/inject_random.py
Executable 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())
|
||||
800
tests/mock_server/mock_server.py
Normal 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("&", "&").replace("<", "<")
|
||||
.replace(">", ">").replace('"', """))
|
||||
|
||||
|
||||
def render_entry_html(e) -> str:
|
||||
import html as _html
|
||||
status = e["status"]
|
||||
task = e.get("task") or {}
|
||||
result = e.get("result")
|
||||
ttype = _html.escape(task.get("type", "?"))
|
||||
mid = _html.escape((task.get("material_id") or "")[:8])
|
||||
src = _html.escape(task.get("_source", ""))
|
||||
bank = _html.escape(task.get("bank_name", ""))
|
||||
injected_at = _fmt_ts(e.get("injected_at"))
|
||||
result_at = _fmt_ts(e.get("result_at"))
|
||||
|
||||
def json_block(obj):
|
||||
if obj is None:
|
||||
return '<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
@ -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 "$@"
|
||||
@ -0,0 +1,6 @@
|
||||
{
|
||||
"bank_name": "dushanbe_city_bank",
|
||||
"material_id": "61d967d1-8d3e-4d05-ba01-8c8ab327b945",
|
||||
"type": "FETCH_TRANSACTIONS",
|
||||
"body": {}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
{
|
||||
"bank_name": "ozon",
|
||||
"material_id": "30bc106c-6935-4f4b-85f3-f1a06adc5c13",
|
||||
"type": "FETCH_TRANSACTIONS",
|
||||
"body": {}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
{
|
||||
"bank_name": "dushanbe_city_bank",
|
||||
"material_id": "61d967d1-8d3e-4d05-ba01-8c8ab327b945",
|
||||
"type": "FETCH_PROFILE",
|
||||
"body": {}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
{
|
||||
"bank_name": "dushanbe_city_bank",
|
||||
"material_id": "61d967d1-8d3e-4d05-ba01-8c8ab327b945",
|
||||
"type": "FETCH_TRANSACTIONS",
|
||||
"body": {}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
{
|
||||
"bank_name": "ozon",
|
||||
"material_id": "30bc106c-6935-4f4b-85f3-f1a06adc5c13",
|
||||
"type": "FETCH_PROFILE",
|
||||
"body": {}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
{
|
||||
"bank_name": "ozon",
|
||||
"material_id": "30bc106c-6935-4f4b-85f3-f1a06adc5c13",
|
||||
"type": "FETCH_TRANSACTIONS",
|
||||
"body": {}
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -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"
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
{
|
||||
"bank_name": "ozon",
|
||||
"material_id": "49001bb5-3b24-478d-b913-c2242aea0732",
|
||||
"type": "FETCH_PROFILE",
|
||||
"body": {}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
{
|
||||
"bank_name": "ozon",
|
||||
"material_id": "49001bb5-3b24-478d-b913-c2242aea0732",
|
||||
"type": "FETCH_TRANSACTIONS",
|
||||
"body": {}
|
||||
}
|
||||
@ -0,0 +1,6 @@
|
||||
{
|
||||
"bank_name": "ozon",
|
||||
"material_id": "49001bb5-3b24-478d-b913-c2242aea0732",
|
||||
"type": "FETCH_TRANSACTIONS",
|
||||
"body": {}
|
||||
}
|
||||
23
tests/mock_server/seed_profile.example.json
Normal 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
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
@ -328,11 +328,23 @@ void BankSetupWizard::setProgress(int value, const QString &text) {
|
||||
}
|
||||
|
||||
void BankSetupWizard::stopWorkerThread() {
|
||||
if (m_workerThread) {
|
||||
m_workerThread->quit();
|
||||
m_workerThread->wait(3000);
|
||||
m_workerThread = nullptr;
|
||||
if (!m_workerThread) return;
|
||||
|
||||
QThread *t = m_workerThread;
|
||||
m_workerThread = nullptr;
|
||||
|
||||
t->requestInterruption();
|
||||
t->quit();
|
||||
if (t->wait(3000)) {
|
||||
t->deleteLater();
|
||||
return;
|
||||
}
|
||||
// Воркер залип (скорее всего в AdbUtils::startProcess / QProcess::waitForFinished).
|
||||
// Отсоединяем QThread от виджета, чтобы ~QWidget не удалил работающий поток
|
||||
// (это триггерит qFatal в ~QThread), и откладываем удаление до естественного завершения.
|
||||
qWarning() << "[BankSetupWizard] worker thread still running after 3s, detaching";
|
||||
t->setParent(nullptr);
|
||||
connect(t, &QThread::finished, t, &QObject::deleteLater);
|
||||
}
|
||||
|
||||
void BankSetupWizard::startVerification() {
|
||||
|
||||