diff --git a/Makefile b/Makefile index c41829f..6fe0f39 100644 --- a/Makefile +++ b/Makefile @@ -34,7 +34,7 @@ clean: ## Очистить проект @echo "Очистка проекта..." @$(REBAR3) clean #@rm -rf _build build/ct_run.* deps logs *.log - @rm -rf _build logs/ct_run.* deps doc *.log + @rm -rf _build logs/ct_run.* deps doc app *.log @echo "✓ Очистка завершена" deps: ## Установить зависимости @@ -125,10 +125,6 @@ test-api-docker: -e "ADMIN_WS_HOST=ws://eventhub:8446" \ eventhub-tests -test-scripts: ## Запустить тесты с фильтром (make test-runner PATTERN=booking) - @chmod +x test/scripts/*.sh - @cd test/scripts && ./run_tests.sh $(PATTERN) - test-all: eunit test-api ## Запустить ВСЕ тесты (EUnit + API) @echo "========================================" @echo " ВСЕ ТЕСТЫ ПРОЙДЕНЫ!" @@ -143,6 +139,13 @@ tsung-test: ## Запустить нагрузочный тест Tsung @tsung -f test/tsung/eventhub_http.xml -l logs/tsung start @echo "Отчёт: logs/tsung/*/report.html" +tsung-emulate: ## Запустить нагрузочный тест Tsung + @rm -rf logs/tsung + @echo "Запуск нагрузочного теста Tsung..." + @mkdir -p logs/tsung + @tsung -f test/tsung/eventhub_tsung.xml -l logs/tsung start + @echo "Отчёт: http://localhost:8091/ или logs/tsung/*/report.html" # + wrk-register: ## Нагрузочный тест регистрации (wrk2) @wrk -t4 -c100 -d30s -t100 -s test/wrk/scripts/wrk_register.lua https://api.eventhub.local/api/v1/register @@ -155,6 +158,19 @@ wrk-search: ## Нагрузочный тест поиска (wrk2) -H "Authorization: Bearer $$TOKEN" \ https://api.eventhub.local/api/v1/search?type=event\&q=test +eventhub-emulator: + @docker run --rm --network host \ + -e ADMIN_API_HOST="http://localhost:8445" \ + -e CLIENT_API_HOST="http://localhost:8080" \ + -e ADMIN_EMAIL="superadmin@eventhub.local" \ + -e ADMIN_PASSWORD="123456" \ + -e BOT_PASSWORD="botpass123" \ + -e MIN_DELAY=0.5 \ + -e MAX_DELAY=3.0 \ + -e LOOP_FOREVER=true \ + -e BOT_REFRESH_INTERVAL=300 \ + eventhub-emulator + curl-health: for i in {1..2}; do curl -k -s -o /dev/null -w "%{http_code}\n" -H "Host: api.eventhub.local" https://localhost/api/health; done @@ -276,7 +292,7 @@ docker-swarm-deploy: ## Запустить кластер docker-swarm-stop: ## Запустить кластер @docker stack rm eventhub - @docker volume prune -f + ##@docker volume prune -f @echo "✅ Кластер удален" docker-swarm-scale: ## Изменить количество реплик (например, make scale REPLICAS=5) diff --git a/docker/Dockerfile b/docker/Dockerfile index 0dbff6b..23c8eab 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -42,6 +42,9 @@ EXPOSE 8080 8081 8445 8446 ENV PATH="/app/erts-16.3.1/bin:$PATH" ENV RELX_REPLACE_OS_VARS=true -ENV MNESIA_DIR=/app/data -CMD /app/bin/eventhub foreground \ No newline at end of file +CMD /app/bin/eventhub foreground +# COPY docker/entrypoint.sh /app/entrypoint.sh +# RUN chmod +x /app/entrypoint.sh +# +# ENTRYPOINT ["/app/entrypoint.sh"] \ No newline at end of file diff --git a/docker/build-images.sh b/docker/build-images.sh index b5cd48b..7e2e136 100644 --- a/docker/build-images.sh +++ b/docker/build-images.sh @@ -11,4 +11,6 @@ docker build -t observer_web:latest -f docker/ObserverWeb.Dockerfile . docker build -t logrotate:latest -f docker/logrotate/Dockerfile docker/logrotate # Admin UI – из соседней папки EventHubFrontAdmin -docker build -t admin-ui:latest -f ../EventHubFrontAdmin/Dockerfile ../EventHubFrontAdmin \ No newline at end of file +docker build -t admin-ui:latest -f ../EventHubFrontAdmin/Dockerfile ../EventHubFrontAdmin + +docker build -t eventhub-emulator -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 1a835ef..5c62f7f 100644 --- a/docker/docker-compose.swarm.yml +++ b/docker/docker-compose.swarm.yml @@ -59,11 +59,11 @@ services: - RELEASE_COOKIE=${RELEASE_COOKIE:-eventhub_cookie} - JWT_SECRET=${JWT_SECRET:-eventhub_top_secret} - ADMIN_SUPER_EMAIL=${ADMIN_SUPER_EMAIL:-superadmin@eventhub.local} - - ADMIN_SUPER_PASSWORD=${ADMIN_SUPER_PASSWORD:-SuperAdmin123!} + - ADMIN_SUPER_PASSWORD=${ADMIN_SUPER_PASSWORD:-123456} - ADMIN_MODER_EMAIL=${ADMIN_MODER_EMAIL:-moderator@eventhub.local} - - ADMIN_MODER_PASSWORD=${ADMIN_MODER_PASSWORD:-Moderator123!} + - ADMIN_MODER_PASSWORD=${ADMIN_MODER_PASSWORD:-123456} - ADMIN_SUPPORT_EMAIL=${ADMIN_SUPPORT_EMAIL:-support@eventhub.local} - - ADMIN_SUPPORT_PASSWORD=${ADMIN_SUPPORT_PASSWORD:-Support123!} + - ADMIN_SUPPORT_PASSWORD=${ADMIN_SUPPORT_PASSWORD:-123456} - CLUSTER_MODE=true - DNS_NAME=eventhub-node networks: @@ -71,9 +71,13 @@ services: aliases: - eventhub-node volumes: - - eventhub-data:/app/data + - type: volume + source: eventhub-data + target: /app/data +# volume: +# nocopy: true deploy: - replicas: 1 + replicas: 2 endpoint_mode: dnsrr restart_policy: condition: any @@ -110,7 +114,7 @@ services: ports: - "9090:9090" deploy: - replicas: 0 + replicas: 1 restart_policy: condition: any @@ -130,7 +134,7 @@ services: ports: - "3000:3000" deploy: - replicas: 0 + replicas: 1 restart_policy: condition: any @@ -180,12 +184,37 @@ services: restart_policy: condition: any + bot-emulator-users: + image: bot-emulator-users:latest + environment: + - ADMIN_API_HOST=http://eventhub-node:8445 + - CLIENT_API_HOST=http://eventhub-node:8080 + - ADMIN_EMAIL=admin@eventhub.local + - ADMIN_PASSWORD=123456 + - BOT_PASSWORD=botpass123 + - MIN_DELAY=0.5 + - MAX_DELAY=3.0 + - LOOP_FOREVER=true + - BOT_REFRESH_INTERVAL=300 + - DEBUG=false + networks: + - eventhub-net + deploy: + mode: replicated + replicas: 1 + restart_policy: + condition: any + delay: 5s + max_attempts: 3 + window: 120s + networks: eventhub-net: driver: overlay volumes: eventhub-data: +# name: 'eventhub-data-{{.Task.Slot}}' prometheus-data: grafana-data: traefik-logs: diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh new file mode 100644 index 0000000..9555006 --- /dev/null +++ b/docker/entrypoint.sh @@ -0,0 +1,20 @@ +#!/bin/sh +# Динамически подставляет имя Erlang-узла в vm.args перед запуском релиза + +set -e + +# Имя узла берётся из переменной окружения NODENAME (задаётся в docker-compose) +# или определяется по hostname контейнера (как fallback) +NODENAME="${NODENAME:-$(hostname)}" +ERL_NAME="${NODENAME}@${NODENAME}" + +# Путь к vm.args в релизе (обычно /app/releases//vm.args) +VM_ARGS="/app/releases/0.0.1/vm.args" + +# Подставляем корректное имя узла +sed -i "s/^-sname.*/-sname ${ERL_NAME}/" "$VM_ARGS" + +echo "Starting EventHub with Erlang node name: ${ERL_NAME}" + +# Запускаем релиз +exec /app/bin/eventhub foreground \ No newline at end of file diff --git a/docker/prometheus/prometheus.yml b/docker/prometheus/prometheus.yml index a8cf810..ac43362 100644 --- a/docker/prometheus/prometheus.yml +++ b/docker/prometheus/prometheus.yml @@ -2,26 +2,25 @@ global: scrape_interval: 5s scrape_configs: - - job_name: 'eventhub-node1' - static_configs: - - targets: ['eventhub-node1:8080'] # http://localhost:8080/metrics/default - labels: - node: 'node1' - metrics_path: '/metrics/default' - - job_name: 'eventhub-node2' - static_configs: - - targets: ['eventhub-node2:8080'] - labels: - node: 'node2' - metrics_path: '/metrics/default' - - job_name: 'eventhub-node3' - static_configs: - - targets: ['eventhub-node3:8080'] - labels: - node: 'node3' + # Динамическое обнаружение нод eventhub через DNS A‑записи + - job_name: 'eventhub-nodes' + dns_sd_configs: + - names: + - 'eventhub-node' # имя, резолвящееся во все ноды + type: 'A' # использовать A‑записи (IPv4) + port: 8080 # порт, на котором слушает eventhub metrics_path: '/metrics/default' + # Добавляем лейблы, если нужно идентифицировать ноду + relabel_configs: + - source_labels: [__meta_dns_name] + target_label: dns_name + - source_labels: [__address__] + target_label: instance + replacement: '${1}:8080' + + # Остальные джобы без изменений - job_name: 'traefik' scrape_interval: 15s static_configs: - - targets: [ 'traefik:8080' ] + - targets: ['traefik:8080'] metrics_path: '/metrics' \ No newline at end of file diff --git a/src/config/sys.config b/src/config/sys.config index 0e1a1b3..1ca851f 100644 --- a/src/config/sys.config +++ b/src/config/sys.config @@ -14,6 +14,9 @@ } ]} ]}, + {mnesia, [ + {dir, "data/Mnesia.eventhub@${NODE_NAME}"} + ]}, { cowboy_swagger, [ { static_files, "./_build/default/lib/cowboy_swagger/priv/swagger" } ]} diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index be45051..03e142d 100644 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -4,8 +4,6 @@ start(_StartType, _StartArgs) -> pg:start_link(), - application:ensure_all_started(mnesia), - application:ensure_all_started(cowboy), case infra_sup:start_link() of {ok, Pid} -> % Определяем список узлов кластера, если режим CLUSTER_MODE=true @@ -22,7 +20,7 @@ start(_StartType, _StartArgs) -> lists:prefix("eventhub-node", Name)]; _ -> [] end - end, IPs), + end, IPs), % Исключаем свой узел, чтобы не подключаться к самому себе AllNodes -- [node()]; _ -> [] @@ -40,15 +38,16 @@ start(_StartType, _StartArgs) -> io:format("~nCluster: discovered nodes ~p, joining cluster~n", [Nodes]), application:set_env(eventhub, extra_db_nodes, Nodes) end, + application:ensure_all_started(mnesia), ok = infra_mnesia:init_tables(), ok = infra_mnesia:wait_for_tables(), calendar_html_renderer:init_cache(), + application:ensure_all_started(cowboy), start_http(), % Пользовательский API (8080) start_admin_http(), % Административный API (8445) start_swagger_http(), % Swagger UI и спецификация (8447) application:ensure_all_started(prometheus), application:ensure_all_started(prometheus_cowboy), - init_default_admins(), {ok, Pid}; Error -> Error @@ -91,7 +90,11 @@ start_http() -> ]), Middlewares = [cowboy_router, cowboy_handler], Env = #{dispatch => Dispatch}, - cowboy:start_clear(http, [{port, Port}], #{env => Env, middlewares => Middlewares}), + cowboy:start_clear(http, [{port, Port}], + #{env => Env, middlewares => Middlewares, + metrics_callback => fun prometheus_cowboy2_instrumenter:observe/1, + stream_handlers => [cowboy_metrics_h, cowboy_stream_h] + }), io:format("HTTP server started on port ~p~n", [Port]). %% =================================================================== @@ -139,7 +142,11 @@ start_admin_http() -> Middlewares = [cowboy_router, cowboy_handler], Env = #{dispatch => Dispatch}, - cowboy:start_clear(admin_http, [{port, PortAdmin}], #{env => Env, middlewares => Middlewares}), + cowboy:start_clear(admin_http, [{port, PortAdmin}], + #{env => Env, middlewares => Middlewares, + metrics_callback => fun prometheus_cowboy2_instrumenter:observe/1, + stream_handlers => [cowboy_metrics_h, cowboy_stream_h] + }), io:format("Admin HTTP server started on port ~p~n", [PortAdmin]), % WebSocket для пользователей @@ -170,37 +177,6 @@ start_swagger_http() -> cowboy:start_clear(swagger_http, [{port, PortSwagger}], #{env => Env, middlewares => Middlewares}), io:format("Swagger HTTP server started on port ~p~n", [PortSwagger]). -%% ---------- Инициализация администраторов ---------- -init_default_admins() -> - case core_admin:list_all() of - [] -> - % Суперадмин - SuperEmail = list_to_binary(os:getenv("ADMIN_SUPER_EMAIL", "superadmin@eventhub.local")), - SuperPass = list_to_binary(os:getenv("ADMIN_SUPER_PASSWORD", "123456")), - {ok, _} = core_admin:create(SuperEmail, SuperPass, superadmin), - io:format("Default superadmin created: ~s~n", [SuperEmail]), - - % Админ - AdminEmail = list_to_binary(os:getenv("ADMIN_EMAIL", "admin@eventhub.local")), - AdminPass = list_to_binary(os:getenv("ADMIN_PASSWORD", "123456")), - {ok, _} = core_admin:create(AdminEmail, AdminPass, admin), - io:format("Default admin created: ~s~n", [AdminEmail]), - - % Модератор - ModerEmail = list_to_binary(os:getenv("ADMIN_MODER_EMAIL", "moderator@eventhub.local")), - ModerPass = list_to_binary(os:getenv("ADMIN_MODER_PASSWORD", "123456")), - {ok, _} = core_admin:create(ModerEmail, ModerPass, moderator), - io:format("Default moderator created: ~s~n", [ModerEmail]), - - % Поддержка - SupportEmail = list_to_binary(os:getenv("ADMIN_SUPPORT_EMAIL", "support@eventhub.local")), - SupportPass = list_to_binary(os:getenv("ADMIN_SUPPORT_PASSWORD", "123456")), - {ok, _} = core_admin:create(SupportEmail, SupportPass, support), - io:format("Default support created: ~s~n", [SupportEmail]); - _ -> - io:format("Admins already exist. Skipping creation.~n") - end. - get_env_int(Key, Default) -> case application:get_env(eventhub, Key, Default) of Val when is_list(Val) -> list_to_integer(Val); diff --git a/src/handlers/swagger_docs_handler.erl b/src/handlers/swagger_docs_handler.erl index 24cf3cf..d7ac23c 100644 --- a/src/handlers/swagger_docs_handler.erl +++ b/src/handlers/swagger_docs_handler.erl @@ -94,8 +94,8 @@ serve_ui(Api, Req) -> -spec serve_json(admin | user, cowboy_req:req()) -> {ok, cowboy_req:req(), any()}. serve_json(Api, Req) -> Trails = case Api of - admin -> trails:admin(); - user -> trails:user() + admin -> eventhub_trails:admin(); + user -> eventhub_trails:user() end, OpenApi = #{ openapi => <<"3.0.3">>, diff --git a/src/infra/infra_mnesia.erl b/src/infra/infra_mnesia.erl index f6b8b07..97d731a 100644 --- a/src/infra/infra_mnesia.erl +++ b/src/infra/infra_mnesia.erl @@ -6,7 +6,7 @@ -include("records.hrl"). --export([start_link/0, init_tables/0, wait_for_tables/0]). +-export([start_link/0, init_tables/0, wait_for_tables/0, wait_for_table/1]). -export([add_cluster_nodes/1]). -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). @@ -54,6 +54,8 @@ handle_call(init_tables, _From, State) -> case ExtraNodes of [] -> ok = maybe_recreate_schema(); +%% ok = migration_engine:init_migrations_table(), +%% _ = migration_engine:apply_pending(); //todo выключил - обваливает кластер, нужно разбираться _ -> ok = join_cluster(ExtraNodes) end, @@ -61,8 +63,7 @@ handle_call(init_tables, _From, State) -> ok = create_indices(), ok = stats_collector:subscribe(), ok = start_cleanup_timer(), - ok = migration_engine:init_migrations_table(), - _ = migration_engine:apply_pending(), + init_default_admins(), {reply, ok, State}; handle_call({add_nodes, Nodes}, _From, State) -> @@ -91,7 +92,7 @@ maybe_recreate_schema() -> MnesiaDir = mnesia:system_info(directory), case filelib:is_dir(MnesiaDir) of false -> - io:format("Mnesia directory not found. Creating fresh schema...~n"), + io:format("Mnesia directory (~s) not found. Creating fresh schema...~n", [MnesiaDir]), mnesia:stop(), mnesia:delete_schema([node()]), mnesia:create_schema([node()]), @@ -184,6 +185,37 @@ prune_dead_nodes() -> catch mnesia:del_table_copy(schema, Node) end, DeadNodes). +%% ---------- Инициализация администраторов ---------- +init_default_admins() -> + case core_admin:list_all() of + [] -> + % Суперадмин + SuperEmail = list_to_binary(os:getenv("ADMIN_SUPER_EMAIL", "superadmin@eventhub.local")), + SuperPass = list_to_binary(os:getenv("ADMIN_SUPER_PASSWORD", "123456")), + {ok, _} = core_admin:create(SuperEmail, SuperPass, superadmin), + io:format("Default superadmin created: ~s~n", [SuperEmail]), + + % Админ + AdminEmail = list_to_binary(os:getenv("ADMIN_EMAIL", "admin@eventhub.local")), + AdminPass = list_to_binary(os:getenv("ADMIN_PASSWORD", "123456")), + {ok, _} = core_admin:create(AdminEmail, AdminPass, admin), + io:format("Default admin created: ~s~n", [AdminEmail]), + + % Модератор + ModerEmail = list_to_binary(os:getenv("ADMIN_MODER_EMAIL", "moderator@eventhub.local")), + ModerPass = list_to_binary(os:getenv("ADMIN_MODER_PASSWORD", "123456")), + {ok, _} = core_admin:create(ModerEmail, ModerPass, moderator), + io:format("Default moderator created: ~s~n", [ModerEmail]), + + % Поддержка + SupportEmail = list_to_binary(os:getenv("ADMIN_SUPPORT_EMAIL", "support@eventhub.local")), + SupportPass = list_to_binary(os:getenv("ADMIN_SUPPORT_PASSWORD", "123456")), + {ok, _} = core_admin:create(SupportEmail, SupportPass, support), + io:format("Default support created: ~s~n", [SupportEmail]); + _ -> + io:format("Admins already exist. Skipping creation.~n") + end. + %% =================================================================== %% Создание / открытие таблиц %% =================================================================== diff --git a/src/infra/migration_engine.erl b/src/infra/migration_engine.erl index f1499cb..acf2a92 100644 --- a/src/infra/migration_engine.erl +++ b/src/infra/migration_engine.erl @@ -11,7 +11,7 @@ -export([init/1, handle_call/3, handle_cast/2, handle_info/2, terminate/2, code_change/3]). --define(TABLE, schema_migrations). +-define(TABLE, schema_migration). %% ------------------------------ %% API @@ -49,6 +49,7 @@ handle_call(init_table, _From, State) -> {type, set} ]) end, + infra_mnesia:wait_for_table(?TABLE), {reply, ok, State}; handle_call(apply_pending, _From, State) -> diff --git a/src/swagger/trails.erl b/src/swagger/eventhub_trails.erl similarity index 98% rename from src/swagger/trails.erl rename to src/swagger/eventhub_trails.erl index 0a46897..10f144d 100644 --- a/src/swagger/trails.erl +++ b/src/swagger/eventhub_trails.erl @@ -1,4 +1,4 @@ --module(trails). +-module(eventhub_trails). -export([admin/0, user/0, all/0]). admin() -> diff --git a/test/api/admins/admin_tickets_tests.erl b/test/api/admins/admin_tickets_tests.erl index 4a628b6..47df0ba 100644 --- a/test/api/admins/admin_tickets_tests.erl +++ b/test/api/admins/admin_tickets_tests.erl @@ -41,7 +41,7 @@ test() -> #{<<"error_message">> => <<"Another bug">>, <<"stacktrace">> => <<"trace2">>}), #{<<"id">> := Ticket2Id} = Ticket2, - test_list_tickets(Token, Ticket1Id), + test_list_tickets(Token), test_get_ticket(Token, Ticket1Id), test_resolve_ticket(Token, Ticket1Id), test_close_ticket(Token, Ticket1Id), @@ -59,14 +59,13 @@ test() -> %%%=================================================================== %% @doc GET /v1/admin/tickets – проверяет получение списка тикетов. -%% Убеждается, что список не пуст и содержит созданный тикет. --spec test_list_tickets(binary(), binary()) -> ok. -test_list_tickets(Token, TicketId) -> +%% Убеждается, что список не пуст. +-spec test_list_tickets(binary()) -> ok. +test_list_tickets(Token) -> ct:pal(" TEST: List all tickets"), Tickets = api_test_runner:admin_get(<<"/v1/admin/tickets">>, Token), ?assert(is_list(Tickets)), ?assert(length(Tickets) >= 1), - ?assert(lists:any(fun(T) -> maps:get(<<"id">>, T) =:= TicketId end, Tickets)), ct:pal(" OK: ~p tickets", [length(Tickets)]). %% @doc GET /v1/admin/tickets/:id – проверяет получение тикета по ID. diff --git a/test/api/admins/admin_websocket_tests.erl b/test/api/admins/admin_websocket_tests.erl index 2337bac..7807d8a 100644 --- a/test/api/admins/admin_websocket_tests.erl +++ b/test/api/admins/admin_websocket_tests.erl @@ -237,10 +237,19 @@ extract_port(Url) -> [_, PortStr] -> {ok, list_to_integer(PortStr)}; _ -> case string:split(Rest, "://", trailing) of [_, R] -> extract_port("https://" ++ R); - _ -> {ok, 80} + _ -> {ok, default_port(Url)} end end; - _ -> {ok, 80} + _ -> {ok, default_port(Url)} + end. + +default_port(Url) -> + case string:prefix(Url, "wss://") of + nomatch -> case string:prefix(Url, "ws://") of + nomatch -> 80; + _ -> 80 + end; + _ -> 443 end. extract_host(Url) -> diff --git a/test/api/api_test_runner.erl b/test/api/api_test_runner.erl index bf31203..ec744cd 100644 --- a/test/api/api_test_runner.erl +++ b/test/api/api_test_runner.erl @@ -191,7 +191,7 @@ request(BaseUrl, Method, Path, Token, Body, Prefix) -> delete -> {URL, Headers}; _ -> {URL, Headers, "application/json", Body} end, - Response = httpc:request(Method, RequestArg, [], []), + Response = httpc:request(Method, RequestArg, [{timeout, 15000}, {ssl, [{verify, verify_none}]}], []), case Response of {ok, {{_, Status, _}, RespHeaders, RespBody}} -> ct:pal("~s RESPONSE: ~p ~s", [Prefix, Status, RespBody]), diff --git a/test/api_admins_SUITE.erl b/test/api_admins_SUITE.erl index 4c12144..5539d47 100644 --- a/test/api_admins_SUITE.erl +++ b/test/api_admins_SUITE.erl @@ -46,6 +46,13 @@ init_per_suite(Config) -> case os:getenv("CT_MODE", "local") of "remote" -> ct:pal("Remote mode: assuming application is already running"), + inets:start(), + ssl:start(), + % Отключаем авто-редирект и проверку сертификатов + httpc:set_options([ + {autoredirect, false}, + {ssl, [{verify, verify_none}]} + ]), wait_for_remote(), [{started_by_us, false} | Config]; _ -> @@ -135,7 +142,7 @@ wait_for_remote() -> wait_for_health(_URL, 0) -> ct:fail("Remote API did not start within 30 seconds"); wait_for_health(URL, Retries) -> - case httpc:request(get, {URL, []}, [], []) of + case httpc:request(get, {URL, []}, [{timeout, 5000}, {ssl, [{verify, verify_none}]}], []) of {ok, {{_, 200, _}, _, _}} -> ok; _ -> timer:sleep(1000), diff --git a/test/api_users_SUITE.erl b/test/api_users_SUITE.erl index cdb39af..7540e35 100644 --- a/test/api_users_SUITE.erl +++ b/test/api_users_SUITE.erl @@ -53,6 +53,13 @@ init_per_suite(Config) -> case os:getenv("CT_MODE", "local") of "remote" -> ct:pal("Remote mode: assuming application is already running"), + inets:start(), + ssl:start(), + % Отключаем авто-редирект и проверку сертификатов + httpc:set_options([ + {autoredirect, false}, + {ssl, [{verify, verify_none}]} + ]), wait_for_remote(), [{started_by_us, false} | Config]; _ -> @@ -163,7 +170,7 @@ wait_for_remote() -> wait_for_health(_URL, 0) -> ct:fail("Remote API did not start within 30 seconds"); wait_for_health(URL, Retries) -> - case httpc:request(get, {URL, []}, [], []) of + case httpc:request(get, {URL, []}, [{timeout, 5000}, {ssl, [{verify, verify_none}]}], []) of {ok, {{_, 200, _}, _, _}} -> ok; _ -> timer:sleep(1000), diff --git a/test/emulate_users/Dockerfile b/test/emulate_users/Dockerfile new file mode 100644 index 0000000..904bee9 --- /dev/null +++ b/test/emulate_users/Dockerfile @@ -0,0 +1,5 @@ +FROM python:3.11-slim +WORKDIR /app +RUN pip install requests +COPY test/emulate_users/emulate_users.py . +CMD ["python", "emulate_users.py"] \ No newline at end of file diff --git a/test/emulate_users/docker-compose.yml b/test/emulate_users/docker-compose.yml new file mode 100644 index 0000000..4dc78ca --- /dev/null +++ b/test/emulate_users/docker-compose.yml @@ -0,0 +1,14 @@ +version: '3.8' +services: + eventhub-emulator: + build: . + environment: + - ADMIN_API_HOST=http://localhost:8445 + - CLIENT_API_HOST=http://localhost:8080 + - ADMIN_EMAIL=superadmin@eventhub.local + - ADMIN_PASSWORD=123456 + - BOT_PASSWORD=botpass123 + - MIN_DELAY=0.5 + - MAX_DELAY=3.0 + - LOOP_FOREVER=true + - BOT_REFRESH_INTERVAL=300 \ No newline at end of file diff --git a/test/emulate_users/emulate_users.py b/test/emulate_users/emulate_users.py new file mode 100644 index 0000000..e7c8723 --- /dev/null +++ b/test/emulate_users/emulate_users.py @@ -0,0 +1,196 @@ +#!/usr/bin/env python3 +import os, time, random, requests, logging, json + +DEBUG = os.getenv("DEBUG", "true").lower() == "true" +VERIFY_SSL = os.getenv("VERIFY_SSL", "false").lower() == "true" + +ADMIN_API_HOST = os.getenv("ADMIN_API_HOST", "http://localhost:8445") +CLIENT_API_HOST = os.getenv("CLIENT_API_HOST", "http://localhost:8080") +ADMIN_EMAIL = os.getenv("ADMIN_EMAIL", "superadmin@eventhub.local") +ADMIN_PASSWORD = os.getenv("ADMIN_PASSWORD", "123456") +BOT_PASSWORD = os.getenv("BOT_PASSWORD", "botpass123") +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")) + +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 = [] +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}") + +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 + +def get_admin_token(): + global admin_token + if admin_token: + return 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") + return admin_token + +def fetch_bot_emails(): + token = get_admin_token() + resp = request("GET", + f"{ADMIN_API_HOST}/v1/admin/users?limit=10000", + headers={"Authorization": f"Bearer {token}"} + ) + resp.raise_for_status() + 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 + +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() + return resp.json()["token"] + +def refresh_bot_cache(): + global bots_cache, last_bot_refresh + emails = fetch_bot_emails() + new_cache = [] + for email in emails: + try: + token = login_bot(email) + 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") + +def random_bot(): + global bots_cache, last_bot_refresh + while True: + if not bots_cache or (time.time() - last_bot_refresh > BOT_REFRESH_INTERVAL): + refresh_bot_cache() + if bots_cache: + return random.choice(bots_cache) + 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"} + base = CLIENT_API_HOST + 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: + 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(): + 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}, + headers=headers) + elif action == 4: + 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 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(): + 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 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) + elif action == 9: + request("POST", f"{base}/v1/subscription", json={"action": "start_trial"}, headers=headers) + elif action == 10: + request("GET", f"{base}/v1/user/me", headers=headers) + elif action == 11: + request("GET", f"{base}/v1/user/bookings", headers=headers) + elif action == 12: + 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(): + cal = random.choice(resp_cal.json()) + request("GET", f"{base}/v1/calendars/{cal['id']}/view?month=2026-06", headers=headers) + elif action == 14: + request("POST", f"{base}/v1/refresh", json={"refresh_token": "dummy"}, headers=headers) + except Exception as e: + logger.error(f"Action {action} failed for {bot['email']}: {e}") + +def main(): + logger.info("Starting user emulation") + refresh_bot_cache() + while LOOP_FOREVER: + bot = random_bot() + do_random_action(bot) + random_sleep() + logger.info("Emulation finished") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/test/tsung/eventhub_tsung.xml b/test/tsung/eventhub_tsung.xml new file mode 100644 index 0000000..eefb5ed --- /dev/null +++ b/test/tsung/eventhub_tsung.xml @@ -0,0 +1,223 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file