Compare commits

...

3 Commits

Author SHA1 Message Date
aleksey 6d52bc3a8e feat: discovery tops for empty GET /v1/search. Refs EventHub/EventHubBack#50
CI / test (push) Successful in 22m57s
CI / deploy-ift (push) Successful in 3m22s
CI / e2e-ift (push) Successful in 1m12s
CI / deploy-stage (push) Successful in 4m21s
CI / e2e-stage (push) Successful in 1m27s
Co-authored-by: Cursor <cursoragent@cursor.com>
2026-07-20 12:17:36 +03:00
aleksey e94c94d1a6 test: unique ticket messages per run to avoid IFT dedupe collisions 2026-07-20 11:27:27 +03:00
aleksey 7c1fe1940d feat: create default personal calendar on email verification. Refs EventHub/EventHubBack#49 2026-07-20 11:17:31 +03:00
10 changed files with 206 additions and 25 deletions
+1 -1
View File
@@ -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">>},
+6 -1
View File
@@ -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} ->
+38 -1
View File
@@ -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
+61
View File
@@ -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,
+4 -2
View File
@@ -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 текущего администратора для теста фильтрации по исполнителю
+8
View File
@@ -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,
+17 -14
View File
@@ -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).
+18 -2
View File
@@ -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">>})),
+17 -1
View File
@@ -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()
},
@@ -122,3 +124,17 @@ 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)).
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)).
+34 -1
View File
@@ -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).