diff --git a/docker/build-images.sh b/docker/build-images.sh index 284ab97..ceccb31 100644 --- a/docker/build-images.sh +++ b/docker/build-images.sh @@ -13,4 +13,4 @@ docker build -t logrotate:latest -f docker/logrotate/Dockerfile docker/logrotate # Admin UI – из соседней папки EventHubFrontAdmin docker build -t eventhub-admin-ui:latest -f ../EventHubFrontAdmin/Dockerfile ../EventHubFrontAdmin -docker build -t eventhub-emulator -f test/emulate_users/Dockerfile . \ No newline at end of file +docker build -t bot-emulator-users:latest -f test/emulate_users/Dockerfile . \ No newline at end of file diff --git a/docker/docker-compose.swarm.yml b/docker/docker-compose.swarm.yml index 47aaa5b..326e793 100644 --- a/docker/docker-compose.swarm.yml +++ b/docker/docker-compose.swarm.yml @@ -74,8 +74,6 @@ services: - type: volume source: eventhub-data target: /app/data -# volume: -# nocopy: true deploy: replicas: 1 endpoint_mode: dnsrr @@ -89,6 +87,8 @@ services: image: eventhub-admin-ui:latest networks: - eventhub-net + depends_on: + - eventhub deploy: replicas: 1 restart_policy: @@ -196,9 +196,13 @@ services: - MAX_DELAY=3.0 - LOOP_FOREVER=true - BOT_REFRESH_INTERVAL=300 + - TICKET_CHANCE=0.07 + - STARTUP_DELAY=30 - DEBUG=false networks: - eventhub-net + depends_on: + - eventhub deploy: mode: replicated replicas: 1 diff --git a/src/handlers/admin/admin_handler_users.erl b/src/handlers/admin/admin_handler_users.erl index 6467113..027506d 100644 --- a/src/handlers/admin/admin_handler_users.erl +++ b/src/handlers/admin/admin_handler_users.erl @@ -101,8 +101,16 @@ list_users(Req) -> -spec parse_user_filters(cowboy_req:req()) -> map(). parse_user_filters(Req) -> Qs = cowboy_req:parse_qs(Req), + RoleBin = proplists:get_value(<<"role">>, Qs), + StatusBin = proplists:get_value(<<"status">>, Qs), #{ - role => proplists:get_value(<<"role">>, Qs), - status => proplists:get_value(<<"status">>, Qs), + role => convert_to_atom(RoleBin), + status => convert_to_atom(StatusBin), q => proplists:get_value(<<"q">>, Qs) - }. \ No newline at end of file + }. + +convert_to_atom(undefined) -> undefined; +convert_to_atom(Bin) when is_binary(Bin) -> + try binary_to_existing_atom(Bin, utf8) + catch error:badarg -> Bin + end. \ No newline at end of file diff --git a/test/api/admins/admin_users_tests.erl b/test/api/admins/admin_users_tests.erl index 6526c8d..887ba29 100644 --- a/test/api/admins/admin_users_tests.erl +++ b/test/api/admins/admin_users_tests.erl @@ -71,6 +71,7 @@ test() -> % ── Фильтрация ── test_filter_users_by_role(Token), + test_filter_users_by_role_bot(Token), % новый тест test_filter_users_by_status(Token), test_filter_combined_role_status(Token), test_filter_users_by_search(Token, User2Email), @@ -149,6 +150,23 @@ test_filter_users_by_role(Token) -> [?assertEqual(<<"bot">>, maps:get(<<"role">>, U)) || U <- Users], ct:pal(" OK: ~p bot users", [length(Users)]). +%% @doc Фильтрация по роли bot с отдельным пользователем (гарантирует наличие). +-spec test_filter_users_by_role_bot(binary()) -> ok. +test_filter_users_by_role_bot(Token) -> + ct:pal(" TEST: Filter users by role=bot (dedicated user)"), + % Создаём пользователя и сразу назначаем ему роль bot + Email = api_test_runner:unique_email(<<"botfilter">>), + UserTok = api_test_runner:register_and_login(Email, <<"testpass">>), + #{<<"id">> := BotId} = api_test_runner:client_get(<<"/v1/user/me">>, UserTok), + Path = <<"/v1/admin/users/", BotId/binary>>, + api_test_runner:admin_put(Path, Token, #{<<"role">> => <<"bot">>}), + % Теперь фильтр должен вернуть хотя бы этого пользователя + Users = api_test_runner:admin_get(<<"/v1/admin/users?role=bot">>, Token), + ?assert(is_list(Users)), + ?assert(length(Users) >= 1), + [?assertEqual(<<"bot">>, maps:get(<<"role">>, U)) || U <- Users], + ct:pal(" OK: ~p bot users", [length(Users)]). + %% @doc Фильтрация по статусу. -spec test_filter_users_by_status(binary()) -> ok. test_filter_users_by_status(Token) -> diff --git a/test/emulate_users/emulate_users.py b/test/emulate_users/emulate_users.py index e7c8723..2b7011e 100644 --- a/test/emulate_users/emulate_users.py +++ b/test/emulate_users/emulate_users.py @@ -1,6 +1,17 @@ #!/usr/bin/env python3 +""" +Эмулятор пользователей для EventHub. + +Скрипт устойчив к отсутствию API при старте и во время работы – +никогда не падает, переходит в режим ожидания и автоматически +восстанавливает работу после появления сервера. +""" + import os, time, random, requests, logging, json +# ------------------------------------------------------------------- +# Настройки (можно переопределять через переменные окружения) +# ------------------------------------------------------------------- DEBUG = os.getenv("DEBUG", "true").lower() == "true" VERIFY_SSL = os.getenv("VERIFY_SSL", "false").lower() == "true" @@ -13,49 +24,55 @@ MIN_DELAY = float(os.getenv("MIN_DELAY", "0.5")) MAX_DELAY = float(os.getenv("MAX_DELAY", "3.0")) LOOP_FOREVER = os.getenv("LOOP_FOREVER", "true").lower() == "true" BOT_REFRESH_INTERVAL = int(os.getenv("BOT_REFRESH_INTERVAL", "300")) +TICKET_CHANCE = float(os.getenv("TICKET_CHANCE", "0.07")) +STARTUP_DELAY = int(os.getenv("STARTUP_DELAY", "30")) +# ------------------------------------------------------------------- +# Логирование +# ------------------------------------------------------------------- if not DEBUG: logging.basicConfig(level=logging.INFO, format='%(asctime)s [%(levelname)s] %(message)s') else: logging.basicConfig(level=logging.DEBUG, format='%(asctime)s [%(levelname)s] %(message)s') logger = logging.getLogger("emulator") -bots_cache = [] +# ------------------------------------------------------------------- +# Состояние +# ------------------------------------------------------------------- +bots_cache = [] # [{"email": ..., "token": ...}] admin_token = None last_bot_refresh = 0 -def log_request(method, url, headers=None, json_data=None): - if not DEBUG: - return - logger.debug(f"--> {method} {url}") - if headers: - # Не выводим полный Authorization, чтобы не светить токен - safe_headers = {k: v if k != "Authorization" else v[:20] + "..." for k, v in headers.items()} - logger.debug(f" Headers: {safe_headers}") - if json_data: - logger.debug(f" Body: {json.dumps(json_data)}") - -def log_response(resp): - if not DEBUG: - return - logger.debug(f"<-- {resp.status_code} {resp.url}") - try: - body = resp.json() - body_str = json.dumps(body, indent=2) - except: - body_str = resp.text[:200] - logger.debug(f" Response: {body_str}") - if resp.status_code not in (200, 201): - logger.warning(f" Unexpected status {resp.status_code}: {body_str}") - +# ------------------------------------------------------------------- +# Безопасный HTTP-запрос +# ------------------------------------------------------------------- def request(method, url, **kwargs): - if not VERIFY_SSL: - kwargs["verify"] = False - log_request(method, url, headers=kwargs.get("headers"), json_data=kwargs.get("json")) - resp = requests.request(method, url, **kwargs) - log_response(resp) - return resp + """ + Абсолютно безопасный HTTP-запрос. + Возвращает объект ответа или None при любой ошибке. + """ + try: + if not VERIFY_SSL: + kwargs["verify"] = False + if DEBUG: + logger.debug(f"--> {method} {url}") + resp = requests.request(method, url, **kwargs) + if DEBUG: + logger.debug(f"<-- {resp.status_code} {resp.url}") + try: + body = resp.json() + body_str = json.dumps(body, indent=2) + except: + body_str = resp.text[:200] + logger.debug(f" Response: {body_str}") + return resp + except Exception as e: + logger.warning(f"Request failed ({method} {url}): {e}") + return None +# ------------------------------------------------------------------- +# Получение административного токена +# ------------------------------------------------------------------- def get_admin_token(): global admin_token if admin_token: @@ -63,37 +80,52 @@ def get_admin_token(): resp = request("POST", f"{ADMIN_API_HOST}/v1/admin/login", json={"email": ADMIN_EMAIL, "password": ADMIN_PASSWORD}, - headers={"Content-Type": "application/json"} - ) - resp.raise_for_status() - admin_token = resp.json()["token"] - logger.info("Admin token obtained") + headers={"Content-Type": "application/json"}) + if resp is None or resp.status_code != 200: + logger.warning("Failed to obtain admin token") + return None + admin_token = resp.json().get("token") + if admin_token: + logger.info("Admin token obtained") return admin_token +# ------------------------------------------------------------------- +# Получение списка email'ов ботов (с фильтром role=bot) +# ------------------------------------------------------------------- def fetch_bot_emails(): token = get_admin_token() + if not token: + return [] resp = request("GET", - f"{ADMIN_API_HOST}/v1/admin/users?limit=10000", - headers={"Authorization": f"Bearer {token}"} - ) - resp.raise_for_status() + f"{ADMIN_API_HOST}/v1/admin/users?role=bot&limit=10000", + headers={"Authorization": f"Bearer {token}"}) + if resp is None or resp.status_code != 200: + logger.warning("Failed to fetch bots") + return [] users = resp.json() - emails = [u["email"] for u in users if u.get("role") == "bot"] - logger.info(f"Fetched {len(emails)} bot emails (total users: {len(users)})") - return emails + return [u["email"] for u in users if u.get("role") == "bot"] +# ------------------------------------------------------------------- +# Логин одного бота +# ------------------------------------------------------------------- def login_bot(email): resp = request("POST", f"{CLIENT_API_HOST}/v1/login", json={"email": email, "password": BOT_PASSWORD}, - headers={"Content-Type": "application/json"} - ) - resp.raise_for_status() + headers={"Content-Type": "application/json"}) + if resp is None or resp.status_code != 200: + raise RuntimeError(f"Bot login failed for {email}") return resp.json()["token"] +# ------------------------------------------------------------------- +# Обновление кеша ботов +# ------------------------------------------------------------------- def refresh_bot_cache(): global bots_cache, last_bot_refresh emails = fetch_bot_emails() + if not emails: + logger.warning("No bot emails fetched, keeping existing cache") + return new_cache = [] for email in emails: try: @@ -101,10 +133,16 @@ def refresh_bot_cache(): new_cache.append({"email": email, "token": token}) except Exception as e: logger.warning(f"Could not login bot {email}: {e}") - bots_cache = new_cache - last_bot_refresh = time.time() - logger.info(f"Bot cache refreshed, {len(bots_cache)} bots ready") + if new_cache: + bots_cache = new_cache + last_bot_refresh = time.time() + logger.info(f"Bot cache refreshed, {len(bots_cache)} bots ready") + else: + logger.warning("No bots could be logged in, keeping existing cache") +# ------------------------------------------------------------------- +# Получение случайного бота (с автоматическим обновлением кеша) +# ------------------------------------------------------------------- def random_bot(): global bots_cache, last_bot_refresh while True: @@ -115,9 +153,15 @@ def random_bot(): logger.warning("No bots available, retrying in 10 seconds...") time.sleep(10) +# ------------------------------------------------------------------- +# Случайная задержка между действиями +# ------------------------------------------------------------------- def random_sleep(): time.sleep(random.uniform(MIN_DELAY, MAX_DELAY)) +# ------------------------------------------------------------------- +# Выполнение случайного действия от имени бота +# ------------------------------------------------------------------- def do_random_action(bot): action = random.randint(1, 14) headers = {"Authorization": f"Bearer {bot['token']}", "Content-Type": "application/json"} @@ -125,13 +169,13 @@ def do_random_action(bot): try: if action == 1: resp = request("POST", f"{base}/v1/calendars", json={"title": f"Cal-{random.randint(1,1000)}", "confirmation": "auto"}, headers=headers) - if resp.status_code == 201: + if resp and resp.status_code == 201: logger.debug(f"Bot {bot['email']} created calendar {resp.json()['id']}") elif action == 2: request("GET", f"{base}/v1/calendars", headers=headers) elif action == 3: resp_cal = request("GET", f"{base}/v1/calendars", headers=headers) - if resp_cal.status_code == 200 and resp_cal.json(): + if resp_cal and resp_cal.status_code == 200 and resp_cal.json(): cal = random.choice(resp_cal.json()) request("POST", f"{base}/v1/calendars/{cal['id']}/events", json={"title": f"Event-{random.randint(1,1000)}", "start_time": "2027-01-01T10:00:00Z", "duration": 60}, @@ -140,31 +184,34 @@ def do_random_action(bot): request("GET", f"{base}/v1/search?q=test&limit=5", headers=headers) elif action == 5: resp_ev = request("GET", f"{base}/v1/search?type=event&limit=20", headers=headers) - if resp_ev.status_code == 200 and resp_ev.json().get("results"): - events = resp_ev.json()["results"].get("events", []) + if resp_ev and resp_ev.status_code == 200: + results = resp_ev.json().get("results", {}) + events = results.get("events", []) if events: ev = random.choice(events) request("POST", f"{base}/v1/events/{ev['id']}/bookings", json={}, headers=headers) elif action == 6: resp_book = request("GET", f"{base}/v1/user/bookings", headers=headers) - if resp_book.status_code == 200 and resp_book.json(): + if resp_book and resp_book.status_code == 200 and resp_book.json(): booking = random.choice(resp_book.json()) request("POST", f"{base}/v1/reviews", json={"target_type": "event", "target_id": booking["event_id"], "rating": random.randint(1,5), "comment": "Nice!"}, headers=headers) elif action == 7: resp_ev = request("GET", f"{base}/v1/search?type=event&limit=20", headers=headers) - if resp_ev.status_code == 200 and resp_ev.json().get("results"): - events = resp_ev.json()["results"].get("events", []) + if resp_ev and resp_ev.status_code == 200: + results = resp_ev.json().get("results", {}) + events = results.get("events", []) if events: ev = random.choice(events) request("POST", f"{base}/v1/reports", json={"target_type": "event", "target_id": ev["id"], "reason": "Test"}, headers=headers) elif action == 8: - request("POST", f"{base}/v1/tickets", - json={"error_message": "Emulated error", "stacktrace": "line 1"}, - headers=headers) + if random.random() < TICKET_CHANCE: + request("POST", f"{base}/v1/tickets", + json={"error_message": "Emulated error", "stacktrace": "line 1"}, + headers=headers) elif action == 9: request("POST", f"{base}/v1/subscription", json={"action": "start_trial"}, headers=headers) elif action == 10: @@ -175,7 +222,7 @@ def do_random_action(bot): request("GET", f"{base}/v1/user/reviews", headers=headers) elif action == 13: resp_cal = request("GET", f"{base}/v1/calendars", headers=headers) - if resp_cal.status_code == 200 and resp_cal.json(): + if resp_cal and resp_cal.status_code == 200 and resp_cal.json(): cal = random.choice(resp_cal.json()) request("GET", f"{base}/v1/calendars/{cal['id']}/view?month=2026-06", headers=headers) elif action == 14: @@ -183,12 +230,26 @@ def do_random_action(bot): except Exception as e: logger.error(f"Action {action} failed for {bot['email']}: {e}") +# ------------------------------------------------------------------- +# Главный цикл +# ------------------------------------------------------------------- def main(): - logger.info("Starting user emulation") - refresh_bot_cache() + logger.info(f"Starting user emulation (startup delay: {STARTUP_DELAY}s)") + time.sleep(STARTUP_DELAY) + # Бесконечно ждём, пока не появятся боты + while True: + refresh_bot_cache() + if bots_cache: + break + logger.info("Waiting 10 seconds before retry...") + time.sleep(10) + while LOOP_FOREVER: - bot = random_bot() - do_random_action(bot) + try: + bot = random_bot() + do_random_action(bot) + except Exception as e: + logger.error(f"Unexpected error in main loop: {e}") random_sleep() logger.info("Emulation finished")