Рефакторинг обработчиков. Часть 3 #21

This commit is contained in:
2026-05-13 23:02:59 +03:00
parent 61bb44ab4a
commit 40806df62a
91 changed files with 6138 additions and 7150 deletions

View File

@@ -1,277 +1,302 @@
%%%-------------------------------------------------------------------
%%% @doc Централизованный модуль для запуска API-тестов.
%%% Предоставляет функции для выполнения HTTP-запросов
%%% к административному и клиентскому API с автоматическим
%%% логированием, проверкой статусов и конфигурацией
%%% через стандартные переменные окружения.
%%% @end
%%%-------------------------------------------------------------------
-module(api_test_runner).
-export([run_all/0, run/1]).
-export([http_post/2, http_post/3, http_get/1, http_get/2, http_put/3, http_delete/2]).
-export([extract_json/2, extract_json/3, assert_status/2]).
-export([unique_email/1, register_and_login/2, create_calendar/2, create_event/3]).
-export([get_admin_token/0, get_admin_id/0, get_user_token/0, get_user_id/0, get_admin_url/0, get_base_url/0, get_admin_ws_url/0, get_base_ws_url/0, login_admin/2, login_custom_admin/2]).
-export([wait_for_server/0]).
-export([format_datetime/1]).
-define(BASE_URL, base_url()).
-define(ADMIN_URL, admin_base_url()).
-export([
get_admin_url/0,
get_base_url/0,
get_base_ws_url/0,
get_admin_ws_url/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
]).
-export([
admin_request/3,
admin_request/4,
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]).
%% Учётные данные по умолчанию (используются в локальном режиме, если словарь пуст)
-define(FALLBACK_ADMIN_EMAIL, <<"admin@eventhub.local">>).
-define(FALLBACK_ADMIN_PASSWORD, <<"123456">>).
-define(USER_EMAIL, <<"global_user@test.com">>).
-define(USER_PASSWORD, <<"user123">>).
%%%===================================================================
%%% Конфигурация окружения (CT_MODE, ...)
%%%===================================================================
%% ------------------------------------------------------------------
%% Выбор базовых URL в зависимости от режима запуска
%% ------------------------------------------------------------------
base_url() ->
case os:getenv("CT_MODE", "local") of
-spec ct_mode() -> string().
ct_mode() ->
os:getenv("CT_MODE", "local").
-spec get_base_url() -> string().
get_base_url() ->
case ct_mode() of
"remote" -> os:getenv("API_HOST", "http://localhost:8080");
_ -> "http://localhost:8080"
end.
base_ws_url() ->
case os:getenv("CT_MODE", "local") of
"remote" -> os:getenv("WS_HOST", "ws://localhost:8081");
_ -> "ws://localhost:8081"
end.
admin_base_url() ->
case os:getenv("CT_MODE", "local") of
-spec get_admin_url() -> string().
get_admin_url() ->
case ct_mode() of
"remote" -> os:getenv("ADMIN_API_HOST", "http://localhost:8445");
_ -> "http://localhost:8445"
end.
admin_ws_url() ->
case os:getenv("CT_MODE", "local") of
-spec get_base_ws_url() -> string().
get_base_ws_url() ->
case ct_mode() of
"remote" -> os:getenv("WS_HOST", "ws://localhost:8081");
_ -> "ws://localhost:8081"
end.
-spec get_admin_ws_url() -> string().
get_admin_ws_url() ->
case ct_mode() of
"remote" -> os:getenv("ADMIN_WS_HOST", "ws://localhost:8446");
_ -> "ws://localhost:8446"
end.
%% ------------------------------------------------------------------
%% Инициализация глобальных тестовых пользователей
%% ------------------------------------------------------------------
init_global_urls() ->
put(admin_url, admin_base_url()),
put(admin_ws_url, admin_ws_url()),
put(base_url, base_url()),
put(base_ws_url, base_ws_url()).
%%%===================================================================
%%% Учётные данные администраторов (из переменных окружения)
%%%===================================================================
init_global_users() ->
case get(admin_token) of
undefined ->
ct:pal("~n=== Initializing global test users ===~n"),
-spec admin_super_email() -> binary().
admin_super_email() ->
list_to_binary(os:getenv("ADMIN_SUPER_EMAIL", "superadmin@eventhub.local")).
%% 1. Администратор
AdminEmail = get(admin_super_email),
AdminPassword = get(admin_super_password),
AdminToken =
if
AdminEmail =/= undefined, AdminPassword =/= undefined ->
%% Учётные данные переданы из api_SUITE (remoteрежим) просто логинимся
login_admin(AdminEmail, AdminPassword);
true ->
%% Локальный режим: админы уже есть, логинимся под суперадмином
login_admin(?FALLBACK_ADMIN_EMAIL, ?FALLBACK_ADMIN_PASSWORD)
end,
-spec admin_super_password() -> binary().
admin_super_password() ->
list_to_binary(os:getenv("ADMIN_SUPER_PASSWORD", "123456")).
%% Получаем ID администратора через /v1/admin/me
MeUrl = ?ADMIN_URL ++ "/v1/admin/me",
{ok, {{_, 200, _}, _, MeBody}} = httpc:request(get,
{MeUrl, [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, ssl_opts(), []),
#{<<"id">> := AdminId} = jsx:decode(list_to_binary(MeBody), [return_maps]),
-spec admin_email() -> binary().
admin_email() ->
list_to_binary(os:getenv("ADMIN_EMAIL", "admin@eventhub.local")).
put(admin_token, AdminToken),
put(admin_id, AdminId),
-spec admin_password() -> binary().
admin_password() ->
list_to_binary(os:getenv("ADMIN_PASSWORD", "123456")).
%% 2. Обычный пользователь
UserToken = register_and_login(?USER_EMAIL, ?USER_PASSWORD),
{ok, {{_, 200, _}, _, UserMeBody}} = http_get("/v1/user/me", UserToken),
#{<<"id">> := UserId} = jsx:decode(list_to_binary(UserMeBody), [return_maps]),
-spec admin_moder_email() -> binary().
admin_moder_email() ->
list_to_binary(os:getenv("ADMIN_MODER_EMAIL", "moderator@eventhub.local")).
put(user_token, UserToken),
put(user_id, UserId),
-spec admin_moder_password() -> binary().
admin_moder_password() ->
list_to_binary(os:getenv("ADMIN_MODER_PASSWORD", "123456")).
ct:pal("Admin ID: ~s, User ID: ~s~n", [AdminId, UserId]),
ct:pal("=== Global users initialized ===~n~n"),
ok;
-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_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;
_ ->
ct:pal("Global users already initialized.~n"),
ok
Token = login_admin(Email, Password),
persistent_term:put(Key, Token),
timer:apply_after(5 * 60 * 1000, fun() -> persistent_term:erase(Key) end),
Token
end.
%% ------------------------------------------------------------------
%% Вход администратора (используется, когда учётки уже известны)
%% ------------------------------------------------------------------
login_admin(Email, Password) ->
ct:pal("Admin url: ~s~n", [?ADMIN_URL]),
ct:pal("Admin: ~s, password: ~s~n", [Email, Password]),
LoginBody = jsx:encode(#{<<"email">> => Email, <<"password">> => Password}),
ct:pal("url: ~s, body: ~s~n", [?ADMIN_URL ++ "/v1/admin/login", LoginBody]),
{ok, {{_, _, _}, _, LoginResp}} = httpc:request(post,
{?ADMIN_URL ++ "/v1/admin/login", [], "application/json", LoginBody}, ssl_opts(), []),
ct:pal("LoginResp: ~s~n", [LoginResp]),
#{<<"token">> := Token} = jsx:decode(list_to_binary(LoginResp), [return_maps]),
%% @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) ->
request(get_admin_url(), Method, Path, Token, Body, "ADMIN").
-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) ->
URL = BaseUrl ++ binary_to_list(Path),
Headers0 = [],
Headers = case Token of
<<>> -> Headers0; % пустой токен не добавляем Authorization
_ -> [{"Authorization", "Bearer " ++ binary_to_list(Token)}]
end,
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, [], []),
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 register_and_login(binary(), binary()) -> binary().
register_and_login(Email, Password) ->
Resp = client_request(post, <<"/v1/register">>, <<>>,
jsx:encode(#{email => Email, password => Password})),
{ok, 201, _, Body} = Resp,
#{<<"token">> := Token} = jsx:decode(list_to_binary(Body), [return_maps]),
Token.
%% ------------------------------------------------------------------
%% Остальные функции (без изменений, только используют ?BASE_URL / ?ADMIN_URL)
%% ------------------------------------------------------------------
get_admin_url() ->
init_global_urls(),
get(admin_url).
get_admin_ws_url() ->
init_global_urls(),
get(admin_ws_url).
get_base_url() ->
init_global_urls(),
get(base_url).
get_base_ws_url() ->
init_global_urls(),
get(base_ws_url).
get_admin_token() ->
init_global_users(),
get(admin_token).
get_admin_id() ->
init_global_users(),
get(admin_id).
get_user_token() ->
init_global_users(),
get(user_token).
get_user_id() ->
init_global_users(),
get(user_id).
run_all() ->
inets:start(),
ssl:start(),
case wait_for_server() of
ok -> ok;
{error, _} -> ct:pal("❌ Server is not running!~n"), exit(server_not_running)
end,
init_global_users(),
ct:pal("Starting API tests...~n"),
Modules = [
api_auth_tests,
api_calendar_tests,
api_event_tests,
api_booking_tests,
api_search_tests,
api_reviews_tests,
api_moderation_tests,
api_tickets_tests,
api_subscription_tests,
api_admin_tests
],
lists:foreach(fun(M) -> M:test() end, Modules).
run(Module) ->
inets:start(),
ssl:start(),
init_global_users(),
Module:test().
%% ── HTTPзапросы ─────────────────────────────────────────
ssl_opts() ->
[{ssl, [{verify, verify_none}]}].
http_post(Url, Body) -> http_post(Url, Body, undefined).
http_post(Url, Body, Token) ->
Headers = case Token of
undefined -> [{"Content-Type", "application/json"}];
_ -> [{"Content-Type", "application/json"}, {"Authorization", "Bearer " ++ binary_to_list(Token)}]
end,
httpc:request(post, {?BASE_URL ++ Url, Headers, "application/json", jsx:encode(Body)}, ssl_opts(), []).
http_get(Url) -> http_get(Url, undefined).
http_get(Url, Token) ->
Headers = case Token of
undefined -> [];
_ -> [{"Authorization", "Bearer " ++ binary_to_list(Token)}]
end,
httpc:request(get, {?BASE_URL ++ Url, Headers}, ssl_opts(), []).
http_put(Url, Body, Token) ->
Headers = [{"Content-Type", "application/json"}, {"Authorization", "Bearer " ++ binary_to_list(Token)}],
httpc:request(put, {?BASE_URL ++ Url, Headers, "application/json", jsx:encode(Body)}, ssl_opts(), []).
http_delete(Url, Token) ->
Headers = [{"Authorization", "Bearer " ++ binary_to_list(Token)}],
httpc:request(delete, {?BASE_URL ++ Url, Headers}, ssl_opts(), []).
%% ── Вспомогательные функции ──────────────────────────────
extract_json({ok, {{_, 200, _}, _, Body}}, Field) ->
Map = jsx:decode(list_to_binary(Body), [return_maps]),
maps:get(Field, Map);
extract_json({ok, {{_, 201, _}, _, Body}}, Field) ->
Map = jsx:decode(list_to_binary(Body), [return_maps]),
maps:get(Field, Map);
extract_json(Response, _Field) ->
error({unexpected_response, Response}).
extract_json(Response, Field, ExpectedStatus) ->
case Response of
{ok, {{_, ExpectedStatus, _}, _, Body}} ->
Map = jsx:decode(list_to_binary(Body), [return_maps]),
maps:get(Field, Map);
_ ->
error({unexpected_response, Response})
end.
assert_status(Status, {ok, {{_, Status, _}, _, _}}) -> ok;
assert_status(Expected, {ok, {{_, Got, _}, _, _}}) ->
error({expected_status, Expected, got, Got}).
unique_email(Prefix) ->
list_to_binary([Prefix, "_", integer_to_binary(os:system_time(millisecond)), "@test.com"]).
register_and_login(Email, Password) ->
RegBody = #{email => Email, password => Password},
case http_post("/v1/register", RegBody) of
{ok, {{_, 201, _}, _, RegResp}} ->
Map = jsx:decode(list_to_binary(RegResp), [return_maps]),
maps:get(<<"token">>, Map);
{ok, {{_, 409, _}, _, _}} ->
LoginBody = #{email => Email, password => Password},
{ok, {{_, 200, _}, _, LoginResp}} = http_post("/v1/login", LoginBody),
Map = jsx:decode(list_to_binary(LoginResp), [return_maps]),
maps:get(<<"token">>, Map)
end.
login_custom_admin(Email, Password) ->
%% LoginBody = #{email => Email, password => Password},
LoginBody = jsx:encode(#{<<"email">> => Email, <<"password">> => Password}),
{ok, {{_, _, _}, _, LoginResp}} = httpc:request(post,
{?ADMIN_URL ++ "/v1/admin/login", [], "application/json", LoginBody}, ssl_opts(), []),
Map = jsx:decode(list_to_binary(LoginResp), [return_maps]),
maps:get(<<"token">>, Map).
-spec create_calendar(binary(), map()) -> binary().
create_calendar(Token, Params) ->
Response = http_post("/v1/calendars", Params, Token),
ct:pal(" create_calendar Response: ~p~n", [Response]),
Id = extract_json(Response, <<"id">>),
Id.
#{<<"id">> := CalId} = client_post(<<"/v1/calendars">>, Token, Params),
CalId.
-spec create_event(binary(), binary(), map()) -> binary().
create_event(Token, CalId, Params) ->
Url = "/v1/calendars/" ++ binary_to_list(CalId) ++ "/events",
Id = extract_json(http_post(Url, Params, Token), <<"id">>),
Id.
Path = <<"/v1/calendars/", CalId/binary, "/events">>,
#{<<"id">> := EventId} = client_post(Path, Token, Params),
EventId.
wait_for_server() -> wait_for_server(30).
wait_for_server(0) -> {error, timeout};
wait_for_server(Attempts) ->
case httpc:request(get, {?BASE_URL ++ "/health", []}, ssl_opts(), [{timeout, 1000}]) of
{ok, {{_, 200, _}, _, _}} -> ok;
_ -> timer:sleep(1000), wait_for_server(Attempts - 1)
end.
%%%===================================================================
%%% Внутренние функции
%%%===================================================================
format_datetime({{Year, Month, Day}, {Hour, Minute, Second}}) ->
iolist_to_binary(
io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ",
[Year, Month, Day, Hour, Minute, Second])
).
-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.