%%%------------------------------------------------------------------- %%% @doc Централизованный модуль для запуска API-тестов. %%% Предоставляет функции для выполнения HTTP-запросов %%% к административному и клиентскому API с автоматическим %%% логированием, проверкой статусов и конфигурацией %%% через стандартные переменные окружения. %%% @end %%%------------------------------------------------------------------- -module(api_test_runner). -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 , 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() -> case ct_mode() of "remote" -> os:getenv("API_HOST", "http://localhost:8080"); _ -> "http://localhost:8080" end. -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. -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. %%%=================================================================== %%% Учётные данные администраторов (из переменных окружения) %%%=================================================================== -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()), <>. -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. -spec verify_user(AdminToken :: binary(), UserId :: binary()) -> ok. verify_user(AdminToken, UserId) -> #{<<"token">> := Token} = admin_get( <<"/v1/admin/users/", UserId/binary, "/verification-token">>, AdminToken), {ok, 200, _, _} = client_request(post, <<"/v1/verify">>, <<>>, jsx:encode(#{<<"token">> => Token})), ok. %%%=================================================================== %%% Внутренние функции %%%=================================================================== -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.