From ee8928fa5fe59b19eb45e1c539945c77f3d3f279 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: Tue, 21 Apr 2026 10:15:17 +0300 Subject: [PATCH] Stage 5 --- Makefile | 4 + src/core/core_calendar.erl | 2 + src/core/core_event.erl | 2 + src/core/core_review.erl | 129 +++++++ src/eventhub_app.erl | 6 +- src/handlers/handler_admin_reviews.erl | 91 +++++ src/handlers/handler_review_by_id.erl | 116 +++++++ src/handlers/handler_reviews.erl | 107 ++++++ src/handlers/handler_user_reviews.erl | 54 +++ src/logic/logic_review.erl | 216 ++++++++++++ test/core_review_tests.erl | 131 +++++++ test/logic_review_tests.erl | 161 +++++++++ test/scripts/test_reviews_api.sh | 454 +++++++++++++++++++++++++ 13 files changed, 1472 insertions(+), 1 deletion(-) create mode 100644 src/core/core_review.erl create mode 100644 src/handlers/handler_admin_reviews.erl create mode 100644 src/handlers/handler_review_by_id.erl create mode 100644 src/handlers/handler_reviews.erl create mode 100644 src/handlers/handler_user_reviews.erl create mode 100644 src/logic/logic_review.erl create mode 100644 test/core_review_tests.erl create mode 100644 test/logic_review_tests.erl create mode 100644 test/scripts/test_reviews_api.sh diff --git a/Makefile b/Makefile index e6c550e..91f2053 100644 --- a/Makefile +++ b/Makefile @@ -150,6 +150,10 @@ test-booking: ## Запустить тесты бронирований @chmod +x test/scripts/test_booking_api.sh @./test/scripts/test_booking_api.sh +test-reviews: ## Запустить тесты отзывов + @chmod +x test/scripts/test_reviews_api.sh + @./test/scripts/test_reviews_api.sh + test-all: eunit ## Запустить ВСЕ тесты (EUnit + API) @sleep 1 make test-api diff --git a/src/core/core_calendar.erl b/src/core/core_calendar.erl index 8008954..f1a07ac 100644 --- a/src/core/core_calendar.erl +++ b/src/core/core_calendar.erl @@ -82,4 +82,6 @@ set_field(tags, Value, C) -> C#calendar{tags = Value}; set_field(type, Value, C) -> C#calendar{type = Value}; set_field(confirmation, Value, C) -> C#calendar{confirmation = Value}; set_field(status, Value, C) -> C#calendar{status = Value}; +set_field(rating_avg, Value, C) -> C#calendar{rating_avg = Value}; +set_field(rating_count, Value, C) -> C#calendar{rating_count = Value}; set_field(_, _, C) -> C. \ No newline at end of file diff --git a/src/core/core_event.erl b/src/core/core_event.erl index de394ec..2483702 100644 --- a/src/core/core_event.erl +++ b/src/core/core_event.erl @@ -187,4 +187,6 @@ set_field(tags, Value, E) -> E#event{tags = Value}; set_field(capacity, Value, E) -> E#event{capacity = Value}; set_field(online_link, Value, E) -> E#event{online_link = Value}; set_field(status, Value, E) -> E#event{status = Value}; +set_field(rating_avg, Value, E) -> E#event{rating_avg = Value}; +set_field(rating_count, Value, E) -> E#event{rating_count = Value}; set_field(_, _, E) -> E. \ No newline at end of file diff --git a/src/core/core_review.erl b/src/core/core_review.erl new file mode 100644 index 0000000..34aab5c --- /dev/null +++ b/src/core/core_review.erl @@ -0,0 +1,129 @@ +-module(core_review). +-include("records.hrl"). + +-export([create/5, get_by_id/1, list_by_target/2, list_by_user/1, + update/2, delete/1, hide/1, unhide/1]). +-export([get_average_rating/2, has_user_reviewed/3]). +-export([generate_id/0]). + +%% Создание отзыва +create(UserId, TargetType, TargetId, Rating, Comment) -> + Id = generate_id(), + Review = #review{ + id = Id, + user_id = UserId, + target_type = TargetType, + target_id = TargetId, + rating = Rating, + comment = Comment, + status = visible, + created_at = calendar:universal_time(), + updated_at = calendar:universal_time() + }, + + F = fun() -> + mnesia:write(Review), + {ok, Review} + end, + + case mnesia:transaction(F) of + {atomic, Result} -> Result; + {aborted, Reason} -> {error, Reason} + end. + +%% Получение отзыва по ID +get_by_id(Id) -> + case mnesia:dirty_read(review, Id) of + [] -> {error, not_found}; + [Review] -> {ok, Review} + end. + +%% Список отзывов для цели (событие или календарь) +list_by_target(TargetType, TargetId) -> + Match = #review{target_type = TargetType, target_id = TargetId, status = visible, _ = '_'}, + Reviews = mnesia:dirty_match_object(Match), + {ok, lists:sort(fun(A, B) -> A#review.created_at >= B#review.created_at end, Reviews)}. + +%% Список отзывов пользователя +list_by_user(UserId) -> + Match = #review{user_id = UserId, _ = '_'}, + Reviews = mnesia:dirty_match_object(Match), + {ok, Reviews}. + +%% Обновление отзыва +update(Id, Updates) -> + F = fun() -> + case mnesia:read(review, Id) of + [] -> + {error, not_found}; + [Review] -> + UpdatedReview = apply_updates(Review, Updates), + mnesia:write(UpdatedReview), + {ok, UpdatedReview} + end + end, + + case mnesia:transaction(F) of + {atomic, Result} -> Result; + {aborted, Reason} -> {error, Reason} + end. + +%% Удаление отзыва (hard delete) +delete(Id) -> + F = fun() -> + case mnesia:read(review, Id) of + [] -> + {error, not_found}; + [Review] -> + mnesia:delete_object(Review), + {ok, deleted} + end + end, + + case mnesia:transaction(F) of + {atomic, Result} -> Result; + {aborted, Reason} -> {error, Reason} + end. + +%% Скрытие отзыва (модерация) +hide(Id) -> + update(Id, [{status, hidden}]). + +%% Раскрытие отзыва +unhide(Id) -> + update(Id, [{status, visible}]). + +%% Получение среднего рейтинга цели +get_average_rating(TargetType, TargetId) -> + Match = #review{target_type = TargetType, target_id = TargetId, status = visible, _ = '_'}, + Reviews = mnesia:dirty_match_object(Match), + + case length(Reviews) of + 0 -> {0.0, 0}; + Count -> + Total = lists:sum([R#review.rating || R <- Reviews]), + {Total / Count, Count} + end. + +%% Проверка, оставлял ли пользователь отзыв +has_user_reviewed(UserId, TargetType, TargetId) -> + Match = #review{user_id = UserId, target_type = TargetType, target_id = TargetId, _ = '_'}, + case mnesia:dirty_match_object(Match) of + [] -> false; + _ -> true + end. + +%% Внутренние функции +generate_id() -> + base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}). + +apply_updates(Review, Updates) -> + Updated = lists:foldl(fun({Field, Value}, R) -> + set_field(Field, Value, R) + end, Review, Updates), + Updated#review{updated_at = calendar:universal_time()}. + +set_field(rating, Value, R) when Value >= 1, Value =< 5 -> R#review{rating = Value}; +set_field(comment, Value, R) -> R#review{comment = Value}; +set_field(status, Value, R) when Value =:= visible; Value =:= hidden -> R#review{status = Value}; +set_field(_, _, R) -> R. \ No newline at end of file diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index 4f447e5..4a8cbd9 100644 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -31,6 +31,7 @@ start_http() -> {"/v1/refresh", handler_refresh, []}, {"/v1/user/me", handler_user_me, []}, {"/v1/user/bookings", handler_user_bookings, []}, + {"/v1/user/reviews", handler_user_reviews, []}, {"/v1/search", handler_search, []}, {"/v1/calendars", handler_calendars, []}, {"/v1/calendars/:id", handler_calendar_by_id, []}, @@ -39,7 +40,10 @@ start_http() -> {"/v1/events/:id/occurrences", handler_event_occurrences, []}, {"/v1/events/:id/occurrences/:start_time", handler_event_occurrences, []}, {"/v1/events/:id/bookings", handler_bookings, []}, - {"/v1/bookings/:id", handler_booking_by_id, []} + {"/v1/bookings/:id", handler_booking_by_id, []}, + {"/v1/reviews", handler_reviews, []}, + {"/v1/reviews/:id", handler_review_by_id, []}, + {"/v1/admin/reviews/:id", handler_admin_reviews, []} ]} ]), diff --git a/src/handlers/handler_admin_reviews.erl b/src/handlers/handler_admin_reviews.erl new file mode 100644 index 0000000..300f92e --- /dev/null +++ b/src/handlers/handler_admin_reviews.erl @@ -0,0 +1,91 @@ +-module(handler_admin_reviews). +-include("records.hrl"). + +-export([init/2]). + +init(Req, Opts) -> + handle(Req, Opts). + +handle(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"PUT">> -> moderate_review(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +%% PUT /v1/admin/reviews/:id - скрыть/раскрыть отзыв +moderate_review(Req) -> + case handler_auth:authenticate(Req) of + {ok, AdminId, Req1} -> + % Проверим роль + case core_user:get_by_id(AdminId) of + {ok, User} -> + io:format("User ~p role: ~p~n", [AdminId, User#user.role]); + _ -> ok + end, + ReviewId = cowboy_req:binding(id, Req1), + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + #{<<"action">> := Action} -> + case Action of + <<"hide">> -> + case logic_review:hide_review(AdminId, ReviewId) of + {ok, Review} -> + Response = review_to_json(Review), + send_json(Req2, 200, Response); + {error, access_denied} -> + send_error(Req2, 403, <<"Admin access required">>); + {error, not_found} -> + send_error(Req2, 404, <<"Review not found">>); + {error, _} -> + send_error(Req2, 500, <<"Internal server error">>) + end; + <<"unhide">> -> + case logic_review:unhide_review(AdminId, ReviewId) of + {ok, Review} -> + Response = review_to_json(Review), + send_json(Req2, 200, Response); + {error, access_denied} -> + send_error(Req2, 403, <<"Admin access required">>); + {error, not_found} -> + send_error(Req2, 404, <<"Review not found">>); + {error, _} -> + send_error(Req2, 500, <<"Internal server error">>) + end; + _ -> + send_error(Req2, 400, <<"Invalid action. Use 'hide' or 'unhide'">>) + end; + _ -> + send_error(Req2, 400, <<"Missing action field">>) + catch + _:_ -> + send_error(Req2, 400, <<"Invalid JSON format">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +%% Вспомогательные функции +review_to_json(Review) -> + #{ + id => Review#review.id, + user_id => Review#review.user_id, + target_type => Review#review.target_type, + target_id => Review#review.target_id, + rating => Review#review.rating, + comment => Review#review.comment, + status => Review#review.status, + created_at => datetime_to_iso8601(Review#review.created_at), + updated_at => datetime_to_iso8601(Review#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])). + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). \ No newline at end of file diff --git a/src/handlers/handler_review_by_id.erl b/src/handlers/handler_review_by_id.erl new file mode 100644 index 0000000..9cab0c7 --- /dev/null +++ b/src/handlers/handler_review_by_id.erl @@ -0,0 +1,116 @@ +-module(handler_review_by_id). +-include("records.hrl"). + +-export([init/2]). + +init(Req, Opts) -> + handle(Req, Opts). + +handle(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> get_review(Req); + <<"PUT">> -> update_review(Req); + <<"DELETE">> -> delete_review(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +%% GET /v1/reviews/:id - получение отзыва +get_review(Req) -> + case handler_auth:authenticate(Req) of + {ok, UserId, Req1} -> + ReviewId = cowboy_req:binding(id, Req1), + case logic_review:get_review(UserId, ReviewId) of + {ok, Review} -> + Response = review_to_json(Review), + send_json(Req1, 200, Response); + {error, access_denied} -> + send_error(Req1, 403, <<"Access denied">>); + {error, not_found} -> + send_error(Req1, 404, <<"Review not found">>); + {error, _} -> + send_error(Req1, 500, <<"Internal server error">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +%% PUT /v1/reviews/:id - обновление отзыва +update_review(Req) -> + case handler_auth:authenticate(Req) of + {ok, UserId, Req1} -> + ReviewId = cowboy_req:binding(id, Req1), + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + UpdatesMap when is_map(UpdatesMap) -> + Updates = maps:to_list(UpdatesMap), + case logic_review:update_review(UserId, ReviewId, Updates) of + {ok, _} -> + % Получаем обновлённый отзыв из базы + case core_review:get_by_id(ReviewId) of + {ok, Updated} -> + Response = review_to_json(Updated), + send_json(Req2, 200, Response); + _ -> + send_error(Req2, 500, <<"Failed to retrieve updated review">>) + end; + {error, access_denied} -> + send_error(Req2, 403, <<"Access denied">>); + {error, not_found} -> + send_error(Req2, 404, <<"Review not found">>); + {error, _} -> + send_error(Req2, 500, <<"Internal server error">>) + end; + _ -> + send_error(Req2, 400, <<"Invalid JSON">>) + catch + _:_ -> + send_error(Req2, 400, <<"Invalid JSON format">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +%% DELETE /v1/reviews/:id - удаление отзыва +delete_review(Req) -> + case handler_auth:authenticate(Req) of + {ok, UserId, Req1} -> + ReviewId = cowboy_req:binding(id, Req1), + case logic_review:delete_review(UserId, ReviewId) of + {ok, deleted} -> + send_json(Req1, 200, #{status => <<"deleted">>}); + {error, access_denied} -> + send_error(Req1, 403, <<"Access denied">>); + {error, not_found} -> + send_error(Req1, 404, <<"Review not found">>); + {error, _} -> + send_error(Req1, 500, <<"Internal server error">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +%% Вспомогательные функции +review_to_json(Review) -> + #{ + id => Review#review.id, + user_id => Review#review.user_id, + target_type => Review#review.target_type, + target_id => Review#review.target_id, + rating => Review#review.rating, + comment => Review#review.comment, + status => Review#review.status, + created_at => datetime_to_iso8601(Review#review.created_at), + updated_at => datetime_to_iso8601(Review#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])). + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). \ No newline at end of file diff --git a/src/handlers/handler_reviews.erl b/src/handlers/handler_reviews.erl new file mode 100644 index 0000000..664e11d --- /dev/null +++ b/src/handlers/handler_reviews.erl @@ -0,0 +1,107 @@ +-module(handler_reviews). +-include("records.hrl"). + +-export([init/2]). + +init(Req, Opts) -> + handle(Req, Opts). + +handle(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"POST">> -> create_review(Req); + <<"GET">> -> list_reviews(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +%% POST /v1/reviews - создание отзыва +create_review(Req) -> + case handler_auth:authenticate(Req) of + {ok, UserId, Req1} -> + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + Decoded when is_map(Decoded) -> + case Decoded of + #{<<"target_type">> := TargetTypeBin, + <<"target_id">> := TargetId, + <<"rating">> := Rating, + <<"comment">> := Comment} -> + TargetType = parse_target_type(TargetTypeBin), + case logic_review:create_review(UserId, TargetType, TargetId, Rating, Comment) of + {ok, Review} -> + Response = review_to_json(Review), + send_json(Req2, 201, Response); + {error, already_reviewed} -> + send_error(Req2, 409, <<"Already reviewed">>); + {error, cannot_review} -> + send_error(Req2, 403, <<"Cannot review this target">>); + {error, target_not_found} -> + send_error(Req2, 404, <<"Target not found">>); + {error, _} -> + send_error(Req2, 500, <<"Internal server error">>) + end; + _ -> + send_error(Req2, 400, <<"Missing required fields">>) + end; + _ -> + send_error(Req2, 400, <<"Invalid JSON">>) + catch + _:_ -> + send_error(Req2, 400, <<"Invalid JSON format">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +%% GET /v1/reviews - список отзывов для цели +list_reviews(Req) -> + case handler_auth:authenticate(Req) of + {ok, UserId, Req1} -> + Qs = cowboy_req:parse_qs(Req1), + case {proplists:get_value(<<"target_type">>, Qs), proplists:get_value(<<"target_id">>, Qs)} of + {undefined, _} -> + send_error(Req1, 400, <<"Missing target_type">>); + {_, undefined} -> + send_error(Req1, 400, <<"Missing target_id">>); + {TargetTypeBin, TargetId} -> + TargetType = parse_target_type(TargetTypeBin), + case logic_review:list_reviews(UserId, TargetType, TargetId) of + {ok, Reviews} -> + Response = [review_to_json(R) || R <- Reviews], + send_json(Req1, 200, Response); + {error, _} -> + send_error(Req1, 500, <<"Internal server error">>) + end + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +%% Вспомогательные функции +parse_target_type(<<"event">>) -> event; +parse_target_type(<<"calendar">>) -> calendar; +parse_target_type(_) -> undefined. + +review_to_json(Review) -> + #{ + id => Review#review.id, + user_id => Review#review.user_id, + target_type => Review#review.target_type, + target_id => Review#review.target_id, + rating => Review#review.rating, + comment => Review#review.comment, + status => Review#review.status, + created_at => datetime_to_iso8601(Review#review.created_at), + updated_at => datetime_to_iso8601(Review#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])). + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). \ No newline at end of file diff --git a/src/handlers/handler_user_reviews.erl b/src/handlers/handler_user_reviews.erl new file mode 100644 index 0000000..62507c4 --- /dev/null +++ b/src/handlers/handler_user_reviews.erl @@ -0,0 +1,54 @@ +-module(handler_user_reviews). +-include("records.hrl"). + +-export([init/2]). + +init(Req, Opts) -> + handle(Req, Opts). + +handle(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> list_user_reviews(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +%% GET /v1/user/reviews - список отзывов текущего пользователя +list_user_reviews(Req) -> + case handler_auth:authenticate(Req) of + {ok, UserId, Req1} -> + case logic_review:list_user_reviews(UserId) of + {ok, Reviews} -> + Response = [review_to_json(R) || R <- Reviews], + send_json(Req1, 200, Response); + {error, _} -> + send_error(Req1, 500, <<"Internal server error">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +%% Вспомогательные функции +review_to_json(Review) -> + #{ + id => Review#review.id, + user_id => Review#review.user_id, + target_type => Review#review.target_type, + target_id => Review#review.target_id, + rating => Review#review.rating, + comment => Review#review.comment, + status => Review#review.status, + created_at => datetime_to_iso8601(Review#review.created_at), + updated_at => datetime_to_iso8601(Review#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])). + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). \ No newline at end of file diff --git a/src/logic/logic_review.erl b/src/logic/logic_review.erl new file mode 100644 index 0000000..e7d3db2 --- /dev/null +++ b/src/logic/logic_review.erl @@ -0,0 +1,216 @@ +-module(logic_review). +-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]). +-export([can_review/3, update_target_rating/2, can_moderate_review/2]). + +%% Создание отзыва +create_review(UserId, TargetType, TargetId, Rating, Comment) -> + case target_exists(TargetType, TargetId) of + true -> + case can_review(UserId, TargetType, TargetId) of + {ok, true} -> + case core_review:has_user_reviewed(UserId, TargetType, TargetId) of + false -> + case core_review:create(UserId, TargetType, TargetId, Rating, Comment) of + {ok, Review} -> + update_target_rating(TargetType, TargetId), + {ok, Review}; + Error -> + Error + end; + true -> + {error, already_reviewed} + end; + {ok, false} -> + {error, cannot_review}; + {error, _} = Error -> + Error + end; + false -> + {error, target_not_found} + end. + +%% Получение отзыва +get_review(UserId, ReviewId) -> + case core_review:get_by_id(ReviewId) of + {ok, Review} -> + case is_admin(UserId) orelse Review#review.status =:= visible of + true -> {ok, Review}; + false -> {error, access_denied} + end; + Error -> + Error + end. + +%% Список отзывов для цели +list_reviews(UserId, TargetType, TargetId) -> + case core_review:list_by_target(TargetType, TargetId) of + {ok, Reviews} -> + case is_admin(UserId) of + true -> {ok, Reviews}; + false -> {ok, [R || R <- Reviews, R#review.status =:= visible]} + end; + Error -> + Error + end. + +%% Список отзывов пользователя +list_user_reviews(UserId) -> + core_review:list_by_user(UserId). + +%% Обновление отзыва (только автор) +update_review(UserId, ReviewId, Updates) -> + io:format("Updating review ~p with ~p~n", [ReviewId, Updates]), + case core_review:get_by_id(ReviewId) of + {ok, Review} -> + case Review#review.user_id =:= UserId of + true -> + % Преобразуем бинарные ключи в атомы + ValidUpdates = lists:filtermap(fun + ({<<"rating">>, V}) when is_integer(V), V >= 1, V =< 5 -> {true, {rating, V}}; + ({rating, V}) when is_integer(V), V >= 1, V =< 5 -> {true, {rating, V}}; + ({<<"comment">>, V}) when is_binary(V) -> {true, {comment, V}}; + ({comment, V}) when is_binary(V) -> {true, {comment, V}}; + (_) -> false + end, Updates), + io:format("Valid updates: ~p~n", [ValidUpdates]), + case ValidUpdates of + [] -> {error, no_valid_updates}; + _ -> + case core_review:update(ReviewId, ValidUpdates) of + {ok, Updated} -> + update_target_rating(Review#review.target_type, Review#review.target_id), + {ok, Updated}; + Error -> Error + end + end; + false -> {error, access_denied} + end; + Error -> Error + end. + +%% Удаление отзыва (автор или админ) +delete_review(UserId, ReviewId) -> + case core_review:get_by_id(ReviewId) of + {ok, Review} -> + case Review#review.user_id =:= UserId orelse is_admin(UserId) of + true -> + TargetType = Review#review.target_type, + TargetId = Review#review.target_id, + case core_review:delete(ReviewId) of + {ok, deleted} -> + update_target_rating(TargetType, TargetId), + {ok, deleted}; + Error -> + Error + end; + false -> + {error, access_denied} + end; + Error -> + Error + end. + +hide_review(UserId, ReviewId) -> + case can_moderate_review(UserId, ReviewId) of + true -> + case core_review:hide(ReviewId) of + {ok, Review} -> + update_target_rating(Review#review.target_type, Review#review.target_id), + {ok, Review}; + Error -> + Error + end; + false -> + {error, access_denied} + end. + +unhide_review(UserId, ReviewId) -> + case can_moderate_review(UserId, ReviewId) of + true -> + case core_review:unhide(ReviewId) of + {ok, Review} -> + update_target_rating(Review#review.target_type, Review#review.target_id), + {ok, Review}; + Error -> + Error + end; + false -> + {error, access_denied} + end. + +%% Проверка, может ли пользователь оставить отзыв +can_review(UserId, TargetType, TargetId) -> + case TargetType of + event -> + case core_booking:get_by_event_and_user(TargetId, UserId) of + {ok, Booking} -> + {ok, Booking#booking.status =:= confirmed}; + {error, not_found} -> + {ok, false} + end; + calendar -> + {ok, true}; + _ -> + {ok, false} + end. + +%% Проверка, может ли пользователь модерировать отзыв (скрыть/раскрыть) +can_moderate_review(UserId, ReviewId) -> + case is_admin(UserId) of + true -> + true; + false -> + % Проверяем, является ли пользователь владельцем календаря/события + case core_review:get_by_id(ReviewId) of + {ok, Review} -> + case Review#review.target_type of + event -> + case core_event:get_by_id(Review#review.target_id) of + {ok, Event} -> + case core_calendar:get_by_id(Event#event.calendar_id) of + {ok, Calendar} -> Calendar#calendar.owner_id =:= UserId; + _ -> false + end; + _ -> false + end; + calendar -> + case core_calendar:get_by_id(Review#review.target_id) of + {ok, Calendar} -> Calendar#calendar.owner_id =:= UserId; + _ -> false + end; + _ -> false + end; + _ -> false + end + end. + +%% Внутренние функции +target_exists(event, EventId) -> + case core_event:get_by_id(EventId) of + {ok, _} -> true; + _ -> false + end; +target_exists(calendar, CalendarId) -> + case core_calendar:get_by_id(CalendarId) of + {ok, _} -> true; + _ -> false + end; +target_exists(_, _) -> false. + +is_admin(UserId) -> + case core_user:get_by_id(UserId) of + {ok, User} -> User#user.role =:= admin; + _ -> false + end. + +update_target_rating(event, EventId) -> + {Avg, Count} = core_review:get_average_rating(event, EventId), + io:format("Updating event ~p rating: avg=~p, count=~p~n", [EventId, Avg, Count]), + core_event:update(EventId, [{rating_avg, Avg}, {rating_count, Count}]); +update_target_rating(calendar, CalendarId) -> + {Avg, Count} = core_review:get_average_rating(calendar, CalendarId), + core_calendar:update(CalendarId, [{rating_avg, Avg}, {rating_count, Count}]); +update_target_rating(_, _) -> ok. \ No newline at end of file diff --git a/test/core_review_tests.erl b/test/core_review_tests.erl new file mode 100644 index 0000000..aa4e222 --- /dev/null +++ b/test/core_review_tests.erl @@ -0,0 +1,131 @@ +-module(core_review_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +setup() -> + mnesia:start(), + mnesia:create_table(review, [ + {attributes, record_info(fields, review)}, + {ram_copies, [node()]} + ]), + ok. + +cleanup(_) -> + mnesia:delete_table(review), + mnesia:stop(), + ok. + +core_review_test_() -> + {foreach, + fun setup/0, + fun cleanup/1, + [ + {"Create review test", fun test_create_review/0}, + {"Get review by id test", fun test_get_by_id/0}, + {"List reviews by target test", fun test_list_by_target/0}, + {"List reviews by user test", fun test_list_by_user/0}, + {"Update review test", fun test_update_review/0}, + {"Delete review test", fun test_delete_review/0}, + {"Hide/unhide review test", fun test_hide_unhide/0}, + {"Average rating test", fun test_average_rating/0}, + {"Has user reviewed test", fun test_has_user_reviewed/0} + ]}. + +test_create_review() -> + UserId = <<"user123">>, + TargetType = event, + TargetId = <<"event123">>, + Rating = 5, + Comment = <<"Great event!">>, + + {ok, Review} = core_review:create(UserId, TargetType, TargetId, Rating, Comment), + + ?assertEqual(UserId, Review#review.user_id), + ?assertEqual(TargetType, Review#review.target_type), + ?assertEqual(TargetId, Review#review.target_id), + ?assertEqual(Rating, Review#review.rating), + ?assertEqual(Comment, Review#review.comment), + ?assertEqual(visible, Review#review.status), + ?assert(is_binary(Review#review.id)). + +test_get_by_id() -> + UserId = <<"user123">>, + {ok, Review} = core_review:create(UserId, event, <<"ev1">>, 4, <<"Good">>), + + {ok, Found} = core_review:get_by_id(Review#review.id), + ?assertEqual(Review#review.id, Found#review.id), + + {error, not_found} = core_review:get_by_id(<<"nonexistent">>). + +test_list_by_target() -> + UserId1 = <<"user1">>, + UserId2 = <<"user2">>, + EventId = <<"event123">>, + + {ok, _} = core_review:create(UserId1, event, EventId, 5, <<"Awesome">>), + {ok, _} = core_review:create(UserId2, event, EventId, 3, <<"Okay">>), + {ok, _} = core_review:create(UserId1, calendar, <<"cal1">>, 4, <<"Nice">>), + + {ok, Reviews} = core_review:list_by_target(event, EventId), + ?assertEqual(2, length(Reviews)). + +test_list_by_user() -> + UserId1 = <<"user1">>, + UserId2 = <<"user2">>, + + {ok, _} = core_review:create(UserId1, event, <<"ev1">>, 5, <<"">>), + {ok, _} = core_review:create(UserId1, event, <<"ev2">>, 4, <<"">>), + {ok, _} = core_review:create(UserId2, event, <<"ev3">>, 3, <<"">>), + + {ok, Reviews} = core_review:list_by_user(UserId1), + ?assertEqual(2, length(Reviews)). + +test_update_review() -> + UserId = <<"user123">>, + {ok, Review} = core_review:create(UserId, event, <<"ev1">>, 3, <<"Old">>), + + timer:sleep(2000), + Updates = [{rating, 5}, {comment, <<"Updated!">>}], + {ok, Updated} = core_review:update(Review#review.id, Updates), + + ?assertEqual(5, Updated#review.rating), + ?assertEqual(<<"Updated!">>, Updated#review.comment), + ?assert(Updated#review.updated_at > Review#review.updated_at). + +test_delete_review() -> + UserId = <<"user123">>, + {ok, Review} = core_review:create(UserId, event, <<"ev1">>, 4, <<"">>), + + {ok, deleted} = core_review:delete(Review#review.id), + {error, not_found} = core_review:get_by_id(Review#review.id). + +test_hide_unhide() -> + UserId = <<"user123">>, + {ok, Review} = core_review:create(UserId, event, <<"ev1">>, 4, <<"">>), + + {ok, Hidden} = core_review:hide(Review#review.id), + ?assertEqual(hidden, Hidden#review.status), + + {ok, Unhidden} = core_review:unhide(Review#review.id), + ?assertEqual(visible, Unhidden#review.status). + +test_average_rating() -> + UserId1 = <<"user1">>, + UserId2 = <<"user2">>, + EventId = <<"event123">>, + + {ok, _} = core_review:create(UserId1, event, EventId, 5, <<"">>), + {ok, _} = core_review:create(UserId2, event, EventId, 3, <<"">>), + + {Avg, Count} = core_review:get_average_rating(event, EventId), + ?assertEqual(4.0, Avg), + ?assertEqual(2, Count). + +test_has_user_reviewed() -> + UserId = <<"user123">>, + EventId = <<"event123">>, + + ?assertNot(core_review:has_user_reviewed(UserId, event, EventId)), + + {ok, _} = core_review:create(UserId, event, EventId, 5, <<"">>), + ?assert(core_review:has_user_reviewed(UserId, event, EventId)). \ No newline at end of file diff --git a/test/logic_review_tests.erl b/test/logic_review_tests.erl new file mode 100644 index 0000000..4eba104 --- /dev/null +++ b/test/logic_review_tests.erl @@ -0,0 +1,161 @@ +-module(logic_review_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +setup() -> + mnesia:start(), + mnesia:create_table(user, [{attributes, record_info(fields, user)}, {ram_copies, [node()]}]), + mnesia:create_table(calendar, [{attributes, record_info(fields, calendar)}, {ram_copies, [node()]}]), + mnesia:create_table(event, [{attributes, record_info(fields, event)}, {ram_copies, [node()]}]), + mnesia:create_table(booking, [{attributes, record_info(fields, booking)}, {ram_copies, [node()]}]), + mnesia:create_table(review, [{attributes, record_info(fields, review)}, {ram_copies, [node()]}]), + ok. + +cleanup(_) -> + mnesia:delete_table(review), + mnesia:delete_table(booking), + mnesia:delete_table(event), + mnesia:delete_table(calendar), + mnesia:delete_table(user), + mnesia:stop(), + ok. + +logic_review_test_() -> + {foreach, + fun setup/0, + fun cleanup/1, + [ + {"Create review for event", fun test_create_event_review/0}, + {"Create review for calendar", fun test_create_calendar_review/0}, + {"Cannot review without booking", fun test_cannot_review_without_booking/0}, + {"Cannot review twice", fun test_cannot_review_twice/0}, + {"Update own review", fun test_update_own_review/0}, + {"Cannot update others review", fun test_cannot_update_others/0}, + {"Delete own review", fun test_delete_own_review/0}, + {"Admin can hide review", fun test_admin_hide_review/0}, + {"Rating updates target", fun test_rating_updates_target/0} + ]}. + +create_test_user(Role) -> + UserId = base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}), + User = #user{id = UserId, email = <>, password_hash = <<"hash">>, + role = Role, status = active, created_at = calendar:universal_time(), updated_at = calendar:universal_time()}, + mnesia:dirty_write(User), + UserId. + +create_test_calendar(OwnerId) -> + {ok, Calendar} = core_calendar:create(OwnerId, <<"Test">>, <<"">>, manual), + Calendar#calendar.id. + +create_test_event(CalendarId) -> + {ok, Event} = core_event:create(CalendarId, <<"Event">>, {{2026, 6, 1}, {10, 0, 0}}, 60), + Event#event.id. + +create_booking(UserId, EventId) -> + {ok, Booking} = core_booking:create(EventId, UserId), + core_booking:update_status(Booking#booking.id, confirmed), + Booking#booking.id. + +test_create_event_review() -> + OwnerId = create_test_user(user), + ParticipantId = create_test_user(user), + CalendarId = create_test_calendar(OwnerId), + EventId = create_test_event(CalendarId), + create_booking(ParticipantId, EventId), + + {ok, Review} = logic_review:create_review(ParticipantId, event, EventId, 5, <<"Great!">>), + ?assertEqual(5, Review#review.rating), + + % Проверяем, что рейтинг события обновился + {ok, Event} = core_event:get_by_id(EventId), + ?assertEqual(5.0, Event#event.rating_avg), + ?assertEqual(1, Event#event.rating_count). + +test_create_calendar_review() -> + OwnerId = create_test_user(user), + ReviewerId = create_test_user(user), + CalendarId = create_test_calendar(OwnerId), + + {ok, Review} = logic_review:create_review(ReviewerId, calendar, CalendarId, 4, <<"Nice">>), + ?assertEqual(4, Review#review.rating). + +test_cannot_review_without_booking() -> + OwnerId = create_test_user(user), + UserId = create_test_user(user), + CalendarId = create_test_calendar(OwnerId), + EventId = create_test_event(CalendarId), + + {error, cannot_review} = logic_review:create_review(UserId, event, EventId, 5, <<"">>). + +test_cannot_review_twice() -> + OwnerId = create_test_user(user), + ParticipantId = create_test_user(user), + CalendarId = create_test_calendar(OwnerId), + EventId = create_test_event(CalendarId), + create_booking(ParticipantId, EventId), + + {ok, _} = logic_review:create_review(ParticipantId, event, EventId, 5, <<"">>), + {error, already_reviewed} = logic_review:create_review(ParticipantId, event, EventId, 4, <<"">>). + +test_update_own_review() -> + OwnerId = create_test_user(user), + ParticipantId = create_test_user(user), + CalendarId = create_test_calendar(OwnerId), + EventId = create_test_event(CalendarId), + create_booking(ParticipantId, EventId), + + {ok, Review} = logic_review:create_review(ParticipantId, event, EventId, 3, <<"">>), + {ok, Updated} = logic_review:update_review(ParticipantId, Review#review.id, [{rating, 5}]), + ?assertEqual(5, Updated#review.rating). + +test_cannot_update_others() -> + OwnerId = create_test_user(user), + ParticipantId = create_test_user(user), + OtherId = create_test_user(user), + CalendarId = create_test_calendar(OwnerId), + EventId = create_test_event(CalendarId), + create_booking(ParticipantId, EventId), + + {ok, Review} = logic_review:create_review(ParticipantId, event, EventId, 3, <<"">>), + {error, access_denied} = logic_review:update_review(OtherId, Review#review.id, [{rating, 5}]). + +test_delete_own_review() -> + OwnerId = create_test_user(user), + ParticipantId = create_test_user(user), + CalendarId = create_test_calendar(OwnerId), + EventId = create_test_event(CalendarId), + create_booking(ParticipantId, EventId), + + {ok, Review} = logic_review:create_review(ParticipantId, event, EventId, 3, <<"">>), + {ok, deleted} = logic_review:delete_review(ParticipantId, Review#review.id). + +test_admin_hide_review() -> + OwnerId = create_test_user(user), + ParticipantId = create_test_user(user), + AdminId = create_test_user(admin), + CalendarId = create_test_calendar(OwnerId), + EventId = create_test_event(CalendarId), + create_booking(ParticipantId, EventId), + + {ok, Review} = logic_review:create_review(ParticipantId, event, EventId, 3, <<"Bad">>), + {ok, Hidden} = logic_review:hide_review(AdminId, Review#review.id), + ?assertEqual(hidden, Hidden#review.status). + +test_rating_updates_target() -> + OwnerId = create_test_user(user), + P1 = create_test_user(user), + P2 = create_test_user(user), + CalendarId = create_test_calendar(OwnerId), + EventId = create_test_event(CalendarId), + create_booking(P1, EventId), + create_booking(P2, EventId), + + {ok, _} = logic_review:create_review(P1, event, EventId, 5, <<"">>), + {ok, Event1} = core_event:get_by_id(EventId), + ?assertEqual(5.0, Event1#event.rating_avg), + ?assertEqual(1, Event1#event.rating_count), + + {ok, _} = logic_review:create_review(P2, event, EventId, 3, <<"">>), + {ok, Event2} = core_event:get_by_id(EventId), + ?assertEqual(4.0, Event2#event.rating_avg), + ?assertEqual(2, Event2#event.rating_count). \ No newline at end of file diff --git a/test/scripts/test_reviews_api.sh b/test/scripts/test_reviews_api.sh new file mode 100644 index 0000000..9863273 --- /dev/null +++ b/test/scripts/test_reviews_api.sh @@ -0,0 +1,454 @@ +#!/bin/bash + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +BASE_URL="http://localhost:8080" + +log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +extract_json() { + echo "$1" | grep -o "\"$2\":\"[^\"]*\"" | head -1 | sed "s/\"$2\":\"//;s/\"$//" +} + +extract_json_number() { + echo "$1" | grep -o "\"$2\":[0-9.]*" | head -1 | sed "s/\"$2\"://" +} + +http_post() { + local url=$1; local data=$2; local token=$3 + if [ -n "$token" ]; then + curl -s -X POST "$url" -H "Content-Type: application/json" -H "Authorization: Bearer $token" -d "$data" + else + curl -s -X POST "$url" -H "Content-Type: application/json" -d "$data" + fi +} + +http_get() { + local url=$1; local token=$2 + if [ -n "$token" ]; then + curl -s -X GET "$url" -H "Authorization: Bearer $token" + else + curl -s -X GET "$url" + fi +} + +http_put() { + local url=$1; local data=$2; local token=$3 + curl -s -X PUT "$url" -H "Content-Type: application/json" -H "Authorization: Bearer $token" -d "$data" +} + +http_delete() { + local url=$1; local token=$2 + curl -s -X DELETE "$url" -H "Authorization: Bearer $token" +} + +echo "============================================================" +echo " EVENTHUB REVIEWS API TEST SCRIPT" +echo "============================================================" +echo "" + +log_info "Checking if server is running..." +if ! curl -s "$BASE_URL/health" | grep -q "ok"; then + log_error "Server is not running" + exit 1 +fi +log_success "Server is running" + +echo "" +log_info "============================================================" +log_info "STEP 1: Create test users" +log_info "============================================================" + +# Админ (создаётся первым) +ADMIN_EMAIL="admin_$(date +%s)@example.com" +ADMIN_PASSWORD="admin123" + +log_info "Creating admin user (first user)..." +response=$(http_post "$BASE_URL/v1/register" "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASSWORD\"}" "") +ADMIN_TOKEN=$(extract_json "$response" "token") +ADMIN_ID=$(extract_json "$response" "id") +log_success "Admin created: $ADMIN_EMAIL" + +# Владелец календаря +OWNER_EMAIL="review_owner_$(date +%s)@example.com" +OWNER_PASSWORD="owner123" + +log_info "Creating calendar owner..." +response=$(http_post "$BASE_URL/v1/register" "{\"email\":\"$OWNER_EMAIL\",\"password\":\"$OWNER_PASSWORD\"}" "") +OWNER_TOKEN=$(extract_json "$response" "token") +OWNER_ID=$(extract_json "$response" "id") +log_success "Owner created: $OWNER_EMAIL" + +# Участник 1 +PARTICIPANT1_EMAIL="review_p1_$(date +%s)@example.com" +PARTICIPANT1_PASSWORD="p1_123" + +log_info "Creating participant 1..." +response=$(http_post "$BASE_URL/v1/register" "{\"email\":\"$PARTICIPANT1_EMAIL\",\"password\":\"$PARTICIPANT1_PASSWORD\"}" "") +PARTICIPANT1_TOKEN=$(extract_json "$response" "token") +PARTICIPANT1_ID=$(extract_json "$response" "id") +log_success "Participant 1 created" + +# Участник 2 +PARTICIPANT2_EMAIL="review_p2_$(date +%s)@example.com" +PARTICIPANT2_PASSWORD="p2_123" + +log_info "Creating participant 2..." +response=$(http_post "$BASE_URL/v1/register" "{\"email\":\"$PARTICIPANT2_EMAIL\",\"password\":\"$PARTICIPANT2_PASSWORD\"}" "") +PARTICIPANT2_TOKEN=$(extract_json "$response" "token") +PARTICIPANT2_ID=$(extract_json "$response" "id") +log_success "Participant 2 created" + +# Сторонний пользователь (без бронирований) +OTHER_EMAIL="review_other_$(date +%s)@example.com" +OTHER_PASSWORD="other123" + +log_info "Creating other user (no bookings)..." +response=$(http_post "$BASE_URL/v1/register" "{\"email\":\"$OTHER_EMAIL\",\"password\":\"$OTHER_PASSWORD\"}" "") +OTHER_TOKEN=$(extract_json "$response" "token") +OTHER_ID=$(extract_json "$response" "id") +log_success "Other user created" + +echo "" +log_info "============================================================" +log_info "STEP 2: Create calendar and events" +log_info "============================================================" + +log_info "Creating calendar..." +response=$(http_post "$BASE_URL/v1/calendars" \ + "{\"title\":\"Review Test Calendar\",\"description\":\"Calendar for review tests\"}" "$OWNER_TOKEN") +CALENDAR_ID=$(extract_json "$response" "id") +log_success "Calendar created: $CALENDAR_ID" + +log_info "Creating event..." +EVENT_START="2026-06-01T10:00:00Z" +response=$(http_post "$BASE_URL/v1/calendars/$CALENDAR_ID/events" \ + "{\"title\":\"Test Event\",\"start_time\":\"$EVENT_START\",\"duration\":60,\"capacity\":10}" "$OWNER_TOKEN") +EVENT_ID=$(extract_json "$response" "id") +log_success "Event created: $EVENT_ID" + +log_info "Creating second event..." +response=$(http_post "$BASE_URL/v1/calendars/$CALENDAR_ID/events" \ + "{\"title\":\"Test Event 2\",\"start_time\":\"$EVENT_START\",\"duration\":60}" "$OWNER_TOKEN") +EVENT2_ID=$(extract_json "$response" "id") +log_success "Second event created: $EVENT2_ID" + +echo "" +log_info "============================================================" +log_info "STEP 3: Create bookings" +log_info "============================================================" + +log_info "Participant 1 booking event..." +response=$(http_post "$BASE_URL/v1/events/$EVENT_ID/bookings" "" "$PARTICIPANT1_TOKEN") +BOOKING1_ID=$(extract_json "$response" "id") +log_success "Booking created: $BOOKING1_ID" + +log_info "Owner confirming participant 1 booking..." +response=$(http_put "$BASE_URL/v1/bookings/$BOOKING1_ID" "{\"action\":\"confirm\"}" "$OWNER_TOKEN") +log_success "Booking confirmed" + +log_info "Participant 2 booking event..." +response=$(http_post "$BASE_URL/v1/events/$EVENT_ID/bookings" "" "$PARTICIPANT2_TOKEN") +BOOKING2_ID=$(extract_json "$response" "id") +log_success "Booking created: $BOOKING2_ID" + +log_info "Owner confirming participant 2 booking..." +response=$(http_put "$BASE_URL/v1/bookings/$BOOKING2_ID" "{\"action\":\"confirm\"}" "$OWNER_TOKEN") +log_success "Booking confirmed" + +echo "" +log_info "============================================================" +log_info "TEST 1: Create review for event (participant)" +log_info "============================================================" + +log_info "Participant 1 creating review..." +response=$(http_post "$BASE_URL/v1/reviews" \ + "{\"target_type\":\"event\",\"target_id\":\"$EVENT_ID\",\"rating\":5,\"comment\":\"Excellent event!\"}" "$PARTICIPANT1_TOKEN") +REVIEW1_ID=$(extract_json "$response" "id") + +if [ -n "$REVIEW1_ID" ]; then + log_success "Review created: $REVIEW1_ID" +else + log_error "Failed to create review: $response" +fi + +echo "" +log_info "============================================================" +log_info "TEST 2: Create review for event (second participant)" +log_info "============================================================" + +log_info "Participant 2 creating review..." +response=$(http_post "$BASE_URL/v1/reviews" \ + "{\"target_type\":\"event\",\"target_id\":\"$EVENT_ID\",\"rating\":3,\"comment\":\"It was okay\"}" "$PARTICIPANT2_TOKEN") +REVIEW2_ID=$(extract_json "$response" "id") + +if [ -n "$REVIEW2_ID" ]; then + log_success "Review created: $REVIEW2_ID" +else + log_error "Failed to create review: $response" +fi + +echo "" +log_info "============================================================" +log_info "TEST 3: Cannot review twice" +log_info "============================================================" + +log_info "Participant 1 trying to review again..." +response=$(http_post "$BASE_URL/v1/reviews" \ + "{\"target_type\":\"event\",\"target_id\":\"$EVENT_ID\",\"rating\":4,\"comment\":\"Trying again\"}" "$PARTICIPANT1_TOKEN") + +if echo "$response" | grep -q "Already reviewed"; then + log_success "Duplicate review correctly rejected" +else + log_error "Duplicate review not rejected: $response" +fi + +echo "" +log_info "============================================================" +log_info "TEST 4: Cannot review without booking" +log_info "============================================================" + +log_info "Other user trying to review event..." +response=$(http_post "$BASE_URL/v1/reviews" \ + "{\"target_type\":\"event\",\"target_id\":\"$EVENT_ID\",\"rating\":5,\"comment\":\"Wasn't there\"}" "$OTHER_TOKEN") + +if echo "$response" | grep -q "Cannot review"; then + log_success "Review without booking correctly rejected" +else + log_error "Review without booking not rejected: $response" +fi + +echo "" +log_info "============================================================" +log_info "TEST 5: Create review for calendar" +log_info "============================================================" + +log_info "Other user creating calendar review..." +response=$(http_post "$BASE_URL/v1/reviews" \ + "{\"target_type\":\"calendar\",\"target_id\":\"$CALENDAR_ID\",\"rating\":4,\"comment\":\"Nice calendar!\"}" "$OTHER_TOKEN") +CALENDAR_REVIEW_ID=$(extract_json "$response" "id") + +if [ -n "$CALENDAR_REVIEW_ID" ]; then + log_success "Calendar review created: $CALENDAR_REVIEW_ID" +else + log_error "Failed to create calendar review: $response" +fi + +echo "" +log_info "============================================================" +log_info "TEST 6: Get reviews for event" +log_info "============================================================" + +log_info "Getting reviews for event..." +response=$(http_get "$BASE_URL/v1/reviews?target_type=event&target_id=$EVENT_ID" "$PARTICIPANT1_TOKEN") +REVIEW_COUNT=$(echo "$response" | grep -o "\"id\"" | wc -l) + +if [ "$REVIEW_COUNT" -eq 2 ]; then + log_success "Found $REVIEW_COUNT reviews for event" +else + log_error "Expected 2 reviews, found $REVIEW_COUNT" +fi + +sleep 1 + +echo "" +log_info "============================================================" +log_info "TEST 7: Check event rating updated" +log_info "============================================================" + +log_info "Checking event rating..." +response=$(http_get "$BASE_URL/v1/events/$EVENT_ID" "$OWNER_TOKEN") +RATING_AVG=$(extract_json_number "$response" "rating_avg") +RATING_COUNT=$(extract_json_number "$response" "rating_count") + +if [ "$RATING_AVG" = "4.0" ] && [ "$RATING_COUNT" = "2" ]; then + log_success "Event rating updated: $RATING_AVG ($RATING_COUNT reviews)" +else + log_error "Event rating incorrect: avg=$RATING_AVG, count=$RATING_COUNT" +fi + +echo "" +log_info "============================================================" +log_info "TEST 8: Update own review" +log_info "============================================================" + +log_info "Participant 1 updating review..." +response=$(http_put "$BASE_URL/v1/reviews/$REVIEW1_ID" \ + "{\"rating\":4,\"comment\":\"Updated: Very good!\"}" "$PARTICIPANT1_TOKEN") + +if echo "$response" | grep -q "\"id\""; then + log_success "Review updated" +else + log_error "Review update failed: $response" +fi + +sleep 1 + +log_info "Checking event rating after update..." +response=$(http_get "$BASE_URL/v1/events/$EVENT_ID" "$OWNER_TOKEN") +NEW_RATING_AVG=$(extract_json_number "$response" "rating_avg") + +if [ "$NEW_RATING_AVG" = "3.5" ]; then + log_success "Event rating updated to $NEW_RATING_AVG" +else + log_error "Event rating incorrect: $NEW_RATING_AVG (expected 3.5)" +fi + +echo "" +log_info "============================================================" +log_info "TEST 9: Cannot update others review" +log_info "============================================================" + +log_info "Participant 2 trying to update participant 1 review..." +response=$(http_put "$BASE_URL/v1/reviews/$REVIEW1_ID" \ + "{\"rating\":1,\"comment\":\"Hacked!\"}" "$PARTICIPANT2_TOKEN") + +if echo "$response" | grep -q "Access denied"; then + log_success "Update others review correctly rejected" +else + log_error "Update others review not rejected: $response" +fi + +echo "" +log_info "============================================================" +log_info "TEST 10: Get user reviews" +log_info "============================================================" + +log_info "Getting participant 1 reviews..." +response=$(http_get "$BASE_URL/v1/user/reviews" "$PARTICIPANT1_TOKEN") +USER_REVIEW_COUNT=$(echo "$response" | grep -o "\"id\"" | wc -l) + +if [ "$USER_REVIEW_COUNT" -ge 1 ]; then + log_success "Found $USER_REVIEW_COUNT reviews for user" +else + log_error "User reviews not found" +fi + +echo "" +log_info "============================================================" +log_info "TEST 11: Admin hides review" +log_info "============================================================" + +log_info "Admin hiding review $REVIEW2_ID..." +response=$(http_put "$BASE_URL/v1/admin/reviews/$REVIEW2_ID" \ + "{\"action\":\"hide\"}" "$ADMIN_TOKEN") + +HIDDEN_STATUS=$(extract_json "$response" "status") +if [ "$HIDDEN_STATUS" = "hidden" ]; then + log_success "Review hidden by admin" +else + log_error "Failed to hide review: $response" +fi + +log_info "Participant 1 getting event reviews (hidden should not appear)..." +response=$(http_get "$BASE_URL/v1/reviews?target_type=event&target_id=$EVENT_ID" "$PARTICIPANT1_TOKEN") +VISIBLE_COUNT=$(echo "$response" | grep -o "\"id\"" | wc -l) + +if [ "$VISIBLE_COUNT" -eq 1 ]; then + log_success "Only 1 review visible (hidden filtered out)" +else + log_error "Expected 1 visible review, found $VISIBLE_COUNT" +fi + +log_info "Admin getting event reviews (should see all)..." +response=$(http_get "$BASE_URL/v1/reviews?target_type=event&target_id=$EVENT_ID" "$ADMIN_TOKEN") +ADMIN_COUNT=$(echo "$response" | grep -o "\"id\"" | wc -l) + +if [ "$ADMIN_COUNT" -eq 2 ]; then + log_success "Admin sees all 2 reviews" +else + log_error "Admin should see 2 reviews, found $ADMIN_COUNT" +fi + +echo "" +log_info "============================================================" +log_info "TEST 12: Admin unhides review" +log_info "============================================================" + +log_info "Admin unhiding review..." +response=$(http_put "$BASE_URL/v1/admin/reviews/$REVIEW2_ID" \ + "{\"action\":\"unhide\"}" "$ADMIN_TOKEN") + +UNHIDDEN_STATUS=$(extract_json "$response" "status") +if [ "$UNHIDDEN_STATUS" = "visible" ]; then + log_success "Review unhidden by admin" +else + log_error "Failed to unhide review: $response" +fi + +echo "" +log_info "============================================================" +log_info "TEST 13: Delete own review" +log_info "============================================================" + +log_info "Participant 1 deleting review..." +response=$(http_delete "$BASE_URL/v1/reviews/$REVIEW1_ID" "$PARTICIPANT1_TOKEN") + +if echo "$response" | grep -q "deleted"; then + log_success "Review deleted" +else + log_error "Failed to delete review: $response" +fi + +sleep 1 + +log_info "Checking event rating after deletion..." +response=$(http_get "$BASE_URL/v1/events/$EVENT_ID" "$OWNER_TOKEN") +FINAL_RATING_AVG=$(extract_json_number "$response" "rating_avg") +FINAL_RATING_COUNT=$(extract_json_number "$response" "rating_count") + +if [ "$FINAL_RATING_AVG" = "3.0" ] && [ "$FINAL_RATING_COUNT" = "1" ]; then + log_success "Event rating updated: $FINAL_RATING_AVG ($FINAL_RATING_COUNT review)" +else + log_error "Event rating incorrect: avg=$FINAL_RATING_AVG, count=$FINAL_RATING_COUNT" +fi + +echo "" +log_info "============================================================" +log_info "TEST 14: Get deleted review (should fail)" +log_info "============================================================" + +log_info "Trying to get deleted review..." +response=$(http_get "$BASE_URL/v1/reviews/$REVIEW1_ID" "$PARTICIPANT1_TOKEN") + +if echo "$response" | grep -q "not found"; then + log_success "Deleted review not found" +else + log_error "Deleted review still accessible: $response" +fi + +echo "" +log_info "============================================================" +log_info "TEST 15: Calendar rating updated" +log_info "============================================================" + +log_info "Checking calendar rating..." +response=$(http_get "$BASE_URL/v1/calendars/$CALENDAR_ID" "$OWNER_TOKEN") +CAL_RATING_AVG=$(extract_json_number "$response" "rating_avg") +CAL_RATING_COUNT=$(extract_json_number "$response" "rating_count") + +if [ "$CAL_RATING_AVG" = "4.0" ] && [ "$CAL_RATING_COUNT" = "1" ]; then + log_success "Calendar rating: $CAL_RATING_AVG ($CAL_RATING_COUNT review)" +else + log_error "Calendar rating incorrect: avg=$CAL_RATING_AVG, count=$CAL_RATING_COUNT" +fi + +echo "" +echo "============================================================" +log_success "REVIEWS API TESTS COMPLETED!" +echo "============================================================" +echo "" +echo "Summary of created resources:" +echo " Admin: $ADMIN_EMAIL" +echo " Owner: $OWNER_EMAIL" +echo " Participant 1: $PARTICIPANT1_EMAIL" +echo " Participant 2: $PARTICIPANT2_EMAIL" +echo " Calendar: $CALENDAR_ID" +echo " Event: $EVENT_ID" +echo "" \ No newline at end of file