Compare commits
10 Commits
2440c50330
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 9886dff8bf | |||
| 5ac23219ca | |||
| 7368adfb37 | |||
| 59220b955e | |||
| 4932f9abae | |||
| dcb5163946 | |||
| fc38f63497 | |||
| 6d52bc3a8e | |||
| e94c94d1a6 | |||
| 7c1fe1940d |
Regular → Executable
+4
-1
@@ -39,4 +39,7 @@ COPY docker/observer_web/dev.exs ./dev.exs
|
|||||||
EXPOSE 4000
|
EXPOSE 4000
|
||||||
|
|
||||||
ENV RELEASE_COOKIE=eventhub_cookie
|
ENV RELEASE_COOKIE=eventhub_cookie
|
||||||
CMD elixir --sname observer_web@observer_web --cookie "${RELEASE_COOKIE}" -S mix run --no-halt dev.exs
|
# --hidden: observer must not join the visible global mesh. Without it, OTP
|
||||||
|
# prevent_overlapping_partitions disconnects eventhub-node1 <-> eventhub-node2
|
||||||
|
# when observer connects to both (Mnesia running_partitioned_network → e2e 404).
|
||||||
|
CMD elixir --hidden --sname observer_web@observer_web --cookie "${RELEASE_COOKIE}" -S mix run --no-halt dev.exs
|
||||||
|
|||||||
Regular → Executable
+8
@@ -103,6 +103,14 @@
|
|||||||
rights :: read | write | admin
|
rights :: read | write | admin
|
||||||
}).
|
}).
|
||||||
|
|
||||||
|
%% Follow чужого календаря (не путать с платной subscription / calendar_share)
|
||||||
|
-record(calendar_follow, {
|
||||||
|
id :: binary(),
|
||||||
|
calendar_id :: binary(),
|
||||||
|
user_id :: binary(),
|
||||||
|
created_at :: calendar:datetime()
|
||||||
|
}).
|
||||||
|
|
||||||
%% ------------------- Специалисты календаря ---------------------------
|
%% ------------------- Специалисты календаря ---------------------------
|
||||||
-record(calendar_specialist, {
|
-record(calendar_specialist, {
|
||||||
calendar_id :: binary(),
|
calendar_id :: binary(),
|
||||||
|
|||||||
Executable
+111
@@ -0,0 +1,111 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Хранение follow чужих календарей (уникальность calendar_id+user_id).
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-module(core_calendar_follow).
|
||||||
|
-include("records.hrl").
|
||||||
|
|
||||||
|
-export([follow/2, unfollow/2, is_following/2, list_by_user/1,
|
||||||
|
list_by_calendar/1, delete_by_calendar/1]).
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Подписаться на календарь. Идемпотентно, если уже follow.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec follow(CalendarId :: binary(), UserId :: binary()) ->
|
||||||
|
{ok, #calendar_follow{}} | {error, term()}.
|
||||||
|
follow(CalendarId, UserId) ->
|
||||||
|
Now = calendar:universal_time(),
|
||||||
|
F = fun() ->
|
||||||
|
case find(CalendarId, UserId) of
|
||||||
|
[#calendar_follow{} = Existing] ->
|
||||||
|
{ok, Existing};
|
||||||
|
[] ->
|
||||||
|
Rec = #calendar_follow{
|
||||||
|
id = infra_utils:generate_id(16),
|
||||||
|
calendar_id = CalendarId,
|
||||||
|
user_id = UserId,
|
||||||
|
created_at = Now
|
||||||
|
},
|
||||||
|
mnesia:write(Rec),
|
||||||
|
{ok, Rec}
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
case mnesia:transaction(F) of
|
||||||
|
{atomic, Result} -> Result;
|
||||||
|
{aborted, Reason} -> {error, Reason}
|
||||||
|
end.
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Отписаться. Идемпотентно, если follow нет.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec unfollow(CalendarId :: binary(), UserId :: binary()) -> ok | {error, term()}.
|
||||||
|
unfollow(CalendarId, UserId) ->
|
||||||
|
F = fun() ->
|
||||||
|
case find(CalendarId, UserId) of
|
||||||
|
[] ->
|
||||||
|
ok;
|
||||||
|
[#calendar_follow{id = Id}] ->
|
||||||
|
mnesia:delete({calendar_follow, Id}),
|
||||||
|
ok
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
case mnesia:transaction(F) of
|
||||||
|
{atomic, ok} -> ok;
|
||||||
|
{aborted, Reason} -> {error, Reason}
|
||||||
|
end.
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Есть ли follow у пользователя.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec is_following(UserId :: binary(), CalendarId :: binary()) -> boolean().
|
||||||
|
is_following(UserId, CalendarId) ->
|
||||||
|
case mnesia:dirty_match_object(
|
||||||
|
#calendar_follow{calendar_id = CalendarId, user_id = UserId, _ = '_'}) of
|
||||||
|
[] -> false;
|
||||||
|
_ -> true
|
||||||
|
end.
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Все follow пользователя.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec list_by_user(UserId :: binary()) -> [#calendar_follow{}].
|
||||||
|
list_by_user(UserId) ->
|
||||||
|
mnesia:dirty_match_object(#calendar_follow{user_id = UserId, _ = '_'}).
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Все follow календаря.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec list_by_calendar(CalendarId :: binary()) -> [#calendar_follow{}].
|
||||||
|
list_by_calendar(CalendarId) ->
|
||||||
|
mnesia:dirty_match_object(#calendar_follow{calendar_id = CalendarId, _ = '_'}).
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Удалить все follow календаря (при удалении calendar).
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec delete_by_calendar(CalendarId :: binary()) -> ok.
|
||||||
|
delete_by_calendar(CalendarId) ->
|
||||||
|
F = fun() ->
|
||||||
|
lists:foreach(
|
||||||
|
fun(#calendar_follow{id = Id}) ->
|
||||||
|
mnesia:delete({calendar_follow, Id})
|
||||||
|
end,
|
||||||
|
mnesia:match_object(#calendar_follow{calendar_id = CalendarId, _ = '_'})),
|
||||||
|
ok
|
||||||
|
end,
|
||||||
|
case mnesia:transaction(F) of
|
||||||
|
{atomic, ok} -> ok;
|
||||||
|
{aborted, Reason} -> error({delete_follows_failed, Reason})
|
||||||
|
end.
|
||||||
|
|
||||||
|
%%%===================================================================
|
||||||
|
%%% Internal
|
||||||
|
%%%===================================================================
|
||||||
|
|
||||||
|
find(CalendarId, UserId) ->
|
||||||
|
mnesia:match_object(#calendar_follow{calendar_id = CalendarId, user_id = UserId, _ = '_'}).
|
||||||
@@ -8,35 +8,27 @@
|
|||||||
-export([count_subscriptions_by_plan/0, count_subscriptions_by_status/0,
|
-export([count_subscriptions_by_plan/0, count_subscriptions_by_status/0,
|
||||||
count_trial_subscriptions/0, get_ending_paid_subscriptions/1]).
|
count_trial_subscriptions/0, get_ending_paid_subscriptions/1]).
|
||||||
|
|
||||||
-define(TRIAL_DAYS, 30).
|
|
||||||
|
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
%%% @doc Создание подписки.
|
%%% @doc Создание подписки.
|
||||||
%%% `TrialUsed` – `true`, если подписка платная; `false` для пробного периода.
|
%%% `TrialUsed` – флаг (использован ли trial); на длительность не влияет.
|
||||||
|
%%% Длительность всегда считается из `Plan` через `plan_to_months/1`.
|
||||||
%%% Все поля записи инициализированы, `undefined` не возникает.
|
%%% Все поля записи инициализированы, `undefined` не возникает.
|
||||||
%%% @end
|
%%% @end
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
-spec create(UserId :: binary(), Plan :: monthly | quarterly | biannual | annual,
|
-spec create(UserId :: binary(), Plan :: monthly | quarterly | biannual | annual | trial,
|
||||||
TrialUsed :: boolean()) -> {ok, #subscription{}} | {error, term()}.
|
TrialUsed :: boolean()) -> {ok, #subscription{}} | {error, term()}.
|
||||||
create(UserId, Plan, TrialUsed) ->
|
create(UserId, Plan, TrialUsed) ->
|
||||||
Id = infra_utils:generate_id(16),
|
Id = infra_utils:generate_id(16),
|
||||||
Now = calendar:universal_time(),
|
Now = calendar:universal_time(),
|
||||||
{StartDate, EndDate} = case TrialUsed of
|
DurationMonths = plan_to_months(Plan),
|
||||||
true ->
|
EndDate = add_months(Now, DurationMonths),
|
||||||
DurationMonths = plan_to_months(Plan),
|
|
||||||
End = add_months(Now, DurationMonths),
|
|
||||||
{Now, End};
|
|
||||||
false ->
|
|
||||||
End = add_days(Now, ?TRIAL_DAYS),
|
|
||||||
{Now, End}
|
|
||||||
end,
|
|
||||||
Subscription = #subscription{
|
Subscription = #subscription{
|
||||||
id = Id,
|
id = Id,
|
||||||
user_id = UserId,
|
user_id = UserId,
|
||||||
plan = Plan,
|
plan = Plan,
|
||||||
status = active,
|
status = active,
|
||||||
trial_used = TrialUsed,
|
trial_used = TrialUsed,
|
||||||
started_at = StartDate,
|
started_at = Now,
|
||||||
expires_at = EndDate,
|
expires_at = EndDate,
|
||||||
created_at = Now,
|
created_at = Now,
|
||||||
updated_at = Now
|
updated_at = Now
|
||||||
@@ -167,11 +159,6 @@ add_months(DateTime, Months) ->
|
|||||||
NewDays = Days + (Months * 30),
|
NewDays = Days + (Months * 30),
|
||||||
calendar:gregorian_seconds_to_datetime(NewDays * 86400).
|
calendar:gregorian_seconds_to_datetime(NewDays * 86400).
|
||||||
|
|
||||||
-spec add_days(calendar:datetime(), pos_integer()) -> calendar:datetime().
|
|
||||||
add_days(DateTime, Days) ->
|
|
||||||
Seconds = calendar:datetime_to_gregorian_seconds(DateTime),
|
|
||||||
calendar:gregorian_seconds_to_datetime(Seconds + (Days * 86400)).
|
|
||||||
|
|
||||||
%%%===================================================================
|
%%%===================================================================
|
||||||
%%% Новые обёртки для админки
|
%%% Новые обёртки для админки
|
||||||
%%%===================================================================
|
%%%===================================================================
|
||||||
|
|||||||
Regular → Executable
+2
@@ -90,9 +90,11 @@ start_http() ->
|
|||||||
{"/v1/user/me", handler_user_me, []},
|
{"/v1/user/me", handler_user_me, []},
|
||||||
{"/v1/user/bookings", handler_user_bookings, []},
|
{"/v1/user/bookings", handler_user_bookings, []},
|
||||||
{"/v1/user/reviews", handler_user_reviews, []},
|
{"/v1/user/reviews", handler_user_reviews, []},
|
||||||
|
{"/v1/user/following", handler_user_following, []},
|
||||||
{"/v1/search", handler_search, []},
|
{"/v1/search", handler_search, []},
|
||||||
{"/v1/calendars", handler_calendars, []},
|
{"/v1/calendars", handler_calendars, []},
|
||||||
{"/v1/calendars/:id", handler_calendar_by_id, []},
|
{"/v1/calendars/:id", handler_calendar_by_id, []},
|
||||||
|
{"/v1/calendars/:id/follow", handler_calendar_follow, []},
|
||||||
{"/v1/calendars/:calendar_id/view", handler_calendar_view, []},
|
{"/v1/calendars/:calendar_id/view", handler_calendar_view, []},
|
||||||
{"/v1/calendars/:calendar_id/events", handler_events, []},
|
{"/v1/calendars/:calendar_id/events", handler_events, []},
|
||||||
{"/v1/events/:id", handler_event_by_id, []},
|
{"/v1/events/:id", handler_event_by_id, []},
|
||||||
|
|||||||
Regular → Executable
+3
-1
@@ -114,7 +114,9 @@ get_calendar(Req) ->
|
|||||||
CalendarId = cowboy_req:binding(id, Req1),
|
CalendarId = cowboy_req:binding(id, Req1),
|
||||||
case logic_calendar:get_calendar(UserId, CalendarId) of
|
case logic_calendar:get_calendar(UserId, CalendarId) of
|
||||||
{ok, Calendar} ->
|
{ok, Calendar} ->
|
||||||
handler_utils:send_json(Req1, 200, handler_utils:calendar_to_json(Calendar));
|
Json0 = handler_utils:calendar_to_json(Calendar),
|
||||||
|
Following = logic_calendar_follow:is_following(UserId, CalendarId),
|
||||||
|
handler_utils:send_json(Req1, 200, Json0#{following => Following});
|
||||||
{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} ->
|
||||||
|
|||||||
Executable
+123
@@ -0,0 +1,123 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Follow / unfollow чужого календаря.
|
||||||
|
%%%
|
||||||
|
%%% POST /v1/calendars/:id/follow — подписаться
|
||||||
|
%%% DELETE /v1/calendars/:id/follow — отписаться
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-module(handler_calendar_follow).
|
||||||
|
-behaviour(cowboy_handler).
|
||||||
|
|
||||||
|
-export([init/2]).
|
||||||
|
-export([trails/0]).
|
||||||
|
|
||||||
|
-include("records.hrl").
|
||||||
|
|
||||||
|
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||||
|
init(Req, Opts) ->
|
||||||
|
handle(Req, Opts).
|
||||||
|
|
||||||
|
-spec trails() -> [map()].
|
||||||
|
trails() ->
|
||||||
|
BaseParams = [
|
||||||
|
#{
|
||||||
|
name => <<"id">>,
|
||||||
|
in => <<"path">>,
|
||||||
|
description => <<"Calendar ID">>,
|
||||||
|
required => true,
|
||||||
|
schema => #{type => string}
|
||||||
|
}
|
||||||
|
],
|
||||||
|
FollowResponse = #{
|
||||||
|
type => object,
|
||||||
|
properties => #{
|
||||||
|
calendar_id => #{type => string},
|
||||||
|
following => #{type => boolean}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[
|
||||||
|
#{
|
||||||
|
path => <<"/v1/calendars/:id/follow">>,
|
||||||
|
method => <<"POST">>,
|
||||||
|
description => <<"Follow a calendar (not paid subscription)">>,
|
||||||
|
tags => [<<"Calendars">>],
|
||||||
|
parameters => BaseParams,
|
||||||
|
responses => #{
|
||||||
|
200 => #{
|
||||||
|
description => <<"Following">>,
|
||||||
|
content => #{<<"application/json">> => #{schema => FollowResponse}}
|
||||||
|
},
|
||||||
|
401 => #{description => <<"Unauthorized">>},
|
||||||
|
403 => #{description => <<"Own calendar or access denied">>},
|
||||||
|
404 => #{description => <<"Calendar not found">>}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
#{
|
||||||
|
path => <<"/v1/calendars/:id/follow">>,
|
||||||
|
method => <<"DELETE">>,
|
||||||
|
description => <<"Unfollow a calendar">>,
|
||||||
|
tags => [<<"Calendars">>],
|
||||||
|
parameters => BaseParams,
|
||||||
|
responses => #{
|
||||||
|
200 => #{
|
||||||
|
description => <<"Unfollowed">>,
|
||||||
|
content => #{<<"application/json">> => #{schema => FollowResponse}}
|
||||||
|
},
|
||||||
|
401 => #{description => <<"Unauthorized">>},
|
||||||
|
403 => #{description => <<"Own calendar">>},
|
||||||
|
404 => #{description => <<"Calendar not found">>}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
].
|
||||||
|
|
||||||
|
-spec handle(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||||
|
handle(Req, _Opts) ->
|
||||||
|
case cowboy_req:method(Req) of
|
||||||
|
<<"POST">> -> follow(Req);
|
||||||
|
<<"DELETE">> -> unfollow(Req);
|
||||||
|
_ -> handler_utils:send_error(Req, 405, <<"Method not allowed">>)
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec follow(cowboy_req:req()) -> {ok, cowboy_req:req(), any()}.
|
||||||
|
follow(Req) ->
|
||||||
|
case handler_utils:auth_user(Req) of
|
||||||
|
{ok, UserId, Req1} ->
|
||||||
|
CalendarId = cowboy_req:binding(id, Req1),
|
||||||
|
case logic_calendar_follow:follow(UserId, CalendarId) of
|
||||||
|
{ok, _} ->
|
||||||
|
handler_utils:send_json(Req1, 200, #{
|
||||||
|
calendar_id => CalendarId,
|
||||||
|
following => true
|
||||||
|
});
|
||||||
|
{error, not_found} ->
|
||||||
|
handler_utils:send_error(Req1, 404, <<"Calendar not found">>);
|
||||||
|
{error, own_calendar} ->
|
||||||
|
handler_utils:send_error(Req1, 403, <<"Cannot follow own calendar">>);
|
||||||
|
{error, access_denied} ->
|
||||||
|
handler_utils:send_error(Req1, 403, <<"Access denied">>);
|
||||||
|
{error, _} ->
|
||||||
|
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||||
|
end;
|
||||||
|
{error, Code, Message, Req1} ->
|
||||||
|
handler_utils:send_error(Req1, Code, Message)
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec unfollow(cowboy_req:req()) -> {ok, cowboy_req:req(), any()}.
|
||||||
|
unfollow(Req) ->
|
||||||
|
case handler_utils:auth_user(Req) of
|
||||||
|
{ok, UserId, Req1} ->
|
||||||
|
CalendarId = cowboy_req:binding(id, Req1),
|
||||||
|
case logic_calendar_follow:unfollow(UserId, CalendarId) of
|
||||||
|
ok ->
|
||||||
|
handler_utils:send_json(Req1, 200, #{
|
||||||
|
calendar_id => CalendarId,
|
||||||
|
following => false
|
||||||
|
});
|
||||||
|
{error, own_calendar} ->
|
||||||
|
handler_utils:send_error(Req1, 403, <<"Cannot unfollow own calendar">>);
|
||||||
|
{error, _} ->
|
||||||
|
handler_utils:send_error(Req1, 500, <<"Internal server error">>)
|
||||||
|
end;
|
||||||
|
{error, Code, Message, Req1} ->
|
||||||
|
handler_utils:send_error(Req1, Code, Message)
|
||||||
|
end.
|
||||||
@@ -24,7 +24,7 @@ trails() ->
|
|||||||
#{
|
#{
|
||||||
path => <<"/v1/search">>,
|
path => <<"/v1/search">>,
|
||||||
method => <<"GET">>,
|
method => <<"GET">>,
|
||||||
description => <<"Search calendars and events">>,
|
description => <<"Search calendars and events. Empty query (auth only) returns discovery tops by rating; use q/tags/geo/from/to for filtered search.">>,
|
||||||
tags => [<<"Search">>],
|
tags => [<<"Search">>],
|
||||||
parameters => [
|
parameters => [
|
||||||
#{name => <<"type">>, in => <<"query">>, schema => #{type => string, enum => [<<"calendar">>, <<"event">>]}, description => <<"Type of entities to search">>},
|
#{name => <<"type">>, in => <<"query">>, schema => #{type => string, enum => [<<"calendar">>, <<"event">>]}, description => <<"Type of entities to search">>},
|
||||||
@@ -101,28 +101,42 @@ search(Req) ->
|
|||||||
%%%===================================================================
|
%%%===================================================================
|
||||||
|
|
||||||
%% @private Собирает карту параметров для поискового движка.
|
%% @private Собирает карту параметров для поискового движка.
|
||||||
|
%% Не кладёт sort/tags/geo/даты, если их нет в QS — иначе
|
||||||
|
%% logic_search:is_discovery_request/2 никогда не сработает (Back#50).
|
||||||
-spec parse_params(cowboy_req:qs()) -> map().
|
-spec parse_params(cowboy_req:qs()) -> map().
|
||||||
parse_params(Qs) ->
|
parse_params(Qs) ->
|
||||||
Params = #{
|
Params0 = #{
|
||||||
limit => parse_int_param(Qs, <<"limit">>, 20),
|
limit => parse_int_param(Qs, <<"limit">>, 20),
|
||||||
offset => parse_int_param(Qs, <<"offset">>, 0),
|
offset => parse_int_param(Qs, <<"offset">>, 0)
|
||||||
tags => proplists:get_value(<<"tags">>, Qs),
|
|
||||||
sort => proplists:get_value(<<"sort">>, Qs, <<"start_time">>),
|
|
||||||
order => proplists:get_value(<<"order">>, Qs, <<"asc">>)
|
|
||||||
},
|
},
|
||||||
Params1 = case {parse_float_param(Qs, <<"lat">>), parse_float_param(Qs, <<"lon">>)} of
|
Params1 = case proplists:get_value(<<"tags">>, Qs) of
|
||||||
|
undefined -> Params0;
|
||||||
|
<<>> -> Params0;
|
||||||
|
Tags -> Params0#{tags => Tags}
|
||||||
|
end,
|
||||||
|
Params2 = case proplists:get_value(<<"sort">>, Qs) of
|
||||||
|
undefined -> Params1;
|
||||||
|
<<>> -> Params1;
|
||||||
|
Sort ->
|
||||||
|
Order = case proplists:get_value(<<"order">>, Qs) of
|
||||||
|
undefined -> <<"asc">>;
|
||||||
|
<<>> -> <<"asc">>;
|
||||||
|
O -> O
|
||||||
|
end,
|
||||||
|
Params1#{sort => Sort, order => Order}
|
||||||
|
end,
|
||||||
|
Params3 = case {parse_float_param(Qs, <<"lat">>), parse_float_param(Qs, <<"lon">>)} of
|
||||||
{{ok, Lat}, {ok, Lon}} ->
|
{{ok, Lat}, {ok, Lon}} ->
|
||||||
Radius = parse_int_param(Qs, <<"radius">>, 10),
|
Radius = parse_int_param(Qs, <<"radius">>, 10),
|
||||||
Params#{lat => Lat, lon => Lon, radius => Radius};
|
Params2#{lat => Lat, lon => Lon, radius => Radius};
|
||||||
_ -> Params
|
_ -> Params2
|
||||||
end,
|
end,
|
||||||
Params2 = case {parse_datetime_param(Qs, <<"from">>), parse_datetime_param(Qs, <<"to">>)} of
|
case {parse_datetime_param(Qs, <<"from">>), parse_datetime_param(Qs, <<"to">>)} of
|
||||||
{{ok, From}, {ok, To}} -> Params1#{from => From, to => To};
|
{{ok, From}, {ok, To}} -> Params3#{from => From, to => To};
|
||||||
{{ok, From}, error} -> Params1#{from => From};
|
{{ok, From}, error} -> Params3#{from => From};
|
||||||
{error, {ok, To}} -> Params1#{to => To};
|
{error, {ok, To}} -> Params3#{to => To};
|
||||||
_ -> Params1
|
_ -> Params3
|
||||||
end,
|
end.
|
||||||
Params2.
|
|
||||||
|
|
||||||
-spec parse_int_param(cowboy_req:qs(), binary(), integer()) -> integer().
|
-spec parse_int_param(cowboy_req:qs(), binary(), integer()) -> integer().
|
||||||
parse_int_param(Qs, Key, Default) ->
|
parse_int_param(Qs, Key, Default) ->
|
||||||
|
|||||||
Executable
+55
@@ -0,0 +1,55 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Список календарей, которые пользователь отслеживает (follow).
|
||||||
|
%%%
|
||||||
|
%%% GET /v1/user/following
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-module(handler_user_following).
|
||||||
|
-behaviour(cowboy_handler).
|
||||||
|
|
||||||
|
-export([init/2]).
|
||||||
|
-export([trails/0]).
|
||||||
|
|
||||||
|
-include("records.hrl").
|
||||||
|
|
||||||
|
-spec init(cowboy_req:req(), any()) -> {ok, cowboy_req:req(), any()}.
|
||||||
|
init(Req0, _State) ->
|
||||||
|
case cowboy_req:method(Req0) of
|
||||||
|
<<"GET">> -> list_following(Req0);
|
||||||
|
_ -> handler_utils:send_error(Req0, 405, <<"Method not allowed">>)
|
||||||
|
end.
|
||||||
|
|
||||||
|
-spec trails() -> [map()].
|
||||||
|
trails() ->
|
||||||
|
[
|
||||||
|
#{
|
||||||
|
path => <<"/v1/user/following">>,
|
||||||
|
method => <<"GET">>,
|
||||||
|
description => <<"List calendars the current user follows">>,
|
||||||
|
tags => [<<"Calendars">>],
|
||||||
|
responses => #{
|
||||||
|
200 => #{
|
||||||
|
description => <<"Array of followed calendars">>,
|
||||||
|
content => #{<<"application/json">> => #{schema => #{
|
||||||
|
type => array,
|
||||||
|
items => #{type => object}
|
||||||
|
}}}
|
||||||
|
},
|
||||||
|
401 => #{description => <<"Unauthorized">>}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
].
|
||||||
|
|
||||||
|
-spec list_following(cowboy_req:req()) -> {ok, cowboy_req:req(), any()}.
|
||||||
|
list_following(Req) ->
|
||||||
|
case handler_utils:auth_user(Req) of
|
||||||
|
{ok, UserId, Req1} ->
|
||||||
|
{ok, Calendars} = logic_calendar_follow:list_following_calendars(UserId),
|
||||||
|
Response = [
|
||||||
|
maps:put(following, true, handler_utils:calendar_to_json(C))
|
||||||
|
|| C <- Calendars
|
||||||
|
],
|
||||||
|
handler_utils:send_json(Req1, 200, Response);
|
||||||
|
{error, Code, Message, Req1} ->
|
||||||
|
handler_utils:send_error(Req1, Code, Message)
|
||||||
|
end.
|
||||||
@@ -19,7 +19,12 @@ init(Req, _Opts) ->
|
|||||||
{ok, UserId} ->
|
{ok, UserId} ->
|
||||||
core_user:update(UserId, [{status, active}]),
|
core_user:update(UserId, [{status, active}]),
|
||||||
core_verification:delete_token(Token),
|
core_verification:delete_token(Token),
|
||||||
handler_utils:send_json(Req1, 200, #{<<"message">> => <<"Account verified">>});
|
case logic_calendar:ensure_default_calendar(UserId) of
|
||||||
|
ok ->
|
||||||
|
handler_utils:send_json(Req1, 200, #{<<"message">> => <<"Account verified">>});
|
||||||
|
{error, Reason} ->
|
||||||
|
handler_utils:send_error(Req1, 500, Reason)
|
||||||
|
end;
|
||||||
{error, expired} ->
|
{error, expired} ->
|
||||||
handler_utils:send_error(Req1, 410, <<"Token expired">>);
|
handler_utils:send_error(Req1, 410, <<"Token expired">>);
|
||||||
{error, not_found} ->
|
{error, not_found} ->
|
||||||
|
|||||||
Regular → Executable
+13
-6
@@ -16,13 +16,20 @@ discover_loop() ->
|
|||||||
io:format("Checking epmd on ~s...~n", [IPStr]),
|
io:format("Checking epmd on ~s...~n", [IPStr]),
|
||||||
case erl_epmd:names(IP) of
|
case erl_epmd:names(IP) of
|
||||||
{ok, List} ->
|
{ok, List} ->
|
||||||
|
%% Only eventhub-node* — connecting to observer_web (same cookie)
|
||||||
|
%% triggers OTP prevent_overlapping_partitions and splits the mesh.
|
||||||
lists:foreach(fun({Name, _Port}) ->
|
lists:foreach(fun({Name, _Port}) ->
|
||||||
Node = list_to_atom(Name ++ "@" ++ Name),
|
case lists:prefix("eventhub-node", Name) of
|
||||||
io:format(" Trying net_kernel:connect_node(~s)...~n", [Node]),
|
false ->
|
||||||
case net_kernel:connect_node(Node) of
|
ok;
|
||||||
true -> io:format(" *** Connected to ~s ***~n", [Node]), join_and_replicate(Node); %io:format(" *** Connected to ~s ***~n", [Node]);
|
true ->
|
||||||
false -> io:format(" *** Failed to connect to ~s ***~n", [Node]);
|
Node = list_to_atom(Name ++ "@" ++ Name),
|
||||||
ignored -> ok
|
io:format(" Trying net_kernel:connect_node(~s)...~n", [Node]),
|
||||||
|
case net_kernel:connect_node(Node) of
|
||||||
|
true -> io:format(" *** Connected to ~s ***~n", [Node]), join_and_replicate(Node);
|
||||||
|
false -> io:format(" *** Failed to connect to ~s ***~n", [Node]);
|
||||||
|
ignored -> ok
|
||||||
|
end
|
||||||
end
|
end
|
||||||
end, List);
|
end, List);
|
||||||
{error, Reason} ->
|
{error, Reason} ->
|
||||||
|
|||||||
Regular → Executable
+2
-1
@@ -14,7 +14,7 @@
|
|||||||
|
|
||||||
-define(TABLES, [
|
-define(TABLES, [
|
||||||
user, session, verification, admin, admin_session, auth_session,
|
user, session, verification, admin, admin_session, auth_session,
|
||||||
calendar, calendar_share, calendar_specialist,
|
calendar, calendar_share, calendar_follow, calendar_specialist,
|
||||||
event, recurrence_exception,
|
event, recurrence_exception,
|
||||||
booking,
|
booking,
|
||||||
review, review_vote, report, banned_word, automod_settings, automod_hit,
|
review, review_vote, report, banned_word, automod_settings, automod_hit,
|
||||||
@@ -317,6 +317,7 @@ table_opts(user) -> [{disc_copies, [node()]}, {attributes, record_info(fields, u
|
|||||||
table_opts(admin) -> [{disc_copies, [node()]}, {attributes, record_info(fields, admin)}];
|
table_opts(admin) -> [{disc_copies, [node()]}, {attributes, record_info(fields, admin)}];
|
||||||
table_opts(calendar) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar)}];
|
table_opts(calendar) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar)}];
|
||||||
table_opts(calendar_share) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_share)}];
|
table_opts(calendar_share) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_share)}];
|
||||||
|
table_opts(calendar_follow) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_follow)}];
|
||||||
table_opts(calendar_specialist) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_specialist)}];
|
table_opts(calendar_specialist) -> [{disc_copies, [node()]}, {attributes, record_info(fields, calendar_specialist)}];
|
||||||
table_opts(event) -> [{disc_copies, [node()]}, {attributes, record_info(fields, event)}];
|
table_opts(event) -> [{disc_copies, [node()]}, {attributes, record_info(fields, event)}];
|
||||||
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)}];
|
||||||
|
|||||||
Regular → Executable
+2
-1
@@ -24,7 +24,8 @@
|
|||||||
'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'
|
'20260719210000_review_vote',
|
||||||
|
'20260720210000_calendar_follow'
|
||||||
]).
|
]).
|
||||||
|
|
||||||
%% ------------------------------
|
%% ------------------------------
|
||||||
|
|||||||
@@ -3,7 +3,8 @@
|
|||||||
-export([create_booking/2, confirm_booking/2, confirm_booking/3,
|
-export([create_booking/2, confirm_booking/2, confirm_booking/3,
|
||||||
cancel_booking/2, cancel_booking/3, get_booking/2,
|
cancel_booking/2, cancel_booking/3, get_booking/2,
|
||||||
list_bookings/2, list_user_bookings/1, delete_booking/2,
|
list_bookings/2, list_user_bookings/1, delete_booking/2,
|
||||||
list_bookings_admin/0, get_booking_admin/1, list_event_bookings/1]).
|
list_bookings_admin/0, get_booking_admin/1,
|
||||||
|
list_event_bookings/1, list_event_bookings/2]).
|
||||||
|
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
%%% @doc Создание бронирования со статусом `pending`.
|
%%% @doc Создание бронирования со статусом `pending`.
|
||||||
@@ -151,13 +152,37 @@ get_booking_admin(BookingId) ->
|
|||||||
core_booking:get_by_id(BookingId).
|
core_booking:get_by_id(BookingId).
|
||||||
|
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
%%% @doc Список бронирований события (административный).
|
%%% @doc Список бронирований события (административный / внутренний).
|
||||||
%%% @end
|
%%% @end
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
-spec list_event_bookings(EventId :: binary()) -> {ok, [#booking{}]}.
|
-spec list_event_bookings(EventId :: binary()) -> {ok, [#booking{}]}.
|
||||||
list_event_bookings(EventId) ->
|
list_event_bookings(EventId) ->
|
||||||
core_booking:list_by_event(EventId).
|
core_booking:list_by_event(EventId).
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Список бронирований события для владельца календаря (или admin).
|
||||||
|
%%% Возвращает полный список заявок без фильтрации по участнику.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec list_event_bookings(UserId :: binary(), EventId :: binary()) ->
|
||||||
|
{ok, [#booking{}]} | {error, not_found | access_denied}.
|
||||||
|
list_event_bookings(UserId, EventId) ->
|
||||||
|
case core_event:get_by_id(EventId) of
|
||||||
|
{ok, Event} ->
|
||||||
|
case core_calendar:get_by_id(Event#event.calendar_id) of
|
||||||
|
{ok, Calendar} ->
|
||||||
|
case admin_utils:is_admin(UserId)
|
||||||
|
orelse Calendar#calendar.owner_id =:= UserId of
|
||||||
|
true -> core_booking:list_by_event(EventId);
|
||||||
|
false -> {error, access_denied}
|
||||||
|
end;
|
||||||
|
{error, not_found} ->
|
||||||
|
{error, not_found}
|
||||||
|
end;
|
||||||
|
{error, not_found} ->
|
||||||
|
{error, not_found}
|
||||||
|
end.
|
||||||
|
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
%%% @doc Список всех бронирований (административный).
|
%%% @doc Список всех бронирований (административный).
|
||||||
%%% @end
|
%%% @end
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
-include("records.hrl").
|
-include("records.hrl").
|
||||||
|
|
||||||
-export([create_calendar/3, create_calendar/4, get_calendar/2, list_calendars/1,
|
-export([create_calendar/3, create_calendar/4, get_calendar/2, list_calendars/1,
|
||||||
update_calendar/3, delete_calendar/2]).
|
update_calendar/3, delete_calendar/2, ensure_default_calendar/1]).
|
||||||
-export([can_access/2, can_edit/2]).
|
-export([can_access/2, can_edit/2]).
|
||||||
-export([admin_list_all/0, admin_get_by_id/1, admin_update/2, admin_delete/1]).
|
-export([admin_list_all/0, admin_get_by_id/1, admin_update/2, admin_delete/1]).
|
||||||
|
|
||||||
@@ -50,6 +50,43 @@ create_calendar(UserId, Title, Description, Confirmation, Type) ->
|
|||||||
{error, user_not_found}
|
{error, user_not_found}
|
||||||
end.
|
end.
|
||||||
|
|
||||||
|
%% @doc Создаёт дефолтный personal-календарь после активации пользователя.
|
||||||
|
%% Идемпотентно: если у владельца уже есть active personal — ok.
|
||||||
|
-spec ensure_default_calendar(UserId :: binary()) -> ok | {error, term()}.
|
||||||
|
ensure_default_calendar(UserId) ->
|
||||||
|
case has_active_personal_calendar(UserId) of
|
||||||
|
true ->
|
||||||
|
ok;
|
||||||
|
false ->
|
||||||
|
case core_user:get_by_id(UserId) of
|
||||||
|
{ok, User} ->
|
||||||
|
Title = default_calendar_title(User),
|
||||||
|
case create_calendar(UserId, Title, <<>>, manual, personal) of
|
||||||
|
{ok, _} -> ok;
|
||||||
|
Error -> Error
|
||||||
|
end;
|
||||||
|
Error ->
|
||||||
|
Error
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
has_active_personal_calendar(UserId) ->
|
||||||
|
case core_calendar:list_by_owner(UserId) of
|
||||||
|
{ok, Calendars} ->
|
||||||
|
lists:any(
|
||||||
|
fun(#calendar{type = personal}) -> true;
|
||||||
|
(_) -> false
|
||||||
|
end,
|
||||||
|
Calendars);
|
||||||
|
_ ->
|
||||||
|
false
|
||||||
|
end.
|
||||||
|
|
||||||
|
default_calendar_title(#user{nickname = Nick}) when is_binary(Nick), byte_size(Nick) > 0 ->
|
||||||
|
Nick;
|
||||||
|
default_calendar_title(_) ->
|
||||||
|
<<"Мой календарь">>.
|
||||||
|
|
||||||
%% Получение календаря с проверкой доступа
|
%% Получение календаря с проверкой доступа
|
||||||
get_calendar(UserId, CalendarId) ->
|
get_calendar(UserId, CalendarId) ->
|
||||||
case core_calendar:get_by_id(CalendarId) of
|
case core_calendar:get_by_id(CalendarId) of
|
||||||
|
|||||||
Executable
+84
@@ -0,0 +1,84 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Бизнес-логика follow чужого календаря.
|
||||||
|
%%% Follow не даёт право на отзыв (см. logic_review:can_review/3).
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-module(logic_calendar_follow).
|
||||||
|
-include("records.hrl").
|
||||||
|
|
||||||
|
-export([follow/2, unfollow/2, is_following/2, list_following_calendars/1]).
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Follow доступного чужого календаря.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec follow(UserId :: binary(), CalendarId :: binary()) ->
|
||||||
|
{ok, #calendar_follow{}} | {error, term()}.
|
||||||
|
follow(UserId, CalendarId) ->
|
||||||
|
case core_calendar:get_by_id(CalendarId) of
|
||||||
|
{error, not_found} ->
|
||||||
|
{error, not_found};
|
||||||
|
{ok, #calendar{owner_id = UserId}} ->
|
||||||
|
{error, own_calendar};
|
||||||
|
{ok, Calendar} ->
|
||||||
|
case logic_calendar:can_access(UserId, Calendar) of
|
||||||
|
false ->
|
||||||
|
{error, access_denied};
|
||||||
|
true ->
|
||||||
|
case Calendar#calendar.status of
|
||||||
|
active ->
|
||||||
|
core_calendar_follow:follow(CalendarId, UserId);
|
||||||
|
_ ->
|
||||||
|
{error, not_found}
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end.
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Unfollow. Идемпотентно.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec unfollow(UserId :: binary(), CalendarId :: binary()) -> ok | {error, term()}.
|
||||||
|
unfollow(UserId, CalendarId) ->
|
||||||
|
case core_calendar:get_by_id(CalendarId) of
|
||||||
|
{error, not_found} ->
|
||||||
|
%% всё равно снимаем локальный follow, если был
|
||||||
|
core_calendar_follow:unfollow(CalendarId, UserId);
|
||||||
|
{ok, #calendar{owner_id = UserId}} ->
|
||||||
|
{error, own_calendar};
|
||||||
|
{ok, _} ->
|
||||||
|
core_calendar_follow:unfollow(CalendarId, UserId)
|
||||||
|
end.
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Флаг following для текущего пользователя.
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec is_following(UserId :: binary(), CalendarId :: binary()) -> boolean().
|
||||||
|
is_following(UserId, CalendarId) ->
|
||||||
|
core_calendar_follow:is_following(UserId, CalendarId).
|
||||||
|
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Список календарей, на которые подписан пользователь (active, доступные).
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-spec list_following_calendars(UserId :: binary()) -> {ok, [#calendar{}]}.
|
||||||
|
list_following_calendars(UserId) ->
|
||||||
|
Follows = core_calendar_follow:list_by_user(UserId),
|
||||||
|
Calendars = lists:filtermap(
|
||||||
|
fun(#calendar_follow{calendar_id = CalId}) ->
|
||||||
|
case core_calendar:get_by_id(CalId) of
|
||||||
|
{ok, #calendar{status = active} = Cal} ->
|
||||||
|
case logic_calendar:can_access(UserId, Cal) of
|
||||||
|
true -> {true, Cal};
|
||||||
|
false -> false
|
||||||
|
end;
|
||||||
|
_ ->
|
||||||
|
false
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
Follows),
|
||||||
|
Sorted = lists:sort(
|
||||||
|
fun(#calendar{title = A}, #calendar{title = B}) -> A =< B end,
|
||||||
|
Calendars),
|
||||||
|
{ok, Sorted}.
|
||||||
Regular → Executable
+16
-1
@@ -216,7 +216,22 @@ can_review(UserId, TargetType, TargetId) ->
|
|||||||
{ok, false}
|
{ok, false}
|
||||||
end;
|
end;
|
||||||
calendar ->
|
calendar ->
|
||||||
{ok, true};
|
case core_booking:list_by_user(UserId) of
|
||||||
|
{ok, Bookings} ->
|
||||||
|
Has = lists:any(
|
||||||
|
fun(B) ->
|
||||||
|
B#booking.status =:= confirmed andalso
|
||||||
|
case core_event:get_by_id(B#booking.event_id) of
|
||||||
|
{ok, Event} -> Event#event.calendar_id =:= TargetId;
|
||||||
|
_ -> false
|
||||||
|
end
|
||||||
|
end,
|
||||||
|
Bookings
|
||||||
|
),
|
||||||
|
{ok, Has};
|
||||||
|
_ ->
|
||||||
|
{ok, false}
|
||||||
|
end;
|
||||||
_ ->
|
_ ->
|
||||||
{ok, false}
|
{ok, false}
|
||||||
end.
|
end.
|
||||||
|
|||||||
Regular → Executable
+66
@@ -16,6 +16,7 @@
|
|||||||
%% ─────────────────────────────────────────────────────────────────
|
%% ─────────────────────────────────────────────────────────────────
|
||||||
-define(DEFAULT_LIMIT, 20).
|
-define(DEFAULT_LIMIT, 20).
|
||||||
-define(MAX_LIMIT, 100).
|
-define(MAX_LIMIT, 100).
|
||||||
|
-define(DISCOVERY_FETCH, 200).
|
||||||
-define(EARTH_RADIUS_KM, 6371.0).
|
-define(EARTH_RADIUS_KM, 6371.0).
|
||||||
|
|
||||||
%%%-------------------------------------------------------------------
|
%%%-------------------------------------------------------------------
|
||||||
@@ -36,6 +37,14 @@
|
|||||||
search(Type, Query, UserId, Params) ->
|
search(Type, Query, UserId, Params) ->
|
||||||
Limit = min(maps:get(limit, Params, ?DEFAULT_LIMIT), ?MAX_LIMIT),
|
Limit = min(maps:get(limit, Params, ?DEFAULT_LIMIT), ?MAX_LIMIT),
|
||||||
Offset = maps:get(offset, Params, 0),
|
Offset = maps:get(offset, Params, 0),
|
||||||
|
case is_discovery_request(Query, Params) of
|
||||||
|
true ->
|
||||||
|
discovery_search(Type, UserId, Params, Limit, Offset);
|
||||||
|
false ->
|
||||||
|
filtered_search(Type, Query, UserId, Params, Limit, Offset)
|
||||||
|
end.
|
||||||
|
|
||||||
|
filtered_search(Type, Query, UserId, Params, Limit, Offset) ->
|
||||||
case Type of
|
case Type of
|
||||||
<<"event">> ->
|
<<"event">> ->
|
||||||
{ok, Total, Events} = search_events(Query, UserId, Params, Limit, Offset),
|
{ok, Total, Events} = search_events(Query, UserId, Params, Limit, Offset),
|
||||||
@@ -52,6 +61,58 @@ search(Type, Query, UserId, Params) ->
|
|||||||
}}
|
}}
|
||||||
end.
|
end.
|
||||||
|
|
||||||
|
%% Пустой search (страница «Главная»): tops из stats_tops; иначе — полный scan.
|
||||||
|
is_discovery_request(Query, Params) ->
|
||||||
|
QueryEmpty = Query =:= undefined orelse Query =:= <<>>,
|
||||||
|
QueryEmpty andalso
|
||||||
|
not maps:is_key(tags, Params) andalso
|
||||||
|
not maps:is_key(from, Params) andalso
|
||||||
|
not maps:is_key(to, Params) andalso
|
||||||
|
not maps:is_key(lat, Params) andalso
|
||||||
|
not maps:is_key(lon, Params) andalso
|
||||||
|
not maps:is_key(sort, Params).
|
||||||
|
|
||||||
|
discovery_search(Type, UserId, Params, Limit, Offset) ->
|
||||||
|
case Type of
|
||||||
|
<<"event">> ->
|
||||||
|
{ok, Total, Events} = discovery_events(UserId, Params, Limit, Offset),
|
||||||
|
{ok, Total, #{<<"events">> => Events}};
|
||||||
|
<<"calendar">> ->
|
||||||
|
{ok, Total, Calendars} = discovery_calendars(UserId, Params, Limit, Offset),
|
||||||
|
{ok, Total, #{<<"calendars">> => Calendars}};
|
||||||
|
_ ->
|
||||||
|
{ok, EventsTotal, Events} = discovery_events(UserId, Params, Limit, Offset),
|
||||||
|
{ok, CalendarsTotal, Calendars} = discovery_calendars(UserId, Params, Limit, Offset),
|
||||||
|
{ok, EventsTotal + CalendarsTotal, #{
|
||||||
|
<<"events">> => Events,
|
||||||
|
<<"calendars">> => Calendars
|
||||||
|
}}
|
||||||
|
end.
|
||||||
|
|
||||||
|
discovery_events(UserId, Params, Limit, Offset) ->
|
||||||
|
FetchN = max(Limit + Offset, ?DISCOVERY_FETCH),
|
||||||
|
Tops = core_event:get_top_events_by_rating(FetchN),
|
||||||
|
Accessible = filter_accessible_events(Tops, UserId),
|
||||||
|
case Accessible of
|
||||||
|
[] ->
|
||||||
|
search_events(undefined, UserId, Params, Limit, Offset);
|
||||||
|
Items ->
|
||||||
|
Total = length(Items),
|
||||||
|
{ok, Total, format_events(paginate(Items, Limit, Offset))}
|
||||||
|
end.
|
||||||
|
|
||||||
|
discovery_calendars(UserId, Params, Limit, Offset) ->
|
||||||
|
FetchN = max(Limit + Offset, ?DISCOVERY_FETCH),
|
||||||
|
Tops = core_calendar:get_top_calendars_by_rating(FetchN),
|
||||||
|
Accessible = filter_accessible_calendars(Tops, UserId),
|
||||||
|
case Accessible of
|
||||||
|
[] ->
|
||||||
|
search_calendars(undefined, UserId, Params, Limit, Offset);
|
||||||
|
Items ->
|
||||||
|
Total = length(Items),
|
||||||
|
{ok, Total, format_calendars(paginate(Items, Limit, Offset))}
|
||||||
|
end.
|
||||||
|
|
||||||
%% ============ Поиск событий ============
|
%% ============ Поиск событий ============
|
||||||
|
|
||||||
-spec search_events(Query :: binary() | undefined,
|
-spec search_events(Query :: binary() | undefined,
|
||||||
@@ -263,9 +324,14 @@ format_event(Event) ->
|
|||||||
#location{address = Addr, lat = Lat, lon = Lon} ->
|
#location{address = Addr, lat = Lat, lon = Lon} ->
|
||||||
#{address => Addr, lat => Lat, lon => Lon}
|
#{address => Addr, lat => Lat, lon => Lon}
|
||||||
end,
|
end,
|
||||||
|
CalendarTitle = case core_calendar:get_by_id(Event#event.calendar_id) of
|
||||||
|
{ok, Cal} -> Cal#calendar.title;
|
||||||
|
_ -> null
|
||||||
|
end,
|
||||||
#{
|
#{
|
||||||
id => Event#event.id,
|
id => Event#event.id,
|
||||||
calendar_id => Event#event.calendar_id,
|
calendar_id => Event#event.calendar_id,
|
||||||
|
calendar_title => CalendarTitle,
|
||||||
title => Event#event.title,
|
title => Event#event.title,
|
||||||
description => Event#event.description,
|
description => Event#event.description,
|
||||||
event_type => Event#event.event_type,
|
event_type => Event#event.event_type,
|
||||||
|
|||||||
@@ -6,8 +6,6 @@
|
|||||||
-export([check_user_subscription/1, can_create_commercial_calendar/1]).
|
-export([check_user_subscription/1, can_create_commercial_calendar/1]).
|
||||||
-export([handle_expired_subscriptions/0]).
|
-export([handle_expired_subscriptions/0]).
|
||||||
|
|
||||||
-define(TRIAL_DAYS, 30).
|
|
||||||
|
|
||||||
%% ============ Управление подписками ============
|
%% ============ Управление подписками ============
|
||||||
|
|
||||||
%% Начать пробный период (вызывается при первой попытке создать commercial календарь)
|
%% Начать пробный период (вызывается при первой попытке создать commercial календарь)
|
||||||
@@ -22,6 +20,7 @@ start_trial(UserId) ->
|
|||||||
true ->
|
true ->
|
||||||
{error, trial_already_used};
|
{error, trial_already_used};
|
||||||
false ->
|
false ->
|
||||||
|
% trial_used=true — флаг; длительность из plan=trial (~1 месяц)
|
||||||
case core_subscription:create(UserId, trial, true) of
|
case core_subscription:create(UserId, trial, true) of
|
||||||
{ok, Subscription} ->
|
{ok, Subscription} ->
|
||||||
{ok, Subscription};
|
{ok, Subscription};
|
||||||
@@ -34,7 +33,7 @@ start_trial(UserId) ->
|
|||||||
activate_subscription(UserId, Plan, PaymentInfo) ->
|
activate_subscription(UserId, Plan, PaymentInfo) ->
|
||||||
case process_payment(PaymentInfo, plan_price(Plan)) of
|
case process_payment(PaymentInfo, plan_price(Plan)) of
|
||||||
ok ->
|
ok ->
|
||||||
% Проверяем, была ли у пользователя хоть одна подписка
|
% Флаг trial_used: была ли уже любая подписка (не влияет на длительность)
|
||||||
{ok, AllSubs} = core_subscription:list_by_user(UserId),
|
{ok, AllSubs} = core_subscription:list_by_user(UserId),
|
||||||
TrialUsed = length(AllSubs) > 0,
|
TrialUsed = length(AllSubs) > 0,
|
||||||
|
|
||||||
@@ -45,7 +44,7 @@ activate_subscription(UserId, Plan, PaymentInfo) ->
|
|||||||
_ -> ok
|
_ -> ok
|
||||||
end,
|
end,
|
||||||
|
|
||||||
% Создаём новую подписку
|
% Длительность всегда из Plan (plan_to_months), TrialUsed — только флаг
|
||||||
case core_subscription:create(UserId, Plan, TrialUsed) of
|
case core_subscription:create(UserId, Plan, TrialUsed) of
|
||||||
{ok, Subscription} ->
|
{ok, Subscription} ->
|
||||||
{ok, Subscription};
|
{ok, Subscription};
|
||||||
|
|||||||
+37
@@ -0,0 +1,37 @@
|
|||||||
|
%% @doc Create calendar_follow table and indexes if missing.
|
||||||
|
-module('20260720210000_calendar_follow').
|
||||||
|
|
||||||
|
-export([up/0, down/0]).
|
||||||
|
|
||||||
|
-include("records.hrl").
|
||||||
|
|
||||||
|
up() ->
|
||||||
|
ensure_table(calendar_follow, record_info(fields, calendar_follow)),
|
||||||
|
ensure_index(calendar_follow, calendar_id),
|
||||||
|
ensure_index(calendar_follow, user_id),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
down() ->
|
||||||
|
_ = mnesia:delete_table(calendar_follow),
|
||||||
|
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.
|
||||||
Regular → Executable
+2
@@ -72,6 +72,7 @@ user() ->
|
|||||||
handler_booking_by_id,
|
handler_booking_by_id,
|
||||||
handler_bookings,
|
handler_bookings,
|
||||||
handler_calendar_by_id,
|
handler_calendar_by_id,
|
||||||
|
handler_calendar_follow,
|
||||||
handler_calendar_view,
|
handler_calendar_view,
|
||||||
handler_calendars,
|
handler_calendars,
|
||||||
handler_event_by_id,
|
handler_event_by_id,
|
||||||
@@ -86,6 +87,7 @@ user() ->
|
|||||||
handler_ticket_by_id,
|
handler_ticket_by_id,
|
||||||
handler_tickets,
|
handler_tickets,
|
||||||
handler_user_bookings,
|
handler_user_bookings,
|
||||||
|
handler_user_following,
|
||||||
handler_user_me,
|
handler_user_me,
|
||||||
handler_user_reviews
|
handler_user_reviews
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -34,10 +34,12 @@ test() ->
|
|||||||
UserToken = api_test_runner:get_user_token(),
|
UserToken = api_test_runner:get_user_token(),
|
||||||
% Создаём два тикета для разных проверок
|
% Создаём два тикета для разных проверок
|
||||||
Ticket1 = api_test_runner:client_post(<<"/v1/tickets">>, UserToken,
|
Ticket1 = api_test_runner:client_post(<<"/v1/tickets">>, UserToken,
|
||||||
#{<<"error_message">> => <<"Test bug">>, <<"stacktrace">> => <<"trace">>}),
|
#{<<"error_message">> => api_test_runner:unique_ticket_message(<<"Test bug">>),
|
||||||
|
<<"stacktrace">> => <<"trace">>}),
|
||||||
#{<<"id">> := Ticket1Id} = Ticket1,
|
#{<<"id">> := Ticket1Id} = Ticket1,
|
||||||
Ticket2 = api_test_runner:client_post(<<"/v1/tickets">>, UserToken,
|
Ticket2 = api_test_runner:client_post(<<"/v1/tickets">>, UserToken,
|
||||||
#{<<"error_message">> => <<"Another bug">>, <<"stacktrace">> => <<"trace2">>}),
|
#{<<"error_message">> => api_test_runner:unique_ticket_message(<<"Another bug">>),
|
||||||
|
<<"stacktrace">> => <<"trace2">>}),
|
||||||
#{<<"id">> := Ticket2Id} = Ticket2,
|
#{<<"id">> := Ticket2Id} = Ticket2,
|
||||||
|
|
||||||
% Получаем ID текущего администратора для теста фильтрации по исполнителю
|
% Получаем ID текущего администратора для теста фильтрации по исполнителю
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
get_support_token/0,
|
get_support_token/0,
|
||||||
get_user_token/0,
|
get_user_token/0,
|
||||||
unique_email/1,
|
unique_email/1,
|
||||||
|
unique_ticket_message/1,
|
||||||
future_date/0,
|
future_date/0,
|
||||||
register_and_login/2,
|
register_and_login/2,
|
||||||
create_calendar/2,
|
create_calendar/2,
|
||||||
@@ -335,6 +336,16 @@ unique_email(Prefix) ->
|
|||||||
Unique = integer_to_binary(erlang:system_time()),
|
Unique = integer_to_binary(erlang:system_time()),
|
||||||
<<Prefix/binary, "_", Unique/binary, "@test.local">>.
|
<<Prefix/binary, "_", Unique/binary, "@test.local">>.
|
||||||
|
|
||||||
|
%% Уникальное сообщение тикета на прогон (IFT: иначе дедуп по error_hash
|
||||||
|
%% возвращает чужой reporter_id и list/get своих тикетов падает).
|
||||||
|
%% system_time — стабильно уникален между контейнерами; unique_integer —
|
||||||
|
%% различает вызовы внутри одного прогона (monotonic сбрасывается при старте BEAM).
|
||||||
|
-spec unique_ticket_message(binary()) -> binary().
|
||||||
|
unique_ticket_message(Prefix) ->
|
||||||
|
Time = integer_to_binary(erlang:system_time()),
|
||||||
|
Seq = integer_to_binary(erlang:unique_integer([positive, monotonic])),
|
||||||
|
<<Prefix/binary, " ", Time/binary, "-", Seq/binary>>.
|
||||||
|
|
||||||
-spec future_date() -> calendar:datetime().
|
-spec future_date() -> calendar:datetime().
|
||||||
future_date() ->
|
future_date() ->
|
||||||
Seconds = calendar:datetime_to_gregorian_seconds(calendar:universal_time()) + 86400,
|
Seconds = calendar:datetime_to_gregorian_seconds(calendar:universal_time()) + 86400,
|
||||||
|
|||||||
Regular → Executable
+9
@@ -92,7 +92,16 @@ test_report_on_review(Admin) ->
|
|||||||
}),
|
}),
|
||||||
Owner = api_test_runner:get_user_token(),
|
Owner = api_test_runner:get_user_token(),
|
||||||
CalId = api_test_runner:create_calendar(Owner, #{title => <<"RevRepCal">>}),
|
CalId = api_test_runner:create_calendar(Owner, #{title => <<"RevRepCal">>}),
|
||||||
|
#{<<"id">> := EventId} = api_test_runner:client_post(
|
||||||
|
<<"/v1/calendars/", CalId/binary, "/events">>, Owner,
|
||||||
|
#{title => <<"Event for review report">>,
|
||||||
|
start_time => api_test_runner:future_date_iso8601(),
|
||||||
|
duration => 60}),
|
||||||
Reviewer = api_test_runner:get_user_token(),
|
Reviewer = api_test_runner:get_user_token(),
|
||||||
|
#{<<"id">> := BookingId} = api_test_runner:client_post(
|
||||||
|
<<"/v1/events/", EventId/binary, "/bookings">>, Reviewer, #{}),
|
||||||
|
api_test_runner:client_put(<<"/v1/bookings/", BookingId/binary>>, Owner,
|
||||||
|
#{action => <<"confirm">>}),
|
||||||
{ok, 201, _, RevBody} = api_test_runner:client_request(post, <<"/v1/reviews">>, Reviewer,
|
{ok, 201, _, RevBody} = api_test_runner:client_request(post, <<"/v1/reviews">>, Reviewer,
|
||||||
jsx:encode(#{
|
jsx:encode(#{
|
||||||
target_type => <<"calendar">>,
|
target_type => <<"calendar">>,
|
||||||
|
|||||||
+113
@@ -0,0 +1,113 @@
|
|||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
%%% @doc Тесты follow / unfollow чужого календаря.
|
||||||
|
%%%
|
||||||
|
%%% POST /v1/calendars/:id/follow
|
||||||
|
%%% DELETE /v1/calendars/:id/follow
|
||||||
|
%%% GET /v1/user/following
|
||||||
|
%%% GET /v1/calendars/:id (поле following)
|
||||||
|
%%% @end
|
||||||
|
%%%-------------------------------------------------------------------
|
||||||
|
-module(user_calendar_follow_tests).
|
||||||
|
-include_lib("eunit/include/eunit.hrl").
|
||||||
|
|
||||||
|
-export([test/0]).
|
||||||
|
|
||||||
|
-spec test() -> ok.
|
||||||
|
test() ->
|
||||||
|
ct:pal("=== User Calendar Follow Tests ==="),
|
||||||
|
OwnerEmail = api_test_runner:unique_email(<<"flowner">>),
|
||||||
|
OwnerToken = api_test_runner:register_and_login(OwnerEmail, <<"pass">>),
|
||||||
|
FollowerEmail = api_test_runner:unique_email(<<"fluser">>),
|
||||||
|
FollowerToken = api_test_runner:register_and_login(FollowerEmail, <<"pass">>),
|
||||||
|
|
||||||
|
%% commercial calendar requires subscription
|
||||||
|
{ok, 201, _, _} = api_test_runner:client_request(
|
||||||
|
post, <<"/v1/subscription">>, OwnerToken,
|
||||||
|
jsx:encode(#{action => <<"start_trial">>})),
|
||||||
|
CalId = api_test_runner:create_calendar(OwnerToken, #{
|
||||||
|
title => <<"FollowMe">>,
|
||||||
|
type => <<"commercial">>
|
||||||
|
}),
|
||||||
|
FollowPath = <<"/v1/calendars/", CalId/binary, "/follow">>,
|
||||||
|
|
||||||
|
test_follow(FollowerToken, FollowPath, CalId),
|
||||||
|
test_follow_idempotent(FollowerToken, FollowPath, CalId),
|
||||||
|
test_get_calendar_following(FollowerToken, CalId, true),
|
||||||
|
test_list_following(FollowerToken, CalId),
|
||||||
|
test_own_forbidden(OwnerToken, FollowPath),
|
||||||
|
test_unfollow(FollowerToken, FollowPath, CalId),
|
||||||
|
test_unfollow_idempotent(FollowerToken, FollowPath, CalId),
|
||||||
|
test_get_calendar_following(FollowerToken, CalId, false),
|
||||||
|
test_list_following_empty(FollowerToken),
|
||||||
|
test_unauthorized(FollowPath),
|
||||||
|
|
||||||
|
ct:pal("=== All user calendar follow tests passed ==="),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
%%%===================================================================
|
||||||
|
%%% Cases
|
||||||
|
%%%===================================================================
|
||||||
|
|
||||||
|
test_follow(Token, Path, CalId) ->
|
||||||
|
ct:pal(" TEST: POST follow"),
|
||||||
|
{ok, 200, _, Body} = api_test_runner:client_request(post, Path, Token, <<"{}">>),
|
||||||
|
Resp = jsx:decode(list_to_binary(Body), [return_maps]),
|
||||||
|
?assertEqual(CalId, maps:get(<<"calendar_id">>, Resp)),
|
||||||
|
?assertEqual(true, maps:get(<<"following">>, Resp)),
|
||||||
|
ct:pal(" OK").
|
||||||
|
|
||||||
|
test_follow_idempotent(Token, Path, CalId) ->
|
||||||
|
ct:pal(" TEST: POST follow idempotent"),
|
||||||
|
{ok, 200, _, Body} = api_test_runner:client_request(post, Path, Token, <<"{}">>),
|
||||||
|
Resp = jsx:decode(list_to_binary(Body), [return_maps]),
|
||||||
|
?assertEqual(CalId, maps:get(<<"calendar_id">>, Resp)),
|
||||||
|
?assertEqual(true, maps:get(<<"following">>, Resp)),
|
||||||
|
ct:pal(" OK").
|
||||||
|
|
||||||
|
test_get_calendar_following(Token, CalId, Expected) ->
|
||||||
|
ct:pal(" TEST: GET calendar following=~p", [Expected]),
|
||||||
|
Cal = api_test_runner:client_get(<<"/v1/calendars/", CalId/binary>>, Token),
|
||||||
|
?assertEqual(Expected, maps:get(<<"following">>, Cal)),
|
||||||
|
ct:pal(" OK").
|
||||||
|
|
||||||
|
test_list_following(Token, CalId) ->
|
||||||
|
ct:pal(" TEST: GET /v1/user/following"),
|
||||||
|
List = api_test_runner:client_get(<<"/v1/user/following">>, Token),
|
||||||
|
?assert(is_list(List)),
|
||||||
|
Match = [C || C <- List, maps:get(<<"id">>, C) =:= CalId],
|
||||||
|
?assertMatch([_], Match),
|
||||||
|
[C] = Match,
|
||||||
|
?assertEqual(true, maps:get(<<"following">>, C)),
|
||||||
|
ct:pal(" OK").
|
||||||
|
|
||||||
|
test_own_forbidden(Token, Path) ->
|
||||||
|
ct:pal(" TEST: follow own calendar forbidden"),
|
||||||
|
Resp = api_test_runner:client_request(post, Path, Token, <<"{}">>),
|
||||||
|
?assertMatch({ok, 403, _, _}, Resp),
|
||||||
|
ct:pal(" OK").
|
||||||
|
|
||||||
|
test_unfollow(Token, Path, CalId) ->
|
||||||
|
ct:pal(" TEST: DELETE unfollow"),
|
||||||
|
Resp = api_test_runner:client_delete(Path, Token),
|
||||||
|
?assertEqual(CalId, maps:get(<<"calendar_id">>, Resp)),
|
||||||
|
?assertEqual(false, maps:get(<<"following">>, Resp)),
|
||||||
|
ct:pal(" OK").
|
||||||
|
|
||||||
|
test_unfollow_idempotent(Token, Path, CalId) ->
|
||||||
|
ct:pal(" TEST: DELETE unfollow idempotent"),
|
||||||
|
Resp = api_test_runner:client_delete(Path, Token),
|
||||||
|
?assertEqual(CalId, maps:get(<<"calendar_id">>, Resp)),
|
||||||
|
?assertEqual(false, maps:get(<<"following">>, Resp)),
|
||||||
|
ct:pal(" OK").
|
||||||
|
|
||||||
|
test_list_following_empty(Token) ->
|
||||||
|
ct:pal(" TEST: GET following empty"),
|
||||||
|
List = api_test_runner:client_get(<<"/v1/user/following">>, Token),
|
||||||
|
?assertEqual([], List),
|
||||||
|
ct:pal(" OK").
|
||||||
|
|
||||||
|
test_unauthorized(Path) ->
|
||||||
|
ct:pal(" TEST: unauthorized"),
|
||||||
|
Resp = api_test_runner:client_request(post, Path, <<>>, <<"{}">>),
|
||||||
|
?assertMatch({ok, 401, _, _}, Resp),
|
||||||
|
ct:pal(" OK").
|
||||||
@@ -31,18 +31,21 @@ test() ->
|
|||||||
StrangerEmail = api_test_runner:unique_email(<<"stranger">>),
|
StrangerEmail = api_test_runner:unique_email(<<"stranger">>),
|
||||||
StrangerToken = api_test_runner:register_and_login(StrangerEmail, <<"pass">>),
|
StrangerToken = api_test_runner:register_and_login(StrangerEmail, <<"pass">>),
|
||||||
|
|
||||||
|
PrimaryMsg = api_test_runner:unique_ticket_message(<<"Something broke">>),
|
||||||
|
DedupeMsg = api_test_runner:unique_ticket_message(<<"Dedupe me">>),
|
||||||
|
|
||||||
% Создаём тикет
|
% Создаём тикет
|
||||||
#{<<"id">> := TicketId} = api_test_runner:client_post(<<"/v1/tickets">>, Token,
|
#{<<"id">> := TicketId} = api_test_runner:client_post(<<"/v1/tickets">>, Token,
|
||||||
#{error_message => <<"Something broke">>, stacktrace => <<"line 42">>}),
|
#{error_message => PrimaryMsg, stacktrace => <<"line 42">>}),
|
||||||
|
|
||||||
test_create_ticket(Token),
|
test_create_ticket(Token, api_test_runner:unique_ticket_message(<<"Test bug">>)),
|
||||||
test_create_ticket_dedupe(Token),
|
test_create_ticket_dedupe(Token, DedupeMsg),
|
||||||
test_create_ticket_manual(Token),
|
test_create_ticket_manual(Token),
|
||||||
test_create_ticket_missing_fields(Token),
|
test_create_ticket_missing_fields(Token),
|
||||||
test_create_ticket_unauthorized(),
|
test_create_ticket_unauthorized(),
|
||||||
test_list_tickets(Token, TicketId),
|
test_list_tickets(Token, TicketId),
|
||||||
test_list_tickets_unauthorized(),
|
test_list_tickets_unauthorized(),
|
||||||
test_get_ticket(Token, TicketId),
|
test_get_ticket(Token, TicketId, PrimaryMsg),
|
||||||
test_get_ticket_forbidden(StrangerToken, TicketId),
|
test_get_ticket_forbidden(StrangerToken, TicketId),
|
||||||
test_get_ticket_not_found(Token),
|
test_get_ticket_not_found(Token),
|
||||||
test_get_ticket_unauthorized(TicketId),
|
test_get_ticket_unauthorized(TicketId),
|
||||||
@@ -54,12 +57,12 @@ test() ->
|
|||||||
%%%===================================================================
|
%%%===================================================================
|
||||||
|
|
||||||
%% @doc Успешное создание тикета: 201 Created.
|
%% @doc Успешное создание тикета: 201 Created.
|
||||||
-spec test_create_ticket(binary()) -> ok.
|
-spec test_create_ticket(binary(), binary()) -> ok.
|
||||||
test_create_ticket(Token) ->
|
test_create_ticket(Token, ErrorMessage) ->
|
||||||
ct:pal(" TEST: Create a ticket"),
|
ct:pal(" TEST: Create a ticket"),
|
||||||
Resp = api_test_runner:client_request(post, <<"/v1/tickets">>, Token,
|
Resp = api_test_runner:client_request(post, <<"/v1/tickets">>, Token,
|
||||||
jsx:encode(#{
|
jsx:encode(#{
|
||||||
error_message => <<"Test bug">>,
|
error_message => ErrorMessage,
|
||||||
stacktrace => <<"trace">>,
|
stacktrace => <<"trace">>,
|
||||||
source => <<"frontend">>,
|
source => <<"frontend">>,
|
||||||
context => #{route => <<"/test">>, build => <<"dev">>}
|
context => #{route => <<"/test">>, build => <<"dev">>}
|
||||||
@@ -75,11 +78,11 @@ test_create_ticket(Token) ->
|
|||||||
ct:pal(" OK: ticket ~s created", [Id]).
|
ct:pal(" OK: ticket ~s created", [Id]).
|
||||||
|
|
||||||
%% @doc Повторный POST с тем же сообщением увеличивает count.
|
%% @doc Повторный POST с тем же сообщением увеличивает count.
|
||||||
-spec test_create_ticket_dedupe(binary()) -> ok.
|
-spec test_create_ticket_dedupe(binary(), binary()) -> ok.
|
||||||
test_create_ticket_dedupe(Token) ->
|
test_create_ticket_dedupe(Token, ErrorMessage) ->
|
||||||
ct:pal(" TEST: Dedupe ticket by hash"),
|
ct:pal(" TEST: Dedupe ticket by hash"),
|
||||||
Payload = jsx:encode(#{
|
Payload = jsx:encode(#{
|
||||||
error_message => <<"Dedupe me">>,
|
error_message => ErrorMessage,
|
||||||
stacktrace => <<"same stack">>,
|
stacktrace => <<"same stack">>,
|
||||||
source => <<"frontend">>
|
source => <<"frontend">>
|
||||||
}),
|
}),
|
||||||
@@ -97,7 +100,7 @@ test_create_ticket_manual(Token) ->
|
|||||||
ct:pal(" TEST: Create manual ticket"),
|
ct:pal(" TEST: Create manual ticket"),
|
||||||
Resp = api_test_runner:client_request(post, <<"/v1/tickets">>, Token,
|
Resp = api_test_runner:client_request(post, <<"/v1/tickets">>, Token,
|
||||||
jsx:encode(#{
|
jsx:encode(#{
|
||||||
error_message => <<"Button does nothing">>,
|
error_message => api_test_runner:unique_ticket_message(<<"Button does nothing">>),
|
||||||
source => <<"manual">>,
|
source => <<"manual">>,
|
||||||
context => #{steps => <<"1. Open calendar\n2. Click share">>}
|
context => #{steps => <<"1. Open calendar\n2. Click share">>}
|
||||||
})),
|
})),
|
||||||
@@ -146,13 +149,13 @@ test_list_tickets_unauthorized() ->
|
|||||||
ct:pal(" OK: got 401").
|
ct:pal(" OK: got 401").
|
||||||
|
|
||||||
%% @doc GET /v1/tickets/:id – получение своего тикета.
|
%% @doc GET /v1/tickets/:id – получение своего тикета.
|
||||||
-spec test_get_ticket(binary(), binary()) -> ok.
|
-spec test_get_ticket(binary(), binary(), binary()) -> ok.
|
||||||
test_get_ticket(Token, TicketId) ->
|
test_get_ticket(Token, TicketId, ExpectedMessage) ->
|
||||||
ct:pal(" TEST: Get my ticket by ID"),
|
ct:pal(" TEST: Get my ticket by ID"),
|
||||||
Path = <<"/v1/tickets/", TicketId/binary>>,
|
Path = <<"/v1/tickets/", TicketId/binary>>,
|
||||||
Ticket = api_test_runner:client_get(Path, Token),
|
Ticket = api_test_runner:client_get(Path, Token),
|
||||||
?assertEqual(TicketId, maps:get(<<"id">>, Ticket)),
|
?assertEqual(TicketId, maps:get(<<"id">>, Ticket)),
|
||||||
?assertEqual(<<"Something broke">>, maps:get(<<"error_message">>, Ticket)),
|
?assertEqual(ExpectedMessage, maps:get(<<"error_message">>, Ticket)),
|
||||||
ct:pal(" OK: got my ticket").
|
ct:pal(" OK: got my ticket").
|
||||||
|
|
||||||
%% @doc GET /v1/tickets/:id – попытка доступа к чужому тикету (403).
|
%% @doc GET /v1/tickets/:id – попытка доступа к чужому тикету (403).
|
||||||
@@ -179,4 +182,4 @@ test_get_ticket_unauthorized(TicketId) ->
|
|||||||
Path = <<"/v1/tickets/", TicketId/binary>>,
|
Path = <<"/v1/tickets/", TicketId/binary>>,
|
||||||
Resp = api_test_runner:client_request(get, Path, <<>>),
|
Resp = api_test_runner:client_request(get, Path, <<>>),
|
||||||
?assertMatch({ok, 401, _, _}, Resp),
|
?assertMatch({ok, 401, _, _}, Resp),
|
||||||
ct:pal(" OK: got 401").
|
ct:pal(" OK: got 401").
|
||||||
|
|||||||
@@ -47,11 +47,27 @@ test() ->
|
|||||||
#{<<"token">> := AuthToken} = jsx:decode(list_to_binary(LoginBody), [return_maps]),
|
#{<<"token">> := AuthToken} = jsx:decode(list_to_binary(LoginBody), [return_maps]),
|
||||||
?assert(is_binary(AuthToken)),
|
?assert(is_binary(AuthToken)),
|
||||||
|
|
||||||
% 6. Повторное использование того же токена – ошибка 404
|
% 6. После активации — дефолтный приватный personal-календарь
|
||||||
|
Calendars = api_test_runner:client_get(<<"/v1/calendars">>, AuthToken),
|
||||||
|
?assertEqual(1, length(Calendars)),
|
||||||
|
[DefaultCal] = Calendars,
|
||||||
|
?assertEqual(<<"personal">>, maps:get(<<"type">>, DefaultCal)),
|
||||||
|
?assertEqual(<<>>, maps:get(<<"short_name">>, DefaultCal)),
|
||||||
|
ExpectedTitle = case string:split(Email, <<"@">>) of
|
||||||
|
[Local, _] when byte_size(Local) > 0 -> Local;
|
||||||
|
_ -> <<"Мой календарь">>
|
||||||
|
end,
|
||||||
|
?assertEqual(ExpectedTitle, maps:get(<<"title">>, DefaultCal)),
|
||||||
|
|
||||||
|
% 7. Повторное использование того же токена – ошибка 404
|
||||||
{ok, 404, _, _} = api_test_runner:client_request(post, <<"/v1/verify">>, <<>>,
|
{ok, 404, _, _} = api_test_runner:client_request(post, <<"/v1/verify">>, <<>>,
|
||||||
jsx:encode(#{<<"token">> => Token})),
|
jsx:encode(#{<<"token">> => Token})),
|
||||||
|
|
||||||
% 7. Невалидный токен – ошибка 404
|
% 7. Повторное использование того же токена – ошибка 404
|
||||||
|
{ok, 404, _, _} = api_test_runner:client_request(post, <<"/v1/verify">>, <<>>,
|
||||||
|
jsx:encode(#{<<"token">> => Token})),
|
||||||
|
|
||||||
|
% 8. Невалидный токен – ошибка 404
|
||||||
{ok, 404, _, _} = api_test_runner:client_request(post, <<"/v1/verify">>, <<>>,
|
{ok, 404, _, _} = api_test_runner:client_request(post, <<"/v1/verify">>, <<>>,
|
||||||
jsx:encode(#{<<"token">> => <<"invalid_token">>})),
|
jsx:encode(#{<<"token">> => <<"invalid_token">>})),
|
||||||
|
|
||||||
|
|||||||
Regular → Executable
+4
@@ -40,6 +40,7 @@ all() ->
|
|||||||
user_test_reviews,
|
user_test_reviews,
|
||||||
user_test_review_by_id,
|
user_test_review_by_id,
|
||||||
user_test_review_vote,
|
user_test_review_vote,
|
||||||
|
user_test_calendar_follow,
|
||||||
user_test_my_reviews,
|
user_test_my_reviews,
|
||||||
user_test_search,
|
user_test_search,
|
||||||
user_test_refresh,
|
user_test_refresh,
|
||||||
@@ -140,6 +141,9 @@ user_test_review_by_id(_Config) ->
|
|||||||
user_test_review_vote(_Config) ->
|
user_test_review_vote(_Config) ->
|
||||||
user_review_vote_tests:test().
|
user_review_vote_tests:test().
|
||||||
|
|
||||||
|
user_test_calendar_follow(_Config) ->
|
||||||
|
user_calendar_follow_tests:test().
|
||||||
|
|
||||||
user_test_my_reviews(_Config) ->
|
user_test_my_reviews(_Config) ->
|
||||||
user_my_reviews_tests:test().
|
user_my_reviews_tests:test().
|
||||||
|
|
||||||
|
|||||||
Executable
+49
@@ -0,0 +1,49 @@
|
|||||||
|
-module(core_calendar_follow_tests).
|
||||||
|
-include_lib("eunit/include/eunit.hrl").
|
||||||
|
-include("records.hrl").
|
||||||
|
|
||||||
|
-define(TABLES, [calendar_follow]).
|
||||||
|
|
||||||
|
setup() ->
|
||||||
|
eh_test_support:start_mnesia(),
|
||||||
|
eh_test_support:ensure_tables(?TABLES),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
cleanup(_) ->
|
||||||
|
eh_test_support:delete_tables(?TABLES),
|
||||||
|
eh_test_support:stop_mnesia(),
|
||||||
|
ok.
|
||||||
|
|
||||||
|
core_calendar_follow_test_() ->
|
||||||
|
{foreach, fun setup/0, fun cleanup/1, [
|
||||||
|
{"follow and is_following", fun test_follow/0},
|
||||||
|
{"follow idempotent", fun test_follow_idempotent/0},
|
||||||
|
{"unfollow", fun test_unfollow/0},
|
||||||
|
{"list_by_user", fun test_list_by_user/0}
|
||||||
|
]}.
|
||||||
|
|
||||||
|
test_follow() ->
|
||||||
|
{ok, F} = core_calendar_follow:follow(<<"cal1">>, <<"u1">>),
|
||||||
|
?assertEqual(<<"cal1">>, F#calendar_follow.calendar_id),
|
||||||
|
?assertEqual(<<"u1">>, F#calendar_follow.user_id),
|
||||||
|
?assert(core_calendar_follow:is_following(<<"u1">>, <<"cal1">>)),
|
||||||
|
?assertNot(core_calendar_follow:is_following(<<"u2">>, <<"cal1">>)).
|
||||||
|
|
||||||
|
test_follow_idempotent() ->
|
||||||
|
{ok, F1} = core_calendar_follow:follow(<<"cal1">>, <<"u1">>),
|
||||||
|
{ok, F2} = core_calendar_follow:follow(<<"cal1">>, <<"u1">>),
|
||||||
|
?assertEqual(F1#calendar_follow.id, F2#calendar_follow.id),
|
||||||
|
?assertEqual(1, length(core_calendar_follow:list_by_user(<<"u1">>))).
|
||||||
|
|
||||||
|
test_unfollow() ->
|
||||||
|
{ok, _} = core_calendar_follow:follow(<<"cal1">>, <<"u1">>),
|
||||||
|
ok = core_calendar_follow:unfollow(<<"cal1">>, <<"u1">>),
|
||||||
|
?assertNot(core_calendar_follow:is_following(<<"u1">>, <<"cal1">>)),
|
||||||
|
ok = core_calendar_follow:unfollow(<<"cal1">>, <<"u1">>).
|
||||||
|
|
||||||
|
test_list_by_user() ->
|
||||||
|
{ok, _} = core_calendar_follow:follow(<<"cal1">>, <<"u1">>),
|
||||||
|
{ok, _} = core_calendar_follow:follow(<<"cal2">>, <<"u1">>),
|
||||||
|
{ok, _} = core_calendar_follow:follow(<<"cal1">>, <<"u2">>),
|
||||||
|
?assertEqual(2, length(core_calendar_follow:list_by_user(<<"u1">>))),
|
||||||
|
?assertEqual(2, length(core_calendar_follow:list_by_calendar(<<"cal1">>))).
|
||||||
@@ -27,6 +27,10 @@ core_subscription_test_() ->
|
|||||||
[
|
[
|
||||||
{"Create trial subscription", fun test_create_trial/0},
|
{"Create trial subscription", fun test_create_trial/0},
|
||||||
{"Create paid subscription", fun test_create_paid/0},
|
{"Create paid subscription", fun test_create_paid/0},
|
||||||
|
{"Create monthly duration", fun test_create_monthly_duration/0},
|
||||||
|
{"Create quarterly duration", fun test_create_quarterly_duration/0},
|
||||||
|
{"Create biannual duration", fun test_create_biannual_duration/0},
|
||||||
|
{"Create annual duration", fun test_create_annual_duration/0},
|
||||||
{"Get active by user", fun test_get_active_by_user/0},
|
{"Get active by user", fun test_get_active_by_user/0},
|
||||||
{"List by user", fun test_list_by_user/0},
|
{"List by user", fun test_list_by_user/0},
|
||||||
{"Update status", fun test_update_status/0},
|
{"Update status", fun test_update_status/0},
|
||||||
@@ -51,6 +55,35 @@ test_create_paid() ->
|
|||||||
?assertEqual(true, Sub#subscription.trial_used),
|
?assertEqual(true, Sub#subscription.trial_used),
|
||||||
?assertEqual(active, Sub#subscription.status).
|
?assertEqual(active, Sub#subscription.status).
|
||||||
|
|
||||||
|
%% Длительность не зависит от trial_used — только от Plan (месяц ≈ 30 дней).
|
||||||
|
%% add_months считает по календарным дням от полуночи даты старта.
|
||||||
|
assert_plan_duration(Sub, Months) ->
|
||||||
|
{{Y1, M1, D1}, _} = Sub#subscription.started_at,
|
||||||
|
{{Y2, M2, D2}, Time2} = Sub#subscription.expires_at,
|
||||||
|
StartDays = calendar:date_to_gregorian_days({Y1, M1, D1}),
|
||||||
|
EndDays = calendar:date_to_gregorian_days({Y2, M2, D2}),
|
||||||
|
?assertEqual(Months * 30, EndDays - StartDays),
|
||||||
|
?assertEqual({0, 0, 0}, Time2).
|
||||||
|
|
||||||
|
test_create_monthly_duration() ->
|
||||||
|
%% Даже при trial_used=false длительность = 1 месяц, не «trial 30 дней» отдельно
|
||||||
|
{ok, Sub} = core_subscription:create(<<"u-m">>, monthly, false),
|
||||||
|
?assertEqual(false, Sub#subscription.trial_used),
|
||||||
|
assert_plan_duration(Sub, 1).
|
||||||
|
|
||||||
|
test_create_quarterly_duration() ->
|
||||||
|
{ok, Sub} = core_subscription:create(<<"u-q">>, quarterly, false),
|
||||||
|
?assertEqual(false, Sub#subscription.trial_used),
|
||||||
|
assert_plan_duration(Sub, 3).
|
||||||
|
|
||||||
|
test_create_biannual_duration() ->
|
||||||
|
{ok, Sub} = core_subscription:create(<<"u-b">>, biannual, true),
|
||||||
|
assert_plan_duration(Sub, 6).
|
||||||
|
|
||||||
|
test_create_annual_duration() ->
|
||||||
|
{ok, Sub} = core_subscription:create(<<"u-a">>, annual, false),
|
||||||
|
assert_plan_duration(Sub, 12).
|
||||||
|
|
||||||
test_get_active_by_user() ->
|
test_get_active_by_user() ->
|
||||||
UserId = <<"user123">>,
|
UserId = <<"user123">>,
|
||||||
{ok, Sub1} = core_subscription:create(UserId, trial, false),
|
{ok, Sub1} = core_subscription:create(UserId, trial, false),
|
||||||
|
|||||||
Regular → Executable
+2
@@ -126,6 +126,8 @@ table_opts(calendar) ->
|
|||||||
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar)}];
|
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar)}];
|
||||||
table_opts(calendar_share) ->
|
table_opts(calendar_share) ->
|
||||||
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar_share)}];
|
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar_share)}];
|
||||||
|
table_opts(calendar_follow) ->
|
||||||
|
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar_follow)}];
|
||||||
table_opts(calendar_specialist) ->
|
table_opts(calendar_specialist) ->
|
||||||
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar_specialist)}];
|
[{ram_copies, [node()]}, {attributes, record_info(fields, calendar_specialist)}];
|
||||||
table_opts(event) ->
|
table_opts(event) ->
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
-include_lib("eunit/include/eunit.hrl").
|
-include_lib("eunit/include/eunit.hrl").
|
||||||
-include("records.hrl").
|
-include("records.hrl").
|
||||||
|
|
||||||
-define(TABLES, [user, calendar, event, booking]).
|
-define(TABLES, [user, calendar, event, booking, admin]).
|
||||||
|
|
||||||
setup() ->
|
setup() ->
|
||||||
eh_test_support:start_mnesia(),
|
eh_test_support:start_mnesia(),
|
||||||
@@ -29,6 +29,8 @@ logic_booking_test_() ->
|
|||||||
{"Cancel booking by participant", fun test_cancel_booking/0},
|
{"Cancel booking by participant", fun test_cancel_booking/0},
|
||||||
{"Cancel booking access denied", fun test_cancel_access_denied/0},
|
{"Cancel booking access denied", fun test_cancel_access_denied/0},
|
||||||
{"List event bookings", fun test_list_event_bookings/0},
|
{"List event bookings", fun test_list_event_bookings/0},
|
||||||
|
{"List event bookings as owner", fun test_list_event_bookings_owner/0},
|
||||||
|
{"List event bookings non-owner denied", fun test_list_event_bookings_non_owner/0},
|
||||||
{"List user bookings", fun test_list_user_bookings/0}
|
{"List user bookings", fun test_list_user_bookings/0}
|
||||||
]}.
|
]}.
|
||||||
|
|
||||||
@@ -163,6 +165,31 @@ test_list_event_bookings() ->
|
|||||||
{ok, Bookings} = logic_booking:list_event_bookings(EventId),
|
{ok, Bookings} = logic_booking:list_event_bookings(EventId),
|
||||||
?assertEqual(2, length(Bookings)).
|
?assertEqual(2, length(Bookings)).
|
||||||
|
|
||||||
|
test_list_event_bookings_owner() ->
|
||||||
|
OwnerId = create_test_user(user),
|
||||||
|
Participant1Id = create_test_user(user),
|
||||||
|
Participant2Id = create_test_user(user),
|
||||||
|
CalendarId = create_test_calendar(OwnerId, manual),
|
||||||
|
EventId = create_test_event(CalendarId),
|
||||||
|
|
||||||
|
{ok, _} = logic_booking:create_booking(Participant1Id, EventId),
|
||||||
|
{ok, _} = logic_booking:create_booking(Participant2Id, EventId),
|
||||||
|
|
||||||
|
{ok, Bookings} = logic_booking:list_event_bookings(OwnerId, EventId),
|
||||||
|
?assertEqual(2, length(Bookings)).
|
||||||
|
|
||||||
|
test_list_event_bookings_non_owner() ->
|
||||||
|
OwnerId = create_test_user(user),
|
||||||
|
ParticipantId = create_test_user(user),
|
||||||
|
StrangerId = create_test_user(user),
|
||||||
|
CalendarId = create_test_calendar(OwnerId, manual),
|
||||||
|
EventId = create_test_event(CalendarId),
|
||||||
|
|
||||||
|
{ok, _} = logic_booking:create_booking(ParticipantId, EventId),
|
||||||
|
|
||||||
|
{error, access_denied} = logic_booking:list_event_bookings(StrangerId, EventId),
|
||||||
|
{error, access_denied} = logic_booking:list_event_bookings(ParticipantId, EventId).
|
||||||
|
|
||||||
test_list_user_bookings() ->
|
test_list_user_bookings() ->
|
||||||
OwnerId = create_test_user(user),
|
OwnerId = create_test_user(user),
|
||||||
ParticipantId = create_test_user(user),
|
ParticipantId = create_test_user(user),
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ logic_calendar_test_() ->
|
|||||||
{"List calendars test", fun test_list_calendars/0},
|
{"List calendars test", fun test_list_calendars/0},
|
||||||
{"Update calendar test", fun test_update_calendar/0},
|
{"Update calendar test", fun test_update_calendar/0},
|
||||||
{"Delete calendar test", fun test_delete_calendar/0},
|
{"Delete calendar test", fun test_delete_calendar/0},
|
||||||
{"Access control test", fun test_access_control/0}
|
{"Access control test", fun test_access_control/0},
|
||||||
|
{"Ensure default calendar test", fun test_ensure_default_calendar/0}
|
||||||
]}.
|
]}.
|
||||||
|
|
||||||
create_test_user() ->
|
create_test_user() ->
|
||||||
@@ -41,6 +42,7 @@ create_test_user() ->
|
|||||||
password_hash = <<"hash">>,
|
password_hash = <<"hash">>,
|
||||||
role = user,
|
role = user,
|
||||||
status = active,
|
status = active,
|
||||||
|
nickname = <<>>,
|
||||||
created_at = calendar:universal_time(),
|
created_at = calendar:universal_time(),
|
||||||
updated_at = calendar:universal_time()
|
updated_at = calendar:universal_time()
|
||||||
},
|
},
|
||||||
@@ -121,4 +123,18 @@ test_access_control() ->
|
|||||||
|
|
||||||
{ok, Frozen} = core_calendar:update(CommercialCalendar#calendar.id, [{status, frozen}]),
|
{ok, Frozen} = core_calendar:update(CommercialCalendar#calendar.id, [{status, frozen}]),
|
||||||
?assertNot(logic_calendar:can_access(OtherId, Frozen)),
|
?assertNot(logic_calendar:can_access(OtherId, Frozen)),
|
||||||
?assertNot(logic_calendar:can_access(OwnerId, Frozen)).
|
?assertNot(logic_calendar:can_access(OwnerId, Frozen)).
|
||||||
|
|
||||||
|
test_ensure_default_calendar() ->
|
||||||
|
UserId = create_test_user(),
|
||||||
|
ok = logic_calendar:ensure_default_calendar(UserId),
|
||||||
|
{ok, Calendars} = logic_calendar:list_calendars(UserId),
|
||||||
|
?assertEqual(1, length(Calendars)),
|
||||||
|
Cal = hd(Calendars),
|
||||||
|
?assertEqual(personal, Cal#calendar.type),
|
||||||
|
?assertEqual(<<>>, Cal#calendar.short_name),
|
||||||
|
?assertEqual(active, Cal#calendar.status),
|
||||||
|
?assert(byte_size(Cal#calendar.title) > 0),
|
||||||
|
ok = logic_calendar:ensure_default_calendar(UserId),
|
||||||
|
{ok, Calendars2} = logic_calendar:list_calendars(UserId),
|
||||||
|
?assertEqual(1, length(Calendars2)).
|
||||||
Regular → Executable
+11
@@ -21,6 +21,7 @@ logic_review_test_() ->
|
|||||||
[
|
[
|
||||||
{"Create review for event", fun test_create_event_review/0},
|
{"Create review for event", fun test_create_event_review/0},
|
||||||
{"Create review for calendar", fun test_create_calendar_review/0},
|
{"Create review for calendar", fun test_create_calendar_review/0},
|
||||||
|
{"Cannot review calendar without booking", fun test_cannot_review_calendar_without_booking/0},
|
||||||
{"Cannot review without booking", fun test_cannot_review_without_booking/0},
|
{"Cannot review without booking", fun test_cannot_review_without_booking/0},
|
||||||
{"Cannot review twice", fun test_cannot_review_twice/0},
|
{"Cannot review twice", fun test_cannot_review_twice/0},
|
||||||
{"Update own review", fun test_update_own_review/0},
|
{"Update own review", fun test_update_own_review/0},
|
||||||
@@ -74,10 +75,20 @@ test_create_calendar_review() ->
|
|||||||
OwnerId = create_test_user(),
|
OwnerId = create_test_user(),
|
||||||
ReviewerId = create_test_user(),
|
ReviewerId = create_test_user(),
|
||||||
CalendarId = create_test_calendar(OwnerId),
|
CalendarId = create_test_calendar(OwnerId),
|
||||||
|
EventId = create_test_event(CalendarId),
|
||||||
|
create_booking(ReviewerId, EventId),
|
||||||
|
|
||||||
{ok, Review} = logic_review:create_review(ReviewerId, calendar, CalendarId, 4, <<"Nice">>),
|
{ok, Review} = logic_review:create_review(ReviewerId, calendar, CalendarId, 4, <<"Nice">>),
|
||||||
?assertEqual(4, Review#review.rating).
|
?assertEqual(4, Review#review.rating).
|
||||||
|
|
||||||
|
test_cannot_review_calendar_without_booking() ->
|
||||||
|
OwnerId = create_test_user(),
|
||||||
|
UserId = create_test_user(),
|
||||||
|
CalendarId = create_test_calendar(OwnerId),
|
||||||
|
_EventId = create_test_event(CalendarId),
|
||||||
|
|
||||||
|
{error, cannot_review} = logic_review:create_review(UserId, calendar, CalendarId, 4, <<"Nope">>).
|
||||||
|
|
||||||
test_cannot_review_without_booking() ->
|
test_cannot_review_without_booking() ->
|
||||||
OwnerId = create_test_user(),
|
OwnerId = create_test_user(),
|
||||||
UserId = create_test_user(),
|
UserId = create_test_user(),
|
||||||
|
|||||||
@@ -29,7 +29,9 @@ logic_search_test_() ->
|
|||||||
{"Pagination", fun test_pagination/0},
|
{"Pagination", fun test_pagination/0},
|
||||||
{"Sorting", fun test_sorting/0},
|
{"Sorting", fun test_sorting/0},
|
||||||
{"Access control in search", fun test_access_control/0},
|
{"Access control in search", fun test_access_control/0},
|
||||||
{"Empty search results", fun test_empty_search/0}
|
{"Empty search results", fun test_empty_search/0},
|
||||||
|
{"Discovery tops without filters", fun test_discovery_returns_tops/0},
|
||||||
|
{"Query switches to filtered search", fun test_discovery_with_q_uses_filter/0}
|
||||||
]}.
|
]}.
|
||||||
|
|
||||||
%% Вспомогательные функции
|
%% Вспомогательные функции
|
||||||
@@ -239,3 +241,34 @@ test_empty_search() ->
|
|||||||
{Total, Results} = events_from(logic_search:search(<<"event">>, <<"nonexistent">>, OwnerId, #{})),
|
{Total, Results} = events_from(logic_search:search(<<"event">>, <<"nonexistent">>, OwnerId, #{})),
|
||||||
?assertEqual(0, Total),
|
?assertEqual(0, Total),
|
||||||
?assertEqual([], Results).
|
?assertEqual([], Results).
|
||||||
|
|
||||||
|
test_discovery_returns_tops() ->
|
||||||
|
OwnerId = create_test_user(user),
|
||||||
|
ViewerId = create_test_user(user),
|
||||||
|
CalendarId = create_test_calendar(OwnerId, commercial, []),
|
||||||
|
StartTime = eh_test_support:future_start(),
|
||||||
|
LowId = create_test_event(CalendarId, <<"Low Rated">>, <<"">>, StartTime, [], undefined),
|
||||||
|
HighId = create_test_event(CalendarId, <<"High Rated">>, <<"">>, StartTime, [], undefined),
|
||||||
|
{ok, _} = core_event:update(LowId, [{rating_avg, 1.0}, {rating_count, 1}]),
|
||||||
|
{ok, _} = core_event:update(HighId, [{rating_avg, 5.0}, {rating_count, 10}]),
|
||||||
|
stats_tops:init_tables(),
|
||||||
|
stats_tops:rebuild(),
|
||||||
|
|
||||||
|
{Total, [First | _]} = events_from(logic_search:search(<<"event">>, undefined, ViewerId, #{})),
|
||||||
|
?assertEqual(2, Total),
|
||||||
|
?assertMatch(#{title := <<"High Rated">>}, First).
|
||||||
|
|
||||||
|
test_discovery_with_q_uses_filter() ->
|
||||||
|
OwnerId = create_test_user(user),
|
||||||
|
CalendarId = create_test_calendar(OwnerId, personal, []),
|
||||||
|
StartTime = eh_test_support:future_start(),
|
||||||
|
LowId = create_test_event(CalendarId, <<"Alpha">>, <<"">>, StartTime, [], undefined),
|
||||||
|
HighId = create_test_event(CalendarId, <<"Beta">>, <<"">>, StartTime, [], undefined),
|
||||||
|
{ok, _} = core_event:update(LowId, [{rating_avg, 1.0}]),
|
||||||
|
{ok, _} = core_event:update(HighId, [{rating_avg, 5.0}]),
|
||||||
|
stats_tops:init_tables(),
|
||||||
|
stats_tops:rebuild(),
|
||||||
|
|
||||||
|
{Total, Results} = events_from(logic_search:search(<<"event">>, <<"Alpha">>, OwnerId, #{})),
|
||||||
|
?assertEqual(1, Total),
|
||||||
|
?assertMatch([#{title := <<"Alpha">>}], Results).
|
||||||
|
|||||||
@@ -11,6 +11,10 @@ logic_subscription_test_() ->
|
|||||||
{"Start trial duplicate", fun test_start_trial_duplicate/0},
|
{"Start trial duplicate", fun test_start_trial_duplicate/0},
|
||||||
{"Activate subscription (no trial)", fun test_activate_subscription_no_trial/0},
|
{"Activate subscription (no trial)", fun test_activate_subscription_no_trial/0},
|
||||||
{"Activate subscription (after trial)", fun test_activate_subscription_after_trial/0},
|
{"Activate subscription (after trial)", fun test_activate_subscription_after_trial/0},
|
||||||
|
{"Activate quarterly duration", fun test_activate_quarterly_duration/0},
|
||||||
|
{"Activate monthly duration", fun test_activate_monthly_duration/0},
|
||||||
|
{"Activate biannual duration", fun test_activate_biannual_duration/0},
|
||||||
|
{"Activate annual duration", fun test_activate_annual_duration/0},
|
||||||
{"Check subscription - free", fun test_check_free/0},
|
{"Check subscription - free", fun test_check_free/0},
|
||||||
{"Check subscription - trial", fun test_check_trial/0},
|
{"Check subscription - trial", fun test_check_trial/0},
|
||||||
{"Check subscription - paid", fun test_check_paid/0},
|
{"Check subscription - paid", fun test_check_paid/0},
|
||||||
@@ -76,6 +80,38 @@ test_activate_subscription_after_trial() ->
|
|||||||
?assertEqual(monthly, Sub#subscription.plan),
|
?assertEqual(monthly, Sub#subscription.plan),
|
||||||
?assertEqual(true, Sub#subscription.trial_used).
|
?assertEqual(true, Sub#subscription.trial_used).
|
||||||
|
|
||||||
|
%% Длительность при активации (в т.ч. первой платной с trial_used=false) из Plan.
|
||||||
|
assert_plan_duration(Sub, Months) ->
|
||||||
|
{{Y1, M1, D1}, _} = Sub#subscription.started_at,
|
||||||
|
{{Y2, M2, D2}, Time2} = Sub#subscription.expires_at,
|
||||||
|
StartDays = calendar:date_to_gregorian_days({Y1, M1, D1}),
|
||||||
|
EndDays = calendar:date_to_gregorian_days({Y2, M2, D2}),
|
||||||
|
?assertEqual(Months * 30, EndDays - StartDays),
|
||||||
|
?assertEqual({0, 0, 0}, Time2).
|
||||||
|
|
||||||
|
test_activate_monthly_duration() ->
|
||||||
|
UserId = create_test_user(),
|
||||||
|
{ok, Sub} = logic_subscription:activate_subscription(UserId, monthly, #{card => "4242"}),
|
||||||
|
?assertEqual(false, Sub#subscription.trial_used),
|
||||||
|
assert_plan_duration(Sub, 1).
|
||||||
|
|
||||||
|
test_activate_quarterly_duration() ->
|
||||||
|
UserId = create_test_user(),
|
||||||
|
{ok, Sub} = logic_subscription:activate_subscription(UserId, quarterly, #{card => "4242"}),
|
||||||
|
?assertEqual(quarterly, Sub#subscription.plan),
|
||||||
|
?assertEqual(false, Sub#subscription.trial_used),
|
||||||
|
assert_plan_duration(Sub, 3).
|
||||||
|
|
||||||
|
test_activate_biannual_duration() ->
|
||||||
|
UserId = create_test_user(),
|
||||||
|
{ok, Sub} = logic_subscription:activate_subscription(UserId, biannual, #{card => "4242"}),
|
||||||
|
assert_plan_duration(Sub, 6).
|
||||||
|
|
||||||
|
test_activate_annual_duration() ->
|
||||||
|
UserId = create_test_user(),
|
||||||
|
{ok, Sub} = logic_subscription:activate_subscription(UserId, annual, #{card => "4242"}),
|
||||||
|
assert_plan_duration(Sub, 12).
|
||||||
|
|
||||||
test_check_free() ->
|
test_check_free() ->
|
||||||
UserId = create_test_user(),
|
UserId = create_test_user(),
|
||||||
{ok, free, free} = logic_subscription:check_user_subscription(UserId).
|
{ok, free, free} = logic_subscription:check_user_subscription(UserId).
|
||||||
|
|||||||
Regular → Executable
+2
-1
@@ -10,7 +10,8 @@
|
|||||||
"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"
|
"20260719210000_review_vote",
|
||||||
|
"20260720210000_calendar_follow"
|
||||||
]).
|
]).
|
||||||
|
|
||||||
setup() ->
|
setup() ->
|
||||||
|
|||||||
Reference in New Issue
Block a user