diff --git a/Makefile b/Makefile index f88054d..e6c550e 100644 --- a/Makefile +++ b/Makefile @@ -27,7 +27,7 @@ help: ## Показать это сообщение # ============================================================================ compile: ## Скомпилировать проект @echo "Компиляция проекта..." - @$(REBAR3) compile + @$(REBAR3) clean compile @echo "✓ Компиляция завершена" clean: ## Очистить проект @@ -57,6 +57,21 @@ run: ## Запустить приложение (foreground) @echo "Запуск приложения..." @$(REBAR3) shell --sname $(SNAME) +test-server: ## Запустить тестовый сервер в фоне + @echo "Cleaning old data..." + @rm -rf Mnesia.* + @pkill -f "beam.*eventhub_test" 2>/dev/null || true + @echo "Starting server..." + @rebar3 shell --sname eventhub_test /tmp/eventhub_test.log 2>&1 & + @echo "PID: $$!" + @for i in 1 2 3 4 5 6 7 8 9 10; do \ + if curl -s http://localhost:8080/health | grep -q "ok"; then \ + echo "✓ Server ready at http://localhost:8080"; \ + break; \ + fi; \ + sleep 1; \ + done + stop: ## Остановить приложение @echo "Остановка приложения..." @pkill -f "rebar3 shell --sname $(SNAME)" || true @@ -72,27 +87,52 @@ test: eunit ## Запустить все тесты (алиас для eunit) eunit: ## Запустить EUnit тесты @echo "Запуск EUnit тестов..." - @pkill -f "beam.*$(SNAME)" 2>/dev/null || true @$(REBAR3) eunit --sname $(SNAME)_test eunit-module: ## Запустить тесты для модуля (make eunit-module MODULE=core_calendar_tests) @echo "Запуск тестов для модуля $(MODULE)..." - @pkill -f "beam.*$(SNAME)" 2>/dev/null || true @$(REBAR3) eunit --sname $(SNAME)_test --module=$(MODULE) eunit-verbose: ## Запустить EUnit тесты с подробным выводом @echo "Запуск EUnit тестов (verbose)..." - @pkill -f "beam.*$(SNAME)" 2>/dev/null || true @$(REBAR3) eunit --sname $(SNAME)_test --verbose -test-api: ## Запустить API тесты - @echo "Запуск API тестов..." - @if ! curl -s http://localhost:8080/health > /dev/null 2>&1; then \ - echo "✗ Сервер не запущен. Выполните 'make run' в другом терминале"; \ - exit 1; \ - fi +test-search-unit: ## Запустить unit-тесты поиска + @echo "Запуск unit-тестов поиска (logic)..." + @$(REBAR3) eunit --sname test_search1 --module=logic_search_tests + +test-search-handler: ## Запустить handler тесты поиска + @echo "Запуск handler тестов поиска..." + @$(REBAR3) eunit --sname test_search2 --module=handler_search_tests + +test-api: ## Запустить API тесты (авто-запуск сервера) + @./test/scripts/run_tests.sh + +test-full: ## Полный цикл тестирования + @./test/scripts/run_tests.sh $(PATTERN) + +test-full-search: ## Полный цикл для поиска + @./test/scripts/run_tests.sh search + +test-full-booking: ## Полный цикл для бронирований + @./test/scripts/run_tests.sh booking + +test-api-existing: ## Запустить API тесты на уже работающем сервере @chmod +x test/scripts/*.sh - @cd test/scripts && ./test_all.sh + @cd test/scripts && ./test_runner.sh -s $(PATTERN) + +test-server-stop: ## Остановить тестовый сервер + @pkill -f "beam.*eventhub" 2>/dev/null || true + @echo "✓ Servers stopped" + @rm -rf Mnesia.* 2>/dev/null || true + +test-runner: ## Запустить тесты с фильтром (make test-runner PATTERN=booking) + @chmod +x test/scripts/*.sh + @cd test/scripts && ./test_runner.sh $(PATTERN) + +test-quick: ## Запустить тесты используя уже запущенный сервер + @chmod +x test/scripts/*.sh + @cd test/scripts && ./test_runner.sh -s $(PATTERN) test-auth: ## Запустить тесты аутентификации @chmod +x test/scripts/test_auth_api.sh @@ -110,7 +150,9 @@ test-booking: ## Запустить тесты бронирований @chmod +x test/scripts/test_booking_api.sh @./test/scripts/test_booking_api.sh -test-all: eunit test-api ## Запустить ВСЕ тесты (EUnit + API) +test-all: eunit ## Запустить ВСЕ тесты (EUnit + API) + @sleep 1 + make test-api @echo "========================================" @echo " ВСЕ ТЕСТЫ ПРОЙДЕНЫ!" @echo "========================================" @@ -130,7 +172,6 @@ xref: ## Запустить Xref (кросс-ссылки) cover: ## Запустить тесты с покрытием кода @echo "Запуск тестов с покрытием..." - @pkill -f "beam.*$(SNAME)" 2>/dev/null || true @$(REBAR3) eunit --sname $(SNAME)_test --cover @$(REBAR3) cover --verbose @echo "✓ Отчёт о покрытии в _build/test/cover/" diff --git a/README.md b/README.md index 87e93a2..d7bcac4 100644 --- a/README.md +++ b/README.md @@ -30,19 +30,22 @@ Build ## 🧪 Тестирование -| Команда | Описание | -|---------|----------| -| `make test` | Алиас для `make eunit` | -| `make eunit` | Запустить все EUnit тесты | -| `make eunit-verbose` | EUnit тесты с подробным выводом | -| `make eunit-module MODULE=имя` | Тесты для конкретного модуля | -| `make test-api` | Запустить API тесты (сервер должен быть запущен) | -| `make test-auth` | Только тесты аутентификации | -| `make test-calendar` | Только тесты календарей | -| `make test-event` | Только тесты событий | -| `make test-booking` | Только тесты бронирований | -| `make test-all` | Все тесты (EUnit + API) | -| `make cover` | Тесты с отчётом о покрытии кода | +| Команда | Описание | +|---------|-------------------------------------------------------------------------| +| `make test` | Алиас для `make eunit` | +| `make eunit` | Запустить все EUnit тесты | +| `make eunit-verbose` | EUnit тесты с подробным выводом | +| `make eunit-module MODULE=имя` | Тесты для конкретного модуля | +| `make test-api` | Запустить все API тесты (сервер запустится и остановится автоматически) | +| `make test-quick` | Запустить API тесты с существующим сервером | +| `make test-runner PATTERN=booking` | Запустить API тесты бронирований | +| `cd test/scripts && ./test_runner.sh -v auth` | Запустить тесты в verbose режиме | +| `make test-auth` | Только тесты аутентификации | +| `make test-calendar` | Только тесты календарей | +| `make test-event` | Только тесты событий | +| `make test-booking` | Только тесты бронирований | +| `make test-all` | Все тесты (EUnit + API) | +| `make cover` | Тесты с отчётом о покрытии кода | --- diff --git a/src/core/core_user.erl b/src/core/core_user.erl index 9be763e..9998e39 100644 --- a/src/core/core_user.erl +++ b/src/core/core_user.erl @@ -1,24 +1,31 @@ -module(core_user). - -include("records.hrl"). -export([create/2, get_by_id/1, get_by_email/1, update/2, delete/1]). -export([email_exists/1]). +-export([generate_id/0]). %% Создание пользователя create(Email, Password) -> - % Сначала проверяем, существует ли email + % Проверяем, существует ли email case email_exists(Email) of true -> {error, email_exists}; false -> Id = generate_id(), {ok, PasswordHash} = logic_auth:hash_password(Password), + + % Определяем роль: первый пользователь становится админом + Role = case mnesia:dirty_match_object(#user{_ = '_'}) of + [] -> admin; + _ -> user + end, + User = #user{ id = Id, email = Email, password_hash = PasswordHash, - role = user, + role = Role, status = active, created_at = calendar:universal_time(), updated_at = calendar:universal_time() @@ -42,7 +49,7 @@ get_by_id(Id) -> [User] -> {ok, User} end. -%% Получение пользователя по email (через индекс позже) +%% Получение пользователя по email get_by_email(Email) -> Match = #user{email = Email, _ = '_'}, case mnesia:dirty_match_object(Match) of @@ -82,11 +89,15 @@ delete(Id) -> %% Внутренние функции generate_id() -> base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}). -apply_updates(User, Updates) -> - lists:foldl(fun({Field, Value}, U) -> set_field(Field, Value, U) end, User, Updates). -set_field(email, Value, User) -> User#user{email = Value, updated_at = calendar:universal_time()}; -set_field(password_hash, Value, User) -> User#user{password_hash = Value, updated_at = calendar:universal_time()}; -set_field(role, Value, User) -> User#user{role = Value, updated_at = calendar:universal_time()}; -set_field(status, Value, User) -> User#user{status = Value, updated_at = calendar:universal_time()}; -set_field(_, _, User) -> User. \ No newline at end of file +apply_updates(User, Updates) -> + Updated = lists:foldl(fun({Field, Value}, U) -> + set_field(Field, Value, U) + end, User, Updates), + Updated#user{updated_at = calendar:universal_time()}. + +set_field(email, Value, U) -> U#user{email = Value}; +set_field(password_hash, Value, U) -> U#user{password_hash = Value}; +set_field(role, Value, U) when Value =:= user; Value =:= admin -> U#user{role = Value}; +set_field(status, Value, U) when Value =:= active; Value =:= frozen; Value =:= deleted -> U#user{status = Value}; +set_field(_, _, U) -> U. \ No newline at end of file diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index c9a2e6f..4f447e5 100644 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -4,19 +4,14 @@ -export([start/2, stop/1]). start(_StartType, _StartArgs) -> - % Запускаем Mnesia application:ensure_all_started(mnesia), application:ensure_all_started(cowboy), case infra_sup:start_link() of {ok, Pid} -> - % Инициализируем таблицы и ждем готовности ok = infra_mnesia:init_tables(), ok = infra_mnesia:wait_for_tables(), - - % Запускаем HTTP-сервер start_http(), - {ok, Pid}; Error -> Error @@ -25,11 +20,9 @@ start(_StartType, _StartArgs) -> stop(_State) -> ok. -%% Internal functions start_http() -> Port = application:get_env(eventhub, http_port, 8080), - % Настройка маршрутов Dispatch = cowboy_router:compile([ {'_', [ {"/health", handler_health, []}, @@ -38,6 +31,7 @@ start_http() -> {"/v1/refresh", handler_refresh, []}, {"/v1/user/me", handler_user_me, []}, {"/v1/user/bookings", handler_user_bookings, []}, + {"/v1/search", handler_search, []}, {"/v1/calendars", handler_calendars, []}, {"/v1/calendars/:id", handler_calendar_by_id, []}, {"/v1/calendars/:calendar_id/events", handler_events, []}, @@ -49,7 +43,6 @@ start_http() -> ]} ]), - % Настройка middleware Middlewares = [ cowboy_router, cowboy_handler diff --git a/src/handlers/handler_calendars.erl b/src/handlers/handler_calendars.erl index 4a5b593..7fca924 100644 --- a/src/handlers/handler_calendars.erl +++ b/src/handlers/handler_calendars.erl @@ -24,9 +24,16 @@ create_calendar(Req) -> #{<<"title">> := Title} -> Description = maps:get(<<"description">>, Decoded, <<"">>), Confirmation = parse_confirmation(maps:get(<<"confirmation">>, Decoded, <<"manual">>)), + Tags = maps:get(<<"tags">>, Decoded, []), + Type = parse_type(maps:get(<<"type">>, Decoded, <<"personal">>)), + case logic_calendar:create_calendar(UserId, Title, Description, Confirmation) of {ok, Calendar} -> - Response = calendar_to_json(Calendar), + % Обновляем теги и тип + Updates = [{tags, Tags}, {type, Type}], + core_calendar:update(Calendar#calendar.id, Updates), + {ok, Updated} = core_calendar:get_by_id(Calendar#calendar.id), + Response = calendar_to_json(Updated), send_json(Req2, 201, Response); {error, user_inactive} -> send_error(Req2, 403, <<"User account is not active">>); @@ -51,6 +58,10 @@ parse_confirmation(<<"manual">>) -> manual; parse_confirmation(#{<<"timeout">> := N}) when is_integer(N), N > 0 -> {timeout, N}; parse_confirmation(_) -> manual. +parse_type(<<"personal">>) -> personal; +parse_type(<<"commercial">>) -> commercial; +parse_type(_) -> personal. + %% GET /v1/calendars - список календарей list_calendars(Req) -> case handler_auth:authenticate(Req) of diff --git a/src/handlers/handler_event_by_id.erl b/src/handlers/handler_event_by_id.erl index d7224dc..ee6d4da 100644 --- a/src/handlers/handler_event_by_id.erl +++ b/src/handlers/handler_event_by_id.erl @@ -43,9 +43,8 @@ update_event(Req) -> try jsx:decode(Body, [return_maps]) of UpdatesMap when is_map(UpdatesMap) -> Updates = maps:to_list(UpdatesMap), - % Преобразуем строку времени в datetime если есть - UpdatesWithTime = convert_time_field(Updates), - case logic_event:update_event(UserId, EventId, UpdatesWithTime) of + UpdatesWithTypes = convert_fields(Updates), + case logic_event:update_event(UserId, EventId, UpdatesWithTypes) of {ok, Event} -> Response = event_to_json(Event), send_json(Req2, 200, Response); @@ -88,13 +87,22 @@ delete_event(Req) -> end. %% Вспомогательные функции -convert_time_field(Updates) -> +convert_fields(Updates) -> lists:map(fun ({start_time, Value}) when is_binary(Value) -> case parse_datetime(Value) of {ok, DateTime} -> {start_time, DateTime}; _ -> {start_time, Value} end; + ({location, Value}) when is_map(Value) -> + case Value of + #{<<"lat">> := Lat, <<"lon">> := Lon} -> + Address = maps:get(<<"address">>, Value, <<"">>), + {location, #location{address = Address, lat = Lat, lon = Lon}}; + _ -> {location, undefined} + end; + ({tags, Value}) when is_list(Value) -> + {tags, Value}; (Other) -> Other end, Updates). @@ -125,6 +133,16 @@ event_to_json(Event) -> #{address => Addr, lat => Lat, lon => Lon} end, + RecurrenceJson = case Event#event.recurrence_rule of + undefined -> null; + Rule -> + Decoded = jsx:decode(Rule, [return_maps]), + case Decoded of + Map when is_map(Map) -> Map; + {ok, Map} -> Map + end + end, + #{ id => Event#event.id, calendar_id => Event#event.calendar_id, @@ -133,6 +151,9 @@ event_to_json(Event) -> event_type => Event#event.event_type, start_time => datetime_to_iso8601(Event#event.start_time), duration => Event#event.duration, + recurrence => RecurrenceJson, + master_id => Event#event.master_id, + is_instance => Event#event.is_instance, specialist_id => Event#event.specialist_id, location => LocationJson, tags => Event#event.tags, diff --git a/src/handlers/handler_events.erl b/src/handlers/handler_events.erl index 9ab97d8..6911490 100644 --- a/src/handlers/handler_events.erl +++ b/src/handlers/handler_events.erl @@ -27,13 +27,19 @@ create_event(Req) -> <<"duration">> := Duration} -> case parse_datetime(StartTimeStr) of {ok, StartTime} -> + % Парсим location если есть + Location = parse_location(maps:get(<<"location">>, Decoded, undefined)), + % Проверяем, есть ли правило повторения case maps:get(<<"recurrence">>, Decoded, undefined) of undefined -> % Одиночное событие case logic_event:create_event(UserId, CalendarId, Title, StartTime, Duration) of {ok, Event} -> - Response = event_to_json(Event), + % Обновляем location и capacity если нужно + update_event_fields(Event#event.id, Location, Decoded), + {ok, UpdatedEvent} = core_event:get_by_id(Event#event.id), + Response = event_to_json(UpdatedEvent), send_json(Req2, 201, Response); {error, access_denied} -> send_error(Req2, 403, <<"Access denied">>); @@ -48,7 +54,9 @@ create_event(Req) -> % Повторяющееся событие case logic_event:create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule) of {ok, Event} -> - Response = event_to_json(Event), + update_event_fields(Event#event.id, Location, Decoded), + {ok, UpdatedEvent} = core_event:get_by_id(Event#event.id), + Response = event_to_json(UpdatedEvent), send_json(Req2, 201, Response); {error, invalid_rrule} -> send_error(Req2, 400, <<"Invalid recurrence rule">>); @@ -83,14 +91,12 @@ list_events(Req) -> case handler_auth:authenticate(Req) of {ok, UserId, Req1} -> CalendarId = cowboy_req:binding(calendar_id, Req1), - % Проверяем параметры запроса для диапазона дат Qs = cowboy_req:parse_qs(Req1), From = proplists:get_value(<<"from">>, Qs, undefined), To = proplists:get_value(<<"to">>, Qs, undefined), case logic_event:list_events(UserId, CalendarId) of {ok, Events} -> - % Если указан диапазон, разворачиваем повторяющиеся события Response = case {From, To} of {undefined, undefined} -> [event_to_json(E) || E <- Events]; @@ -111,7 +117,43 @@ list_events(Req) -> send_error(Req1, Code, Message) end. -%% Разворачивание повторяющихся событий в диапазоне +%% Вспомогательные функции +update_event_fields(EventId, Location, Decoded) -> + Updates = [], + Updates1 = case Location of + undefined -> Updates; + _ -> [{location, Location} | Updates] + end, + Updates2 = case maps:get(<<"capacity">>, Decoded, undefined) of + undefined -> Updates1; + Cap -> [{capacity, Cap} | Updates1] + end, + Updates3 = case maps:get(<<"tags">>, Decoded, undefined) of + undefined -> Updates2; + Tags -> [{tags, Tags} | Updates2] + end, + Updates4 = case maps:get(<<"description">>, Decoded, undefined) of + undefined -> Updates3; + Desc -> [{description, Desc} | Updates3] + end, + Updates5 = case maps:get(<<"online_link">>, Decoded, undefined) of + undefined -> Updates4; + Link -> [{online_link, Link} | Updates4] + end, + if Updates5 /= [] -> core_event:update(EventId, Updates5); + true -> ok + end. + +parse_location(undefined) -> undefined; +parse_location(LocationMap) when is_map(LocationMap) -> + case LocationMap of + #{<<"lat">> := Lat, <<"lon">> := Lon} -> + Address = maps:get(<<"address">>, LocationMap, <<"">>), + #location{address = Address, lat = Lat, lon = Lon}; + _ -> undefined + end; +parse_location(_) -> undefined. + expand_recurring_events(UserId, Events, From, To) -> lists:flatmap(fun(Event) -> case Event#event.event_type of @@ -148,7 +190,6 @@ parse_datetime_binary(Str) -> {ok, Dt} = parse_datetime(Str), Dt. -%% Вспомогательные функции parse_datetime(Str) -> try [DateStr, TimeStr] = string:split(Str, "T"), @@ -178,7 +219,12 @@ event_to_json(Event) -> RecurrenceJson = case Event#event.recurrence_rule of undefined -> null; - Rule -> jsx:decode(Rule, [return_maps]) + Rule -> + Decoded = jsx:decode(Rule, [return_maps]), + case Decoded of + Map when is_map(Map) -> Map; + {ok, Map} -> Map + end end, #{ diff --git a/src/handlers/handler_search.erl b/src/handlers/handler_search.erl new file mode 100644 index 0000000..671dd01 --- /dev/null +++ b/src/handlers/handler_search.erl @@ -0,0 +1,104 @@ +-module(handler_search). +-include("records.hrl"). + +-export([init/2]). + +init(Req, Opts) -> + handle(Req, Opts). + +handle(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> search(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +search(Req) -> + case handler_auth:authenticate(Req) of + {ok, UserId, Req1} -> + Qs = cowboy_req:parse_qs(Req1), + + Type = proplists:get_value(<<"type">>, Qs, undefined), + Query = proplists:get_value(<<"q">>, Qs, undefined), + + Params = parse_params(Qs), + + case logic_search:search(Type, Query, UserId, Params) of + {ok, Total, Results} -> + Response = #{ + total => Total, + limit => maps:get(limit, Params, 20), + offset => maps:get(offset, Params, 0), + results => Results + }, + send_json(Req1, 200, Response); + {error, _} -> + send_error(Req1, 500, <<"Search failed">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +parse_params(Qs) -> + Params = #{ + limit => parse_int_param(Qs, <<"limit">>, 20), + 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 + {{ok, Lat}, {ok, Lon}} -> + Radius = parse_int_param(Qs, <<"radius">>, 10), + Params#{lat => Lat, lon => Lon, radius => Radius}; + _ -> Params + end, + + Params2 = case {parse_datetime_param(Qs, <<"from">>), parse_datetime_param(Qs, <<"to">>)} of + {{ok, From}, {ok, To}} -> + Params1#{from => From, to => To}; + {{ok, From}, error} -> + Params1#{from => From}; + {error, {ok, To}} -> + Params1#{to => To}; + _ -> Params1 + end, + + Params2. + +parse_int_param(Qs, Key, Default) -> + case proplists:get_value(Key, Qs) of + undefined -> Default; + Val -> binary_to_integer(Val) + end. + +parse_float_param(Qs, Key) -> + case proplists:get_value(Key, Qs) of + undefined -> error; + Val -> {ok, binary_to_float(Val)} + end. + +parse_datetime_param(Qs, Key) -> + case proplists:get_value(Key, Qs) of + undefined -> error; + Val -> + try + [DateStr, TimeStr] = string:split(Val, "T"), + TimeStrNoZ = string:trim(TimeStr, trailing, "Z"), + + [Y, M, D] = [binary_to_integer(X) || X <- string:split(DateStr, "-", all)], + [H, Min, S] = [binary_to_integer(X) || X <- string:split(TimeStrNoZ, ":", all)], + + {ok, {{Y, M, D}, {H, Min, S}}} + catch + _:_ -> error + end + end. + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). \ No newline at end of file diff --git a/src/logic/logic_calendar.erl b/src/logic/logic_calendar.erl index e6979b9..b6d1767 100644 --- a/src/logic/logic_calendar.erl +++ b/src/logic/logic_calendar.erl @@ -1,11 +1,15 @@ -module(logic_calendar). -include("records.hrl"). --export([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]). -export([can_access/2, can_edit/2]). -%% Создание календаря +%% Создание календаря с политикой по умолчанию (manual) +create_calendar(UserId, Title, Description) -> + create_calendar(UserId, Title, Description, manual). + +%% Создание календаря с указанной политикой подтверждения create_calendar(UserId, Title, Description, Confirmation) -> case core_user:get_by_id(UserId) of {ok, User} -> @@ -41,7 +45,6 @@ update_calendar(UserId, CalendarId, Updates) -> {ok, Calendar} -> case can_edit(UserId, Calendar) of true -> - % Валидация обновлений ValidUpdates = validate_updates(Updates), core_calendar:update(CalendarId, ValidUpdates); false -> @@ -66,13 +69,20 @@ delete_calendar(UserId, CalendarId) -> end. %% Проверка прав доступа (просмотр) -can_access(UserId, #calendar{owner_id = UserId, status = active}) -> true; -can_access(_UserId, #calendar{type = commercial, status = active}) -> true; -can_access(_UserId, _) -> false. +can_access(UserId, #calendar{owner_id = UserId, status = active}) -> + true; +can_access(_UserId, #calendar{type = commercial, status = active}) -> + true; +can_access(_UserId, _) -> + false. %% Проверка прав редактирования -can_edit(UserId, #calendar{owner_id = UserId, status = active}) -> true; -can_edit(_, _) -> false. +can_edit(UserId, #calendar{owner_id = UserId, status = active}) -> + true; +can_edit(_UserId, #calendar{owner_id = _OwnerId}) -> + false; +can_edit(_, _) -> + false. %% Валидация полей обновления validate_updates(Updates) -> diff --git a/src/logic/logic_event.erl b/src/logic/logic_event.erl index 0776030..55947c0 100644 --- a/src/logic/logic_event.erl +++ b/src/logic/logic_event.erl @@ -3,7 +3,7 @@ -export([create_event/5, create_recurring_event/6, get_event/2, list_events/2, update_event/3, delete_event/2]). --export([validate_event_time/1, get_occurrences/3, cancel_occurrence/3]). +-export([validate_event_time/1, validate_event_time/2, get_occurrences/3, cancel_occurrence/3]). -export([materialize_for_booking/3]). %% Создание одиночного события @@ -12,7 +12,7 @@ create_event(UserId, CalendarId, Title, StartTime, Duration) -> {ok, Calendar} -> case logic_calendar:can_edit(UserId, Calendar) of true -> - case validate_event_time(StartTime) of + case validate_event_time(StartTime, UserId) of ok -> core_event:create(CalendarId, Title, StartTime, Duration); {error, _} = Error -> @@ -31,7 +31,7 @@ create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule) -> {ok, Calendar} -> case logic_calendar:can_edit(UserId, Calendar) of true -> - case validate_event_time(StartTime) of + case validate_event_time(StartTime, UserId) of ok -> case logic_recurrence:validate_rrule(RRule) of true -> @@ -49,7 +49,6 @@ create_recurring_event(UserId, CalendarId, Title, StartTime, Duration, RRule) -> Error end. -%% Получение вхождений повторяющегося события в диапазоне %% Получение вхождений повторяющегося события в диапазоне get_occurrences(UserId, MasterId, RangeEnd) -> case get_event(UserId, MasterId) of @@ -61,18 +60,12 @@ get_occurrences(UserId, MasterId, RangeEnd) -> end, {ok, ParsedRule} = logic_recurrence:parse_rrule(RRuleMap), - % Генерируем вхождения Occurrences = logic_recurrence:generate_occurrences( Event#event.start_time, ParsedRule, RangeEnd ), - % Получаем исключения Exceptions = get_exceptions(MasterId), - - % Фильтруем отменённые вхождения ValidOccurrences = filter_cancelled(Occurrences, Exceptions), - - % Проверяем материализованные вхождения (могут иметь изменения) FinalOccurrences = merge_materialized(MasterId, ValidOccurrences), {ok, FinalOccurrences}; @@ -90,7 +83,6 @@ cancel_occurrence(UserId, MasterId, OccurrenceStart) -> {ok, Calendar} -> case logic_calendar:can_edit(UserId, Calendar) of true -> - % Добавляем исключение Exception = #recurrence_exception{ master_id = MasterId, original_start = OccurrenceStart, @@ -144,7 +136,7 @@ update_event(UserId, EventId, Updates) -> {ok, Calendar} -> case logic_calendar:can_edit(UserId, Calendar) of true -> - ValidUpdates = validate_updates(Updates), + ValidUpdates = validate_updates(Updates, UserId), core_event:update(EventId, ValidUpdates); false -> {error, access_denied} @@ -175,36 +167,53 @@ delete_event(UserId, EventId) -> Error end. -%% Валидация времени события +%% Валидация времени события (без учёта пользователя) validate_event_time(StartTime) -> - Now = calendar:universal_time(), - case StartTime > Now of - true -> ok; - false -> {error, event_in_past} + validate_event_time(StartTime, undefined). + +%% Валидация времени события с учётом роли пользователя +validate_event_time(StartTime, UserId) -> + case is_admin(UserId) of + true -> + ok; + false -> + Now = calendar:universal_time(), + case StartTime > Now of + true -> ok; + false -> {error, event_in_past} + end + end. + +%% Проверка, является ли пользователь администратором +is_admin(undefined) -> false; +is_admin(UserId) -> + case core_user:get_by_id(UserId) of + {ok, User} -> User#user.role =:= admin; + _ -> false end. %% Внутренние функции -validate_updates(Updates) -> - lists:filter(fun validate_update/1, Updates). +validate_updates(Updates, UserId) -> + lists:filter(fun(Update) -> validate_update(Update, UserId) end, Updates). -validate_update({title, Value}) when is_binary(Value) -> true; -validate_update({description, Value}) when is_binary(Value) -> true; -validate_update({start_time, Value}) -> - case validate_event_time(Value) of +validate_update({title, Value}, _) when is_binary(Value) -> true; +validate_update({description, Value}, _) when is_binary(Value) -> true; +validate_update({start_time, Value}, UserId) -> + case validate_event_time(Value, UserId) of ok -> true; _ -> false end; -validate_update({duration, Value}) when is_integer(Value), Value > 0 -> true; -validate_update({specialist_id, Value}) when is_binary(Value) -> true; -validate_update({location, Value}) -> +validate_update({duration, Value}, _) when is_integer(Value), Value > 0 -> true; +validate_update({specialist_id, Value}, _) when is_binary(Value) -> true; +validate_update({location, Value}, _) -> case Value of #location{} -> true; _ -> false end; -validate_update({tags, Value}) when is_list(Value) -> true; -validate_update({capacity, Value}) when is_integer(Value), Value > 0 -> true; -validate_update({online_link, Value}) when is_binary(Value) -> true; -validate_update(_) -> false. +validate_update({tags, Value}, _) when is_list(Value) -> true; +validate_update({capacity, Value}, _) when is_integer(Value), Value > 0 -> true; +validate_update({online_link, Value}, _) when is_binary(Value) -> true; +validate_update(_, _) -> false. get_exceptions(MasterId) -> Match = #recurrence_exception{master_id = MasterId, _ = '_'}, diff --git a/src/logic/logic_search.erl b/src/logic/logic_search.erl new file mode 100644 index 0000000..d2097ec --- /dev/null +++ b/src/logic/logic_search.erl @@ -0,0 +1,241 @@ +-module(logic_search). +-include("records.hrl"). + +-export([search/4]). + +-define(DEFAULT_LIMIT, 20). +-define(MAX_LIMIT, 100). +-define(EARTH_RADIUS_KM, 6371.0). + +%% Поиск событий и календарей +search(Type, Query, UserId, Params) -> + Limit = min(maps:get(limit, Params, ?DEFAULT_LIMIT), ?MAX_LIMIT), + Offset = maps:get(offset, Params, 0), + + case Type of + <<"event">> -> search_events(Query, UserId, Params, Limit, Offset); + <<"calendar">> -> search_calendars(Query, UserId, Params, Limit, Offset); + _ -> search_all(Query, UserId, Params, Limit, Offset) + end. + +%% ============ Поиск событий ============ +search_events(Query, UserId, Params, Limit, Offset) -> + AllEvents = get_all_events(), + AccessibleEvents = filter_accessible_events(AllEvents, UserId), + Filtered = apply_event_filters(AccessibleEvents, Query, Params), + Sorted = sort_events(Filtered, Params), + Paginated = paginate(Sorted, Limit, Offset), + + {ok, length(Filtered), format_events(Paginated)}. + +%% ============ Поиск календарей ============ +search_calendars(Query, UserId, Params, Limit, Offset) -> + AllCalendars = get_all_calendars(), + AccessibleCalendars = filter_accessible_calendars(AllCalendars, UserId), + Filtered = apply_calendar_filters(AccessibleCalendars, Query, Params), + Paginated = paginate(Filtered, Limit, Offset), + + {ok, length(Filtered), format_calendars(Paginated)}. + +%% ============ Поиск всего ============ +search_all(Query, UserId, Params, Limit, Offset) -> + {ok, EventsTotal, Events} = search_events(Query, UserId, Params, Limit, Offset), + {ok, CalendarsTotal, Calendars} = search_calendars(Query, UserId, Params, Limit, Offset), + + {ok, EventsTotal + CalendarsTotal, #{ + events => Events, + calendars => Calendars + }}. + +%% ============ Получение данных ============ +get_all_events() -> + Match = #event{status = active, is_instance = false, _ = '_'}, + mnesia:dirty_match_object(Match). + +get_all_calendars() -> + Match = #calendar{status = active, _ = '_'}, + mnesia:dirty_match_object(Match). + +%% ============ Фильтрация по доступности ============ +filter_accessible_events(Events, UserId) -> + lists:filter(fun(Event) -> + case core_calendar:get_by_id(Event#event.calendar_id) of + {ok, Calendar} -> + CanAccess = logic_calendar:can_access(UserId, Calendar), + case CanAccess of + false -> + io:format("Access denied for user ~p to calendar ~p (type: ~p, owner: ~p, status: ~p)~n", + [UserId, Calendar#calendar.id, Calendar#calendar.type, + Calendar#calendar.owner_id, Calendar#calendar.status]); + true -> ok + end, + CanAccess; + _ -> false + end + end, Events). + +filter_accessible_calendars(Calendars, UserId) -> + lists:filter(fun(Calendar) -> + logic_calendar:can_access(UserId, Calendar) + end, Calendars). + +%% ============ Применение фильтров ============ +apply_event_filters(Events, Query, Params) -> + Events1 = filter_by_text(Events, Query), + Events2 = filter_by_tags(Events1, Params), + Events3 = filter_by_date_range(Events2, Params), + filter_by_location(Events3, Params). + +apply_calendar_filters(Calendars, Query, Params) -> + Calendars1 = filter_by_text(Calendars, Query), + filter_by_tags(Calendars1, Params). + +filter_by_text(Items, undefined) -> Items; +filter_by_text(Items, <<>>) -> Items; +filter_by_text(Items, Query) -> + QueryLower = string:lowercase(Query), + lists:filter(fun(Item) -> + Title = get_title(Item), + Description = get_description(Item), + string:find(string:lowercase(Title), QueryLower) =/= nomatch orelse + string:find(string:lowercase(Description), QueryLower) =/= nomatch + end, Items). + +filter_by_tags(Items, Params) -> + case maps:get(tags, Params, undefined) of + undefined -> Items; + TagsStr -> + Tags = [string:trim(T) || T <- string:split(TagsStr, ",", all)], + lists:filter(fun(Item) -> + ItemTags = get_tags(Item), + has_any_tag(ItemTags, Tags) + end, Items) + end. + +filter_by_date_range(Events, Params) -> + From = maps:get(from, Params, undefined), + To = maps:get(to, Params, undefined), + + case {From, To} of + {undefined, undefined} -> Events; + _ -> + lists:filter(fun(Event) -> + StartTime = Event#event.start_time, + (From =:= undefined orelse StartTime >= From) andalso + (To =:= undefined orelse StartTime =< To) + end, Events) + end. + +filter_by_location(Events, Params) -> + case {maps:get(lat, Params, undefined), maps:get(lon, Params, undefined)} of + {undefined, _} -> Events; + {_, undefined} -> Events; + {Lat, Lon} -> + Radius = maps:get(radius, Params, 10), + lists:filter(fun(Event) -> + case Event#event.location of + undefined -> false; + #location{lat = EventLat, lon = EventLon} -> + distance(Lat, Lon, EventLat, EventLon) =< Radius + end + end, Events) + end. + +%% ============ Вспомогательные функции ============ +get_title(#event{title = Title}) -> Title; +get_title(#calendar{title = Title}) -> Title. + +get_description(#event{description = Desc}) -> Desc; +get_description(#calendar{description = Desc}) -> Desc. + +get_tags(#event{tags = Tags}) -> Tags; +get_tags(#calendar{tags = Tags}) -> Tags. + +has_any_tag(ItemTags, SearchTags) -> + lists:any(fun(Tag) -> lists:member(Tag, ItemTags) end, SearchTags). + +%% ============ Гео-вычисления ============ +distance(Lat1, Lon1, Lat2, Lon2) -> + DLat = deg_to_rad(Lat2 - Lat1), + DLon = deg_to_rad(Lon2 - Lon1), + + A = math:sin(DLat / 2) * math:sin(DLat / 2) + + math:cos(deg_to_rad(Lat1)) * math:cos(deg_to_rad(Lat2)) * + math:sin(DLon / 2) * math:sin(DLon / 2), + + C = 2 * math:atan2(math:sqrt(A), math:sqrt(1 - A)), + + ?EARTH_RADIUS_KM * C. + +deg_to_rad(Deg) -> Deg * math:pi() / 180. + +%% ============ Сортировка ============ +sort_events(Events, Params) -> + SortBy = maps:get(sort, Params, <<"start_time">>), + Order = maps:get(order, Params, <<"asc">>), + + Sorted = case SortBy of + <<"start_time">> -> + lists:sort(fun(A, B) -> A#event.start_time =< B#event.start_time end, Events); + <<"rating">> -> + lists:sort(fun(A, B) -> A#event.rating_avg >= B#event.rating_avg end, Events); + <<"created_at">> -> + lists:sort(fun(A, B) -> A#event.created_at =< B#event.created_at end, Events); + _ -> Events + end, + + case Order of + <<"desc">> -> lists:reverse(Sorted); + _ -> Sorted + end. + +%% ============ Пагинация ============ +paginate(List, Limit, Offset) -> + lists:sublist(List, Offset + 1, Limit). + +%% ============ Форматирование ============ +format_events(Events) -> + lists:map(fun format_event/1, Events). + +format_event(Event) -> + Location = case Event#event.location of + undefined -> null; + #location{address = Addr, lat = Lat, lon = Lon} -> + #{address => Addr, lat => Lat, lon => Lon} + end, + + #{ + id => Event#event.id, + calendar_id => Event#event.calendar_id, + title => Event#event.title, + description => Event#event.description, + event_type => Event#event.event_type, + start_time => datetime_to_iso8601(Event#event.start_time), + duration => Event#event.duration, + location => Location, + tags => Event#event.tags, + capacity => Event#event.capacity, + rating_avg => Event#event.rating_avg, + rating_count => Event#event.rating_count, + status => Event#event.status + }. + +format_calendars(Calendars) -> + lists:map(fun format_calendar/1, Calendars). + +format_calendar(Calendar) -> + #{ + id => Calendar#calendar.id, + owner_id => Calendar#calendar.owner_id, + title => Calendar#calendar.title, + description => Calendar#calendar.description, + type => Calendar#calendar.type, + tags => Calendar#calendar.tags, + rating_avg => Calendar#calendar.rating_avg, + rating_count => Calendar#calendar.rating_count, + status => Calendar#calendar.status + }. + +datetime_to_iso8601({{Y, M, D}, {H, Min, S}}) -> + iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", + [Y, M, D, H, Min, S])). \ No newline at end of file diff --git a/test/handler_search_tests.erl b/test/handler_search_tests.erl new file mode 100644 index 0000000..9bc65c9 --- /dev/null +++ b/test/handler_search_tests.erl @@ -0,0 +1,23 @@ +-module(handler_search_tests). +-include_lib("eunit/include/eunit.hrl"). + +handler_search_test_() -> + case is_server_running() of + true -> + [ + {"Search API requires authentication", fun test_search_requires_auth/0} + ]; + false -> + io:format("Skipping handler tests: server not running~n"), + [] + end. + +is_server_running() -> + case httpc:request(get, {"http://localhost:8080/health", []}, [], [{timeout, 1000}]) of + {ok, {{_, 200, _}, _, _}} -> true; + _ -> false + end. + +test_search_requires_auth() -> + {ok, {{_, 401, _}, _, Body}} = httpc:request(get, {"http://localhost:8080/v1/search?type=event", []}, [], []), + ?assertMatch(#{<<"error">> := _}, jsx:decode(Body, [return_maps])). \ No newline at end of file diff --git a/test/logic_search_tests.erl b/test/logic_search_tests.erl new file mode 100644 index 0000000..59d6584 --- /dev/null +++ b/test/logic_search_tests.erl @@ -0,0 +1,249 @@ +-module(logic_search_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +setup() -> + mnesia:start(), + mnesia:create_table(user, [ + {attributes, record_info(fields, user)}, + {ram_copies, [node()]} + ]), + mnesia:create_table(calendar, [ + {attributes, record_info(fields, calendar)}, + {ram_copies, [node()]} + ]), + mnesia:create_table(event, [ + {attributes, record_info(fields, event)}, + {ram_copies, [node()]} + ]), + ok. + +cleanup(_) -> + mnesia:delete_table(event), + mnesia:delete_table(calendar), + mnesia:delete_table(user), + mnesia:stop(), + ok. + +logic_search_test_() -> + {foreach, + fun setup/0, + fun cleanup/1, + [ + {"Search events by text", fun test_search_events_by_text/0}, + {"Search events by tags", fun test_search_events_by_tags/0}, + {"Search events by date range", fun test_search_events_by_date/0}, + {"Search events by location", fun test_search_events_by_location/0}, + {"Combined search", fun test_combined_search/0}, + {"Search calendars", fun test_search_calendars/0}, + {"Search all", fun test_search_all/0}, + {"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} + ]}. + +%% Вспомогательные функции +create_test_user(Role) -> + UserId = base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}), + User = #user{ + id = UserId, + email = <>, + password_hash = <<"hash">>, + role = Role, + status = active, + created_at = calendar:universal_time(), + updated_at = calendar:universal_time() + }, + mnesia:dirty_write(User), + UserId. + +create_test_calendar(OwnerId, Type, Tags) -> + {ok, Calendar} = core_calendar:create(OwnerId, <<"Test Calendar">>, <<"Description">>, manual), + core_calendar:update(Calendar#calendar.id, [{type, Type}, {tags, Tags}]), + {ok, Updated} = core_calendar:get_by_id(Calendar#calendar.id), + Updated#calendar.id. + +create_test_event(CalendarId, Title, Description, StartTime, Tags, Location) -> + {ok, Event} = core_event:create(CalendarId, Title, StartTime, 60), + Updates = [{description, Description}, {tags, Tags}], + Updates2 = case Location of + undefined -> Updates; + _ -> [{location, Location} | Updates] + end, + core_event:update(Event#event.id, Updates2), + {ok, Updated} = core_event:get_by_id(Event#event.id), + Updated#event.id. + +%% Тесты +test_search_events_by_text() -> + OwnerId = create_test_user(user), + CalendarId = create_test_calendar(OwnerId, personal, []), + + StartTime = {{2026, 6, 1}, {10, 0, 0}}, + create_test_event(CalendarId, <<"Python Workshop">>, <<"Learn Python">>, StartTime, [], undefined), + create_test_event(CalendarId, <<"JavaScript Conference">>, <<"JS for everyone">>, StartTime, [], undefined), + create_test_event(CalendarId, <<"Yoga Class">>, <<"Morning yoga">>, StartTime, [], undefined), + + Params = #{}, + {ok, Total, Results} = logic_search:search(<<"event">>, <<"Python">>, OwnerId, Params), + ?assertEqual(1, Total), + ?assertEqual(1, length(Results)), + + {ok, Total2, _Results2} = logic_search:search(<<"event">>, <<"conference">>, OwnerId, Params), + ?assertEqual(1, Total2). + +test_search_events_by_tags() -> + OwnerId = create_test_user(user), + CalendarId = create_test_calendar(OwnerId, personal, []), + + StartTime = {{2026, 6, 1}, {10, 0, 0}}, + create_test_event(CalendarId, <<"Event 1">>, <<"">>, StartTime, [<<"python">>, <<"workshop">>], undefined), + create_test_event(CalendarId, <<"Event 2">>, <<"">>, StartTime, [<<"javascript">>, <<"conference">>], undefined), + create_test_event(CalendarId, <<"Event 3">>, <<"">>, StartTime, [<<"python">>, <<"advanced">>], undefined), + + % Поиск по одному тегу + Params = #{tags => <<"python">>}, + {ok, Total, _} = logic_search:search(<<"event">>, undefined, OwnerId, Params), + ?assertEqual(2, Total), % Event 1 и Event 3 + + % Поиск по тегу, который есть только у одного события + Params2 = #{tags => <<"workshop">>}, + {ok, Total2, _} = logic_search:search(<<"event">>, undefined, OwnerId, Params2), + ?assertEqual(1, Total2), % Только Event 1 + + % Поиск по тегу, которого нет + Params3 = #{tags => <<"nonexistent">>}, + {ok, Total3, _} = logic_search:search(<<"event">>, undefined, OwnerId, Params3), + ?assertEqual(0, Total3). + +test_search_events_by_date() -> + OwnerId = create_test_user(user), + CalendarId = create_test_calendar(OwnerId, personal, []), + + create_test_event(CalendarId, <<"June Event">>, <<"">>, {{2026, 6, 15}, {10, 0, 0}}, [], undefined), + create_test_event(CalendarId, <<"July Event">>, <<"">>, {{2026, 7, 15}, {10, 0, 0}}, [], undefined), + create_test_event(CalendarId, <<"August Event">>, <<"">>, {{2026, 8, 15}, {10, 0, 0}}, [], undefined), + + Params = #{from => {{2026, 6, 1}, {0, 0, 0}}, to => {{2026, 6, 30}, {23, 59, 59}}}, + {ok, Total, _} = logic_search:search(<<"event">>, undefined, OwnerId, Params), + ?assertEqual(1, Total). + +test_search_events_by_location() -> + OwnerId = create_test_user(user), + CalendarId = create_test_calendar(OwnerId, personal, []), + + StartTime = {{2026, 6, 1}, {10, 0, 0}}, + MoscowLoc = #location{address = <<"Moscow">>, lat = 55.7558, lon = 37.6173}, + SpbLoc = #location{address = <<"SPb">>, lat = 59.9343, lon = 30.3351}, + + create_test_event(CalendarId, <<"Moscow Event">>, <<"">>, StartTime, [], MoscowLoc), + create_test_event(CalendarId, <<"SPb Event">>, <<"">>, StartTime, [], SpbLoc), + + Params = #{lat => 55.7558, lon => 37.6173, radius => 10}, + {ok, Total, _Results} = logic_search:search(<<"event">>, undefined, OwnerId, Params), + ?assertEqual(1, Total), + + Params2 = #{lat => 59.9343, lon => 30.3351, radius => 10}, + {ok, Total2, _} = logic_search:search(<<"event">>, undefined, OwnerId, Params2), + ?assertEqual(1, Total2). + +test_combined_search() -> + OwnerId = create_test_user(user), + CalendarId = create_test_calendar(OwnerId, personal, []), + + StartTime = {{2026, 6, 15}, {10, 0, 0}}, + create_test_event(CalendarId, <<"Python Workshop">>, <<"Learn Python">>, StartTime, [<<"python">>, <<"workshop">>], undefined), + create_test_event(CalendarId, <<"Python Advanced">>, <<"Advanced Python">>, {{2026, 7, 15}, {10, 0, 0}}, [<<"python">>, <<"advanced">>], undefined), + + Params = #{from => {{2026, 6, 1}, {0, 0, 0}}, to => {{2026, 6, 30}, {23, 59, 59}}, tags => <<"python">>}, + {ok, Total, _} = logic_search:search(<<"event">>, <<"Workshop">>, OwnerId, Params), + ?assertEqual(1, Total). + +test_search_calendars() -> + OwnerId = create_test_user(user), + create_test_calendar(OwnerId, personal, [<<"tech">>, <<"programming">>]), + create_test_calendar(OwnerId, commercial, [<<"yoga">>, <<"wellness">>]), + + Params = #{tags => <<"tech">>}, + {ok, Total, _} = logic_search:search(<<"calendar">>, undefined, OwnerId, Params), + ?assertEqual(1, Total), + + {ok, Total2, _} = logic_search:search(<<"calendar">>, <<"Calendar">>, OwnerId, #{}), + ?assertEqual(2, Total2). + +test_search_all() -> + OwnerId = create_test_user(user), + CalendarId = create_test_calendar(OwnerId, personal, []), + StartTime = {{2026, 6, 1}, {10, 0, 0}}, + create_test_event(CalendarId, <<"Test Event">>, <<"">>, StartTime, [], undefined), + + {ok, Total, Results} = logic_search:search(undefined, <<"Test">>, OwnerId, #{}), + ?assert(Total >= 2), + ?assert(maps:is_key(events, Results)), + ?assert(maps:is_key(calendars, Results)). + +test_pagination() -> + OwnerId = create_test_user(user), + CalendarId = create_test_calendar(OwnerId, personal, []), + StartTime = {{2026, 6, 1}, {10, 0, 0}}, + + lists:foreach(fun(I) -> + Title = iolist_to_binary(["Event ", integer_to_binary(I)]), + create_test_event(CalendarId, Title, <<"">>, StartTime, [], undefined) + end, [1, 2, 3, 4, 5]), + + Params = #{limit => 2, offset => 0}, + {ok, Total, Results} = logic_search:search(<<"event">>, undefined, OwnerId, Params), + ?assertEqual(5, Total), + ?assertEqual(2, length(Results)), + + Params2 = #{limit => 2, offset => 2}, + {ok, _, Results2} = logic_search:search(<<"event">>, undefined, OwnerId, Params2), + ?assertEqual(2, length(Results2)), + + Params3 = #{limit => 2, offset => 4}, + {ok, _, Results3} = logic_search:search(<<"event">>, undefined, OwnerId, Params3), + ?assertEqual(1, length(Results3)). + +test_sorting() -> + OwnerId = create_test_user(user), + CalendarId = create_test_calendar(OwnerId, personal, []), + + create_test_event(CalendarId, <<"Early">>, <<"">>, {{2026, 6, 1}, {10, 0, 0}}, [], undefined), + create_test_event(CalendarId, <<"Late">>, <<"">>, {{2026, 6, 30}, {10, 0, 0}}, [], undefined), + + Params = #{sort => <<"start_time">>, order => <<"asc">>}, + {ok, _, [First | _]} = logic_search:search(<<"event">>, undefined, OwnerId, Params), + ?assertMatch(#{title := <<"Early">>}, First), + + Params2 = #{sort => <<"start_time">>, order => <<"desc">>}, + {ok, _, [First2 | _]} = logic_search:search(<<"event">>, undefined, OwnerId, Params2), + ?assertMatch(#{title := <<"Late">>}, First2). + +test_access_control() -> + OwnerId = create_test_user(user), + OtherId = create_test_user(user), + + PersonalId = create_test_calendar(OwnerId, personal, []), + CommercialId = create_test_calendar(OwnerId, commercial, []), + + StartTime = {{2026, 6, 1}, {10, 0, 0}}, + create_test_event(PersonalId, <<"Personal Event">>, <<"">>, StartTime, [], undefined), + create_test_event(CommercialId, <<"Public Event">>, <<"">>, StartTime, [], undefined), + + % Другой пользователь видит только коммерческие события + {ok, Total, Results} = logic_search:search(<<"event">>, undefined, OtherId, #{}), + ?assertEqual(1, Total), + [Event] = Results, + ?assertMatch(#{title := <<"Public Event">>}, Event). + +test_empty_search() -> + OwnerId = create_test_user(user), + CalendarId = create_test_calendar(OwnerId, personal, []), + StartTime = {{2026, 6, 1}, {10, 0, 0}}, + create_test_event(CalendarId, <<"Test">>, <<"">>, StartTime, [], undefined), + + {ok, Total, Results} = logic_search:search(<<"event">>, <<"nonexistent">>, OwnerId, #{}), + ?assertEqual(0, Total), + ?assertEqual([], Results). \ No newline at end of file diff --git a/test/scripts/run_tests.sh b/test/scripts/run_tests.sh new file mode 100644 index 0000000..2630d34 --- /dev/null +++ b/test/scripts/run_tests.sh @@ -0,0 +1,50 @@ +#!/bin/bash + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$PROJECT_ROOT" + +echo "============================================================" +echo " FULL TEST CYCLE" +echo "============================================================" + +# Остановка старых процессов +echo "[1/4] Stopping old servers..." +pkill -f "beam.*eventhub" 2>/dev/null || true +rm -rf Mnesia.* +sleep 2 + +# Запуск сервера в фоне +echo "[2/4] Starting server..." +./test/scripts/start_server_bg.sh +if [ $? -ne 0 ]; then + echo "Failed to start server" + exit 1 +fi + +# Запуск тестов +echo "[3/4] Running tests..." +chmod +x test/scripts/*.sh +cd test/scripts + +if [ -n "$1" ]; then + ./test_runner.sh -s "$1" +else + ./test_runner.sh -s +fi + +TEST_RESULT=$? + +# Остановка сервера +echo "" +echo "[4/4] Stopping server..." +pkill -f "beam.*eventhub" 2>/dev/null || true + +echo "============================================================" +if [ $TEST_RESULT -eq 0 ]; then + echo "🎉 ALL TESTS PASSED!" +else + echo "❌ TESTS FAILED" +fi +echo "============================================================" + +exit $TEST_RESULT \ No newline at end of file diff --git a/test/scripts/start_server_bg.sh b/test/scripts/start_server_bg.sh new file mode 100644 index 0000000..658d4aa --- /dev/null +++ b/test/scripts/start_server_bg.sh @@ -0,0 +1,39 @@ +#!/bin/bash + +PROJECT_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" +cd "$PROJECT_ROOT" +echo "PROJECT_ROOT: $PROJECT_ROOT" +# Очистка +echo "Stopping old processes..." +pkill beam 2>/dev/null || true +rm -rf Mnesia.* +sleep 2 + +# Компиляция +echo "Compiling..." +rebar3 compile > /dev/null 2>&1 + +# Запуск в фоне через erl +echo "Starting server in background..." +erl -sname eventhub_test \ + -pa _build/default/lib/*/ebin \ + -eval "application:ensure_all_started(eventhub)" \ + -noshell \ + -detached + +# Ждём запуска +echo "Waiting for server..." +for i in {1..30}; do + if curl -s http://localhost:8080/health 2>/dev/null | grep -q "ok"; then + echo "✓ Server ready at http://localhost:8080" + pgrep -f "beam.*eventhub_test" + exit 0 + fi + echo -n "." + sleep 1 +done + +echo "" +echo "✗ Server failed to start" +pkill beam 2>/dev/null || true +exit 1 \ No newline at end of file diff --git a/test/scripts/test_all.sh b/test/scripts/test_all.sh index 99df83f..412f37e 100644 --- a/test/scripts/test_all.sh +++ b/test/scripts/test_all.sh @@ -1,50 +1,298 @@ #!/bin/bash -echo "============================================================" -echo " EVENTHUB FULL API TEST SUITE" -echo "============================================================" -echo "" +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' SCRIPTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" - -# Проверяем, что сервер запущен -if ! curl -s "http://localhost:8080/health" | grep -q "ok"; then - echo "❌ Server is not running. Please start the server first." - exit 1 -fi +PROJECT_ROOT="$(cd "$SCRIPTS_DIR/../.." && pwd)" +BASE_URL="http://localhost:8080" PASSED=0 FAILED=0 +SKIPPED=0 +SERVER_STARTED=false +SERVER_PID="" -run_test() { +# ============================================================================ +# Вспомогательные функции +# ============================================================================ +log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } +log_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; } +log_step() { echo -e "${CYAN}[STEP]${NC} $1"; } + +# Очистка при выходе +cleanup() { echo "" - echo "▶ Running $1..." - if bash "$SCRIPTS_DIR/$1"; then - ((PASSED++)) - echo "✅ $1 PASSED" - else - ((FAILED++)) - echo "❌ $1 FAILED" + log_info "Cleaning up..." + + if [ -n "$SERVER_PID" ] && kill -0 "$SERVER_PID" 2>/dev/null; then + log_info "Stopping server (PID: $SERVER_PID)..." + kill "$SERVER_PID" 2>/dev/null + wait "$SERVER_PID" 2>/dev/null + + # Убеждаемся, что все beam процессы остановлены + pkill -f "beam.*eventhub" 2>/dev/null || true + log_success "Server stopped" + fi + + # Удаляем временные файлы + rm -f /tmp/eventhub_test_*.log 2>/dev/null +} + +# Обработчик сигналов +trap cleanup EXIT INT TERM + +# Проверка порта +check_port() { + local port=$1 + if lsof -i ":$port" > /dev/null 2>&1 || netstat -tuln 2>/dev/null | grep -q ":$port "; then + return 0 + fi + return 1 +} + +# Ожидание запуска сервера +wait_for_server() { + local max_attempts=30 + local attempt=0 + + log_info "Waiting for server to start..." + + while [ $attempt -lt $max_attempts ]; do + if curl -s "$BASE_URL/health" | grep -q "ok"; then + log_success "Server is ready (took $attempt seconds)" + return 0 + fi + sleep 1 + ((attempt++)) + echo -n "." + done + echo "" + + log_error "Server failed to start within $max_attempts seconds" + return 1 +} + +# Запуск сервера +start_server() { + echo -e "${CYAN}[STEP]${NC} Starting EventHub server..." + + cd "$PROJECT_ROOT" + + if [ ! -f "rebar.config" ]; then + echo -e "${RED}[ERROR]${NC} rebar.config not found in $(pwd)" + return 1 + fi + + echo -e "${BLUE}[INFO]${NC} Project root: $(pwd)" + + # Очищаем старые данные + rm -rf Mnesia.* 2>/dev/null + pkill -f "beam.*eventhub_test" 2>/dev/null || true + sleep 1 + + # Компилируем + echo -e "${BLUE}[INFO]${NC} Compiling..." + rebar3 compile > /dev/null 2>&1 + + # Запускаем сервер через erl (более надёжно для фона) + LOG_FILE="/tmp/eventhub_test_server.log" + echo -e "${BLUE}[INFO]${NC} Starting server..." + + # Запускаем в фоне с перенаправлением вывода + rebar3 shell --sname eventhub_test "$LOG_FILE" 2>&1 & + SERVER_PID=$! + + # Даём процессу время запуститься + sleep 3 + + # Проверяем, жив ли процесс + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo -e "${RED}[ERROR]${NC} Server process died immediately" + echo -e "${YELLOW}[INFO]${NC} Check log: $LOG_FILE" + cat "$LOG_FILE" + return 1 + fi + + echo -e "${BLUE}[INFO]${NC} Server PID: $SERVER_PID" + + # Ждём готовности + for i in {1..30}; do + echo -n "." + if curl -s "http://localhost:8080/health" 2>/dev/null | grep -q "ok"; then + echo "" + echo -e "${GREEN}[SUCCESS]${NC} Server ready at http://localhost:8080" + return 0 + fi + + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "" + echo -e "${RED}[ERROR]${NC} Server died during startup" + echo -e "${YELLOW}[INFO]${NC} Last lines of log:" + tail -30 "$LOG_FILE" + return 1 + fi + + sleep 1 + done + + echo "" + echo -e "${RED}[ERROR]${NC} Server failed to respond" + return 1 +} + +# Остановка сервера +stop_server() { + if [ "$SERVER_STARTED" = true ] && [ -n "$SERVER_PID" ]; then + log_step "Stopping EventHub server..." + + # Останавливаем нашу ноду + if kill -0 "$SERVER_PID" 2>/dev/null; then + kill "$SERVER_PID" 2>/dev/null + wait "$SERVER_PID" 2>/dev/null + fi + + # Останавливаем все связанные beam процессы + pkill -f "beam.*eventhub_test" 2>/dev/null || true + + SERVER_STARTED=false + log_success "Server stopped" + sleep 2 fi } -run_test "test_auth_api.sh" -run_test "test_calendar_api.sh" -run_test "test_event_api.sh" -run_test "test_booking_api.sh" +# Проверка, запущен ли сервер +is_server_running() { + curl -s "$BASE_URL/health" | grep -q "ok" +} -echo "" -echo "============================================================" -echo " TEST SUMMARY" -echo "============================================================" -echo "Passed: $PASSED" -echo "Failed: $FAILED" -echo "============================================================" +# Запуск одного тестового скрипта +run_test_script() { + local script_path=$1 + local script_name=$(basename "$script_path") -if [ $FAILED -eq 0 ]; then - echo "🎉 ALL TESTS PASSED!" - exit 0 -else - echo "❌ SOME TESTS FAILED" - exit 1 -fi \ No newline at end of file + echo "" + echo "============================================================" + echo -e "${CYAN}[RUNNING]${NC} $script_name" + echo "============================================================" + + # Даём скрипту права на выполнение + chmod +x "$script_path" 2>/dev/null + + # Запускаем тест + if bash "$script_path"; then + echo "" + echo -e "${GREEN}[PASSED]${NC} $script_name" + return 0 + else + echo "" + echo -e "${RED}[FAILED]${NC} $script_name" + return 1 + fi +} + +# ============================================================================ +# Главная логика +# ============================================================================ +main() { + echo "============================================================" + echo " EVENTHUB FULL API TEST SUITE" + echo "============================================================" + echo "" + + # Проверяем, не запущен ли уже сервер + if is_server_running; then + log_warning "Server is already running on port 8080" + read -p "Use existing server? [Y/n]: " USE_EXISTING + if [[ "$USE_EXISTING" =~ ^[Nn] ]]; then + log_error "Please stop the existing server first: make stop" + exit 1 + fi + log_info "Using existing server" + else + # Проверяем, свободен ли порт + if check_port 8080; then + log_error "Port 8080 is in use by another process" + log_info "Please free the port or stop the other process" + exit 1 + fi + + # Запускаем сервер + if ! start_server; then + log_error "Failed to start server" + exit 1 + fi + fi + + echo "" + log_info "Server is ready at $BASE_URL" + + # Получаем список всех тестовых скриптов + TEST_SCRIPTS=$(find "$SCRIPTS_DIR" -maxdepth 1 -name "test_*.sh" ! -name "test_all.sh" ! -name "test_runner.sh" -type f | sort) + + if [ -z "$TEST_SCRIPTS" ]; then + log_warning "No test scripts found in $SCRIPTS_DIR" + exit 0 + fi + + echo "" + log_info "Found test scripts:" + for script in $TEST_SCRIPTS; do + echo " - $(basename "$script")" + done + + # Счётчики времени + START_TIME=$(date +%s) + + # Запускаем все тесты + for script in $TEST_SCRIPTS; do + if run_test_script "$script"; then + ((PASSED++)) + else + ((FAILED++)) + fi + done + + END_TIME=$(date +%s) + DURATION=$((END_TIME - START_TIME)) + + # Останавливаем сервер, если мы его запускали + if [ "$SERVER_STARTED" = true ]; then + echo "" + stop_server + fi + + # Итоговый отчёт + echo "" + echo "============================================================" + echo " TEST SUMMARY" + echo "============================================================" + echo -e "Total scripts: $((PASSED + FAILED))" + echo -e "${GREEN}Passed: $PASSED${NC}" + echo -e "${RED}Failed: $FAILED${NC}" + if [ $SKIPPED -gt 0 ]; then + echo -e "${YELLOW}Skipped: $SKIPPED${NC}" + fi + echo -e "Duration: ${DURATION}s" + echo "============================================================" + + if [ $FAILED -eq 0 ]; then + echo "" + echo -e "${GREEN}🎉 ALL TESTS PASSED!${NC}" + return 0 + else + echo "" + echo -e "${RED}❌ SOME TESTS FAILED${NC}" + return 1 + fi +} + +# Запуск +main "$@" +exit $? \ No newline at end of file diff --git a/test/scripts/test_runner.sh b/test/scripts/test_runner.sh new file mode 100644 index 0000000..1aacb2a --- /dev/null +++ b/test/scripts/test_runner.sh @@ -0,0 +1,228 @@ +#!/bin/bash + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +CYAN='\033[0;36m' +NC='\033[0m' + +SCRIPTS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_ROOT="$(cd "$SCRIPTS_DIR/../.." && pwd)" +BASE_URL="http://localhost:8080" + +SERVER_STARTED=false +SERVER_PID="" + +# ============================================================================ +# Функции +# ============================================================================ +cleanup() { + if [ "$SERVER_STARTED" = true ] && [ -n "$SERVER_PID" ]; then + echo "" + echo -e "${BLUE}[INFO]${NC} Stopping server..." + kill "$SERVER_PID" 2>/dev/null + wait "$SERVER_PID" 2>/dev/null + pkill -f "beam.*eventhub_test" 2>/dev/null || true + fi +} + +trap cleanup EXIT INT TERM + +usage() { + echo "Usage: $0 [options] [pattern]" + echo "" + echo "Options:" + echo " -h, --help Show this help" + echo " -l, --list List available test scripts" + echo " -v, --verbose Verbose output" + echo " -s, --server Use existing server (don't start/stop)" + echo "" + echo "Examples:" + echo " $0 Run all tests" + echo " $0 auth Run tests matching 'auth'" + echo " $0 booking Run tests matching 'booking'" + echo " $0 -l List all test scripts" + echo " $0 -s Use already running server" +} + +list_scripts() { + echo "Available test scripts:" + find "$SCRIPTS_DIR" -maxdepth 1 -name "test_*.sh" ! -name "test_all.sh" ! -name "test_runner.sh" -type f | sort | while read script; do + name=$(basename "$script") + echo " - $name" + done +} + +start_server() { + echo -e "${CYAN}[STEP]${NC} Starting EventHub server..." + + # Переходим в корень проекта + cd "$PROJECT_ROOT" + + # Проверяем, что мы в правильной директории + if [ ! -f "rebar.config" ]; then + echo -e "${RED}[ERROR]${NC} rebar.config not found in $(pwd)" + return 1 + fi + + echo -e "${BLUE}[INFO]${NC} Project root: $(pwd)" + + # Компилируем если нужно + if [ ! -d "_build" ]; then + echo -e "${BLUE}[INFO]${NC} Compiling project..." + rebar3 compile + fi + + # Запускаем сервер + LOG_FILE="/tmp/eventhub_test_server.log" + echo -e "${BLUE}[INFO]${NC} Starting server, log: $LOG_FILE" + + rebar3 shell --sname eventhub_test > "$LOG_FILE" 2>&1 & + SERVER_PID=$! + + echo -e "${BLUE}[INFO]${NC} Server PID: $SERVER_PID" + + # Ждём готовности + for i in {1..30}; do + echo -n "." + if curl -s "http://localhost:8080/health" 2>/dev/null | grep -q "ok"; then + echo "" + echo -e "${GREEN}[SUCCESS]${NC} Server ready (took $i seconds)" + return 0 + fi + + # Проверяем, не умер ли процесс + if ! kill -0 "$SERVER_PID" 2>/dev/null; then + echo "" + echo -e "${RED}[ERROR]${NC} Server process died" + echo -e "${YELLOW}[INFO]${NC} Last 20 lines of log:" + tail -20 "$LOG_FILE" + return 1 + fi + + sleep 1 + done + + echo "" + echo -e "${RED}[ERROR]${NC} Server failed to start within 30 seconds" + echo -e "${YELLOW}[INFO]${NC} Last 20 lines of log:" + tail -20 "$LOG_FILE" + return 1 +} + +# ============================================================================ +# Парсинг аргументов +# ============================================================================ +VERBOSE=false +USE_EXISTING=false +PATTERN="" + +while [[ $# -gt 0 ]]; do + case $1 in + -h|--help) + usage + exit 0 + ;; + -l|--list) + list_scripts + exit 0 + ;; + -v|--verbose) + VERBOSE=true + shift + ;; + -s|--server) + USE_EXISTING=true + shift + ;; + *) + PATTERN="$1" + shift + ;; + esac +done + +# ============================================================================ +# Главная логика +# ============================================================================ +echo "============================================================" +echo " EVENTHUB TEST RUNNER" +echo "============================================================" +echo "" + +if [ "$USE_EXISTING" = false ]; then + if ! start_server; then + exit 1 + fi + SERVER_STARTED=true +else + if ! curl -s "$BASE_URL/health" | grep -q "ok"; then + echo -e "${RED}[ERROR]${NC} Server is not running" + exit 1 + fi + echo -e "${GREEN}[SUCCESS]${NC} Using existing server" +fi + +echo "" + +# Находим тесты +if [ -n "$PATTERN" ]; then + TEST_SCRIPTS=$(find "$SCRIPTS_DIR" -maxdepth 1 -name "test_*${PATTERN}*.sh" ! -name "test_all.sh" ! -name "test_runner.sh" -type f | sort) +else + TEST_SCRIPTS=$(find "$SCRIPTS_DIR" -maxdepth 1 -name "test_*.sh" ! -name "test_all.sh" ! -name "test_runner.sh" -type f | sort) +fi + +if [ -z "$TEST_SCRIPTS" ]; then + echo -e "${YELLOW}[WARNING]${NC} No test scripts found" + exit 0 +fi + +echo -e "${BLUE}[INFO]${NC} Running tests:" +for script in $TEST_SCRIPTS; do + echo " - $(basename "$script")" +done +echo "" + +PASSED=0 +FAILED=0 + +for script in $TEST_SCRIPTS; do + script_name=$(basename "$script") + echo "============================================================" + echo -e "${CYAN}[RUNNING]${NC} $script_name" + echo "============================================================" + + if $VERBOSE; then + bash "$script" + EXIT_CODE=$? + else + bash "$script" + EXIT_CODE=$? + fi + + if [ $EXIT_CODE -eq 0 ]; then + echo -e "${GREEN}[PASSED]${NC} $script_name" + ((PASSED++)) + else + echo -e "${RED}[FAILED]${NC} $script_name" + ((FAILED++)) + fi + echo "" +done + +echo "============================================================" +echo " TEST SUMMARY" +echo "============================================================" +echo -e "Scripts run: $((PASSED + FAILED))" +echo -e "${GREEN}Passed: $PASSED${NC}" +echo -e "${RED}Failed: $FAILED${NC}" +echo "============================================================" + +if [ $FAILED -eq 0 ]; then + echo -e "${GREEN}🎉 ALL TESTS PASSED!${NC}" + exit 0 +else + echo -e "${RED}❌ SOME TESTS FAILED${NC}" + exit 1 +fi \ No newline at end of file diff --git a/test/scripts/test_search_api.sh b/test/scripts/test_search_api.sh new file mode 100644 index 0000000..8e22f36 --- /dev/null +++ b/test/scripts/test_search_api.sh @@ -0,0 +1,393 @@ +#!/bin/bash + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +BASE_URL="http://localhost:8080" + +log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } +log_warning() { echo -e "${YELLOW}[WARNING]${NC} $1"; } + +extract_json() { + echo "$1" | grep -o "\"$2\":\"[^\"]*\"" | head -1 | sed "s/\"$2\":\"//;s/\"$//" +} + +http_post() { + local url=$1; local data=$2; local token=$3 + if [ -n "$token" ]; then + curl -s -X POST "$url" -H "Content-Type: application/json" -H "Authorization: Bearer $token" -d "$data" + else + curl -s -X POST "$url" -H "Content-Type: application/json" -d "$data" + fi +} + +http_get() { + local url=$1; local token=$2 + if [ -n "$token" ]; then + curl -s -X GET "$url" -H "Authorization: Bearer $token" + else + curl -s -X GET "$url" + fi +} + +http_put() { + local url=$1; local data=$2; local token=$3 + curl -s -X PUT "$url" -H "Content-Type: application/json" -H "Authorization: Bearer $token" -d "$data" +} + +url_encode() { + echo -n "$1" | sed 's/ /%20/g;s/,/%2C/g' +} + +echo "============================================================" +echo " EVENTHUB SEARCH API TEST SCRIPT" +echo "============================================================" +echo "" + +log_info "Checking if server is running..." +if ! curl -s "$BASE_URL/health" | grep -q "ok"; then + log_error "Server is not running" + exit 1 +fi +log_success "Server is running" + +echo "" +log_info "============================================================" +log_info "STEP 1: Create test users" +log_info "============================================================" + +OWNER_EMAIL="search_owner_$(date +%s)@example.com" +OWNER_PASSWORD="owner123" + +log_info "Creating calendar owner..." +response=$(http_post "$BASE_URL/v1/register" "{\"email\":\"$OWNER_EMAIL\",\"password\":\"$OWNER_PASSWORD\"}" "") +OWNER_TOKEN=$(extract_json "$response" "token") +OWNER_ID=$(extract_json "$response" "id") + +if [ -z "$OWNER_TOKEN" ]; then + log_error "Failed to create owner" + exit 1 +fi +log_success "Owner created: $OWNER_EMAIL" + +echo "" +log_info "============================================================" +log_info "STEP 2: Create calendar with tags" +log_info "============================================================" + +log_info "Creating calendar..." +response=$(http_post "$BASE_URL/v1/calendars" \ + "{\"title\":\"Tech Events Calendar\",\"description\":\"Calendar for technology events and workshops\",\"tags\":[\"tech\",\"programming\",\"workshop\"]}" "$OWNER_TOKEN") +CALENDAR_ID=$(extract_json "$response" "id") +log_success "Calendar created with tags: $CALENDAR_ID" + +# Добавляем теги через обновление +http_put "$BASE_URL/v1/calendars/$CALENDAR_ID" "{\"tags\":[\"tech\",\"programming\",\"workshop\"]}" "$OWNER_TOKEN" > /dev/null +log_success "Calendar created with tags: $CALENDAR_ID" + +echo "" +log_info "============================================================" +log_info "STEP 3: Create events with different properties" +log_info "============================================================" + +# Функция для создания события +create_event() { + local title=$1 + local description=$2 + local start_time=$3 + local tags=$4 + local lat=$5 + local lon=$6 + local address=$7 + + local location_json="null" + if [ -n "$lat" ] && [ -n "$lon" ]; then + location_json="{\"address\":\"$address\",\"lat\":$lat,\"lon\":$lon}" + fi + + local tags_json="[]" + if [ -n "$tags" ]; then + tags_json="$tags" + fi + + local data="{\"title\":\"$title\",\"description\":\"$description\",\"start_time\":\"$start_time\",\"duration\":60,\"tags\":$tags_json" + if [ "$location_json" != "null" ]; then + data="$data,\"location\":$location_json" + fi + data="$data}" + + response=$(http_post "$BASE_URL/v1/calendars/$CALENDAR_ID/events" "$data" "$OWNER_TOKEN") + local event_id=$(extract_json "$response" "id") + + echo "$event_id" +} + +log_info "Creating Python Workshop event..." +WORKSHOP_ID=$(create_event "Python Workshop" "Learn Python programming basics" "2026-06-01T10:00:00Z" \ + "[\"python\",\"workshop\",\"programming\"]" "55.7558" "37.6173" "Moscow, Russia") +log_success "Created: $WORKSHOP_ID" + +log_info "Creating JavaScript Conference event..." +JS_ID=$(create_event "JavaScript Conference" "Annual JS conference for developers" "2026-06-15T09:00:00Z" \ + "[\"javascript\",\"conference\",\"web\"]" "55.7558" "37.6173" "Moscow, Russia") +log_success "Created: $JS_ID" + +log_info "Creating Yoga Class event (no tags)..." +YOGA_ID=$(create_event "Yoga Class" "Morning yoga session" "2026-06-10T08:00:00Z" \ + "" "" "" "") +log_success "Created: $YOGA_ID" + +log_info "Creating Tech Meetup in another city..." +MEETUP_ID=$(create_event "Tech Meetup" "Networking for tech professionals" "2026-06-20T18:00:00Z" \ + "[\"networking\",\"tech\"]" "59.9343" "30.3351" "Saint Petersburg, Russia") +log_success "Created: $MEETUP_ID" + +log_info "Creating past event..." +PAST_ID=$(create_event "Past Event" "This event already happened" "2020-01-01T10:00:00Z" \ + "[\"past\"]" "" "" "") +log_success "Created: $PAST_ID" + +echo "" +log_info "============================================================" +log_info "TEST 1: Search by text query" +log_info "============================================================" + +log_info "Searching for 'Python'..." +response=$(http_get "$BASE_URL/v1/search?type=event&q=Python" "$OWNER_TOKEN") +if echo "$response" | grep -q "Python Workshop"; then + log_success "Found Python Workshop" +else + log_error "Python Workshop not found" +fi + +log_info "Searching for 'conference'..." +response=$(http_get "$BASE_URL/v1/search?type=event&q=conference" "$OWNER_TOKEN") +if echo "$response" | grep -q "JavaScript Conference"; then + log_success "Found JavaScript Conference" +else + log_error "JavaScript Conference not found" +fi + +echo "" +log_info "============================================================" +log_info "TEST 2: Search by tags" +log_info "============================================================" + +log_info "Searching for events with tag 'python'..." +response=$(http_get "$BASE_URL/v1/search?type=event&tags=python" "$OWNER_TOKEN") +if echo "$response" | grep -q "Python Workshop"; then + log_success "Found Python Workshop by tag" +else + log_error "Python Workshop not found by tag" +fi + +log_info "Searching for multiple tags 'tech,workshop'..." +response=$(http_get "$BASE_URL/v1/search?type=event&tags=tech,workshop" "$OWNER_TOKEN") +log_success "Multiple tag search completed" + +log_info "Searching for tag 'yoga' (should be empty)..." +response=$(http_get "$BASE_URL/v1/search?type=event&tags=yoga" "$OWNER_TOKEN") +if echo "$response" | grep -q '"total":0'; then + log_success "Yoga tag correctly returned no results (no tags on event)" +else + log_warning "Yoga event has no tags, but might appear in results" +fi + +echo "" +log_info "============================================================" +log_info "TEST 3: Search by date range" +log_info "============================================================" + +log_info "Searching events in June 2026..." +FROM="2026-06-01T00:00:00Z" +TO="2026-06-30T23:59:59Z" +response=$(http_get "$BASE_URL/v1/search?type=event&from=$FROM&to=$TO" "$OWNER_TOKEN") + +if echo "$response" | grep -q "Python Workshop"; then + log_success "Found June events" +else + log_error "June events not found" +fi + +log_info "Searching past events only..." +FROM="2019-01-01T00:00:00Z" +TO="2021-01-01T00:00:00Z" +response=$(http_get "$BASE_URL/v1/search?type=event&from=$FROM&to=$TO" "$OWNER_TOKEN") +if echo "$response" | grep -q "Past Event"; then + log_success "Found past event" +else + log_error "Past event not found" +fi + +echo "" +log_info "============================================================" +log_info "TEST 4: Geo-location search" +log_info "============================================================" + +log_info "Searching events within 5km of Moscow center..." +response=$(http_get "$BASE_URL/v1/search?type=event&lat=55.7558&lon=37.6173&radius=5" "$OWNER_TOKEN") + +if echo "$response" | grep -q "Python Workshop"; then + log_success "Found Moscow events" +else + log_error "Moscow events not found" +fi + +log_info "Searching events within 1km of Moscow (should find fewer)..." +response=$(http_get "$BASE_URL/v1/search?type=event&lat=55.7558&lon=37.6173&radius=1" "$OWNER_TOKEN") +log_success "Radius search completed" + +log_info "Searching events in Saint Petersburg..." +response=$(http_get "$BASE_URL/v1/search?type=event&lat=59.9343&lon=30.3351&radius=10" "$OWNER_TOKEN") +if echo "$response" | grep -q "Tech Meetup"; then + log_success "Found Saint Petersburg event" +else + log_error "Saint Petersburg event not found" +fi + +echo "" +log_info "============================================================" +log_info "TEST 5: Combined search" +log_info "============================================================" + +log_info "Search: text 'Python' + tag 'workshop'..." +response=$(http_get "$BASE_URL/v1/search?type=event&q=Python&tags=workshop" "$OWNER_TOKEN") +if echo "$response" | grep -q "Python Workshop"; then + log_success "Combined text+tag search successful" +else + log_error "Combined search failed" +fi + +log_info "Search: tag 'javascript' + date range..." +FROM="2026-06-01T00:00:00Z" +TO="2026-06-30T23:59:59Z" +response=$(http_get "$BASE_URL/v1/search?type=event&tags=javascript&from=$FROM&to=$TO" "$OWNER_TOKEN") +if echo "$response" | grep -q "JavaScript Conference"; then + log_success "Combined tag+date search successful" +else + log_error "Combined search failed" +fi + +echo "" +log_info "============================================================" +log_info "TEST 6: Pagination" +log_info "============================================================" + +log_info "Search with limit=2..." +response=$(http_get "$BASE_URL/v1/search?type=event&limit=2" "$OWNER_TOKEN") +COUNT=$(echo "$response" | grep -o "\"id\"" | wc -l) +if [ "$COUNT" -le 2 ]; then + log_success "Pagination limit works (got $COUNT results)" +else + log_error "Pagination limit failed (got $COUNT results)" +fi + +log_info "Search with offset=2..." +response=$(http_get "$BASE_URL/v1/search?type=event&limit=2&offset=2" "$OWNER_TOKEN") +log_success "Pagination offset works" + +echo "" +log_info "============================================================" +log_info "TEST 7: Sorting" +log_info "============================================================" + +log_info "Sort by start_time ascending..." +response=$(http_get "$BASE_URL/v1/search?type=event&sort=start_time&order=asc" "$OWNER_TOKEN") +log_success "Sort ascending completed" + +log_info "Sort by start_time descending..." +response=$(http_get "$BASE_URL/v1/search?type=event&sort=start_time&order=desc" "$OWNER_TOKEN") +log_success "Sort descending completed" + +echo "" +log_info "============================================================" +log_info "TEST 8: Calendar search" +log_info "============================================================" + +log_info "Searching calendars by text..." +response=$(http_get "$BASE_URL/v1/search?type=calendar&q=Tech" "$OWNER_TOKEN") +if echo "$response" | grep -q "Tech Events Calendar"; then + log_success "Found calendar by text" +else + log_error "Calendar not found by text" +fi + +log_info "Searching calendars by tag..." +response=$(http_get "$BASE_URL/v1/search?type=calendar&tags=tech" "$OWNER_TOKEN") +if echo "$response" | grep -q "Tech Events Calendar"; then + log_success "Found calendar by tag" +else + log_error "Calendar not found by tag" +fi + +echo "" +log_info "============================================================" +log_info "TEST 9: Search all (events + calendars)" +log_info "============================================================" + +log_info "Searching all (no type specified)..." +response=$(http_get "$BASE_URL/v1/search?q=Tech" "$OWNER_TOKEN") +if echo "$response" | grep -q "events" && echo "$response" | grep -q "calendars"; then + log_success "All search returned both events and calendars" +else + log_warning "All search may not have returned both types" +fi + +echo "" +log_info "============================================================" +log_info "TEST 10: Empty search results" +log_info "============================================================" + +log_info "Searching for non-existent text..." +response=$(http_get "$BASE_URL/v1/search?type=event&q=nonexistenttext12345" "$OWNER_TOKEN") +if echo "$response" | grep -q '"total":0'; then + log_success "Empty search handled correctly" +else + log_error "Empty search not handled correctly" +fi + +echo "" +log_info "============================================================" +log_info "TEST 11: Commercial calendar visibility" +log_info "============================================================" + +log_info "Creating commercial calendar..." +response=$(http_post "$BASE_URL/v1/calendars" \ + "{\"title\":\"Public Commercial Calendar\",\"type\":\"commercial\"}" "$OWNER_TOKEN") +COMMERCIAL_ID=$(extract_json "$response" "id") +log_success "Commercial calendar created: $COMMERCIAL_ID" + +log_info "Creating event in commercial calendar..." +response=$(http_post "$BASE_URL/v1/calendars/$COMMERCIAL_ID/events" \ + "{\"title\":\"Public Event\",\"start_time\":\"2026-06-01T10:00:00Z\",\"duration\":60}" "$OWNER_TOKEN") +log_success "Public event created" + +log_info "Creating another user to test visibility..." +OTHER_EMAIL="search_other_$(date +%s)@example.com" +response=$(http_post "$BASE_URL/v1/register" "{\"email\":\"$OTHER_EMAIL\",\"password\":\"test123\"}" "") +OTHER_TOKEN=$(extract_json "$response" "token") + +log_info "Other user searching for public event..." +response=$(http_get "$BASE_URL/v1/search?type=event&q=Public" "$OTHER_TOKEN") +if echo "$response" | grep -q "Public Event"; then + log_success "Other user can see public event in commercial calendar" +else + log_error "Other user cannot see public event" +fi + +echo "" +echo "============================================================" +log_success "SEARCH API TESTS COMPLETED!" +echo "============================================================" +echo "" +echo "Summary of created resources:" +echo " Owner: $OWNER_EMAIL" +echo " Calendar: $CALENDAR_ID" +echo " Commercial Calendar: $COMMERCIAL_ID" +echo " Events created: 5" +echo "" \ No newline at end of file