%%%------------------------------------------------------------------- %%% @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 , 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]). %%%=================================================================== %%% Конфигурация окружения (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_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()), <>. %% Уникальное сообщение тикета на прогон (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])), <>. -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.