From 393cf0063145b5967789ea28ae99b8af53369046 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B5=D0=B9=20=D0=A1=D0=B0?= =?UTF-8?q?=D0=B1=D0=B8=D0=BB=D0=B8=D0=BD?= Date: Fri, 8 May 2026 16:33:46 +0300 Subject: [PATCH] =?UTF-8?q?=D0=94=D0=BE=D0=B1=D0=B0=D0=B2=D0=BB=D0=B5?= =?UTF-8?q?=D0=BD=20=D1=8D=D0=BD=D0=B4=D0=BF=D0=BE=D0=B9=D0=BD=D1=82=20/v1?= =?UTF-8?q?/admin/reviews=20https://git.sabilin.com/EventHub/EventHubBack/?= =?UTF-8?q?issues/20?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/core/core_review.erl | 4 +- src/eventhub_app.erl | 3 +- src/handlers/admin/admin_handler_reviews.erl | 98 +++---- .../admin/admin_handler_reviews_by_id.erl | 93 +++++++ src/logic/logic_review.erl | 70 ++++- test/api/api_admin_tests.erl | 253 +++++++++++------- test/api/api_test_runner.erl | 4 +- 7 files changed, 378 insertions(+), 147 deletions(-) create mode 100644 src/handlers/admin/admin_handler_reviews_by_id.erl diff --git a/src/core/core_review.erl b/src/core/core_review.erl index 52d768e..fc87ce9 100644 --- a/src/core/core_review.erl +++ b/src/core/core_review.erl @@ -5,7 +5,7 @@ update/2, delete/1, hide/2, unhide/2]). -export([get_average_rating/2, has_user_reviewed/3]). -export([generate_id/0]). --export([count_reviews/0]). +-export([count_reviews/0, list_all/0]). %% Создание отзыва create(UserId, TargetType, TargetId, Rating, Comment) -> @@ -115,6 +115,8 @@ has_user_reviewed(UserId, TargetType, TargetId) -> count_reviews() -> mnesia:table_info(review, size). +list_all() -> mnesia:dirty_match_object(#review{_ = '_'}). + %% Внутренние функции generate_id() -> base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}). diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index 3f9c8bd..5c73e24 100644 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -111,7 +111,8 @@ start_admin_http() -> {"/v1/admin/reports", admin_handler_reports, []}, {"/v1/admin/reports/:id", admin_handler_report_by_id, []}, % ================== ОТЗЫВЫ ================== - {"/v1/admin/reviews/:id", admin_handler_reviews, []}, + {"/v1/admin/reviews", admin_handler_reviews, []}, + {"/v1/admin/reviews/:id", admin_handler_reviews_by_id, []}, % ================== БАН-СЛОВА ================== {"/v1/admin/banned-words", admin_handler_banned_words, []}, {"/v1/admin/banned-words/:word", admin_handler_banned_words, []}, diff --git a/src/handlers/admin/admin_handler_reviews.erl b/src/handlers/admin/admin_handler_reviews.erl index e104f24..efd8cc5 100644 --- a/src/handlers/admin/admin_handler_reviews.erl +++ b/src/handlers/admin/admin_handler_reviews.erl @@ -1,63 +1,71 @@ -module(admin_handler_reviews). -behaviour(cowboy_handler). --export([init/2]). -include("records.hrl"). +-export([init/2]). + init(Req, _Opts) -> case cowboy_req:method(Req) of - <<"GET">> -> get_review(Req); - <<"PUT">> -> update_review(Req); - _ -> send_error(Req, 405, <<"Method not allowed">>) + <<"GET">> -> list_reviews(Req); + <<"PATCH">> -> bulk_update_reviews(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) end. -get_review(Req) -> +list_reviews(Req) -> + case auth_admin(Req) of + {ok, _AdminId, Req1} -> + Filters = parse_filters(Req1), + Reviews = logic_review:list_admin_reviews(Filters), + Json = [review_to_json(R) || R <- Reviews], + send_json(Req1, 200, Json); + {error, Code, Msg, Req1} -> + send_error(Req1, Code, Msg) + end. + +bulk_update_reviews(Req) -> + case auth_admin(Req) of + {ok, _AdminId, Req1} -> + try + {ok, Body, Req2} = cowboy_req:read_body(Req1), + Operations = jsx:decode(Body, [return_maps]), + case logic_review:bulk_update_status(Operations) of + {ok, Count} -> + send_json(Req2, 200, #{updated_count => Count}); + {error, Reason} -> + send_error(Req2, 400, Reason) + end + catch + _:_ -> send_error(Req1, 400, <<"Invalid JSON body">>) + end; + {error, Code, Msg, Req1} -> + send_error(Req1, Code, Msg) + end. + +auth_admin(Req) -> case handler_auth:authenticate(Req) of {ok, AdminId, Req1} -> case admin_utils:is_admin(AdminId) of - true -> - ReviewId = cowboy_req:binding(id, Req1), - case core_review:get_by_id(ReviewId) of - {ok, Review} -> - send_json(Req1, 200, review_to_json(Review)); - {error, not_found} -> - send_error(Req1, 404, <<"Review not found">>) - end; - false -> - send_error(Req1, 403, <<"Admin access required">>) + true -> {ok, AdminId, Req1}; + false -> {error, 403, <<"Admin access required">>, Req1} end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) + {error, Code, Msg, Req1} -> + {error, Code, Msg, Req1} end. -update_review(Req) -> - case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> - case admin_utils:is_admin(AdminId) of - true -> - ReviewId = cowboy_req:binding(id, Req1), - {ok, Body, Req2} = cowboy_req:read_body(Req1), - try jsx:decode(Body, [return_maps]) of - #{<<"status">> := NewStatus} -> - case core_review:update_status(ReviewId, NewStatus) of - {ok, Review} -> - send_json(Req2, 200, review_to_json(Review)); - {error, not_found} -> - send_error(Req2, 404, <<"Review not found">>); - {error, _} -> - send_error(Req2, 500, <<"Internal server error">>) - end; - _ -> - send_error(Req2, 400, <<"Missing status field">>) - catch - _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) - end; - false -> - send_error(Req1, 403, <<"Admin access required">>) - end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. +%% Извлечение параметров фильтрации из query string. +%% Например: ?target_type=event&target_id=...&user_id=... +parse_filters(Req) -> + Qs = cowboy_req:parse_qs(Req), + lists:filtermap( + fun + ({<<"target_type">>, Val}) -> {true, {target_type, Val}}; + ({<<"target_id">>, Val}) -> {true, {target_id, Val}}; + ({<<"user_id">>, Val}) -> {true, {user_id, Val}}; + (_) -> false + end, + Qs + ). review_to_json(R) -> #{ diff --git a/src/handlers/admin/admin_handler_reviews_by_id.erl b/src/handlers/admin/admin_handler_reviews_by_id.erl new file mode 100644 index 0000000..43c1972 --- /dev/null +++ b/src/handlers/admin/admin_handler_reviews_by_id.erl @@ -0,0 +1,93 @@ +-module(admin_handler_reviews_by_id). +-behaviour(cowboy_handler). +-export([init/2]). + +-include("records.hrl"). + +init(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> get_review(Req); + <<"PUT">> -> update_review(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +get_review(Req) -> + case handler_auth:authenticate(Req) of + {ok, AdminId, Req1} -> + case admin_utils:is_admin(AdminId) of + true -> + ReviewId = cowboy_req:binding(id, Req1), + case core_review:get_by_id(ReviewId) of + {ok, Review} -> + send_json(Req1, 200, review_to_json(Review)); + {error, not_found} -> + send_error(Req1, 404, <<"Review not found">>) + end; + false -> + send_error(Req1, 403, <<"Admin access required">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +update_review(Req) -> + case handler_auth:authenticate(Req) of + {ok, AdminId, Req1} -> + case admin_utils:is_admin(AdminId) of + true -> + ReviewId = cowboy_req:binding(id, Req1), + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + #{<<"status">> := NewStatus} -> + case core_review:update_status(ReviewId, NewStatus) of + {ok, Review} -> + send_json(Req2, 200, review_to_json(Review)); + {error, not_found} -> + send_error(Req2, 404, <<"Review not found">>); + {error, _} -> + send_error(Req2, 500, <<"Internal server error">>) + end; + _ -> + send_error(Req2, 400, <<"Missing status field">>) + catch + _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) + end; + false -> + send_error(Req1, 403, <<"Admin access required">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +review_to_json(R) -> + #{ + id => R#review.id, + user_id => R#review.user_id, + target_type => R#review.target_type, + target_id => R#review.target_id, + rating => R#review.rating, + comment => R#review.comment, + status => R#review.status, + created_at => datetime_to_iso8601(R#review.created_at), + updated_at => datetime_to_iso8601(R#review.updated_at) + }. + +datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> + iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", + [Year, Month, Day, Hour, Minute, Second])); +datetime_to_iso8601(undefined) -> undefined. + +send_json(Req, Status, Data) -> + Headers = #{ + <<"content-type">> => <<"application/json">>, + <<"access-control-allow-origin">> => <<"*">>, + <<"access-control-expose-headers">> => <<"Content-Range">> + }, + Body = jsx:encode(Data), + cowboy_req:reply(Status, Headers, Body, Req), + {ok, Body, []}. + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/logic/logic_review.erl b/src/logic/logic_review.erl index cdef096..b596147 100644 --- a/src/logic/logic_review.erl +++ b/src/logic/logic_review.erl @@ -2,8 +2,9 @@ -include("records.hrl"). -export([create_review/5, get_review/2, list_reviews/3, list_user_reviews/1, - update_review/3, delete_review/2, hide_review/2, unhide_review/2]). + update_review/3, delete_review/2, hide_review/2, hide_review/3, unhide_review/2, unhide_review/3]). -export([can_review/3, update_target_rating/2, can_moderate_review/2]). +-export([list_admin_reviews/1, bulk_update_status/1]). %% Создание отзыва create_review(UserId, TargetType, TargetId, Rating, Comment) -> @@ -114,9 +115,12 @@ delete_review(UserId, ReviewId) -> end. hide_review(UserId, ReviewId) -> + hide_review(UserId, ReviewId, <<>>). + +hide_review(UserId, ReviewId, Reason) -> case can_moderate_review(UserId, ReviewId) of true -> - case core_review:hide(ReviewId) of + case core_review:hide(ReviewId, Reason) of {ok, Review} -> update_target_rating(Review#review.target_type, Review#review.target_id), {ok, Review}; @@ -128,9 +132,11 @@ hide_review(UserId, ReviewId) -> end. unhide_review(UserId, ReviewId) -> + unhide_review(UserId, ReviewId, <<>>). +unhide_review(UserId, ReviewId, Reason) -> case can_moderate_review(UserId, ReviewId) of true -> - case core_review:unhide(ReviewId) of + case core_review:unhide(ReviewId, Reason) of {ok, Review} -> update_target_rating(Review#review.target_type, Review#review.target_id), {ok, Review}; @@ -187,6 +193,64 @@ can_moderate_review(UserId, ReviewId) -> end end. +%%%------------------------------------------------------------------- +%%% @doc Получить список всех отзывов (административный режим). +%%% Фильтры: [{target_type, Type}, {target_id, Id}, {user_id, UserId}] +%%% Все фильтры опциональны. +%%% @end +%%%------------------------------------------------------------------- +list_admin_reviews(Filters) -> + AllReviews = core_review:list_all(), + apply_filters(AllReviews, Filters). + +%% Вспомогательная функция: фильтрация списка по proplist +apply_filters(Reviews, []) -> + Reviews; +apply_filters(Reviews, [{target_type, Type} | Rest]) -> + apply_filters( + [R || R <- Reviews, R#review.target_type =:= Type], + Rest + ); +apply_filters(Reviews, [{target_id, Id} | Rest]) -> + apply_filters( + [R || R <- Reviews, R#review.target_id =:= Id], + Rest + ); +apply_filters(Reviews, [{user_id, UserId} | Rest]) -> + apply_filters( + [R || R <- Reviews, R#review.user_id =:= UserId], + Rest + ); +apply_filters(Reviews, [_ | Rest]) -> + apply_filters(Reviews, Rest). % Игнорируем неизвестные фильтры + +%%%------------------------------------------------------------------- +%%% @doc Массово обновить статусы отзывов. +%%% Operations: [#{id => ReviewId, status => visible | hidden}, ...] +%%% Все изменения выполняются в одной Mnesia-транзакции. +%%% @end +%%%------------------------------------------------------------------- +bulk_update_status(Operations) when is_list(Operations) -> + Fun = fun() -> + lists:foreach(fun do_update_status/1, Operations) + end, + case mnesia:transaction(Fun) of + {atomic, ok} -> + {ok, length(Operations)}; + {aborted, Reason} -> + {error, Reason} + end. + +do_update_status(#{<<"id">> := Id, <<"status">> := NewStatus}) -> + case core_review:get_by_id(Id) of + {ok, Review} -> + IdReview = Review#review{id = Id}, + UpdatedReview = Review#review{status = NewStatus}, + core_review:update(IdReview, UpdatedReview); + not_found -> + mnesia:abort(<<"review_not_found">>) + end. + %% Внутренние функции target_exists(event, EventId) -> case core_event:get_by_id(EventId) of diff --git a/test/api/api_admin_tests.erl b/test/api/api_admin_tests.erl index e3b28e5..30b2dfc 100644 --- a/test/api/api_admin_tests.erl +++ b/test/api/api_admin_tests.erl @@ -1,202 +1,263 @@ -module(api_admin_tests). -export([test/0]). -%% Учётные данные по умолчанию (используются, если словарь процесса пуст) --define(FALLBACK_ADMIN_SUPER_EMAIL, <<"superadmin@eventhub.local">>). +%% Учётные данные по умолчанию +-define(FALLBACK_ADMIN_SUPER_EMAIL, <<"superadmin@eventhub.local">>). -define(FALLBACK_ADMIN_SUPER_PASSWORD, <<"123456">>). --define(FALLBACK_ADMIN_MODER_EMAIL, <<"moderator@eventhub.local">>). +-define(FALLBACK_ADMIN_MODER_EMAIL, <<"moderator@eventhub.local">>). -define(FALLBACK_ADMIN_MODER_PASSWORD, <<"123456">>). --define(FALLBACK_ADMIN_SUPPORT_EMAIL, <<"support@eventhub.local">>). +-define(FALLBACK_ADMIN_SUPPORT_EMAIL, <<"support@eventhub.local">>). -define(FALLBACK_ADMIN_SUPPORT_PASSWORD,<<"123456">>). test() -> ct:pal("Testing admin panel API...~n"), AdminURL = api_test_runner:get_admin_url(), - UserURL = api_test_runner:get_base_url(), + UserURL = api_test_runner:get_base_url(), - % Получаем токен суперадмина (уже проинициализирован в api_test_runner) + % Получаем токен суперадмина AdminToken = api_test_runner:get_admin_token(), %% TEST 1: Admin healthcheck (public) - ct:pal(" TEST 1: Admin healthcheck... "), + ct:pal(" TEST 1: Admin healthcheck... "), {ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/health", []}, [], []), ct:pal("OK~n"), %% TEST 2: Admin login (дополнительная проверка) - ct:pal(" TEST 2: Admin login (attempt)... "), - % Теперь используем суперадмина, который гарантированно создан - LoginBody = jsx:encode(#{<<"email">> => ?FALLBACK_ADMIN_SUPER_EMAIL, <<"password">> => ?FALLBACK_ADMIN_SUPER_PASSWORD}), + ct:pal(" TEST 2: Admin login (attempt)... "), + LoginBody = jsx:encode(#{ + <<"email">> => ?FALLBACK_ADMIN_SUPER_EMAIL, + <<"password">> => ?FALLBACK_ADMIN_SUPER_PASSWORD + }), case httpc:request(post, {AdminURL ++ "/v1/admin/login", [], "application/json", LoginBody}, [], []) of - {ok, {{_, 200, _}, _, _}} -> - ct:pal("OK (logged in)~n"); - _ -> - ct:pal("SKIPPED (credentials not found, using runner token)~n") + {ok, {{_, 200, _}, _, _}} -> ct:pal("OK (logged in)~n"); + _ -> ct:pal("SKIPPED (credentials not found, using runner token)~n") end, %% TEST 3: Admin stats (superadmin) - ct:pal(" TEST 3: Admin stats (superadmin)... "), - {ok, {{_, 200, _}, _, StatsResp1}} = httpc:request(get, - {AdminURL ++ "/v1/admin/stats", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + ct:pal(" TEST 3: Admin stats (superadmin)... "), + {ok, {{_, 200, _}, _, StatsResp1}} = httpc:request(get, {AdminURL ++ "/v1/admin/stats", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), Stats1 = jsx:decode(list_to_binary(StatsResp1), [return_maps]), - ct:pal(" OK (keys: ~p)~n", [maps:keys(Stats1)]), + ct:pal(" OK (keys: ~p)~n", [maps:keys(Stats1)]), %% TEST 4: List users - ct:pal(" TEST 4: List users... "), - {ok, {{_, 200, _}, _, _}} = httpc:request(get, - {AdminURL ++ "/v1/admin/users", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + ct:pal(" TEST 4: List users... "), + {ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/users", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), ct:pal("OK~n"), %% TEST 5: Get user by ID - ct:pal(" TEST 5: Get user by ID... "), + ct:pal(" TEST 5: Get user by ID... "), UserId = api_test_runner:get_user_id(), - {ok, {{_, 200, _}, _, _}} = httpc:request(get, - {AdminURL ++ "/v1/admin/users/" ++ binary_to_list(UserId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + {ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/users/" ++ binary_to_list(UserId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), ct:pal("OK~n"), %% TEST 6: List reports - ct:pal(" TEST 6: List reports... "), - {ok, {{_, 200, _}, _, _}} = httpc:request(get, - {AdminURL ++ "/v1/admin/reports", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + ct:pal(" TEST 6: List reports... "), + {ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/reports", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), ct:pal("OK~n"), %% ── TEST 7: Full moderation flow (create event, report, resolve) ── - ct:pal(" TEST 7: Moderation flow... "), - - % Создаём календарь и событие от имени пользователя + ct:pal(" TEST 7: Moderation flow... "), UserToken = api_test_runner:get_user_token(), CalId = api_test_runner:create_calendar(UserToken, #{title => <<"ModerationTest">>}), EventId = api_test_runner:create_event(UserToken, CalId, #{ - title => <<"Event to report">>, + title => <<"Event to report">>, start_time => api_SUITE:future_date(), - duration => 60 + duration => 60 }), - % Подаём жалобу на это событие CreateBody = jsx:encode(#{ <<"target_type">> => <<"event">>, - <<"target_id">> => EventId, - <<"reason">> => <<"Inappropriate content">> + <<"target_id">> => EventId, + <<"reason">> => <<"Inappropriate content">> }), - {ok, {{_, 201, _}, _, CreateResp}} = httpc:request(post, - {UserURL ++ "/v1/reports", - [{"Authorization", "Bearer " ++ binary_to_list(UserToken)}], - "application/json", CreateBody}, [], []), + {ok, {{_, 201, _}, _, CreateResp}} = httpc:request(post, {UserURL ++ "/v1/reports", [{"Authorization", "Bearer " ++ binary_to_list(UserToken)}], "application/json", CreateBody}, [], []), #{<<"id">> := ReportId} = jsx:decode(list_to_binary(CreateResp), [return_maps]), - % Администратор изменяет статус жалобы EditBody = jsx:encode(#{ <<"status">> => <<"reviewed">>, <<"reason">> => <<"Issue resolved">> }), - {ok, {{_, 200, _}, _, _}} = httpc:request(put, - {AdminURL ++ "/v1/admin/reports/" ++ binary_to_list(ReportId), - [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], - "application/json", EditBody}, [], []), + {ok, {{_, 200, _}, _, _}} = httpc:request(put, {AdminURL ++ "/v1/admin/reports/" ++ binary_to_list(ReportId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", EditBody}, [], []), ct:pal("OK~n"), %% TEST 8: List banned words - ct:pal(" TEST 8: List banned words... "), - {ok, {{_, 200, _}, _, _}} = httpc:request(get, - {AdminURL ++ "/v1/admin/banned-words", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + ct:pal(" TEST 8: List banned words... "), + {ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/banned-words", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), ct:pal("OK~n"), %% TEST 9: Add banned word - ct:pal(" TEST 9: Add banned word... "), + ct:pal(" TEST 9: Add banned word... "), BannedWordBody = jsx:encode(#{<<"word">> => <<"test_banned_word">>}), - {ok, {{_, 201, _}, _, _}} = httpc:request(post, - {AdminURL ++ "/v1/admin/banned-words", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", BannedWordBody}, [], []), + {ok, {{_, 201, _}, _, _}} = httpc:request(post, {AdminURL ++ "/v1/admin/banned-words", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", BannedWordBody}, [], []), ct:pal("OK~n"), %% TEST 10: Delete banned word - ct:pal(" TEST 10: Delete banned word... "), - {ok, {{_, 200, _}, _, _}} = httpc:request(delete, - {AdminURL ++ "/v1/admin/banned-words/test_banned_word", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + ct:pal(" TEST 10: Delete banned word... "), + {ok, {{_, 200, _}, _, _}} = httpc:request(delete, {AdminURL ++ "/v1/admin/banned-words/test_banned_word", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), ct:pal("OK~n"), %% TEST 11: List tickets - ct:pal(" TEST 11: List tickets... "), - {ok, {{_, 200, _}, _, _}} = httpc:request(get, - {AdminURL ++ "/v1/admin/tickets", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + ct:pal(" TEST 11: List tickets... "), + {ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/tickets", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), ct:pal("OK~n"), %% TEST 12: Create ticket - ct:pal(" TEST 12: Create ticket... "), - TicketBody = jsx:encode(#{<<"error_message">> => <<"Test error">>, <<"stacktrace">> => <<"trace">>}), - {ok, {{_, 201, _}, _, TicketResp}} = httpc:request(post, - {AdminURL ++ "/v1/admin/tickets", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", TicketBody}, [], []), + ct:pal(" TEST 12: Create ticket... "), + TicketBody = jsx:encode(#{ + <<"error_message">> => <<"Test error">>, + <<"stacktrace">> => <<"trace">> + }), + {ok, {{_, 201, _}, _, TicketResp}} = httpc:request(post, {AdminURL ++ "/v1/admin/tickets", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", TicketBody}, [], []), #{<<"id">> := TicketId} = jsx:decode(list_to_binary(TicketResp), [return_maps]), ct:pal("OK~n"), %% TEST 13: Get ticket by ID - ct:pal(" TEST 13: Get ticket by ID... "), - {ok, {{_, 200, _}, _, _}} = httpc:request(get, - {AdminURL ++ "/v1/admin/tickets/" ++ binary_to_list(TicketId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + ct:pal(" TEST 13: Get ticket by ID... "), + {ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/tickets/" ++ binary_to_list(TicketId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), ct:pal("OK~n"), %% TEST 14: Update ticket - ct:pal(" TEST 14: Update ticket... "), + ct:pal(" TEST 14: Update ticket... "), UpdateTicketBody = jsx:encode(#{<<"status">> => <<"closed">>}), - {ok, {{_, 200, _}, _, _}} = httpc:request(put, - {AdminURL ++ "/v1/admin/tickets/" ++ binary_to_list(TicketId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", UpdateTicketBody}, [], []), + {ok, {{_, 200, _}, _, _}} = httpc:request(put, {AdminURL ++ "/v1/admin/tickets/" ++ binary_to_list(TicketId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", UpdateTicketBody}, [], []), ct:pal("OK~n"), %% TEST 15: Delete ticket - ct:pal(" TEST 15: Delete ticket... "), - {ok, {{_, 200, _}, _, _}} = httpc:request(delete, - {AdminURL ++ "/v1/admin/tickets/" ++ binary_to_list(TicketId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + ct:pal(" TEST 15: Delete ticket... "), + {ok, {{_, 200, _}, _, _}} = httpc:request(delete, {AdminURL ++ "/v1/admin/tickets/" ++ binary_to_list(TicketId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), ct:pal("OK~n"), %% TEST 16: Ticket stats - ct:pal(" TEST 16: Ticket stats... "), - {ok, {{_, 200, _}, _, _}} = httpc:request(get, - {AdminURL ++ "/v1/admin/tickets/stats", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + ct:pal(" TEST 16: Ticket stats... "), + {ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/tickets/stats", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), ct:pal("OK~n"), %% TEST 17: List subscriptions - ct:pal(" TEST 17: List subscriptions... "), - {ok, {{_, 200, _}, _, _}} = httpc:request(get, - {AdminURL ++ "/v1/admin/subscriptions", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + ct:pal(" TEST 17: List subscriptions... "), + {ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/subscriptions", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), ct:pal("OK~n"), %% TEST 18: Create subscription - ct:pal(" TEST 18: Create subscription... "), - SubBody = jsx:encode(#{<<"user_id">> => UserId, <<"plan">> => <<"monthly">>}), - {ok, {{_, 201, _}, _, SubResp}} = httpc:request(post, - {AdminURL ++ "/v1/admin/subscriptions", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", SubBody}, [], []), + ct:pal(" TEST 18: Create subscription... "), + SubBody = jsx:encode(#{ + <<"user_id">> => UserId, + <<"plan">> => <<"monthly">> + }), + {ok, {{_, 201, _}, _, SubResp}} = httpc:request(post, {AdminURL ++ "/v1/admin/subscriptions", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", SubBody}, [], []), #{<<"id">> := SubId} = jsx:decode(list_to_binary(SubResp), [return_maps]), ct:pal("OK~n"), %% TEST 19: Get subscription by ID - ct:pal(" TEST 19: Get subscription by ID... "), - {ok, {{_, 200, _}, _, _}} = httpc:request(get, - {AdminURL ++ "/v1/admin/subscriptions/" ++ binary_to_list(SubId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + ct:pal(" TEST 19: Get subscription by ID... "), + {ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/subscriptions/" ++ binary_to_list(SubId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), ct:pal("OK~n"), %% TEST 20: Update subscription - ct:pal(" TEST 20: Update subscription... "), + ct:pal(" TEST 20: Update subscription... "), UpdateSubBody = jsx:encode(#{<<"status">> => <<"cancelled">>}), - {ok, {{_, 200, _}, _, _}} = httpc:request(put, - {AdminURL ++ "/v1/admin/subscriptions/" ++ binary_to_list(SubId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", UpdateSubBody}, [], []), + {ok, {{_, 200, _}, _, _}} = httpc:request(put, {AdminURL ++ "/v1/admin/subscriptions/" ++ binary_to_list(SubId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", UpdateSubBody}, [], []), ct:pal("OK~n"), %% TEST 21: Delete subscription - ct:pal(" TEST 21: Delete subscription... "), - {ok, {{_, 200, _}, _, _}} = httpc:request(delete, - {AdminURL ++ "/v1/admin/subscriptions/" ++ binary_to_list(SubId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + ct:pal(" TEST 21: Delete subscription... "), + {ok, {{_, 200, _}, _, _}} = httpc:request(delete, {AdminURL ++ "/v1/admin/subscriptions/" ++ binary_to_list(SubId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), ct:pal("OK~n"), %% TEST 22: Moderation - block user - ct:pal(" TEST 22: Moderation - block user... "), - ModBody = jsx:encode(#{<<"action">> => <<"block">>, <<"reason">> => <<"test">>}), - {ok, {{_, 200, _}, _, _}} = httpc:request(put, - {AdminURL ++ "/v1/admin/user/" ++ binary_to_list(UserId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", ModBody}, [], []), + ct:pal(" TEST 22: Moderation - block user... "), + ModBody = jsx:encode(#{ + <<"action">> => <<"block">>, + <<"reason">> => <<"test">> + }), + {ok, {{_, 200, _}, _, _}} = httpc:request(put, {AdminURL ++ "/v1/admin/user/" ++ binary_to_list(UserId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", ModBody}, [], []), ct:pal("OK~n"), %% TEST 23: Moderation - unblock user - ct:pal(" TEST 23: Moderation - unblock user... "), - UnblockBody = jsx:encode(#{<<"action">> => <<"unblock">>, <<"reason">> => <<"restore">>}), - {ok, {{_, 200, _}, _, _}} = httpc:request(put, - {AdminURL ++ "/v1/admin/user/" ++ binary_to_list(UserId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", UnblockBody}, [], []), + ct:pal(" TEST 23: Moderation - unblock user... "), + UnblockBody = jsx:encode(#{ + <<"action">> => <<"unblock">>, + <<"reason">> => <<"restore">> + }), + {ok, {{_, 200, _}, _, _}} = httpc:request(put, {AdminURL ++ "/v1/admin/user/" ++ binary_to_list(UserId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", UnblockBody}, [], []), + ct:pal("OK~n"), + + %% ======================================================== + %% Admin Reviews list tests + %% ======================================================== + + %% Подготовка тестовых данных для отзывов + ct:pal(" Preparing test data for reviews... "), + UserToken = api_test_runner:get_user_token(), + % Создаем календарь и событие (отдельные переменные, чтобы не перекрыть TEST 7) + RevCalId = api_test_runner:create_calendar(UserToken, #{title => <<"ReviewsTest">>}), + RevEventId = api_test_runner:create_event(UserToken, RevCalId, #{ + title => <<"Event for review testing">>, + start_time => api_SUITE:future_date(), + duration => 60 + }), + ct:pal("OK (calendar: ~s, event: ~s)~n", [RevCalId, RevEventId]), + + ParticipantEmail = api_test_runner:unique_email(<<"rev_1">>), + ParticipantEmail2 = api_test_runner:unique_email(<<"rev_2">>), + ParticipantToken = api_test_runner:register_and_login(ParticipantEmail, <<"part123">>), + ParticipantToken2 = api_test_runner:register_and_login(ParticipantEmail2, <<"part123">>), + + % Создаём и подтверждаем бронирование + BookingId = api_test_runner:extract_json( + api_test_runner:http_post("/v1/events/" ++ binary_to_list(RevEventId) ++ "/bookings", #{}, ParticipantToken), <<"id">>), + api_test_runner:http_put("/v1/bookings/" ++ binary_to_list(BookingId), #{action => <<"confirm">>}, UserToken), + + Booking2Id = api_test_runner:extract_json( + api_test_runner:http_post("/v1/events/" ++ binary_to_list(RevEventId) ++ "/bookings", #{}, ParticipantToken2), <<"id">>), + api_test_runner:http_put("/v1/bookings/" ++ binary_to_list(Booking2Id), #{action => <<"confirm">>}, UserToken), + + ReviewId = api_test_runner:extract_json( + api_test_runner:http_post("/v1/reviews", + #{target_type => <<"event">>, target_id => RevEventId, rating => 5, comment => <<"Great!">>}, + ParticipantToken), <<"id">>), + ct:pal(" Review2Id: ~p~n", [ReviewId]), + Review2Id = api_test_runner:extract_json( + api_test_runner:http_post("/v1/reviews", + #{target_type => <<"event">>, target_id => RevEventId, rating => 5, comment => <<"Great!">>}, + ParticipantToken2), <<"id">>), + ct:pal(" Review2Id: ~p~n", [Review2Id]), + + %% TEST 24: List all reviews (GET /v1/admin/reviews) + ct:pal(" TEST 24: List all reviews... "), + {ok, {{_, 200, _}, _, ListReviewsResp}} = httpc:request(get, {AdminURL ++ "/v1/admin/reviews", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + ReviewsList = jsx:decode(list_to_binary(ListReviewsResp), [return_maps]), + true = is_list(ReviewsList), + ct:pal(" OK (count: ~p)~n", [length(ReviewsList)]), + + %% TEST 25: List reviews with filters (GET /v1/admin/reviews?target_type=event&target_id=...) + ct:pal(" TEST 25: List reviews with filters... "), + FilterURL = AdminURL ++ "/v1/admin/reviews?target_type=event&target_id=" ++ binary_to_list(RevEventId), + {ok, {{_, 200, _}, _, FilteredResp}} = httpc:request(get, {FilterURL, [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + FilteredList = jsx:decode(list_to_binary(FilteredResp), [return_maps]), + ct:pal(" OK (filtered count: ~p)~n", [length(FilteredList)]), + + %% TEST 26: Bulk update review statuses (PATCH /v1/admin/reviews) + ct:pal(" TEST 26: Bulk update review statuses... "), + case ReviewsList of + [FirstReview, SecondReview | _] -> + FirstId = maps:get(<<"id">>, FirstReview), + SecondId = maps:get(<<"id">>, SecondReview), + PatchBody = jsx:encode([ + #{<<"id">> => FirstId, <<"status">> => <<"visible">>}, + #{<<"id">> => SecondId, <<"status">> => <<"hidden">>} + ]), + ct:pal(" OK (PatchBody: ~p)~n", [PatchBody]), + {ok, {{_, 200, _}, _, PatchResp}} = httpc:request(patch, {AdminURL ++ "/v1/admin/reviews", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", PatchBody}, [], []), + #{<<"updated_count">> := UpdatedCount} = jsx:decode(list_to_binary(PatchResp), [return_maps]), + true = (UpdatedCount =:= 2), + ct:pal(" OK (updated: ~p)~n", [UpdatedCount]); + _ -> + ct:pal("SKIPPED (not enough reviews for bulk update)~n") + end, + + %% TEST 27: Method not allowed (POST /v1/admin/reviews → 405) + ct:pal(" TEST 27: POST method not allowed... "), + {ok, {{_, 405, _}, _, _}} = httpc:request(post, {AdminURL ++ "/v1/admin/reviews", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", <<"{}">>}, [], []), ct:pal("OK~n"), ct:pal("~n✅ Admin API tests passed!~n"), diff --git a/test/api/api_test_runner.erl b/test/api/api_test_runner.erl index 204f948..2039bf1 100644 --- a/test/api/api_test_runner.erl +++ b/test/api/api_test_runner.erl @@ -243,7 +243,9 @@ register_and_login(Email, Password) -> end. create_calendar(Token, Params) -> - Id = extract_json(http_post("/v1/calendars", Params, Token), <<"id">>), + Response = http_post("/v1/calendars", Params, Token), + ct:pal(" create_calendar Response: ~p~n", [Response]), + Id = extract_json(Response, <<"id">>), Id. create_event(Token, CalId, Params) ->