Рефакторинг админских обработчиков - пагинация, сортировка, фильтрация. Финал

This commit is contained in:
2026-05-26 21:50:12 +03:00
parent ed5b275efb
commit 19b3d0dba0
21 changed files with 1833 additions and 421 deletions
+10 -2
View File
@@ -130,6 +130,7 @@ create_admin(Req) ->
_ ->
case logic_admin:create_admin(Email, Password, Role) of
{ok, Admin} ->
% Аудит создания администратора
admin_utils:log_admin_action(SuperAdminId, <<"create_admin">>, <<"admin">>, Email, Req2),
handler_utils:send_json(Req2, 201, handler_utils:admin_to_json(Admin));
{error, email_exists} ->
@@ -158,7 +159,14 @@ create_admin(Req) ->
parse_admin_filters(Req) ->
Qs = cowboy_req:parse_qs(Req),
#{
role => proplists:get_value(<<"role">>, Qs),
status => proplists:get_value(<<"status">>, Qs),
role => to_atom(proplists:get_value(<<"role">>, Qs)),
status => to_atom(proplists:get_value(<<"status">>, Qs)),
q => proplists:get_value(<<"q">>, Qs)
}.
%% @private Безопасное преобразование бинарного значения в атом.
to_atom(undefined) -> undefined;
to_atom(Bin) when is_binary(Bin) ->
try binary_to_existing_atom(Bin, utf8)
catch error:badarg -> Bin
end.
+31 -7
View File
@@ -1,6 +1,6 @@
%%%-------------------------------------------------------------------
%%% @doc Административный обработчик журнала аудита.
%%% GET – список записей аудита с пагинацией и фильтрацией.
%%% GET – список записей аудита с пагинацией, фильтрацией и сортировкой.
%%% Доступно только суперадмину.
%%% @end
%%%-------------------------------------------------------------------
@@ -33,6 +33,8 @@ trails() ->
#{name => <<"action">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by action">>},
#{name => <<"date_from">>, in => <<"query">>, schema => #{type => string, format => <<"date-time">>}, description => <<"Start timestamp (ISO8601)">>},
#{name => <<"date_to">>, in => <<"query">>, schema => #{type => string, format => <<"date-time">>}, description => <<"End timestamp (ISO8601)">>},
#{name => <<"sort">>, in => <<"query">>, schema => #{type => string, enum => [<<"timestamp">>, <<"action">>, <<"admin_id">>, <<"entity_type">>]}, description => <<"Sort field">>},
#{name => <<"order">>, in => <<"query">>, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}, description => <<"Sort direction">>},
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>},
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>}
],
@@ -77,16 +79,20 @@ audit_schema() ->
-spec list_audit(cowboy_req:req()) -> {ok, binary(), cowboy_req:req()}.
list_audit(Req) ->
case handler_utils:is_superadmin(Req) of
{ok, SuperAdminId, Req1} ->
{ok, _SuperAdminId, Req1} ->
Filters = parse_audit_filters(Req1),
Pagination = handler_utils:parse_pagination_params(Req1),
Pagination0 = handler_utils:parse_pagination_params(Req1),
Pagination = Pagination0#{
sort => maps:get(sort, Pagination0, <<"timestamp">>),
order => maps:get(order, Pagination0, <<"desc">>)
},
AllRecords = core_admin_audit:list(Filters),
Total = length(AllRecords),
Page = lists:sublist(AllRecords, maps:get(offset, Pagination) + 1, maps:get(limit, Pagination)),
Sorted = sort_audit_records(AllRecords, Pagination),
Total = length(Sorted),
Page = lists:sublist(Sorted, maps:get(offset, Pagination) + 1, maps:get(limit, Pagination)),
Json = [handler_utils:audit_to_json(R) || R <- Page],
ExtraHeaders = handler_utils:pagination_headers(Pagination, Total),
% Аудит доступа к журналу
admin_utils:log_admin_action(SuperAdminId, <<"view_audit">>, <<"audit">>, <<"list">>, Req1),
% Аудит чтения списка аудита НЕ пишем, чтобы не замусоривать журнал
handler_utils:send_json(Req1, 200, Json, ExtraHeaders);
{error, Code, Msg, Req1} ->
handler_utils:send_error(Req1, Code, Msg)
@@ -106,3 +112,21 @@ parse_audit_filters(Req) ->
{date_from, handler_utils:parse_datetime_qs(proplists:get_value(<<"date_from">>, Qs))},
{date_to, handler_utils:parse_datetime_qs(proplists:get_value(<<"date_to">>, Qs))}
].
%% @private Сортирует записи аудита по указанному полю.
sort_audit_records(Records, #{sort := Sort, order := Order}) ->
Field = binary_to_existing_atom(Sort, utf8),
lists:sort(fun(A, B) ->
VA = audit_field(A, Field),
VB = audit_field(B, Field),
case Order of
<<"desc">> -> VA > VB;
_ -> VA < VB
end
end, Records).
audit_field(#admin_audit{timestamp = V}, timestamp) -> V;
audit_field(#admin_audit{action = V}, action) -> V;
audit_field(#admin_audit{admin_id = V}, admin_id) -> V;
audit_field(#admin_audit{entity_type = V}, entity_type) -> V;
audit_field(_, _) -> undefined.
@@ -1,6 +1,6 @@
%%%-------------------------------------------------------------------
%%% @doc Административный обработчик бан-слов.
%%% GET – список всех слов с пагинацией.
%%% GET – список всех слов с пагинацией, фильтрацией и сортировкой.
%%% POST – добавить новое слово.
%%% PUT – редактировать существующее слово.
%%% DELETE – удалить слово по :word.
@@ -37,6 +37,9 @@ trails() ->
description => <<"List all banned words (admin)">>,
tags => [<<"Banned Words">>],
parameters => [
#{name => <<"q">>, in => <<"query">>, schema => #{type => string}, description => <<"Search by word">>},
#{name => <<"sort">>, in => <<"query">>, schema => #{type => string, enum => [<<"word">>, <<"added_at">>]}, description => <<"Sort field">>},
#{name => <<"order">>, in => <<"query">>, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}, description => <<"Sort direction">>},
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>},
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>}
],
@@ -141,11 +144,27 @@ banned_word_schema() ->
list_words(Req) ->
case handler_utils:auth_admin(Req) of
{ok, _AdminId, Req1} ->
Qs = cowboy_req:parse_qs(Req1),
Q = proplists:get_value(<<"q">>, Qs),
SortBy = proplists:get_value(<<"sort">>, Qs, <<"word">>),
SortOrder = proplists:get_value(<<"order">>, Qs, <<"asc">>),
Pagination = handler_utils:parse_pagination_params(Req1),
AllWords = core_banned_words:list_banned_words(),
BannedWords = lists:sort(AllWords),
Total = length(BannedWords),
Page = lists:sublist(BannedWords, maps:get(offset, Pagination) + 1, maps:get(limit, Pagination)),
% Фильтрация
Filtered = case Q of
undefined -> AllWords;
_ -> [W || W <- AllWords, string:str(string:lowercase(binary_to_list(W#banned_word.word)), string:lowercase(binary_to_list(Q))) > 0]
end,
% Сортировка
Sorted = case {SortBy, SortOrder} of
{<<"word">>, <<"asc">>} -> lists:keysort(#banned_word.word, Filtered);
{<<"word">>, <<"desc">>} -> lists:reverse(lists:keysort(#banned_word.word, Filtered));
{<<"added_at">>, <<"asc">>} -> lists:keysort(#banned_word.added_at, Filtered);
{<<"added_at">>, <<"desc">>} -> lists:reverse(lists:keysort(#banned_word.added_at, Filtered));
_ -> Filtered
end,
Total = length(Sorted),
Page = lists:sublist(Sorted, maps:get(offset, Pagination) + 1, maps:get(limit, Pagination)),
Json = [word_to_map(W) || W <- Page],
ExtraHeaders = handler_utils:pagination_headers(Pagination, Total),
handler_utils:send_json(Req1, 200, Json, ExtraHeaders);
+24 -4
View File
@@ -32,6 +32,12 @@ trails() ->
description => <<"Filter by target type">>},
#{name => <<"q">>, in => <<"query">>, schema => #{type => string},
description => <<"Search in reason">>},
#{name => <<"sort">>, in => <<"query">>,
schema => #{type => string, enum => [<<"created_at">>, <<"status">>]},
description => <<"Sort field">>},
#{name => <<"order">>, in => <<"query">>,
schema => #{type => string, enum => [<<"asc">>, <<"desc">>]},
description => <<"Sort direction">>},
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer},
description => <<"Page size">>},
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer},
@@ -69,7 +75,11 @@ list_reports(Req) ->
case handler_utils:auth_admin(Req) of
{ok, AdminId, Req1} ->
Filters = parse_report_filters(Req1),
Pagination = handler_utils:parse_pagination_params(Req1),
Pagination0 = handler_utils:parse_pagination_params(Req1),
Pagination = Pagination0#{
sort => maps:get(sort, Pagination0, <<"created_at">>),
order => maps:get(order, Pagination0, <<"desc">>)
},
case logic_report:list_reports(AdminId) of
{ok, AllReports} ->
Filtered = apply_report_filters(AllReports, Filters),
@@ -89,9 +99,19 @@ list_reports(Req) ->
parse_report_filters(Req) ->
Qs = cowboy_req:parse_qs(Req),
#{ status => proplists:get_value(<<"status">>, Qs),
target_type => proplists:get_value(<<"target_type">>, Qs),
q => proplists:get_value(<<"q">>, Qs) }.
StatusBin = proplists:get_value(<<"status">>, Qs),
TargetBin = proplists:get_value(<<"target_type">>, Qs),
#{
status => convert_to_atom(StatusBin),
target_type => convert_to_atom(TargetBin),
q => proplists:get_value(<<"q">>, Qs)
}.
convert_to_atom(undefined) -> undefined;
convert_to_atom(Bin) when is_binary(Bin) ->
try binary_to_existing_atom(Bin, utf8)
catch error:badarg -> Bin
end.
apply_report_filters(Reports, Filters) ->
Status = maps:get(status, Filters, undefined),
+11 -10
View File
@@ -31,10 +31,10 @@ trails() ->
#{name => <<"target_id">>, in => <<"query">>, schema => #{type => string}, description => <<"ID of target">>},
#{name => <<"user_id">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by user">>},
#{name => <<"status">>, in => <<"query">>, schema => #{type => string}, description => <<"visible, hidden, deleted, or all">>},
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>},
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>},
#{name => <<"sort">>, in => <<"query">>, schema => #{type => string, enum => [<<"created_at">>, <<"updated_at">>, <<"rating">>]}, description => <<"Sort field">>},
#{name => <<"order">>, in => <<"query">>, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}, description => <<"Sort direction">>}
#{name => <<"order">>, in => <<"query">>, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}, description => <<"Sort direction">>},
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>},
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>}
],
responses => #{
200 => #{ description => <<"Array of reviews">>, content => #{<<"application/json">> => #{schema => #{ type => array, items => review_schema() }}} }
@@ -83,16 +83,11 @@ list_reviews(Req) ->
target_type => normalize_target_type(proplists:get_value(<<"target_type">>, Qs)),
target_id => proplists:get_value(<<"target_id">>, Qs),
user_id => proplists:get_value(<<"user_id">>, Qs),
status => proplists:get_value(<<"status">>, Qs)
status => normalize_status(proplists:get_value(<<"status">>, Qs))
},
Filters = maps:filter(fun(_, V) -> V =/= undefined end, Filters0),
Pagination = handler_utils:parse_pagination_params(Req1),
% Убедимся, что в Pagination есть ключи sort и order со значениями по умолчанию
PaginationWithSort = Pagination#{
sort => maps:get(sort, Pagination, <<"created_at">>),
order => maps:get(order, Pagination, <<"desc">>)
},
{ok, Total, Reviews} = logic_review:list_admin_reviews(Filters, PaginationWithSort),
{ok, Total, Reviews} = logic_review:list_admin_reviews(Filters, Pagination),
Json = [handler_utils:review_to_json(R) || R <- Reviews],
ExtraHeaders = handler_utils:pagination_headers(Pagination, Total),
handler_utils:send_json(Req1, 200, Json, ExtraHeaders);
@@ -105,6 +100,12 @@ normalize_target_type(<<"event">>) -> event;
normalize_target_type(<<"calendar">>) -> calendar;
normalize_target_type(Other) -> Other.
%% @private Преобразует бинарный status в атом.
normalize_status(<<"visible">>) -> visible;
normalize_status(<<"hidden">>) -> hidden;
normalize_status(<<"deleted">>) -> deleted;
normalize_status(Other) -> Other.
bulk_update_reviews(Req) ->
case handler_utils:auth_admin(Req) of
{ok, AdminId, Req1} ->
+9 -5
View File
@@ -24,6 +24,8 @@ trails() ->
#{name => <<"status">>, in => <<"query">>, schema => #{type => string, enum => [<<"open">>, <<"in_progress">>, <<"resolved">>, <<"closed">>]}, description => <<"Filter by status">>},
#{name => <<"assigned_to">>, in => <<"query">>, schema => #{type => string}, description => <<"Filter by assigned admin ID">>},
#{name => <<"q">>, in => <<"query">>, schema => #{type => string}, description => <<"Search in error_message">>},
#{name => <<"sort">>, in => <<"query">>, schema => #{type => string, enum => [<<"first_seen">>, <<"last_seen">>, <<"status">>]}, description => <<"Sort field">>},
#{name => <<"order">>, in => <<"query">>, schema => #{type => string, enum => [<<"asc">>, <<"desc">>]}, description => <<"Sort direction">>},
#{name => <<"limit">>, in => <<"query">>, schema => #{type => integer}, description => <<"Page size">>},
#{name => <<"offset">>, in => <<"query">>, schema => #{type => integer}, description => <<"Offset">>}
],
@@ -51,8 +53,7 @@ trails() ->
].
ticket_schema() ->
#{
type => object,
#{ type => object,
properties => #{
id => #{type => string},
reporter_id => #{type => string},
@@ -70,8 +71,7 @@ ticket_schema() ->
}.
ticket_update_schema() ->
#{
type => object,
#{ type => object,
properties => #{
status => #{type => string, enum => [<<"open">>, <<"in_progress">>, <<"resolved">>, <<"closed">>]},
assigned_to => #{type => string},
@@ -85,7 +85,11 @@ list_tickets(Req) ->
case handler_utils:auth_admin(Req) of
{ok, AdminId, Req1} ->
Filters = parse_ticket_filters(Req1),
Pagination = handler_utils:parse_pagination_params(Req1),
Pagination0 = handler_utils:parse_pagination_params(Req1),
Pagination = Pagination0#{
sort => maps:get(sort, Pagination0, <<"first_seen">>),
order => maps:get(order, Pagination0, <<"desc">>)
},
TicketsResult = case maps:get(status, Filters, undefined) of
undefined -> logic_ticket:list_tickets(AdminId);
StatusBin ->
+3 -6
View File
@@ -123,14 +123,11 @@ parse_int_qs(Bin, Default) ->
-spec pagination_headers(map(), non_neg_integer()) -> map().
pagination_headers(#{limit := Limit, offset := Offset}, Total) ->
NextOffset = Offset + Limit,
HasMore = NextOffset < Total,
RangeEnd = min(Offset + Limit - 1, Total - 1),
#{
<<"content-range">> => iolist_to_binary(io_lib:format("items ~B-~B/~B", [Offset, RangeEnd, Total])),
<<"x-total-count">> => integer_to_binary(Total),
<<"x-next-offset">> => case HasMore of
true -> integer_to_binary(NextOffset);
false -> <<"">>
end
<<"access-control-expose-headers">> => <<"Content-Range, X-Total-Count">>
}.
%% @doc Преобразует бинарный ISO8601 параметр в datetime().
+6 -1
View File
@@ -208,6 +208,11 @@ list_admin_reviews(Filters) ->
%% Вспомогательная функция: фильтрация списка по proplist
apply_filters(Reviews, []) ->
Reviews;
apply_filters(Reviews, [{status, Status} | Rest]) ->
apply_filters(
[R || R <- Reviews, R#review.status =:= Status],
Rest
);
apply_filters(Reviews, [{target_type, Type} | Rest]) ->
apply_filters(
[R || R <- Reviews, R#review.target_type =:= Type],
@@ -224,7 +229,7 @@ apply_filters(Reviews, [{user_id, UserId} | Rest]) ->
Rest
);
apply_filters(Reviews, [_ | Rest]) ->
apply_filters(Reviews, Rest). % Игнорируем неизвестные фильтры
apply_filters(Reviews, Rest).
%%%-------------------------------------------------------------------
%%% @doc Массово обновить статусы отзывов.
+166 -18
View File
@@ -16,47 +16,75 @@
%%% - удаление (блокировку) администратора
%%% - ошибки 403 для обычного администратора
%%% - ошибки 409 (дубликат email) и 400 (неверная роль) при создании
%%% - **фильтрацию** по role и status
%%% - **комбинации фильтров** (role + status)
%%% - **пагинацию** (limit/offset, x-total-count)
%%% - **сортировку** по email, role, created_at
%%% - **комбинации сортировки с фильтрацией и пагинацией**
%%% @end
%%%-------------------------------------------------------------------
-module(admin_admins_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== Admin Admins Tests ==="),
SuperToken = api_test_runner:get_superadmin_token(),
% Создаём нового администратора для проверки CRUD (у него будет свой ID)
AdminEmail = api_test_runner:unique_email(<<"newadmin.admins.tests">>),
AdminPassword = <<"AdminPass123">>,
#{<<"id">> := AdminId} = create_admin(SuperToken, AdminEmail, AdminPassword, <<"admin">>),
% Создаём несколько администраторов для проверки фильтрации
Admin1 = create_admin(SuperToken,
api_test_runner:unique_email(<<"admin1.tests">>),
<<"AdminPass123">>, <<"admin">>),
Admin2 = create_admin(SuperToken,
api_test_runner:unique_email(<<"admin2.tests">>),
<<"AdminPass123">>, <<"moderator">>),
% Третий админ с той же ролью, что и первый, для проверки фильтрации по роли
Admin3 = create_admin(SuperToken,
api_test_runner:unique_email(<<"admin3.tests">>),
<<"AdminPass123">>, <<"admin">>),
% Токен обычного администратора (уже существующего admin@eventhub.local)
AdminToken = api_test_runner:get_admin_token(),
% Тесты с правами суперадмина
% ── Основные CRUD-тесты ──
test_list_admins(SuperToken),
test_get_admin(SuperToken, AdminId),
test_update_admin(SuperToken, AdminId),
test_delete_admin(SuperToken, AdminId),
test_get_admin(SuperToken, maps:get(<<"id">>, Admin1)),
test_update_admin(SuperToken, maps:get(<<"id">>, Admin1)),
test_delete_admin(SuperToken, maps:get(<<"id">>, Admin2)),
% Тесты ограничений для обычного админа (используем готовый токен)
% ── Тесты ограничений для обычного админа ──
test_list_admins_forbidden(AdminToken),
test_create_admin_forbidden(AdminToken),
test_get_admin_forbidden(AdminToken, AdminId),
test_update_admin_forbidden(AdminToken, AdminId),
test_delete_admin_forbidden(AdminToken, AdminId),
test_get_admin_forbidden(AdminToken, maps:get(<<"id">>, Admin3)),
test_update_admin_forbidden(AdminToken, maps:get(<<"id">>, Admin3)),
test_delete_admin_forbidden(AdminToken, maps:get(<<"id">>, Admin3)),
% Тесты валидации при создании
% ── Валидация создания ──
test_create_admin_duplicate_email(SuperToken),
test_create_admin_invalid_role(SuperToken),
% ── Фильтрация ──
test_filter_by_role(SuperToken),
test_filter_by_status(SuperToken),
test_filter_combined(SuperToken),
% ── Пагинация ──
test_pagination_headers(SuperToken),
test_filter_with_pagination(SuperToken),
% ── Сортировка ──
test_sort_by_email_asc(SuperToken),
test_sort_by_role_desc(SuperToken),
test_sort_by_created_at_asc(SuperToken),
% ── Комбинации сортировки с фильтрами и пагинацией ──
test_sort_combined_with_filter(SuperToken),
test_sort_combined_with_filter_and_pagination(SuperToken),
ct:pal("=== All admin admins tests passed ==="),
ok.
@@ -104,7 +132,6 @@ test_delete_admin(Token, AdminId) ->
?assertNot(lists:any(fun(A) -> maps:get(<<"id">>, A) =:= AdminId end, Admins)).
%% ── Тесты ограничений ──
-spec test_list_admins_forbidden(binary()) -> ok.
test_list_admins_forbidden(Token) ->
ct:pal(" TEST: List admins as non-superadmin (403)"),
@@ -132,7 +159,8 @@ test_get_admin_forbidden(Token, AdminId) ->
test_update_admin_forbidden(Token, AdminId) ->
ct:pal(" TEST: Update admin as non-superadmin (403)"),
Path = <<"/v1/admin/admins/", AdminId/binary>>,
Resp = api_test_runner:admin_request(put, Path, Token, jsx:encode(#{nickname => <<"fail">>})),
Resp = api_test_runner:admin_request(put, Path, Token,
jsx:encode(#{nickname => <<"fail">>})),
?assertMatch({ok, 403, _, _}, Resp),
ct:pal(" OK: got 403").
@@ -145,7 +173,6 @@ test_delete_admin_forbidden(Token, AdminId) ->
ct:pal(" OK: got 403").
%% ── Валидация создания ──
-spec test_create_admin_duplicate_email(binary()) -> ok.
test_create_admin_duplicate_email(SuperToken) ->
ct:pal(" TEST: Create admin with duplicate email (409)"),
@@ -167,6 +194,127 @@ test_create_admin_invalid_role(SuperToken) ->
?assertMatch({ok, 400, _, _}, Resp),
ct:pal(" OK: got 400").
%% ── Фильтрация и пагинация ──
%% @doc Фильтрация по роли: ?role=admin
-spec test_filter_by_role(binary()) -> ok.
test_filter_by_role(Token) ->
ct:pal(" TEST: Filter admins by role"),
Admins = api_test_runner:admin_get(<<"/v1/admin/admins?role=admin">>, Token),
?assert(is_list(Admins)),
?assert(length(Admins) >= 1),
[?assertEqual(<<"admin">>, maps:get(<<"role">>, A)) || A <- Admins],
ct:pal(" OK: ~p admins with role=admin", [length(Admins)]).
%% @doc Фильтрация по статусу: ?status=active
-spec test_filter_by_status(binary()) -> ok.
test_filter_by_status(Token) ->
ct:pal(" TEST: Filter admins by status"),
Admins = api_test_runner:admin_get(<<"/v1/admin/admins?status=active">>, Token),
?assert(is_list(Admins)),
?assert(length(Admins) >= 1),
[?assertEqual(<<"active">>, maps:get(<<"status">>, A)) || A <- Admins],
ct:pal(" OK: ~p active admins", [length(Admins)]).
%% @doc Комбинация фильтров: ?role=admin&status=active
-spec test_filter_combined(binary()) -> ok.
test_filter_combined(Token) ->
ct:pal(" TEST: Combined filter (role=admin & status=active)"),
Admins = api_test_runner:admin_get(<<"/v1/admin/admins?role=admin&status=active">>, Token),
?assert(is_list(Admins)),
[?assertEqual(<<"admin">>, maps:get(<<"role">>, A)) || A <- Admins],
[?assertEqual(<<"active">>, maps:get(<<"status">>, A)) || A <- Admins],
ct:pal(" OK: ~p admins match both criteria", [length(Admins)]).
%% @doc Фильтр с пагинацией: ?role=admin&limit=1&offset=0
-spec test_filter_with_pagination(binary()) -> ok.
test_filter_with_pagination(Token) ->
ct:pal(" TEST: Filter + pagination (role=admin, limit=1)"),
{ok, 200, Headers, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/admins?role=admin&limit=1&offset=0">>, Token),
Admins = jsx:decode(list_to_binary(Body), [return_maps]),
?assertEqual(1, length(Admins)),
?assertEqual(<<"admin">>, maps:get(<<"role">>, hd(Admins))),
% Проверяем, что заголовок x-total-count присутствует и >= 1
Total = list_to_integer(
proplists:get_value("x-total-count", Headers, "0")),
?assert(Total >= 1),
ct:pal(" OK: got 1 admin of ~p total", [Total]).
%% @doc Проверка заголовков пагинации (x-total-count)
-spec test_pagination_headers(binary()) -> ok.
test_pagination_headers(Token) ->
ct:pal(" TEST: Pagination headers (x-total-count)"),
{ok, 200, Headers, _} = api_test_runner:admin_request(get,
<<"/v1/admin/admins?limit=2&offset=0">>, Token),
Total = list_to_integer(
proplists:get_value("x-total-count", Headers, "0")),
?assert(Total >= 1),
ct:pal(" OK: total count = ~p", [Total]).
%% ── Сортировка ──
%% @doc Сортировка по email по возрастанию.
-spec test_sort_by_email_asc(binary()) -> ok.
test_sort_by_email_asc(Token) ->
ct:pal(" TEST: Sort admins by email ascending"),
Admins = api_test_runner:admin_get(<<"/v1/admin/admins?sort=email&order=asc">>, Token),
Emails = [maps:get(<<"email">>, A) || A <- Admins],
?assertEqual(lists:sort(Emails), Emails),
ct:pal(" OK: sorted by email asc").
%% @doc Сортировка по роли по убыванию.
-spec test_sort_by_role_desc(binary()) -> ok.
test_sort_by_role_desc(Token) ->
ct:pal(" TEST: Sort admins by role descending"),
Admins = api_test_runner:admin_get(<<"/v1/admin/admins?sort=role&order=desc">>, Token),
Roles = [maps:get(<<"role">>, A) || A <- Admins],
% Проверяем, что список отсортирован по убыванию
?assertEqual(lists:reverse(lists:sort(Roles)), Roles),
ct:pal(" OK: sorted by role desc").
%% @doc Сортировка по дате создания по возрастанию.
-spec test_sort_by_created_at_asc(binary()) -> ok.
test_sort_by_created_at_asc(Token) ->
ct:pal(" TEST: Sort admins by created_at ascending"),
Admins = api_test_runner:admin_get(<<"/v1/admin/admins?sort=created_at&order=asc">>, Token),
Dates = [maps:get(<<"created_at">>, A) || A <- Admins],
?assertEqual(lists:sort(Dates), Dates),
ct:pal(" OK: sorted by created_at asc").
%% @doc Сортировка + фильтр: role=admin, сортировка по email по убыванию.
-spec test_sort_combined_with_filter(binary()) -> ok.
test_sort_combined_with_filter(Token) ->
ct:pal(" TEST: Sort admins (email desc) filtered by role=admin"),
Admins = api_test_runner:admin_get(
<<"/v1/admin/admins?role=admin&sort=email&order=desc">>, Token),
% Убедимся, что все имеют роль admin
[?assertEqual(<<"admin">>, maps:get(<<"role">>, A)) || A <- Admins],
% Проверяем сортировку
Emails = [maps:get(<<"email">>, A) || A <- Admins],
?assertEqual(lists:reverse(lists:sort(Emails)), Emails),
ct:pal(" OK: ~p admins sorted by email desc", [length(Admins)]).
%% @doc Сортировка + фильтр + пагинация: status=active, сортировка по created_at desc, limit=2.
-spec test_sort_combined_with_filter_and_pagination(binary()) -> ok.
test_sort_combined_with_filter_and_pagination(Token) ->
ct:pal(" TEST: Filter + sort + pagination (status=active, sort=created_at desc, limit=2)"),
{ok, 200, Headers, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/admins?status=active&sort=created_at&order=desc&limit=2&offset=0">>, Token),
Admins = jsx:decode(list_to_binary(Body), [return_maps]),
?assert(length(Admins) =< 2),
% Проверяем, что все активны
[?assertEqual(<<"active">>, maps:get(<<"status">>, A)) || A <- Admins],
% Проверяем сортировку по убыванию дат
Dates = [maps:get(<<"created_at">>, A) || A <- Admins],
Sorted = lists:reverse(lists:sort(Dates)),
?assertEqual(Sorted, Dates),
% Проверяем наличие заголовка пагинации
Total = list_to_integer(
proplists:get_value("x-total-count", Headers, "0")),
?assert(Total >= 1),
ct:pal(" OK: got ~p admins (total ~p)", [length(Admins), Total]).
%%%===================================================================
%%% Вспомогательные функции
%%%===================================================================
+128 -13
View File
@@ -7,14 +7,20 @@
%%% Проверяет:
%%% - получение списка записей аудита
%%% - фильтрацию по admin_id
%%% - пагинацию
%%% - фильтрацию по action
%%% - комбинацию фильтров (admin_id + action)
%%% - пагинацию (limit/offset, x-total-count)
%%% - сортировку по timestamp, action, admin_id
%%% - комбинации сортировки с фильтрацией и пагинацией
%%% @end
%%%-------------------------------------------------------------------
-module(admin_audit_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== Admin Audit Tests ==="),
@@ -23,10 +29,9 @@ test() ->
AdminToken = api_test_runner:get_admin_token(),
SuperToken = api_test_runner:get_superadmin_token(),
% Создаём тестовую запись аудита
% Создаём тестовые записи аудита
Me = api_test_runner:admin_get(<<"/v1/admin/me">>, AdminToken),
AdminId = maps:get(<<"id">>, Me),
Path = <<"/v1/admin/user/", UserId/binary>>,
Body = #{<<"action">> => <<"block">>, <<"reason">> => <<"Test">>},
api_test_runner:admin_put(Path, AdminToken, Body),
@@ -34,14 +39,38 @@ test() ->
Body2 = #{<<"action">> => <<"unblock">>, <<"reason">> => <<"Test">>},
api_test_runner:admin_put(Path, AdminToken, Body2),
% ── Основные тесты ──
test_list_audit(SuperToken),
test_filter_audit(SuperToken, AdminId),
test_audit_pagination(SuperToken),
test_list_admin_forbidden(AdminToken),
% ── Фильтрация ──
test_filter_audit_by_admin_id(SuperToken, AdminId),
test_filter_audit_by_action(SuperToken),
% ── Комбинация фильтров ──
test_filter_audit_combined(SuperToken, AdminId),
% ── Пагинация ──
test_audit_pagination(SuperToken),
test_audit_pagination_headers(SuperToken),
% ── Сортировка ──
test_sort_by_timestamp_desc(SuperToken),
test_sort_by_action_asc(SuperToken),
test_sort_by_admin_id_asc(SuperToken),
% ── Комбинации сортировки с фильтрацией и пагинацией ──
test_sort_combined_with_filter(SuperToken, AdminId),
test_sort_combined_with_filter_and_pagination(SuperToken, AdminId),
ct:pal("=== All admin audit tests passed ==="),
ok.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
%% @doc GET /v1/admin/audit список всех записей аудита.
test_list_audit(Token) ->
ct:pal(" TEST: List all audit records"),
Records = api_test_runner:admin_get(<<"/v1/admin/audit">>, Token),
@@ -49,13 +78,44 @@ test_list_audit(Token) ->
?assert(length(Records) >= 1),
ct:pal(" OK: ~p records", [length(Records)]).
test_filter_audit(Token, AdminId) ->
%% @doc GET /v1/admin/audit запрет для обычного админа.
test_list_admin_forbidden(Token) ->
ct:pal(" TEST: List audit as non-superadmin (403)"),
Resp = api_test_runner:admin_request(get, <<"/v1/admin/audit">>, Token),
?assertMatch({ok, 403, _, _}, Resp),
ct:pal(" OK: got 403").
%% ── Фильтрация ──
%% @doc Фильтрация по admin_id.
test_filter_audit_by_admin_id(Token, AdminId) ->
ct:pal(" TEST: Filter audit by admin_id"),
Records = api_test_runner:admin_get(<<"/v1/admin/audit?admin_id=", AdminId/binary>>, Token),
?assert(is_list(Records)),
[?assertEqual(AdminId, maps:get(<<"admin_id">>, R)) || R <- Records],
ct:pal(" OK: ~p records", [length(Records)]).
%% @doc Фильтрация по action.
test_filter_audit_by_action(Token) ->
ct:pal(" TEST: Filter audit by action"),
Records = api_test_runner:admin_get(<<"/v1/admin/audit?action=block_user">>, Token),
?assert(is_list(Records)),
[?assertEqual(<<"block_user">>, maps:get(<<"action">>, R)) || R <- Records],
ct:pal(" OK: ~p records with action=block_user", [length(Records)]).
%% @doc Комбинация фильтров: admin_id + action.
test_filter_audit_combined(Token, AdminId) ->
ct:pal(" TEST: Combined filter (admin_id + action)"),
Records = api_test_runner:admin_get(
<<"/v1/admin/audit?admin_id=", AdminId/binary, "&action=unblock_user">>, Token),
?assert(is_list(Records)),
[?assertEqual(AdminId, maps:get(<<"admin_id">>, R)) || R <- Records],
[?assertEqual(<<"unblock_user">>, maps:get(<<"action">>, R)) || R <- Records],
ct:pal(" OK: ~p records match both criteria", [length(Records)]).
%% ── Пагинация ──
%% @doc Пагинация: limit=1, offset=0.
test_audit_pagination(Token) ->
ct:pal(" TEST: Audit pagination"),
Page1 = api_test_runner:admin_get(<<"/v1/admin/audit?limit=1&offset=0">>, Token),
@@ -71,9 +131,64 @@ test_audit_pagination(Token) ->
end,
ct:pal(" OK").
-spec test_list_admin_forbidden(binary()) -> ok.
test_list_admin_forbidden(Token) ->
ct:pal(" TEST: List audit as non-superadmin (403)"),
Resp = api_test_runner:admin_request(get, <<"/v1/admin/audit">>, Token),
?assertMatch({ok, 403, _, _}, Resp),
ct:pal(" OK: got 403").
%% @doc Проверка заголовков пагинации (x-total-count).
test_audit_pagination_headers(Token) ->
ct:pal(" TEST: Audit pagination headers (x-total-count)"),
{ok, 200, Headers, _} = api_test_runner:admin_request(get,
<<"/v1/admin/audit?limit=2&offset=0">>, Token),
Total = list_to_integer(
proplists:get_value("x-total-count", Headers, "0")),
?assert(Total >= 1),
ct:pal(" OK: total count = ~p", [Total]).
%% ── Сортировка ──
%% @doc Сортировка по timestamp по убыванию.
test_sort_by_timestamp_desc(Token) ->
ct:pal(" TEST: Sort audit by timestamp desc"),
Records = api_test_runner:admin_get(<<"/v1/admin/audit?sort=timestamp&order=desc">>, Token),
Timestamps = [maps:get(<<"timestamp">>, R) || R <- Records],
?assertEqual(lists:reverse(lists:sort(Timestamps)), Timestamps),
ct:pal(" OK: sorted by timestamp desc").
%% @doc Сортировка по action по возрастанию.
test_sort_by_action_asc(Token) ->
ct:pal(" TEST: Sort audit by action asc"),
Records = api_test_runner:admin_get(<<"/v1/admin/audit?sort=action&order=asc">>, Token),
Actions = [maps:get(<<"action">>, R) || R <- Records],
?assertEqual(lists:sort(Actions), Actions),
ct:pal(" OK: sorted by action asc").
%% @doc Сортировка по admin_id по возрастанию.
test_sort_by_admin_id_asc(Token) ->
ct:pal(" TEST: Sort audit by admin_id asc"),
Records = api_test_runner:admin_get(<<"/v1/admin/audit?sort=admin_id&order=asc">>, Token),
AdminIds = [maps:get(<<"admin_id">>, R) || R <- Records],
?assertEqual(lists:sort(AdminIds), AdminIds),
ct:pal(" OK: sorted by admin_id asc").
%% ── Комбинации сортировки с фильтрацией и пагинацией ──
%% @doc Сортировка + фильтр: admin_id + action, сортировка по timestamp asc.
test_sort_combined_with_filter(Token, AdminId) ->
ct:pal(" TEST: Sort audit (timestamp asc) filtered by admin_id + action"),
Records = api_test_runner:admin_get(
<<"/v1/admin/audit?admin_id=", AdminId/binary, "&action=unblock_user&sort=timestamp&order=asc">>, Token),
[?assertEqual(AdminId, maps:get(<<"admin_id">>, R)) || R <- Records],
[?assertEqual(<<"unblock_user">>, maps:get(<<"action">>, R)) || R <- Records],
Timestamps = [maps:get(<<"timestamp">>, R) || R <- Records],
?assertEqual(lists:sort(Timestamps), Timestamps),
ct:pal(" OK: ~p records sorted", [length(Records)]).
%% @doc Сортировка + фильтр + пагинация: action=block, сортировка по timestamp desc, limit=1.
test_sort_combined_with_filter_and_pagination(Token, _AdminId) ->
ct:pal(" TEST: Filter + sort + pagination (action=block_user, sort=timestamp desc, limit=1)"),
{ok, 200, Headers, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/audit?action=block_user&sort=timestamp&order=desc&limit=1&offset=0">>, Token),
Records = jsx:decode(list_to_binary(Body), [return_maps]),
?assert(length(Records) =< 1),
[?assertEqual(<<"block_user">>, maps:get(<<"action">>, R)) || R <- Records],
Total = list_to_integer(
proplists:get_value("x-total-count", Headers, "0")),
?assert(Total >= 1),
ct:pal(" OK: got ~p records (total ~p)", [length(Records), Total]).
+122 -38
View File
@@ -14,7 +14,10 @@
%%% - редактирование существующего слова
%%% - удаление слова
%%% - массовую загрузку списком
%%% - пагинацию
%%% - **фильтрацию** по слову (q)
%%% - **сортировку** по word, added_at
%%% - **комбинации фильтрации, сортировки и пагинации**
%%% - **пагинацию** (limit/offset, x-total-count)
%%% @end
%%%-------------------------------------------------------------------
-module(admin_banned_words_tests).
@@ -29,23 +32,42 @@ test() ->
ct:pal("=== Admin Banned Words Tests ==="),
Token = api_test_runner:get_admin_token(),
% Добавляем два исходных слова
% Добавляем несколько слов для тестирования
Unique = integer_to_binary(erlang:system_time()),
Word1 = <<"badword1_", Unique/binary>>,
Word2 = <<"badword2_", Unique/binary>>,
Word1 = <<"alpha_", Unique/binary>>,
Word2 = <<"beta_", Unique/binary>>,
Word3 = <<"gamma_", Unique/binary>>,
api_test_runner:admin_post(<<"/v1/admin/banned-words">>, Token, #{<<"word">> => Word1}),
api_test_runner:admin_post(<<"/v1/admin/banned-words">>, Token, #{<<"word">> => Word2}),
api_test_runner:admin_post(<<"/v1/admin/banned-words">>, Token, #{<<"word">> => Word3}),
test_list_words(Token, Word1, Word2),
% ── Основные тесты ──
test_list_words(Token),
test_add_word(Token),
test_update_word(Token, Word1),
test_batch_add_words(Token),
test_words_pagination(Token),
test_delete_word(Token, Word2),
test_update_word_conflict(Token),
test_batch_add_invalid(Token),
test_batch_add_duplicates(Token),
% ── Пагинация ──
test_words_pagination(Token),
test_pagination_headers(Token),
% ── Фильтрация ──
test_filter_by_word(Token, Word3),
% ── Сортировка ──
test_sort_by_word_asc(Token),
test_sort_by_added_at_desc(Token),
% ── Комбинации ──
test_filter_with_sort(Token, Unique),
test_filter_sort_pagination(Token, Unique),
% Удаление beta только после всех тестов фильтрации/сортировки
test_delete_word(Token, Word2),
ct:pal("=== All admin banned words tests passed ==="),
ok.
@@ -54,17 +76,14 @@ test() ->
%%%===================================================================
%% @doc GET /v1/admin/banned-words список всех слов.
-spec test_list_words(binary(), binary(), binary()) -> ok.
test_list_words(Token, Word1, Word2) ->
test_list_words(Token) ->
ct:pal(" TEST: List all banned words"),
Words = api_test_runner:admin_get(<<"/v1/admin/banned-words">>, Token),
?assert(is_list(Words)),
?assert(length(Words) >= 2),
?assert(lists:any(fun(W) -> maps:get(<<"word">>, W) =:= Word1 orelse maps:get(<<"word">>, W) =:= Word2 end, Words)),
?assert(length(Words) >= 3),
ct:pal(" OK: ~p words", [length(Words)]).
%% @doc POST /v1/admin/banned-words добавление нового слова.
-spec test_add_word(binary()) -> ok.
test_add_word(Token) ->
ct:pal(" TEST: Add a new banned word"),
Unique = integer_to_binary(erlang:system_time()),
@@ -76,40 +95,31 @@ test_add_word(Token) ->
ct:pal(" OK").
%% @doc PUT /v1/admin/banned-words/:word редактирование слова.
-spec test_update_word(binary(), binary()) -> ok.
test_update_word(Token, Word) ->
ct:pal(" TEST: Update a banned word"),
NewWord = <<Word/binary, "_updated">>,
Result = api_test_runner:admin_put(<<"/v1/admin/banned-words/", Word/binary>>, Token, #{<<"word">> => NewWord}),
?assertEqual(<<"updated">>, maps:get(<<"status">>, Result)),
% Проверяем, что старое слово отсутствует, а новое присутствует
Words = api_test_runner:admin_get(<<"/v1/admin/banned-words">>, Token),
?assertNot(lists:any(fun(W) -> maps:get(<<"word">>, W) =:= Word end, Words)),
?assert(lists:any(fun(W) -> maps:get(<<"word">>, W) =:= NewWord end, Words)),
ct:pal(" OK").
%% @doc POST /v1/admin/banned-words/batch массовая загрузка.
-spec test_batch_add_words(binary()) -> ok.
test_batch_add_words(Token) ->
ct:pal(" TEST: Batch add banned words"),
Unique = integer_to_binary(erlang:system_time()),
Words = [
<<"batch1_", Unique/binary>>,
<<"batch2_", Unique/binary>>,
<<"batch3_", Unique/binary>>
],
Words = [<<"batch1_", Unique/binary>>, <<"batch2_", Unique/binary>>, <<"batch3_", Unique/binary>>],
Body = jsx:encode(#{<<"words">> => Words}),
{ok, 200, _, RespBody} = api_test_runner:admin_request(post, <<"/v1/admin/banned-words/batch">>, Token, Body),
Result = jsx:decode(list_to_binary(RespBody), [return_maps]),
?assertEqual(3, maps:get(<<"added">>, Result)),
?assertEqual(0, maps:get(<<"skipped">>, Result)),
% Проверяем наличие всех слов
AllWords = api_test_runner:admin_get(<<"/v1/admin/banned-words">>, Token),
[?assert(lists:any(fun(W) -> maps:get(<<"word">>, W) =:= Word end, AllWords)) || Word <- Words],
ct:pal(" OK").
%% @doc DELETE /v1/admin/banned-words/:word удаление слова.
-spec test_delete_word(binary(), binary()) -> ok.
test_delete_word(Token, Word) ->
ct:pal(" TEST: Delete a banned word"),
Path = <<"/v1/admin/banned-words/", Word/binary>>,
@@ -119,29 +129,17 @@ test_delete_word(Token, Word) ->
?assertNot(lists:any(fun(W) -> maps:get(<<"word">>, W) =:= Word end, Words)),
ct:pal(" OK").
%% @doc GET /v1/admin/banned-words?limit=...&offset=... пагинация.
-spec test_words_pagination(binary()) -> ok.
test_words_pagination(Token) ->
ct:pal(" TEST: Banned words pagination"),
Page1 = api_test_runner:admin_get(<<"/v1/admin/banned-words?limit=1&offset=0">>, Token),
?assert(length(Page1) >= 1),
Page2 = api_test_runner:admin_get(<<"/v1/admin/banned-words?limit=1&offset=1">>, Token),
?assert(length(Page2) >= 1),
Id1 = maps:get(<<"id">>, hd(Page1)),
Id2 = maps:get(<<"id">>, hd(Page2)),
?assertNotEqual(Id1, Id2),
ct:pal(" OK").
%% @doc PUT /v1/admin/banned-words/:word попытка переименовать в уже существующее слово.
%% @doc PUT /v1/admin/banned-words/:word – попытка переименовать в уже существующее слово (409).
test_update_word_conflict(Token) ->
ct:pal(" TEST: Update banned word to existing word"),
ct:pal(" TEST: Update banned word to existing word (conflict)"),
Unique = integer_to_binary(erlang:system_time()),
WordA = <<"conflictA_", Unique/binary>>,
WordB = <<"conflictB_", Unique/binary>>,
api_test_runner:admin_post(<<"/v1/admin/banned-words">>, Token, #{<<"word">> => WordA}),
api_test_runner:admin_post(<<"/v1/admin/banned-words">>, Token, #{<<"word">> => WordB}),
Path = <<"/v1/admin/banned-words/", WordA/binary>>,
{ok, 409, _, _} = api_test_runner:admin_request(put, Path, Token, jsx:encode(#{<<"word">> => WordB})),
Resp = api_test_runner:admin_request(put, Path, Token, jsx:encode(#{<<"word">> => WordB})),
?assertMatch({ok, 409, _, _}, Resp),
ct:pal(" OK: got 409 conflict").
%% @doc POST /v1/admin/banned-words/batch с невалидными элементами.
@@ -175,3 +173,89 @@ test_batch_add_duplicates(Token) ->
?assertEqual(2, maps:get(<<"added">>, Result)),
?assertEqual(2, maps:get(<<"skipped">>, Result)),
ct:pal(" OK: duplicates handled").
%% ── Пагинация ──
%% @doc GET /v1/admin/banned-words?limit=...&offset=... пагинация.
test_words_pagination(Token) ->
ct:pal(" TEST: Banned words pagination"),
Page1 = api_test_runner:admin_get(<<"/v1/admin/banned-words?limit=1&offset=0">>, Token),
?assert(length(Page1) >= 1),
Page2 = api_test_runner:admin_get(<<"/v1/admin/banned-words?limit=1&offset=1">>, Token),
?assert(length(Page2) >= 1),
Id1 = maps:get(<<"id">>, hd(Page1)),
Id2 = maps:get(<<"id">>, hd(Page2)),
?assertNotEqual(Id1, Id2),
ct:pal(" OK").
%% @doc Проверка заголовков пагинации (x-total-count).
test_pagination_headers(Token) ->
ct:pal(" TEST: Pagination headers (x-total-count)"),
{ok, 200, Headers, _} = api_test_runner:admin_request(get,
<<"/v1/admin/banned-words?limit=2&offset=0">>, Token),
Total = list_to_integer(
proplists:get_value("x-total-count", Headers, "0")),
?assert(Total >= 1),
ct:pal(" OK: total count = ~p", [Total]).
%% ── Фильтрация ──
%% @doc Фильтрация по слову (q).
test_filter_by_word(Token, Word) ->
ct:pal(" TEST: Filter banned words by word (q)"),
Words = api_test_runner:admin_get(<<"/v1/admin/banned-words?q=", Word/binary>>, Token),
?assert(is_list(Words)),
?assert(length(Words) >= 1),
[?assertEqual(Word, maps:get(<<"word">>, W)) || W <- Words],
ct:pal(" OK: ~p words match", [length(Words)]).
%% ── Сортировка ──
%% @doc Сортировка по word по возрастанию.
test_sort_by_word_asc(Token) ->
ct:pal(" TEST: Sort banned words by word asc"),
Words = api_test_runner:admin_get(<<"/v1/admin/banned-words?sort=word&order=asc">>, Token),
WordList = [maps:get(<<"word">>, W) || W <- Words],
?assertEqual(lists:sort(WordList), WordList),
ct:pal(" OK: sorted by word asc").
%% @doc Сортировка по added_at по убыванию.
test_sort_by_added_at_desc(Token) ->
ct:pal(" TEST: Sort banned words by added_at desc"),
Words = api_test_runner:admin_get(<<"/v1/admin/banned-words?sort=added_at&order=desc">>, Token),
Dates = [maps:get(<<"added_at">>, W) || W <- Words],
Sorted = lists:reverse(lists:sort(Dates)),
?assertEqual(Sorted, Dates),
ct:pal(" OK: sorted by added_at desc").
%% ── Комбинации ──
%% @doc Фильтр + сортировка: q=alpha, сортировка по added_at asc.
test_filter_with_sort(Token, Unique) ->
ct:pal(" TEST: Filter + sort (q=alpha, sort=added_at, order=asc)"),
Q = <<"alpha_", Unique/binary>>,
Words = api_test_runner:admin_get(
<<"/v1/admin/banned-words?q=", Q/binary, "&sort=added_at&order=asc">>, Token),
?assert(length(Words) >= 1),
% Проверяем, что все слова содержат "alpha"
[?assert(binary:match(maps:get(<<"word">>, W), <<"alpha">>) =/= nomatch) || W <- Words],
Dates = [maps:get(<<"added_at">>, W) || W <- Words],
?assertEqual(lists:sort(Dates), Dates),
ct:pal(" OK: ~p words", [length(Words)]).
%% @doc Фильтр + сортировка + пагинация: q=beta, sort=word desc, limit=1.
test_filter_sort_pagination(Token, Unique) ->
ct:pal(" TEST: Filter + sort + pagination (q=beta, sort=word desc, limit=1)"),
Q = <<"beta_", Unique/binary>>,
{ok, 200, Headers, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/banned-words?q=", Q/binary, "&sort=word&order=desc&limit=1&offset=0">>, Token),
Words = jsx:decode(list_to_binary(Body), [return_maps]),
?assert(length(Words) =< 1),
case Words of
[W | _] -> ?assert(binary:match(maps:get(<<"word">>, W), <<"beta">>) =/= nomatch);
_ -> ok
end,
Total = list_to_integer(
proplists:get_value("x-total-count", Headers, "0")),
?assert(Total >= 1),
ct:pal(" OK: got ~p words (total ~p)", [length(Words), Total]).
+108 -19
View File
@@ -5,7 +5,8 @@
%%% GET /v1/admin/calendars/:id
%%% PUT /v1/admin/calendars/:id
%%% DELETE /v1/admin/calendars/:id
%%% Проверяет пагинацию, фильтрацию по статусу/типу, поиск и сортировку.
%%% Проверяет пагинацию, фильтрацию по статусу/типу, поиск и сортировку,
%%% а также комбинации фильтров, сортировки и пагинации.
%%% @end
%%%-------------------------------------------------------------------
-module(admin_calendars_tests).
@@ -36,9 +37,20 @@ test() ->
test_filter_by_status(AdminToken),
test_filter_by_type(AdminToken),
test_search(AdminToken),
%% Комбинации фильтров
test_filter_combined_status_type(AdminToken),
%% Сортировка
test_sort_by_title(AdminToken),
test_sort_by_created_at(AdminToken)
test_sort_by_created_at(AdminToken),
%% Фильтр + сортировка
test_filter_status_with_sort(AdminToken),
test_filter_type_with_sort(AdminToken),
test_filter_search_with_sort(AdminToken),
%% Фильтр + пагинация
test_filter_status_with_pagination(AdminToken),
%% Фильтр + сортировка + пагинация
test_filter_type_with_sort_and_pagination(AdminToken),
test_filter_sort_pagination_combined(AdminToken)
after
cleanup(AdminToken, CalIds)
end,
@@ -52,18 +64,14 @@ test() ->
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]}.
@@ -76,10 +84,8 @@ create_calendar(UserToken, Title, Description) ->
-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),
@@ -110,7 +116,6 @@ 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)),
@@ -129,7 +134,6 @@ test_update_calendar_invalid_body(AdminToken, CalId) ->
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)),
@@ -148,11 +152,8 @@ test_pagination(AdminToken) ->
{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")
),
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),
@@ -166,7 +167,6 @@ test_filter_by_status(AdminToken) ->
{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],
@@ -189,18 +189,30 @@ test_search(AdminToken) ->
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
string:str(Title, "commercial") > 0 orelse string:str(Desc, "commercial") > 0
end, Results),
?assert(HasMatch),
ct:pal(" OK: search works").
%%%===================================================================
%%% Комбинации фильтров
%%%===================================================================
test_filter_combined_status_type(AdminToken) ->
ct:pal(" TEST: Combined filter (status=active, type=personal)"),
{ok, 200, _, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/calendars?status=active&type=personal">>, AdminToken, <<"">>),
List = jsx:decode(list_to_binary(Body), [return_maps]),
[?assertEqual(<<"active">>, maps:get(<<"status">>, C)) || C <- List],
[?assertEqual(<<"personal">>, maps:get(<<"type">>, C)) || C <- List],
ct:pal(" OK: ~p calendars match both criteria", [length(List)]).
%%%===================================================================
%%% Сортировка
%%%===================================================================
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, <<"">>),
{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),
@@ -208,9 +220,86 @@ test_sort_by_title(AdminToken) ->
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, <<"">>),
{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").
%%%===================================================================
%%% Фильтр + сортировка
%%%===================================================================
test_filter_status_with_sort(AdminToken) ->
ct:pal(" TEST: Filter status=frozen + sort by title asc"),
{ok, 200, _, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/calendars?status=frozen&sort_by=title&sort_order=asc">>, AdminToken, <<"">>),
List = jsx:decode(list_to_binary(Body), [return_maps]),
[?assertEqual(<<"frozen">>, maps:get(<<"status">>, C)) || C <- List],
Titles = [maps:get(<<"title">>, C) || C <- List],
?assertEqual(lists:sort(Titles), Titles),
ct:pal(" OK: ~p calendars", [length(List)]).
test_filter_type_with_sort(AdminToken) ->
ct:pal(" TEST: Filter type=commercial + sort by created_at desc"),
{ok, 200, _, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/calendars?type=commercial&sort_by=created_at&sort_order=desc">>, AdminToken, <<"">>),
List = jsx:decode(list_to_binary(Body), [return_maps]),
[?assertEqual(<<"commercial">>, maps:get(<<"type">>, C)) || C <- List],
Dates = [maps:get(<<"created_at">>, C) || C <- List],
Sorted = lists:reverse(lists:sort(Dates)),
?assertEqual(Sorted, Dates),
ct:pal(" OK: ~p calendars", [length(List)]).
test_filter_search_with_sort(AdminToken) ->
ct:pal(" TEST: Search q=Personal + sort by title desc"),
{ok, 200, _, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/calendars?q=Personal&sort_by=title&sort_order=desc">>, AdminToken, <<"">>),
List = jsx:decode(list_to_binary(Body), [return_maps]),
[?assert(string:str(string:lowercase(binary_to_list(maps:get(<<"title">>, C))), "personal") > 0
orelse string:str(string:lowercase(binary_to_list(maps:get(<<"description">>, C))), "personal") > 0)
|| C <- List],
Titles = [maps:get(<<"title">>, C) || C <- List],
?assertEqual(lists:reverse(lists:sort(Titles)), Titles),
ct:pal(" OK: ~p calendars", [length(List)]).
%%%===================================================================
%%% Фильтр + пагинация
%%%===================================================================
test_filter_status_with_pagination(AdminToken) ->
ct:pal(" TEST: Filter status=active + pagination (limit=1, offset=0)"),
{ok, 200, Headers, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/calendars?status=active&limit=1&offset=0">>, AdminToken, <<"">>),
List = jsx:decode(list_to_binary(Body), [return_maps]),
?assertEqual(1, length(List)),
[?assertEqual(<<"active">>, maps:get(<<"status">>, C)) || C <- List],
Total = list_to_integer(proplists:get_value("x-total-count", Headers, "0")),
?assert(Total >= 1),
ct:pal(" OK: total count = ~p", [Total]).
%%%===================================================================
%%% Фильтр + сортировка + пагинация
%%%===================================================================
test_filter_type_with_sort_and_pagination(AdminToken) ->
ct:pal(" TEST: Filter type=personal + sort by created_at asc + pagination (limit=1)"),
{ok, 200, Headers, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/calendars?type=personal&sort_by=created_at&sort_order=asc&limit=1&offset=0">>, AdminToken, <<"">>),
List = jsx:decode(list_to_binary(Body), [return_maps]),
?assertEqual(1, length(List)),
[?assertEqual(<<"personal">>, maps:get(<<"type">>, C)) || C <- List],
Total = list_to_integer(proplists:get_value("x-total-count", Headers, "0")),
?assert(Total >= 1),
ct:pal(" OK: total count = ~p", [Total]).
test_filter_sort_pagination_combined(AdminToken) ->
ct:pal(" TEST: status=active + sort_by=title asc + limit=2"),
{ok, 200, Headers, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/calendars?status=active&sort_by=title&sort_order=asc&limit=2&offset=0">>, AdminToken, <<"">>),
List = jsx:decode(list_to_binary(Body), [return_maps]),
[?assertEqual(<<"active">>, maps:get(<<"status">>, C)) || C <- List],
Titles = [maps:get(<<"title">>, C) || C <- List],
?assertEqual(lists:sort(Titles), Titles),
Total = list_to_integer(proplists:get_value("x-total-count", Headers, "0")),
?assert(Total >= 1),
ct:pal(" OK: ~p calendars (total ~p)", [length(List), Total]).
+102 -12
View File
@@ -5,22 +5,17 @@
%%% GET /v1/admin/events/:id
%%% PUT /v1/admin/events/:id
%%% DELETE /v1/admin/events/:id
%%% Проверяет фильтрацию, сортировку, пагинацию и их комбинации.
%%% @end
%%%-------------------------------------------------------------------
-module(admin_events_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% EUnit test generator
%%%===================================================================
test() ->
ct:pal("=== Admin Events Tests ==="),
Token = api_test_runner:get_admin_token(),
UserToken = api_test_runner:get_user_token(),
%% Создаём тестовый календарь и два события
CalId = api_test_runner:create_calendar(UserToken, #{title => <<"EventsTestCal">>}),
Event1Id = api_test_runner:create_event(UserToken, CalId, #{
@@ -33,7 +28,6 @@ test() ->
start_time => api_test_runner:future_date(),
duration => 60
}),
%% Выполняем тесты
test_list_events(Token, CalId, Event1Id, Event2Id),
test_get_event(Token, Event1Id),
@@ -42,15 +36,25 @@ test() ->
test_filter_events(Token, CalId),
test_search_events(Token, CalId),
test_event_pagination(Token, CalId),
test_delete_event(Token, Event1Id),
%% Комбинации фильтров
test_filter_combined_status_calendar(Token, CalId),
test_filter_combined_title_status(Token, CalId),
%% Сортировка
test_sort_by_title(Token, CalId),
test_sort_by_start_time(Token, CalId),
%% Фильтр + сортировка
test_filter_status_sort_title(Token, CalId),
test_search_sort_created_at(Token, CalId),
%% Фильтр + сортировка + пагинация
test_filter_sort_pagination_combined(Token, CalId),
%% Проверка заголовка x-total-count
test_pagination_headers(Token, CalId),
test_delete_event(Token, Event1Id),
ct:pal("=== All admin events tests passed ==="),
ok.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
%% @doc GET /v1/admin/events список событий (базовая проверка).
test_list_events(Token, _CalId, _Event1Id, _Event2Id) ->
ct:pal(" TEST: List all events"),
@@ -117,3 +121,89 @@ test_event_pagination(Token, CalId) ->
Id2 = maps:get(<<"id">>, hd(Page2)),
?assertNotEqual(Id1, Id2),
ct:pal(" OK").
%% ── Комбинации фильтров ──
test_filter_combined_status_calendar(Token, CalId) ->
ct:pal(" TEST: Filter by status=active + calendar_id"),
Path = <<"/v1/admin/events?status=active&calendar_id=", CalId/binary>>,
{ok, 200, _, Body} = api_test_runner:admin_request(get, Path, Token, <<"">>),
Events = jsx:decode(list_to_binary(Body), [return_maps]),
[?assertEqual(<<"active">>, maps:get(<<"status">>, E)) || E <- Events],
[?assertEqual(CalId, maps:get(<<"calendar_id">>, E)) || E <- Events],
ct:pal(" OK: ~p events", [length(Events)]).
test_filter_combined_title_status(Token, CalId) ->
ct:pal(" TEST: Filter by title=Updated + status=active"),
Path = <<"/v1/admin/events?title=Updated%20by%20admin&status=active&calendar_id=", CalId/binary>>,
{ok, 200, _, Body} = api_test_runner:admin_request(get, Path, Token, <<"">>),
Events = jsx:decode(list_to_binary(Body), [return_maps]),
[?assertEqual(<<"Updated by admin">>, maps:get(<<"title">>, E)) || E <- Events],
[?assertEqual(<<"active">>, maps:get(<<"status">>, E)) || E <- Events],
ct:pal(" OK: ~p events", [length(Events)]).
%% ── Сортировка ──
test_sort_by_title(Token, CalId) ->
ct:pal(" TEST: Sort by title asc"),
Path = <<"/v1/admin/events?calendar_id=", CalId/binary, "&sort=title&order=asc">>,
{ok, 200, _, Body} = api_test_runner:admin_request(get, Path, Token, <<"">>),
Events = jsx:decode(list_to_binary(Body), [return_maps]),
Titles = [maps:get(<<"title">>, E) || E <- Events],
?assertEqual(lists:sort(Titles), Titles),
ct:pal(" OK: sorted by title asc").
test_sort_by_start_time(Token, CalId) ->
ct:pal(" TEST: Sort by start_time desc"),
Path = <<"/v1/admin/events?calendar_id=", CalId/binary, "&sort=start_time&order=desc">>,
{ok, 200, _, Body} = api_test_runner:admin_request(get, Path, Token, <<"">>),
Events = jsx:decode(list_to_binary(Body), [return_maps]),
Dates = [maps:get(<<"start_time">>, E) || E <- Events],
Sorted = lists:reverse(lists:sort(Dates)),
?assertEqual(Sorted, Dates),
ct:pal(" OK: sorted by start_time desc").
%% ── Фильтр + сортировка ──
test_filter_status_sort_title(Token, CalId) ->
ct:pal(" TEST: Filter status=active + sort by title desc"),
Path = <<"/v1/admin/events?status=active&calendar_id=", CalId/binary, "&sort=title&order=desc">>,
{ok, 200, _, Body} = api_test_runner:admin_request(get, Path, Token, <<"">>),
Events = jsx:decode(list_to_binary(Body), [return_maps]),
[?assertEqual(<<"active">>, maps:get(<<"status">>, E)) || E <- Events],
Titles = [maps:get(<<"title">>, E) || E <- Events],
?assertEqual(lists:reverse(lists:sort(Titles)), Titles),
ct:pal(" OK: ~p events", [length(Events)]).
test_search_sort_created_at(Token, CalId) ->
ct:pal(" TEST: Search q=Updated + sort by created_at asc"),
Path = <<"/v1/admin/events?q=Updated&calendar_id=", CalId/binary, "&sort=created_at&order=asc">>,
{ok, 200, _, Body} = api_test_runner:admin_request(get, Path, Token, <<"">>),
Events = jsx:decode(list_to_binary(Body), [return_maps]),
[?assert(binary:match(maps:get(<<"title">>, E), <<"Updated">>) =/= nomatch) || E <- Events],
Dates = [maps:get(<<"created_at">>, E) || E <- Events],
?assertEqual(lists:sort(Dates), Dates),
ct:pal(" OK: ~p events", [length(Events)]).
%% ── Фильтр + сортировка + пагинация ──
test_filter_sort_pagination_combined(Token, CalId) ->
ct:pal(" TEST: status=active + sort=start_time desc + limit=1"),
Path = <<"/v1/admin/events?status=active&calendar_id=", CalId/binary, "&sort=start_time&order=desc&limit=1&offset=0">>,
{ok, 200, Headers, Body} = api_test_runner:admin_request(get, Path, Token, <<"">>),
Events = jsx:decode(list_to_binary(Body), [return_maps]),
?assert(length(Events) =< 1),
[?assertEqual(<<"active">>, maps:get(<<"status">>, E)) || E <- Events],
Total = list_to_integer(proplists:get_value("x-total-count", Headers, "0")),
?assert(Total >= 1),
ct:pal(" OK: ~p events (total ~p)", [length(Events), Total]).
%% ── Заголовки пагинации ──
test_pagination_headers(Token, CalId) ->
ct:pal(" TEST: Pagination headers (x-total-count)"),
Path = <<"/v1/admin/events?calendar_id=", CalId/binary, "&limit=2&offset=0">>,
{ok, 200, Headers, _} = api_test_runner:admin_request(get, Path, Token, <<"">>),
Total = list_to_integer(proplists:get_value("x-total-count", Headers, "0")),
?assert(Total >= 1),
ct:pal(" OK: total count = ~p", [Total]).
+27 -13
View File
@@ -9,23 +9,21 @@
%%% - заморозку/разморозку события
%%% - скрытие/раскрытие отзыва
%%% - блокировку/разблокировку пользователя
%%% - наличие записей в аудите после модерации
%%% @end
%%%-------------------------------------------------------------------
-module(admin_moderation_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== Admin Moderation Tests ==="),
Token = api_test_runner:get_admin_token(),
UserToken = api_test_runner:get_user_token(),
% Создаём календарь и событие
CalId = api_test_runner:create_calendar(UserToken, #{title => <<"ModTestCal">>}),
EventId = api_test_runner:create_event(UserToken, CalId, #{
@@ -33,24 +31,18 @@ test() ->
start_time => api_test_runner:future_date(),
duration => 60
}),
% Создаём пользователя для блокировки
UserEmail = api_test_runner:unique_email(<<"moduser">>),
UserTok = api_test_runner:register_and_login(UserEmail, <<"pass">>),
#{<<"id">> := UserId} = api_test_runner:client_get(<<"/v1/user/me">>, UserTok),
% Создаём отзыв (требуется участие)
#{<<"id">> := BookingId} = api_test_runner:client_post(
<<"/v1/events/", EventId/binary, "/bookings">>, UserTok, #{}),
api_test_runner:client_put(<<"/v1/bookings/", BookingId/binary>>, UserToken,
#{action => <<"confirm">>}),
#{<<"id">> := ReviewId} = api_test_runner:client_post(<<"/v1/reviews">>, UserTok, #{
target_type => <<"event">>,
target_id => EventId,
rating => 3,
comment => <<"Moderate me">>
}),
#{<<"id">> := ReviewId} = api_test_runner:client_post(<<"/v1/reviews">>, UserTok,
#{ target_type => <<"event">>, target_id => EventId, rating => 3,
comment => <<"Moderate me">> }),
% Тесты модерации
test_freeze_calendar(Token, CalId),
test_unfreeze_calendar(Token, CalId),
@@ -60,7 +52,8 @@ test() ->
test_unhide_review(Token, ReviewId),
test_block_user(Token, UserId),
test_unblock_user(Token, UserId),
% Проверка аудита после модерации
test_moderate_calendar_audit(Token),
ct:pal("=== All admin moderation tests passed ==="),
ok.
@@ -131,3 +124,24 @@ test_unblock_user(Token, UserId) ->
Resp = api_test_runner:admin_put(Path, Token, Body),
?assertEqual(<<"active">>, maps:get(<<"status">>, Resp)),
ct:pal(" OK").
test_moderate_calendar_audit(Token) ->
ct:pal(" TEST: Audit record for freeze calendar"),
UserToken2 = api_test_runner:get_user_token(),
CalId = api_test_runner:create_calendar(UserToken2, #{title => <<"AuditCal">>}),
Path = <<"/v1/admin/calendar/", CalId/binary>>,
api_test_runner:admin_put(Path, Token, #{action => <<"freeze">>, reason => <<"Violation">>}),
ct:sleep(200),
SuperToken = api_test_runner:get_superadmin_token(),
AuditRecords = api_test_runner:admin_get(<<"/v1/admin/audit?action=freeze_calendar">>, SuperToken),
?assert(length(AuditRecords) >= 1),
Matching = [R || R <- AuditRecords,
case maps:get(<<"entity_id">>, R) of
#{<<"id">> := Id} -> Id =:= CalId;
_ -> false
end],
?assert(length(Matching) >= 1),
Last = hd(Matching),
?assertEqual(<<"freeze_calendar">>, maps:get(<<"action">>, Last)),
?assertEqual(<<"calendar">>, maps:get(<<"entity_type">>, Last)),
ct:pal(" OK: audit record found").
+118 -13
View File
@@ -11,33 +11,31 @@
%%% - получение жалобы по ID
%%% - обновление статуса жалобы (review, dismiss)
%%% - фильтрацию по статусу и типу цели
%%% - пагинацию
%%% - комбинации фильтров (status + target_type)
%%% - сортировку по created_at и status
%%% - комбинации фильтрации с сортировкой
%%% - пагинацию (limit/offset, x-total-count)
%%% - комбинации фильтрации, сортировки и пагинации
%%% @end
%%%-------------------------------------------------------------------
-module(admin_reports_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== Admin Reports Tests ==="),
Token = api_test_runner:get_admin_token(),
UserToken = api_test_runner:get_user_token(),
% Создаём тестовые данные: календарь, событие
CalId = api_test_runner:create_calendar(UserToken, #{title => <<"ReportsTestCal">>}),
EventId = api_test_runner:create_event(UserToken, CalId, #{
title => <<"Event for reporting">>,
start_time => api_test_runner:future_date(),
duration => 60
}),
% Создаём две жалобы от имени пользователя
#{<<"id">> := Report1Id} = api_test_runner:client_post(<<"/v1/reports">>, UserToken, #{
<<"target_type">> => <<"event">>,
<<"target_id">> => EventId,
@@ -53,8 +51,22 @@ test() ->
test_get_report(Token, Report1Id),
test_review_report(Token, Report1Id),
test_dismiss_report(Token, Report2Id),
test_filter_reports(Token),
test_filter_combined_status_target(Token),
test_sort_by_created_at_desc(Token),
test_sort_by_created_at_asc(Token),
test_sort_by_status_asc(Token),
test_filter_status_sort_created_at(Token),
test_filter_target_sort_status(Token),
test_report_pagination(Token),
test_pagination_headers(Token),
test_filter_status_with_pagination(Token),
test_filter_sort_pagination_combined(Token),
ct:pal("=== All admin reports tests passed ==="),
ok.
@@ -63,7 +75,6 @@ test() ->
%%% Тестовые функции
%%%===================================================================
%% @doc GET /v1/admin/reports – проверяет получение списка жалоб.
-spec test_list_reports(binary(), binary()) -> ok.
test_list_reports(Token, ReportId) ->
ct:pal(" TEST: List all reports"),
@@ -73,7 +84,6 @@ test_list_reports(Token, ReportId) ->
?assert(lists:any(fun(R) -> maps:get(<<"id">>, R) =:= ReportId end, Reports)),
ct:pal(" OK: ~p reports", [length(Reports)]).
%% @doc GET /v1/admin/reports/:id проверяет получение жалобы по ID.
-spec test_get_report(binary(), binary()) -> ok.
test_get_report(Token, ReportId) ->
ct:pal(" TEST: Get report by ID"),
@@ -83,7 +93,6 @@ test_get_report(Token, ReportId) ->
?assertEqual(<<"pending">>, maps:get(<<"status">>, Report)),
ct:pal(" OK").
%% @doc PUT /v1/admin/reports/:id проверяет изменение статуса на reviewed.
-spec test_review_report(binary(), binary()) -> ok.
test_review_report(Token, ReportId) ->
ct:pal(" TEST: Review report (set status to reviewed)"),
@@ -92,7 +101,6 @@ test_review_report(Token, ReportId) ->
?assertEqual(<<"reviewed">>, maps:get(<<"status">>, Updated)),
ct:pal(" OK").
%% @doc PUT /v1/admin/reports/:id проверяет изменение статуса на dismissed.
-spec test_dismiss_report(binary(), binary()) -> ok.
test_dismiss_report(Token, ReportId) ->
ct:pal(" TEST: Dismiss report (set status to dismissed)"),
@@ -101,7 +109,6 @@ test_dismiss_report(Token, ReportId) ->
?assertEqual(<<"dismissed">>, maps:get(<<"status">>, Updated)),
ct:pal(" OK").
%% @doc GET /v1/admin/reports?status=...&target_type=... проверяет фильтрацию.
-spec test_filter_reports(binary()) -> ok.
test_filter_reports(Token) ->
ct:pal(" TEST: Filter reports by status=reviewed"),
@@ -116,7 +123,69 @@ test_filter_reports(Token) ->
[?assertEqual(<<"event">>, maps:get(<<"target_type">>, R)) || R <- Reports2],
ct:pal(" OK: ~p reports for events", [length(Reports2)]).
%% @doc GET /v1/admin/reports?limit=...&offset=... проверяет пагинацию.
-spec test_filter_combined_status_target(binary()) -> ok.
test_filter_combined_status_target(Token) ->
ct:pal(" TEST: Combined filter (status=dismissed & target_type=event)"),
Reports = api_test_runner:admin_get(
<<"/v1/admin/reports?status=dismissed&target_type=event">>, Token),
?assert(is_list(Reports)),
[?assertEqual(<<"dismissed">>, maps:get(<<"status">>, R)) || R <- Reports],
[?assertEqual(<<"event">>, maps:get(<<"target_type">>, R)) || R <- Reports],
ct:pal(" OK: ~p reports match both criteria", [length(Reports)]).
-spec test_sort_by_created_at_desc(binary()) -> ok.
test_sort_by_created_at_desc(Token) ->
ct:pal(" TEST: Sort by created_at desc"),
{ok, 200, _, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/reports?sort=created_at&order=desc">>, Token, <<"">>),
Reports = jsx:decode(list_to_binary(Body), [return_maps]),
Dates = [maps:get(<<"created_at">>, R) || R <- Reports],
Sorted = lists:reverse(lists:sort(Dates)),
?assertEqual(Sorted, Dates),
ct:pal(" OK: sorted by created_at desc").
-spec test_sort_by_created_at_asc(binary()) -> ok.
test_sort_by_created_at_asc(Token) ->
ct:pal(" TEST: Sort by created_at asc"),
{ok, 200, _, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/reports?sort=created_at&order=asc">>, Token, <<"">>),
Reports = jsx:decode(list_to_binary(Body), [return_maps]),
Dates = [maps:get(<<"created_at">>, R) || R <- Reports],
?assertEqual(lists:sort(Dates), Dates),
ct:pal(" OK: sorted by created_at asc").
-spec test_sort_by_status_asc(binary()) -> ok.
test_sort_by_status_asc(Token) ->
ct:pal(" TEST: Sort by status asc"),
{ok, 200, _, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/reports?sort=status&order=asc">>, Token, <<"">>),
Reports = jsx:decode(list_to_binary(Body), [return_maps]),
Statuses = [maps:get(<<"status">>, R) || R <- Reports],
?assertEqual(lists:sort(Statuses), Statuses),
ct:pal(" OK: sorted by status asc").
-spec test_filter_status_sort_created_at(binary()) -> ok.
test_filter_status_sort_created_at(Token) ->
ct:pal(" TEST: Filter status=reviewed + sort by created_at asc"),
{ok, 200, _, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/reports?status=reviewed&sort=created_at&order=asc">>, Token, <<"">>),
Reports = jsx:decode(list_to_binary(Body), [return_maps]),
[?assertEqual(<<"reviewed">>, maps:get(<<"status">>, R)) || R <- Reports],
Dates = [maps:get(<<"created_at">>, R) || R <- Reports],
?assertEqual(lists:sort(Dates), Dates),
ct:pal(" OK: ~p reports sorted", [length(Reports)]).
-spec test_filter_target_sort_status(binary()) -> ok.
test_filter_target_sort_status(Token) ->
ct:pal(" TEST: Filter target_type=event + sort by status desc"),
{ok, 200, _, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/reports?target_type=event&sort=status&order=desc">>, Token, <<"">>),
Reports = jsx:decode(list_to_binary(Body), [return_maps]),
[?assertEqual(<<"event">>, maps:get(<<"target_type">>, R)) || R <- Reports],
Statuses = [maps:get(<<"status">>, R) || R <- Reports],
?assertEqual(lists:reverse(lists:sort(Statuses)), Statuses),
ct:pal(" OK: ~p reports sorted", [length(Reports)]).
-spec test_report_pagination(binary()) -> ok.
test_report_pagination(Token) ->
ct:pal(" TEST: Report pagination"),
@@ -132,3 +201,39 @@ test_report_pagination(Token) ->
_ -> ok
end,
ct:pal(" OK").
-spec test_pagination_headers(binary()) -> ok.
test_pagination_headers(Token) ->
ct:pal(" TEST: Pagination headers (x-total-count)"),
{ok, 200, Headers, _} = api_test_runner:admin_request(get,
<<"/v1/admin/reports?limit=2&offset=0">>, Token, <<"">>),
Total = list_to_integer(
proplists:get_value("x-total-count", Headers, "0")),
?assert(Total >= 1),
ct:pal(" OK: total count = ~p", [Total]).
-spec test_filter_status_with_pagination(binary()) -> ok.
test_filter_status_with_pagination(Token) ->
ct:pal(" TEST: Filter status=dismissed + pagination (limit=1)"),
{ok, 200, Headers, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/reports?status=dismissed&limit=1&offset=0">>, Token, <<"">>),
Reports = jsx:decode(list_to_binary(Body), [return_maps]),
?assert(length(Reports) =< 1),
[?assertEqual(<<"dismissed">>, maps:get(<<"status">>, R)) || R <- Reports],
Total = list_to_integer(
proplists:get_value("x-total-count", Headers, "0")),
?assert(Total >= 1),
ct:pal(" OK: total count = ~p", [Total]).
-spec test_filter_sort_pagination_combined(binary()) -> ok.
test_filter_sort_pagination_combined(Token) ->
ct:pal(" TEST: Filter target_type=event + sort by created_at desc + pagination (limit=1)"),
{ok, 200, Headers, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/reports?target_type=event&sort=created_at&order=desc&limit=1&offset=0">>, Token, <<"">>),
Reports = jsx:decode(list_to_binary(Body), [return_maps]),
?assert(length(Reports) =< 1),
[?assertEqual(<<"event">>, maps:get(<<"target_type">>, R)) || R <- Reports],
Total = list_to_integer(
proplists:get_value("x-total-count", Headers, "0")),
?assert(Total >= 1),
ct:pal(" OK: total count = ~p", [Total]).
+139 -21
View File
@@ -1,26 +1,33 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты административного API для управления отзывами.
%%%
%%% Покрывает эндпоинты:
%%% GET /v1/admin/reviews
%%% PATCH /v1/admin/reviews
%%% GET /v1/admin/reviews/:id
%%% PUT /v1/admin/reviews/:id
%%%
%%% Проверяет:
%%% - получение списка отзывов
%%% - фильтрацию по цели, статусу, пользователю
%%% - комбинации фильтров
%%% - сортировку по created_at, rating
%%% - комбинации фильтрации с сортировкой
%%% - пагинацию
%%% - комбинации фильтрации, сортировки и пагинации
%%% @end
%%%-------------------------------------------------------------------
-module(admin_reviews_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% EUnit test generator
%%% Главная тестовая функция
%%%===================================================================
test() ->
ct:pal("=== Admin Reviews Tests ==="),
Token = api_test_runner:get_admin_token(),
UserToken = api_test_runner:get_user_token(),
%% Создаём тестовые данные: календарь, событие
CalId = api_test_runner:create_calendar(UserToken, #{title => <<"ReviewsTestCal">>}),
EventId = api_test_runner:create_event(UserToken, CalId, #{
@@ -28,46 +35,54 @@ test() ->
start_time => api_test_runner:future_date(),
duration => 60
}),
%% Создаём двух пользователей для отзывов
User1Email = api_test_runner:unique_email(<<"rev1">>),
User1Token = api_test_runner:register_and_login(User1Email, <<"pass1">>),
User2Email = api_test_runner:unique_email(<<"rev2">>),
User2Token = api_test_runner:register_and_login(User2Email, <<"pass2">>),
%% Записываем пользователей на событие и подтверждаем бронирования
#{<<"id">> := Booking1Id} = api_test_runner:client_post(
<<"/v1/events/", EventId/binary, "/bookings">>, User1Token, #{}),
#{<<"id">> := Booking2Id} = api_test_runner:client_post(
<<"/v1/events/", EventId/binary, "/bookings">>, User2Token, #{}),
ct:pal(" Confirming bookings as calendar owner..."),
api_test_runner:client_put(<<"/v1/bookings/", Booking1Id/binary>>, UserToken,
#{action => <<"confirm">>}),
api_test_runner:client_put(<<"/v1/bookings/", Booking2Id/binary>>, UserToken,
#{action => <<"confirm">>}),
%% Оставляем отзывы от имени участников
#{<<"id">> := Review1Id} = api_test_runner:client_post(<<"/v1/reviews">>, User1Token, #{
target_type => <<"event">>,
target_id => EventId,
rating => 5,
comment => <<"Great!">>
}),
#{<<"id">> := Review2Id} = api_test_runner:client_post(<<"/v1/reviews">>, User2Token, #{
target_type => <<"event">>,
target_id => EventId,
rating => 4,
comment => <<"Good">>
}),
#{<<"id">> := Review1Id} = api_test_runner:client_post(<<"/v1/reviews">>, User1Token,
#{ target_type => <<"event">>, target_id => EventId, rating => 5,
comment => <<"Great!">> }),
#{<<"id">> := Review2Id} = api_test_runner:client_post(<<"/v1/reviews">>, User2Token,
#{ target_type => <<"event">>, target_id => EventId, rating => 4,
comment => <<"Good">> }),
%% Получаем ID первого пользователя для теста фильтрации по user_id
#{<<"id">> := User1Id} = api_test_runner:client_get(<<"/v1/user/me">>, User1Token),
%% Запускаем тесты
test_list_reviews(Token),
test_filter_reviews(Token, EventId),
test_filter_by_user(Token, User1Id),
% Сначала меняем статусы, чтобы потом проверить фильтрацию по статусам
test_bulk_update_status(Token, Review1Id, Review2Id),
test_filter_by_status(Token),
test_filter_combined_status_target(Token, EventId),
test_get_review(Token, Review1Id),
test_update_review(Token, Review1Id),
test_sort_by_created_at_desc(Token),
test_sort_by_created_at_asc(Token),
test_sort_by_rating_asc(Token),
test_filter_status_sort_created_at(Token),
test_filter_sort_pagination_combined(Token, EventId),
test_review_pagination(Token),
test_pagination_headers(Token),
ct:pal("=== All admin reviews tests passed ==="),
ok.
@@ -94,6 +109,15 @@ test_filter_reviews(Token, EventId) ->
[?assertEqual(EventId, maps:get(<<"target_id">>, R)) || R <- Reviews],
ct:pal(" OK: ~p reviews for event", [length(Reviews)]).
%% @doc GET /v1/admin/reviews?user_id=... фильтрация по пользователю.
test_filter_by_user(Token, UserId) ->
ct:pal(" TEST: Filter reviews by user_id"),
Path = <<"/v1/admin/reviews?user_id=", UserId/binary>>,
Reviews = api_test_runner:admin_get(Path, Token),
?assert(is_list(Reviews)),
[?assertEqual(UserId, maps:get(<<"user_id">>, R)) || R <- Reviews],
ct:pal(" OK: ~p reviews by user", [length(Reviews)]).
%% @doc PATCH /v1/admin/reviews – массовое обновление статусов.
test_bulk_update_status(Token, Review1Id, Review2Id) ->
ct:pal(" TEST: Bulk update review statuses"),
@@ -101,10 +125,29 @@ test_bulk_update_status(Token, Review1Id, Review2Id) ->
#{<<"id">> => Review1Id, <<"status">> => <<"visible">>},
#{<<"id">> => Review2Id, <<"status">> => <<"hidden">>}
],
#{<<"updated_count">> := Count} = api_test_runner:admin_patch(<<"/v1/admin/reviews">>, Token, Body),
#{<<"updated_count">> := Count} = api_test_runner:admin_patch(
<<"/v1/admin/reviews">>, Token, Body),
?assertEqual(2, Count),
ct:pal(" OK: updated ~p reviews", [Count]).
%% @doc GET /v1/admin/reviews?status=visible фильтрация по статусу.
test_filter_by_status(Token) ->
ct:pal(" TEST: Filter reviews by status=visible"),
Reviews = api_test_runner:admin_get(<<"/v1/admin/reviews?status=visible">>, Token),
?assert(is_list(Reviews)),
[?assertEqual(<<"visible">>, maps:get(<<"status">>, R)) || R <- Reviews],
ct:pal(" OK: ~p visible reviews", [length(Reviews)]).
%% @doc GET /v1/admin/reviews?status=hidden&target_type=event комбинация фильтров.
test_filter_combined_status_target(Token, EventId) ->
ct:pal(" TEST: Combined filter (status=hidden & target_type=event)"),
Path = <<"/v1/admin/reviews?status=hidden&target_type=event&target_id=", EventId/binary>>,
Reviews = api_test_runner:admin_get(Path, Token),
?assert(is_list(Reviews)),
[?assertEqual(<<"hidden">>, maps:get(<<"status">>, R)) || R <- Reviews],
[?assertEqual(EventId, maps:get(<<"target_id">>, R)) || R <- Reviews],
ct:pal(" OK: ~p reviews match both criteria", [length(Reviews)]).
%% @doc GET /v1/admin/reviews/:id получение отзыва по ID.
test_get_review(Token, ReviewId) ->
ct:pal(" TEST: Get review by ID"),
@@ -123,6 +166,71 @@ test_update_review(Token, ReviewId) ->
?assertEqual(<<"Updated by admin">>, maps:get(<<"comment">>, Updated)),
ct:pal(" OK").
%% ── Сортировка ──
%% @doc Сортировка по created_at по убыванию.
test_sort_by_created_at_desc(Token) ->
ct:pal(" TEST: Sort by created_at desc"),
{ok, 200, _, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/reviews?sort=created_at&order=desc">>, Token, <<"">>),
Reviews = jsx:decode(list_to_binary(Body), [return_maps]),
Dates = [maps:get(<<"created_at">>, R) || R <- Reviews],
Sorted = lists:reverse(lists:sort(Dates)),
?assertEqual(Sorted, Dates),
ct:pal(" OK: sorted by created_at desc").
%% @doc Сортировка по created_at по возрастанию.
test_sort_by_created_at_asc(Token) ->
ct:pal(" TEST: Sort by created_at asc"),
{ok, 200, _, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/reviews?sort=created_at&order=asc">>, Token, <<"">>),
Reviews = jsx:decode(list_to_binary(Body), [return_maps]),
Dates = [maps:get(<<"created_at">>, R) || R <- Reviews],
?assertEqual(lists:sort(Dates), Dates),
ct:pal(" OK: sorted by created_at asc").
%% @doc Сортировка по rating по возрастанию.
test_sort_by_rating_asc(Token) ->
ct:pal(" TEST: Sort by rating asc"),
{ok, 200, _, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/reviews?sort=rating&order=asc">>, Token, <<"">>),
Reviews = jsx:decode(list_to_binary(Body), [return_maps]),
Ratings = [maps:get(<<"rating">>, R) || R <- Reviews],
?assertEqual(lists:sort(Ratings), Ratings),
ct:pal(" OK: sorted by rating asc").
%% ── Фильтр + сортировка ──
%% @doc Фильтр status=visible + сортировка по created_at desc.
test_filter_status_sort_created_at(Token) ->
ct:pal(" TEST: Filter status=visible + sort by created_at desc"),
{ok, 200, _, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/reviews?status=visible&sort=created_at&order=desc">>, Token, <<"">>),
Reviews = jsx:decode(list_to_binary(Body), [return_maps]),
[?assertEqual(<<"visible">>, maps:get(<<"status">>, R)) || R <- Reviews],
Dates = [maps:get(<<"created_at">>, R) || R <- Reviews],
Sorted = lists:reverse(lists:sort(Dates)),
?assertEqual(Sorted, Dates),
ct:pal(" OK: ~p reviews sorted", [length(Reviews)]).
%% ── Фильтр + сортировка + пагинация ──
%% @doc Фильтр target_type=event + сортировка по rating desc + пагинация limit=1.
test_filter_sort_pagination_combined(Token, EventId) ->
ct:pal(" TEST: Filter + sort + pagination (target_type=event, sort=rating desc, limit=1)"),
Path = <<"/v1/admin/reviews?target_type=event&target_id=", EventId/binary,
"&sort=rating&order=desc&limit=1&offset=0">>,
{ok, 200, Headers, Body} = api_test_runner:admin_request(get, Path, Token, <<"">>),
Reviews = jsx:decode(list_to_binary(Body), [return_maps]),
?assert(length(Reviews) =< 1),
[?assertEqual(EventId, maps:get(<<"target_id">>, R)) || R <- Reviews],
Total = list_to_integer(
proplists:get_value("x-total-count", Headers, "0")),
?assert(Total >= 1),
ct:pal(" OK: total count = ~p", [Total]).
%% ── Пагинация ──
%% @doc GET /v1/admin/reviews?limit=...&offset=... пагинация.
test_review_pagination(Token) ->
ct:pal(" TEST: Review pagination"),
@@ -134,3 +242,13 @@ test_review_pagination(Token) ->
Id2 = maps:get(<<"id">>, hd(Page2)),
?assertNotEqual(Id1, Id2),
ct:pal(" OK").
%% @doc Проверка заголовков пагинации (x-total-count).
test_pagination_headers(Token) ->
ct:pal(" TEST: Pagination headers (x-total-count)"),
{ok, 200, Headers, _} = api_test_runner:admin_request(get,
<<"/v1/admin/reviews?limit=2&offset=0">>, Token, <<"">>),
Total = list_to_integer(
proplists:get_value("x-total-count", Headers, "0")),
?assert(Total >= 1),
ct:pal(" OK: total count = ~p", [Total]).
+166 -7
View File
@@ -12,19 +12,20 @@
%%% - получение подписки по ID
%%% - обновление подписки (изменение плана, статуса)
%%% - удаление подписки
%%% - фильтрацию по статусу и плану
%%% - пагинацию
%%% - **фильтрацию** по статусу и плану
%%% - **комбинации фильтров** (status + plan)
%%% - **пагинацию** (limit/offset, xtotalcount)
%%% - **сортировку** по created_at и expires_at
%%% - **комбинации сортировки с фильтрацией и пагинацией**
%%% @end
%%%-------------------------------------------------------------------
-module(admin_subscriptions_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== Admin Subscriptions Tests ==="),
@@ -34,20 +35,43 @@ test() ->
% Создаём две подписки для разных проверок
#{<<"id">> := Sub1Id} = api_test_runner:client_post(<<"/v1/subscription">>, UserToken,
#{<<"action">> => <<"start_trial">>}),
% Для второй подписки создаём нового пользователя
User2Email = api_test_runner:unique_email(<<"sub2">>),
User2Token = api_test_runner:register_and_login(User2Email, <<"pass2">>),
#{<<"id">> := Sub2Id} = api_test_runner:client_post(<<"/v1/subscription">>, User2Token,
#{<<"action">> => <<"start_trial">>}),
% Теперь две подписки в системе
% Основные проверки
test_list_subscriptions(Token, Sub1Id),
test_get_subscription(Token, Sub1Id),
test_update_subscription(Token, Sub1Id),
% После обновления Sub1Id имеет план biannual и статус active
% Фильтрация
test_filter_subscriptions(Token),
% Комбинации фильтров
test_filter_combined_status_plan(Token),
test_filter_combined_plan_status(Token),
% Сортировка
test_sort_by_created_at_desc(Token),
test_sort_by_created_at_asc(Token),
test_sort_by_expires_at_asc(Token),
% Фильтр + сортировка
test_filter_status_sort_created_at(Token),
test_filter_plan_sort_expires_at(Token),
% Пагинация
test_subscription_pagination(Token),
test_pagination_headers(Token),
% Фильтр + пагинация
test_filter_status_with_pagination(Token),
% Фильтр + сортировка + пагинация
test_filter_sort_pagination_combined(Token),
% Удаляем вторую подписку, чтобы проверить delete
test_delete_subscription(Token, Sub2Id),
@@ -100,6 +124,8 @@ test_delete_subscription(Token, SubId) ->
?assertEqual(<<"deleted">>, maps:get(<<"status">>, Deleted)),
ct:pal(" OK").
%% ── Фильтрация ──
%% @doc GET /v1/admin/subscriptions?status=...&plan=... фильтрация.
-spec test_filter_subscriptions(binary()) -> ok.
test_filter_subscriptions(Token) ->
@@ -117,6 +143,95 @@ test_filter_subscriptions(Token) ->
[?assertEqual(<<"biannual">>, maps:get(<<"plan">>, S)) || S <- Subs2],
ct:pal(" OK: ~p biannual subscriptions", [length(Subs2)]).
%% ── Комбинации фильтров ──
%% @doc Комбинация фильтров: status=active&plan=biannual
-spec test_filter_combined_status_plan(binary()) -> ok.
test_filter_combined_status_plan(Token) ->
ct:pal(" TEST: Combined filter (status=active & plan=biannual)"),
Subs = api_test_runner:admin_get(
<<"/v1/admin/subscriptions?status=active&plan=biannual">>, Token),
?assert(is_list(Subs)),
[?assertEqual(<<"active">>, maps:get(<<"status">>, S)) || S <- Subs],
[?assertEqual(<<"biannual">>, maps:get(<<"plan">>, S)) || S <- Subs],
ct:pal(" OK: ~p subscriptions match both criteria", [length(Subs)]).
%% @doc Комбинация фильтров: plan=biannual&status=active (обратный порядок)
-spec test_filter_combined_plan_status(binary()) -> ok.
test_filter_combined_plan_status(Token) ->
ct:pal(" TEST: Combined filter (plan=biannual & status=active)"),
Subs = api_test_runner:admin_get(
<<"/v1/admin/subscriptions?plan=biannual&status=active">>, Token),
?assert(is_list(Subs)),
[?assertEqual(<<"active">>, maps:get(<<"status">>, S)) || S <- Subs],
[?assertEqual(<<"biannual">>, maps:get(<<"plan">>, S)) || S <- Subs],
ct:pal(" OK: ~p subscriptions match both criteria", [length(Subs)]).
%% ── Сортировка ──
%% @doc Сортировка по created_at по убыванию.
-spec test_sort_by_created_at_desc(binary()) -> ok.
test_sort_by_created_at_desc(Token) ->
ct:pal(" TEST: Sort subscriptions by created_at desc"),
{ok, 200, _, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/subscriptions?sort=created_at&order=desc">>, Token, <<"">>),
Subs = jsx:decode(list_to_binary(Body), [return_maps]),
Dates = [maps:get(<<"created_at">>, S) || S <- Subs],
Sorted = lists:reverse(lists:sort(Dates)),
?assertEqual(Sorted, Dates),
ct:pal(" OK: sorted by created_at desc").
%% @doc Сортировка по created_at по возрастанию.
-spec test_sort_by_created_at_asc(binary()) -> ok.
test_sort_by_created_at_asc(Token) ->
ct:pal(" TEST: Sort subscriptions by created_at asc"),
{ok, 200, _, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/subscriptions?sort=created_at&order=asc">>, Token, <<"">>),
Subs = jsx:decode(list_to_binary(Body), [return_maps]),
Dates = [maps:get(<<"created_at">>, S) || S <- Subs],
?assertEqual(lists:sort(Dates), Dates),
ct:pal(" OK: sorted by created_at asc").
%% @doc Сортировка по expires_at по возрастанию.
-spec test_sort_by_expires_at_asc(binary()) -> ok.
test_sort_by_expires_at_asc(Token) ->
ct:pal(" TEST: Sort subscriptions by expires_at asc"),
{ok, 200, _, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/subscriptions?sort=expires_at&order=asc">>, Token, <<"">>),
Subs = jsx:decode(list_to_binary(Body), [return_maps]),
Dates = [maps:get(<<"expires_at">>, S) || S <- Subs],
?assertEqual(lists:sort(Dates), Dates),
ct:pal(" OK: sorted by expires_at asc").
%% ── Фильтр + сортировка ──
%% @doc Фильтр status=active + сортировка по created_at desc.
-spec test_filter_status_sort_created_at(binary()) -> ok.
test_filter_status_sort_created_at(Token) ->
ct:pal(" TEST: Filter status=active + sort by created_at desc"),
{ok, 200, _, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/subscriptions?status=active&sort=created_at&order=desc">>, Token, <<"">>),
Subs = jsx:decode(list_to_binary(Body), [return_maps]),
[?assertEqual(<<"active">>, maps:get(<<"status">>, S)) || S <- Subs],
Dates = [maps:get(<<"created_at">>, S) || S <- Subs],
Sorted = lists:reverse(lists:sort(Dates)),
?assertEqual(Sorted, Dates),
ct:pal(" OK: ~p subscriptions sorted", [length(Subs)]).
%% @doc Фильтр plan=biannual + сортировка по expires_at asc.
-spec test_filter_plan_sort_expires_at(binary()) -> ok.
test_filter_plan_sort_expires_at(Token) ->
ct:pal(" TEST: Filter plan=biannual + sort by expires_at asc"),
{ok, 200, _, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/subscriptions?plan=biannual&sort=expires_at&order=asc">>, Token, <<"">>),
Subs = jsx:decode(list_to_binary(Body), [return_maps]),
[?assertEqual(<<"biannual">>, maps:get(<<"plan">>, S)) || S <- Subs],
Dates = [maps:get(<<"expires_at">>, S) || S <- Subs],
?assertEqual(lists:sort(Dates), Dates),
ct:pal(" OK: ~p subscriptions sorted", [length(Subs)]).
%% ── Пагинация ──
%% @doc GET /v1/admin/subscriptions?limit=...&offset=... пагинация.
-spec test_subscription_pagination(binary()) -> ok.
test_subscription_pagination(Token) ->
@@ -129,3 +244,47 @@ test_subscription_pagination(Token) ->
Id2 = maps:get(<<"id">>, hd(Page2)),
?assertNotEqual(Id1, Id2),
ct:pal(" OK").
%% @doc Проверка заголовков пагинации (x-total-count).
-spec test_pagination_headers(binary()) -> ok.
test_pagination_headers(Token) ->
ct:pal(" TEST: Pagination headers (x-total-count)"),
{ok, 200, Headers, _} = api_test_runner:admin_request(get,
<<"/v1/admin/subscriptions?limit=2&offset=0">>, Token, <<"">>),
Total = list_to_integer(
proplists:get_value("x-total-count", Headers, "0")),
?assert(Total >= 1),
ct:pal(" OK: total count = ~p", [Total]).
%% ── Фильтр + пагинация ──
%% @doc Фильтр status=active + пагинация limit=1.
-spec test_filter_status_with_pagination(binary()) -> ok.
test_filter_status_with_pagination(Token) ->
ct:pal(" TEST: Filter status=active + pagination (limit=1)"),
{ok, 200, Headers, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/subscriptions?status=active&limit=1&offset=0">>, Token, <<"">>),
Subs = jsx:decode(list_to_binary(Body), [return_maps]),
?assert(length(Subs) =< 1),
[?assertEqual(<<"active">>, maps:get(<<"status">>, S)) || S <- Subs],
Total = list_to_integer(
proplists:get_value("x-total-count", Headers, "0")),
?assert(Total >= 1),
ct:pal(" OK: total count = ~p", [Total]).
%% ── Фильтр + сортировка + пагинация ──
%% @doc Фильтр status=active + сортировка по created_at desc + пагинация limit=1.
-spec test_filter_sort_pagination_combined(binary()) -> ok.
test_filter_sort_pagination_combined(Token) ->
ct:pal(" TEST: Filter + sort + pagination (status=active, sort=created_at desc, limit=1)"),
{ok, 200, Headers, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/subscriptions?status=active&sort=created_at&order=desc&limit=1&offset=0">>,
Token, <<"">>),
Subs = jsx:decode(list_to_binary(Body), [return_maps]),
?assert(length(Subs) =< 1),
[?assertEqual(<<"active">>, maps:get(<<"status">>, S)) || S <- Subs],
Total = list_to_integer(
proplists:get_value("x-total-count", Headers, "0")),
?assert(Total >= 1),
ct:pal(" OK: total count = ~p", [Total]).
+195 -25
View File
@@ -13,41 +13,74 @@
%%% - разрешение (resolve) и закрытие (close) тикета
%%% - назначение исполнителя
%%% - удаление тикета
%%% - фильтрацию по статусу
%%% - фильтрацию по статусу, исполнителю, поиску
%%% - комбинации фильтров
%%% - пагинацию
%%% - сортировку по first_seen, last_seen, status
%%% - комбинации фильтрации, сортировки и пагинации
%%% @end
%%%-------------------------------------------------------------------
-module(admin_tickets_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== Admin Tickets Tests ==="),
Token = api_test_runner:get_admin_token(),
UserToken = api_test_runner:get_user_token(),
% Создаём два тикета для разных проверок
Ticket1 = api_test_runner:client_post(<<"/v1/tickets">>, UserToken,
#{<<"error_message">> => <<"Test bug">>, <<"stacktrace">> => <<"trace">>}),
#{<<"id">> := Ticket1Id} = Ticket1,
Ticket2 = api_test_runner:client_post(<<"/v1/tickets">>, UserToken,
#{<<"error_message">> => <<"Another bug">>, <<"stacktrace">> => <<"trace2">>}),
#{<<"id">> := Ticket2Id} = Ticket2,
% Получаем ID текущего администратора для теста фильтрации по исполнителю
Me = api_test_runner:admin_get(<<"/v1/admin/me">>, Token),
AdminId = maps:get(<<"id">>, Me),
% Основные проверки
test_list_tickets(Token),
test_get_ticket(Token, Ticket1Id),
test_resolve_ticket(Token, Ticket1Id),
test_close_ticket(Token, Ticket1Id),
test_assign_ticket(Token, Ticket1Id),
test_filter_tickets(Token),
test_assign_ticket(Token, Ticket1Id, AdminId),
% Фильтрация
test_filter_tickets_by_status(Token, Ticket2Id),
test_filter_tickets_by_assigned(Token, AdminId),
test_filter_tickets_by_search(Token),
% Комбинации фильтров
test_filter_combined_status_assigned(Token, AdminId),
test_filter_combined_status_search(Token, Ticket2Id),
% Сортировка
test_sort_by_first_seen_desc(Token),
test_sort_by_first_seen_asc(Token),
test_sort_by_last_seen_desc(Token),
test_sort_by_status_asc(Token),
% Фильтр + сортировка
test_filter_status_sort_first_seen(Token, Ticket2Id),
test_filter_assigned_sort_last_seen(Token, AdminId),
% Пагинация
test_ticket_pagination(Token, Ticket2Id),
test_pagination_headers(Token),
% Фильтр + пагинация
test_filter_status_with_pagination(Token, Ticket2Id),
% Фильтр + сортировка + пагинация
test_filter_sort_pagination_combined(Token, Ticket2Id),
% Удаление
test_delete_ticket(Token, Ticket1Id),
test_delete_ticket(Token, Ticket2Id),
@@ -59,8 +92,6 @@ test() ->
%%%===================================================================
%% @doc GET /v1/admin/tickets – проверяет получение списка тикетов.
%% Убеждается, что список не пуст.
-spec test_list_tickets(binary()) -> ok.
test_list_tickets(Token) ->
ct:pal(" TEST: List all tickets"),
Tickets = api_test_runner:admin_get(<<"/v1/admin/tickets">>, Token),
@@ -69,8 +100,6 @@ test_list_tickets(Token) ->
ct:pal(" OK: ~p tickets", [length(Tickets)]).
%% @doc GET /v1/admin/tickets/:id проверяет получение тикета по ID.
%% Убеждается, что статус нового тикета – open.
-spec test_get_ticket(binary(), binary()) -> ok.
test_get_ticket(Token, TicketId) ->
ct:pal(" TEST: Get ticket by ID"),
Path = <<"/v1/admin/tickets/", TicketId/binary>>,
@@ -80,8 +109,6 @@ test_get_ticket(Token, TicketId) ->
ct:pal(" OK").
%% @doc PUT /v1/admin/tickets/:id разрешение тикета (resolve).
%% Ожидается статус resolved после успешного выполнения.
-spec test_resolve_ticket(binary(), binary()) -> ok.
test_resolve_ticket(Token, TicketId) ->
ct:pal(" TEST: Resolve ticket"),
Path = <<"/v1/admin/tickets/", TicketId/binary>>,
@@ -94,7 +121,6 @@ test_resolve_ticket(Token, TicketId) ->
ct:pal(" OK").
%% @doc PUT /v1/admin/tickets/:id закрытие тикета (close).
-spec test_close_ticket(binary(), binary()) -> ok.
test_close_ticket(Token, TicketId) ->
ct:pal(" TEST: Close ticket"),
Path = <<"/v1/admin/tickets/", TicketId/binary>>,
@@ -103,18 +129,14 @@ test_close_ticket(Token, TicketId) ->
ct:pal(" OK").
%% @doc PUT /v1/admin/tickets/:id назначение исполнителя.
-spec test_assign_ticket(binary(), binary()) -> ok.
test_assign_ticket(Token, TicketId) ->
test_assign_ticket(Token, TicketId, AdminId) ->
ct:pal(" TEST: Assign ticket"),
Me = api_test_runner:admin_get(<<"/v1/admin/me">>, Token),
AdminId = maps:get(<<"id">>, Me),
Path = <<"/v1/admin/tickets/", TicketId/binary>>,
Updated = api_test_runner:admin_put(Path, Token, #{<<"assigned_to">> => AdminId}),
?assertEqual(AdminId, maps:get(<<"assigned_to">>, Updated)),
ct:pal(" OK").
%% @doc DELETE /v1/admin/tickets/:id удаление тикета.
-spec test_delete_ticket(binary(), binary()) -> ok.
test_delete_ticket(Token, TicketId) ->
ct:pal(" TEST: Delete ticket"),
Path = <<"/v1/admin/tickets/", TicketId/binary>>,
@@ -122,10 +144,10 @@ test_delete_ticket(Token, TicketId) ->
?assertEqual(<<"deleted">>, maps:get(<<"status">>, Deleted)),
ct:pal(" OK").
%% @doc GET /v1/admin/tickets?status=... проверяет фильтрацию по статусу open.
%% Использует второй тикет, который всё ещё open.
-spec test_filter_tickets(binary()) -> ok.
test_filter_tickets(Token) ->
%% ── Фильтрация ──
%% @doc Фильтрация по статусу open.
test_filter_tickets_by_status(Token, _TicketId) ->
ct:pal(" TEST: Filter tickets by status=open"),
Tickets = api_test_runner:admin_get(<<"/v1/admin/tickets?status=open">>, Token),
?assert(is_list(Tickets)),
@@ -133,9 +155,117 @@ test_filter_tickets(Token) ->
[?assertEqual(<<"open">>, maps:get(<<"status">>, T)) || T <- Tickets],
ct:pal(" OK: ~p open tickets", [length(Tickets)]).
%% @doc Фильтрация по исполнителю.
test_filter_tickets_by_assigned(Token, AdminId) ->
ct:pal(" TEST: Filter tickets by assigned_to"),
Path = <<"/v1/admin/tickets?assigned_to=", AdminId/binary>>,
Tickets = api_test_runner:admin_get(Path, Token),
?assert(is_list(Tickets)),
[?assertEqual(AdminId, maps:get(<<"assigned_to">>, T)) || T <- Tickets],
ct:pal(" OK: ~p tickets assigned to admin", [length(Tickets)]).
%% @doc Поиск по тексту ошибки.
test_filter_tickets_by_search(Token) ->
ct:pal(" TEST: Search tickets by q=bug"),
Tickets = api_test_runner:admin_get(<<"/v1/admin/tickets?q=bug">>, Token),
?assert(is_list(Tickets)),
?assert(length(Tickets) >= 1),
[?assert(string:str(binary_to_list(maps:get(<<"error_message">>, T)), "bug") > 0) || T <- Tickets],
ct:pal(" OK: ~p tickets match search", [length(Tickets)]).
%% ── Комбинации фильтров ──
%% @doc Комбинация фильтров: status=open + assigned_to.
test_filter_combined_status_assigned(Token, AdminId) ->
ct:pal(" TEST: Combined filter (status=open & assigned_to)"),
Path = <<"/v1/admin/tickets?status=open&assigned_to=", AdminId/binary>>,
Tickets = api_test_runner:admin_get(Path, Token),
?assert(is_list(Tickets)),
[?assertEqual(<<"open">>, maps:get(<<"status">>, T)) || T <- Tickets],
[?assertEqual(AdminId, maps:get(<<"assigned_to">>, T)) || T <- Tickets],
ct:pal(" OK: ~p tickets match both criteria", [length(Tickets)]).
%% @doc Комбинация фильтров: status=open + поиск.
test_filter_combined_status_search(Token, _TicketId) ->
ct:pal(" TEST: Combined filter (status=open & q=Another)"),
Tickets = api_test_runner:admin_get(<<"/v1/admin/tickets?status=open&q=Another">>, Token),
?assert(is_list(Tickets)),
[?assertEqual(<<"open">>, maps:get(<<"status">>, T)) || T <- Tickets],
[?assert(string:str(binary_to_list(maps:get(<<"error_message">>, T)), "Another") > 0) || T <- Tickets],
ct:pal(" OK: ~p tickets match both criteria", [length(Tickets)]).
%% ── Сортировка ──
%% @doc Сортировка по first_seen по убыванию.
test_sort_by_first_seen_desc(Token) ->
ct:pal(" TEST: Sort tickets by first_seen desc"),
{ok, 200, _, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/tickets?sort=first_seen&order=desc">>, Token, <<"">>),
Tickets = jsx:decode(list_to_binary(Body), [return_maps]),
Dates = [maps:get(<<"first_seen">>, T) || T <- Tickets],
Sorted = lists:reverse(lists:sort(Dates)),
?assertEqual(Sorted, Dates),
ct:pal(" OK: sorted by first_seen desc").
%% @doc Сортировка по first_seen по возрастанию.
test_sort_by_first_seen_asc(Token) ->
ct:pal(" TEST: Sort tickets by first_seen asc"),
{ok, 200, _, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/tickets?sort=first_seen&order=asc">>, Token, <<"">>),
Tickets = jsx:decode(list_to_binary(Body), [return_maps]),
Dates = [maps:get(<<"first_seen">>, T) || T <- Tickets],
?assertEqual(lists:sort(Dates), Dates),
ct:pal(" OK: sorted by first_seen asc").
%% @doc Сортировка по last_seen по убыванию.
test_sort_by_last_seen_desc(Token) ->
ct:pal(" TEST: Sort tickets by last_seen desc"),
{ok, 200, _, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/tickets?sort=last_seen&order=desc">>, Token, <<"">>),
Tickets = jsx:decode(list_to_binary(Body), [return_maps]),
Dates = [maps:get(<<"last_seen">>, T) || T <- Tickets],
Sorted = lists:reverse(lists:sort(Dates)),
?assertEqual(Sorted, Dates),
ct:pal(" OK: sorted by last_seen desc").
%% @doc Сортировка по status по возрастанию.
test_sort_by_status_asc(Token) ->
ct:pal(" TEST: Sort tickets by status asc"),
{ok, 200, _, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/tickets?sort=status&order=asc">>, Token, <<"">>),
Tickets = jsx:decode(list_to_binary(Body), [return_maps]),
Statuses = [maps:get(<<"status">>, T) || T <- Tickets],
?assertEqual(lists:sort(Statuses), Statuses),
ct:pal(" OK: sorted by status asc").
%% ── Фильтр + сортировка ──
%% @doc Фильтр status=open + сортировка по first_seen desc.
test_filter_status_sort_first_seen(Token, _TicketId) ->
ct:pal(" TEST: Filter status=open + sort by first_seen desc"),
{ok, 200, _, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/tickets?status=open&sort=first_seen&order=desc">>, Token, <<"">>),
Tickets = jsx:decode(list_to_binary(Body), [return_maps]),
[?assertEqual(<<"open">>, maps:get(<<"status">>, T)) || T <- Tickets],
Dates = [maps:get(<<"first_seen">>, T) || T <- Tickets],
Sorted = lists:reverse(lists:sort(Dates)),
?assertEqual(Sorted, Dates),
ct:pal(" OK: ~p tickets sorted", [length(Tickets)]).
%% @doc Фильтр assigned_to + сортировка по last_seen asc.
test_filter_assigned_sort_last_seen(Token, AdminId) ->
ct:pal(" TEST: Filter assigned_to + sort by last_seen asc"),
Path = <<"/v1/admin/tickets?assigned_to=", AdminId/binary, "&sort=last_seen&order=asc">>,
{ok, 200, _, Body} = api_test_runner:admin_request(get, Path, Token, <<"">>),
Tickets = jsx:decode(list_to_binary(Body), [return_maps]),
[?assertEqual(AdminId, maps:get(<<"assigned_to">>, T)) || T <- Tickets],
Dates = [maps:get(<<"last_seen">>, T) || T <- Tickets],
?assertEqual(lists:sort(Dates), Dates),
ct:pal(" OK: ~p tickets sorted", [length(Tickets)]).
%% ── Пагинация ──
%% @doc GET /v1/admin/tickets?limit=...&offset=... проверяет пагинацию.
%% Использует второй тикет как гарантированно существующий.
-spec test_ticket_pagination(binary(), binary()) -> ok.
test_ticket_pagination(Token, _TicketId) ->
ct:pal(" TEST: Ticket pagination"),
Page1 = api_test_runner:admin_get(<<"/v1/admin/tickets?limit=1&offset=0">>, Token),
@@ -150,3 +280,43 @@ test_ticket_pagination(Token, _TicketId) ->
_ -> ok
end,
ct:pal(" OK").
%% @doc Проверка заголовков пагинации (x-total-count).
test_pagination_headers(Token) ->
ct:pal(" TEST: Pagination headers (x-total-count)"),
{ok, 200, Headers, _} = api_test_runner:admin_request(get,
<<"/v1/admin/tickets?limit=2&offset=0">>, Token, <<"">>),
Total = list_to_integer(
proplists:get_value("x-total-count", Headers, "0")),
?assert(Total >= 1),
ct:pal(" OK: total count = ~p", [Total]).
%% ── Фильтр + пагинация ──
%% @doc Фильтр status=open + пагинация limit=1.
test_filter_status_with_pagination(Token, _TicketId) ->
ct:pal(" TEST: Filter status=open + pagination (limit=1)"),
{ok, 200, Headers, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/tickets?status=open&limit=1&offset=0">>, Token, <<"">>),
Tickets = jsx:decode(list_to_binary(Body), [return_maps]),
?assert(length(Tickets) =< 1),
[?assertEqual(<<"open">>, maps:get(<<"status">>, T)) || T <- Tickets],
Total = list_to_integer(
proplists:get_value("x-total-count", Headers, "0")),
?assert(Total >= 1),
ct:pal(" OK: total count = ~p", [Total]).
%% ── Фильтр + сортировка + пагинация ──
%% @doc Фильтр status=open + сортировка по first_seen desc + пагинация limit=1.
test_filter_sort_pagination_combined(Token, _TicketId) ->
ct:pal(" TEST: Filter + sort + pagination (status=open, sort=first_seen desc, limit=1)"),
{ok, 200, Headers, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/tickets?status=open&sort=first_seen&order=desc&limit=1&offset=0">>, Token, <<"">>),
Tickets = jsx:decode(list_to_binary(Body), [return_maps]),
?assert(length(Tickets) =< 1),
[?assertEqual(<<"open">>, maps:get(<<"status">>, T)) || T <- Tickets],
Total = list_to_integer(
proplists:get_value("x-total-count", Headers, "0")),
?assert(Total >= 1),
ct:pal(" OK: total count = ~p", [Total]).
+268 -24
View File
@@ -1,38 +1,116 @@
%%%-------------------------------------------------------------------
%%% @doc Тесты административного API для управления пользователями.
%%%
%%% Покрывает эндпоинты:
%%% GET /v1/admin/users
%%% GET /v1/admin/users/:id
%%% PUT /v1/admin/users/:id
%%% DELETE /v1/admin/users/:id
%%%
%%% Проверяет:
%%% - получение списка пользователей
%%% - получение пользователя по ID
%%% - обновление пользователя (роль, статус)
%%% - мягкое удаление пользователя
%%% - **фильтрацию** по роли, статусу и поиск (q)
%%% - **комбинации фильтров** (role + status)
%%% - **пагинацию** (limit/offset, xtotalcount)
%%% - **сортировку** по email, роли и дате создания
%%% - **комбинации сортировки с фильтрацией и пагинацией**
%%% @end
%%%-------------------------------------------------------------------
-module(admin_users_tests).
-include_lib("eunit/include/eunit.hrl").
-export([test/0]).
%%%===================================================================
%%% Главная тестовая функция
%%%===================================================================
-spec test() -> ok.
test() ->
ct:pal("=== Admin Users Tests ==="),
Token = api_test_runner:get_admin_token(),
% Создаём несколько тестовых пользователей с разными ролями
User1Email = api_test_runner:unique_email(<<"usertest1">>),
User1Token = api_test_runner:register_and_login(User1Email, <<"testpass">>),
Me1 = api_test_runner:client_get(<<"/v1/user/me">>, User1Token),
User1Id = maps:get(<<"id">>, Me1),
%% Создаём тестового пользователя
Email = api_test_runner:unique_email(<<"usertest">>),
UserToken = api_test_runner:register_and_login(Email, <<"testpass">>),
Me = api_test_runner:client_get(<<"/v1/user/me">>, UserToken),
UserId = maps:get(<<"id">>, Me),
User2Email = api_test_runner:unique_email(<<"usertest2">>),
User2Token = api_test_runner:register_and_login(User2Email, <<"testpass">>),
Me2 = api_test_runner:client_get(<<"/v1/user/me">>, User2Token),
User2Id = maps:get(<<"id">>, Me2),
User3Email = api_test_runner:unique_email(<<"usertest3">>),
User3Token = api_test_runner:register_and_login(User3Email, <<"testpass">>),
Me3 = api_test_runner:client_get(<<"/v1/user/me">>, User3Token),
User3Id = maps:get(<<"id">>, Me3),
% Обновляем пользователей, чтобы получить разные роли и статусы
% User1: роль bot, статус frozen (для тестов фильтрации)
Path1 = <<"/v1/admin/users/", User1Id/binary>>,
api_test_runner:admin_put(Path1, Token, #{
<<"role">> => <<"bot">>,
<<"status">> => <<"frozen">>,
<<"reason">> => <<"Test freeze">>
}),
% User2: роль user, статус active
Path2 = <<"/v1/admin/users/", User2Id/binary>>,
api_test_runner:admin_put(Path2, Token, #{
<<"role">> => <<"user">>,
<<"status">> => <<"active">>
}),
% User3: оставляем как есть (user / active) для тестов поиска
% ── Основные CRUD-тесты ──
test_list_users(Token),
test_get_user(Token, UserId),
test_update_user(Token, UserId),
test_filter_users(Token),
test_delete_user(Token, UserId),
test_get_user(Token, User1Id),
test_update_user(Token, User1Id),
test_delete_user(Token, User1Id),
% ── Фильтрация ──
test_filter_users_by_role(Token),
test_filter_users_by_status(Token),
test_filter_combined_role_status(Token),
test_filter_users_by_search(Token, User2Email),
% ── Сортировка ──
test_sort_by_email_asc(Token),
test_sort_by_email_desc(Token),
test_sort_by_role_desc(Token),
test_sort_by_created_at_asc(Token),
% ── Комбинации фильтрации и сортировки ──
test_filter_status_sort_email(Token),
test_filter_role_sort_created_at(Token),
% ── Пагинация ──
test_user_pagination(Token),
test_pagination_headers(Token),
% ── Комбинации с пагинацией ──
test_filter_status_with_pagination(Token),
test_filter_sort_pagination_combined(Token),
ct:pal("=== All admin users tests passed ==="),
ok.
%%%===================================================================
%%% Тестовые функции
%%%===================================================================
%% @doc GET /v1/admin/users – список пользователей.
-spec test_list_users(binary()) -> ok.
test_list_users(Token) ->
ct:pal(" TEST: List all users"),
Users = api_test_runner:admin_get(<<"/v1/admin/users">>, Token),
?assert(is_list(Users)),
?assert(length(Users) >= 1),
?assert(length(Users) >= 3),
ct:pal(" OK: ~p users", [length(Users)]).
%% @doc GET /v1/admin/users/:id получение пользователя по ID.
-spec test_get_user(binary(), binary()) -> ok.
test_get_user(Token, UserId) ->
ct:pal(" TEST: Get user by ID"),
Path = <<"/v1/admin/users/", UserId/binary>>,
@@ -40,28 +118,194 @@ test_get_user(Token, UserId) ->
?assertEqual(UserId, maps:get(<<"id">>, User)),
ct:pal(" OK: ~s", [maps:get(<<"email">>, User)]).
%% @doc PUT /v1/admin/users/:id обновление пользователя.
-spec test_update_user(binary(), binary()) -> ok.
test_update_user(Token, UserId) ->
ct:pal(" TEST: Update user (change role and status)"),
ct:pal(" TEST: Update user (change nickname)"),
Path = <<"/v1/admin/users/", UserId/binary>>,
Updated = api_test_runner:admin_put(Path, Token, #{
<<"role">> => <<"bot">>,
<<"status">> => <<"frozen">>,
<<"reason">> => <<"Test freeze">>
<<"nickname">> => <<"TestBot">>
}),
?assertEqual(<<"bot">>, maps:get(<<"role">>, Updated)),
?assertEqual(<<"frozen">>, maps:get(<<"status">>, Updated)),
?assertEqual(<<"TestBot">>, maps:get(<<"nickname">>, Updated)),
ct:pal(" OK").
test_filter_users(Token) ->
ct:pal(" TEST: Filter users by status=frozen"),
Users = api_test_runner:admin_get(<<"/v1/admin/users?status=frozen">>, Token),
?assert(is_list(Users)),
[?assertEqual(<<"frozen">>, maps:get(<<"status">>, U)) || U <- Users],
ct:pal(" OK: ~p frozen users", [length(Users)]).
%% @doc DELETE /v1/admin/users/:id мягкое удаление.
-spec test_delete_user(binary(), binary()) -> ok.
test_delete_user(Token, UserId) ->
ct:pal(" TEST: Delete (soft-delete) user"),
Path = <<"/v1/admin/users/", UserId/binary>>,
Deleted = api_test_runner:admin_delete(Path, Token),
?assertEqual(<<"deleted">>, maps:get(<<"status">>, Deleted)),
ct:pal(" OK").
%% ── Фильтрация ──
%% @doc Фильтрация по роли.
-spec test_filter_users_by_role(binary()) -> ok.
test_filter_users_by_role(Token) ->
ct:pal(" TEST: Filter users by role=bot"),
Users = api_test_runner:admin_get(<<"/v1/admin/users?role=bot">>, Token),
?assert(is_list(Users)),
[?assertEqual(<<"bot">>, maps:get(<<"role">>, U)) || U <- Users],
ct:pal(" OK: ~p bot users", [length(Users)]).
%% @doc Фильтрация по статусу.
-spec test_filter_users_by_status(binary()) -> ok.
test_filter_users_by_status(Token) ->
ct:pal(" TEST: Filter users by status=active"),
Users = api_test_runner:admin_get(<<"/v1/admin/users?status=active">>, Token),
?assert(is_list(Users)),
[?assertEqual(<<"active">>, maps:get(<<"status">>, U)) || U <- Users],
ct:pal(" OK: ~p active users", [length(Users)]).
%% @doc Комбинация фильтров: роль + статус.
-spec test_filter_combined_role_status(binary()) -> ok.
test_filter_combined_role_status(Token) ->
ct:pal(" TEST: Combined filter (role=user & status=active)"),
Users = api_test_runner:admin_get(
<<"/v1/admin/users?role=user&status=active">>, Token),
?assert(is_list(Users)),
[?assertEqual(<<"user">>, maps:get(<<"role">>, U)) || U <- Users],
[?assertEqual(<<"active">>, maps:get(<<"status">>, U)) || U <- Users],
ct:pal(" OK: ~p users match both criteria", [length(Users)]).
%% @doc Поиск по email.
-spec test_filter_users_by_search(binary(), binary()) -> ok.
test_filter_users_by_search(Token, Email) ->
ct:pal(" TEST: Search users by email prefix"),
Prefix = binary:part(Email, 0, 10),
Path = <<"/v1/admin/users?q=", Prefix/binary>>,
Users = api_test_runner:admin_get(Path, Token),
?assert(is_list(Users)),
?assert(length(Users) >= 1),
ct:pal(" OK: ~p users match search", [length(Users)]).
%% ── Сортировка ──
%% @doc Сортировка по email по возрастанию.
-spec test_sort_by_email_asc(binary()) -> ok.
test_sort_by_email_asc(Token) ->
ct:pal(" TEST: Sort users by email asc"),
{ok, 200, _, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/users?sort=email&order=asc">>, Token, <<"">>),
Users = jsx:decode(list_to_binary(Body), [return_maps]),
Emails = [maps:get(<<"email">>, U) || U <- Users],
?assertEqual(lists:sort(Emails), Emails),
ct:pal(" OK: sorted by email asc").
%% @doc Сортировка по email по убыванию.
-spec test_sort_by_email_desc(binary()) -> ok.
test_sort_by_email_desc(Token) ->
ct:pal(" TEST: Sort users by email desc"),
{ok, 200, _, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/users?sort=email&order=desc">>, Token, <<"">>),
Users = jsx:decode(list_to_binary(Body), [return_maps]),
Emails = [maps:get(<<"email">>, U) || U <- Users],
?assertEqual(lists:reverse(lists:sort(Emails)), Emails),
ct:pal(" OK: sorted by email desc").
%% @doc Сортировка по роли по убыванию.
-spec test_sort_by_role_desc(binary()) -> ok.
test_sort_by_role_desc(Token) ->
ct:pal(" TEST: Sort users by role desc"),
{ok, 200, _, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/users?sort=role&order=desc">>, Token, <<"">>),
Users = jsx:decode(list_to_binary(Body), [return_maps]),
Roles = [maps:get(<<"role">>, U) || U <- Users],
?assertEqual(lists:reverse(lists:sort(Roles)), Roles),
ct:pal(" OK: sorted by role desc").
%% @doc Сортировка по created_at по возрастанию.
-spec test_sort_by_created_at_asc(binary()) -> ok.
test_sort_by_created_at_asc(Token) ->
ct:pal(" TEST: Sort users by created_at asc"),
{ok, 200, _, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/users?sort=created_at&order=asc">>, Token, <<"">>),
Users = jsx:decode(list_to_binary(Body), [return_maps]),
Dates = [maps:get(<<"created_at">>, U) || U <- Users],
?assertEqual(lists:sort(Dates), Dates),
ct:pal(" OK: sorted by created_at asc").
%% ── Комбинации фильтрации и сортировки ──
%% @doc Фильтр status=active + сортировка по email asc.
-spec test_filter_status_sort_email(binary()) -> ok.
test_filter_status_sort_email(Token) ->
ct:pal(" TEST: Filter status=active + sort by email asc"),
{ok, 200, _, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/users?status=active&sort=email&order=asc">>, Token, <<"">>),
Users = jsx:decode(list_to_binary(Body), [return_maps]),
[?assertEqual(<<"active">>, maps:get(<<"status">>, U)) || U <- Users],
Emails = [maps:get(<<"email">>, U) || U <- Users],
?assertEqual(lists:sort(Emails), Emails),
ct:pal(" OK: ~p users sorted", [length(Users)]).
%% @doc Фильтр role=user + сортировка по created_at desc.
-spec test_filter_role_sort_created_at(binary()) -> ok.
test_filter_role_sort_created_at(Token) ->
ct:pal(" TEST: Filter role=user + sort by created_at desc"),
{ok, 200, _, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/users?role=user&sort=created_at&order=desc">>, Token, <<"">>),
Users = jsx:decode(list_to_binary(Body), [return_maps]),
[?assertEqual(<<"user">>, maps:get(<<"role">>, U)) || U <- Users],
Dates = [maps:get(<<"created_at">>, U) || U <- Users],
Sorted = lists:reverse(lists:sort(Dates)),
?assertEqual(Sorted, Dates),
ct:pal(" OK: ~p users sorted", [length(Users)]).
%% ── Пагинация ──
%% @doc GET /v1/admin/users?limit=...&offset=... пагинация.
-spec test_user_pagination(binary()) -> ok.
test_user_pagination(Token) ->
ct:pal(" TEST: User pagination"),
Page1 = api_test_runner:admin_get(<<"/v1/admin/users?limit=1&offset=0">>, Token),
?assert(length(Page1) >= 1),
Page2 = api_test_runner:admin_get(<<"/v1/admin/users?limit=1&offset=1">>, Token),
?assert(length(Page2) >= 1),
Id1 = maps:get(<<"id">>, hd(Page1)),
Id2 = maps:get(<<"id">>, hd(Page2)),
?assertNotEqual(Id1, Id2),
ct:pal(" OK").
%% @doc Проверка заголовков пагинации (x-total-count).
-spec test_pagination_headers(binary()) -> ok.
test_pagination_headers(Token) ->
ct:pal(" TEST: Pagination headers (x-total-count)"),
{ok, 200, Headers, _} = api_test_runner:admin_request(get,
<<"/v1/admin/users?limit=2&offset=0">>, Token, <<"">>),
Total = list_to_integer(
proplists:get_value("x-total-count", Headers, "0")),
?assert(Total >= 1),
ct:pal(" OK: total count = ~p", [Total]).
%% ── Комбинации с пагинацией ──
%% @doc Фильтр status=active + пагинация limit=1.
-spec test_filter_status_with_pagination(binary()) -> ok.
test_filter_status_with_pagination(Token) ->
ct:pal(" TEST: Filter status=active + pagination (limit=1)"),
{ok, 200, Headers, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/users?status=active&limit=1&offset=0">>, Token, <<"">>),
Users = jsx:decode(list_to_binary(Body), [return_maps]),
?assert(length(Users) =< 1),
[?assertEqual(<<"active">>, maps:get(<<"status">>, U)) || U <- Users],
Total = list_to_integer(
proplists:get_value("x-total-count", Headers, "0")),
?assert(Total >= 1),
ct:pal(" OK: total count = ~p", [Total]).
%% @doc Фильтр + сортировка + пагинация: status=active + created_at desc + limit=1.
-spec test_filter_sort_pagination_combined(binary()) -> ok.
test_filter_sort_pagination_combined(Token) ->
ct:pal(" TEST: Filter + sort + pagination (status=active, sort=created_at desc, limit=1)"),
{ok, 200, Headers, Body} = api_test_runner:admin_request(get,
<<"/v1/admin/users?status=active&sort=created_at&order=desc&limit=1&offset=0">>,
Token, <<"">>),
Users = jsx:decode(list_to_binary(Body), [return_maps]),
?assert(length(Users) =< 1),
[?assertEqual(<<"active">>, maps:get(<<"status">>, U)) || U <- Users],
Total = list_to_integer(
proplists:get_value("x-total-count", Headers, "0")),
?assert(Total >= 1),
ct:pal(" OK: total count = ~p", [Total]).
-1
View File
@@ -64,7 +64,6 @@ init_per_suite(Config) ->
[{started_by_us, false} | Config];
false ->
ct:pal("Local mode: starting application..."),
%% ok = application:load(eventhub),
{ok, _} = application:ensure_all_started(eventhub),
timer:sleep(1000),
[{started_by_us, true} | Config]
-1
View File
@@ -69,7 +69,6 @@ init_per_suite(Config) ->
[{started_by_us, false} | Config];
false ->
ct:pal("Local mode: starting application..."),
%% ok = application:load(eventhub),
{ok, _} = application:ensure_all_started(eventhub),
timer:sleep(1000),
[{started_by_us, true} | Config]