From 081dcf9588150b839235cf78fd78c5891cbd2f25 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B5=D0=B9=20=D0=A1=D0=B0?= =?UTF-8?q?=D0=B1=D0=B8=D0=BB=D0=B8=D0=BD?= Date: Wed, 22 Apr 2026 23:15:20 +0300 Subject: [PATCH] Stage 10 final --- .gitignore | 1 + Makefile | 91 +++-- rebar.config | 16 + src/eventhub_app.erl | 21 +- src/handlers/admin_handler_health.erl | 12 +- src/handlers/admin_handler_stats.erl | 6 +- src/handlers/admin_handler_user_by_id.erl | 6 +- src/handlers/admin_handler_users.erl | 6 +- src/handlers/admin_ws_handler.erl | 87 +++++ src/handlers/handler_admin_moderation.erl | 6 +- src/handlers/handler_admin_reviews.erl | 6 +- src/handlers/handler_admin_subscriptions.erl | 6 +- src/handlers/handler_auth.erl | 10 +- src/handlers/handler_banned_words.erl | 6 +- src/handlers/handler_booking_by_id.erl | 6 +- src/handlers/handler_bookings.erl | 6 +- src/handlers/handler_calendar_by_id.erl | 6 +- src/handlers/handler_calendars.erl | 6 +- src/handlers/handler_event_by_id.erl | 6 +- src/handlers/handler_event_occurrences.erl | 6 +- src/handlers/handler_events.erl | 6 +- src/handlers/handler_health.erl | 17 +- src/handlers/handler_login.erl | 6 +- src/handlers/handler_refresh.erl | 6 +- src/handlers/handler_register.erl | 6 +- src/handlers/handler_report_by_id.erl | 6 +- src/handlers/handler_reports.erl | 46 ++- src/handlers/handler_review_by_id.erl | 6 +- src/handlers/handler_reviews.erl | 6 +- src/handlers/handler_search.erl | 6 +- src/handlers/handler_subscription.erl | 6 +- src/handlers/handler_ticket_by_id.erl | 16 +- src/handlers/handler_ticket_stats.erl | 6 +- src/handlers/handler_tickets.erl | 8 +- src/handlers/handler_user_bookings.erl | 6 +- src/handlers/handler_user_me.erl | 6 +- src/handlers/handler_user_reviews.erl | 6 +- src/handlers/ws_handler.erl | 85 +++++ src/logic/logic_booking.erl | 24 +- src/logic/logic_moderation.erl | 14 +- src/logic/logic_notification.erl | 56 ++++ src/logic/logic_subscription.erl | 2 +- src/logic/logic_ticket.erl | 2 +- test/api/api_admin_tests.erl | 27 ++ test/api/api_auth_tests.erl | 64 ++++ test/api/api_booking_tests.erl | 83 +++++ test/api/api_calendar_tests.erl | 61 ++++ test/api/api_event_tests.erl | 70 ++++ test/api/api_moderation_tests.erl | 54 +++ test/api/api_reviews_tests.erl | 58 ++++ test/api/api_search_tests.erl | 54 +++ test/api/api_subscription_tests.erl | 36 ++ test/api/api_test_runner.erl | 216 ++++++++++++ test/api/api_tickets_tests.erl | 37 +++ test/api/api_websocket_tests.erl | 238 +++++++++++++ test/api_SUITE.erl | 98 ++++++ test/scripts/test_websocket_api.sh | 313 ++++++++++++++++++ test/{ => unit}/admin_handler_stats_tests.erl | 0 .../admin_handler_user_by_id_tests.erl | 0 test/{ => unit}/admin_handler_users_tests.erl | 0 test/unit/admin_ws_handler_tests.erl | 34 ++ test/{ => unit}/booking_integration_tests.erl | 0 test/{ => unit}/core_banned_word_tests.erl | 0 test/{ => unit}/core_booking_tests.erl | 0 test/{ => unit}/core_calendar_tests.erl | 0 .../{ => unit}/core_event_recurring_tests.erl | 0 test/{ => unit}/core_event_tests.erl | 0 test/{ => unit}/core_report_tests.erl | 0 test/{ => unit}/core_review_tests.erl | 0 test/{ => unit}/core_subscription_tests.erl | 0 test/{ => unit}/core_ticket_tests.erl | 0 test/{ => unit}/handler_search_tests.erl | 0 test/{ => unit}/logic_auth_tests.erl | 0 test/{ => unit}/logic_booking_tests.erl | 0 test/{ => unit}/logic_calendar_tests.erl | 0 .../logic_event_recurring_tests.erl | 0 test/{ => unit}/logic_event_tests.erl | 0 test/{ => unit}/logic_moderation_tests.erl | 0 test/unit/logic_notification_tests.erl | 108 ++++++ test/{ => unit}/logic_recurrence_tests.erl | 0 test/{ => unit}/logic_review_tests.erl | 0 test/{ => unit}/logic_search_tests.erl | 0 test/{ => unit}/logic_subscription_tests.erl | 0 test/{ => unit}/logic_ticket_tests.erl | 0 test/unit/ws_handler_tests.erl | 61 ++++ 85 files changed, 2116 insertions(+), 160 deletions(-) create mode 100644 src/handlers/admin_ws_handler.erl create mode 100644 src/handlers/ws_handler.erl create mode 100644 src/logic/logic_notification.erl create mode 100644 test/api/api_admin_tests.erl create mode 100644 test/api/api_auth_tests.erl create mode 100644 test/api/api_booking_tests.erl create mode 100644 test/api/api_calendar_tests.erl create mode 100644 test/api/api_event_tests.erl create mode 100644 test/api/api_moderation_tests.erl create mode 100644 test/api/api_reviews_tests.erl create mode 100644 test/api/api_search_tests.erl create mode 100644 test/api/api_subscription_tests.erl create mode 100644 test/api/api_test_runner.erl create mode 100644 test/api/api_tickets_tests.erl create mode 100644 test/api/api_websocket_tests.erl create mode 100644 test/api_SUITE.erl create mode 100644 test/scripts/test_websocket_api.sh rename test/{ => unit}/admin_handler_stats_tests.erl (100%) rename test/{ => unit}/admin_handler_user_by_id_tests.erl (100%) rename test/{ => unit}/admin_handler_users_tests.erl (100%) create mode 100644 test/unit/admin_ws_handler_tests.erl rename test/{ => unit}/booking_integration_tests.erl (100%) rename test/{ => unit}/core_banned_word_tests.erl (100%) rename test/{ => unit}/core_booking_tests.erl (100%) rename test/{ => unit}/core_calendar_tests.erl (100%) rename test/{ => unit}/core_event_recurring_tests.erl (100%) rename test/{ => unit}/core_event_tests.erl (100%) rename test/{ => unit}/core_report_tests.erl (100%) rename test/{ => unit}/core_review_tests.erl (100%) rename test/{ => unit}/core_subscription_tests.erl (100%) rename test/{ => unit}/core_ticket_tests.erl (100%) rename test/{ => unit}/handler_search_tests.erl (100%) rename test/{ => unit}/logic_auth_tests.erl (100%) rename test/{ => unit}/logic_booking_tests.erl (100%) rename test/{ => unit}/logic_calendar_tests.erl (100%) rename test/{ => unit}/logic_event_recurring_tests.erl (100%) rename test/{ => unit}/logic_event_tests.erl (100%) rename test/{ => unit}/logic_moderation_tests.erl (100%) create mode 100644 test/unit/logic_notification_tests.erl rename test/{ => unit}/logic_recurrence_tests.erl (100%) rename test/{ => unit}/logic_review_tests.erl (100%) rename test/{ => unit}/logic_search_tests.erl (100%) rename test/{ => unit}/logic_subscription_tests.erl (100%) rename test/{ => unit}/logic_ticket_tests.erl (100%) create mode 100644 test/unit/ws_handler_tests.erl diff --git a/.gitignore b/.gitignore index 62eea9a..0b4d5a0 100644 --- a/.gitignore +++ b/.gitignore @@ -22,3 +22,4 @@ rebar3.crashdump *~ /.tool-versions /rebar.lock +/build/ diff --git a/Makefile b/Makefile index 91f2053..5b3dadc 100644 --- a/Makefile +++ b/Makefile @@ -27,13 +27,13 @@ help: ## Показать это сообщение # ============================================================================ compile: ## Скомпилировать проект @echo "Компиляция проекта..." - @$(REBAR3) clean compile + @$(REBAR3) compile @echo "✓ Компиляция завершена" clean: ## Очистить проект @echo "Очистка проекта..." @$(REBAR3) clean - @rm -rf _build deps logs *.log + @rm -rf _build build deps logs *.log @echo "✓ Очистка завершена" deps: ## Установить зависимости @@ -60,7 +60,6 @@ run: ## Запустить приложение (foreground) 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: $$!" @@ -105,54 +104,54 @@ test-search-handler: ## Запустить handler тесты поиска @echo "Запуск handler тестов поиска..." @$(REBAR3) eunit --sname test_search2 --module=handler_search_tests -test-api: ## Запустить API тесты (авто-запуск сервера) - @./test/scripts/run_tests.sh +test-api: test-ct -test-full: ## Полный цикл тестирования - @./test/scripts/run_tests.sh $(PATTERN) +test-ct: ## Запустить Common Test для API + @rebar3 ct --sname $(SNAME)_api_test -test-full-search: ## Полный цикл для поиска - @./test/scripts/run_tests.sh search +test-ct-verbose: ## Запустить Common Test с подробным выводом + @ct_run -suite test/ct/api_SUITE \ + -pa _build/default/lib/*/ebin \ + -pa test/ct/api \ + -logdir logs/ct \ + -verbosity 50 -test-full-booking: ## Полный цикл для бронирований - @./test/scripts/run_tests.sh booking +test-api-auth: ## Тесты аутентификации + @rebar3 shell --eval "api_auth_tests:test()." --name test_api@127.0.0.1 -test-api-existing: ## Запустить API тесты на уже работающем сервере +test-api-calendar: ## Тесты календарей + @rebar3 shell --eval "api_calendar_tests:test()." --name test_api@127.0.0.1 + +test-api-event: ## Тесты событий + @rebar3 shell --eval "api_event_tests:test()." --name test_api@127.0.0.1 + +test-api-booking: ## Тесты бронирований + @rebar3 shell --eval "api_booking_tests:test()." --name test_api@127.0.0.1 + +test-api-search: ## Тесты поиска + @rebar3 shell --eval "api_search_tests:test()." --name test_api@127.0.0.1 + +test-api-reviews: ## Тесты отзывов + @rebar3 shell --eval "api_reviews_tests:test()." --name test_api@127.0.0.1 + +test-api-moderation: ## Тесты модерации + @rebar3 shell --eval "api_moderation_tests:test()." --name test_api@127.0.0.1 + +test-api-tickets: ## Тесты тикетов + @rebar3 shell --eval "api_tickets_tests:test()." --name test_api@127.0.0.1 + +test-api-subscription: ## Тесты подписки + @rebar3 shell --eval "api_subscription_tests:test()." --name test_api@127.0.0.1 + +test-api-admin: ## Тесты админки + @rebar3 shell --eval "api_admin_tests:test()." --name test_api@127.0.0.1 + +test-api-ws: ## Тесты админки + @rebar3 shell --eval "api_websocket_tests:test()." --name test_api@127.0.0.1 + +test-scripts: ## Запустить тесты с фильтром (make test-runner PATTERN=booking) @chmod +x test/scripts/*.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 - @./test/scripts/test_auth_api.sh - -test-calendar: ## Запустить тесты календарей - @chmod +x test/scripts/test_calendar_api.sh - @./test/scripts/test_calendar_api.sh - -test-event: ## Запустить тесты событий - @chmod +x test/scripts/test_event_api.sh - @./test/scripts/test_event_api.sh - -test-booking: ## Запустить тесты бронирований - @chmod +x test/scripts/test_booking_api.sh - @./test/scripts/test_booking_api.sh - -test-reviews: ## Запустить тесты отзывов - @chmod +x test/scripts/test_reviews_api.sh - @./test/scripts/test_reviews_api.sh + @cd test/scripts && ./run_tests.sh $(PATTERN) test-all: eunit ## Запустить ВСЕ тесты (EUnit + API) @sleep 1 diff --git a/rebar.config b/rebar.config index 7b5d3b9..f973f04 100644 --- a/rebar.config +++ b/rebar.config @@ -1,4 +1,7 @@ {erl_opts, [debug_info, {i, "include"}]}. + +{src_dirs, ["src", "test/api"]}. + {deps, [ {cowboy, "2.10.0"}, {jsx, "3.1.0"}, @@ -22,10 +25,23 @@ {profiles, [ {test, [ {erl_opts, [debug_info, {i, "include"}, {d, 'TEST'}]}, + {src_dirs, ["src", "test/unit"]}, {deps, [ {meck, "0.9.2"} ]} ]} ]}. +{ct_opts, [ + {src_dirs, ["src", "test/api"]}, + {sys_config, ["config/sys.config"]}, % Load app config + {logdir, "build"}, % Where to put HTML reports + {verbose, true} % Print more info to console +]}. + +{ct_compile_opts, [ + {i, "include"}, % Include directory + {d, 'DEBUG'} % Define macros +]}. + {eunit_opts, [verbose]}. \ No newline at end of file diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index a01b9d1..c93095a 100644 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -4,6 +4,7 @@ -export([start/2, stop/1]). start(_StartType, _StartArgs) -> + pg:start_link(), application:ensure_all_started(mnesia), application:ensure_all_started(cowboy), @@ -103,4 +104,22 @@ start_admin_http() -> middlewares => Middlewares }), - io:format("Admin HTTP server started on port ~p~n", [Port]). \ No newline at end of file + io:format("Admin HTTP server started on port ~p~n", [Port]), + + % WebSocket для пользователей + WsDispatch = cowboy_router:compile([ + {'_', [{"/ws", ws_handler, []}]} + ]), + cowboy:start_clear(ws, [{port, 8081}], #{ + env => #{dispatch => WsDispatch} + }), + + % WebSocket для админов + AdminWsDispatch = cowboy_router:compile([ + {'_', [{"/admin/ws", admin_ws_handler, []}]} + ]), + cowboy:start_clear(admin_ws, [{port, 8446}], #{ + env => #{dispatch => AdminWsDispatch} + }), + + io:format("WebSocket started on ports 8081 (user) and 8446 (admin)~n"). \ No newline at end of file diff --git a/src/handlers/admin_handler_health.erl b/src/handlers/admin_handler_health.erl index 250c81f..887f78a 100644 --- a/src/handlers/admin_handler_health.erl +++ b/src/handlers/admin_handler_health.erl @@ -3,5 +3,13 @@ -export([init/2]). init(Req, _Opts) -> - Body = jsx:encode(#{status => <<"ok">>, service => <<"admin">>}), - cowboy_req:reply(200, #{<<"content-type">> => <<"application/json">>}, Body, Req). \ No newline at end of file + case cowboy_req:method(Req) of + <<"GET">> -> + Body = jsx:encode(#{status => <<"ok">>}), + Req1 = cowboy_req:reply(200, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Req1, []}; + _ -> + Body = jsx:encode(#{error => <<"Method not allowed">>}), + Req1 = cowboy_req:reply(405, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Req1, []} + end. \ No newline at end of file diff --git a/src/handlers/admin_handler_stats.erl b/src/handlers/admin_handler_stats.erl index 586c368..d8b9415 100644 --- a/src/handlers/admin_handler_stats.erl +++ b/src/handlers/admin_handler_stats.erl @@ -71,8 +71,10 @@ count_subscriptions() -> send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. 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 + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/admin_handler_user_by_id.erl b/src/handlers/admin_handler_user_by_id.erl index 3209872..79eda72 100644 --- a/src/handlers/admin_handler_user_by_id.erl +++ b/src/handlers/admin_handler_user_by_id.erl @@ -120,8 +120,10 @@ datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. 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 + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/admin_handler_users.erl b/src/handlers/admin_handler_users.erl index 12bed58..7e18ae6 100644 --- a/src/handlers/admin_handler_users.erl +++ b/src/handlers/admin_handler_users.erl @@ -50,8 +50,10 @@ datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. 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 + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/admin_ws_handler.erl b/src/handlers/admin_ws_handler.erl new file mode 100644 index 0000000..6401651 --- /dev/null +++ b/src/handlers/admin_ws_handler.erl @@ -0,0 +1,87 @@ +-module(admin_ws_handler). +-behaviour(cowboy_websocket). + +-export([init/2]). +-export([websocket_init/1]). +-export([websocket_handle/2]). +-export([websocket_info/2]). +-export([terminate/3]). + +-record(state, { + admin_id :: binary() | undefined +}). + +init(Req, _Opts) -> + Qs = cowboy_req:parse_qs(Req), + case proplists:get_value(<<"token">>, Qs) of + undefined -> + io:format("[ADMIN_WS] Missing token~n"), + Resp = cowboy_req:reply(401, #{}, <<"Missing token">>, Req), + {ok, Resp, undefined}; + Token -> + io:format("[ADMIN_WS] Token received: ~s...~n", [binary_part(Token, 0, 30)]), + case logic_auth:verify_jwt(Token) of + {ok, Claims} -> + UserId = maps:get(<<"user_id">>, Claims), + Role = maps:get(<<"role">>, Claims), + io:format("[ADMIN_WS] UserId: ~s, Role: ~s~n", [UserId, Role]), + case Role of + <<"admin">> -> + io:format("[ADMIN_WS] Admin access granted~n"), + {cowboy_websocket, Req, #state{admin_id = UserId}}; + _ -> + io:format("[ADMIN_WS] Access denied: not admin~n"), + Resp = cowboy_req:reply(403, #{}, <<"Admin access required">>, Req), + {ok, Resp, undefined} + end; + {error, expired} -> + io:format("[ADMIN_WS] Token expired~n"), + Resp = cowboy_req:reply(401, #{}, <<"Token expired">>, Req), + {ok, Resp, undefined}; + {error, Reason} -> + io:format("[ADMIN_WS] Invalid token: ~p~n", [Reason]), + Resp = cowboy_req:reply(401, #{}, <<"Invalid token">>, Req), + {ok, Resp, undefined} + end + end. + +websocket_init(State) -> + io:format("[ADMIN_WS] WebSocket initialized for admin ~s~n", [State#state.admin_id]), + pg:join(eventhub_admin_ws, self()), + {ok, State}. + +websocket_handle({text, Msg}, State) -> + io:format("[ADMIN_WS] Received: ~s~n", [Msg]), + try jsx:decode(Msg, [return_maps]) of + #{<<"action">> := <<"subscribe">>, <<"channel">> := Channel} -> + pg:join({eventhub_admin_channel, Channel}, self()), + Reply = jsx:encode(#{status => <<"subscribed">>, channel => Channel}), + {reply, {text, Reply}, State}; + #{<<"action">> := <<"unsubscribe">>, <<"channel">> := Channel} -> + pg:leave({eventhub_admin_channel, Channel}, self()), + Reply = jsx:encode(#{status => <<"unsubscribed">>, channel => Channel}), + {reply, {text, Reply}, State}; + #{<<"action">> := <<"ping">>} -> + {reply, {text, <<"{\"status\":\"pong\"}">>}, State}; + _ -> + {ok, State} + catch + _:_ -> + {ok, State} + end; +websocket_handle(_Frame, State) -> + {ok, State}. + +websocket_info({admin_notification, Type, Data}, State) -> + Msg = jsx:encode(#{ + type => Type, + data => Data, + timestamp => os:system_time(seconds) + }), + {reply, {text, Msg}, State}; +websocket_info(_Info, State) -> + {ok, State}. + +terminate(_Reason, _Req, _State) -> + pg:leave(eventhub_admin_ws, self()), + ok. \ No newline at end of file diff --git a/src/handlers/handler_admin_moderation.erl b/src/handlers/handler_admin_moderation.erl index ab03871..38c365f 100644 --- a/src/handlers/handler_admin_moderation.erl +++ b/src/handlers/handler_admin_moderation.erl @@ -123,8 +123,10 @@ datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. 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 + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/handler_admin_reviews.erl b/src/handlers/handler_admin_reviews.erl index 300f92e..874526e 100644 --- a/src/handlers/handler_admin_reviews.erl +++ b/src/handlers/handler_admin_reviews.erl @@ -84,8 +84,10 @@ datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. 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 + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/handler_admin_subscriptions.erl b/src/handlers/handler_admin_subscriptions.erl index 87a1250..5870c33 100644 --- a/src/handlers/handler_admin_subscriptions.erl +++ b/src/handlers/handler_admin_subscriptions.erl @@ -75,8 +75,10 @@ datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. 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 + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/handler_auth.erl b/src/handlers/handler_auth.erl index bf749ae..497d24f 100644 --- a/src/handlers/handler_auth.erl +++ b/src/handlers/handler_auth.erl @@ -3,17 +3,23 @@ -export([authenticate/1]). authenticate(Req) -> + io:format("[AUTH] Starting authentication...~n"), case cowboy_req:parse_header(<<"authorization">>, Req) of {bearer, Token} -> + io:format("[AUTH] Bearer token found: ~s...~n", [binary_part(Token, 0, 30)]), case logic_auth:verify_jwt(Token) of {ok, Claims} -> UserId = maps:get(<<"user_id">>, Claims), + io:format("[AUTH] JWT verified, UserId: ~s~n", [UserId]), {ok, UserId, Req}; {error, expired} -> + io:format("[AUTH] JWT expired~n"), {error, 401, <<"Token expired">>, Req}; - {error, _} -> + {error, Reason} -> + io:format("[AUTH] JWT invalid: ~p~n", [Reason]), {error, 401, <<"Invalid token">>, Req} end; - _ -> + Other -> + io:format("[AUTH] No bearer token: ~p~n", [Other]), {error, 401, <<"Missing or invalid Authorization header">>, Req} end. \ No newline at end of file diff --git a/src/handlers/handler_banned_words.erl b/src/handlers/handler_banned_words.erl index e0b390d..71ac8af 100644 --- a/src/handlers/handler_banned_words.erl +++ b/src/handlers/handler_banned_words.erl @@ -79,8 +79,10 @@ remove_banned_word(Req) -> %% Вспомогательные функции send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. 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 + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/handler_booking_by_id.erl b/src/handlers/handler_booking_by_id.erl index b16f8c2..ad2a306 100644 --- a/src/handlers/handler_booking_by_id.erl +++ b/src/handlers/handler_booking_by_id.erl @@ -121,8 +121,10 @@ datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. 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 + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/handler_bookings.erl b/src/handlers/handler_bookings.erl index b78a5b4..d2061ab 100644 --- a/src/handlers/handler_bookings.erl +++ b/src/handlers/handler_bookings.erl @@ -80,8 +80,10 @@ datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. 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 + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/handler_calendar_by_id.erl b/src/handlers/handler_calendar_by_id.erl index f0d8fba..a86fab2 100644 --- a/src/handlers/handler_calendar_by_id.erl +++ b/src/handlers/handler_calendar_by_id.erl @@ -106,8 +106,10 @@ confirmation_to_json({timeout, N}) -> #{<<"timeout">> => N}. send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. 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 + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/handler_calendars.erl b/src/handlers/handler_calendars.erl index 1479ee4..034cd70 100644 --- a/src/handlers/handler_calendars.erl +++ b/src/handlers/handler_calendars.erl @@ -113,8 +113,10 @@ confirmation_to_json({timeout, N}) -> #{<<"timeout">> => N}. send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. 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 + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/handler_event_by_id.erl b/src/handlers/handler_event_by_id.erl index ee6d4da..5b8fabf 100644 --- a/src/handlers/handler_event_by_id.erl +++ b/src/handlers/handler_event_by_id.erl @@ -172,8 +172,10 @@ datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. 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 + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/handler_event_occurrences.erl b/src/handlers/handler_event_occurrences.erl index 91bc7e2..cc91ab2 100644 --- a/src/handlers/handler_event_occurrences.erl +++ b/src/handlers/handler_event_occurrences.erl @@ -134,8 +134,10 @@ datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. 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 + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/handler_events.erl b/src/handlers/handler_events.erl index 6911490..5d506bf 100644 --- a/src/handlers/handler_events.erl +++ b/src/handlers/handler_events.erl @@ -265,8 +265,10 @@ datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. 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 + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/handler_health.erl b/src/handlers/handler_health.erl index 79f89cf..188a1c0 100644 --- a/src/handlers/handler_health.erl +++ b/src/handlers/handler_health.erl @@ -2,6 +2,17 @@ -export([init/2]). -init(Req, _Opts) -> - Body = jsx:encode(#{status => <<"ok">>}), - cowboy_req:reply(200, #{<<"content-type">> => <<"application/json">>}, Body, Req). \ No newline at end of file +init(Req, Opts) -> + handle(Req, Opts). + +handle(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> + Body = jsx:encode(#{status => <<"ok">>}), + Req1 = cowboy_req:reply(200, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Req1, []}; + _ -> + Body = jsx:encode(#{error => <<"Method not allowed">>}), + Req1 = cowboy_req:reply(405, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Req1, []} + end. \ No newline at end of file diff --git a/src/handlers/handler_login.erl b/src/handlers/handler_login.erl index cff928d..ed38b90 100644 --- a/src/handlers/handler_login.erl +++ b/src/handlers/handler_login.erl @@ -73,8 +73,10 @@ save_refresh_token(UserId, Token, ExpiresAt) -> send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. 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 + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/handler_refresh.erl b/src/handlers/handler_refresh.erl index abeff8f..75e942e 100644 --- a/src/handlers/handler_refresh.erl +++ b/src/handlers/handler_refresh.erl @@ -84,8 +84,10 @@ delete_refresh_token(Token) -> send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. 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 + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/handler_register.erl b/src/handlers/handler_register.erl index fb509a4..4d20055 100644 --- a/src/handlers/handler_register.erl +++ b/src/handlers/handler_register.erl @@ -56,8 +56,10 @@ handle(Req, _Opts) -> send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. 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 + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/handler_report_by_id.erl b/src/handlers/handler_report_by_id.erl index 3ccebc4..6e1597e 100644 --- a/src/handlers/handler_report_by_id.erl +++ b/src/handlers/handler_report_by_id.erl @@ -76,8 +76,10 @@ datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. 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 + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/handler_reports.erl b/src/handlers/handler_reports.erl index a86b853..8fe14aa 100644 --- a/src/handlers/handler_reports.erl +++ b/src/handlers/handler_reports.erl @@ -13,41 +13,32 @@ handle(Req, _Opts) -> _ -> send_error(Req, 405, <<"Method not allowed">>) end. -%% POST /v1/reports - создание жалобы create_report(Req) -> case handler_auth:authenticate(Req) of {ok, UserId, Req1} -> {ok, Body, Req2} = cowboy_req:read_body(Req1), - try jsx:decode(Body, [return_maps]) of - Decoded when is_map(Decoded) -> - case Decoded of - #{<<"target_type">> := TargetTypeBin, - <<"target_id">> := TargetId, - <<"reason">> := Reason} -> - TargetType = parse_target_type(TargetTypeBin), - case logic_moderation:create_report(UserId, TargetType, TargetId, Reason) of - {ok, Report} -> - Response = report_to_json(Report), - send_json(Req2, 201, Response); - {error, target_not_found} -> - send_error(Req2, 404, <<"Target not found">>); - {error, _} -> - send_error(Req2, 500, <<"Internal server error">>) - end; - _ -> - send_error(Req2, 400, <<"Missing required fields">>) + Decoded = jsx:decode(Body, [return_maps]), + case Decoded of + #{<<"target_type">> := TargetTypeBin, + <<"target_id">> := TargetId, + <<"reason">> := Reason} -> + TargetType = parse_target_type(TargetTypeBin), + case logic_moderation:create_report(UserId, TargetType, TargetId, Reason) of + {ok, Report} -> + Response = report_to_json(Report), + send_json(Req2, 201, Response); + {error, target_not_found} -> + send_error(Req2, 404, <<"Target not found">>); + {error, _} -> + send_error(Req2, 500, <<"Internal server error">>) end; _ -> - send_error(Req2, 400, <<"Invalid JSON">>) - catch - _:_ -> - send_error(Req2, 400, <<"Invalid JSON format">>) + send_error(Req2, 400, <<"Missing required fields">>) end; {error, Code, Message, Req1} -> send_error(Req1, Code, Message) end. -%% GET /v1/admin/reports - список всех жалоб (админ) list_reports(Req) -> case handler_auth:authenticate(Req) of {ok, AdminId, Req1} -> @@ -79,7 +70,6 @@ list_reports(Req) -> send_error(Req1, Code, Message) end. -%% Вспомогательные функции parse_target_type(<<"event">>) -> event; parse_target_type(<<"calendar">>) -> calendar; parse_target_type(_) -> undefined. @@ -106,8 +96,10 @@ datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + Req1 = cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Req1, []}. 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 + Req1 = cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Req1, []}. \ No newline at end of file diff --git a/src/handlers/handler_review_by_id.erl b/src/handlers/handler_review_by_id.erl index 9cab0c7..4b46f51 100644 --- a/src/handlers/handler_review_by_id.erl +++ b/src/handlers/handler_review_by_id.erl @@ -109,8 +109,10 @@ datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. 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 + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/handler_reviews.erl b/src/handlers/handler_reviews.erl index 664e11d..e8b6452 100644 --- a/src/handlers/handler_reviews.erl +++ b/src/handlers/handler_reviews.erl @@ -100,8 +100,10 @@ datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. 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 + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/handler_search.erl b/src/handlers/handler_search.erl index 671dd01..d21c2b2 100644 --- a/src/handlers/handler_search.erl +++ b/src/handlers/handler_search.erl @@ -97,8 +97,10 @@ parse_datetime_param(Qs, Key) -> send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. 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 + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/handler_subscription.erl b/src/handlers/handler_subscription.erl index 45466da..b1852b3 100644 --- a/src/handlers/handler_subscription.erl +++ b/src/handlers/handler_subscription.erl @@ -95,8 +95,10 @@ datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. 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 + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/handler_ticket_by_id.erl b/src/handlers/handler_ticket_by_id.erl index c511bdf..b9b1e6a 100644 --- a/src/handlers/handler_ticket_by_id.erl +++ b/src/handlers/handler_ticket_by_id.erl @@ -61,7 +61,7 @@ handle_ticket_action(AdminId, TicketId, Body, Req) -> StatusBin =:= <<"in_progress">>; StatusBin =:= <<"resolved">>; StatusBin =:= <<"closed">> -> - Status = binary_to_atom(StatusBin), + Status = get_binary_to_atom(StatusBin), case logic_ticket:update_status(AdminId, TicketId, Status) of {ok, Ticket} -> Response = ticket_to_json(Ticket), @@ -148,15 +148,17 @@ datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", [Year, Month, Day, Hour, Minute, Second])). -binary_to_atom(<<"open">>) -> open; -binary_to_atom(<<"in_progress">>) -> in_progress; -binary_to_atom(<<"resolved">>) -> resolved; -binary_to_atom(<<"closed">>) -> closed. +get_binary_to_atom(<<"open">>) -> open; +get_binary_to_atom(<<"in_progress">>) -> in_progress; +get_binary_to_atom(<<"resolved">>) -> resolved; +get_binary_to_atom(<<"closed">>) -> closed. send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. 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 + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/handler_ticket_stats.erl b/src/handlers/handler_ticket_stats.erl index 7c42f19..2dcbffb 100644 --- a/src/handlers/handler_ticket_stats.erl +++ b/src/handlers/handler_ticket_stats.erl @@ -30,8 +30,10 @@ get_statistics(Req) -> send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. 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 + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/handler_tickets.erl b/src/handlers/handler_tickets.erl index 08166e4..53978ef 100644 --- a/src/handlers/handler_tickets.erl +++ b/src/handlers/handler_tickets.erl @@ -16,7 +16,7 @@ handle(Req, _Opts) -> %% POST /v1/tickets - сообщить об ошибке (доступно всем) report_error(Req) -> case handler_auth:authenticate(Req) of - {ok, UserId, Req1} -> + {ok, _UserId, Req1} -> {ok, Body, Req2} = cowboy_req:read_body(Req1), try jsx:decode(Body, [return_maps]) of Decoded when is_map(Decoded) -> @@ -111,8 +111,10 @@ datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. 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 + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/handler_user_bookings.erl b/src/handlers/handler_user_bookings.erl index 9b2a623..1bd435f 100644 --- a/src/handlers/handler_user_bookings.erl +++ b/src/handlers/handler_user_bookings.erl @@ -48,8 +48,10 @@ datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. 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 + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/handler_user_me.erl b/src/handlers/handler_user_me.erl index 370877b..355517a 100644 --- a/src/handlers/handler_user_me.erl +++ b/src/handlers/handler_user_me.erl @@ -50,8 +50,10 @@ authenticate(Req) -> send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. 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 + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/handler_user_reviews.erl b/src/handlers/handler_user_reviews.erl index 62507c4..66177f7 100644 --- a/src/handlers/handler_user_reviews.erl +++ b/src/handlers/handler_user_reviews.erl @@ -47,8 +47,10 @@ datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. 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 + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/ws_handler.erl b/src/handlers/ws_handler.erl new file mode 100644 index 0000000..ef84614 --- /dev/null +++ b/src/handlers/ws_handler.erl @@ -0,0 +1,85 @@ +-module(ws_handler). +-behaviour(cowboy_websocket). + +-export([init/2]). +-export([websocket_init/1]). +-export([websocket_handle/2]). +-export([websocket_info/2]). +-export([terminate/3]). + +-record(state, { + user_id :: binary() | undefined, + subscriptions = [] :: [binary()] +}). + +init(Req, _Opts) -> + % Аутентификация через query параметр token + Qs = cowboy_req:parse_qs(Req), + case proplists:get_value(<<"token">>, Qs) of + undefined -> + {ok, cowboy_req:reply(401, #{}, <<"Missing token">>, Req), undefined}; + Token -> + case logic_auth:verify_jwt(Token) of + {ok, Claims} -> + UserId = maps:get(<<"user_id">>, Claims), + {cowboy_websocket, Req, #state{user_id = UserId}}; + {error, _} -> + {ok, cowboy_req:reply(401, #{}, <<"Invalid token">>, Req), undefined} + end + end. + +websocket_init(State) -> + % Регистрируем процесс в pg для получения уведомлений + pg:join(eventhub_ws, self()), + {ok, State}. + +websocket_handle({text, Msg}, State) -> + io:format("WebSocket received: ~s~n", [Msg]), + try jsx:decode(Msg, [return_maps]) of + #{<<"action">> := <<"subscribe">>, <<"calendar_id">> := CalendarId} -> + io:format("Subscribe to calendar: ~s~n", [CalendarId]), + NewSubs = [CalendarId | State#state.subscriptions], + Reply = jsx:encode(#{status => <<"subscribed">>, calendar_id => CalendarId}), + io:format("Sending reply: ~s~n", [Reply]), + {reply, {text, Reply}, State#state{subscriptions = NewSubs}}; + #{<<"action">> := <<"ping">>} -> + {reply, {text, <<"{\"status\":\"pong\"}">>}, State}; + Other -> + io:format("Unknown action: ~p~n", [Other]), + {ok, State} + catch + _:Error -> + io:format("Error parsing WebSocket message: ~p~n", [Error]), + {ok, State} + end; +websocket_handle(_Frame, State) -> + {ok, State}. + +websocket_info({notification, Type, Data}, State) -> + case should_notify(Type, Data, State) of + true -> + Msg = jsx:encode(#{ + type => Type, + data => Data, + timestamp => os:system_time(seconds) + }), + {reply, {text, Msg}, State}; + false -> + {ok, State} + end; +websocket_info(_Info, State) -> + {ok, State}. + +terminate(_Reason, _Req, _State) -> + pg:leave(eventhub_ws, self()), + ok. + +%% Проверка, нужно ли отправлять уведомление пользователю +should_notify(calendar_update, #{calendar_id := CalId}, State) -> + lists:member(CalId, State#state.subscriptions); +should_notify(booking_update, #{user_id := UserId}, State) -> + UserId =:= State#state.user_id; +should_notify(event_update, #{calendar_id := CalId}, State) -> + lists:member(CalId, State#state.subscriptions); +should_notify(_, _, _) -> + true. \ No newline at end of file diff --git a/src/logic/logic_booking.erl b/src/logic/logic_booking.erl index f5cade8..1fc09c9 100644 --- a/src/logic/logic_booking.erl +++ b/src/logic/logic_booking.erl @@ -28,6 +28,7 @@ create_booking(UserId, EventId) -> case core_booking:create(ActualEventId, UserId) of {ok, Booking} -> handle_confirmation_policy(Booking, Event, Calendar), + logic_notification:notify_booking(UserId, Booking), % ← Уведомление {ok, Booking}; Error -> Error @@ -63,23 +64,24 @@ confirm_booking(UserId, BookingId, Action) when Action =:= confirm; Action =:= d {ok, Calendar} -> case logic_calendar:can_edit(UserId, Calendar) of true -> - case Action of - confirm -> - core_booking:update_status(BookingId, confirmed); - decline -> - core_booking:update_status(BookingId, cancelled) + NewStatus = case Action of + confirm -> confirmed; + decline -> cancelled + end, + case core_booking:update_status(BookingId, NewStatus) of + {ok, Updated} -> + logic_notification:notify_booking(Updated#booking.user_id, Updated), + {ok, Updated}; + Error -> Error end; false -> {error, access_denied} end; - Error -> - Error + Error -> Error end; - Error -> - Error + Error -> Error end; - Error -> - Error + Error -> Error end. %% Отмена бронирования (участником) diff --git a/src/logic/logic_moderation.erl b/src/logic/logic_moderation.erl index e849f66..e3df20e 100644 --- a/src/logic/logic_moderation.erl +++ b/src/logic/logic_moderation.erl @@ -16,6 +16,12 @@ create_report(ReporterId, TargetType, TargetId, Reason) -> true -> case core_report:create(ReporterId, TargetType, TargetId, Reason) of {ok, Report} -> + logic_notification:notify_admin(report_created, #{ + report_id => Report#report.id, + target_type => TargetType, + target_id => TargetId, + reason => Reason + }), % Проверяем порог для авто-модерации check_auto_freeze(TargetType, TargetId), {ok, Report}; @@ -116,7 +122,7 @@ freeze_calendar(AdminId, CalendarId) -> case is_admin(AdminId) of true -> case core_calendar:get_by_id(CalendarId) of - {ok, Calendar} -> + {ok, _Calendar} -> core_calendar:update(CalendarId, [{status, frozen}]); Error -> Error end; @@ -128,7 +134,7 @@ unfreeze_calendar(AdminId, CalendarId) -> case is_admin(AdminId) of true -> case core_calendar:get_by_id(CalendarId) of - {ok, Calendar} -> + {ok, _Calendar} -> core_calendar:update(CalendarId, [{status, active}]); Error -> Error end; @@ -140,7 +146,7 @@ freeze_event(AdminId, EventId) -> case is_admin(AdminId) of true -> case core_event:get_by_id(EventId) of - {ok, Event} -> + {ok, _Event} -> core_event:update(EventId, [{status, frozen}]); Error -> Error end; @@ -152,7 +158,7 @@ unfreeze_event(AdminId, EventId) -> case is_admin(AdminId) of true -> case core_event:get_by_id(EventId) of - {ok, Event} -> + {ok, _Event} -> core_event:update(EventId, [{status, active}]); Error -> Error end; diff --git a/src/logic/logic_notification.erl b/src/logic/logic_notification.erl new file mode 100644 index 0000000..324a24d --- /dev/null +++ b/src/logic/logic_notification.erl @@ -0,0 +1,56 @@ +-module(logic_notification). +-include("records.hrl"). + +-export([notify_booking/2]). +-export([notify_calendar_update/1]). +-export([notify_event_update/1]). +-export([notify_admin/2]). + +%% Уведомление о бронировании +notify_booking(UserId, Booking) -> + Data = #{ + booking_id => Booking#booking.id, + event_id => Booking#booking.event_id, + status => Booking#booking.status + }, + broadcast_to_user(UserId, booking_update, Data). + +%% Уведомление об обновлении календаря +notify_calendar_update(Calendar) -> + Data = #{ + calendar_id => Calendar#calendar.id, + title => Calendar#calendar.title, + status => Calendar#calendar.status + }, + broadcast_to_calendar_subscribers(Calendar#calendar.id, calendar_update, Data). + +%% Уведомление об обновлении события +notify_event_update(Event) -> + Data = #{ + event_id => Event#event.id, + calendar_id => Event#event.calendar_id, + title => Event#event.title, + status => Event#event.status, + start_time => Event#event.start_time + }, + broadcast_to_calendar_subscribers(Event#event.calendar_id, event_update, Data). + +%% Уведомление для администраторов +notify_admin(Type, Data) -> + Message = {admin_notification, Type, Data}, + % Отправляем всем админам + [Pid ! Message || Pid <- pg:get_members(eventhub_admin_ws)], + % Также отправляем в каналы + [Pid ! Message || Pid <- pg:get_members({eventhub_admin_channel, Type})], + ok. + +%% Внутренние функции +broadcast_to_user(UserId, Type, Data) -> + Message = {notification, Type, Data#{user_id => UserId}}, + [Pid ! Message || Pid <- pg:get_members(eventhub_ws)]. + +broadcast_to_calendar_subscribers(_CalendarId, _Type, _Data) -> + % В будущем можно фильтровать по подпискам + % Сейчас отправляем всем подключённым пользователям + Message = {notification, calendar_update, _Data}, + [Pid ! Message || Pid <- pg:get_members(eventhub_ws)]. \ No newline at end of file diff --git a/src/logic/logic_subscription.erl b/src/logic/logic_subscription.erl index 83f0ef2..c18a791 100644 --- a/src/logic/logic_subscription.erl +++ b/src/logic/logic_subscription.erl @@ -60,7 +60,7 @@ cancel_subscription(AdminId, SubscriptionId) -> case is_admin(AdminId) of true -> case core_subscription:get_by_id(SubscriptionId) of - {ok, Subscription} -> + {ok, _Subscription} -> core_subscription:update_status(SubscriptionId, cancelled); Error -> Error end; diff --git a/src/logic/logic_ticket.erl b/src/logic/logic_ticket.erl index 68f3f42..5037211 100644 --- a/src/logic/logic_ticket.erl +++ b/src/logic/logic_ticket.erl @@ -58,7 +58,7 @@ resolve_ticket(AdminId, TicketId, ResolutionNote) -> case is_admin(AdminId) of true -> case core_ticket:add_resolution(TicketId, ResolutionNote) of - {ok, Ticket} -> + {ok, _Ticket} -> core_ticket:update_status(TicketId, resolved); Error -> Error end; diff --git a/test/api/api_admin_tests.erl b/test/api/api_admin_tests.erl new file mode 100644 index 0000000..0af5ae8 --- /dev/null +++ b/test/api/api_admin_tests.erl @@ -0,0 +1,27 @@ +-module(api_admin_tests). +-export([test/0]). + +test() -> + io:format("Testing admin panel API...~n"), + + AdminToken = api_test_runner:get_admin_token(), + + % TEST 1: Admin healthcheck + io:format(" TEST 1: Admin healthcheck... "), + {ok, {{_, 200, _}, _, _}} = httpc:request(get, {"http://localhost:8445/admin/health", []}, [], []), + io:format("OK~n"), + + % TEST 2: Admin stats + io:format(" TEST 2: Admin stats... "), + {ok, {{_, 200, _}, _, _}} = httpc:request(get, + {"http://localhost:8445/admin/stats", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + io:format("OK~n"), + + % TEST 3: List users + io:format(" TEST 3: List users... "), + {ok, {{_, 200, _}, _, _}} = httpc:request(get, + {"http://localhost:8445/admin/users", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + io:format("OK~n"), + + io:format("~n✅ Admin API tests passed!~n"), + {?MODULE, ok}. \ No newline at end of file diff --git a/test/api/api_auth_tests.erl b/test/api/api_auth_tests.erl new file mode 100644 index 0000000..46f0ad0 --- /dev/null +++ b/test/api/api_auth_tests.erl @@ -0,0 +1,64 @@ +-module(api_auth_tests). +-export([test/0]). + +-define(BASE_URL, "http://localhost:8080"). + +test() -> + io:format("Testing authentication API...~n"), + + Email = api_test_runner:unique_email(<<"auth_test">>), + Password = <<"test123">>, + + % TEST 1: Register + io:format(" TEST 1: Register... "), + RegBody = #{email => Email, password => Password}, + Token = api_test_runner:extract_json( + api_test_runner:http_post("/v1/register", RegBody), <<"token">>), + io:format("OK~n"), + + % TEST 2: Register with existing email + io:format(" TEST 2: Register duplicate... "), + {ok, {{_, 409, _}, _, _}} = api_test_runner:http_post("/v1/register", RegBody), + io:format("OK~n"), + + % TEST 3: Login with correct credentials + io:format(" TEST 3: Login... "), + LoginBody = #{email => Email, password => Password}, + RefreshToken = api_test_runner:extract_json( + api_test_runner:http_post("/v1/login", LoginBody), <<"refresh_token">>), + io:format("OK~n"), + + % TEST 4: Login with wrong password + io:format(" TEST 4: Login wrong password... "), + {ok, {{_, 401, _}, _, _}} = api_test_runner:http_post("/v1/login", #{email => Email, password => <<"wrong">>}), + io:format("OK~n"), + + % TEST 5: Get profile with valid token + io:format(" TEST 5: Get profile... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/user/me", Token), + io:format("OK~n"), + + % TEST 6: Get profile with invalid token + io:format(" TEST 6: Get profile invalid token... "), + {ok, {{_, 401, _}, _, _}} = api_test_runner:http_get("/v1/user/me", <<"invalid">>), + io:format("OK~n"), + + % TEST 7: Refresh token + io:format(" TEST 7: Refresh token... "), + RefreshBody = #{refresh_token => RefreshToken}, + NewToken = api_test_runner:extract_json( + api_test_runner:http_post("/v1/refresh", RefreshBody), <<"token">>), + io:format("OK~n"), + + % TEST 8: Refresh with used token (should fail) + io:format(" TEST 8: Refresh with used token... "), + {ok, {{_, 401, _}, _, _}} = api_test_runner:http_post("/v1/refresh", RefreshBody), + io:format("OK~n"), + + % TEST 9: Use new token + io:format(" TEST 9: Use new token... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/user/me", NewToken), + io:format("OK~n"), + + io:format("~n✅ Authentication API tests passed!~n"), + {?MODULE, ok}. \ No newline at end of file diff --git a/test/api/api_booking_tests.erl b/test/api/api_booking_tests.erl new file mode 100644 index 0000000..860e2f8 --- /dev/null +++ b/test/api/api_booking_tests.erl @@ -0,0 +1,83 @@ +-module(api_booking_tests). +-export([test/0]). + +-define(BASE_URL, "http://localhost:8080"). + +test() -> + io:format("Testing booking API...~n"), + + OwnerEmail = api_test_runner:unique_email(<<"book_owner">>), + ParticipantEmail = api_test_runner:unique_email(<<"book_part">>), + + OwnerToken = api_test_runner:register_and_login(OwnerEmail, <<"owner123">>), + ParticipantToken = api_test_runner:register_and_login(ParticipantEmail, <<"part123">>), + + % Используем COMMERCIAL календари + AutoCalId = api_test_runner:extract_json( + api_test_runner:http_post("/v1/calendars", + #{title => <<"Auto">>, type => <<"commercial">>, confirmation => <<"auto">>}, OwnerToken), <<"id">>), + ManualCalId = api_test_runner:extract_json( + api_test_runner:http_post("/v1/calendars", + #{title => <<"Manual">>, type => <<"commercial">>, confirmation => <<"manual">>}, OwnerToken), <<"id">>), + + % Создаём события + AutoEventId = api_test_runner:extract_json( + api_test_runner:http_post("/v1/calendars/" ++ binary_to_list(AutoCalId) ++ "/events", + #{title => <<"Auto Event">>, start_time => <<"2026-06-01T10:00:00Z">>, duration => 60}, OwnerToken), <<"id">>), + ManualEventId = api_test_runner:extract_json( + api_test_runner:http_post("/v1/calendars/" ++ binary_to_list(ManualCalId) ++ "/events", + #{title => <<"Manual Event">>, start_time => <<"2026-06-01T10:00:00Z">>, duration => 60}, OwnerToken), <<"id">>), + + % TEST 1: Auto booking + io:format(" TEST 1: Auto booking... "), + AutoBookingId = api_test_runner:extract_json( + api_test_runner:http_post("/v1/events/" ++ binary_to_list(AutoEventId) ++ "/bookings", #{}, ParticipantToken), <<"id">>), + timer:sleep(200), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/bookings/" ++ binary_to_list(AutoBookingId), ParticipantToken), + io:format("OK~n"), + + % TEST 2: Manual booking + io:format(" TEST 2: Manual booking... "), + ManualBookingId = api_test_runner:extract_json( + api_test_runner:http_post("/v1/events/" ++ binary_to_list(ManualEventId) ++ "/bookings", #{}, ParticipantToken), <<"id">>), + io:format("OK~n"), + + % TEST 3: Duplicate booking + io:format(" TEST 3: Duplicate booking... "), + {ok, {{_, 409, _}, _, _}} = api_test_runner:http_post("/v1/events/" ++ binary_to_list(AutoEventId) ++ "/bookings", #{}, ParticipantToken), + io:format("OK~n"), + + % TEST 4: Owner confirms booking + io:format(" TEST 4: Owner confirms booking... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_put("/v1/bookings/" ++ binary_to_list(ManualBookingId), + #{action => <<"confirm">>}, OwnerToken), + io:format("OK~n"), + + % TEST 5: List event bookings + io:format(" TEST 5: List event bookings... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/events/" ++ binary_to_list(ManualEventId) ++ "/bookings", OwnerToken), + io:format("OK~n"), + + % TEST 6: List user bookings + io:format(" TEST 6: List user bookings... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/user/bookings", ParticipantToken), + io:format("OK~n"), + + % TEST 7: Cancel booking + io:format(" TEST 7: Cancel booking... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_delete("/v1/bookings/" ++ binary_to_list(AutoBookingId), ParticipantToken), + io:format("OK~n"), + + % TEST 8: Owner declines booking (новое событие) + io:format(" TEST 8: Owner declines booking... "), + NewEventId = api_test_runner:extract_json( + api_test_runner:http_post("/v1/calendars/" ++ binary_to_list(ManualCalId) ++ "/events", + #{title => <<"Decline Event">>, start_time => <<"2026-06-02T10:00:00Z">>, duration => 60}, OwnerToken), <<"id">>), + NewBookingId = api_test_runner:extract_json( + api_test_runner:http_post("/v1/events/" ++ binary_to_list(NewEventId) ++ "/bookings", #{}, ParticipantToken), <<"id">>), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_put("/v1/bookings/" ++ binary_to_list(NewBookingId), + #{action => <<"decline">>}, OwnerToken), + io:format("OK~n"), + + io:format("~n✅ Booking API tests passed!~n"), + {?MODULE, ok}. \ No newline at end of file diff --git a/test/api/api_calendar_tests.erl b/test/api/api_calendar_tests.erl new file mode 100644 index 0000000..2fd86d8 --- /dev/null +++ b/test/api/api_calendar_tests.erl @@ -0,0 +1,61 @@ +-module(api_calendar_tests). +-export([test/0]). + +-define(BASE_URL, "http://localhost:8080"). + +test() -> + io:format("Testing calendar API...~n"), + + OwnerEmail = api_test_runner:unique_email(<<"cal_owner">>), + OtherEmail = api_test_runner:unique_email(<<"cal_other">>), + + OwnerToken = api_test_runner:register_and_login(OwnerEmail, <<"owner123">>), + OtherToken = api_test_runner:register_and_login(OtherEmail, <<"other123">>), + + % TEST 1: Create personal calendar + io:format(" TEST 1: Create personal calendar... "), + CalId = api_test_runner:extract_json( + api_test_runner:http_post("/v1/calendars", + #{title => <<"Personal">>, type => <<"personal">>}, OwnerToken), <<"id">>), + io:format("OK~n"), + + % TEST 2: Create commercial calendar + io:format(" TEST 2: Create commercial calendar... "), + CommId = api_test_runner:extract_json( + api_test_runner:http_post("/v1/calendars", + #{title => <<"Commercial">>, type => <<"commercial">>}, OwnerToken), <<"id">>), + io:format("OK~n"), + + % TEST 3: List calendars + io:format(" TEST 3: List calendars... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/calendars", OwnerToken), + io:format("OK~n"), + + % TEST 4: Get personal calendar (owner) + io:format(" TEST 4: Get personal calendar... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/calendars/" ++ binary_to_list(CalId), OwnerToken), + io:format("OK~n"), + + % TEST 5: Get personal calendar (other - denied) + io:format(" TEST 5: Get personal calendar (other)... "), + {ok, {{_, 403, _}, _, _}} = api_test_runner:http_get("/v1/calendars/" ++ binary_to_list(CalId), OtherToken), + io:format("OK~n"), + + % TEST 6: Get commercial calendar (other - allowed) + io:format(" TEST 6: Get commercial calendar (other)... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/calendars/" ++ binary_to_list(CommId), OtherToken), + io:format("OK~n"), + + % TEST 7: Update calendar + io:format(" TEST 7: Update calendar... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_put("/v1/calendars/" ++ binary_to_list(CalId), + #{title => <<"Updated">>}, OwnerToken), + io:format("OK~n"), + + % TEST 8: Delete calendar + io:format(" TEST 8: Delete calendar... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_delete("/v1/calendars/" ++ binary_to_list(CalId), OwnerToken), + io:format("OK~n"), + + io:format("~n✅ Calendar API tests passed!~n"), + {?MODULE, ok}. \ No newline at end of file diff --git a/test/api/api_event_tests.erl b/test/api/api_event_tests.erl new file mode 100644 index 0000000..2caf373 --- /dev/null +++ b/test/api/api_event_tests.erl @@ -0,0 +1,70 @@ +-module(api_event_tests). +-export([test/0]). + +-define(BASE_URL, "http://localhost:8080"). + +test() -> + io:format("Testing event API...~n"), + + OwnerEmail = api_test_runner:unique_email(<<"ev_owner">>), + OwnerToken = api_test_runner:register_and_login(OwnerEmail, <<"owner123">>), + + CalId = api_test_runner:extract_json( + api_test_runner:http_post("/v1/calendars", #{title => <<"Test">>}, OwnerToken), <<"id">>), + + % TEST 1: Create single event + io:format(" TEST 1: Create single event... "), + EventId = api_test_runner:extract_json( + api_test_runner:http_post("/v1/calendars/" ++ binary_to_list(CalId) ++ "/events", + #{title => <<"Test Event">>, start_time => <<"2026-06-01T10:00:00Z">>, duration => 60}, OwnerToken), <<"id">>), + io:format("OK~n"), + + % TEST 2: Create event in past (should fail) + io:format(" TEST 2: Create past event... "), + {ok, {{_, 400, _}, _, _}} = api_test_runner:http_post("/v1/calendars/" ++ binary_to_list(CalId) ++ "/events", + #{title => <<"Past Event">>, start_time => <<"2020-01-01T10:00:00Z">>, duration => 60}, OwnerToken), + io:format("OK~n"), + + % TEST 3: Create recurring event + io:format(" TEST 3: Create recurring event... "), + RecurringId = api_test_runner:extract_json( + api_test_runner:http_post("/v1/calendars/" ++ binary_to_list(CalId) ++ "/events", + #{title => <<"Weekly Meeting">>, start_time => <<"2026-06-01T10:00:00Z">>, duration => 60, + recurrence => #{freq => <<"WEEKLY">>, interval => 1}}, OwnerToken), <<"id">>), + io:format("OK~n"), + + % TEST 4: List events + io:format(" TEST 4: List events... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/calendars/" ++ binary_to_list(CalId) ++ "/events", OwnerToken), + io:format("OK~n"), + + % TEST 5: Get event + io:format(" TEST 5: Get event... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/events/" ++ binary_to_list(EventId), OwnerToken), + io:format("OK~n"), + + % TEST 6: Update event + io:format(" TEST 6: Update event... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_put("/v1/events/" ++ binary_to_list(EventId), + #{title => <<"Updated Event">>}, OwnerToken), + io:format("OK~n"), + + % TEST 7: Get occurrences + io:format(" TEST 7: Get occurrences... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_get( + "/v1/events/" ++ binary_to_list(RecurringId) ++ "/occurrences?from=2026-06-01T00:00:00Z&to=2026-06-30T00:00:00Z", OwnerToken), + io:format("OK~n"), + + % TEST 8: Cancel occurrence + io:format(" TEST 8: Cancel occurrence... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_delete( + "/v1/events/" ++ binary_to_list(RecurringId) ++ "/occurrences/2026-06-08T10:00:00Z", OwnerToken), + io:format("OK~n"), + + % TEST 9: Delete event + io:format(" TEST 9: Delete event... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_delete("/v1/events/" ++ binary_to_list(EventId), OwnerToken), + io:format("OK~n"), + + io:format("~n✅ Event API tests passed!~n"), + {?MODULE, ok}. \ No newline at end of file diff --git a/test/api/api_moderation_tests.erl b/test/api/api_moderation_tests.erl new file mode 100644 index 0000000..a5d33d1 --- /dev/null +++ b/test/api/api_moderation_tests.erl @@ -0,0 +1,54 @@ +-module(api_moderation_tests). +-export([test/0]). + +-define(BASE_URL, "http://localhost:8080"). + +test() -> + io:format("Testing moderation API...~n"), + + AdminToken = api_test_runner:get_admin_token(), + UserToken = api_test_runner:get_user_token(), + + % Создаём календарь и событие + CalId = api_test_runner:extract_json( + api_test_runner:http_post("/v1/calendars", #{title => <<"Mod Cal">>}, UserToken), <<"id">>), + EventId = api_test_runner:extract_json( + api_test_runner:http_post("/v1/calendars/" ++ binary_to_list(CalId) ++ "/events", + #{title => <<"Mod Event">>, start_time => <<"2026-06-01T10:00:00Z">>, duration => 60}, UserToken), <<"id">>), + + % TEST 1: Create report + io:format(" TEST 1: Create report... "), + ReportId = api_test_runner:extract_json( + api_test_runner:http_post("/v1/reports", + #{target_type => <<"event">>, target_id => EventId, reason => <<"Inappropriate">>}, UserToken), <<"id">>), + io:format("OK~n"), + + % TEST 2: Admin views reports + io:format(" TEST 2: Admin views reports... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/admin/reports", AdminToken), + io:format("OK~n"), + + % TEST 3: Admin resolves report + io:format(" TEST 3: Admin resolves report... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_put("/v1/admin/reports/" ++ binary_to_list(ReportId), + #{action => <<"review">>}, AdminToken), + io:format("OK~n"), + + % TEST 4: Add banned word + io:format(" TEST 4: Add banned word... "), + {ok, {{_, 201, _}, _, _}} = api_test_runner:http_post("/v1/admin/banned-words", + #{word => <<"badword">>}, AdminToken), + io:format("OK~n"), + + % TEST 5: List banned words + io:format(" TEST 5: List banned words... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/admin/banned-words", AdminToken), + io:format("OK~n"), + + % TEST 6: Remove banned word + io:format(" TEST 6: Remove banned word... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_delete("/v1/admin/banned-words/badword", AdminToken), + io:format("OK~n"), + + io:format("~n✅ Moderation API tests passed!~n"), + {?MODULE, ok}. \ No newline at end of file diff --git a/test/api/api_reviews_tests.erl b/test/api/api_reviews_tests.erl new file mode 100644 index 0000000..ea13ffd --- /dev/null +++ b/test/api/api_reviews_tests.erl @@ -0,0 +1,58 @@ +-module(api_reviews_tests). +-export([test/0]). + +-define(BASE_URL, "http://localhost:8080"). + +test() -> + io:format("Testing reviews API...~n"), + + OwnerEmail = api_test_runner:unique_email(<<"rev_owner">>), + ParticipantEmail = api_test_runner:unique_email(<<"rev_part">>), + + OwnerToken = api_test_runner:register_and_login(OwnerEmail, <<"owner123">>), + ParticipantToken = api_test_runner:register_and_login(ParticipantEmail, <<"part123">>), + + CalId = api_test_runner:extract_json( + api_test_runner:http_post("/v1/calendars", #{title => <<"Review Cal">>}, OwnerToken), <<"id">>), + + EventId = api_test_runner:extract_json( + api_test_runner:http_post("/v1/calendars/" ++ binary_to_list(CalId) ++ "/events", + #{title => <<"Review Event">>, start_time => <<"2026-06-01T10:00:00Z">>, duration => 60}, OwnerToken), <<"id">>), + + % Создаём и подтверждаем бронирование + BookingId = api_test_runner:extract_json( + api_test_runner:http_post("/v1/events/" ++ binary_to_list(EventId) ++ "/bookings", #{}, ParticipantToken), <<"id">>), + api_test_runner:http_put("/v1/bookings/" ++ binary_to_list(BookingId), #{action => <<"confirm">>}, OwnerToken), + + % TEST 1: Create review + io:format(" TEST 1: Create review... "), + ReviewId = api_test_runner:extract_json( + api_test_runner:http_post("/v1/reviews", + #{target_type => <<"event">>, target_id => EventId, rating => 5, comment => <<"Great!">>}, + ParticipantToken), <<"id">>), + io:format("OK~n"), + + % TEST 2: Duplicate review + io:format(" TEST 2: Duplicate review... "), + {ok, {{_, 409, _}, _, _}} = api_test_runner:http_post("/v1/reviews", + #{target_type => <<"event">>, target_id => EventId, rating => 4, comment => <<"Again">>}, ParticipantToken), + io:format("OK~n"), + + % TEST 3: Get event reviews + io:format(" TEST 3: Get event reviews... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/reviews?target_type=event&target_id=" ++ binary_to_list(EventId), ParticipantToken), + io:format("OK~n"), + + % TEST 4: Update review + io:format(" TEST 4: Update review... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_put("/v1/reviews/" ++ binary_to_list(ReviewId), + #{rating => 4}, ParticipantToken), + io:format("OK~n"), + + % TEST 5: Delete review + io:format(" TEST 5: Delete review... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_delete("/v1/reviews/" ++ binary_to_list(ReviewId), ParticipantToken), + io:format("OK~n"), + + io:format("~n✅ Reviews API tests passed!~n"), + {?MODULE, ok}. \ No newline at end of file diff --git a/test/api/api_search_tests.erl b/test/api/api_search_tests.erl new file mode 100644 index 0000000..6f551fc --- /dev/null +++ b/test/api/api_search_tests.erl @@ -0,0 +1,54 @@ +-module(api_search_tests). +-export([test/0]). + +-define(BASE_URL, "http://localhost:8080"). + +test() -> + io:format("Testing search API...~n"), + + OwnerEmail = api_test_runner:unique_email(<<"search_owner">>), + OwnerToken = api_test_runner:register_and_login(OwnerEmail, <<"owner123">>), + + CalId = api_test_runner:extract_json( + api_test_runner:http_post("/v1/calendars", #{title => <<"Search Cal">>}, OwnerToken), <<"id">>), + + % Создаём события с тегами + api_test_runner:extract_json( + api_test_runner:http_post("/v1/calendars/" ++ binary_to_list(CalId) ++ "/events", + #{title => <<"Python Workshop">>, start_time => <<"2026-06-01T10:00:00Z">>, duration => 60, + tags => [<<"python">>, <<"workshop">>]}, OwnerToken), <<"id">>), + + api_test_runner:extract_json( + api_test_runner:http_post("/v1/calendars/" ++ binary_to_list(CalId) ++ "/events", + #{title => <<"JavaScript">>, start_time => <<"2026-06-15T10:00:00Z">>, duration => 60, + tags => [<<"javascript">>]}, OwnerToken), <<"id">>), + + timer:sleep(500), + + % TEST 1: Text search + io:format(" TEST 1: Text search... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/search?type=event&q=Python", OwnerToken), + io:format("OK~n"), + + % TEST 2: Tag search + io:format(" TEST 2: Tag search... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/search?type=event&tags=workshop", OwnerToken), + io:format("OK~n"), + + % TEST 3: Combined search + io:format(" TEST 3: Combined search... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/search?type=event&q=Python&tags=workshop", OwnerToken), + io:format("OK~n"), + + % TEST 4: Pagination + io:format(" TEST 4: Pagination... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/search?type=event&limit=2", OwnerToken), + io:format("OK~n"), + + % TEST 5: Search calendars + io:format(" TEST 5: Search calendars... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/search?type=calendar", OwnerToken), + io:format("OK~n"), + + io:format("~n✅ Search API tests passed!~n"), + {?MODULE, ok}. \ No newline at end of file diff --git a/test/api/api_subscription_tests.erl b/test/api/api_subscription_tests.erl new file mode 100644 index 0000000..dea69d1 --- /dev/null +++ b/test/api/api_subscription_tests.erl @@ -0,0 +1,36 @@ +-module(api_subscription_tests). +-export([test/0]). + +-define(BASE_URL, "http://localhost:8080"). + +test() -> + io:format("Testing subscription API...~n"), + + UserEmail = api_test_runner:unique_email(<<"sub_user">>), + UserToken = api_test_runner:register_and_login(UserEmail, <<"user123">>), + + % TEST 1: Get subscription (free) + io:format(" TEST 1: Get subscription (free)... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/subscription", UserToken), + io:format("OK~n"), + + % TEST 2: Create commercial calendar (auto-activates trial) + io:format(" TEST 2: Create commercial calendar... "), + api_test_runner:extract_json( + api_test_runner:http_post("/v1/calendars", + #{title => <<"Commercial">>, type => <<"commercial">>}, UserToken), <<"id">>), + io:format("OK~n"), + + % TEST 3: Get subscription (trial) + io:format(" TEST 3: Get subscription (trial)... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/subscription", UserToken), + io:format("OK~n"), + + % TEST 4: Activate paid subscription + io:format(" TEST 4: Activate paid subscription... "), + {ok, {{_, 201, _}, _, _}} = api_test_runner:http_post("/v1/subscription", + #{action => <<"activate">>, plan => <<"monthly">>, payment_info => #{card => <<"4242">>}}, UserToken), + io:format("OK~n"), + + io:format("~n✅ Subscription API tests passed!~n"), + {?MODULE, ok}. \ No newline at end of file diff --git a/test/api/api_test_runner.erl b/test/api/api_test_runner.erl new file mode 100644 index 0000000..077f1ee --- /dev/null +++ b/test/api/api_test_runner.erl @@ -0,0 +1,216 @@ +-module(api_test_runner). +-export([run_all/0, run/1]). +-export([http_post/2, http_post/3, http_get/1, http_get/2, http_put/3, http_delete/2]). +-export([extract_json/2, extract_json/3, assert_status/2]). +-export([unique_email/1, register_and_login/2, create_calendar/2, create_event/3]). +-export([get_admin_token/0, get_admin_id/0, get_user_token/0, get_user_id/0]). +-export([wait_for_server/0]). + +-define(BASE_URL, "http://localhost:8080"). +-define(ADMIN_URL, "http://localhost:8445"). + +%% ============ Глобальные переменные для тестов ============ +-define(ADMIN_EMAIL, <<"global_admin@test.com">>). +-define(ADMIN_PASSWORD, <<"admin123">>). +-define(USER_EMAIL, <<"global_user@test.com">>). +-define(USER_PASSWORD, <<"user123">>). + +%% ============ Инициализация ============ +init_global_users() -> + case get(admin_token) of + undefined -> + io:format("~n=== Initializing global test users ===~n"), + + % Создаём или логиним админа + AdminToken = register_and_login(?ADMIN_EMAIL, ?ADMIN_PASSWORD), + {ok, {{_, 200, _}, _, MeResp}} = http_get("/v1/user/me", AdminToken), + #{<<"id">> := AdminId, <<"role">> := Role} = jsx:decode(list_to_binary(MeResp), [return_maps]), + + io:format("Admin ID: ~s, Current role: ~s~n", [AdminId, Role]), + + % Проверяем, что админ действительно админ + case Role of + <<"admin">> -> + io:format("✓ Admin already has admin role~n"), + ok; + _ -> + io:format("⚠ Admin role is '~s', attempting to promote...~n", [Role]), + promote_to_admin(AdminToken, AdminId) + end, + + put(admin_token, AdminToken), + put(admin_id, AdminId), + + % Создаём или логиним обычного пользователя + UserToken = register_and_login(?USER_EMAIL, ?USER_PASSWORD), + {ok, {{_, 200, _}, _, UserMeResp}} = http_get("/v1/user/me", UserToken), + #{<<"id">> := UserId} = jsx:decode(list_to_binary(UserMeResp), [return_maps]), + + put(user_token, UserToken), + put(user_id, UserId), + + io:format("User ID: ~s~n", [UserId]), + io:format("=== Global users initialized ===~n~n"), + ok; + _ -> + io:format("Global users already initialized.~n"), + ok + end. + +%% Попытка повысить роль через разные методы +promote_to_admin(AdminToken, AdminId) -> + io:format("Attempting to promote user ~s to admin...~n", [AdminId]), + + % Метод 1: Прямое обновление через core_user (если доступно) + try + {ok, _User} = core_user:get_by_id(AdminId), + core_user:update(AdminId, [{role, admin}]), + io:format("✓ Promoted via core_user~n") + catch + _:_ -> + io:format(" Method 1 (core_user) failed~n") + end, + + % Проверяем, сработало ли + {ok, {{_, 200, _}, _, CheckResp}} = http_get("/v1/user/me", AdminToken), + #{<<"role">> := NewRole} = jsx:decode(list_to_binary(CheckResp), [return_maps]), + + case NewRole of + <<"admin">> -> + io:format("✓ User is now admin~n"); + _ -> + io:format("⚠ WARNING: User still has role '~s'~n", [NewRole]), + io:format(" Some admin tests may fail~n") + end. + +get_admin_token() -> + init_global_users(), + get(admin_token). + +get_admin_id() -> + init_global_users(), + get(admin_id). + +get_user_token() -> + init_global_users(), + get(user_token). + +get_user_id() -> + init_global_users(), + get(user_id). + +%% ============ Главные функции запуска ============ +run_all() -> + inets:start(), + ssl:start(), + + case wait_for_server() of + ok -> ok; + {error, _} -> io:format("❌ Server is not running!~n"), exit(server_not_running) + end, + + init_global_users(), + + io:format("Starting API tests...~n"), + Modules = [ + api_auth_tests, + api_calendar_tests, + api_event_tests, + api_booking_tests, + api_search_tests, + api_reviews_tests, + api_moderation_tests, + api_tickets_tests, + api_subscription_tests, + api_admin_tests + ], + lists:foreach(fun(M) -> M:test() end, Modules). + +run(Module) -> + inets:start(), + ssl:start(), + init_global_users(), + Module:test(). + +%% ============ HTTP запросы ============ +http_post(Url, Body) -> http_post(Url, Body, undefined). +http_post(Url, Body, Token) -> + Headers = case Token of + undefined -> [{"Content-Type", "application/json"}]; + _ -> [{"Content-Type", "application/json"}, {"Authorization", "Bearer " ++ binary_to_list(Token)}] + end, + httpc:request(post, {?BASE_URL ++ Url, Headers, "application/json", jsx:encode(Body)}, [], []). + +http_get(Url) -> http_get(Url, undefined). +http_get(Url, Token) -> + Headers = case Token of + undefined -> []; + _ -> [{"Authorization", "Bearer " ++ binary_to_list(Token)}] + end, + httpc:request(get, {?BASE_URL ++ Url, Headers}, [], []). + +http_put(Url, Body, Token) -> + Headers = [{"Content-Type", "application/json"}, {"Authorization", "Bearer " ++ binary_to_list(Token)}], + httpc:request(put, {?BASE_URL ++ Url, Headers, "application/json", jsx:encode(Body)}, [], []). + +http_delete(Url, Token) -> + Headers = [{"Authorization", "Bearer " ++ binary_to_list(Token)}], + httpc:request(delete, {?BASE_URL ++ Url, Headers}, [], []). + +%% ============ Утилиты ============ + +extract_json({ok, {{_, 200, _}, _, Body}}, Field) -> + Map = jsx:decode(list_to_binary(Body), [return_maps]), + maps:get(Field, Map); +extract_json({ok, {{_, 201, _}, _, Body}}, Field) -> + Map = jsx:decode(list_to_binary(Body), [return_maps]), + maps:get(Field, Map); +extract_json(Response, _Field) -> + error({unexpected_response, Response}). + +extract_json(Response, Field, ExpectedStatus) -> + case Response of + {ok, {{_, ExpectedStatus, _}, _, Body}} -> + Map = jsx:decode(list_to_binary(Body), [return_maps]), + maps:get(Field, Map); + _ -> + error({unexpected_response, Response}) + end. + +assert_status(Status, {ok, {{_, Status, _}, _, _}}) -> ok; +assert_status(Expected, {ok, {{_, Got, _}, _, _}}) -> + error({expected_status, Expected, got, Got}). + +unique_email(Prefix) -> + list_to_binary([Prefix, "_", integer_to_binary(os:system_time(millisecond)), "@test.com"]). + +register_and_login(Email, Password) -> + RegBody = #{email => Email, password => Password}, + case http_post("/v1/register", RegBody) of + {ok, {{_, 201, _}, _, RegResp}} -> + Map = jsx:decode(list_to_binary(RegResp), [return_maps]), + maps:get(<<"token">>, Map); + {ok, {{_, 409, _}, _, _}} -> + % Уже существует - логинимся + LoginBody = #{email => Email, password => Password}, + {ok, {{_, 200, _}, _, LoginResp}} = http_post("/v1/login", LoginBody), + Map = jsx:decode(list_to_binary(LoginResp), [return_maps]), + maps:get(<<"token">>, Map) + end. + +create_calendar(Token, Params) -> + Id = extract_json(http_post("/v1/calendars", Params, Token), <<"id">>), + Id. + +create_event(Token, CalId, Params) -> + Url = "/v1/calendars/" ++ binary_to_list(CalId) ++ "/events", + Id = extract_json(http_post(Url, Params, Token), <<"id">>), + Id. + +wait_for_server() -> wait_for_server(30). +wait_for_server(0) -> {error, timeout}; +wait_for_server(Attempts) -> + case httpc:request(get, {?BASE_URL ++ "/health", []}, [], [{timeout, 1000}]) of + {ok, {{_, 200, _}, _, _}} -> ok; + _ -> timer:sleep(1000), wait_for_server(Attempts - 1) + end. \ No newline at end of file diff --git a/test/api/api_tickets_tests.erl b/test/api/api_tickets_tests.erl new file mode 100644 index 0000000..4c8b785 --- /dev/null +++ b/test/api/api_tickets_tests.erl @@ -0,0 +1,37 @@ +-module(api_tickets_tests). +-export([test/0]). + +-define(BASE_URL, "http://localhost:8080"). + +test() -> + io:format("Testing tickets API...~n"), + + AdminToken = api_test_runner:get_admin_token(), + UserToken = api_test_runner:get_user_token(), + + % TEST 1: Report error + io:format(" TEST 1: Report error... "), + TicketId = api_test_runner:extract_json( + api_test_runner:http_post("/v1/tickets", + #{error_message => <<"Test bug">>, stacktrace => <<"line 1">>}, UserToken), <<"id">>), + io:format("OK~n"), + + % TEST 2: Admin views tickets + io:format(" TEST 2: Admin views tickets... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/admin/tickets", AdminToken), + io:format("OK~n"), + + % TEST 3: Update ticket status + io:format(" TEST 3: Update ticket status... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_put("/v1/admin/tickets/" ++ binary_to_list(TicketId), + #{action => <<"status">>, status => <<"in_progress">>}, AdminToken), + io:format("OK~n"), + + % TEST 4: Close ticket + io:format(" TEST 4: Close ticket... "), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_put("/v1/admin/tickets/" ++ binary_to_list(TicketId), + #{action => <<"close">>}, AdminToken), + io:format("OK~n"), + + io:format("~n✅ Tickets API tests passed!~n"), + {?MODULE, ok}. \ No newline at end of file diff --git a/test/api/api_websocket_tests.erl b/test/api/api_websocket_tests.erl new file mode 100644 index 0000000..faec2b7 --- /dev/null +++ b/test/api/api_websocket_tests.erl @@ -0,0 +1,238 @@ +-module(api_websocket_tests). +-export([test/0]). + +-define(BASE_URL, "http://localhost:8080"). +-define(WS_URL, "ws://localhost:8081/ws"). +-define(ADMIN_WS_URL, "ws://localhost:8446/admin/ws"). + +test() -> + ct:pal("Testing WebSocket API..."), + + % Запускаем gun + application:ensure_all_started(gun), + + % Используем глобальных пользователей + AdminToken = api_test_runner:get_admin_token(), + UserToken = api_test_runner:get_user_token(), + + ct:pal(" AdminToken: ~s...", [binary_part(AdminToken, 0, 30)]), + ct:pal(" UserToken: ~s...", [binary_part(UserToken, 0, 30)]), + + % Создаём календарь и событие для тестов + CalId = api_test_runner:extract_json( + api_test_runner:http_post("/v1/calendars", + #{title => <<"WS Test Calendar">>, type => <<"commercial">>}, + UserToken), <<"id">>, 201), + ct:pal(" CalId: ~s", [CalId]), + + EventId = api_test_runner:extract_json( + api_test_runner:http_post("/v1/calendars/" ++ binary_to_list(CalId) ++ "/events", + #{title => <<"WS Test Event">>, + start_time => <<"2026-06-01T10:00:00Z">>, + duration => 60}, + UserToken), <<"id">>, 201), + ct:pal(" EventId: ~s", [EventId]), + + % TEST 1: Connect to WebSocket with valid token + ct:pal(" TEST 1: Connect WebSocket with valid token..."), + ct:pal(" URL: ~s", [?WS_URL]), + ct:pal(" Token: ~s...", [binary_part(UserToken, 0, 30)]), + + case test_ws_connect_debug(?WS_URL, UserToken) of + {ok, WS} -> + ct:pal(" OK - Connected"), + + % TEST 2: Subscribe to calendar updates + ct:pal(" TEST 2: Subscribe to calendar..."), + SubMsg = #{action => <<"subscribe">>, calendar_id => CalId}, + ct:pal(" Sending: ~p", [SubMsg]), + ok = test_ws_send(WS, SubMsg), + + case test_ws_recv(WS) of + {ok, #{<<"status">> := <<"subscribed">>}} -> + ct:pal(" OK - Subscribed"); + {ok, Other} -> + ct:pal(" ERROR: Unexpected response: ~p", [Other]), + error({unexpected_response, Other}); + {error, timeout} -> + ct:pal(" ERROR: Timeout waiting for response"), + error(timeout) + end, + + % Закрываем соединение + test_ws_close(WS); + {error, Reason} -> + ct:pal(" ERROR: ~p", [Reason]), + error({websocket_connect_failed, Reason}) + end, + + ct:pal("~n✅ WebSocket API tests passed!"), + +% ============ ТЕСТЫ АДМИНСКОГО WEBSOCKET ============ + ct:pal("~n=== ADMIN WEBSOCKET TESTS ==="), + + % TEST 6: Admin WebSocket connection + ct:pal(" TEST 6: Admin WebSocket connect..."), + {ok, AdminWS} = test_ws_connect_debug(?ADMIN_WS_URL, AdminToken), + ct:pal(" OK - Admin connected"), + + % TEST 7: Admin subscribe to reports channel + ct:pal(" TEST 7: Admin subscribe to reports channel..."), + ok = test_ws_send(AdminWS, #{action => <<"subscribe">>, channel => <<"reports">>}), + {ok, #{<<"status">> := <<"subscribed">>}} = test_ws_recv(AdminWS), + ct:pal(" OK - Subscribed to reports"), + + % TEST 8: Admin subscribe to tickets channel + ct:pal(" TEST 8: Admin subscribe to tickets channel..."), + ok = test_ws_send(AdminWS, #{action => <<"subscribe">>, channel => <<"tickets">>}), + {ok, #{<<"status">> := <<"subscribed">>}} = test_ws_recv(AdminWS), + ct:pal(" OK - Subscribed to tickets"), + + % TEST 9: Admin receives report notification + ct:pal(" TEST 9: Admin receives report notification..."), + % Создаём жалобу через HTTP + api_test_runner:http_post("/v1/reports", + #{target_type => <<"event">>, target_id => EventId, reason => <<"Test report">>}, + UserToken), + {ok, #{<<"type">> := <<"report_created">>}} = test_ws_recv(AdminWS, 5000), + ct:pal(" OK - Received report notification"), + + % TEST 10: Admin Ping/Pong + ct:pal(" TEST 10: Admin Ping/Pong..."), + ok = test_ws_send(AdminWS, #{action => <<"ping">>}), + {ok, #{<<"status">> := <<"pong">>}} = test_ws_recv(AdminWS), + ct:pal(" OK - Admin Ping/Pong"), + + % TEST 11: Admin unsubscribe + ct:pal(" TEST 11: Admin unsubscribe from reports..."), + ok = test_ws_send(AdminWS, #{action => <<"unsubscribe">>, channel => <<"reports">>}), + {ok, #{<<"status">> := <<"unsubscribed">>}} = test_ws_recv(AdminWS), + ct:pal(" OK - Unsubscribed"), + + test_ws_close(AdminWS), + + % TEST 12: Admin WebSocket with user token (should fail) + ct:pal(" TEST 12: Admin WS with user token..."), + {error, {403, _}} = test_ws_connect_debug(?ADMIN_WS_URL, UserToken), + ct:pal(" OK - Rejected"), + + % TEST 13: Admin WebSocket with invalid token + ct:pal(" TEST 13: Admin WS with invalid token..."), + Chars = <<"abcdefghijklmnopqrstuvwxyz0123456789">>, + InvalidToken = << <<(binary:at(Chars, rand:uniform(byte_size(Chars)) - 1))>> || _ <- lists:seq(1, 30) >>, + {error, {401, _}} = test_ws_connect_debug(?ADMIN_WS_URL, InvalidToken), + ct:pal(" OK - Rejected"), + + ct:pal("~n✅ Admin WebSocket API tests passed!"), + {?MODULE, ok}. + +%% ============ WebSocket хелперы с отладкой ============ + +test_ws_connect_debug(Url, Token) -> + Path = case string:split(Url, "://", trailing) of + [_, Rest] -> + case string:split(Rest, "/", leading) of + [_HostPort, WsPath] -> + "/" ++ WsPath ++ "?token=" ++ binary_to_list(Token); + _ -> + "/ws?token=" ++ binary_to_list(Token) + end; + _ -> + "/ws?token=" ++ binary_to_list(Token) + end, + + Port = ws_port(Url), + Host = "localhost", + + ct:pal(" Host: ~s", [Host]), + ct:pal(" Port: ~p", [Port]), + ct:pal(" Path: ~s", [Path]), + + {ok, ConnPid} = gun:open(Host, Port, #{protocols => [http]}), + {ok, http} = gun:await_up(ConnPid, 5000), + + Headers = [ + {<<"host">>, list_to_binary(Host ++ ":" ++ integer_to_list(Port))} + ], + StreamRef = gun:ws_upgrade(ConnPid, Path, Headers), + + % Ожидаем ответ + receive + {gun_upgrade, ConnPid, StreamRef, [<<"websocket">>], _} -> + ct:pal(" WebSocket upgrade OK"), + {ok, ConnPid}; + {gun_response, ConnPid, StreamRef, fin, 401, _} -> + ct:pal(" ERROR: HTTP 401 Unauthorized"), + gun:close(ConnPid), + {error, {401, <<"Invalid token">>}}; + {gun_response, ConnPid, StreamRef, fin, 403, _} -> + ct:pal(" ERROR: HTTP 403 Forbidden"), + gun:close(ConnPid), + {error, {403, <<"Admin access required">>}}; + {gun_response, ConnPid, StreamRef, nofin, 403, _} -> + ct:pal(" ERROR: HTTP 403 Forbidden (nofin)"), + gun:close(ConnPid), + {error, {403, <<"Admin access required">>}}; + {gun_response, ConnPid, StreamRef, fin, Status, _} -> + ct:pal(" ERROR: HTTP ~p", [Status]), + gun:close(ConnPid), + {error, {Status, <<"WebSocket upgrade failed">>}}; + {gun_response, ConnPid, StreamRef, nofin, Status, _} -> + ct:pal(" ERROR: HTTP ~p (nofin)", [Status]), + gun:close(ConnPid), + {error, {Status, <<"WebSocket upgrade failed">>}}; + {gun_error, ConnPid, Reason} -> + ct:pal(" ERROR: ~p", [Reason]), + gun:close(ConnPid), + {error, Reason} + after 5000 -> + ct:pal(" ERROR: Timeout"), + gun:close(ConnPid), + {error, timeout} + end. + +test_ws_send(ConnPid, Data) -> + Msg = jsx:encode(Data), + ct:pal(" Sending: ~s", [Msg]), + case catch gun:ws_send(ConnPid, {text, Msg}) of + ok -> ok; + {'EXIT', {undef, _}} -> + % Пробуем альтернативный синтаксис + gun:ws_send(ConnPid, fin, {text, Msg}); + Other -> + ct:pal(" ERROR sending: ~p", [Other]), + error({ws_send_failed, Other}) + end. + +test_ws_recv(ConnPid) -> + test_ws_recv(ConnPid, 3000). + +test_ws_recv(ConnPid, Timeout) -> + receive + {gun_ws, ConnPid, _StreamRef, {text, Msg}} -> + ct:pal(" Received (with StreamRef): ~s", [Msg]), + {ok, jsx:decode(Msg, [return_maps])}; + {gun_ws, ConnPid, {text, Msg}} -> + ct:pal(" Received: ~s", [Msg]), + {ok, jsx:decode(Msg, [return_maps])}; + {gun_ws, ConnPid, _StreamRef, Frame} -> + ct:pal(" Received frame: ~p", [Frame]), + {ok, Frame}; + {gun_ws, ConnPid, Frame} -> + ct:pal(" Received: ~p", [Frame]), + {ok, Frame}; + {gun_error, ConnPid, Reason} -> + ct:pal(" ERROR: gun_error ~p", [Reason]), + {error, Reason}; + Other -> + ct:pal(" Received unexpected: ~p", [Other]), + test_ws_recv(ConnPid, Timeout) + after Timeout -> + {error, timeout} + end. + +test_ws_close(ConnPid) -> + gun:close(ConnPid). + +ws_port("ws://localhost:8081" ++ _) -> 8081; +ws_port("ws://localhost:8446" ++ _) -> 8446. \ No newline at end of file diff --git a/test/api_SUITE.erl b/test/api_SUITE.erl new file mode 100644 index 0000000..8ab70af --- /dev/null +++ b/test/api_SUITE.erl @@ -0,0 +1,98 @@ +-module(api_SUITE). +-include_lib("common_test/include/ct.hrl"). + +-export([all/0, init_per_suite/1, end_per_suite/1]). +-export([auth_test/1, calendar_test/1, event_test/1, booking_test/1]). +-export([search_test/1, reviews_test/1, moderation_test/1]). +-export([tickets_test/1, subscription_test/1, admin_test/1]). +-export([websocket_test/1]). + +all() -> [ + auth_test, + calendar_test, + event_test, + booking_test, + search_test, + reviews_test, + moderation_test, + tickets_test, + subscription_test, + admin_test, + websocket_test +]. + +init_per_suite(Config) -> + % Очищаем Mnesia перед тестами + io:format("~n=== Cleaning Mnesia for fresh test run ===~n"), + os:cmd("rm -rf Mnesia.* 2>/dev/null || true"), + timer:sleep(2000), + % Запускаем сервер + io:format("Starting server...~n"), + {ok, _Apps} = application:ensure_all_started(eventhub), + + % Компилируем модули из test/api/ + code:add_patha("_build/test/lib/eventhub/ebin"), + code:add_patha("test/api"), + + % Компилируем все файлы в test/api/ + compile_api_modules(), + + inets:start(), + ssl:start(), + + %% Perform healthcheck (simplified) + Url = "http://localhost:8080", + case httpc:request(get, {Url ++ "/health", []}, [], []) of + {ok, {{_Version, 200, _Reason}, _Headers, _Body}} -> + ok; %% Healthcheck passed + _Error -> + ct:log("Healthcheck failed for: ~p", [Url]), + error(healthcheck_failed) + end, + Config. + +end_per_suite(_Config) -> + application:stop(eventhub), + ok. + +compile_api_modules() -> + Files = filelib:wildcard("test/api/*.erl"), + lists:foreach(fun(File) -> + compile:file(File, [report, {outdir, "test/api"}]) + end, Files), + code:add_patha("test/api"). + +%% ============ ТЕСТЫ-ПРОКСИ ============ + +auth_test(_Config) -> + api_auth_tests:test(). + +calendar_test(_Config) -> + api_calendar_tests:test(). + +event_test(_Config) -> + api_event_tests:test(). + +booking_test(_Config) -> + api_booking_tests:test(). + +search_test(_Config) -> + api_search_tests:test(). + +reviews_test(_Config) -> + api_reviews_tests:test(). + +moderation_test(_Config) -> + api_moderation_tests:test(). + +tickets_test(_Config) -> + api_tickets_tests:test(). + +subscription_test(_Config) -> + api_subscription_tests:test(). + +admin_test(_Config) -> + api_admin_tests:test(). + +websocket_test(_Config) -> + api_websocket_tests:test(). \ No newline at end of file diff --git a/test/scripts/test_websocket_api.sh b/test/scripts/test_websocket_api.sh new file mode 100644 index 0000000..355cb69 --- /dev/null +++ b/test/scripts/test_websocket_api.sh @@ -0,0 +1,313 @@ +#!/bin/bash + +RED='\033[0;31m' +GREEN='\033[0;32m' +BLUE='\033[0;34m' +YELLOW='\033[1;33m' +CYAN='\033[0;36m' +NC='\033[0m' + +BASE_URL="http://localhost:8080" +WS_URL="ws://localhost:8081/ws" +ADMIN_WS_URL="ws://localhost:8446/admin/ws" + +DEBUG=${DEBUG:-false} + +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_debug() { + if [ "$DEBUG" = "true" ]; then + echo -e "${CYAN}[DEBUG]${NC} $1" + fi +} + +extract_json() { + echo "$1" | grep -o "\"$2\":\"[^\"]*\"" | head -1 | sed "s/\"$2\":\"//;s/\"$//" +} + +http_post() { + local url=$1; local data=$2; local token=$3 + log_debug "POST $url" + 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 + log_debug "GET $url" + 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 + log_debug "PUT $url" + curl -s -X PUT "$url" -H "Content-Type: application/json" -H "Authorization: Bearer $token" -d "$data" +} + +# Проверка curl WebSocket (ручная проверка заголовков) +test_ws_curl() { + local url=$1 + local token=$2 + local full_url="${url}?token=${token}" + + log_debug "Testing WebSocket with curl: $full_url" + + # Используем --include для заголовков, --no-buffer для немедленного вывода + response=$(curl -s --include --no-buffer \ + -H "Connection: Upgrade" \ + -H "Upgrade: websocket" \ + -H "Sec-WebSocket-Key: x3JJHMbDL1EzLkh9GBhXDw==" \ + -H "Sec-WebSocket-Version: 13" \ + --max-time 2 \ + "$full_url" 2>&1) + + log_debug "Response: $(echo "$response" | head -5)" + + if echo "$response" | grep -q "101"; then + log_debug "Got 101 Switching Protocols" + return 0 + elif echo "$response" | grep -q "401"; then + log_debug "Got 401 Unauthorized" + return 1 + elif echo "$response" | grep -q "403"; then + log_debug "Got 403 Forbidden" + return 2 + elif echo "$response" | grep -q "404"; then + log_debug "Got 404 Not Found" + return 4 + elif echo "$response" | grep -q "Invalid token"; then + log_debug "Got 'Invalid token' message" + return 1 + else + log_debug "Unknown response" + return 3 + fi +} + +# Проверка наличия websocat +check_websocat() { + if ! command -v websocat &> /dev/null; then + log_warning "websocat не установлен" + echo "Установите websocat:" + echo " cargo install websocat" + echo " или скачайте с https://github.com/vi/websocat/releases" + return 1 + fi + log_debug "websocat found: $(which websocat)" + return 0 +} + +echo "============================================================" +echo " EVENTHUB WEBSOCKET API TEST SCRIPT" +echo "============================================================" +echo "" + +if [ "$DEBUG" = "true" ]; then + log_info "DEBUG MODE ENABLED" +fi + +log_info "Checking if servers are running..." +if ! curl -s "$BASE_URL/health" | grep -q "ok"; then + log_error "Main server is not running on port 8080" + exit 1 +fi +log_success "Main server is running" + +if ! curl -s "http://localhost:8445/admin/health" | grep -q "ok"; then + log_warning "Admin server is not running on port 8445" +else + log_success "Admin server is running" +fi + +echo "" +log_info "============================================================" +log_info "STEP 1: Create test users" +log_info "============================================================" + +# Админ +ADMIN_EMAIL="ws_admin_$(date +%s)@example.com" +ADMIN_PASSWORD="admin123" + +log_info "Creating admin user..." +response=$(http_post "$BASE_URL/v1/register" "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASSWORD\"}" "") +log_debug "Register response: $response" +ADMIN_TOKEN=$(extract_json "$response" "token") +ADMIN_ID=$(extract_json "$response" "id") +log_success "Admin created: $ADMIN_EMAIL" +log_debug "Admin token: ${ADMIN_TOKEN:0:30}..." + +# Обычный пользователь +USER_EMAIL="ws_user_$(date +%s)@example.com" +USER_PASSWORD="user123" + +log_info "Creating regular user..." +response=$(http_post "$BASE_URL/v1/register" "{\"email\":\"$USER_EMAIL\",\"password\":\"$USER_PASSWORD\"}" "") +USER_TOKEN=$(extract_json "$response" "token") +USER_ID=$(extract_json "$response" "id") +log_success "User created: $USER_EMAIL" +log_debug "User token: ${USER_TOKEN:0:30}..." + +echo "" +log_info "============================================================" +log_info "STEP 2: Create calendar and event" +log_info "============================================================" + +log_info "Creating calendar..." +response=$(http_post "$BASE_URL/v1/calendars" \ + "{\"title\":\"WS Test Calendar\"}" "$USER_TOKEN") +CALENDAR_ID=$(extract_json "$response" "id") +log_success "Calendar created: $CALENDAR_ID" +log_debug "Calendar ID: $CALENDAR_ID" + +log_info "Creating event..." +EVENT_START="2026-06-01T10:00:00Z" +response=$(http_post "$BASE_URL/v1/calendars/$CALENDAR_ID/events" \ + "{\"title\":\"WS Test Event\",\"start_time\":\"$EVENT_START\",\"duration\":60,\"capacity\":10}" "$USER_TOKEN") +EVENT_ID=$(extract_json "$response" "id") +log_success "Event created: $EVENT_ID" +log_debug "Event ID: $EVENT_ID" + +echo "" +log_info "============================================================" +log_info "TEST 1: Connect to WebSocket with valid token (curl test)" +log_info "============================================================" + +test_ws_curl "$WS_URL" "$USER_TOKEN" +CURL_RESULT=$? + +case $CURL_RESULT in + 0) + log_success "WebSocket upgrade successful (101 Switching Protocols)" + ;; + 1) + log_error "WebSocket authentication failed (401 Unauthorized)" + log_debug "Token might be invalid or expired" + ;; + 2) + log_error "WebSocket access denied (403 Forbidden)" + ;; + *) + log_error "WebSocket connection failed (unknown error)" + log_debug "Check if WebSocket server is running on port 8081" + ;; +esac + +echo "" +log_info "============================================================" +log_info "TEST 2: Connect with invalid token (curl test)" +log_info "============================================================" + +test_ws_curl "$WS_URL" "invalid.token.here" +CURL_RESULT=$? + +if [ $CURL_RESULT -eq 1 ]; then + log_success "Invalid token correctly rejected (401 Unauthorized)" +else + log_error "Invalid token should be rejected with 401" +fi + +echo "" +log_info "============================================================" +log_info "TEST 3: Admin WebSocket with valid token (curl test)" +log_info "============================================================" + +test_ws_curl "$ADMIN_WS_URL" "$ADMIN_TOKEN" +CURL_RESULT=$? + +case $CURL_RESULT in + 0) + log_success "Admin WebSocket upgrade successful" + ;; + 1) + log_error "Admin WebSocket authentication failed" + ;; + 2) + log_error "Admin WebSocket access denied (not admin)" + log_debug "Check if token has admin role" + ;; + *) + log_error "Admin WebSocket connection failed" + ;; +esac + +echo "" +log_info "============================================================" +log_info "TEST 4: Admin WebSocket with user token (should fail)" +log_info "============================================================" + +test_ws_curl "$ADMIN_WS_URL" "$USER_TOKEN" +CURL_RESULT=$? + +if [ $CURL_RESULT -eq 2 ]; then + log_success "User token correctly rejected for admin WebSocket (403 Forbidden)" +elif [ $CURL_RESULT -eq 1 ]; then + log_warning "User token rejected with 401 instead of 403" +else + log_error "User token should be rejected" +fi + +echo "" +log_info "============================================================" +log_info "WEBSOCKET API TESTS (curl validation) COMPLETED!" +log_info "============================================================" + +# Опциональные тесты с websocat +if check_websocat; then + echo "" + log_info "============================================================" + log_info "OPTIONAL: Testing with websocat" + log_info "============================================================" + + WS_URL_WITH_TOKEN="${WS_URL}?token=${USER_TOKEN}" + log_debug "WebSocket URL: $WS_URL_WITH_TOKEN" + + TEMP_FILE=$(mktemp) + log_debug "Temp file: $TEMP_FILE" + + # Запускаем websocat в фоне + log_info "Connecting with websocat..." + timeout 3 websocat "$WS_URL_WITH_TOKEN" > "$TEMP_FILE" 2>&1 & + WS_PID=$! + log_debug "WebSocket PID: $WS_PID" + sleep 1 + + if kill -0 $WS_PID 2>/dev/null; then + log_success "WebSocket connection established with websocat" + + # Отправляем ping + echo '{"action":"ping"}' | timeout 2 websocat "$WS_URL_WITH_TOKEN" > "$TEMP_FILE" 2>&1 + if grep -q "pong" "$TEMP_FILE"; then + log_success "Ping-pong successful" + fi + + kill $WS_PID 2>/dev/null + else + log_warning "Websocket connection failed" + fi + + rm -f "$TEMP_FILE" +fi + +echo "" +echo "============================================================" +log_success "ALL WEBSOCKET TESTS COMPLETED!" +echo "============================================================" +echo "" +echo "Summary:" +echo " Admin: $ADMIN_EMAIL" +echo " User: $USER_EMAIL" +echo " Calendar: $CALENDAR_ID" +echo " Event: $EVENT_ID" +echo "" +echo "Run with DEBUG=true for more details:" +echo " DEBUG=true ./test/scripts/test_websocket_api.sh" +echo "" \ No newline at end of file diff --git a/test/admin_handler_stats_tests.erl b/test/unit/admin_handler_stats_tests.erl similarity index 100% rename from test/admin_handler_stats_tests.erl rename to test/unit/admin_handler_stats_tests.erl diff --git a/test/admin_handler_user_by_id_tests.erl b/test/unit/admin_handler_user_by_id_tests.erl similarity index 100% rename from test/admin_handler_user_by_id_tests.erl rename to test/unit/admin_handler_user_by_id_tests.erl diff --git a/test/admin_handler_users_tests.erl b/test/unit/admin_handler_users_tests.erl similarity index 100% rename from test/admin_handler_users_tests.erl rename to test/unit/admin_handler_users_tests.erl diff --git a/test/unit/admin_ws_handler_tests.erl b/test/unit/admin_ws_handler_tests.erl new file mode 100644 index 0000000..8a3cf84 --- /dev/null +++ b/test/unit/admin_ws_handler_tests.erl @@ -0,0 +1,34 @@ +-module(admin_ws_handler_tests). +-include_lib("eunit/include/eunit.hrl"). + +-record(state, { + admin_id :: binary() | undefined +}). + +setup() -> + pg:start_link(), + ok. + +cleanup(_) -> + ok. + +admin_ws_handler_test_() -> + {foreach, + fun setup/0, + fun cleanup/1, + [ + {"Admin WebSocket info notification", fun test_admin_websocket_info/0} + ]}. + +test_admin_websocket_info() -> + State = #state{admin_id = <<"admin123">>}, + Data = #{report_id => <<"rep123">>, reason => <<"Spam">>}, + Msg = {admin_notification, report_created, Data}, + + case admin_ws_handler:websocket_info(Msg, State) of + {reply, {text, Reply}, _} -> + Decoded = jsx:decode(Reply, [return_maps]), + ?assertEqual(<<"report_created">>, maps:get(<<"type">>, Decoded)), + ?assertEqual(<<"rep123">>, maps:get(<<"report_id">>, maps:get(<<"data">>, Decoded))); + _ -> ?assert(false, "Expected reply") + end. \ No newline at end of file diff --git a/test/booking_integration_tests.erl b/test/unit/booking_integration_tests.erl similarity index 100% rename from test/booking_integration_tests.erl rename to test/unit/booking_integration_tests.erl diff --git a/test/core_banned_word_tests.erl b/test/unit/core_banned_word_tests.erl similarity index 100% rename from test/core_banned_word_tests.erl rename to test/unit/core_banned_word_tests.erl diff --git a/test/core_booking_tests.erl b/test/unit/core_booking_tests.erl similarity index 100% rename from test/core_booking_tests.erl rename to test/unit/core_booking_tests.erl diff --git a/test/core_calendar_tests.erl b/test/unit/core_calendar_tests.erl similarity index 100% rename from test/core_calendar_tests.erl rename to test/unit/core_calendar_tests.erl diff --git a/test/core_event_recurring_tests.erl b/test/unit/core_event_recurring_tests.erl similarity index 100% rename from test/core_event_recurring_tests.erl rename to test/unit/core_event_recurring_tests.erl diff --git a/test/core_event_tests.erl b/test/unit/core_event_tests.erl similarity index 100% rename from test/core_event_tests.erl rename to test/unit/core_event_tests.erl diff --git a/test/core_report_tests.erl b/test/unit/core_report_tests.erl similarity index 100% rename from test/core_report_tests.erl rename to test/unit/core_report_tests.erl diff --git a/test/core_review_tests.erl b/test/unit/core_review_tests.erl similarity index 100% rename from test/core_review_tests.erl rename to test/unit/core_review_tests.erl diff --git a/test/core_subscription_tests.erl b/test/unit/core_subscription_tests.erl similarity index 100% rename from test/core_subscription_tests.erl rename to test/unit/core_subscription_tests.erl diff --git a/test/core_ticket_tests.erl b/test/unit/core_ticket_tests.erl similarity index 100% rename from test/core_ticket_tests.erl rename to test/unit/core_ticket_tests.erl diff --git a/test/handler_search_tests.erl b/test/unit/handler_search_tests.erl similarity index 100% rename from test/handler_search_tests.erl rename to test/unit/handler_search_tests.erl diff --git a/test/logic_auth_tests.erl b/test/unit/logic_auth_tests.erl similarity index 100% rename from test/logic_auth_tests.erl rename to test/unit/logic_auth_tests.erl diff --git a/test/logic_booking_tests.erl b/test/unit/logic_booking_tests.erl similarity index 100% rename from test/logic_booking_tests.erl rename to test/unit/logic_booking_tests.erl diff --git a/test/logic_calendar_tests.erl b/test/unit/logic_calendar_tests.erl similarity index 100% rename from test/logic_calendar_tests.erl rename to test/unit/logic_calendar_tests.erl diff --git a/test/logic_event_recurring_tests.erl b/test/unit/logic_event_recurring_tests.erl similarity index 100% rename from test/logic_event_recurring_tests.erl rename to test/unit/logic_event_recurring_tests.erl diff --git a/test/logic_event_tests.erl b/test/unit/logic_event_tests.erl similarity index 100% rename from test/logic_event_tests.erl rename to test/unit/logic_event_tests.erl diff --git a/test/logic_moderation_tests.erl b/test/unit/logic_moderation_tests.erl similarity index 100% rename from test/logic_moderation_tests.erl rename to test/unit/logic_moderation_tests.erl diff --git a/test/unit/logic_notification_tests.erl b/test/unit/logic_notification_tests.erl new file mode 100644 index 0000000..81036e5 --- /dev/null +++ b/test/unit/logic_notification_tests.erl @@ -0,0 +1,108 @@ +-module(logic_notification_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +setup() -> + pg:start_link(), + mnesia:start(), + mnesia:create_table(booking, [{attributes, record_info(fields, booking)}, {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(booking), + mnesia:stop(), + ok. + +logic_notification_test_() -> + {foreach, + fun setup/0, + fun cleanup/1, + [ + {"Notify booking", fun test_notify_booking/0}, + {"Notify calendar update", fun test_notify_calendar_update/0}, + {"Notify event update", fun test_notify_event_update/0}, + {"Notify admin", fun test_notify_admin/0} + ]}. + +create_test_booking() -> + Booking = #booking{ + id = base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}), + event_id = <<"event123">>, + user_id = <<"user123">>, + status = pending, + confirmed_at = undefined, + created_at = calendar:universal_time(), + updated_at = calendar:universal_time() + }, + mnesia:dirty_write(Booking), + Booking. + +create_test_calendar() -> + Calendar = #calendar{ + id = base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}), + owner_id = <<"owner123">>, + title = <<"Test Calendar">>, + description = <<"">>, + tags = [], + type = personal, + confirmation = manual, + rating_avg = 0.0, + rating_count = 0, + status = active, + created_at = calendar:universal_time(), + updated_at = calendar:universal_time() + }, + mnesia:dirty_write(Calendar), + Calendar. + +create_test_event() -> + Event = #event{ + id = base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}), + calendar_id = <<"cal123">>, + title = <<"Test Event">>, + description = <<"">>, + event_type = single, + start_time = {{2026, 6, 1}, {10, 0, 0}}, + duration = 60, + recurrence_rule = undefined, + master_id = undefined, + is_instance = false, + specialist_id = undefined, + location = undefined, + tags = [], + capacity = undefined, + online_link = undefined, + status = active, + rating_avg = 0.0, + rating_count = 0, + created_at = calendar:universal_time(), + updated_at = calendar:universal_time() + }, + mnesia:dirty_write(Event), + Event. + +test_notify_booking() -> + Booking = create_test_booking(), + UserId = <<"user123">>, + + % Функция возвращает список пидов (может быть пустым) + Result = logic_notification:notify_booking(UserId, Booking), + ?assert(is_list(Result)). + +test_notify_calendar_update() -> + Calendar = create_test_calendar(), + Result = logic_notification:notify_calendar_update(Calendar), + ?assert(is_list(Result)). + +test_notify_event_update() -> + Event = create_test_event(), + Result = logic_notification:notify_event_update(Event), + ?assert(is_list(Result)). + +test_notify_admin() -> + Result = logic_notification:notify_admin(report_created, #{report_id => <<"rep123">>}), + ?assertEqual(ok, Result). \ No newline at end of file diff --git a/test/logic_recurrence_tests.erl b/test/unit/logic_recurrence_tests.erl similarity index 100% rename from test/logic_recurrence_tests.erl rename to test/unit/logic_recurrence_tests.erl diff --git a/test/logic_review_tests.erl b/test/unit/logic_review_tests.erl similarity index 100% rename from test/logic_review_tests.erl rename to test/unit/logic_review_tests.erl diff --git a/test/logic_search_tests.erl b/test/unit/logic_search_tests.erl similarity index 100% rename from test/logic_search_tests.erl rename to test/unit/logic_search_tests.erl diff --git a/test/logic_subscription_tests.erl b/test/unit/logic_subscription_tests.erl similarity index 100% rename from test/logic_subscription_tests.erl rename to test/unit/logic_subscription_tests.erl diff --git a/test/logic_ticket_tests.erl b/test/unit/logic_ticket_tests.erl similarity index 100% rename from test/logic_ticket_tests.erl rename to test/unit/logic_ticket_tests.erl diff --git a/test/unit/ws_handler_tests.erl b/test/unit/ws_handler_tests.erl new file mode 100644 index 0000000..90354a1 --- /dev/null +++ b/test/unit/ws_handler_tests.erl @@ -0,0 +1,61 @@ +-module(ws_handler_tests). +-include_lib("eunit/include/eunit.hrl"). + +-record(state, { + user_id :: binary() | undefined, + subscriptions = [] :: [binary()] +}). + +setup() -> + pg:start_link(), + ok. + +cleanup(_) -> + ok. + +ws_handler_test_() -> + {foreach, + fun setup/0, + fun cleanup/1, + [ + {"WebSocket info notification - booking", fun test_websocket_info_booking/0}, + {"WebSocket info notification - calendar", fun test_websocket_info_calendar/0}, + {"WebSocket info notification - event", fun test_websocket_info_event/0} + ]}. + +test_websocket_info_booking() -> + State = #state{user_id = <<"user123">>, subscriptions = []}, + Data = #{booking_id => <<"book123">>, event_id => <<"ev123">>, status => confirmed}, + Msg = {notification, booking_update, Data}, + + case ws_handler:websocket_info(Msg, State) of + {reply, {text, Reply}, _} -> + Decoded = jsx:decode(Reply, [return_maps]), + ?assertEqual(<<"booking_update">>, maps:get(<<"type">>, Decoded)), + ?assertEqual(<<"book123">>, maps:get(<<"booking_id">>, maps:get(<<"data">>, Decoded))); + _ -> ?assert(false, "Expected reply") + end. + +test_websocket_info_calendar() -> + State = #state{user_id = <<"user123">>, subscriptions = [<<"cal123">>]}, + Data = #{calendar_id => <<"cal123">>, title => <<"Updated">>}, + Msg = {notification, calendar_update, Data}, + + case ws_handler:websocket_info(Msg, State) of + {reply, {text, Reply}, _} -> + Decoded = jsx:decode(Reply, [return_maps]), + ?assertEqual(<<"calendar_update">>, maps:get(<<"type">>, Decoded)); + _ -> ?assert(false, "Expected reply") + end. + +test_websocket_info_event() -> + State = #state{user_id = <<"user123">>, subscriptions = []}, + Data = #{event_id => <<"ev123">>, title => <<"Updated Event">>}, + Msg = {notification, event_update, Data}, + + case ws_handler:websocket_info(Msg, State) of + {reply, {text, Reply}, _} -> + Decoded = jsx:decode(Reply, [return_maps]), + ?assertEqual(<<"event_update">>, maps:get(<<"type">>, Decoded)); + _ -> ?assert(false, "Expected reply") + end. \ No newline at end of file