Compare commits
3 Commits
2440c50330
...
6d52bc3a8e
| Author | SHA1 | Date | |
|---|---|---|---|
| 6d52bc3a8e | |||
| e94c94d1a6 | |||
| 7c1fe1940d |
@@ -24,7 +24,7 @@ trails() ->
|
||||
#{
|
||||
path => <<"/v1/search">>,
|
||||
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">>],
|
||||
parameters => [
|
||||
#{name => <<"type">>, in => <<"query">>, schema => #{type => string, enum => [<<"calendar">>, <<"event">>]}, description => <<"Type of entities to search">>},
|
||||
|
||||
@@ -19,7 +19,12 @@ init(Req, _Opts) ->
|
||||
{ok, UserId} ->
|
||||
core_user:update(UserId, [{status, active}]),
|
||||
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} ->
|
||||
handler_utils:send_error(Req1, 410, <<"Token expired">>);
|
||||
{error, not_found} ->
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
-include("records.hrl").
|
||||
|
||||
-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([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}
|
||||
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) ->
|
||||
case core_calendar:get_by_id(CalendarId) of
|
||||
|
||||
@@ -16,6 +16,7 @@
|
||||
%% ─────────────────────────────────────────────────────────────────
|
||||
-define(DEFAULT_LIMIT, 20).
|
||||
-define(MAX_LIMIT, 100).
|
||||
-define(DISCOVERY_FETCH, 200).
|
||||
-define(EARTH_RADIUS_KM, 6371.0).
|
||||
|
||||
%%%-------------------------------------------------------------------
|
||||
@@ -36,6 +37,14 @@
|
||||
search(Type, Query, UserId, Params) ->
|
||||
Limit = min(maps:get(limit, Params, ?DEFAULT_LIMIT), ?MAX_LIMIT),
|
||||
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
|
||||
<<"event">> ->
|
||||
{ok, Total, Events} = search_events(Query, UserId, Params, Limit, Offset),
|
||||
@@ -52,6 +61,58 @@ search(Type, Query, UserId, Params) ->
|
||||
}}
|
||||
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,
|
||||
|
||||
@@ -34,10 +34,12 @@ test() ->
|
||||
UserToken = api_test_runner:get_user_token(),
|
||||
% Создаём два тикета для разных проверок
|
||||
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,
|
||||
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 текущего администратора для теста фильтрации по исполнителю
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
get_support_token/0,
|
||||
get_user_token/0,
|
||||
unique_email/1,
|
||||
unique_ticket_message/1,
|
||||
future_date/0,
|
||||
register_and_login/2,
|
||||
create_calendar/2,
|
||||
@@ -335,6 +336,13 @@ unique_email(Prefix) ->
|
||||
Unique = integer_to_binary(erlang:system_time()),
|
||||
<<Prefix/binary, "_", Unique/binary, "@test.local">>.
|
||||
|
||||
%% Уникальное сообщение тикета на прогон (IFT: иначе дедуп по error_hash
|
||||
%% возвращает чужой reporter_id и list/get своих тикетов падает).
|
||||
-spec unique_ticket_message(binary()) -> binary().
|
||||
unique_ticket_message(Prefix) ->
|
||||
Unique = integer_to_binary(erlang:unique_integer([positive, monotonic])),
|
||||
<<Prefix/binary, " ", Unique/binary>>.
|
||||
|
||||
-spec future_date() -> calendar:datetime().
|
||||
future_date() ->
|
||||
Seconds = calendar:datetime_to_gregorian_seconds(calendar:universal_time()) + 86400,
|
||||
|
||||
@@ -31,18 +31,21 @@ test() ->
|
||||
StrangerEmail = api_test_runner:unique_email(<<"stranger">>),
|
||||
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,
|
||||
#{error_message => <<"Something broke">>, stacktrace => <<"line 42">>}),
|
||||
#{error_message => PrimaryMsg, stacktrace => <<"line 42">>}),
|
||||
|
||||
test_create_ticket(Token),
|
||||
test_create_ticket_dedupe(Token),
|
||||
test_create_ticket(Token, api_test_runner:unique_ticket_message(<<"Test bug">>)),
|
||||
test_create_ticket_dedupe(Token, DedupeMsg),
|
||||
test_create_ticket_manual(Token),
|
||||
test_create_ticket_missing_fields(Token),
|
||||
test_create_ticket_unauthorized(),
|
||||
test_list_tickets(Token, TicketId),
|
||||
test_list_tickets_unauthorized(),
|
||||
test_get_ticket(Token, TicketId),
|
||||
test_get_ticket(Token, TicketId, PrimaryMsg),
|
||||
test_get_ticket_forbidden(StrangerToken, TicketId),
|
||||
test_get_ticket_not_found(Token),
|
||||
test_get_ticket_unauthorized(TicketId),
|
||||
@@ -54,12 +57,12 @@ test() ->
|
||||
%%%===================================================================
|
||||
|
||||
%% @doc Успешное создание тикета: 201 Created.
|
||||
-spec test_create_ticket(binary()) -> ok.
|
||||
test_create_ticket(Token) ->
|
||||
-spec test_create_ticket(binary(), binary()) -> ok.
|
||||
test_create_ticket(Token, ErrorMessage) ->
|
||||
ct:pal(" TEST: Create a ticket"),
|
||||
Resp = api_test_runner:client_request(post, <<"/v1/tickets">>, Token,
|
||||
jsx:encode(#{
|
||||
error_message => <<"Test bug">>,
|
||||
error_message => ErrorMessage,
|
||||
stacktrace => <<"trace">>,
|
||||
source => <<"frontend">>,
|
||||
context => #{route => <<"/test">>, build => <<"dev">>}
|
||||
@@ -75,11 +78,11 @@ test_create_ticket(Token) ->
|
||||
ct:pal(" OK: ticket ~s created", [Id]).
|
||||
|
||||
%% @doc Повторный POST с тем же сообщением увеличивает count.
|
||||
-spec test_create_ticket_dedupe(binary()) -> ok.
|
||||
test_create_ticket_dedupe(Token) ->
|
||||
-spec test_create_ticket_dedupe(binary(), binary()) -> ok.
|
||||
test_create_ticket_dedupe(Token, ErrorMessage) ->
|
||||
ct:pal(" TEST: Dedupe ticket by hash"),
|
||||
Payload = jsx:encode(#{
|
||||
error_message => <<"Dedupe me">>,
|
||||
error_message => ErrorMessage,
|
||||
stacktrace => <<"same stack">>,
|
||||
source => <<"frontend">>
|
||||
}),
|
||||
@@ -97,7 +100,7 @@ test_create_ticket_manual(Token) ->
|
||||
ct:pal(" TEST: Create manual ticket"),
|
||||
Resp = api_test_runner:client_request(post, <<"/v1/tickets">>, Token,
|
||||
jsx:encode(#{
|
||||
error_message => <<"Button does nothing">>,
|
||||
error_message => api_test_runner:unique_ticket_message(<<"Button does nothing">>),
|
||||
source => <<"manual">>,
|
||||
context => #{steps => <<"1. Open calendar\n2. Click share">>}
|
||||
})),
|
||||
@@ -146,13 +149,13 @@ test_list_tickets_unauthorized() ->
|
||||
ct:pal(" OK: got 401").
|
||||
|
||||
%% @doc GET /v1/tickets/:id – получение своего тикета.
|
||||
-spec test_get_ticket(binary(), binary()) -> ok.
|
||||
test_get_ticket(Token, TicketId) ->
|
||||
-spec test_get_ticket(binary(), binary(), binary()) -> ok.
|
||||
test_get_ticket(Token, TicketId, ExpectedMessage) ->
|
||||
ct:pal(" TEST: Get my ticket by ID"),
|
||||
Path = <<"/v1/tickets/", TicketId/binary>>,
|
||||
Ticket = api_test_runner:client_get(Path, Token),
|
||||
?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").
|
||||
|
||||
%% @doc GET /v1/tickets/:id – попытка доступа к чужому тикету (403).
|
||||
@@ -179,4 +182,4 @@ test_get_ticket_unauthorized(TicketId) ->
|
||||
Path = <<"/v1/tickets/", TicketId/binary>>,
|
||||
Resp = api_test_runner:client_request(get, Path, <<>>),
|
||||
?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]),
|
||||
?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">>, <<>>,
|
||||
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">>, <<>>,
|
||||
jsx:encode(#{<<"token">> => <<"invalid_token">>})),
|
||||
|
||||
|
||||
@@ -30,7 +30,8 @@ logic_calendar_test_() ->
|
||||
{"List calendars test", fun test_list_calendars/0},
|
||||
{"Update calendar test", fun test_update_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() ->
|
||||
@@ -41,6 +42,7 @@ create_test_user() ->
|
||||
password_hash = <<"hash">>,
|
||||
role = user,
|
||||
status = active,
|
||||
nickname = <<>>,
|
||||
created_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}]),
|
||||
?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)).
|
||||
@@ -29,7 +29,9 @@ logic_search_test_() ->
|
||||
{"Pagination", fun test_pagination/0},
|
||||
{"Sorting", fun test_sorting/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, #{})),
|
||||
?assertEqual(0, Total),
|
||||
?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).
|
||||
|
||||
Reference in New Issue
Block a user