#!/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(encoding="utf-8")) _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(encoding="utf-8")) except Exception as e: print(f"[mock] skipping {f.name}: {e}") continue if task.get("type") != "FETCH_TRANSACTIONS": continue mid = task.get("material_id") if not mid: continue task["_source"] = f.name _pool_by_material[mid].append(task) count += 1 print(f"[mock] loaded pool: {count} FETCH_TRANSACTIONS across " f"{len(_pool_by_material)} materials") for mid, lst in _pool_by_material.items(): print(f" {mid}: {len(lst)} tasks") def enqueue_task(task: dict) -> dict: """Attach _task_id, store in queue, track in _injected.""" task = copy.deepcopy(task) tid = str(uuid.uuid4()) task["_task_id"] = tid entry = { "id": tid, "task": task, "injected_at": time.time(), "result": None, "result_at": None, "status": "pending", } _injected.append(entry) idx = len(_injected) - 1 key = (task.get("material_id"), task.get("type")) _pending_by_key[key].append(idx) _task_queue.append(task) return entry def bootstrap_tasks(): """Queue 1 FETCH_PROFILE + N FETCH_TRANSACTIONS per seeded material.""" global _bootstrap_done if _bootstrap_done: return queued = 0 for bp in _bank_profiles: bank = bp.get("bank_name") for mat in bp.get("materials", []): mid = mat.get("id") if not mid: continue # 1 × FETCH_PROFILE enqueue_task({ "bank_name": bank, "material_id": mid, "type": "FETCH_PROFILE", "body": {}, "_source": "bootstrap", }) queued += 1 # N × random FETCH_TRANSACTIONS from pool pool = _pool_by_material.get(mid, []) if not pool: print(f"[mock] bootstrap: no pool tasks for {mid} ({bank})") continue pick = random.sample(pool, min(_bootstrap_ft_count, len(pool))) for t in pick: enqueue_task(t) queued += 1 _bootstrap_done = True print(f"[mock] bootstrap queued {queued} tasks") # ---------------------------------------------------------------- auth @app.post("/api/v1/auth/login") def auth_login(): body = request.get_json(silent=True) or {} log("POST /auth/login", body) with _lock: bootstrap_tasks() return ok({ "access_token": "mock-access-token", "refresh_token": "mock-refresh-token", "expires_in": 3600, }) @app.post("/api/v1/auth/refresh_token") def auth_refresh(): return ok({ "access_token": "mock-access-token", "refresh_token": "mock-refresh-token", "expires_in": 3600, }) # ---------------------------------------------------------------- desktop @app.get("/api/v1/desktop/") def desktop_list(): return ok([{ "id": _desktop_id, "profile_id": _profile_id, "hidden": False, "name": "mock-desktop", }]) @app.post("/api/v1/desktop/") def desktop_create(): body = request.get_json(silent=True) or {} log("POST /desktop/", body) return ok({"id": _desktop_id, "profile_id": _profile_id}) # ---------------------------------------------------------------- device @app.get("/api/v1/device/") def device_list(desktop_id): return ok(_devices_by_desktop.get(desktop_id, [])) @app.post("/api/v1/device/") def device_create(): body = request.get_json(silent=True) or {} log("POST /device/", body) api_id = str(uuid.uuid4()) dev = { "id": api_id, "name": body.get("name", "unknown"), "status": body.get("status", "OFFLINE"), "screen_width": body.get("screen_width", 0), "screen_height": body.get("screen_height", 0), "battery": body.get("battery", 0), } _devices_by_desktop.setdefault(_desktop_id, []).append(dev) return ok(dev) @app.patch("/api/v1/device/") def device_patch(api_id): return ok({"id": api_id}) @app.delete("/api/v1/device/") def device_delete(api_id): return ok({"id": api_id}) @app.post("/api/v1/device/add_screenshot/") def device_screenshot(api_id): return ok({"id": api_id}) # ---------------------------------------------------------------- profile @app.get("/api/v1/profile/current_profile") def current_profile(): return ok({ "id": _profile_id, "bank_profiles": _bank_profiles, }) @app.post("/api/v1/profile/bank_profile") def bank_profile_create(): body = request.get_json(silent=True) or {} bp = dict(body) bp["id"] = bp.get("id") or str(uuid.uuid4()) bp.setdefault("materials", []) _bank_profiles.append(bp) return ok(bp) @app.patch("/api/v1/profile/bank_profile//") def bank_profile_toggle(bp_id, state): return ok({"id": bp_id, "state": state}) @app.post("/api/v1/profile/material") def material_create(): body = request.get_json(silent=True) or {} mat = dict(body) mat["id"] = mat.get("id") or str(uuid.uuid4()) return ok(mat) @app.patch("/api/v1/profile/material//") def material_toggle(mat_id, state): return ok({"id": mat_id, "state": state}) # ---------------------------------------------------------------- tasks @app.get("/api/v1/task/get_tasks") def get_tasks(): """Deliver at most 1 task per material per poll. A material is blocked until the previous task's result arrives via POST /task/task_result. This prevents the 5-min expire window in EventHandler from killing tasks that were just waiting in queue. """ material_ids = request.args.getlist("materials_id") with _lock: if not _task_queue: return ok([]) delivered = [] delivered_mats = set() kept = deque() while _task_queue: t = _task_queue.popleft() mid = t.get("material_id") # Filter by requested materials if material_ids and mid not in material_ids: kept.append(t) continue # Skip if this material already has an in-flight task if mid in _in_flight or mid in delivered_mats: kept.append(t) continue delivered.append(t) delivered_mats.add(mid) _in_flight.add(mid) _task_queue.extend(kept) if delivered: log("GET /task/get_tasks -> delivered", [{"type": t.get("type"), "material_id": t.get("material_id")} for t in delivered]) return ok(delivered) @app.post("/api/v1/task/task_result") def task_result(): body = request.get_json(silent=True) or {} mid = body.get("material_id") log("POST /task/task_result", {"type": body.get("type"), "material_id": mid}) with _lock: # Unblock this material for the next task delivery _in_flight.discard(mid) key = (mid, body.get("type")) idx = None if _pending_by_key[key]: idx = _pending_by_key[key].popleft() if idx is not None: _injected[idx]["result"] = body _injected[idx]["result_at"] = time.time() _injected[idx]["status"] = "completed" else: _injected.append({ "id": str(uuid.uuid4()), "task": None, "injected_at": None, "result": body, "result_at": time.time(), "status": "unsolicited", }) return ok({}) # ---------------------------------------------------------------- monitoring @app.post("/monitoring/log") def monitoring_log(): return ok({}) # ---------------------------------------------------------------- admin @app.post("/_admin/inject") def admin_inject(): body = request.get_json(silent=True) if body is None: return jsonify({"error": "json body required"}), 400 tasks = body if isinstance(body, list) else [body] with _lock: for t in tasks: enqueue_task(t) log("_admin/inject", {"queued": len(tasks)}) return ok({"queued": len(tasks)}) @app.post("/_admin/seed_profile") def admin_seed_profile(): body = request.get_json(silent=True) or {} global _bank_profiles _bank_profiles = body.get("bank_profiles", []) return ok({"bank_profiles": len(_bank_profiles)}) @app.post("/_admin/seed_devices") def admin_seed_devices(): body = request.get_json(silent=True) or [] _devices_by_desktop[_desktop_id] = body return ok({"devices": len(body)}) @app.post("/_admin/bootstrap") def admin_bootstrap(): """Re-run bootstrap manually (useful if ARC was already logged in).""" global _bootstrap_done with _lock: _bootstrap_done = False bootstrap_tasks() return ok({"queued": sum(1 for e in _injected if e["status"] == "pending")}) @app.get("/_admin/report") def admin_report(): """Full task ↔ result pairing report.""" with _lock: summary = { "total": len(_injected), "pending": sum(1 for e in _injected if e["status"] == "pending"), "completed": sum(1 for e in _injected if e["status"] == "completed"), "unsolicited": sum(1 for e in _injected if e["status"] == "unsolicited"), } return jsonify({ "summary": summary, "entries": _injected, }) @app.get("/_admin/report/pending") def admin_report_pending(): with _lock: return jsonify([e for e in _injected if e["status"] == "pending"]) def _snapshot_entries_and_summary(): entries = list(_injected) summary = { "total": len(entries), "pending": sum(1 for e in entries if e["status"] == "pending"), "completed": sum(1 for e in entries if e["status"] == "completed"), "unsolicited": sum(1 for e in entries if e["status"] == "unsolicited"), } return entries, summary @app.get("/_admin/report/html") def admin_report_html(): """Pretty HTML view of task ↔ result pairs. Auto-refreshes via JS.""" with _lock: entries, summary = _snapshot_entries_and_summary() return Response(render_report_html(entries, summary), mimetype="text/html") @app.get("/_admin/report/fragment") def admin_report_fragment(): """JSON fragment for JS auto-refresh: rendered body HTML + summary counts.""" with _lock: entries, summary = _snapshot_entries_and_summary() # Render just the grouped body (without the full page shell) from collections import defaultdict as _dd import html as _html groups = _dd(list) for e in entries: mid = (e.get("task") or {}).get("material_id") label = _device_label_by_material.get(mid) or ( "(без устройства)" if mid is None else f"{mid[:8]}…" ) groups[label].append(e) blocks = [] for label in sorted(groups.keys()): gentries = groups[label] g_done = sum(1 for e in gentries if e["status"] == "completed") g_pend = sum(1 for e in gentries if e["status"] == "pending") rows_html = "\n".join(render_entry_html(e) for e in gentries) blocks.append( f'
' f'

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

{rows_html}
' ) body = "\n".join(blocks) or '
Отчёт пуст
' return jsonify({"html": body, "summary": summary}) def _fmt_ts(ts): if not ts: return "—" return time.strftime("%H:%M:%S", time.localtime(ts)) def _esc(s: str) -> str: return (s.replace("&", "&").replace("<", "<") .replace(">", ">").replace('"', """)) def render_entry_html(e) -> str: import html as _html status = e["status"] task = e.get("task") or {} result = e.get("result") ttype = _html.escape(task.get("type", "?")) mid = _html.escape((task.get("material_id") or "")[:8]) src = _html.escape(task.get("_source", "")) bank = _html.escape(task.get("bank_name", "")) injected_at = _fmt_ts(e.get("injected_at")) result_at = _fmt_ts(e.get("result_at")) def json_block(obj): if obj is None: return '' text = json.dumps(obj, ensure_ascii=False, indent=2) return f'
{_html.escape(text)}
' result_summary = "" if result and isinstance(result.get("data"), dict): d = result["data"] if d.get("status") == "error": result_summary = f'
ERROR: {_html.escape(str(d.get("description", "")))}
' elif result and isinstance(result.get("data"), list): result_summary = f'
{len(result["data"])} items
' return f"""
{status} {ttype} {bank} {mid}… {src} inj {injected_at} → res {result_at}
Task JSON
{json_block(task)}
Result JSON
{result_summary} {json_block(result)}
""" def render_profile_section() -> str: """Render loaded bank profiles / materials as an HTML info block.""" import html as _html if not _bank_profiles: return '
Профиль не загружен
' # Group by device devices: dict = {} for bp in _bank_profiles: dev_id = bp.get("device_id", "?") label = bp.get("device_label") or (dev_id[:8] + "…") if dev_id not in devices: devices[dev_id] = {"label": label, "profiles": []} devices[dev_id]["profiles"].append(bp) device_blocks = [] for dev_id, dev in devices.items(): label = _html.escape(dev["label"]) dev_mats = sum(len(bp.get("materials", [])) for bp in dev["profiles"]) bp_cards = [] for bp in dev["profiles"]: bank = _html.escape(bp.get("bank_name", "?")) first = bp.get("first_name") or "" last = bp.get("last_name") or "" name = _html.escape(f"{first} {last}".strip() or "—") phone = _html.escape(bp.get("phone_number") or "—") mats = bp.get("materials", []) mat_rows = [] for m in mats: mid = _html.escape(m.get("id", "?")[:12]) card = _html.escape(m.get("card_number") or "—") cur = m.get("currency", "") state = _html.escape(m.get("state", "?")) # Count pool tasks for this material pool_n = len(_pool_by_material.get(m.get("id", ""), [])) pool_badge = (f'{pool_n}' if pool_n > 0 else '0') mat_rows.append( f'{mid}…' f'{card}{cur}' f'{state}' f'{pool_badge}' ) mat_table = "" if mat_rows: mat_table = ( '' '' '' + "".join(mat_rows) + '
Material IDКартаВалютаСтатусПул
' ) bp_cards.append( f'
' f'
' f'{bank}' f'{name}' f'{phone}' f'
' f'{mat_table}' f'
' ) device_blocks.append( f'
' f'
' f'{label}' f'{len(dev["profiles"])} профилей, {dev_mats} материалов' f'
' f'
{"".join(bp_cards)}
' f'
' ) pool_count = sum(len(v) for v in _pool_by_material.values()) pool_info = (f'{pool_count} задач в пуле' if pool_count > 0 else 'пул пуст') total_mats = sum(len(bp.get("materials", [])) for bp in _bank_profiles) return ( '
' 'Загруженные данные ' f'({len(devices)} устройств, ' f'{len(_bank_profiles)} профилей, ' f'{total_mats} материалов, ' f'{pool_info})' + "".join(device_blocks) + '
' ) def render_report_html(entries, summary) -> str: import html as _html # Group by device label groups: dict = defaultdict(list) for e in entries: mid = (e.get("task") or {}).get("material_id") label = _device_label_by_material.get(mid) or ( "(без устройства)" if mid is None else f"{mid[:8]}…" ) groups[label].append(e) group_blocks = [] for label in sorted(groups.keys()): gentries = groups[label] g_total = len(gentries) g_done = sum(1 for e in gentries if e["status"] == "completed") g_pend = sum(1 for e in gentries if e["status"] == "pending") rows_html = "\n".join(render_entry_html(e) for e in gentries) group_blocks.append(f"""

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

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

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

{summary['total']}
всего
{summary['completed']}
выполнено
{summary['pending']}
в очереди
{summary['unsolicited']}
незапрошенные
{render_profile_section()}
{body_rows}
""" @app.post("/_admin/reset") def admin_reset(): global _bootstrap_done with _lock: _task_queue.clear() _injected.clear() _pending_by_key.clear() _in_flight.clear() _bootstrap_done = False return ok({}) @app.get("/_admin/state") def admin_state(): with _lock: return jsonify({ "queued_tasks": len(_task_queue), "injected": len(_injected), "completed": sum(1 for e in _injected if e["status"] == "completed"), "pending": sum(1 for e in _injected if e["status"] == "pending"), "bank_profiles": len(_bank_profiles), "pool_materials": len(_pool_by_material), "bootstrap_done": _bootstrap_done, }) if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--host", default="127.0.0.1") parser.add_argument("--port", type=int, default=8888) parser.add_argument("--ft-per-material", type=int, default=3, help="How many random FETCH_TRANSACTIONS to bootstrap per material") parser.add_argument("--seed", type=int, default=None, help="RNG seed for reproducible bootstrap picks") args = parser.parse_args() _bootstrap_ft_count = args.ft_per_material if args.seed is not None: random.seed(args.seed) # Fresh start: drop any leftover state from a previous run in this process. _task_queue.clear() _injected.clear() _pending_by_key.clear() _in_flight.clear() _bootstrap_done = False print("[mock] state cleared — starting fresh") load_default_profile() load_pool() app.run(host=args.host, port=args.port, threaded=True)