Files
EventHubBack/test/api/api_test_runner.erl
T
aleksey 5e569da1d8
CI / test (push) Failing after 5m10s
CI / deploy-ift (push) Has been skipped
CI / e2e-ift (push) Has been skipped
CI / deploy-stage (push) Has been skipped
CI / e2e-stage (push) Has been skipped
fix(mnesia): per-slot volumes hint and harden verification lookup.
Avoid shared Mnesia dir across replicas; make get_or_create_token and CT verify_user resilient to brief cross-node lag.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-17 09:54:10 +03:00

435 lines
17 KiB
Erlang
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
%%%-------------------------------------------------------------------
%%% @doc Централизованный модуль для запуска API-тестов.
%%% Предоставляет функции для выполнения HTTP-запросов
%%% к административному и клиентскому API с автоматическим
%%% логированием, проверкой статусов и конфигурацией
%%% через стандартные переменные окружения.
%%% @end
%%%-------------------------------------------------------------------
-module(api_test_runner).
-export([
ct_mode/0,
get_admin_url/0,
get_base_url/0,
get_base_ws_url/0,
get_admin_ws_url/0,
ensure_local_app_ports/0,
get_admin_token/0,
get_superadmin_token/0,
get_moderator_token/0,
get_support_token/0,
get_user_token/0,
unique_email/1,
future_date/0,
register_and_login/2,
create_calendar/2,
create_event/3
, get_admin_refresh_token/0
, admin_super_email/0
, admin_super_password/0]).
-export([
admin_request/3,
admin_request/4,
admin_request/5,
client_request/3,
client_request/4
]).
-export([
admin_get/2,
admin_post/3,
admin_put/3,
admin_delete/2,
client_get/2,
client_post/3,
client_put/3,
client_delete/2,
admin_patch/3]).
-export([verify_user/2]).
-export([future_date_iso8601/0, iso8601_utc/1, future_month_range_iso8601/0,
next_month_start_iso8601/0, next_month_range_iso8601/0]).
%%%===================================================================
%%% Конфигурация окружения (CT_MODE, ...)
%%%===================================================================
-spec ct_mode() -> string().
ct_mode() ->
os:getenv("CT_MODE", "local").
-spec get_base_url() -> string().
get_base_url() ->
os:getenv("API_HOST", "http://localhost:18080").
-spec get_admin_url() -> string().
get_admin_url() ->
os:getenv("ADMIN_API_HOST", "http://localhost:18445").
-spec get_base_ws_url() -> string().
get_base_ws_url() ->
os:getenv("WS_HOST", "ws://localhost:18081").
-spec get_admin_ws_url() -> string().
get_admin_ws_url() ->
os:getenv("ADMIN_WS_HOST", "ws://localhost:18446").
%% @doc Порты CT-local (не docker 8080/8445). Вызывать до ensure_all_started(eventhub).
-spec ensure_local_app_ports() -> ok.
ensure_local_app_ports() ->
_ = application:load(eventhub),
_ = application:load(mnesia),
application:set_env(eventhub, http_port, env_port("HTTP_PORT", 18080)),
application:set_env(eventhub, ws_port, env_port("WS_PORT", 18081)),
application:set_env(eventhub, admin_http_port, env_port("ADMIN_HTTP_PORT", 18445)),
application:set_env(eventhub, admin_ws_port, env_port("ADMIN_WS_PORT", 18446)),
application:set_env(eventhub, swagger_http_port, env_port("SWAGGER_HTTP_PORT", 18447)),
ensure_fresh_mnesia_dir(),
clear_cached_admin_tokens(),
ok.
env_port(Name, Default) ->
case os:getenv(Name) of
false -> Default;
"" -> Default;
Val -> list_to_integer(Val)
end.
ensure_fresh_mnesia_dir() ->
%% Absolute path under WSL data (CT cwd is logs/test/ct/ct_run.* — relative data/ is missing).
Dir = filename:join([
os:getenv("HOME", "/home/alexc"),
"eventhub-wsl-data", "data", "mnesia_ct"
]),
_ = application:load(mnesia),
application:set_env(mnesia, dir, Dir),
_ = (catch mnesia:stop()),
ok = filelib:ensure_dir(filename:join(Dir, ".keep")),
_ = file:del_dir_r(Dir),
_ = (catch mnesia:delete_schema([node()])),
case mnesia:create_schema([node()]) of
ok ->
ok;
{error, {already_exists, _}} ->
ok;
{error, Reason} ->
error({ct_mnesia_create_schema_failed, Reason, Dir, node()})
end.
clear_cached_admin_tokens() ->
lists:foreach(
fun(Role) -> _ = persistent_term:erase({?MODULE, admin_token, Role}) end,
[admin, superadmin, moderator, support]
).
%%%===================================================================
%%% Учётные данные администраторов (из переменных окружения)
%%%===================================================================
-spec admin_super_email() -> binary().
admin_super_email() ->
list_to_binary(os:getenv("ADMIN_SUPER_EMAIL", "superadmin@eventhub.local")).
-spec admin_super_password() -> binary().
admin_super_password() ->
list_to_binary(os:getenv("ADMIN_SUPER_PASSWORD", "123456")).
-spec admin_email() -> binary().
admin_email() ->
list_to_binary(os:getenv("ADMIN_EMAIL", "admin@eventhub.local")).
-spec admin_password() -> binary().
admin_password() ->
list_to_binary(os:getenv("ADMIN_PASSWORD", "123456")).
-spec admin_moder_email() -> binary().
admin_moder_email() ->
list_to_binary(os:getenv("ADMIN_MODER_EMAIL", "moderator@eventhub.local")).
-spec admin_moder_password() -> binary().
admin_moder_password() ->
list_to_binary(os:getenv("ADMIN_MODER_PASSWORD", "123456")).
-spec admin_support_email() -> binary().
admin_support_email() ->
list_to_binary(os:getenv("ADMIN_SUPPORT_EMAIL", "support@eventhub.local")).
-spec admin_support_password() -> binary().
admin_support_password() ->
list_to_binary(os:getenv("ADMIN_SUPPORT_PASSWORD", "123456")).
%%%===================================================================
%%% Получение токенов (с кешированием в persistent_term)
%%%===================================================================
-spec get_admin_token() -> binary().
get_admin_token() ->
get_or_login(admin, admin_email(), admin_password()).
-spec get_superadmin_token() -> binary().
get_superadmin_token() ->
get_or_login(superadmin, admin_super_email(), admin_super_password()).
-spec get_moderator_token() -> binary().
get_moderator_token() ->
get_or_login(moderator, admin_moder_email(), admin_moder_password()).
-spec get_support_token() -> binary().
get_support_token() ->
get_or_login(support, admin_support_email(), admin_support_password()).
-spec get_admin_refresh_token() -> binary().
get_admin_refresh_token() ->
Email = admin_email(),
Password = admin_password(),
Body = jsx:encode(#{email => Email, password => Password}),
{ok, 200, _, RespBody} = admin_request(post, <<"/v1/admin/login">>, <<>>, Body),
#{<<"refresh_token">> := RefreshToken} = jsx:decode(list_to_binary(RespBody), [return_maps]),
RefreshToken.
-spec get_or_login(atom(), binary(), binary()) -> binary().
get_or_login(Role, Email, Password) ->
Key = {?MODULE, admin_token, Role},
case persistent_term:get(Key, undefined) of
Token when is_binary(Token) -> Token;
_ ->
Token = login_admin(Email, Password),
persistent_term:put(Key, Token),
timer:apply_after(5 * 60 * 1000, fun() -> persistent_term:erase(Key) end),
Token
end.
%% @doc Возвращает JWT-токен обычного пользователя.
%% При каждом вызове создаёт нового уникального пользователя,
%% чтобы избежать конфликтов состояния в тестах.
-spec get_user_token() -> binary().
get_user_token() ->
Email = unique_email(<<"testuser">>),
register_and_login(Email, <<"testpass">>).
%%%===================================================================
%%% HTTP-клиент (логирование, заголовки)
%%%===================================================================
-spec admin_request(atom(), binary(), binary()) -> {ok, integer(), proplists:proplist(), binary()} | {error, term()}.
admin_request(Method, Path, Token) ->
admin_request(Method, Path, Token, <<>>).
-spec admin_request(atom(), binary(), binary(), binary()) -> {ok, integer(), proplists:proplist(), binary()} | {error, term()}.
admin_request(Method, Path, Token, Body) ->
admin_request(Method, Path, Token, Body, []).
-spec admin_request(atom(), binary(), binary(), binary(), [{string(), string()}]) ->
{ok, integer(), proplists:proplist(), binary()} | {error, term()}.
admin_request(Method, Path, Token, Body, ExtraHeaders) ->
request(get_admin_url(), Method, Path, Token, Body, "ADMIN", ExtraHeaders).
-spec client_request(atom(), binary(), binary()) -> {ok, integer(), proplists:proplist(), binary()} | {error, term()}.
client_request(Method, Path, Token) ->
client_request(Method, Path, Token, <<>>).
-spec client_request(atom(), binary(), binary(), binary()) -> {ok, integer(), proplists:proplist(), binary()} | {error, term()}.
client_request(Method, Path, Token, Body) ->
request(get_base_url(), Method, Path, Token, Body, "CLIENT", []).
%%%===================================================================
%%% Внутренняя реализация HTTP-запроса
%%%===================================================================
-spec request(string(), atom(), binary(), binary(), binary(), string()) -> {ok, integer(), proplists:proplist(), binary()} | {error, term()}.
request(BaseUrl, Method, Path, Token, Body, Prefix) ->
request(BaseUrl, Method, Path, Token, Body, Prefix, []).
-spec request(string(), atom(), binary(), binary(), binary(), string(), [{string(), string()}]) ->
{ok, integer(), proplists:proplist(), binary()} | {error, term()}.
request(BaseUrl, Method, Path, Token, Body, Prefix, ExtraHeaders) ->
URL = BaseUrl ++ binary_to_list(Path),
AuthHeaders = case Token of
<<>> -> [];
_ -> [{"Authorization", "Bearer " ++ binary_to_list(Token)}]
end,
Headers = ExtraHeaders ++ AuthHeaders,
ct:pal("~s REQUEST: ~s ~s", [Prefix, Method, URL]),
RequestArg = case Method of
get -> {URL, Headers};
delete -> {URL, Headers};
_ -> {URL, Headers, "application/json", Body}
end,
Response = httpc:request(Method, RequestArg, [{timeout, 25000}, {ssl, [{verify, verify_none}]}], []),
case Response of
{ok, {{_, Status, _}, RespHeaders, RespBody}} ->
ct:pal("~s RESPONSE: ~p ~s", [Prefix, Status, RespBody]),
{ok, Status, RespHeaders, RespBody};
_ ->
ct:pal("~s REQUEST ERROR: ~p", [Prefix, Response]),
{error, Response}
end.
%%%===================================================================
%%% Высокоуровневые обёртки (GET/POST/PUT/DELETE)
%%%===================================================================
-spec admin_get(binary(), binary()) -> jsx:json_term().
admin_get(Path, Token) ->
{ok, 200, _, Body} = admin_request(get, Path, Token),
jsx:decode(list_to_binary(Body), [return_maps]).
-spec admin_post(binary(), binary(), map()) -> jsx:json_term().
admin_post(Path, Token, BodyMap) ->
Body = jsx:encode(BodyMap),
{ok, 201, _, RespBody} = admin_request(post, Path, Token, Body),
jsx:decode(list_to_binary(RespBody), [return_maps]).
-spec admin_put(binary(), binary(), map()) -> jsx:json_term().
admin_put(Path, Token, BodyMap) ->
Body = jsx:encode(BodyMap),
{ok, 200, _, RespBody} = admin_request(put, Path, Token, Body),
jsx:decode(list_to_binary(RespBody), [return_maps]).
%% В api_test_runner.erl добавить в блок высокоуровневых обёрток:
-spec admin_patch(binary(), binary(), [map()]) -> jsx:json_term().
admin_patch(Path, Token, BodyList) ->
Body = jsx:encode(BodyList),
{ok, 200, _, RespBody} = admin_request(patch, Path, Token, Body),
jsx:decode(list_to_binary(RespBody), [return_maps]).
-spec admin_delete(binary(), binary()) -> jsx:json_term().
admin_delete(Path, Token) ->
{ok, 200, _, Body} = admin_request(delete, Path, Token),
jsx:decode(list_to_binary(Body), [return_maps]).
-spec client_get(binary(), binary()) -> jsx:json_term().
client_get(Path, Token) ->
{ok, 200, _, Body} = client_request(get, Path, Token),
jsx:decode(list_to_binary(Body), [return_maps]).
-spec client_post(binary(), binary(), map()) -> jsx:json_term().
client_post(Path, Token, BodyMap) ->
Body = jsx:encode(BodyMap),
{ok, 201, _, RespBody} = client_request(post, Path, Token, Body),
jsx:decode(list_to_binary(RespBody), [return_maps]).
-spec client_put(binary(), binary(), map()) -> jsx:json_term().
client_put(Path, Token, BodyMap) ->
Body = jsx:encode(BodyMap),
{ok, 200, _, RespBody} = client_request(put, Path, Token, Body),
jsx:decode(list_to_binary(RespBody), [return_maps]).
-spec client_delete(binary(), binary()) -> jsx:json_term().
client_delete(Path, Token) ->
{ok, 200, _, Body} = client_request(delete, Path, Token),
jsx:decode(list_to_binary(Body), [return_maps]).
%%%===================================================================
%%% Фикстуры (создание тестовых данных)
%%%===================================================================
-spec unique_email(binary()) -> binary().
unique_email(Prefix) ->
Unique = integer_to_binary(erlang:system_time()),
<<Prefix/binary, "_", Unique/binary, "@test.local">>.
-spec future_date() -> calendar:datetime().
future_date() ->
Seconds = calendar:datetime_to_gregorian_seconds(calendar:universal_time()) + 86400,
calendar:gregorian_seconds_to_datetime(Seconds).
-spec future_date_iso8601() -> binary().
future_date_iso8601() ->
iso8601_utc(future_date()).
-spec iso8601_utc(calendar:datetime()) -> binary().
iso8601_utc({{Y, M, D}, {H, Min, S}}) ->
iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
[Y, M, D, H, Min, S])).
-spec future_month_range_iso8601() -> {binary(), binary()}.
future_month_range_iso8601() ->
{{Y, M, _D}, _} = future_date(),
month_range_iso8601(Y, M).
-spec next_month_start_iso8601() -> binary().
next_month_start_iso8601() ->
{{Y, M, _}, _} = future_date(),
{NY, NM} = next_month(Y, M),
iso8601_utc({{NY, NM, 1}, {10, 0, 0}}).
-spec next_month_range_iso8601() -> {binary(), binary()}.
next_month_range_iso8601() ->
{{Y, M, _}, _} = future_date(),
{NY, NM} = next_month(Y, M),
month_range_iso8601(NY, NM).
month_range_iso8601(Y, M) ->
From = iso8601_utc({{Y, M, 1}, {0, 0, 0}}),
LastDay = calendar:last_day_of_the_month(Y, M),
To = iso8601_utc({{Y, M, LastDay}, {23, 59, 59}}),
{From, To}.
next_month(Y, 12) -> {Y + 1, 1};
next_month(Y, M) -> {Y, M + 1}.
-spec register_and_login(binary(), binary()) -> binary().
register_and_login(Email, Password) ->
% 1. Регистрируем пользователя
Resp = client_request(post, <<"/v1/register">>, <<>>,
jsx:encode(#{email => Email, password => Password})),
{ok, 201, _, Body} = Resp,
#{<<"user">> := #{<<"id">> := UserId}} = jsx:decode(list_to_binary(Body), [return_maps]),
% 2. Получаем админский токен (используется кеширование)
AdminToken = get_admin_token(),
% 3. Верифицируем пользователя
verify_user(AdminToken, UserId),
% 4. Логинимся и возвращаем свежий JWT
LoginResp = client_request(post, <<"/v1/login">>, <<>>,
jsx:encode(#{email => Email, password => Password})),
{ok, 200, _, LoginBody} = LoginResp,
#{<<"token">> := Token} = jsx:decode(list_to_binary(LoginBody), [return_maps]),
Token.
-spec create_calendar(binary(), map()) -> binary().
create_calendar(Token, Params) ->
#{<<"id">> := CalId} = client_post(<<"/v1/calendars">>, Token, Params),
CalId.
-spec create_event(binary(), binary(), map()) -> binary().
create_event(Token, CalId, Params) ->
Path = <<"/v1/calendars/", CalId/binary, "/events">>,
#{<<"id">> := EventId} = client_post(Path, Token, Params),
EventId.
%% @doc Подтверждает email пользователя, используя админский эндпоинт
%% для получения верификационного токена и публичный /v1/verify.
%% Retries briefly: IFT Traefik may hit another replica before Mnesia settles.
-spec verify_user(AdminToken :: binary(), UserId :: binary()) -> ok.
verify_user(AdminToken, UserId) ->
verify_user(AdminToken, UserId, 8).
verify_user(AdminToken, UserId, RetriesLeft) ->
Path = <<"/v1/admin/users/", UserId/binary, "/verification-token">>,
case admin_request(get, Path, AdminToken) of
{ok, 200, _, Body} ->
#{<<"token">> := Token} = jsx:decode(list_to_binary(Body), [return_maps]),
{ok, 200, _, _} = client_request(post, <<"/v1/verify">>, <<>>,
jsx:encode(#{<<"token">> => Token})),
ok;
{ok, 404, _, _} when RetriesLeft > 1 ->
timer:sleep(100),
verify_user(AdminToken, UserId, RetriesLeft - 1);
Other ->
error({verify_user_failed, UserId, Other})
end.
%%%===================================================================
%%% Внутренние функции
%%%===================================================================
-spec login_admin(binary(), binary()) -> binary().
login_admin(Email, Password) ->
BodyMap = #{<<"email">> => Email, <<"password">> => Password},
Body = jsx:encode(BodyMap),
{ok, 200, _, RespBody} = admin_request(post, <<"/v1/admin/login">>, <<>>, Body),
#{<<"token">> := Token} = jsx:decode(list_to_binary(RespBody), [return_maps]),
Token.