From c2d2e934d9aefad078700dec066cfd9faad7fc66 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B5=D0=B9=20=D0=A1=D0=B0?= =?UTF-8?q?=D0=B1=D0=B8=D0=BB=D0=B8=D0=BD?= Date: Mon, 25 May 2026 17:50:57 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A0=D0=B0=D0=B7=D0=B4=D0=B5=D0=BB=20api=20?= =?UTF-8?q?=D0=9A=D0=B0=D0=BB=D0=B5=D0=BD=D0=B4=D0=B0=D1=80=D0=B8=20https:?= =?UTF-8?q?//git.sabilin.com/EventHub/EventHubBack/issues/22?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/core_calendar.erl | 16 +- src/eventhub_app.erl | 3 + .../admin/admin_handler_calendar_by_id.erl | 139 +++++++++++ .../admin/admin_handler_calendars.erl | 193 ++++++++++++++++ src/handlers/handler_utils.erl | 16 +- src/logic/logic_calendar.erl | 51 ++++- test/api/admins/admin_calendars_tests.erl | 216 ++++++++++++++++++ test/api_admins_SUITE.erl | 6 +- 8 files changed, 635 insertions(+), 5 deletions(-) create mode 100644 src/handlers/admin/admin_handler_calendar_by_id.erl create mode 100644 src/handlers/admin/admin_handler_calendars.erl create mode 100644 test/api/admins/admin_calendars_tests.erl diff --git a/src/core/core_calendar.erl b/src/core/core_calendar.erl index 9cfd39c..5dc60c8 100644 --- a/src/core/core_calendar.erl +++ b/src/core/core_calendar.erl @@ -1,7 +1,7 @@ -module(core_calendar). -include("records.hrl"). -export([create/4, create/5, get_by_id/1, list_by_owner/1, update/2, delete/1]). --export([count_calendars/0]). +-export([count_calendars/0, list_all/0]). -export([freeze/2, unfreeze/2]). % ← новые функции %% Создание календаря @@ -86,6 +86,13 @@ delete(Id) -> count_calendars() -> mnesia:table_info(calendar, size). +-spec list_all() -> [#calendar{}]. +list_all() -> + {atomic, Calendars} = mnesia:transaction(fun() -> + mnesia:foldl(fun(C, Acc) -> [C | Acc] end, [], calendar) + end), + Calendars. + %% ── НОВЫЕ ФУНКЦИИ ────────────────────────────────────────── freeze(Id, Reason) -> update(Id, [{status, frozen}, {reason, Reason}]). @@ -101,11 +108,16 @@ apply_updates(Calendar, Updates) -> set_field(title, Value, C) -> C#calendar{title = Value}; set_field(description, Value, C) -> C#calendar{description = Value}; +set_field(short_name, Value, C) -> C#calendar{short_name = Value}; +set_field(category, Value, C) -> C#calendar{category = Value}; +set_field(color, Value, C) -> C#calendar{color = Value}; +set_field(image_url, Value, C) -> C#calendar{image_url = Value}; +set_field(settings, Value, C) -> C#calendar{settings = Value}; set_field(tags, Value, C) -> C#calendar{tags = Value}; set_field(type, Value, C) -> C#calendar{type = Value}; set_field(confirmation, Value, C) -> C#calendar{confirmation = Value}; set_field(status, Value, C) -> C#calendar{status = Value}; set_field(rating_avg, Value, C) -> C#calendar{rating_avg = Value}; set_field(rating_count, Value, C) -> C#calendar{rating_count = Value}; -set_field(reason, Value, C) -> C#calendar{reason = Value}; % ← поддержка поля reason +set_field(reason, Value, C) -> C#calendar{reason = Value}; set_field(_, _, C) -> C. \ No newline at end of file diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index f9d21b4..7b216f7 100644 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -113,6 +113,9 @@ start_admin_http() -> % ================== ПОЛЬЗОВАТЕЛИ ================== {"/v1/admin/users", admin_handler_users, []}, {"/v1/admin/users/:id", admin_handler_user_by_id, []}, + % ================== КАЛЕНДАРИ ================== + {"/v1/admin/calendars", admin_handler_calendars, []}, + {"/v1/admin/calendars/:id", admin_handler_calendar_by_id, []}, % ================== СОБЫТИЯ ================== {"/v1/admin/events", admin_handler_events, []}, {"/v1/admin/events/:id", admin_handler_event_by_id, []}, diff --git a/src/handlers/admin/admin_handler_calendar_by_id.erl b/src/handlers/admin/admin_handler_calendar_by_id.erl new file mode 100644 index 0000000..f31180f --- /dev/null +++ b/src/handlers/admin/admin_handler_calendar_by_id.erl @@ -0,0 +1,139 @@ +-module(admin_handler_calendar_by_id). +-behaviour(cowboy_handler). +-export([init/2]). +-export([trails/0]). + +-include("records.hrl"). + +-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}. +init(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> get_calendar(Req); + <<"PUT">> -> update_calendar(Req); + <<"DELETE">> -> delete_calendar(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) + end. + +%%% Swagger +-spec trails() -> [map()]. +trails() -> + Path = <<"/v1/admin/calendars/{id}">>, + [ + #{ + path => Path, + method => <<"GET">>, + description => <<"Get calendar by ID (admin)">>, + tags => [<<"Admin Calendars">>], + parameters => [id_param()], + responses => #{ + 200 => #{description => <<"Calendar object">>, content => calendar_content()}, + 404 => #{description => <<"Not found">>} + } + }, + #{ + path => Path, + method => <<"PUT">>, + description => <<"Update calendar (admin)">>, + tags => [<<"Admin Calendars">>], + parameters => [id_param()], + requestBody => update_body(), + responses => #{ + 200 => #{description => <<"Updated calendar">>, content => calendar_content()}, + 400 => #{description => <<"Bad request">>}, + 404 => #{description => <<"Not found">>} + } + }, + #{ + path => Path, + method => <<"DELETE">>, + description => <<"Delete calendar (admin)">>, + tags => [<<"Admin Calendars">>], + parameters => [id_param()], + responses => #{ + 200 => #{description => <<"Deleted">>}, + 404 => #{description => <<"Not found">>} + } + } + ]. + +id_param() -> #{name => id, in => path, required => true, schema => #{type => string}}. +calendar_content() -> #{<<"application/json">> => #{schema => admin_handler_calendars:calendar_schema()}}. +update_body() -> + #{ + required => true, + content => #{ + <<"application/json">> => #{ + schema => #{ + type => object, + properties => #{ + title => #{type => string}, + description => #{type => string}, + is_public => #{type => boolean} + } + } + } + } + }. + +%%%=================================================================== +%%% HTTP-методы +%%%=================================================================== + +-spec get_calendar(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. +get_calendar(Req) -> + case handler_utils:auth_admin(Req) of + {ok, _AdminId, Req1} -> + Id = cowboy_req:binding(id, Req1), + case logic_calendar:admin_get_by_id(Id) of + {ok, Calendar} -> + Json = handler_utils:calendar_to_json(Calendar), + handler_utils:send_json(Req1, 200, Json); + {error, not_found} -> + handler_utils:send_error(Req1, 404, <<"Calendar not found">>) + end; + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. + +-spec update_calendar(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. +update_calendar(Req) -> + case handler_utils:auth_admin(Req) of + {ok, _AdminId, Req1} -> + Id = cowboy_req:binding(id, Req1), + case cowboy_req:read_body(Req1) of + {ok, Body, Req2} -> + try jsx:decode(Body, [return_maps]) of + Updates -> + case logic_calendar:admin_update(Id, Updates) of + {ok, Updated} -> + Json = handler_utils:calendar_to_json(Updated), + handler_utils:send_json(Req2, 200, Json); + {error, not_found} -> + handler_utils:send_error(Req2, 404, <<"Calendar not found">>); + {error, _} -> + handler_utils:send_error(Req2, 400, <<"Update failed">>) + end + catch + _:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON">>) + end; + {error, _} -> + handler_utils:send_error(Req1, 400, <<"Missing body">>) + end; + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. + +-spec delete_calendar(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}. +delete_calendar(Req) -> + case handler_utils:auth_admin(Req) of + {ok, _AdminId, Req1} -> + Id = cowboy_req:binding(id, Req1), + case logic_calendar:admin_delete(Id) of + ok -> + handler_utils:send_json(Req1, 200, #{}); + {error, not_found} -> + handler_utils:send_error(Req1, 404, <<"Calendar not found">>) + end; + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_calendars.erl b/src/handlers/admin/admin_handler_calendars.erl new file mode 100644 index 0000000..587ae11 --- /dev/null +++ b/src/handlers/admin/admin_handler_calendars.erl @@ -0,0 +1,193 @@ +-module(admin_handler_calendars). +-behaviour(cowboy_handler). +-export([init/2]). +-export([trails/0]). +-export([calendar_schema/0]). % для использования в admin_handler_calendar_by_id + +-include("records.hrl"). + +init(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> list_calendars(Req); + _ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>) + end. + +trails() -> + [ + #{ + path => <<"/v1/admin/calendars">>, + method => <<"GET">>, + description => <<"List all calendars with filtering, sorting, pagination (admin)">>, + tags => [<<"Admin Calendars">>], + parameters => [ + #{name => limit, in => query, schema => #{type => integer}, description => <<"Limit">>}, + #{name => offset, in => query, schema => #{type => integer}, description => <<"Offset">>}, + #{name => status, in => query, schema => #{type => string}, description => <<"Filter by status (active, frozen, deleted)">>}, + #{name => type, in => query, schema => #{type => string}, description => <<"Filter by type (personal, commercial)">>}, + #{name => q, in => query, schema => #{type => string}, description => <<"Search query">>}, + #{name => sort_by, in => query, schema => #{type => string, enum => [<<"title">>, <<"created_at">>, <<"updated_at">>]}, description => <<"Sort field">>}, + #{name => sort_order, in => query, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}, description => <<"Sort direction">>} + ], + responses => #{ + 200 => #{ + description => <<"Array of calendars">>, + content => #{ + <<"application/json">> => #{ + schema => #{ + type => array, + items => calendar_schema() + } + } + } + }, + 403 => #{description => <<"Forbidden">>} + } + } + ]. + +calendar_schema() -> + #{ + type => object, + properties => #{ + id => #{type => string}, + owner_id => #{type => string}, + title => #{type => string}, + description => #{type => string}, + short_name => #{type => string}, + category => #{type => string}, + color => #{type => string}, + image_url => #{type => string}, + settings => #{type => object}, + tags => #{type => array, items => #{type => string}}, + type => #{type => string, enum => [<<"personal">>, <<"commercial">>]}, + confirmation => #{type => string}, + rating_avg => #{type => number}, + rating_count => #{type => integer}, + status => #{type => string, enum => [<<"active">>, <<"frozen">>, <<"deleted">>]}, + reason => #{type => string}, + created_at => #{type => string, format => date_time}, + updated_at => #{type => string, format => date_time} + } + }. + +%%%=================================================================== +%%% HTTP-метод +%%%=================================================================== + +list_calendars(Req) -> + case handler_utils:auth_admin(Req) of + {ok, _AdminId, Req1} -> + % Парсим параметры + Qs = cowboy_req:parse_qs(Req1), + Filters = parse_filters(Qs), + Sort = parse_sort(Qs), + Pagination = handler_utils:parse_pagination_params(Req1), + + % Получаем все календари + {ok, AllCalendars} = logic_calendar:admin_list_all(), + + % Применяем фильтры + Filtered = apply_filters(AllCalendars, Filters), + + % Сортируем + Sorted = apply_sort(Filtered, Sort), + + % Пагинация + Total = length(Sorted), + Start = maps:get(offset, Pagination) + 1, + Limit = maps:get(limit, Pagination), + Page = lists:sublist(Sorted, Start, Limit), + + Json = [handler_utils:calendar_to_json(C) || C <- Page], + ExtraHeaders = handler_utils:pagination_headers(Pagination, Total), + handler_utils:send_json(Req1, 200, Json, ExtraHeaders); + {error, Code, Msg, Req1} -> + handler_utils:send_error(Req1, Code, Msg) + end. + +%%%=================================================================== +%%% Парсинг параметров +%%%=================================================================== + +parse_filters(Qs) -> + Status = proplists:get_value(<<"status">>, Qs, undefined), + Type = proplists:get_value(<<"type">>, Qs, undefined), + Query = proplists:get_value(<<"q">>, Qs, undefined), + #{ + status => safe_to_atom(Status), + type => safe_to_atom(Type), + q => Query + }. + +safe_to_atom(undefined) -> undefined; +safe_to_atom(Bin) when is_binary(Bin) -> + try binary_to_existing_atom(Bin, utf8) + catch error:badarg -> undefined + end. + +parse_sort(Qs) -> + SortBy = proplists:get_value(<<"sort_by">>, Qs, <<"created_at">>), + SortOrder = proplists:get_value(<<"sort_order">>, Qs, <<"desc">>), + #{ + sort_by => SortBy, + sort_order => SortOrder + }. + +%%%=================================================================== +%%% Фильтрация +%%%=================================================================== + +apply_filters(Calendars, Filters) -> + StatusFilter = maps:get(status, Filters, undefined), + TypeFilter = maps:get(type, Filters, undefined), + QueryFilter = maps:get(q, Filters, undefined), + + F1 = case StatusFilter of + undefined -> Calendars; + Status -> [C || C <- Calendars, C#calendar.status =:= Status] + end, + + F2 = case TypeFilter of + undefined -> F1; + Type -> [C || C <- F1, C#calendar.type =:= Type] + end, + + case QueryFilter of + undefined -> F2; + Q -> + LowerQ = string:lowercase(binary_to_list(Q)), + [C || C <- F2, + string:str(string:lowercase(binary_to_list(C#calendar.title)), LowerQ) > 0 orelse + string:str(string:lowercase(binary_to_list(C#calendar.description)), LowerQ) > 0] + end. + +%%%=================================================================== +%%% Сортировка +%%%=================================================================== + +apply_sort(Calendars, #{sort_by := SortBy, sort_order := SortOrder}) -> + Field = binary_to_sort_field(SortBy), + Order = case SortOrder of + <<"asc">> -> fun(A, B) -> A < B end; + <<"desc">> -> fun(A, B) -> A > B end; + _ -> fun(A, B) -> A >= B end % default desc + end, + lists:sort(fun(A, B) -> + ValA = get_field(A, Field), + ValB = get_field(B, Field), + case {ValA, ValB} of + {undefined, _} -> false; + {_, undefined} -> true; + _ -> Order(ValA, ValB) + end + end, Calendars). + +binary_to_sort_field(<<"title">>) -> title; +binary_to_sort_field(<<"created_at">>) -> created_at; +binary_to_sort_field(<<"updated_at">>) -> updated_at; +binary_to_sort_field(_) -> created_at. + +get_field(#calendar{title = Title}, title) -> Title; +get_field(#calendar{created_at = CreatedAt}, created_at) -> CreatedAt; +get_field(#calendar{updated_at = UpdatedAt}, updated_at) -> UpdatedAt; +get_field(_, _) -> undefined. \ No newline at end of file diff --git a/src/handlers/handler_utils.erl b/src/handlers/handler_utils.erl index b760ff6..e7ed1f3 100644 --- a/src/handlers/handler_utils.erl +++ b/src/handlers/handler_utils.erl @@ -26,7 +26,9 @@ calendar_to_json/1, subscription_to_json/1, trails_for_crud/4, - is_superadmin/1]). + is_superadmin/1, + pagination_headers/2 +]). -include("records.hrl"). @@ -118,6 +120,18 @@ parse_int_qs(undefined, Default) -> Default; parse_int_qs(Bin, Default) -> try binary_to_integer(Bin) catch _:_ -> Default end. +-spec pagination_headers(map(), non_neg_integer()) -> map(). +pagination_headers(#{limit := Limit, offset := Offset}, Total) -> + NextOffset = Offset + Limit, + HasMore = NextOffset < Total, + #{ + <<"x-total-count">> => integer_to_binary(Total), + <<"x-next-offset">> => case HasMore of + true -> integer_to_binary(NextOffset); + false -> <<"">> + end + }. + %% @doc Преобразует бинарный ISO8601 параметр в datetime(). -spec parse_datetime_qs(binary() | undefined) -> calendar:datetime() | undefined. parse_datetime_qs(undefined) -> undefined; diff --git a/src/logic/logic_calendar.erl b/src/logic/logic_calendar.erl index c42dfff..a6d2f1c 100644 --- a/src/logic/logic_calendar.erl +++ b/src/logic/logic_calendar.erl @@ -4,6 +4,7 @@ -export([create_calendar/3, create_calendar/4, get_calendar/2, list_calendars/1, update_calendar/3, delete_calendar/2]). -export([can_access/2, can_edit/2]). +-export([admin_list_all/0, admin_get_by_id/1, admin_update/2, admin_delete/1]). %% Создание календаря с политикой по умолчанию (manual) create_calendar(UserId, Title, Description) -> @@ -113,4 +114,52 @@ validate_update({confirmation, Value}) -> {timeout, N} when is_integer(N), N > 0 -> true; _ -> false end; -validate_update(_) -> false. \ No newline at end of file +validate_update(_) -> false. + +%% ─── Административные функции ──────────────────────────────────────── +-spec admin_list_all() -> {ok, [map()]} | {error, term()}. +admin_list_all() -> + Calendars = core_calendar:list_all(), + {ok, Calendars}. + +-spec admin_get_by_id(binary()) -> {ok, #calendar{}} | {error, not_found}. +admin_get_by_id(CalendarId) -> + core_calendar:get_by_id(CalendarId). + +-spec admin_update(binary(), map()) -> {ok, #calendar{}} | {error, term()}. +admin_update(CalendarId, Updates) when is_map(Updates) -> + %% Преобразуем map в список {атом, значение}, исключая служебные ключи + TupleList = maps:fold(fun key_to_update/3, [], Updates), + core_calendar:update(CalendarId, TupleList). + +-spec admin_delete(binary()) -> ok | {error, term()}. +admin_delete(Id) -> + case core_calendar:delete(Id) of + {ok, _} -> ok; + Error -> Error + end. + +key_to_update(<<"title">>, V, Acc) -> [{title, V} | Acc]; +key_to_update(<<"description">>, V, Acc) -> [{description, V} | Acc]; +key_to_update(<<"short_name">>, V, Acc) -> [{short_name, V} | Acc]; +key_to_update(<<"category">>, V, Acc) -> [{category, V} | Acc]; +key_to_update(<<"color">>, V, Acc) -> [{color, V} | Acc]; +key_to_update(<<"image_url">>, V, Acc) -> [{image_url, V} | Acc]; +key_to_update(<<"settings">>, V, Acc) -> [{settings, V} | Acc]; +key_to_update(<<"tags">>, V, Acc) -> [{tags, V} | Acc]; +key_to_update(<<"type">>, V, Acc) -> + Type = binary_to_existing_atom(V, utf8), + [{type, Type} | Acc]; +key_to_update(<<"status">>, V, Acc) -> + Status = binary_to_existing_atom(V, utf8), + [{status, Status} | Acc]; +key_to_update(<<"confirmation">>, V, Acc) -> + % confirmation может быть строкой "auto", "manual" или "timeout:N" + Conf = case V of + <<"auto">> -> auto; + <<"manual">> -> manual; + _ -> V % для сложных значений пока оставляем как есть + end, + [{confirmation, Conf} | Acc]; +key_to_update(<<"reason">>, V, Acc) -> [{reason, V} | Acc]; +key_to_update(_, _V, Acc) -> Acc. \ No newline at end of file diff --git a/test/api/admins/admin_calendars_tests.erl b/test/api/admins/admin_calendars_tests.erl new file mode 100644 index 0000000..4d8f19c --- /dev/null +++ b/test/api/admins/admin_calendars_tests.erl @@ -0,0 +1,216 @@ +%%%------------------------------------------------------------------- +%%% @doc API-тесты для административного управления календарями. +%%% Покрывает эндпоинты: +%%% GET /v1/admin/calendars +%%% GET /v1/admin/calendars/:id +%%% PUT /v1/admin/calendars/:id +%%% DELETE /v1/admin/calendars/:id +%%% Проверяет пагинацию, фильтрацию по статусу/типу, поиск и сортировку. +%%% @end +%%%------------------------------------------------------------------- +-module(admin_calendars_tests). +-include_lib("eunit/include/eunit.hrl"). +-export([test/0]). + +%%%=================================================================== +%%% Главная тестовая функция +%%%=================================================================== +-spec test() -> ok. +test() -> + ct:pal("=== Admin Calendars Tests ==="), + {AdminToken, UserToken, CalIds} = setup(), + [CalId1, CalId2, _CalId3] = CalIds, + try + %% Основные CRUD + test_get_calendar(AdminToken, CalId1), + test_get_calendar_not_found(AdminToken), + test_update_calendar(AdminToken, CalId1, UserToken), + test_update_calendar_not_found(AdminToken), + test_update_calendar_invalid_body(AdminToken, CalId1), + test_delete_calendar(AdminToken, CalId2), + test_delete_calendar_not_found(AdminToken), + + %% Пагинация + test_pagination(AdminToken), + %% Фильтрация + test_filter_by_status(AdminToken), + test_filter_by_type(AdminToken), + test_search(AdminToken), + %% Сортировка + test_sort_by_title(AdminToken), + test_sort_by_created_at(AdminToken) + after + cleanup(AdminToken, CalIds) + end, + ct:pal("=== All admin calendars tests passed ==="), + ok. + +%%%=================================================================== +%%% Подготовка и очистка +%%%=================================================================== +-spec setup() -> {binary(), binary(), [binary()]}. +setup() -> + ct:pal(" Setup: register user and create calendars"), + AdminToken = api_test_runner:get_admin_token(), + + Email = api_test_runner:unique_email(<<"caladmin">>), + Password = <<"testpass">>, + UserToken = api_test_runner:register_and_login(Email, Password), + + CalId1 = create_calendar(UserToken, <<"Personal Active">>, <<"Desc1">>), + CalId2 = create_calendar(UserToken, <<"Commercial">>, <<"Desc2">>), + CalId3 = create_calendar(UserToken, <<"Frozen">>, <<"Desc3">>), + + update_calendar_admin(AdminToken, CalId2, #{<<"type">> => <<"commercial">>}), + update_calendar_admin(AdminToken, CalId3, #{<<"status">> => <<"frozen">>}), + + ct:pal(" Created calendars: ~p", [[CalId1, CalId2, CalId3]]), + {AdminToken, UserToken, [CalId1, CalId2, CalId3]}. + +-spec create_calendar(binary(), binary(), binary()) -> binary(). +create_calendar(UserToken, Title, Description) -> + Body = jsx:encode(#{title => Title, description => Description}), + {ok, 201, _, Resp} = api_test_runner:client_request(post, <<"/v1/calendars">>, UserToken, Body), + #{<<"id">> := Id} = jsx:decode(list_to_binary(Resp), [return_maps]), + Id. + +-spec update_calendar_admin(binary(), binary(), map()) -> ok. +update_calendar_admin(AdminToken, CalId, Updates) -> + % Получаем текущий календарь + {ok, 200, _, RespBody} = api_test_runner:admin_request(get, <<"/v1/admin/calendars/", CalId/binary>>, AdminToken, <<"">>), + Cal = jsx:decode(list_to_binary(RespBody), [return_maps]), + % Сливаем изменения + UpdatedCal = maps:merge(Cal, Updates), + Body = jsx:encode(UpdatedCal), + {ok, 200, _, _} = api_test_runner:admin_request(put, <<"/v1/admin/calendars/", CalId/binary>>, AdminToken, Body), + ok. + +-spec cleanup(binary(), [binary()]) -> ok. +cleanup(_AdminToken, []) -> ok; +cleanup(AdminToken, [Id | Rest]) -> + catch api_test_runner:admin_request(delete, <<"/v1/admin/calendars/", Id/binary>>, AdminToken, <<"">>), + cleanup(AdminToken, Rest). + +%%%=================================================================== +%%% CRUD тесты +%%%=================================================================== +test_get_calendar(AdminToken, CalId) -> + ct:pal(" TEST: Get calendar by ID (admin)"), + {ok, 200, _, RespBody} = api_test_runner:admin_request(get, <<"/v1/admin/calendars/", CalId/binary>>, AdminToken, <<"">>), + Cal = jsx:decode(list_to_binary(RespBody), [return_maps]), + ?assertEqual(CalId, maps:get(<<"id">>, Cal)), + ct:pal(" OK"). + +test_get_calendar_not_found(AdminToken) -> + ct:pal(" TEST: Get non-existent calendar"), + {ok, 404, _, _} = api_test_runner:admin_request(get, <<"/v1/admin/calendars/nonexistent">>, AdminToken, <<"">>), + ct:pal(" OK"). + +test_update_calendar(AdminToken, CalId, UserToken) -> + ct:pal(" TEST: Update calendar (admin)"), + NewTitle = <<"Updated by admin">>, + update_calendar_admin(AdminToken, CalId, #{<<"title">> => NewTitle}), + % Проверяем через пользовательский API + {ok, 200, _, UserResp} = api_test_runner:client_request(get, <<"/v1/calendars/", CalId/binary>>, UserToken, <<"">>), + UserCal = jsx:decode(list_to_binary(UserResp), [return_maps]), + ?assertEqual(NewTitle, maps:get(<<"title">>, UserCal)), + ct:pal(" OK"). + +test_update_calendar_not_found(AdminToken) -> + ct:pal(" TEST: Update non-existent calendar"), + {ok, 404, _, _} = api_test_runner:admin_request(put, <<"/v1/admin/calendars/nonexistent">>, AdminToken, jsx:encode(#{title => <<"x">>})), + ct:pal(" OK"). + +test_update_calendar_invalid_body(AdminToken, CalId) -> + ct:pal(" TEST: Update calendar with invalid JSON"), + {ok, 400, _, _} = api_test_runner:admin_request(put, <<"/v1/admin/calendars/", CalId/binary>>, AdminToken, <<"not json">>), + ct:pal(" OK"). + +test_delete_calendar(AdminToken, CalId) -> + ct:pal(" TEST: Delete calendar (admin)"), + {ok, 200, _, _} = api_test_runner:admin_request(delete, <<"/v1/admin/calendars/", CalId/binary>>, AdminToken, <<"">>), + % Проверяем, что календарь не удалён физически, а помечен как deleted (soft delete) + {ok, 200, _, RespBody} = api_test_runner:admin_request(get, <<"/v1/admin/calendars/", CalId/binary>>, AdminToken, <<"">>), + Cal = jsx:decode(list_to_binary(RespBody), [return_maps]), + ?assertEqual(<<"deleted">>, maps:get(<<"status">>, Cal)), + ct:pal(" OK: calendar soft deleted"). + +test_delete_calendar_not_found(AdminToken) -> + ct:pal(" TEST: Delete non-existent calendar"), + {ok, 404, _, _} = api_test_runner:admin_request(delete, <<"/v1/admin/calendars/nonexistent">>, AdminToken, <<"">>), + ct:pal(" OK"). + +%%%=================================================================== +%%% Пагинация +%%%=================================================================== +test_pagination(AdminToken) -> + ct:pal(" TEST: Pagination"), + {ok, 200, Headers1, Body1} = api_test_runner:admin_request(get, <<"/v1/admin/calendars?limit=2&offset=0">>, AdminToken, <<"">>), + List1 = jsx:decode(list_to_binary(Body1), [return_maps]), + ?assertEqual(2, length(List1)), + TotalCount = list_to_integer( + proplists:get_value("x-total-count", Headers1, "0") + ), + ?assert(TotalCount >= 3), + + {ok, 200, _, Body2} = api_test_runner:admin_request(get, <<"/v1/admin/calendars?limit=2&offset=2">>, AdminToken, <<"">>), + List2 = jsx:decode(list_to_binary(Body2), [return_maps]), + ?assert(length(List2) >= 1), + ct:pal(" OK: got pages"). + +%%%=================================================================== +%%% Фильтрация +%%%=================================================================== +test_filter_by_status(AdminToken) -> + ct:pal(" TEST: Filter by status"), + {ok, 200, _, BodyActive} = api_test_runner:admin_request(get, <<"/v1/admin/calendars?status=active">>, AdminToken, <<"">>), + ActiveList = jsx:decode(list_to_binary(BodyActive), [return_maps]), + [?assertEqual(<<"active">>, maps:get(<<"status">>, C)) || C <- ActiveList], + + {ok, 200, _, BodyFrozen} = api_test_runner:admin_request(get, <<"/v1/admin/calendars?status=frozen">>, AdminToken, <<"">>), + FrozenList = jsx:decode(list_to_binary(BodyFrozen), [return_maps]), + [?assertEqual(<<"frozen">>, maps:get(<<"status">>, C)) || C <- FrozenList], + ?assert(length(FrozenList) >= 1), + ct:pal(" OK: status filter works"). + +test_filter_by_type(AdminToken) -> + ct:pal(" TEST: Filter by type"), + {ok, 200, _, Body} = api_test_runner:admin_request(get, <<"/v1/admin/calendars?type=commercial">>, AdminToken, <<"">>), + CommercialList = jsx:decode(list_to_binary(Body), [return_maps]), + [?assertEqual(<<"commercial">>, maps:get(<<"type">>, C)) || C <- CommercialList], + ?assert(length(CommercialList) >= 1), + ct:pal(" OK: type filter works"). + +test_search(AdminToken) -> + ct:pal(" TEST: Search (q parameter)"), + {ok, 200, _, Body} = api_test_runner:admin_request(get, <<"/v1/admin/calendars?q=commercial">>, AdminToken, <<"">>), + Results = jsx:decode(list_to_binary(Body), [return_maps]), + ?assert(length(Results) >= 1), + HasMatch = lists:any(fun(C) -> + Title = string:lowercase(binary_to_list(maps:get(<<"title">>, C, <<>>))), + Desc = string:lowercase(binary_to_list(maps:get(<<"description">>, C, <<>>))), + string:str(Title, "commercial") > 0 orelse + string:str(Desc, "commercial") > 0 + end, Results), + ?assert(HasMatch), + ct:pal(" OK: search works"). + +%%%=================================================================== +%%% Сортировка +%%%=================================================================== +test_sort_by_title(AdminToken) -> + ct:pal(" TEST: Sort by title asc"), + {ok, 200, _, Body} = api_test_runner:admin_request(get, <<"/v1/admin/calendars?sort_by=title&sort_order=asc">>, AdminToken, <<"">>), + List = jsx:decode(list_to_binary(Body), [return_maps]), + Titles = [maps:get(<<"title">>, C) || C <- List], + ?assertEqual(lists:sort(Titles), Titles), + ct:pal(" OK: sorted by title"). + +test_sort_by_created_at(AdminToken) -> + ct:pal(" TEST: Sort by created_at desc"), + {ok, 200, _, Body} = api_test_runner:admin_request(get, <<"/v1/admin/calendars?sort_by=created_at&sort_order=desc">>, AdminToken, <<"">>), + List = jsx:decode(list_to_binary(Body), [return_maps]), + Dates = [maps:get(<<"created_at">>, C) || C <- List], + Sorted = lists:reverse(lists:sort(Dates)), + ?assertEqual(Sorted, Dates), + ct:pal(" OK: sorted by created_at desc"). \ No newline at end of file diff --git a/test/api_admins_SUITE.erl b/test/api_admins_SUITE.erl index 412080e..15bec28 100644 --- a/test/api_admins_SUITE.erl +++ b/test/api_admins_SUITE.erl @@ -25,6 +25,7 @@ %% @doc Возвращает список тестовых кейсов. all() -> [ + admin_test_calendars, admin_test_events, admin_test_reviews, admin_test_users, @@ -85,7 +86,10 @@ end_per_suite(Config) -> %%% Тестовые кейсы %%%=================================================================== -%% @doc Тесты для событий. +%% @doc Тесты. +admin_test_calendars(_Config) -> + admin_calendars_tests:test(). + admin_test_events(_Config) -> admin_events_tests:test().