From a34e36b96697c84ec903d6b3c9c448caff705889 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: Sat, 9 May 2026 13:38:54 +0300 Subject: [PATCH] =?UTF-8?q?=D0=9F=D0=B0=D0=B3=D0=B8=D0=BD=D0=B0=D1=86?= =?UTF-8?q?=D0=B8=D1=8F,=20=D1=84=D0=B8=D0=BB=D1=8C=D1=82=D1=80=D1=8B=20?= =?UTF-8?q?=D0=B8=20=D1=81=D0=BE=D1=80=D1=82=D0=B8=D1=80=D0=BE=D0=B2=D0=BA?= =?UTF-8?q?=D0=B8=20=D0=A7=D0=B0=D1=81=D1=82=D1=8C=201=20https://git.sabil?= =?UTF-8?q?in.com/EventHub/EventHubBack/issues/20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/admin_handler_event_by_id.erl | 10 +- src/handlers/admin/admin_handler_events.erl | 73 ++++---- src/infra/infra_mnesia.erl | 2 + src/logic/logic_event.erl | 110 ++++++++++++ test/api/api_admin_tests.erl | 161 +++++++++++++++++- 5 files changed, 316 insertions(+), 40 deletions(-) diff --git a/src/handlers/admin/admin_handler_event_by_id.erl b/src/handlers/admin/admin_handler_event_by_id.erl index 2626eeb..cea85fb 100644 --- a/src/handlers/admin/admin_handler_event_by_id.erl +++ b/src/handlers/admin/admin_handler_event_by_id.erl @@ -113,12 +113,14 @@ convert_field({<<"location">>, Val}) -> {location, Val}; convert_field({<<"tags">>, Val}) -> {tags, Val}; convert_field({<<"capacity">>, Val}) -> {capacity, Val}; convert_field({<<"online_link">>, Val}) -> {online_link, Val}; -convert_field({<<"status">>, Val}) -> {status, Val}; +convert_field({<<"status">>, Val}) -> + try binary_to_existing_atom(Val, utf8) of + Atom -> {status, Atom} + catch + error:badarg -> {status, Val} % fallback, но лучше залогировать + end; convert_field(Other) -> Other. -%% event_to_json, datetime_to_iso8601, parse_datetime, parse_datetime_binary -%% берутся те же, что и в admin_handler_events.erl (можно вынести в общий модуль, -%% но для простоты дублируем). event_to_json(Event) -> LocationJson = case Event#event.location of undefined -> null; diff --git a/src/handlers/admin/admin_handler_events.erl b/src/handlers/admin/admin_handler_events.erl index 04b3276..698f222 100644 --- a/src/handlers/admin/admin_handler_events.erl +++ b/src/handlers/admin/admin_handler_events.erl @@ -6,37 +6,51 @@ init(Req, _Opts) -> case cowboy_req:method(Req) of - <<"GET">> -> - list_all_events(Req); - _ -> - send_error(Req, 405, <<"Method not allowed">>) + <<"GET">> -> list_all_events(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) end. -%% GET /v1/admin/events list_all_events(Req) -> case auth_admin(Req) of {ok, _AdminId, Req1} -> - Filters = parse_filters(Req1), - {ok, Events} = logic_event:list_all_events(Filters), + Params = parse_admin_event_search(Req1), + {ok, Total, Events} = logic_event:search_events(Params), Json = [event_to_json(E) || E <- Events], - send_json(Req1, 200, Json); + Limit = maps:get(limit, Params, 50), + Offset = maps:get(offset, Params, 0), + RangeEnd = min(Offset + Limit - 1, Total - 1), + Headers = #{ + <<"content-type">> => <<"application/json">>, + <<"content-range">> => iolist_to_binary( + io_lib:format("items ~B-~B/~B", [Offset, RangeEnd, Total])), + <<"x-total-count">> => integer_to_binary(Total), + <<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">> + }, + Body = jsx:encode(Json), + cowboy_req:reply(200, Headers, Body, Req1), + {ok, Body, []}; {error, Code, Msg, Req1} -> send_error(Req1, Code, Msg) end. -%% --- Вспомогательные функции --- - -parse_filters(Req) -> +parse_admin_event_search(Req) -> Qs = cowboy_req:parse_qs(Req), - lists:filtermap( - fun - ({<<"from">>, Val}) -> {true, {from, parse_datetime_binary(Val)}}; - ({<<"to">>, Val}) -> {true, {to, parse_datetime_binary(Val)}}; - (_) -> false - end, - Qs - ). + #{ + from => parse_datetime_qs(proplists:get_value(<<"from">>, Qs)), + to => parse_datetime_qs(proplists:get_value(<<"to">>, Qs)), + status => proplists:get_value(<<"status">>, Qs, undefined), + calendar_id => proplists:get_value(<<"calendar_id">>, Qs, undefined), + title => proplists:get_value(<<"title">>, Qs, undefined), + q => proplists:get_value(<<"q">>, Qs, undefined), + limit => parse_int_qs(proplists:get_value(<<"limit">>, Qs), 50), + offset => parse_int_qs(proplists:get_value(<<"offset">>, Qs), 0), + sort => proplists:get_value(<<"sort">>, Qs, <<"created_at">>), + order => proplists:get_value(<<"order">>, Qs, <<"desc">>) + }. +%%-------------------------------------------------------------------- +%% Вспомогательные функции +%%-------------------------------------------------------------------- auth_admin(Req) -> case handler_auth:authenticate(Req) of {ok, AdminId, Req1} -> @@ -48,7 +62,14 @@ auth_admin(Req) -> {error, Code, Msg, Req1} end. -%% Сериализация события (полностью скопирована из handler_event_by_id.erl) +parse_int_qs(undefined, Default) -> Default; +parse_int_qs(Bin, Default) -> + try binary_to_integer(Bin) catch _:_ -> Default end. + +parse_datetime_qs(undefined) -> undefined; +parse_datetime_qs(Bin) -> + case parse_datetime(Bin) of {ok, Dt} -> Dt; _ -> undefined end. + event_to_json(Event) -> LocationJson = case Event#event.location of undefined -> null; @@ -97,12 +118,6 @@ datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> datetime_to_iso8601(undefined) -> undefined. -parse_datetime_binary(Str) -> - case parse_datetime(Str) of - {ok, Dt} -> Dt; - _ -> undefined - end. - parse_datetime(Str) -> try [DateStr, TimeStr] = string:split(Str, "T"), @@ -119,12 +134,6 @@ parse_datetime(Str) -> catch _:_ -> {error, invalid_format} end. -send_json(Req, Status, Data) -> - Body = jsx:encode(Data), - Headers = #{<<"content-type">> => <<"application/json">>}, - cowboy_req:reply(Status, Headers, Body, Req), - {ok, Body, []}. - send_error(Req, Status, Message) -> Body = jsx:encode(#{error => Message}), cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), diff --git a/src/infra/infra_mnesia.erl b/src/infra/infra_mnesia.erl index cedb5a9..f6b8b07 100644 --- a/src/infra/infra_mnesia.erl +++ b/src/infra/infra_mnesia.erl @@ -227,6 +227,8 @@ table_opts(admin_session) -> [{ram_copies, [node()]}, {attributes, record_info(f create_indices() -> mnesia:add_table_index(event, calendar_id), + mnesia:add_table_index(event, title), + mnesia:add_table_index(event, created_at), mnesia:add_table_index(event, start_time), mnesia:add_table_index(event, event_type), mnesia:add_table_index(event, master_id), diff --git a/src/logic/logic_event.erl b/src/logic/logic_event.erl index 82da62f..327dbd1 100644 --- a/src/logic/logic_event.erl +++ b/src/logic/logic_event.erl @@ -6,6 +6,9 @@ -export([validate_event_time/1, validate_event_time/2, get_occurrences/3, cancel_occurrence/3]). -export([materialize_for_booking/3]). -export([list_all_events/1, get_event_admin/1, update_event_admin/2, delete_event_admin/1]). +-export([search_events/1]). + +-define(DEFAULT_SEARCH_DAYS, 30). %% Создание одиночного события create_event(UserId, CalendarId, Title, StartTime, Duration) -> @@ -168,6 +171,112 @@ delete_event(UserId, EventId) -> Error end. +%% @doc Поиск событий с пагинацией, фильтрацией и сортировкой +search_events(Params) -> + #{ + from := FromIn, to := ToIn, + status := StatusFilter, + calendar_id := CalId, + title := TitleExact, + q := Query, + limit := Limit, + offset := Offset, + sort := SortField, + order := Order + } = Params, + {From, To} = ensure_time_range(FromIn, ToIn), + %% 1. Получаем исходный список событий + AllEvents = case CalId of + undefined -> + % Без календаря — только активные мастер-события + core_event:list_all(); + _ -> + % Для конкретного календаря загружаем все события (любой статус) + mnesia:dirty_index_match_object( + event, + #event{calendar_id = CalId, _ = '_'}, + calendar_id + ) + end, + %% 2. Фильтрация по дате (игнорируем undefined и instance) + TimeFiltered = [E || E <- AllEvents, + E#event.is_instance =:= false, + E#event.start_time =/= undefined, + E#event.start_time >= From, + E#event.start_time =< To], + %% 3. Фильтр по точному названию + TitleFiltered = if TitleExact /= undefined -> + [E || E <- TimeFiltered, E#event.title =:= TitleExact]; + true -> TimeFiltered + end, + %% 4. Пост‑фильтры по статусу и поисковой строке + %% Определяем эффективный фильтр статуса + EffectiveStatus = case {CalId, StatusFilter} of + {undefined, undefined} -> undefined; % без календаря → только active + {_, undefined} -> <<"all">>; % конкретный календарь → все статусы + _ -> StatusFilter + end, + FinalFiltered = apply_post_filters(TitleFiltered, EffectiveStatus, Query), + %% 5. Сортировка и пагинация + SortedEvents = sort_events(FinalFiltered, SortField, Order), + Total = length(SortedEvents), + Page = lists:sublist(SortedEvents, Offset + 1, Limit), + {ok, Total, Page}. + +%% Дополнительная фильтрация по статусу и подстроке +apply_post_filters(Events, StatusFilter, Query) -> + E1 = case StatusFilter of + <<"all">> -> Events; + undefined -> [E || E <- Events, E#event.status =:= active]; + _ -> + try binary_to_existing_atom(StatusFilter, utf8) of + Atom -> [E || E <- Events, E#event.status =:= Atom] + catch + error:badarg -> Events + end + end, + case Query of + undefined -> E1; + _ -> [E || E <- E1, + string:str(binary_to_list(E#event.title), binary_to_list(Query)) > 0 orelse + string:str(binary_to_list(E#event.description), binary_to_list(Query)) > 0] + end. + +%%-------------------------------------------------------------------- +%% Внутренние функции для search_events +%%-------------------------------------------------------------------- +ensure_time_range(undefined, undefined) -> + NowSec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()), + FromSec = NowSec - ?DEFAULT_SEARCH_DAYS * 86400, + ToSec = NowSec + ?DEFAULT_SEARCH_DAYS * 86400, + {calendar:gregorian_seconds_to_datetime(FromSec), + calendar:gregorian_seconds_to_datetime(ToSec)}; +ensure_time_range(From, undefined) -> + {From, calendar:gregorian_seconds_to_datetime( + calendar:datetime_to_gregorian_seconds(From) + ?DEFAULT_SEARCH_DAYS * 2 * 86400)}; +ensure_time_range(undefined, To) -> + {calendar:gregorian_seconds_to_datetime( + calendar:datetime_to_gregorian_seconds(To) - ?DEFAULT_SEARCH_DAYS * 2 * 86400), To}; +ensure_time_range(From, To) -> {From, To}. + +sort_events(Events, SortField, Order) -> + Field = binary_to_existing_atom(SortField, utf8), + Sorted = lists:sort( + fun(A, B) -> + ValA = event_field(A, Field), + ValB = event_field(B, Field), + if Order == <<"asc">> -> ValA =< ValB; + true -> ValA >= ValB + end + end, Events), + Sorted. + +event_field(Event, created_at) -> Event#event.created_at; +event_field(Event, start_time) -> Event#event.start_time; +event_field(Event, title) -> Event#event.title; +event_field(Event, status) -> Event#event.status; +event_field(_, _) -> undefined. + %% Валидация времени события (без учёта пользователя) validate_event_time(StartTime) -> validate_event_time(StartTime, undefined). @@ -214,6 +323,7 @@ validate_update({location, Value}, _) -> validate_update({tags, Value}, _) when is_list(Value) -> true; validate_update({capacity, Value}, _) when is_integer(Value), Value > 0 -> true; validate_update({online_link, Value}, _) when is_binary(Value) -> true; +validate_update({status, Value}, _) when is_atom(Value) -> true; validate_update(_, _) -> false. get_exceptions(MasterId) -> diff --git a/test/api/api_admin_tests.erl b/test/api/api_admin_tests.erl index 224c7c7..99fcd13 100644 --- a/test/api/api_admin_tests.erl +++ b/test/api/api_admin_tests.erl @@ -43,25 +43,25 @@ test() -> {ok, {{_, 200, _}, _, StatsResp1}} = httpc:request(get, {AdminURL ++ "/v1/admin/stats", [{"Authorization", "Bearer " ++ binary_to_list(SuperadminToken)}]}, [], []), Stats1 = jsx:decode(list_to_binary(StatsResp1), [return_maps]), ct:pal(" OK (Stats 1: ~p)~n", [Stats1]), - map_size(Stats1) > 0, + true = map_size(Stats1) > 0, ct:pal(" Admin stats (admin)... "), {ok, {{_, 200, _}, _, StatsResp2}} = httpc:request(get, {AdminURL ++ "/v1/admin/stats", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), Stats2 = jsx:decode(list_to_binary(StatsResp2), [return_maps]), ct:pal(" OK (Stats 1: ~p)~n", [Stats2]), - map_size(Stats2) > 0, + true = map_size(Stats2) > 0, ct:pal(" Admin stats (moderator)... "), {ok, {{_, 200, _}, _, StatsResp3}} = httpc:request(get, {AdminURL ++ "/v1/admin/stats", [{"Authorization", "Bearer " ++ binary_to_list(ModeratorToken)}]}, [], []), Stats3 = jsx:decode(list_to_binary(StatsResp3), [return_maps]), ct:pal(" OK (Stats 1: ~p)~n", [Stats3]), - map_size(Stats3) > 0, + true = map_size(Stats3) > 0, ct:pal(" Admin stats (support)... "), {ok, {{_, 200, _}, _, StatsResp4}} = httpc:request(get, {AdminURL ++ "/v1/admin/stats", [{"Authorization", "Bearer " ++ binary_to_list(SupportToken)}]}, [], []), Stats4 = jsx:decode(list_to_binary(StatsResp4), [return_maps]), ct:pal(" OK (Stats 1: ~p)~n", [Stats4]), - map_size(Stats4) > 0, + true = map_size(Stats4) > 0, %% TEST 4: List users ct:pal(" TEST 4: List users... "), @@ -356,5 +356,158 @@ test() -> [], []), ct:pal("OK~n"), + %% ======================================================== + %% Extended Admin Events Search & Filter Tests + %% ======================================================== + + %% ── Подготовка изолированных данных ── + ct:pal(" Preparing isolated search test data... "), + UserToken = api_test_runner:get_user_token(), + SearchCalId = api_test_runner:create_calendar(UserToken, #{title => <<"SearchTestCal">>}), + SearchCalIdStr = binary_to_list(SearchCalId), + AlphaId = api_test_runner:create_event(UserToken, SearchCalId, #{ + title => <<"Test Event Alpha">>, + start_time => api_SUITE:future_date(), + duration => 60 + }), + BetaId = api_test_runner:create_event(UserToken, SearchCalId, #{ + title => <<"Beta Event">>, + start_time => api_SUITE:future_date(), + duration => 60 + }), + _AlphaConfId = api_test_runner:create_event(UserToken, SearchCalId, #{ + title => <<"Alpha Conference">>, + start_time => api_SUITE:future_date(), + duration => 60 + }), + % Отменяем BetaId через административный эндпоинт (PUT /v1/admin/events/:id) + ct:pal(" Cancelling Beta Event (admin)... "), + {ok, {{_, 200, _}, _, _}} = + httpc:request(put, {AdminURL ++ "/v1/admin/events/" ++ binary_to_list(BetaId), + [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], + "application/json", jsx:encode(#{<<"status">> => <<"cancelled">>})}, [], []), + ct:pal("OK~n"), + + %% ── TEST 34: Filter by status=active ── + ct:pal(" TEST 34: Filter events by status=active... "), + {ok, {{_, 200, _}, _, Body34}} = + httpc:request(get, {AdminURL ++ "/v1/admin/events?calendar_id=" ++ SearchCalIdStr ++ "&status=active", + [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, + [], []), + Events34 = jsx:decode(list_to_binary(Body34), [return_maps]), + ct:pal("DEBUG: events34 count = ~p", [length(Events34)]), + ct:pal("DEBUG: events34 = ~p", [Events34]), + true = (length(Events34) >= 2), + Ids34 = [maps:get(<<"id">>, E) || E <- Events34], + ct:pal("OK (count: ~p, ids: ~p)~n", [length(Events34), Ids34]), + + %% ── TEST 35: Filter by status=cancelled ── + ct:pal(" TEST 35: Filter events by status=cancelled... "), + {ok, {{_, 200, _}, _, Body35}} = + httpc:request(get, {AdminURL ++ "/v1/admin/events?calendar_id=" ++ SearchCalIdStr ++ "&status=cancelled", + [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, + [], []), + Events35 = jsx:decode(list_to_binary(Body35), [return_maps]), + ct:pal("DEBUG: Events35 count = ~p", [length(Events35)]), + ct:pal("DEBUG: Events35 = ~p", [Events35]), + true = (length(Events35) >= 1), + ct:pal("OK (count: ~p)~n", [length(Events35)]), + + %% ── TEST 36: Filter by status=all ── + ct:pal(" TEST 36: Filter events by status=all... "), + {ok, {{_, 200, _}, _, Body36}} = + httpc:request(get, {AdminURL ++ "/v1/admin/events?calendar_id=" ++ SearchCalIdStr ++ "&status=all", + [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, + [], []), + Events36 = jsx:decode(list_to_binary(Body36), [return_maps]), + ct:pal("DEBUG: Events36 count = ~p", [length(Events36)]), + ct:pal("DEBUG: Events36 = ~p", [Events36]), + true = (length(Events36) >= 3), + ct:pal("OK (count: ~p)~n", [length(Events36)]), + + %% ── TEST 37: Filter by calendar_id ── + ct:pal(" TEST 37: Filter by calendar_id... "), + {ok, {{_, 200, _}, _, Body37}} = + httpc:request(get, {AdminURL ++ "/v1/admin/events?calendar_id=" ++ SearchCalIdStr, + [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, + [], []), + Events37 = jsx:decode(list_to_binary(Body37), [return_maps]), + ct:pal("DEBUG: Events37 count = ~p", [length(Events37)]), + ct:pal("DEBUG: Events37 = ~p", [Events37]), + true = (length(Events37) >= 3), + ct:pal("OK (count: ~p)~n", [length(Events37)]), + + %% ── TEST 38: Exact title match ── + ct:pal(" TEST 38: Exact title match... "), + {ok, {{_, 200, _}, _, Body38}} = + httpc:request(get, {AdminURL ++ "/v1/admin/events?calendar_id=" ++ SearchCalIdStr ++ "&title=Test%20Event%20Alpha", + [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, + [], []), + Events38 = jsx:decode(list_to_binary(Body38), [return_maps]), + ct:pal("DEBUG: Events38 count = ~p", [length(Events38)]), + ct:pal("DEBUG: Events38 = ~p", [Events38]), + 1 = length(Events38), + #{<<"id">> := AlphaId} = hd(Events38), + ct:pal("OK~n"), + + %% ── TEST 39: Substring search (q) ── + ct:pal(" TEST 39: Substring search (q=Alpha)... "), + {ok, {{_, 200, _}, _, Body39}} = + httpc:request(get, {AdminURL ++ "/v1/admin/events?calendar_id=" ++ SearchCalIdStr ++ "&q=Alpha", + [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, + [], []), + Events39 = jsx:decode(list_to_binary(Body39), [return_maps]), + ct:pal("DEBUG: Events39 count = ~p", [length(Events39)]), + ct:pal("DEBUG: Events39 = ~p", [Events39]), + true = (length(Events39) >= 2), + Titles39 = [maps:get(<<"title">>, E) || E <- Events39], + ct:pal("OK (count: ~p, titles: ~p)~n", [length(Events39), Titles39]), + + %% ── TEST 40: Combined filters (calendar_id + status) ── + ct:pal(" TEST 40: Combined filters (calendar+status)... "), + {ok, {{_, 200, _}, _, Body40}} = + httpc:request(get, {AdminURL ++ "/v1/admin/events?calendar_id=" ++ SearchCalIdStr ++ "&status=active", + [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, + [], []), + Events40 = jsx:decode(list_to_binary(Body40), [return_maps]), + ct:pal("DEBUG: Events40 count = ~p", [length(Events40)]), + ct:pal("DEBUG: Events40 = ~p", [Events40]), + true = (length(Events40) >= 2), + ct:pal("OK (count: ~p)~n", [length(Events40)]), + + %% ── TEST 41: Pagination (limit & offset) ── + ct:pal(" TEST 41: Pagination... "), + {ok, {{_, 200, _}, Headers41a, Body41a}} = + httpc:request(get, {AdminURL ++ "/v1/admin/events?calendar_id=" ++ SearchCalIdStr ++ "&status=all&limit=2&offset=0&sort=title&order=asc", + [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, + [], []), + Events41a = jsx:decode(list_to_binary(Body41a), [return_maps]), + ct:pal("DEBUG: Events41a count = ~p", [length(Events41a)]), + ct:pal("DEBUG: Events41a = ~p", [Events41a]), + 2 = length(Events41a), + {"content-range", ContentRange41a} = lists:keyfind("content-range", 1, Headers41a), + ct:pal("page1: ~s; ", [ContentRange41a]), + {ok, {{_, 200, _}, Headers41b, Body41b}} = + httpc:request(get, {AdminURL ++ "/v1/admin/events?calendar_id=" ++ SearchCalIdStr ++ "&status=all&limit=2&offset=2&sort=title&order=asc", + [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, + [], []), + Events41b = jsx:decode(list_to_binary(Body41b), [return_maps]), + ct:pal("DEBUG: Events41b count = ~p", [length(Events41b)]), + ct:pal("DEBUG: Events41b = ~p", [Events41b]), + 1 = length(Events41b), + {"content-range", ContentRange41b} = lists:keyfind("content-range", 1, Headers41b), + ct:pal("page2: ~s~n", [ContentRange41b]), + + %% ── TEST 42: Sorting (order=asc) ── + ct:pal(" TEST 42: Sorting by title ascending... "), + {ok, {{_, 200, _}, _, Body42}} = + httpc:request(get, {AdminURL ++ "/v1/admin/events?calendar_id=" ++ SearchCalIdStr ++ "&status=all&sort=title&order=asc", + [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, + [], []), + Events42 = jsx:decode(list_to_binary(Body42), [return_maps]), + SortedTitles = [maps:get(<<"title">>, E) || E <- Events42], + SortedTitles = lists:sort(SortedTitles), + ct:pal("OK (titles: ~p)~n", [SortedTitles]), + ct:pal("~n✅ Admin API tests passed!~n"), {?MODULE, ok}. \ No newline at end of file