feat: user API голосования за отзывы (like/dislike). Refs EventHub/EventHubBack#47
This commit is contained in:
@@ -183,6 +183,16 @@
|
|||||||
updated_at :: calendar:datetime()
|
updated_at :: calendar:datetime()
|
||||||
}).
|
}).
|
||||||
|
|
||||||
|
%% Голос пользователя за отзыв (уникальность review_id+user_id — в транзакции)
|
||||||
|
-record(review_vote, {
|
||||||
|
id :: binary(),
|
||||||
|
review_id :: binary(),
|
||||||
|
user_id :: binary(),
|
||||||
|
value :: like | dislike,
|
||||||
|
created_at :: calendar:datetime(),
|
||||||
|
updated_at :: calendar:datetime()
|
||||||
|
}).
|
||||||
|
|
||||||
%% ------------------- Жалобы и модерация ------------------------------
|
%% ------------------- Жалобы и модерация ------------------------------
|
||||||
-record(report, {
|
-record(report, {
|
||||||
id :: binary(),
|
id :: binary(),
|
||||||
|
|||||||
@@ -111,8 +111,15 @@ update(Id, Updates) ->
|
|||||||
delete(Id) ->
|
delete(Id) ->
|
||||||
F = fun() ->
|
F = fun() ->
|
||||||
case mnesia:read(review, Id) of
|
case mnesia:read(review, Id) of
|
||||||
[] -> {error, not_found};
|
[] ->
|
||||||
[Review] -> mnesia:delete_object(Review), {ok, deleted}
|
{error, not_found};
|
||||||
|
[Review] ->
|
||||||
|
Votes = mnesia:match_object(#review_vote{review_id = Id, _ = '_'}),
|
||||||
|
lists:foreach(fun(#review_vote{id = VoteId}) ->
|
||||||
|
mnesia:delete({review_vote, VoteId})
|
||||||
|
end, Votes),
|
||||||
|
mnesia:delete_object(Review),
|
||||||
|
{ok, deleted}
|
||||||
end
|
end
|
||||||
end,
|
end,
|
||||||
case mnesia:transaction(F) of
|
case mnesia:transaction(F) of
|
||||||
|
|||||||
@@ -0,0 +1,156 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Хранение голосов пользователей за отзывы (like/dislike).
|
||||||
|
%%% Счётчики на #review{} обновляются в той же транзакции.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-module(core_review_vote).
|
||||||
|
-include("records.hrl").
|
||||||
|
|
||||||
|
-export([upsert/3, delete/2, get_by_user_and_review/2, list_by_user/1,
|
||||||
|
list_by_review/1, delete_by_review/1]).
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Поставить или сменить голос. Идемпотентно при том же value.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec upsert(ReviewId :: binary(), UserId :: binary(), Value :: like | dislike) ->
|
||||||
|
{ok, #review{}, like | dislike} | {error, term()}.
|
||||||
|
upsert(ReviewId, UserId, Value) when Value =:= like; Value =:= dislike ->
|
||||||
|
Now = calendar:universal_time(),
|
||||||
|
F = fun() ->
|
||||||
|
case mnesia:read(review, ReviewId) of
|
||||||
|
[] ->
|
||||||
|
{error, not_found};
|
||||||
|
[#review{status = Status}] when Status =/= visible ->
|
||||||
|
{error, not_found};
|
||||||
|
[#review{user_id = AuthorId}] when AuthorId =:= UserId ->
|
||||||
|
{error, own_review};
|
||||||
|
[Review] ->
|
||||||
|
case find_vote(ReviewId, UserId) of
|
||||||
|
[] ->
|
||||||
|
Vote = #review_vote{
|
||||||
|
id = infra_utils:generate_id(16),
|
||||||
|
review_id = ReviewId,
|
||||||
|
user_id = UserId,
|
||||||
|
value = Value,
|
||||||
|
created_at = Now,
|
||||||
|
updated_at = Now
|
||||||
|
},
|
||||||
|
mnesia:write(Vote),
|
||||||
|
Updated = bump(Review, Value, 1),
|
||||||
|
mnesia:write(Updated),
|
||||||
|
{ok, Updated, Value};
|
||||||
|
[#review_vote{value = Value} = Vote] ->
|
||||||
|
%% тот же голос — идемпотентно
|
||||||
|
mnesia:write(Vote#review_vote{updated_at = Now}),
|
||||||
|
{ok, Review, Value};
|
||||||
|
[#review_vote{value = OldValue} = Vote] ->
|
||||||
|
UpdatedVote = Vote#review_vote{value = Value, updated_at = Now},
|
||||||
|
mnesia:write(UpdatedVote),
|
||||||
|
Updated0 = bump(Review, OldValue, -1),
|
||||||
|
Updated1 = bump(Updated0, Value, 1),
|
||||||
|
mnesia:write(Updated1),
|
||||||
|
{ok, Updated1, Value}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
case mnesia:transaction(F) of
|
||||||
|
{atomic, Result} -> Result;
|
||||||
|
{aborted, Reason} -> {error, Reason}
|
||||||
|
end.
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Снять голос. Идемпотентно, если голоса нет.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec delete(ReviewId :: binary(), UserId :: binary()) ->
|
||||||
|
{ok, #review{}, null} | {error, term()}.
|
||||||
|
delete(ReviewId, UserId) ->
|
||||||
|
F = fun() ->
|
||||||
|
case mnesia:read(review, ReviewId) of
|
||||||
|
[] ->
|
||||||
|
{error, not_found};
|
||||||
|
[#review{status = Status}] when Status =/= visible ->
|
||||||
|
{error, not_found};
|
||||||
|
[#review{user_id = AuthorId}] when AuthorId =:= UserId ->
|
||||||
|
{error, own_review};
|
||||||
|
[Review] ->
|
||||||
|
case find_vote(ReviewId, UserId) of
|
||||||
|
[] ->
|
||||||
|
{ok, Review, null};
|
||||||
|
[#review_vote{value = Value, id = VoteId}] ->
|
||||||
|
mnesia:delete({review_vote, VoteId}),
|
||||||
|
Updated = bump(Review, Value, -1),
|
||||||
|
mnesia:write(Updated),
|
||||||
|
{ok, Updated, null}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
case mnesia:transaction(F) of
|
||||||
|
{atomic, Result} -> Result;
|
||||||
|
{aborted, Reason} -> {error, Reason}
|
||||||
|
end.
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Голос пользователя за конкретный отзыв.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec get_by_user_and_review(UserId :: binary(), ReviewId :: binary()) ->
|
||||||
|
{ok, #review_vote{}} | {error, not_found}.
|
||||||
|
get_by_user_and_review(UserId, ReviewId) ->
|
||||||
|
case mnesia:dirty_match_object(
|
||||||
|
#review_vote{review_id = ReviewId, user_id = UserId, _ = '_'}) of
|
||||||
|
[] -> {error, not_found};
|
||||||
|
[Vote] -> {ok, Vote};
|
||||||
|
[Vote | _] -> {ok, Vote}
|
||||||
|
end.
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Все голоса пользователя.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec list_by_user(UserId :: binary()) -> [#review_vote{}].
|
||||||
|
list_by_user(UserId) ->
|
||||||
|
mnesia:dirty_match_object(#review_vote{user_id = UserId, _ = '_'}).
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Все голоса за отзыв.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec list_by_review(ReviewId :: binary()) -> [#review_vote{}].
|
||||||
|
list_by_review(ReviewId) ->
|
||||||
|
mnesia:dirty_match_object(#review_vote{review_id = ReviewId, _ = '_'}).
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Удалить все голоса отзыва (при физическом удалении review).
|
||||||
|
%%% Вызывать внутри транзакции или отдельно.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec delete_by_review(ReviewId :: binary()) -> ok.
|
||||||
|
delete_by_review(ReviewId) ->
|
||||||
|
F = fun() ->
|
||||||
|
lists:foreach(fun(#review_vote{id = Id}) ->
|
||||||
|
mnesia:delete({review_vote, Id})
|
||||||
|
end, list_by_review_tx(ReviewId)),
|
||||||
|
ok
|
||||||
|
end,
|
||||||
|
case mnesia:transaction(F) of
|
||||||
|
{atomic, ok} -> ok;
|
||||||
|
{aborted, Reason} -> error({delete_votes_failed, Reason})
|
||||||
|
end.
|
||||||
|
|
||||||
|
%%%===================================================================
|
||||||
|
%%% Внутренние
|
||||||
|
%%%===================================================================
|
||||||
|
|
||||||
|
find_vote(ReviewId, UserId) ->
|
||||||
|
mnesia:match_object(#review_vote{review_id = ReviewId, user_id = UserId, _ = '_'}).
|
||||||
|
|
||||||
|
list_by_review_tx(ReviewId) ->
|
||||||
|
mnesia:match_object(#review_vote{review_id = ReviewId, _ = '_'}).
|
||||||
|
|
||||||
|
-spec bump(#review{}, like | dislike, integer()) -> #review{}.
|
||||||
|
bump(#review{likes = L} = R, like, Delta) ->
|
||||||
|
R#review{likes = max(0, L + Delta), updated_at = calendar:universal_time()};
|
||||||
|
bump(#review{dislikes = D} = R, dislike, Delta) ->
|
||||||
|
R#review{dislikes = max(0, D + Delta), updated_at = calendar:universal_time()}.
|
||||||
@@ -102,6 +102,7 @@ start_http() ->
|
|||||||
{"/v1/bookings/:id", handler_booking_by_id, []},
|
{"/v1/bookings/:id", handler_booking_by_id, []},
|
||||||
{"/v1/reviews", handler_reviews, []},
|
{"/v1/reviews", handler_reviews, []},
|
||||||
{"/v1/reviews/:id", handler_review_by_id, []},
|
{"/v1/reviews/:id", handler_review_by_id, []},
|
||||||
|
{"/v1/reviews/:id/vote", handler_review_vote, []},
|
||||||
{"/v1/reports", handler_reports, []},
|
{"/v1/reports", handler_reports, []},
|
||||||
{"/v1/tickets", handler_tickets, []},
|
{"/v1/tickets", handler_tickets, []},
|
||||||
{"/v1/tickets/:id", handler_ticket_by_id, []},
|
{"/v1/tickets/:id", handler_ticket_by_id, []},
|
||||||
|
|||||||
@@ -92,6 +92,7 @@ review_schema() ->
|
|||||||
reason => #{type => string, nullable => true},
|
reason => #{type => string, nullable => true},
|
||||||
likes => #{type => integer},
|
likes => #{type => integer},
|
||||||
dislikes => #{type => integer},
|
dislikes => #{type => integer},
|
||||||
|
my_vote => #{type => string, enum => [<<"like">>, <<"dislike">>, null], nullable => true},
|
||||||
created_at => #{type => string, format => <<"date-time">>},
|
created_at => #{type => string, format => <<"date-time">>},
|
||||||
updated_at => #{type => string, format => <<"date-time">>}
|
updated_at => #{type => string, format => <<"date-time">>}
|
||||||
}
|
}
|
||||||
@@ -128,7 +129,8 @@ get_review(Req) ->
|
|||||||
ReviewId = cowboy_req:binding(id, Req1),
|
ReviewId = cowboy_req:binding(id, Req1),
|
||||||
case logic_review:get_review(UserId, ReviewId) of
|
case logic_review:get_review(UserId, ReviewId) of
|
||||||
{ok, Review} ->
|
{ok, Review} ->
|
||||||
handler_utils:send_json(Req1, 200, handler_utils:review_to_json(Review));
|
MyVote = logic_review:my_vote(UserId, ReviewId),
|
||||||
|
handler_utils:send_json(Req1, 200, handler_utils:review_to_json(Review, MyVote));
|
||||||
{error, access_denied} ->
|
{error, access_denied} ->
|
||||||
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
@@ -154,7 +156,8 @@ update_review(Req) ->
|
|||||||
{ok, _} ->
|
{ok, _} ->
|
||||||
case core_review:get_by_id(ReviewId) of
|
case core_review:get_by_id(ReviewId) of
|
||||||
{ok, Updated} ->
|
{ok, Updated} ->
|
||||||
handler_utils:send_json(Req2, 200, handler_utils:review_to_json(Updated));
|
MyVote = logic_review:my_vote(UserId, ReviewId),
|
||||||
|
handler_utils:send_json(Req2, 200, handler_utils:review_to_json(Updated, MyVote));
|
||||||
_ ->
|
_ ->
|
||||||
handler_utils:send_error(Req2, 500, <<"Failed to retrieve updated review">>)
|
handler_utils:send_error(Req2, 500, <<"Failed to retrieve updated review">>)
|
||||||
end;
|
end;
|
||||||
|
|||||||
@@ -0,0 +1,169 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Голосование за отзыв (клиентский API).
|
||||||
|
%%%
|
||||||
|
%%% PUT /v1/reviews/:id/vote — поставить / сменить like|dislike
|
||||||
|
%%% DELETE /v1/reviews/:id/vote — снять голос
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-module(handler_review_vote).
|
||||||
|
-behaviour(cowboy_handler).
|
||||||
|
|
||||||
|
-export([init/2]).
|
||||||
|
-export([trails/0]).
|
||||||
|
|
||||||
|
-include("records.hrl").
|
||||||
|
|
||||||
|
%%% cowboy_handler callback
|
||||||
|
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||||
|
init(Req, Opts) ->
|
||||||
|
handle(Req, Opts).
|
||||||
|
|
||||||
|
%%% Swagger metadata
|
||||||
|
-spec trails() -> [map()].
|
||||||
|
trails() ->
|
||||||
|
BaseParams = [
|
||||||
|
#{
|
||||||
|
name => <<"id">>,
|
||||||
|
in => <<"path">>,
|
||||||
|
description => <<"Review ID">>,
|
||||||
|
required => true,
|
||||||
|
schema => #{type => string}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
VoteResponse = #{
|
||||||
|
type => object,
|
||||||
|
properties => #{
|
||||||
|
review_id => #{type => string},
|
||||||
|
my_vote => #{type => string, enum => [<<"like">>, <<"dislike">>, null], nullable => true},
|
||||||
|
likes => #{type => integer, minimum => 0},
|
||||||
|
dislikes => #{type => integer, minimum => 0}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
#{
|
||||||
|
path => <<"/v1/reviews/:id/vote">>,
|
||||||
|
method => <<"PUT">>,
|
||||||
|
description => <<"Put or change vote (like/dislike) on a review">>,
|
||||||
|
tags => [<<"Reviews">>],
|
||||||
|
parameters => BaseParams,
|
||||||
|
requestBody => #{
|
||||||
|
required => true,
|
||||||
|
content => #{<<"application/json">> => #{schema => #{
|
||||||
|
type => object,
|
||||||
|
required => [<<"value">>],
|
||||||
|
properties => #{
|
||||||
|
value => #{type => string, enum => [<<"like">>, <<"dislike">>]}
|
||||||
|
}
|
||||||
|
}}}
|
||||||
|
},
|
||||||
|
responses => #{
|
||||||
|
200 => #{
|
||||||
|
description => <<"Vote applied">>,
|
||||||
|
content => #{<<"application/json">> => #{schema => VoteResponse}}
|
||||||
|
},
|
||||||
|
400 => #{description => <<"Invalid body">>},
|
||||||
|
401 => #{description => <<"Unauthorized">>},
|
||||||
|
403 => #{description => <<"Cannot vote on own review">>},
|
||||||
|
404 => #{description => <<"Review not found or unavailable">>}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
#{
|
||||||
|
path => <<"/v1/reviews/:id/vote">>,
|
||||||
|
method => <<"DELETE">>,
|
||||||
|
description => <<"Remove vote from a review">>,
|
||||||
|
tags => [<<"Reviews">>],
|
||||||
|
parameters => BaseParams,
|
||||||
|
responses => #{
|
||||||
|
200 => #{
|
||||||
|
description => <<"Vote removed">>,
|
||||||
|
content => #{<<"application/json">> => #{schema => VoteResponse}}
|
||||||
|
},
|
||||||
|
401 => #{description => <<"Unauthorized">>},
|
||||||
|
403 => #{description => <<"Cannot vote on own review">>},
|
||||||
|
404 => #{description => <<"Review not found or unavailable">>}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
].
|
||||||
|
|
||||||
|
%%%===================================================================
|
||||||
|
%%% HTTP
|
||||||
|
%%%===================================================================
|
||||||
|
|
||||||
|
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||||
|
handle(Req, _Opts) ->
|
||||||
|
case cowboy_req:method(Req) of
|
||||||
|
<<"PUT">> -> put_vote(Req);
|
||||||
|
<<"DELETE">> -> delete_vote(Req);
|
||||||
|
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec put_vote(cowboy_req:req()) -> {ok, cowboy_req:req(), any()}.
|
||||||
|
put_vote(Req) ->
|
||||||
|
case handler_utils:auth_user(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
|
||||||
|
#{<<"value">> := ValueBin} ->
|
||||||
|
case parse_value(ValueBin) of
|
||||||
|
undefined ->
|
||||||
|
handler_utils:send_error(Req2, 400, <<"Invalid value: like or dislike required">>);
|
||||||
|
Value ->
|
||||||
|
case logic_review:put_vote(UserId, ReviewId, Value) of
|
||||||
|
{ok, Review, MyVote} ->
|
||||||
|
handler_utils:send_json(Req2, 200, vote_response(Review, MyVote));
|
||||||
|
{error, not_found} ->
|
||||||
|
handler_utils:send_error(Req2, 404, <<"Review not found">>);
|
||||||
|
{error, own_review} ->
|
||||||
|
handler_utils:send_error(Req2, 403, <<"Cannot vote on own review">>);
|
||||||
|
{error, _} ->
|
||||||
|
handler_utils:send_error(Req2, 500, <<"Internal server error">>)
|
||||||
|
end
|
||||||
|
end;
|
||||||
|
_ ->
|
||||||
|
handler_utils:send_error(Req2, 400, <<"Missing required field: value">>)
|
||||||
|
catch
|
||||||
|
_:_ -> handler_utils:send_error(Req2, 400, <<"Invalid JSON format">>)
|
||||||
|
end;
|
||||||
|
{error, Code, Message, Req1} ->
|
||||||
|
handler_utils:send_error(Req1, Code, Message)
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec delete_vote(cowboy_req:req()) -> {ok, cowboy_req:req(), any()}.
|
||||||
|
delete_vote(Req) ->
|
||||||
|
case handler_utils:auth_user(Req) of
|
||||||
|
{ok, UserId, Req1} ->
|
||||||
|
ReviewId = cowboy_req:binding(id, Req1),
|
||||||
|
case logic_review:delete_vote(UserId, ReviewId) of
|
||||||
|
{ok, Review, null} ->
|
||||||
|
handler_utils:send_json(Req1, 200, vote_response(Review, null));
|
||||||
|
{error, not_found} ->
|
||||||
|
handler_utils:send_error(Req1, 404, <<"Review not found">>);
|
||||||
|
{error, own_review} ->
|
||||||
|
handler_utils:send_error(Req1, 403, <<"Cannot vote on own review">>);
|
||||||
|
{error, _} ->
|
||||||
|
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||||
|
end;
|
||||||
|
{error, Code, Message, Req1} ->
|
||||||
|
handler_utils:send_error(Req1, Code, Message)
|
||||||
|
end.
|
||||||
|
|
||||||
|
%%%===================================================================
|
||||||
|
%%% Helpers
|
||||||
|
%%%===================================================================
|
||||||
|
|
||||||
|
parse_value(<<"like">>) -> like;
|
||||||
|
parse_value(<<"dislike">>) -> dislike;
|
||||||
|
parse_value(_) -> undefined.
|
||||||
|
|
||||||
|
vote_response(#review{id = Id, likes = Likes, dislikes = Dislikes}, MyVote) ->
|
||||||
|
#{
|
||||||
|
review_id => Id,
|
||||||
|
my_vote => vote_json(MyVote),
|
||||||
|
likes => Likes,
|
||||||
|
dislikes => Dislikes
|
||||||
|
}.
|
||||||
|
|
||||||
|
vote_json(like) -> <<"like">>;
|
||||||
|
vote_json(dislike) -> <<"dislike">>;
|
||||||
|
vote_json(null) -> null.
|
||||||
@@ -96,6 +96,7 @@ review_schema() ->
|
|||||||
reason => #{type => string, nullable => true},
|
reason => #{type => string, nullable => true},
|
||||||
likes => #{type => integer},
|
likes => #{type => integer},
|
||||||
dislikes => #{type => integer},
|
dislikes => #{type => integer},
|
||||||
|
my_vote => #{type => string, enum => [<<"like">>, <<"dislike">>, null], nullable => true},
|
||||||
created_at => #{type => string, format => <<"date-time">>},
|
created_at => #{type => string, format => <<"date-time">>},
|
||||||
updated_at => #{type => string, format => <<"date-time">>}
|
updated_at => #{type => string, format => <<"date-time">>}
|
||||||
}
|
}
|
||||||
@@ -130,7 +131,7 @@ create_review(Req) ->
|
|||||||
TargetType = parse_target_type(TargetTypeBin),
|
TargetType = parse_target_type(TargetTypeBin),
|
||||||
case logic_review:create_review(UserId, TargetType, TargetId, Rating, Comment) of
|
case logic_review:create_review(UserId, TargetType, TargetId, Rating, Comment) of
|
||||||
{ok, Review} ->
|
{ok, Review} ->
|
||||||
Response = handler_utils:review_to_json(Review),
|
Response = handler_utils:review_to_json(Review, null),
|
||||||
handler_utils:send_json(Req2, 201, Response);
|
handler_utils:send_json(Req2, 201, Response);
|
||||||
{error, already_reviewed} ->
|
{error, already_reviewed} ->
|
||||||
handler_utils:send_error(Req2, 409, <<"Already reviewed">>);
|
handler_utils:send_error(Req2, 409, <<"Already reviewed">>);
|
||||||
@@ -171,7 +172,9 @@ list_reviews(Req) ->
|
|||||||
TargetType = parse_target_type(TargetTypeBin),
|
TargetType = parse_target_type(TargetTypeBin),
|
||||||
case logic_review:list_reviews(UserId, TargetType, TargetId) of
|
case logic_review:list_reviews(UserId, TargetType, TargetId) of
|
||||||
{ok, Reviews} ->
|
{ok, Reviews} ->
|
||||||
Response = [handler_utils:review_to_json(R) || R <- Reviews],
|
Votes = logic_review:my_votes_map(UserId),
|
||||||
|
Response = [handler_utils:review_to_json(R, maps:get(R#review.id, Votes, null))
|
||||||
|
|| R <- Reviews],
|
||||||
handler_utils:send_json(Req1, 200, Response);
|
handler_utils:send_json(Req1, 200, Response);
|
||||||
{error, _} ->
|
{error, _} ->
|
||||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||||
|
|||||||
@@ -52,6 +52,7 @@ review_schema() ->
|
|||||||
reason => #{type => string, nullable => true},
|
reason => #{type => string, nullable => true},
|
||||||
likes => #{type => integer},
|
likes => #{type => integer},
|
||||||
dislikes => #{type => integer},
|
dislikes => #{type => integer},
|
||||||
|
my_vote => #{type => string, enum => [<<"like">>, <<"dislike">>, null], nullable => true},
|
||||||
created_at => #{type => string, format => <<"date-time">>},
|
created_at => #{type => string, format => <<"date-time">>},
|
||||||
updated_at => #{type => string, format => <<"date-time">>}
|
updated_at => #{type => string, format => <<"date-time">>}
|
||||||
}
|
}
|
||||||
@@ -76,7 +77,8 @@ list_user_reviews(Req) ->
|
|||||||
{ok, UserId, Req1} ->
|
{ok, UserId, Req1} ->
|
||||||
case logic_review:list_user_reviews(UserId) of
|
case logic_review:list_user_reviews(UserId) of
|
||||||
{ok, Reviews} ->
|
{ok, Reviews} ->
|
||||||
Response = [handler_utils:review_to_json(R) || R <- Reviews],
|
%% Свои отзывы: голос за свой отзыв невозможен → my_vote обычно null
|
||||||
|
Response = [handler_utils:review_to_json(R, null) || R <- Reviews],
|
||||||
handler_utils:send_json(Req1, 200, Response);
|
handler_utils:send_json(Req1, 200, Response);
|
||||||
{error, _} ->
|
{error, _} ->
|
||||||
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
event_to_json/1,
|
event_to_json/1,
|
||||||
user_to_json/1,
|
user_to_json/1,
|
||||||
review_to_json/1,
|
review_to_json/1,
|
||||||
|
review_to_json/2,
|
||||||
report_to_json/1,
|
report_to_json/1,
|
||||||
ticket_to_json/1,
|
ticket_to_json/1,
|
||||||
calendar_to_json/1,
|
calendar_to_json/1,
|
||||||
@@ -359,9 +360,14 @@ user_to_json(User) ->
|
|||||||
updated_at => datetime_to_iso8601(User#user.updated_at)
|
updated_at => datetime_to_iso8601(User#user.updated_at)
|
||||||
}.
|
}.
|
||||||
|
|
||||||
%% @doc Преобразует #review{} в JSON-карту.
|
%% @doc Преобразует #review{} в JSON-карту (без my_vote).
|
||||||
-spec review_to_json(#review{}) -> map().
|
-spec review_to_json(#review{}) -> map().
|
||||||
review_to_json(Review) ->
|
review_to_json(Review) ->
|
||||||
|
review_to_json(Review, null).
|
||||||
|
|
||||||
|
%% @doc Преобразует #review{} в JSON с голосом текущего пользователя.
|
||||||
|
-spec review_to_json(#review{}, like | dislike | null) -> map().
|
||||||
|
review_to_json(Review, MyVote) ->
|
||||||
#{
|
#{
|
||||||
id => Review#review.id,
|
id => Review#review.id,
|
||||||
user_id => Review#review.user_id,
|
user_id => Review#review.user_id,
|
||||||
@@ -373,10 +379,15 @@ review_to_json(Review) ->
|
|||||||
reason => Review#review.reason,
|
reason => Review#review.reason,
|
||||||
likes => Review#review.likes,
|
likes => Review#review.likes,
|
||||||
dislikes => Review#review.dislikes,
|
dislikes => Review#review.dislikes,
|
||||||
|
my_vote => my_vote_json(MyVote),
|
||||||
created_at => datetime_to_iso8601(Review#review.created_at),
|
created_at => datetime_to_iso8601(Review#review.created_at),
|
||||||
updated_at => datetime_to_iso8601(Review#review.updated_at)
|
updated_at => datetime_to_iso8601(Review#review.updated_at)
|
||||||
}.
|
}.
|
||||||
|
|
||||||
|
my_vote_json(like) -> <<"like">>;
|
||||||
|
my_vote_json(dislike) -> <<"dislike">>;
|
||||||
|
my_vote_json(null) -> null.
|
||||||
|
|
||||||
%% @doc Преобразует #report{} в JSON-карту.
|
%% @doc Преобразует #report{} в JSON-карту.
|
||||||
-spec report_to_json(#report{}) -> map().
|
-spec report_to_json(#report{}) -> map().
|
||||||
report_to_json(Report) ->
|
report_to_json(Report) ->
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
calendar, calendar_share, calendar_specialist,
|
calendar, calendar_share, calendar_specialist,
|
||||||
event, recurrence_exception,
|
event, recurrence_exception,
|
||||||
booking,
|
booking,
|
||||||
review, report, banned_word, automod_settings, automod_hit,
|
review, review_vote, report, banned_word, automod_settings, automod_hit,
|
||||||
ticket, subscription,
|
ticket, subscription,
|
||||||
admin_audit, notification,
|
admin_audit, notification,
|
||||||
stats_counter, stats_daily, node_metric, schema_migration
|
stats_counter, stats_daily, node_metric, schema_migration
|
||||||
@@ -322,6 +322,7 @@ table_opts(event) -> [{disc_copies, [node()]}, {attributes, record_info(fields,
|
|||||||
table_opts(recurrence_exception) -> [{disc_copies, [node()]}, {attributes, record_info(fields, recurrence_exception)}];
|
table_opts(recurrence_exception) -> [{disc_copies, [node()]}, {attributes, record_info(fields, recurrence_exception)}];
|
||||||
table_opts(booking) -> [{disc_copies, [node()]}, {attributes, record_info(fields, booking)}];
|
table_opts(booking) -> [{disc_copies, [node()]}, {attributes, record_info(fields, booking)}];
|
||||||
table_opts(review) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review)}];
|
table_opts(review) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review)}];
|
||||||
|
table_opts(review_vote) -> [{disc_copies, [node()]}, {attributes, record_info(fields, review_vote)}];
|
||||||
table_opts(report) -> [{disc_copies, [node()]}, {attributes, record_info(fields, report)}];
|
table_opts(report) -> [{disc_copies, [node()]}, {attributes, record_info(fields, report)}];
|
||||||
table_opts(banned_word) -> [{disc_copies, [node()]}, {attributes, record_info(fields, banned_word)}];
|
table_opts(banned_word) -> [{disc_copies, [node()]}, {attributes, record_info(fields, banned_word)}];
|
||||||
table_opts(automod_settings) -> [{disc_copies, [node()]}, {attributes, record_info(fields, automod_settings)}];
|
table_opts(automod_settings) -> [{disc_copies, [node()]}, {attributes, record_info(fields, automod_settings)}];
|
||||||
@@ -355,6 +356,8 @@ create_indices() ->
|
|||||||
mnesia:add_table_index(booking, event_id),
|
mnesia:add_table_index(booking, event_id),
|
||||||
mnesia:add_table_index(booking, user_id),
|
mnesia:add_table_index(booking, user_id),
|
||||||
mnesia:add_table_index(booking, status),
|
mnesia:add_table_index(booking, status),
|
||||||
|
mnesia:add_table_index(review_vote, review_id),
|
||||||
|
mnesia:add_table_index(review_vote, user_id),
|
||||||
mnesia:add_table_index(calendar, owner_id),
|
mnesia:add_table_index(calendar, owner_id),
|
||||||
mnesia:add_table_index(calendar, status),
|
mnesia:add_table_index(calendar, status),
|
||||||
mnesia:add_table_index(calendar, short_name),
|
mnesia:add_table_index(calendar, short_name),
|
||||||
|
|||||||
@@ -23,7 +23,8 @@
|
|||||||
'20260504150000_test_migration',
|
'20260504150000_test_migration',
|
||||||
'20260716230000_ticket_source_and_hash_index',
|
'20260716230000_ticket_source_and_hash_index',
|
||||||
'20260717180000_stats_counters',
|
'20260717180000_stats_counters',
|
||||||
'20260717190000_admin_stats_indexes'
|
'20260717190000_admin_stats_indexes',
|
||||||
|
'20260719210000_review_vote'
|
||||||
]).
|
]).
|
||||||
|
|
||||||
%% ------------------------------
|
%% ------------------------------
|
||||||
|
|||||||
@@ -6,6 +6,7 @@
|
|||||||
-export([can_review/3, update_target_rating/2, can_moderate_review/2]).
|
-export([can_review/3, update_target_rating/2, can_moderate_review/2]).
|
||||||
-export([list_admin_reviews/1, bulk_update_status/2]).
|
-export([list_admin_reviews/1, bulk_update_status/2]).
|
||||||
-export([list_admin_reviews/2, get_review_admin/1, update_review_admin/2]).
|
-export([list_admin_reviews/2, get_review_admin/1, update_review_admin/2]).
|
||||||
|
-export([put_vote/3, delete_vote/2, my_vote/2, my_votes_map/1]).
|
||||||
|
|
||||||
%% Создание отзыва
|
%% Создание отзыва
|
||||||
create_review(UserId, TargetType, TargetId, Rating, Comment) ->
|
create_review(UserId, TargetType, TargetId, Rating, Comment) ->
|
||||||
@@ -68,6 +69,45 @@ list_reviews(UserId, TargetType, TargetId) ->
|
|||||||
list_user_reviews(UserId) ->
|
list_user_reviews(UserId) ->
|
||||||
core_review:list_by_user(UserId).
|
core_review:list_by_user(UserId).
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Поставить / сменить голос (like | dislike).
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec put_vote(UserId :: binary(), ReviewId :: binary(), Value :: like | dislike) ->
|
||||||
|
{ok, #review{}, like | dislike} | {error, term()}.
|
||||||
|
put_vote(UserId, ReviewId, Value) when Value =:= like; Value =:= dislike ->
|
||||||
|
core_review_vote:upsert(ReviewId, UserId, Value).
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Снять голос с отзыва.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec delete_vote(UserId :: binary(), ReviewId :: binary()) ->
|
||||||
|
{ok, #review{}, null} | {error, term()}.
|
||||||
|
delete_vote(UserId, ReviewId) ->
|
||||||
|
core_review_vote:delete(ReviewId, UserId).
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Голос текущего пользователя за отзыв (`null` если нет).
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec my_vote(UserId :: binary(), ReviewId :: binary()) -> like | dislike | null.
|
||||||
|
my_vote(UserId, ReviewId) ->
|
||||||
|
case core_review_vote:get_by_user_and_review(UserId, ReviewId) of
|
||||||
|
{ok, #review_vote{value = Value}} -> Value;
|
||||||
|
{error, not_found} -> null
|
||||||
|
end.
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Карта review_id => like | dislike для всех голосов пользователя.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec my_votes_map(UserId :: binary()) -> #{binary() => like | dislike}.
|
||||||
|
my_votes_map(UserId) ->
|
||||||
|
lists:foldl(fun(#review_vote{review_id = Rid, value = V}, Acc) ->
|
||||||
|
Acc#{Rid => V}
|
||||||
|
end, #{}, core_review_vote:list_by_user(UserId)).
|
||||||
|
|
||||||
%% Обновление отзыва (только автор)
|
%% Обновление отзыва (только автор)
|
||||||
update_review(UserId, ReviewId, Updates) ->
|
update_review(UserId, ReviewId, Updates) ->
|
||||||
io:format("Updating review ~p with ~p~n", [ReviewId, Updates]),
|
io:format("Updating review ~p with ~p~n", [ReviewId, Updates]),
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
%% @doc Create review_vote table and indexes if missing.
|
||||||
|
-module('20260719210000_review_vote').
|
||||||
|
|
||||||
|
-export([up/0, down/0]).
|
||||||
|
|
||||||
|
-include("records.hrl").
|
||||||
|
|
||||||
|
up() ->
|
||||||
|
ensure_table(review_vote, record_info(fields, review_vote)),
|
||||||
|
ensure_index(review_vote, review_id),
|
||||||
|
ensure_index(review_vote, user_id),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
down() ->
|
||||||
|
_ = mnesia:delete_table(review_vote),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
ensure_table(Table, Attrs) ->
|
||||||
|
case lists:member(Table, mnesia:system_info(tables)) of
|
||||||
|
true ->
|
||||||
|
ok;
|
||||||
|
false ->
|
||||||
|
case mnesia:create_table(Table, [{disc_copies, [node()]}, {attributes, Attrs}]) of
|
||||||
|
{atomic, ok} -> ok;
|
||||||
|
{aborted, {already_exists, Table}} -> ok;
|
||||||
|
{aborted, Reason} -> error({create_table_failed, Table, Reason})
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
ensure_index(Table, Attr) ->
|
||||||
|
case mnesia:add_table_index(Table, Attr) of
|
||||||
|
{atomic, ok} -> ok;
|
||||||
|
{aborted, {already_exists, Table, _Pos}} -> ok;
|
||||||
|
{aborted, {already_exists, Table, Attr}} -> ok;
|
||||||
|
{aborted, {already_exists, _}} -> ok;
|
||||||
|
{aborted, Reason} -> error({add_index_failed, Table, Attr, Reason})
|
||||||
|
end.
|
||||||
@@ -1722,6 +1722,15 @@
|
|||||||
},
|
},
|
||||||
"dislikes": {
|
"dislikes": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"my_vote": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"like",
|
||||||
|
"dislike"
|
||||||
|
],
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Current user vote; null if none"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1896,6 +1905,15 @@
|
|||||||
},
|
},
|
||||||
"dislikes": {
|
"dislikes": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"my_vote": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"like",
|
||||||
|
"dislike"
|
||||||
|
],
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Current user vote; null if none"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2687,6 +2705,15 @@
|
|||||||
},
|
},
|
||||||
"dislikes": {
|
"dislikes": {
|
||||||
"type": "integer"
|
"type": "integer"
|
||||||
|
},
|
||||||
|
"my_vote": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"like",
|
||||||
|
"dislike"
|
||||||
|
],
|
||||||
|
"nullable": true,
|
||||||
|
"description": "Current user vote; null if none"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2740,6 +2767,151 @@
|
|||||||
"required": true
|
"required": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
"/v1/reviews/:id/vote": {
|
||||||
|
"put": {
|
||||||
|
"description": "Put or change vote (like/dislike) on a review",
|
||||||
|
"tags": [
|
||||||
|
"Reviews"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "id",
|
||||||
|
"description": "Review ID",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"requestBody": {
|
||||||
|
"required": true,
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"required": [
|
||||||
|
"value"
|
||||||
|
],
|
||||||
|
"properties": {
|
||||||
|
"value": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"like",
|
||||||
|
"dislike"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Vote applied",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"review_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"my_vote": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"like",
|
||||||
|
"dislike"
|
||||||
|
],
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"likes": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"dislikes": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"400": {
|
||||||
|
"description": "Invalid body"
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Cannot vote on own review"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Review not found or unavailable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"delete": {
|
||||||
|
"description": "Remove vote from a review",
|
||||||
|
"tags": [
|
||||||
|
"Reviews"
|
||||||
|
],
|
||||||
|
"parameters": [
|
||||||
|
{
|
||||||
|
"in": "path",
|
||||||
|
"name": "id",
|
||||||
|
"description": "Review ID",
|
||||||
|
"schema": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"required": true
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"responses": {
|
||||||
|
"200": {
|
||||||
|
"description": "Vote removed",
|
||||||
|
"content": {
|
||||||
|
"application/json": {
|
||||||
|
"schema": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"review_id": {
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
"my_vote": {
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"like",
|
||||||
|
"dislike"
|
||||||
|
],
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
|
"likes": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
},
|
||||||
|
"dislikes": {
|
||||||
|
"type": "integer",
|
||||||
|
"minimum": 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"401": {
|
||||||
|
"description": "Unauthorized"
|
||||||
|
},
|
||||||
|
"403": {
|
||||||
|
"description": "Cannot vote on own review"
|
||||||
|
},
|
||||||
|
"404": {
|
||||||
|
"description": "Review not found or unavailable"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"openapi": "3.0.3",
|
"openapi": "3.0.3",
|
||||||
|
|||||||
@@ -79,6 +79,7 @@ user() ->
|
|||||||
handler_events,
|
handler_events,
|
||||||
handler_reports,
|
handler_reports,
|
||||||
handler_review_by_id,
|
handler_review_by_id,
|
||||||
|
handler_review_vote,
|
||||||
handler_reviews,
|
handler_reviews,
|
||||||
handler_search,
|
handler_search,
|
||||||
handler_subscription,
|
handler_subscription,
|
||||||
|
|||||||
@@ -0,0 +1,157 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Тесты голосования за отзывы (like/dislike).
|
||||||
|
%%%
|
||||||
|
%%% PUT /v1/reviews/:id/vote
|
||||||
|
%%% DELETE /v1/reviews/:id/vote
|
||||||
|
%%% GET /v1/reviews/:id (my_vote)
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-module(user_review_vote_tests).
|
||||||
|
-include_lib("eunit/include/eunit.hrl").
|
||||||
|
|
||||||
|
-export([test/0]).
|
||||||
|
|
||||||
|
-spec test() -> ok.
|
||||||
|
test() ->
|
||||||
|
ct:pal("=== User Review Vote Tests ==="),
|
||||||
|
OwnerToken = api_test_runner:get_user_token(),
|
||||||
|
AuthorEmail = api_test_runner:unique_email(<<"rvauthor">>),
|
||||||
|
AuthorToken = api_test_runner:register_and_login(AuthorEmail, <<"pass">>),
|
||||||
|
VoterEmail = api_test_runner:unique_email(<<"rvvoter">>),
|
||||||
|
VoterToken = api_test_runner:register_and_login(VoterEmail, <<"pass">>),
|
||||||
|
OtherEmail = api_test_runner:unique_email(<<"rvother">>),
|
||||||
|
OtherToken = api_test_runner:register_and_login(OtherEmail, <<"pass">>),
|
||||||
|
|
||||||
|
CalId = api_test_runner:create_calendar(OwnerToken, #{title => <<"VoteCal">>}),
|
||||||
|
#{<<"id">> := EventId} = api_test_runner:client_post(
|
||||||
|
<<"/v1/calendars/", CalId/binary, "/events">>, OwnerToken,
|
||||||
|
#{title => <<"Event for votes">>,
|
||||||
|
start_time => api_test_runner:future_date_iso8601(),
|
||||||
|
duration => 60}),
|
||||||
|
#{<<"id">> := BookingId} = api_test_runner:client_post(
|
||||||
|
<<"/v1/events/", EventId/binary, "/bookings">>, AuthorToken, #{}),
|
||||||
|
api_test_runner:client_put(<<"/v1/bookings/", BookingId/binary>>, OwnerToken,
|
||||||
|
#{action => <<"confirm">>}),
|
||||||
|
#{<<"id">> := ReviewId} = api_test_runner:client_post(
|
||||||
|
<<"/v1/reviews">>, AuthorToken,
|
||||||
|
#{target_type => <<"event">>,
|
||||||
|
target_id => EventId,
|
||||||
|
rating => 5,
|
||||||
|
comment => <<"Nice event">>}),
|
||||||
|
|
||||||
|
VotePath = <<"/v1/reviews/", ReviewId/binary, "/vote">>,
|
||||||
|
|
||||||
|
test_put_like(VoterToken, VotePath),
|
||||||
|
test_put_like_idempotent(VoterToken, VotePath),
|
||||||
|
test_switch_to_dislike(VoterToken, VotePath),
|
||||||
|
test_my_vote_on_get(VoterToken, ReviewId),
|
||||||
|
test_my_vote_on_list(VoterToken, EventId, ReviewId),
|
||||||
|
test_delete_vote(VoterToken, VotePath),
|
||||||
|
test_delete_idempotent(VoterToken, VotePath),
|
||||||
|
test_own_review_forbidden(AuthorToken, VotePath),
|
||||||
|
test_unauthorized(VotePath),
|
||||||
|
test_not_found(VoterToken),
|
||||||
|
test_invalid_body(VoterToken, VotePath),
|
||||||
|
test_second_voter(OtherToken, VotePath),
|
||||||
|
|
||||||
|
ct:pal("=== All user review vote tests passed ==="),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
%%%===================================================================
|
||||||
|
%%% Cases
|
||||||
|
%%%===================================================================
|
||||||
|
|
||||||
|
test_put_like(Token, Path) ->
|
||||||
|
ct:pal(" TEST: PUT like"),
|
||||||
|
Resp = api_test_runner:client_put(Path, Token, #{value => <<"like">>}),
|
||||||
|
?assertEqual(<<"like">>, maps:get(<<"my_vote">>, Resp)),
|
||||||
|
?assertEqual(1, maps:get(<<"likes">>, Resp)),
|
||||||
|
?assertEqual(0, maps:get(<<"dislikes">>, Resp)),
|
||||||
|
ct:pal(" OK").
|
||||||
|
|
||||||
|
test_put_like_idempotent(Token, Path) ->
|
||||||
|
ct:pal(" TEST: PUT like idempotent"),
|
||||||
|
Resp = api_test_runner:client_put(Path, Token, #{value => <<"like">>}),
|
||||||
|
?assertEqual(<<"like">>, maps:get(<<"my_vote">>, Resp)),
|
||||||
|
?assertEqual(1, maps:get(<<"likes">>, Resp)),
|
||||||
|
?assertEqual(0, maps:get(<<"dislikes">>, Resp)),
|
||||||
|
ct:pal(" OK").
|
||||||
|
|
||||||
|
test_switch_to_dislike(Token, Path) ->
|
||||||
|
ct:pal(" TEST: switch like -> dislike"),
|
||||||
|
Resp = api_test_runner:client_put(Path, Token, #{value => <<"dislike">>}),
|
||||||
|
?assertEqual(<<"dislike">>, maps:get(<<"my_vote">>, Resp)),
|
||||||
|
?assertEqual(0, maps:get(<<"likes">>, Resp)),
|
||||||
|
?assertEqual(1, maps:get(<<"dislikes">>, Resp)),
|
||||||
|
ct:pal(" OK").
|
||||||
|
|
||||||
|
test_my_vote_on_get(Token, ReviewId) ->
|
||||||
|
ct:pal(" TEST: GET review includes my_vote"),
|
||||||
|
Review = api_test_runner:client_get(<<"/v1/reviews/", ReviewId/binary>>, Token),
|
||||||
|
?assertEqual(<<"dislike">>, maps:get(<<"my_vote">>, Review)),
|
||||||
|
?assertEqual(1, maps:get(<<"dislikes">>, Review)),
|
||||||
|
ct:pal(" OK").
|
||||||
|
|
||||||
|
test_my_vote_on_list(Token, EventId, ReviewId) ->
|
||||||
|
ct:pal(" TEST: GET reviews list includes my_vote"),
|
||||||
|
Path = <<"/v1/reviews?target_type=event&target_id=", EventId/binary>>,
|
||||||
|
List = api_test_runner:client_get(Path, Token),
|
||||||
|
?assert(is_list(List)),
|
||||||
|
Match = [R || R <- List, maps:get(<<"id">>, R) =:= ReviewId],
|
||||||
|
?assertMatch([_], Match),
|
||||||
|
[R] = Match,
|
||||||
|
?assertEqual(<<"dislike">>, maps:get(<<"my_vote">>, R)),
|
||||||
|
ct:pal(" OK").
|
||||||
|
|
||||||
|
test_delete_vote(Token, Path) ->
|
||||||
|
ct:pal(" TEST: DELETE vote"),
|
||||||
|
Resp = api_test_runner:client_delete(Path, Token),
|
||||||
|
?assertEqual(null, maps:get(<<"my_vote">>, Resp)),
|
||||||
|
?assertEqual(0, maps:get(<<"likes">>, Resp)),
|
||||||
|
?assertEqual(0, maps:get(<<"dislikes">>, Resp)),
|
||||||
|
ct:pal(" OK").
|
||||||
|
|
||||||
|
test_delete_idempotent(Token, Path) ->
|
||||||
|
ct:pal(" TEST: DELETE vote idempotent"),
|
||||||
|
Resp = api_test_runner:client_delete(Path, Token),
|
||||||
|
?assertEqual(null, maps:get(<<"my_vote">>, Resp)),
|
||||||
|
?assertEqual(0, maps:get(<<"likes">>, Resp)),
|
||||||
|
?assertEqual(0, maps:get(<<"dislikes">>, Resp)),
|
||||||
|
ct:pal(" OK").
|
||||||
|
|
||||||
|
test_own_review_forbidden(Token, Path) ->
|
||||||
|
ct:pal(" TEST: vote on own review -> 403"),
|
||||||
|
Body = jsx:encode(#{value => <<"like">>}),
|
||||||
|
Resp = api_test_runner:client_request(put, Path, Token, Body),
|
||||||
|
?assertMatch({ok, 403, _, _}, Resp),
|
||||||
|
ct:pal(" OK").
|
||||||
|
|
||||||
|
test_unauthorized(Path) ->
|
||||||
|
ct:pal(" TEST: unauthorized -> 401"),
|
||||||
|
Body = jsx:encode(#{value => <<"like">>}),
|
||||||
|
Resp = api_test_runner:client_request(put, Path, <<>>, Body),
|
||||||
|
?assertMatch({ok, 401, _, _}, Resp),
|
||||||
|
ct:pal(" OK").
|
||||||
|
|
||||||
|
test_not_found(Token) ->
|
||||||
|
ct:pal(" TEST: vote missing review -> 404"),
|
||||||
|
Path = <<"/v1/reviews/missing-review-id/vote">>,
|
||||||
|
Body = jsx:encode(#{value => <<"like">>}),
|
||||||
|
Resp = api_test_runner:client_request(put, Path, Token, Body),
|
||||||
|
?assertMatch({ok, 404, _, _}, Resp),
|
||||||
|
ct:pal(" OK").
|
||||||
|
|
||||||
|
test_invalid_body(Token, Path) ->
|
||||||
|
ct:pal(" TEST: invalid value -> 400"),
|
||||||
|
Body = jsx:encode(#{value => <<"love">>}),
|
||||||
|
Resp = api_test_runner:client_request(put, Path, Token, Body),
|
||||||
|
?assertMatch({ok, 400, _, _}, Resp),
|
||||||
|
ct:pal(" OK").
|
||||||
|
|
||||||
|
test_second_voter(Token, Path) ->
|
||||||
|
ct:pal(" TEST: second voter like"),
|
||||||
|
Resp = api_test_runner:client_put(Path, Token, #{value => <<"like">>}),
|
||||||
|
?assertEqual(<<"like">>, maps:get(<<"my_vote">>, Resp)),
|
||||||
|
?assertEqual(1, maps:get(<<"likes">>, Resp)),
|
||||||
|
?assertEqual(0, maps:get(<<"dislikes">>, Resp)),
|
||||||
|
ct:pal(" OK").
|
||||||
@@ -39,6 +39,7 @@ all() ->
|
|||||||
user_test_my_bookings,
|
user_test_my_bookings,
|
||||||
user_test_reviews,
|
user_test_reviews,
|
||||||
user_test_review_by_id,
|
user_test_review_by_id,
|
||||||
|
user_test_review_vote,
|
||||||
user_test_my_reviews,
|
user_test_my_reviews,
|
||||||
user_test_search,
|
user_test_search,
|
||||||
user_test_refresh,
|
user_test_refresh,
|
||||||
@@ -136,6 +137,9 @@ user_test_reviews(_Config) ->
|
|||||||
user_test_review_by_id(_Config) ->
|
user_test_review_by_id(_Config) ->
|
||||||
user_review_by_id_tests:test().
|
user_review_by_id_tests:test().
|
||||||
|
|
||||||
|
user_test_review_vote(_Config) ->
|
||||||
|
user_review_vote_tests:test().
|
||||||
|
|
||||||
user_test_my_reviews(_Config) ->
|
user_test_my_reviews(_Config) ->
|
||||||
user_my_reviews_tests:test().
|
user_my_reviews_tests:test().
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user