Files
EventHubBack/test/api/api_test_runner.erl
T
aleksey ba1649f344
CI / test (push) Successful in 7m3s
CI / deploy-ift (push) Successful in 3m53s
CI / e2e-ift (push) Successful in 3m34s
CI / deploy-stage (push) Successful in 2m19s
CI / e2e-stage (push) Successful in 1m19s
fix(ct): teardown litter users/calendars after API suites
Track users/calendars created via register_and_login and create_calendar;
soft-delete via admin API in end_per_suite. Skip with CT_KEEP_DATA=1.
Safety-net soft-deletes remaining @test.local users.

Fixes EventHub/EventHubBack#65
2026-07-31 12:21:15 +03:00

632 lines
23 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,
unique_ticket_message/1,
future_date/0,
register_and_login/2,
create_calendar/2,
create_event/3,
ensure_commercial_subscription/1,
existing_personal_calendar_id/1
, 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_patch/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]).
-export([
track_user/1,
track_calendar/1,
cleanup_tracked/0,
keep_ct_data/0,
reset_litter_tracker/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]
).
%%%===================================================================
%%% Litter tracker (users/calendars created during CT)
%%% persistent_term — survives CT testcase process exits (unlike ETS owner).
%%%===================================================================
-define(LITTER_USERS, {?MODULE, tracked_users}).
-define(LITTER_CALS, {?MODULE, tracked_calendars}).
%% @doc CT_KEEP_DATA=1 — skip suite teardown soft-delete (default: clean up).
-spec keep_ct_data() -> boolean().
keep_ct_data() ->
case os:getenv("CT_KEEP_DATA") of
"1" -> true;
"true" -> true;
"yes" -> true;
_ -> false
end.
-spec reset_litter_tracker() -> ok.
reset_litter_tracker() ->
persistent_term:put(?LITTER_USERS, []),
persistent_term:put(?LITTER_CALS, []),
ok.
-spec track_user(binary()) -> ok.
track_user(UserId) when is_binary(UserId), UserId =/= <<>> ->
track_id(?LITTER_USERS, UserId);
track_user(_) ->
ok.
-spec track_calendar(binary()) -> ok.
track_calendar(CalId) when is_binary(CalId), CalId =/= <<>> ->
track_id(?LITTER_CALS, CalId);
track_calendar(_) ->
ok.
track_id(Key, Id) ->
Old = persistent_term:get(Key, []),
case lists:member(Id, Old) of
true -> ok;
false -> persistent_term:put(Key, [Id | Old]), ok
end.
%% @doc Soft-delete tracked calendars then users via admin API.
%% Best-effort: never fails the suite. No-op when CT_KEEP_DATA=1.
-spec cleanup_tracked() -> ok.
cleanup_tracked() ->
case keep_ct_data() of
true ->
ct:pal("CT_KEEP_DATA set — skip API CT litter cleanup"),
ok;
false ->
do_cleanup_tracked()
end.
do_cleanup_tracked() ->
CalIds = lists:usort(persistent_term:get(?LITTER_CALS, [])),
UserIds = lists:usort(persistent_term:get(?LITTER_USERS, [])),
ct:pal("API CT litter cleanup: ~p calendars, ~p users", [length(CalIds), length(UserIds)]),
AdminToken =
try get_admin_token()
catch
_:Reason ->
ct:pal("API CT litter cleanup: no admin token (~p) — skip", [Reason]),
undefined
end,
case AdminToken of
undefined ->
ok;
Token ->
lists:foreach(
fun(Id) ->
admin_delete_best_effort(<<"/v1/admin/calendars/", Id/binary>>, Token)
end,
CalIds
),
lists:foreach(
fun(Id) ->
admin_delete_best_effort(<<"/v1/admin/users/", Id/binary>>, Token)
end,
UserIds
),
cleanup_test_local_users(Token),
reset_litter_tracker(),
ok
end.
%% Safety-net: soft-delete users with @test.local (missed by tracker).
cleanup_test_local_users(AdminToken) ->
case admin_request(get, <<"/v1/admin/users?q=test.local&limit=100">>, AdminToken) of
{ok, 200, _, Body} ->
Users = jsx:decode(list_to_binary(Body), [return_maps]),
lists:foreach(
fun
(#{<<"id">> := Id, <<"email">> := Email}) when is_binary(Email) ->
case binary:match(Email, <<"@test.local">>) of
nomatch -> ok;
_ -> admin_delete_best_effort(<<"/v1/admin/users/", Id/binary>>, AdminToken)
end;
(_) ->
ok
end,
case Users of
L when is_list(L) -> L;
_ -> []
end
);
Other ->
ct:pal("API CT litter cleanup: list users skip (~p)", [Other]),
ok
end.
admin_delete_best_effort(Path, Token) ->
case admin_request(delete, Path, Token) of
{ok, Status, _, _} when Status >= 200, Status < 300 -> ok;
{ok, 404, _, _} -> ok;
Other ->
ct:pal("API CT litter cleanup: DELETE ~s -> ~p", [Path, Other]),
ok
end.
%%%===================================================================
%%% Учётные данные администраторов (из переменных окружения)
%%%===================================================================
-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_patch(binary(), binary(), map()) -> jsx:json_term().
client_patch(Path, Token, BodyMap) ->
Body = jsx:encode(BodyMap),
{ok, 200, _, RespBody} = client_request(patch, 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">>.
%% Уникальное сообщение тикета на прогон (IFT: иначе дедуп по error_hash
%% возвращает чужой reporter_id и list/get своих тикетов падает).
%% system_time — стабильно уникален между контейнерами; unique_integer —
%% различает вызовы внутри одного прогона (monotonic сбрасывается при старте BEAM).
-spec unique_ticket_message(binary()) -> binary().
unique_ticket_message(Prefix) ->
Time = integer_to_binary(erlang:system_time()),
Seq = integer_to_binary(erlang:unique_integer([positive, monotonic])),
<<Prefix/binary, " ", Time/binary, "-", Seq/binary>>.
-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]),
track_user(UserId),
% 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) ->
%% Default commercial: user already has sole personal Default after verify.
Type = maps:get(type, Params, maps:get(<<"type">>, Params, <<"commercial">>)),
Params1 =
case maps:is_key(type, Params) orelse maps:is_key(<<"type">>, Params) of
true -> Params;
false -> Params#{type => Type}
end,
case Type of
<<"commercial">> -> ensure_commercial_subscription(Token);
commercial -> ensure_commercial_subscription(Token);
<<"personal">> -> ok;
personal -> ok;
_ -> ensure_commercial_subscription(Token)
end,
Body = jsx:encode(Params1),
case client_request(post, <<"/v1/calendars">>, Token, Body) of
{ok, 201, _, RespBody} ->
#{<<"id">> := CalId} = jsx:decode(list_to_binary(RespBody), [return_maps]),
%% Personal Default is sole — do not track for delete (403 default_calendar).
case Type of
<<"personal">> -> ok;
personal -> ok;
_ -> track_calendar(CalId)
end,
CalId;
{ok, 409, _, _} when Type =:= <<"personal">>; Type =:= personal ->
%% Sole Default personal already exists — reuse it.
existing_personal_calendar_id(Token);
Other ->
error({create_calendar_failed, Other})
end.
-spec existing_personal_calendar_id(binary()) -> binary().
existing_personal_calendar_id(Token) ->
Cals = client_get(<<"/v1/calendars">>, Token),
case [Id || #{<<"id">> := Id, <<"type">> := <<"personal">>} <- Cals] of
[Id | _] -> Id;
[] -> error(no_personal_calendar)
end.
-spec ensure_commercial_subscription(binary()) -> ok.
ensure_commercial_subscription(Token) ->
case client_request(post, <<"/v1/subscription">>, Token,
jsx:encode(#{action => <<"start_trial">>})) of
{ok, 201, _, _} -> ok;
{ok, 200, _, _} -> ok;
{ok, 409, _, _} -> ok; %% already has subscription / trial used with active
{ok, 400, _, _} -> ok; %% trial_already_used — may still have active paid
_ -> ok
end.
-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.