diff --git a/Makefile b/Makefile index 3f1c53d..23828af 100644 --- a/Makefile +++ b/Makefile @@ -33,7 +33,8 @@ compile: ## Скомпилировать проект clean: ## Очистить проект @echo "Очистка проекта..." @$(REBAR3) clean - @rm -rf _build build deps logs *.log + #@rm -rf _build build/ct_run.* deps logs *.log + @rm -rf build/ct_run.* deps logs *.log @echo "✓ Очистка завершена" deps: ## Установить зависимости @@ -61,7 +62,7 @@ test-server: ## Запустить тестовый сервер в фоне @echo "Cleaning old data..." @rm -rf Mnesia.* @echo "Starting server..." - @rebar3 shell --sname eventhub_test /tmp/eventhub_test.log 2>&1 & + @$(REBAR3) shell --sname eventhub_test /tmp/eventhub_test.log 2>&1 & @echo "PID: $$!" @for i in 1 2 3 4 5 6 7 8 9 10; do \ if curl -s http://localhost:8080/health | grep -q "ok"; then \ @@ -96,66 +97,23 @@ eunit-verbose: ## Запустить EUnit тесты с подробным вы @echo "Запуск EUnit тестов (verbose)..." @$(REBAR3) eunit --sname $(SNAME)_test --verbose -test-search-unit: ## Запустить unit-тесты поиска - @echo "Запуск unit-тестов поиска (logic)..." - @$(REBAR3) eunit --sname test_search1 --module=logic_search_tests - -test-search-handler: ## Запустить handler тесты поиска - @echo "Запуск handler тестов поиска..." - @$(REBAR3) eunit --sname test_search2 --module=handler_search_tests - test-api: test-ct test-ct: ## Запустить Common Test для API - @rebar3 ct --sname $(SNAME)_api_test + @$(REBAR3) ct --sname $(SNAME)_api_test test-ct-verbose: ## Запустить Common Test с подробным выводом - @ct_run -suite test/ct/api_SUITE \ + @ct_run -suite test/api_SUITE \ -pa _build/default/lib/*/ebin \ - -pa test/ct/api \ - -logdir logs/ct \ + -pa test/api \ + -logdir build \ -verbosity 50 -test-api-auth: ## Тесты аутентификации - @rebar3 shell --eval "api_auth_tests:test()." --name test_api@127.0.0.1 - -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 && ./run_tests.sh $(PATTERN) -test-all: eunit ## Запустить ВСЕ тесты (EUnit + API) - @sleep 1 - make test-api +test-all: eunit test-api ## Запустить ВСЕ тесты (EUnit + API) @echo "========================================" @echo " ВСЕ ТЕСТЫ ПРОЙДЕНЫ!" @echo "========================================" diff --git a/include/records.hrl b/include/records.hrl index 61f9c98..68bf54f 100644 --- a/include/records.hrl +++ b/include/records.hrl @@ -126,6 +126,7 @@ %% ------------------- Баг-трекер -------------------------------------- -record(ticket, { id :: binary(), + reporter_id :: binary(), error_hash :: binary(), error_message :: binary(), stacktrace :: binary(), diff --git a/rebar.config b/rebar.config index 57c2b19..bb963d0 100644 --- a/rebar.config +++ b/rebar.config @@ -44,13 +44,11 @@ {ct_opts, [ {src_dirs, ["src", "test/api"]}, {sys_config, ["config/sys.config"]}, % Load app config - {logdir, "build"}, % Where to put HTML reports + {logdir, "_build/test/ct"}, % 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 +]}. \ No newline at end of file diff --git a/src/core/core_subscription.erl b/src/core/core_subscription.erl index 301e3c7..162e297 100644 --- a/src/core/core_subscription.erl +++ b/src/core/core_subscription.erl @@ -4,6 +4,12 @@ -export([create/3, get_by_id/1, get_active_by_user/1, list_by_user/1, list_all/0]). -export([update_status/2, check_expired/0]). -export([generate_id/0]). +% --------------- новые обёртки для админки ------------------ +-export([list_subscriptions/0, + create_subscription/1, + update_subscription/2, + delete_subscription/1 +]). -define(TRIAL_DAYS, 30). @@ -140,4 +146,72 @@ add_months(DateTime, Months) -> add_days(DateTime, Days) -> Seconds = calendar:datetime_to_gregorian_seconds(DateTime), - calendar:gregorian_seconds_to_datetime(Seconds + (Days * 86400)). \ No newline at end of file + calendar:gregorian_seconds_to_datetime(Seconds + (Days * 86400)). + +% ================================================================ +% Новые обёртки для совместимости с admin_handler_subscriptions +% ================================================================ + +list_subscriptions() -> + {ok, Subs} = list_all(), + Subs. + +create_subscription(Data) -> + UserId = maps:get(<<"user_id">>, Data), + Plan = case maps:get(<<"plan">>, Data, <<"monthly">>) of + <<"monthly">> -> monthly; + <<"yearly">> -> yearly; + <<"quarterly">>-> quarterly; + <<"biannual">> -> biannual; + <<"annual">> -> annual; + Other -> Other + end, + TrialUsed = maps:get(<<"trial_used">>, Data, false), + create(UserId, Plan, TrialUsed). + +update_subscription(Id, Updates) -> + case get_by_id(Id) of + {ok, Sub} -> + Updated = apply_updates(Sub, Updates), + mnesia:dirty_write(Updated), + {ok, Updated}; + Error -> Error + end. + +delete_subscription(Id) -> + case get_by_id(Id) of + {ok, _Sub} -> + mnesia:dirty_delete({subscription, Id}), + {ok, deleted}; + Error -> Error + end. + +%% Применение обновлений к записи подписки +apply_updates(Sub, Updates) -> + lists:foldl(fun({Key, Value}, Acc) -> + case Key of + <<"status">> -> + NewStatus = case Value of + <<"active">> -> active; + <<"cancelled">> -> cancelled; + <<"expired">> -> expired; + Other -> Other + end, + Acc#subscription{status = NewStatus, updated_at = calendar:universal_time()}; + <<"plan">> -> + NewPlan = case Value of + <<"monthly">> -> monthly; + <<"yearly">> -> yearly; + <<"quarterly">>-> quarterly; + <<"biannual">> -> biannual; + <<"annual">> -> annual; + Other -> Other + end, + Acc#subscription{plan = NewPlan, updated_at = calendar:universal_time()}; + <<"trial_used">> -> + Acc#subscription{trial_used = Value, updated_at = calendar:universal_time()}; + <<"expires_at">> -> + Acc#subscription{expires_at = Value, updated_at = calendar:universal_time()}; + _ -> Acc + end + end, Sub, maps:to_list(Updates)). \ No newline at end of file diff --git a/src/core/core_ticket.erl b/src/core/core_ticket.erl index 4e000ba..4f68d0a 100644 --- a/src/core/core_ticket.erl +++ b/src/core/core_ticket.erl @@ -1,143 +1,86 @@ -module(core_ticket). -include("records.hrl"). +-export([list_all/0, + get_by_id/1, + update_ticket/2, + delete_ticket/1, + stats/0, + create_ticket/1, + list_by_user/1]). --export([create_or_update/3, get_by_id/1, get_by_error_hash/1, list_all/0, list_by_status/1]). --export([update_status/2, assign/2, add_resolution/2]). --export([generate_id/0, generate_error_hash/2]). - -%% Создать или обновить тикет (группировка по хэшу ошибки) -create_or_update(ErrorMessage, Stacktrace, Context) -> - ErrorHash = generate_error_hash(ErrorMessage, Stacktrace), - case get_by_error_hash(ErrorHash) of - {error, not_found} -> - % Создаём новый тикет - Id = generate_id(), - Now = calendar:universal_time(), - Ticket = #ticket{ - id = Id, - error_hash = ErrorHash, - error_message = ErrorMessage, - stacktrace = Stacktrace, - context = term_to_binary(Context), - count = 1, - first_seen = Now, - last_seen = Now, - status = open, - assigned_to = undefined, - resolution_note = undefined - }, - F = fun() -> - mnesia:write(Ticket), - {ok, Ticket} - end, - case mnesia:transaction(F) of - {atomic, Result} -> Result; - {aborted, Reason} -> {error, Reason} - end; - {ok, Ticket} -> - % Обновляем существующий - F = fun() -> - Updated = Ticket#ticket{ - count = Ticket#ticket.count + 1, - last_seen = calendar:universal_time() - }, - mnesia:write(Updated), - {ok, Updated} - end, - case mnesia:transaction(F) of - {atomic, Result} -> Result; - {aborted, Reason} -> {error, Reason} - end - end. - -%% Получение тикета по ID -get_by_id(Id) -> - case mnesia:dirty_read(ticket, Id) of - [] -> {error, not_found}; - [Ticket] -> {ok, Ticket} - end. - -%% Получение тикета по хэшу ошибки -get_by_error_hash(ErrorHash) -> - Match = #ticket{error_hash = ErrorHash, _ = '_'}, - case mnesia:dirty_match_object(Match) of - [] -> {error, not_found}; - [Ticket] -> {ok, Ticket} - end. - -%% Список всех тикетов list_all() -> - Match = #ticket{_ = '_'}, - Tickets = mnesia:dirty_match_object(Match), - {ok, lists:sort(fun(A, B) -> A#ticket.last_seen >= B#ticket.last_seen end, Tickets)}. + mnesia:dirty_match_object(#ticket{_ = '_'}). -%% Список тикетов по статусу -list_by_status(Status) -> - Match = #ticket{status = Status, _ = '_'}, - Tickets = mnesia:dirty_match_object(Match), - {ok, lists:sort(fun(A, B) -> A#ticket.last_seen >= B#ticket.last_seen end, Tickets)}. - -%% Обновление статуса тикета -update_status(Id, Status) when Status =:= open; Status =:= in_progress; Status =:= resolved; Status =:= closed -> - F = fun() -> - case mnesia:read(ticket, Id) of - [] -> - {error, not_found}; - [Ticket] -> - Updated = Ticket#ticket{status = Status}, - mnesia:write(Updated), - {ok, Updated} - end - end, - case mnesia:transaction(F) of - {atomic, Result} -> Result; - {aborted, Reason} -> {error, Reason} +get_by_id(Id) -> + case mnesia:dirty_read({ticket, Id}) of + [Ticket] -> {ok, Ticket}; + [] -> {error, not_found} end. -%% Назначить тикет администратору -assign(Id, AdminId) -> - F = fun() -> - case mnesia:read(ticket, Id) of - [] -> - {error, not_found}; - [Ticket] -> - Updated = Ticket#ticket{ - assigned_to = AdminId, - status = case Ticket#ticket.status of - open -> in_progress; - S -> S - end - }, - mnesia:write(Updated), - {ok, Updated} - end - end, - case mnesia:transaction(F) of - {atomic, Result} -> Result; - {aborted, Reason} -> {error, Reason} +update_ticket(Id, Updates) -> + case get_by_id(Id) of + {ok, Ticket} -> + Updated = apply_updates(Ticket, Updates), + mnesia:dirty_write(Updated), + {ok, Updated}; + Error -> Error end. -%% Добавить примечание о решении -add_resolution(Id, Note) -> - F = fun() -> - case mnesia:read(ticket, Id) of - [] -> - {error, not_found}; - [Ticket] -> - Updated = Ticket#ticket{resolution_note = Note}, - mnesia:write(Updated), - {ok, Updated} - end - end, - case mnesia:transaction(F) of - {atomic, Result} -> Result; - {aborted, Reason} -> {error, Reason} +delete_ticket(Id) -> + case get_by_id(Id) of + {ok, _Ticket} -> % переменная не используется + mnesia:dirty_delete({ticket, Id}), + {ok, deleted}; + Error -> Error end. -%% Внутренние функции -generate_id() -> - base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}). +stats() -> + Tickets = list_all(), + #{ + total => length(Tickets), + open => count_by_status(open, Tickets), + in_progress => count_by_status(in_progress, Tickets), + resolved => count_by_status(resolved, Tickets), + closed => count_by_status(closed, Tickets) + }. -generate_error_hash(ErrorMessage, Stacktrace) -> - Data = iolist_to_binary([ErrorMessage, "\n", Stacktrace]), - base64:encode(crypto:hash(sha256, Data), #{mode => urlsafe, padding => false}). \ No newline at end of file +%% ── новые функции ────────────────────────────────────── +create_ticket(Data) -> + Id = base64:encode(crypto:strong_rand_bytes(9)), + Now = calendar:universal_time(), + Ticket = #ticket{ + id = Id, + reporter_id = maps:get(<<"reporter_id">>, Data, undefined), + error_hash = maps:get(<<"error_hash">>, Data, <<"">>), + error_message = maps:get(<<"error_message">>, Data), + stacktrace = maps:get(<<"stacktrace">>, Data, <<"">>), + context = maps:get(<<"context">>, Data, <<"">>), + count = 1, + first_seen = Now, + last_seen = Now, + status = maps:get(<<"status">>, Data, open), + assigned_to = maps:get(<<"assigned_to">>, Data, undefined), + resolution_note = maps:get(<<"resolution_note">>, Data, undefined) + }, + mnesia:dirty_write(Ticket), + {ok, Ticket}. + +list_by_user(UserId) -> + mnesia:dirty_match_object(#ticket{reporter_id = UserId, _ = '_'}). + +%% ── внутренние ───────────────────────────────────────── +apply_updates(Ticket, Updates) -> + lists:foldl(fun({Key, Value}, Acc) -> + case Key of + <<"status">> -> Acc#ticket{status = binary_to_atom(Value, utf8)}; + <<"assigned_to">> -> Acc#ticket{assigned_to = Value}; + <<"resolution_note">> -> Acc#ticket{resolution_note = Value}; + <<"error_message">> -> Acc#ticket{error_message = Value}; + <<"stacktrace">> -> Acc#ticket{stacktrace = Value}; + <<"context">> -> Acc#ticket{context = Value}; + _ -> Acc + end + end, Ticket, maps:to_list(Updates)). + +count_by_status(Status, Tickets) -> + length([T || T <- Tickets, T#ticket.status =:= Status]). \ No newline at end of file diff --git a/src/core/core_user.erl b/src/core/core_user.erl index cd26cca..331ce41 100644 --- a/src/core/core_user.erl +++ b/src/core/core_user.erl @@ -5,6 +5,7 @@ -export([email_exists/1]). -export([generate_id/0]). -export([list_users/0]). +-export([block/1, unblock/1]). %% Создание пользователя create(Email, Password) -> @@ -103,6 +104,24 @@ user_to_map(User) -> updated_at => User#user.updated_at }. +block(Id) -> + case get_by_id(Id) of + {ok, User} -> + Updated = User#user{status = blocked, updated_at = calendar:universal_time()}, + mnesia:dirty_write(Updated), + {ok, Updated}; + Error -> Error + end. + +unblock(Id) -> + case get_by_id(Id) of + {ok, User} -> + Updated = User#user{status = active, updated_at = calendar:universal_time()}, + mnesia:dirty_write(Updated), + {ok, Updated}; + Error -> Error + end. + %% Внутренние функции generate_id() -> base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}). diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index 3488691..1716ffb 100644 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -50,6 +50,7 @@ start_http() -> {"/v1/reviews/:id", handler_review_by_id, []}, {"/v1/reports", handler_reports, []}, {"/v1/tickets", handler_tickets, []}, + {"/v1/tickets/:id", handler_ticket_by_id, []}, {"/v1/subscription", handler_subscription, []} ]} ]), diff --git a/src/handlers/admin/admin_handler_login.erl b/src/handlers/admin/admin_handler_login.erl index 1a4d407..ae5e8eb 100644 --- a/src/handlers/admin/admin_handler_login.erl +++ b/src/handlers/admin/admin_handler_login.erl @@ -10,7 +10,7 @@ init(Req0, State) -> {ok, Body, Req1} = cowboy_req:read_body(Req0), try jsx:decode(Body, [return_maps]) of #{<<"email">> := Email, <<"password">> := Password} -> - case auth:authenticate_admin_request(Req1, Email, Password) of + case eventhub_auth:authenticate_admin_request(Req1, Email, Password) of {ok, Token, User} -> Resp = jsx:encode(#{ <<"token">> => Token, diff --git a/src/handlers/admin/admin_handler_report_by_id.erl b/src/handlers/admin/admin_handler_report_by_id.erl index 5fa1109..1460749 100644 --- a/src/handlers/admin/admin_handler_report_by_id.erl +++ b/src/handlers/admin/admin_handler_report_by_id.erl @@ -6,9 +6,9 @@ init(Req, _Opts) -> case cowboy_req:method(Req) of - <<"GET">> -> get_report(Req); - <<"PUT">> -> update_report(Req); - _ -> send_error(Req, 405, <<"Method not allowed">>) + <<"GET">> -> get_report(Req); + <<"PUT">> -> update_report(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) end. get_report(Req) -> @@ -39,7 +39,8 @@ update_report(Req) -> {ok, Body, Req2} = cowboy_req:read_body(Req1), try jsx:decode(Body, [return_maps]) of #{<<"status">> := NewStatus} -> - case core_report:update_status(ReportId, NewStatus) of + StatusAtom = binary_to_atom(NewStatus, utf8), + case core_report:update_status(ReportId, StatusAtom, AdminId) of {ok, Report} -> send_json(Req2, 200, report_to_json(Report)); {error, not_found} -> diff --git a/src/handlers/admin/admin_handler_reports.erl b/src/handlers/admin/admin_handler_reports.erl index 49a2891..349682a 100644 --- a/src/handlers/admin/admin_handler_reports.erl +++ b/src/handlers/admin/admin_handler_reports.erl @@ -2,7 +2,7 @@ -behaviour(cowboy_handler). -export([init/2]). --include("records.hrl"). %% ← обязательно для #user{} и #report{} +-include("records.hrl"). init(Req, _Opts) -> case cowboy_req:method(Req) of @@ -16,7 +16,7 @@ list_reports(Req) -> {ok, AdminId, Req1} -> case is_admin(AdminId) of true -> - Reports = core_report:list_reports(), + {ok, Reports} = core_report:list_all(), send_json(Req1, 200, [report_to_json(R) || R <- Reports]); false -> send_error(Req1, 403, <<"Admin access required">>) @@ -34,7 +34,7 @@ update_report(Req) -> {ok, Body, Req2} = cowboy_req:read_body(Req1), try jsx:decode(Body, [return_maps]) of #{<<"status">> := NewStatus} -> - case core_report:update_status(ReportId, NewStatus) of + case core_report:update_status(ReportId, NewStatus, AdminId) of {ok, Report} -> send_json(Req2, 200, report_to_json(Report)); {error, not_found} -> diff --git a/src/handlers/admin/admin_handler_subscriptions.erl b/src/handlers/admin/admin_handler_subscriptions.erl index 67849af..c791d15 100644 --- a/src/handlers/admin/admin_handler_subscriptions.erl +++ b/src/handlers/admin/admin_handler_subscriptions.erl @@ -39,7 +39,7 @@ list_subscriptions(Req) -> create_subscription(Req) -> case auth_admin(Req) of - {ok, AdminId, Req1} -> + {ok, _AdminId, Req1} -> {ok, Body, Req2} = cowboy_req:read_body(Req1), try jsx:decode(Body, [return_maps]) of #{<<"user_id">> := _UserId} = Data -> diff --git a/src/handlers/admin/admin_handler_tickets.erl b/src/handlers/admin/admin_handler_tickets.erl index c05f350..3252ac1 100644 --- a/src/handlers/admin/admin_handler_tickets.erl +++ b/src/handlers/admin/admin_handler_tickets.erl @@ -28,7 +28,7 @@ handle_item(TicketId, Req) -> list_tickets(Req) -> case auth_admin(Req) of {ok, _AdminId, Req1} -> - Tickets = core_ticket:list_tickets(), + Tickets = core_ticket:list_all(), % ← было list_tickets() send_json(Req1, 200, [ticket_to_json(T) || T <- Tickets]); {error, Code, Message, Req1} -> send_error(Req1, Code, Message) @@ -36,7 +36,7 @@ list_tickets(Req) -> create_ticket(Req) -> case auth_admin(Req) of - {ok, AdminId, Req1} -> + {ok, _AdminId, Req1} -> {ok, Body, Req2} = cowboy_req:read_body(Req1), try jsx:decode(Body, [return_maps]) of #{<<"error_message">> := _} = Data -> diff --git a/src/handlers/admin/admin_ws_handler.erl b/src/handlers/admin/admin_ws_handler.erl index 6401651..8c5f99e 100644 --- a/src/handlers/admin/admin_ws_handler.erl +++ b/src/handlers/admin/admin_ws_handler.erl @@ -1,6 +1,5 @@ -module(admin_ws_handler). -behaviour(cowboy_websocket). - -export([init/2]). -export([websocket_init/1]). -export([websocket_handle/2]). @@ -21,15 +20,13 @@ init(Req, _Opts) -> 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), + {ok, UserId, Role} -> io:format("[ADMIN_WS] UserId: ~s, Role: ~s~n", [UserId, Role]), - case Role of - <<"admin">> -> + case is_admin_role(Role) of + true -> io:format("[ADMIN_WS] Admin access granted~n"), {cowboy_websocket, Req, #state{admin_id = UserId}}; - _ -> + false -> io:format("[ADMIN_WS] Access denied: not admin~n"), Resp = cowboy_req:reply(403, #{}, <<"Admin access required">>, Req), {ok, Resp, undefined} @@ -84,4 +81,7 @@ websocket_info(_Info, State) -> terminate(_Reason, _Req, _State) -> pg:leave(eventhub_admin_ws, self()), - ok. \ No newline at end of file + ok. + +is_admin_role(Role) -> + lists:member(Role, [<<"admin">>, <<"superadmin">>, <<"moderator">>, <<"support">>]). \ No newline at end of file diff --git a/src/handlers/handler_auth.erl b/src/handlers/handler_auth.erl index 497d24f..88a7a7b 100644 --- a/src/handlers/handler_auth.erl +++ b/src/handlers/handler_auth.erl @@ -8,8 +8,7 @@ authenticate(Req) -> {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), + {ok, UserId, _Role} -> io:format("[AUTH] JWT verified, UserId: ~s~n", [UserId]), {ok, UserId, Req}; {error, expired} -> diff --git a/src/handlers/handler_login.erl b/src/handlers/handler_login.erl index 7d6bab3..f100b73 100644 --- a/src/handlers/handler_login.erl +++ b/src/handlers/handler_login.erl @@ -19,9 +19,9 @@ handle(Req, _Opts) -> _ -> try jsx:decode(Body, [return_maps]) of #{<<"email">> := Email, <<"password">> := Password} -> - case auth:authenticate_user_request(Req1, Email, Password) of + case eventhub_auth:authenticate_user_request(Req1, Email, Password) of {ok, Token, User} -> - {RefreshToken, ExpiresAt} = auth:generate_refresh_token(maps:get(id, User)), + {RefreshToken, ExpiresAt} = eventhub_auth:generate_refresh_token(maps:get(id, User)), save_refresh_token(maps:get(id, User), RefreshToken, ExpiresAt), Response = #{ user => #{ diff --git a/src/handlers/handler_refresh.erl b/src/handlers/handler_refresh.erl index 75e942e..5285fa0 100644 --- a/src/handlers/handler_refresh.erl +++ b/src/handlers/handler_refresh.erl @@ -1,91 +1,67 @@ -module(handler_refresh). -include("records.hrl"). - -export([init/2]). -init(Req, Opts) -> - handle(Req, Opts). - -handle(Req, _Opts) -> - case cowboy_req:method(Req) of +init(Req0, _Opts) -> + case cowboy_req:method(Req0) of <<"POST">> -> - {ok, Body, Req1} = cowboy_req:read_body(Req), - case jsx:decode(Body, [return_maps]) of + {ok, Body, Req1} = cowboy_req:read_body(Req0), + try jsx:decode(Body, [return_maps]) of #{<<"refresh_token">> := RefreshToken} -> - case validate_refresh_token(RefreshToken) of - {ok, UserId} -> - case core_user:get_by_id(UserId) of - {ok, User} -> - % Генерируем новые токены - NewToken = logic_auth:generate_jwt(User#user.id, User#user.role), - {NewRefreshToken, ExpiresAt} = logic_auth:generate_refresh_token(User#user.id), - - % Сохраняем новый refresh token - save_refresh_token(User#user.id, NewRefreshToken, ExpiresAt), - - % Удаляем старый refresh token - delete_refresh_token(RefreshToken), - - Response = #{ + case get_session(RefreshToken) of + {ok, Session} -> + % Проверяем, не истекла ли сессия + case Session#session.expires_at > calendar:universal_time() of + true -> + % Генерируем новый access-токен и refresh-токен + User = get_user(Session#session.user_id), + NewToken = eventhub_auth:generate_user_token( + User#user.id, + atom_to_binary(User#user.role, utf8) + ), + {NewRefreshToken, ExpiresAt} = + eventhub_auth:generate_refresh_token(User#user.id), + % Удаляем старую сессию и сохраняем новую + mnesia:dirty_delete_object(Session), + NewSession = #session{ + token = NewRefreshToken, + user_id = User#user.id, + expires_at = ExpiresAt, + type = refresh + }, + mnesia:dirty_write(NewSession), + Resp = jsx:encode(#{ token => NewToken, refresh_token => NewRefreshToken - }, - send_json(Req1, 200, Response); - {error, not_found} -> - send_error(Req1, 401, <<"User not found">>) + }), + cowboy_req:reply(200, #{ + <<"content-type">> => <<"application/json">> + }, Resp, Req1); + false -> + mnesia:dirty_delete_object(Session), + send_error(Req1, 401, <<"Refresh token expired">>) end; - {error, expired} -> - send_error(Req1, 401, <<"Refresh token expired">>); - {error, invalid} -> - send_error(Req1, 401, <<"Invalid refresh token">>) + {error, not_found} -> + send_error(Req1, 401, <<"Refresh token not found">>) end; _ -> - send_error(Req1, 400, <<"Missing refresh_token">>) + send_error(Req1, 400, <<"Missing refresh_token field">>) + catch + _:_ -> send_error(Req1, 400, <<"Invalid JSON">>) end; _ -> - send_error(Req, 405, <<"Method not allowed">>) + send_error(Req0, 405, <<"Method not allowed">>) end. -validate_refresh_token(Token) -> - case get_session_by_token(Token) of - {ok, Session} -> - % Проверяем срок действия - Now = calendar:universal_time(), - case Session#session.expires_at > Now of - true -> {ok, Session#session.user_id}; - false -> {error, expired} - end; - {error, not_found} -> - {error, invalid} +get_session(Token) -> + case mnesia:dirty_read({session, Token}) of + [Session] -> {ok, Session}; + [] -> {error, not_found} end. -get_session_by_token(Token) -> - Match = #session{token = Token, type = refresh, _ = '_'}, - case mnesia:dirty_match_object(Match) of - [] -> {error, not_found}; - [Session] -> {ok, Session} - end. - -save_refresh_token(UserId, Token, ExpiresAt) -> - Session = #session{ - token = Token, - user_id = UserId, - expires_at = ExpiresAt, - type = refresh - }, - mnesia:dirty_write(Session). - -delete_refresh_token(Token) -> - Match = #session{token = Token, type = refresh, _ = '_'}, - case mnesia:dirty_match_object(Match) of - [] -> ok; - [Session] -> mnesia:dirty_delete_object(Session) - end. - -send_json(Req, Status, Data) -> - Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), - {ok, Body, []}. +get_user(UserId) -> + [User] = mnesia:dirty_read({user, UserId}), + User. send_error(Req, Status, Message) -> Body = jsx:encode(#{error => Message}), diff --git a/src/handlers/handler_register.erl b/src/handlers/handler_register.erl index 4d20055..e839623 100644 --- a/src/handlers/handler_register.erl +++ b/src/handlers/handler_register.erl @@ -24,7 +24,7 @@ handle(Req, _Opts) -> false -> case core_user:create(Email, Password) of {ok, User} -> - Token = logic_auth:generate_jwt(User#user.id, User#user.role), + Token = logic_auth:generate_jwt(User#user.id, atom_to_binary(User#user.role, utf8)), Response = #{ user => #{ id => User#user.id, diff --git a/src/handlers/handler_ticket_by_id.erl b/src/handlers/handler_ticket_by_id.erl index b9b1e6a..f183a72 100644 --- a/src/handlers/handler_ticket_by_id.erl +++ b/src/handlers/handler_ticket_by_id.erl @@ -1,157 +1,93 @@ -module(handler_ticket_by_id). --include("records.hrl"). - +-behaviour(cowboy_handler). -export([init/2]). -init(Req, Opts) -> - handle(Req, Opts). +-include("records.hrl"). -handle(Req, _Opts) -> +init(Req, _Opts) -> case cowboy_req:method(Req) of <<"GET">> -> get_ticket(Req); <<"PUT">> -> update_ticket(Req); - _ -> send_error(Req, 405, <<"Method not allowed">>) + _ -> send_error(Req, 405, <<"Method not allowed">>) end. -%% GET /v1/admin/tickets/:id - получить тикет get_ticket(Req) -> case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> + {ok, UserId, Req1} -> TicketId = cowboy_req:binding(id, Req1), - case logic_ticket:get_ticket(AdminId, TicketId) of + io:format("[TICKET_BY_ID] User ~s requests ticket ~s~n", [UserId, TicketId]), + case core_ticket:get_by_id(TicketId) of {ok, Ticket} -> - Response = ticket_to_json(Ticket), - send_json(Req1, 200, Response); - {error, access_denied} -> - send_error(Req1, 403, <<"Admin access required">>); + io:format("[TICKET_BY_ID] Found ticket, reporter_id: ~s~n", [Ticket#ticket.reporter_id]), + case is_admin(UserId) orelse Ticket#ticket.reporter_id =:= UserId of + true -> + io:format("[TICKET_BY_ID] Access granted~n"), + send_json(Req1, 200, ticket_to_json(Ticket)); + false -> + io:format("[TICKET_BY_ID] Access denied~n"), + send_error(Req1, 403, <<"Access denied">>) + end; {error, not_found} -> - send_error(Req1, 404, <<"Ticket not found">>); - {error, _} -> - send_error(Req1, 500, <<"Internal server error">>) + io:format("[TICKET_BY_ID] Ticket not found~n"), + send_error(Req1, 404, <<"Ticket not found">>) end; {error, Code, Message, Req1} -> + io:format("[TICKET_BY_ID] Auth error: ~p - ~s~n", [Code, Message]), send_error(Req1, Code, Message) end. -%% PUT /v1/admin/tickets/:id - обновить тикет update_ticket(Req) -> case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> + {ok, UserId, Req1} -> TicketId = cowboy_req:binding(id, Req1), {ok, Body, Req2} = cowboy_req:read_body(Req1), try jsx:decode(Body, [return_maps]) of - Decoded when is_map(Decoded) -> - handle_ticket_action(AdminId, TicketId, Decoded, Req2); - _ -> - send_error(Req2, 400, <<"Invalid JSON">>) + Updates when is_map(Updates) -> + case core_ticket:get_by_id(TicketId) of + {ok, Ticket} -> + case is_admin(UserId) orelse Ticket#ticket.reporter_id =:= UserId of + true -> + case core_ticket:update_ticket(TicketId, Updates) of + {ok, Updated} -> send_json(Req2, 200, ticket_to_json(Updated)); + {error, R} -> send_error(Req2, 500, R) + end; + false -> send_error(Req2, 403, <<"Access denied">>) + end; + {error, not_found} -> send_error(Req2, 404, <<"Ticket not found">>) + end; + _ -> send_error(Req2, 400, <<"Invalid JSON">>) catch - _:_ -> - send_error(Req2, 400, <<"Invalid JSON format">>) + _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) end; {error, Code, Message, Req1} -> send_error(Req1, Code, Message) end. -%% Обработка действий с тикетом -handle_ticket_action(AdminId, TicketId, Body, Req) -> - case maps:get(<<"action">>, Body, undefined) of - <<"status">> -> - case maps:get(<<"status">>, Body, undefined) of - StatusBin when StatusBin =:= <<"open">>; - StatusBin =:= <<"in_progress">>; - StatusBin =:= <<"resolved">>; - StatusBin =:= <<"closed">> -> - Status = get_binary_to_atom(StatusBin), - case logic_ticket:update_status(AdminId, TicketId, Status) of - {ok, Ticket} -> - Response = ticket_to_json(Ticket), - send_json(Req, 200, Response); - {error, access_denied} -> - send_error(Req, 403, <<"Admin access required">>); - {error, not_found} -> - send_error(Req, 404, <<"Ticket not found">>); - {error, _} -> - send_error(Req, 500, <<"Internal server error">>) - end; - _ -> - send_error(Req, 400, <<"Invalid status">>) - end; - <<"assign">> -> - case maps:get(<<"admin_id">>, Body, undefined) of - undefined -> - send_error(Req, 400, <<"Missing admin_id field">>); - AssignToId -> - case logic_ticket:assign_ticket(AdminId, TicketId, AssignToId) of - {ok, Ticket} -> - Response = ticket_to_json(Ticket), - send_json(Req, 200, Response); - {error, access_denied} -> - send_error(Req, 403, <<"Admin access required">>); - {error, not_found} -> - send_error(Req, 404, <<"Ticket not found">>); - {error, _} -> - send_error(Req, 500, <<"Internal server error">>) - end - end; - <<"resolve">> -> - Note = maps:get(<<"note">>, Body, <<"">>), - case logic_ticket:resolve_ticket(AdminId, TicketId, Note) of - {ok, Ticket} -> - Response = ticket_to_json(Ticket), - send_json(Req, 200, Response); - {error, access_denied} -> - send_error(Req, 403, <<"Admin access required">>); - {error, not_found} -> - send_error(Req, 404, <<"Ticket not found">>); - {error, _} -> - send_error(Req, 500, <<"Internal server error">>) - end; - <<"close">> -> - case logic_ticket:close_ticket(AdminId, TicketId) of - {ok, Ticket} -> - Response = ticket_to_json(Ticket), - send_json(Req, 200, Response); - {error, access_denied} -> - send_error(Req, 403, <<"Admin access required">>); - {error, not_found} -> - send_error(Req, 404, <<"Ticket not found">>); - {error, _} -> - send_error(Req, 500, <<"Internal server error">>) - end; - _ -> - send_error(Req, 400, <<"Invalid action">>) +is_admin(UserId) -> + case core_user:get_by_id(UserId) of + {ok, U} -> lists:member(U#user.role, [admin, superadmin, moderator, support]); + _ -> false end. -%% Вспомогательные функции -ticket_to_json(Ticket) -> - Context = try binary_to_term(Ticket#ticket.context) of - C -> C - catch - _:_ -> #{} - end, - +ticket_to_json(T) -> #{ - id => Ticket#ticket.id, - error_hash => Ticket#ticket.error_hash, - error_message => Ticket#ticket.error_message, - stacktrace => Ticket#ticket.stacktrace, - context => Context, - count => Ticket#ticket.count, - first_seen => datetime_to_iso8601(Ticket#ticket.first_seen), - last_seen => datetime_to_iso8601(Ticket#ticket.last_seen), - status => Ticket#ticket.status, - assigned_to => Ticket#ticket.assigned_to, - resolution_note => Ticket#ticket.resolution_note + id => T#ticket.id, + error_hash => T#ticket.error_hash, + error_message => T#ticket.error_message, + stacktrace => T#ticket.stacktrace, + context => T#ticket.context, + count => T#ticket.count, + first_seen => datetime_to_iso8601(T#ticket.first_seen), + last_seen => datetime_to_iso8601(T#ticket.last_seen), + status => T#ticket.status, + assigned_to => T#ticket.assigned_to, + resolution_note => T#ticket.resolution_note }. 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])). - -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. + [Year, Month, Day, Hour, Minute, Second])); +datetime_to_iso8601(undefined) -> undefined. send_json(Req, Status, Data) -> Body = jsx:encode(Data), diff --git a/src/handlers/handler_tickets.erl b/src/handlers/handler_tickets.erl index 53978ef..74d61d0 100644 --- a/src/handlers/handler_tickets.erl +++ b/src/handlers/handler_tickets.erl @@ -1,113 +1,82 @@ -module(handler_tickets). --include("records.hrl"). - +-behaviour(cowboy_handler). -export([init/2]). -init(Req, Opts) -> - handle(Req, Opts). +-include("records.hrl"). + +init(Req0, Opts) -> + handle(Req0, Opts). handle(Req, _Opts) -> case cowboy_req:method(Req) of <<"GET">> -> list_tickets(Req); - <<"POST">> -> report_error(Req); + <<"POST">> -> create_ticket(Req); _ -> send_error(Req, 405, <<"Method not allowed">>) end. -%% POST /v1/tickets - сообщить об ошибке (доступно всем) -report_error(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 - #{<<"error_message">> := ErrorMessage} -> - Stacktrace = maps:get(<<"stacktrace">>, Decoded, <<"">>), - Context = maps:get(<<"context">>, Decoded, #{}), - - case logic_ticket:report_error(ErrorMessage, Stacktrace, Context) of - {ok, Ticket} -> - Response = ticket_to_json(Ticket), - send_json(Req2, 201, Response); - {error, _} -> - send_error(Req2, 500, <<"Internal server error">>) - end; - _ -> - send_error(Req2, 400, <<"Missing error_message field">>) - end; - _ -> - send_error(Req2, 400, <<"Invalid JSON">>) - catch - _:_ -> - send_error(Req2, 400, <<"Invalid JSON format">>) - end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. - -%% GET /v1/admin/tickets - список тикетов (только админ) list_tickets(Req) -> case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> - Qs = cowboy_req:parse_qs(Req1), - case proplists:get_value(<<"status">>, Qs) of - undefined -> - case logic_ticket:list_tickets(AdminId) of - {ok, Tickets} -> - Response = [ticket_to_json(T) || T <- Tickets], - send_json(Req1, 200, Response); - {error, access_denied} -> - send_error(Req1, 403, <<"Admin access required">>); - {error, _} -> - send_error(Req1, 500, <<"Internal server error">>) - end; - StatusBin -> - Status = parse_status(StatusBin), - case logic_ticket:list_tickets_by_status(AdminId, Status) of - {ok, Tickets} -> - Response = [ticket_to_json(T) || T <- Tickets], - send_json(Req1, 200, Response); - {error, access_denied} -> - send_error(Req1, 403, <<"Admin access required">>); - {error, _} -> - send_error(Req1, 500, <<"Internal server error">>) - end + {ok, UserId, Req1} -> + case is_admin(UserId) of + true -> + Tickets = core_ticket:list_all(), + send_json(Req1, 200, [ticket_to_json(T) || T <- Tickets]); + false -> + Tickets = core_ticket:list_by_user(UserId), + send_json(Req1, 200, [ticket_to_json(T) || T <- Tickets]) end; {error, Code, Message, Req1} -> send_error(Req1, Code, Message) end. -%% Вспомогательные функции -parse_status(<<"open">>) -> open; -parse_status(<<"in_progress">>) -> in_progress; -parse_status(<<"resolved">>) -> resolved; -parse_status(<<"closed">>) -> closed; -parse_status(_) -> open. +create_ticket(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 + #{<<"error_message">> := _} = Data -> + TicketData = maps:merge(#{<<"reporter_id">> => UserId, <<"status">> => <<"open">>}, Data), + case core_ticket:create_ticket(TicketData) of + {ok, Ticket} -> + send_json(Req2, 201, ticket_to_json(Ticket)); + {error, Reason} -> + send_error(Req2, 500, Reason) + end; + _ -> + send_error(Req2, 400, <<"Missing 'error_message' field">>) + catch + _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. -ticket_to_json(Ticket) -> - Context = try binary_to_term(Ticket#ticket.context) of - C -> C - catch - _:_ -> #{} - end, +is_admin(UserId) -> + case core_user:get_by_id(UserId) of + {ok, User} -> + lists:member(User#user.role, [admin, superadmin, moderator, support]); + _ -> false + end. +ticket_to_json(T) -> #{ - id => Ticket#ticket.id, - error_hash => Ticket#ticket.error_hash, - error_message => Ticket#ticket.error_message, - stacktrace => Ticket#ticket.stacktrace, - context => Context, - count => Ticket#ticket.count, - first_seen => datetime_to_iso8601(Ticket#ticket.first_seen), - last_seen => datetime_to_iso8601(Ticket#ticket.last_seen), - status => Ticket#ticket.status, - assigned_to => Ticket#ticket.assigned_to, - resolution_note => Ticket#ticket.resolution_note + id => T#ticket.id, + error_hash => T#ticket.error_hash, + error_message => T#ticket.error_message, + stacktrace => T#ticket.stacktrace, + context => T#ticket.context, + count => T#ticket.count, + first_seen => datetime_to_iso8601(T#ticket.first_seen), + last_seen => datetime_to_iso8601(T#ticket.last_seen), + status => T#ticket.status, + assigned_to => T#ticket.assigned_to, + resolution_note => T#ticket.resolution_note }. 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])). + [Year, Month, Day, Hour, Minute, Second])); +datetime_to_iso8601(undefined) -> undefined. send_json(Req, Status, Data) -> Body = jsx:encode(Data), diff --git a/src/handlers/handler_user_me.erl b/src/handlers/handler_user_me.erl index 355517a..842783c 100644 --- a/src/handlers/handler_user_me.erl +++ b/src/handlers/handler_user_me.erl @@ -1,10 +1,8 @@ -module(handler_user_me). -include("records.hrl"). - -export([init/2]). -init(Req, Opts) -> - handle(Req, Opts). +init(Req, Opts) -> handle(Req, Opts). handle(Req, _Opts) -> case cowboy_req:method(Req) of @@ -14,10 +12,10 @@ handle(Req, _Opts) -> case core_user:get_by_id(UserId) of {ok, User} -> Response = #{ - id => User#user.id, - email => User#user.email, - role => User#user.role, - status => User#user.status, + id => User#user.id, + email => User#user.email, + role => User#user.role, + status => User#user.status, created_at => User#user.created_at, updated_at => User#user.updated_at }, @@ -36,8 +34,7 @@ authenticate(Req) -> case cowboy_req:parse_header(<<"authorization">>, Req) of {bearer, Token} -> case logic_auth:verify_jwt(Token) of - {ok, Claims} -> - UserId = maps:get(<<"user_id">>, Claims), + {ok, UserId, _Role} -> % ← теперь возвращается {ok, UserId, Role} {ok, UserId, Req}; {error, expired} -> {error, 401, <<"Token expired">>, Req}; diff --git a/src/handlers/ws_handler.erl b/src/handlers/ws_handler.erl index 8ff211d..57101d8 100644 --- a/src/handlers/ws_handler.erl +++ b/src/handlers/ws_handler.erl @@ -1,6 +1,5 @@ -module(ws_handler). -behaviour(cowboy_websocket). - -export([init/2]). -export([websocket_init/1]). -export([websocket_handle/2]). @@ -13,15 +12,13 @@ }). 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), + {ok, UserId, _Role} -> {cowboy_websocket, Req, #state{user_id = UserId}}; {error, _} -> {ok, cowboy_req:reply(401, #{}, <<"Invalid token">>, Req), undefined} @@ -29,7 +26,6 @@ init(Req, _Opts) -> end. websocket_init(State) -> - % Регистрируем процесс в pg для получения уведомлений pg:join(eventhub_ws, self()), {ok, State}. @@ -39,9 +35,9 @@ websocket_handle({text, Msg}, State) -> #{<<"action">> := <<"subscribe">>, <<"calendar_id">> := CalendarId} -> io:format("Subscribe to calendar: ~s~n", [CalendarId]), NewSubs = case lists:member(CalendarId, State#state.subscriptions) of - true -> State#state.subscriptions; - false -> [CalendarId | State#state.subscriptions] - end, + true -> State#state.subscriptions; + false -> [CalendarId | State#state.subscriptions] + end, Reply = jsx:encode(#{status => <<"subscribed">>, calendar_id => CalendarId}), io:format("Sending reply: ~s~n", [Reply]), {reply, {text, Reply}, State#state{subscriptions = NewSubs}}; @@ -77,7 +73,6 @@ 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) -> diff --git a/src/infra/auth.erl b/src/infra/eventhub_auth.erl similarity index 94% rename from src/infra/auth.erl rename to src/infra/eventhub_auth.erl index e425d23..b29f4ce 100644 --- a/src/infra/auth.erl +++ b/src/infra/eventhub_auth.erl @@ -1,4 +1,4 @@ --module(auth). +-module(eventhub_auth). -export([ generate_user_token/2, generate_admin_token/2, @@ -145,10 +145,13 @@ authenticate_admin_request(_Req, Email, Password) -> %% ========== REFRESH TOKEN ========== --spec generate_refresh_token(UserId :: binary()) -> {binary(), integer()}. +-spec generate_refresh_token(UserId :: binary()) -> {binary(), calendar:datetime()}. generate_refresh_token(_UserId) -> RefreshToken = base64:encode(crypto:strong_rand_bytes(32)), - ExpiresAt = erlang:system_time(second) + 2592000, % 30 дней + Now = calendar:universal_time(), + ExpiresAt = calendar:gregorian_seconds_to_datetime( + calendar:datetime_to_gregorian_seconds(Now) + 30 * 24 * 3600 + ), {RefreshToken, ExpiresAt}. %% ========== ВНУТРЕННИЕ ========== diff --git a/src/logic/logic_auth.erl b/src/logic/logic_auth.erl index 3133d5c..84a6f55 100644 --- a/src/logic/logic_auth.erl +++ b/src/logic/logic_auth.erl @@ -1,88 +1,43 @@ -module(logic_auth). +-export([hash_password/1, verify_password/2, + generate_jwt/2, verify_jwt/1, + generate_refresh_token/1, + authenticate_user/2]). --export([hash_password/1, verify_password/2]). --export([generate_jwt/2, verify_jwt/1, extract_claims/1]). --export([generate_refresh_token/1]). +-include("records.hrl"). -%% ============ Argon2 хеширование ============ -hash_password(Password) when is_binary(Password) -> +hash_password(Password) -> argon2:hash(Password). -verify_password(Password, Hash) when is_binary(Password), is_binary(Hash) -> +verify_password(Password, Hash) -> argon2:verify(Password, Hash). -%% ============ JWT с использованием jose ============ -get_jwt_secret() -> - <<"my-super-secret-key-for-jwt-32-bytes!">>. - -get_jwk() -> - jose_jwk:from_oct(get_jwt_secret()). - generate_jwt(UserId, Role) -> - JWK = get_jwk(), + eventhub_auth:generate_user_token(UserId, Role). - ExpTime = os:system_time(seconds) + 86400, % 24 часа - Claims = #{ - <<"user_id">> => UserId, - <<"role">> => Role, - <<"exp">> => ExpTime, - <<"iat">> => os:system_time(seconds) - }, +verify_jwt(Token) -> + eventhub_auth:verify_user_token(Token). - JWT = jose_jwt:sign(JWK, #{<<"alg">> => <<"HS256">>}, Claims), - {_, Token} = jose_jws:compact(JWT), - Token. +generate_refresh_token(UserId) -> + eventhub_auth:generate_refresh_token(UserId). -verify_jwt(Token) when is_binary(Token) -> - try - JWK = get_jwk(), - case jose_jwt:verify(JWK, Token) of - {true, {jose_jwt, Claims}, _} -> - case check_expiry(Claims) of - true -> {ok, Claims}; - false -> {error, expired} - end; - {true, Claims, _} when is_map(Claims) -> - case check_expiry(Claims) of - true -> {ok, Claims}; - false -> {error, expired} - end; - {false, _, _} -> - {error, invalid_signature} - end - catch - _:_ -> {error, invalid_token} +authenticate_user(Email, Password) -> + case core_user:get_by_email(Email) of + {ok, User} -> + case verify_password(Password, User#user.password_hash) of + {ok, true} -> + {ok, user_to_map(User)}; + _ -> + {error, invalid_credentials} + end; + {error, not_found} -> + {error, invalid_credentials} end. -extract_claims(Token) when is_binary(Token) -> - try - JWK = get_jwk(), - case jose_jwt:verify(JWK, Token) of - {true, {jose_jwt, Claims}, _} -> - {ok, Claims}; - {true, Claims, _} when is_map(Claims) -> - {ok, Claims}; - _ -> - {error, invalid_token} - end - catch - _:_ -> {error, invalid_token} - end. - -check_expiry(Claims) -> - case maps:find(<<"exp">>, Claims) of - {ok, Exp} when is_integer(Exp) -> - Exp > os:system_time(seconds); - _ -> - false - end. - -%% ============ Refresh Token ============ -generate_refresh_token(_UserId) -> - Token = base64:encode(crypto:strong_rand_bytes(32), #{mode => urlsafe, padding => false}), - ExpiresAt = calendar:universal_time_to_local_time( - calendar:gregorian_seconds_to_datetime( - calendar:datetime_to_gregorian_seconds(calendar:universal_time()) + 30 * 86400 - ) - ), - {Token, ExpiresAt}. \ No newline at end of file +user_to_map(User) -> + #{ + id => User#user.id, + email => User#user.email, + role => atom_to_binary(User#user.role, utf8), + status => atom_to_binary(User#user.status, utf8) + }. \ No newline at end of file diff --git a/src/logic/logic_moderation.erl b/src/logic/logic_moderation.erl index e3df20e..239cf4c 100644 --- a/src/logic/logic_moderation.erl +++ b/src/logic/logic_moderation.erl @@ -1,16 +1,27 @@ -module(logic_moderation). -include("records.hrl"). --export([create_report/4, get_reports/1, get_reports_by_target/3, resolve_report/3]). --export([add_banned_word/2, remove_banned_word/2, list_banned_words/1]). --export([check_content/1, auto_moderate/1]). --export([freeze_calendar/2, unfreeze_calendar/2, freeze_event/2, unfreeze_event/2]). +-export([create_report/4, + get_reports/1, + get_reports_by_target/3, + resolve_report/3]). --define(REPORT_THRESHOLD, 3). % Количество жалоб для авто-заморозки +-export([add_banned_word/2, + remove_banned_word/2, + list_banned_words/1]). -%% ============ Жалобы ============ +-export([check_content/1, + auto_moderate/1]). + +-export([freeze_calendar/2, + unfreeze_calendar/2, + freeze_event/2, + unfreeze_event/2]). + +-define(REPORT_THRESHOLD, 3). + +%% ============ Жалобы ===================================== -%% Создание жалобы create_report(ReporterId, TargetType, TargetId, Reason) -> case target_exists(TargetType, TargetId) of true -> @@ -22,30 +33,29 @@ create_report(ReporterId, TargetType, TargetId, Reason) -> target_id => TargetId, reason => Reason }), - % Проверяем порог для авто-модерации check_auto_freeze(TargetType, TargetId), {ok, Report}; - Error -> Error + Error -> + Error end; - false -> {error, target_not_found} + false -> + {error, target_not_found} end. -%% Получить все жалобы (для админа) get_reports(AdminId) -> case is_admin(AdminId) of - true -> core_report:list_all(); + true -> core_report:list_all(); false -> {error, access_denied} end. -%% Получить жалобы на конкретную цель get_reports_by_target(AdminId, TargetType, TargetId) -> case is_admin(AdminId) of - true -> core_report:list_by_target(TargetType, TargetId); + true -> core_report:list_by_target(TargetType, TargetId); false -> {error, access_denied} end. -%% Рассмотреть жалобу (подтвердить или отклонить) -resolve_report(AdminId, ReportId, Action) when Action =:= reviewed; Action =:= dismissed -> +resolve_report(AdminId, ReportId, Action) + when Action =:= reviewed; Action =:= dismissed -> case is_admin(AdminId) of true -> case core_report:get_by_id(ReportId) of @@ -53,19 +63,23 @@ resolve_report(AdminId, ReportId, Action) when Action =:= reviewed; Action =:= d case Report#report.status of pending -> core_report:update_status(ReportId, Action, AdminId); - _ -> {error, already_resolved} + _ -> + {error, already_resolved} end; - Error -> Error + Error -> + Error end; - false -> {error, access_denied} + false -> + {error, access_denied} end. -%% Проверка порога для авто-заморозки check_auto_freeze(TargetType, TargetId) -> Count = core_report:get_count_by_target(TargetType, TargetId), - if Count >= ?REPORT_THRESHOLD -> - auto_freeze(TargetType, TargetId); - true -> ok + if + Count >= ?REPORT_THRESHOLD -> + auto_freeze(TargetType, TargetId); + true -> + ok end. auto_freeze(event, EventId) -> @@ -82,42 +96,52 @@ auto_freeze(calendar, CalendarId) -> end; auto_freeze(_, _) -> ok. -%% ============ Бан-лист ============ +%% ============ Бан-лист =================================== -%% Добавить запрещённое слово add_banned_word(AdminId, Word) -> case is_admin(AdminId) of - true -> core_banned_word:add(Word); + true -> core_banned_words:add_banned_word(Word, AdminId); false -> {error, access_denied} end. -%% Удалить запрещённое слово remove_banned_word(AdminId, Word) -> case is_admin(AdminId) of - true -> core_banned_word:remove(Word); + true -> core_banned_words:remove_banned_word(Word); false -> {error, access_denied} end. -%% Список запрещённых слов list_banned_words(AdminId) -> case is_admin(AdminId) of - true -> core_banned_word:list_all(); + true -> {ok, core_banned_words:list_banned_words()}; false -> {error, access_denied} end. -%% ============ Контент-фильтр ============ +%% ============ Контент-фильтр ============================= -%% Проверить контент на запрещённые слова check_content(Text) -> - core_banned_word:check_text(Text). + Words = core_banned_words:list_banned_words(), + LowerText = string:lowercase(binary_to_list(Text)), + lists:any(fun(W) -> + string:str(LowerText, binary_to_list(W#banned_word.word)) > 0 + end, Words). -%% Автоматическая модерация контента (замена запрещённых слов) auto_moderate(Text) -> - core_banned_word:filter_text(Text). + Words = core_banned_words:list_banned_words(), + lists:foldl(fun(W, Acc) -> + WordStr = binary_to_list(W#banned_word.word), + LowerAccStr = string:lowercase(binary_to_list(Acc)), + case string:str(LowerAccStr, WordStr) of + 0 -> Acc; + Pos -> + Len = length(WordStr), + Start = binary:part(Acc, {0, Pos-1}), + Rest = binary:part(Acc, {Pos-1+Len, byte_size(Acc)-Pos+1-Len}), + <> + end + end, Text, Words). -%% ============ Заморозка/разморозка ============ +%% ============ Заморозка/разморозка ======================= -%% Заморозить календарь freeze_calendar(AdminId, CalendarId) -> case is_admin(AdminId) of true -> @@ -129,7 +153,6 @@ freeze_calendar(AdminId, CalendarId) -> false -> {error, access_denied} end. -%% Разморозить календарь unfreeze_calendar(AdminId, CalendarId) -> case is_admin(AdminId) of true -> @@ -141,7 +164,6 @@ unfreeze_calendar(AdminId, CalendarId) -> false -> {error, access_denied} end. -%% Заморозить событие freeze_event(AdminId, EventId) -> case is_admin(AdminId) of true -> @@ -153,7 +175,6 @@ freeze_event(AdminId, EventId) -> false -> {error, access_denied} end. -%% Разморозить событие unfreeze_event(AdminId, EventId) -> case is_admin(AdminId) of true -> @@ -165,7 +186,7 @@ unfreeze_event(AdminId, EventId) -> false -> {error, access_denied} end. -%% ============ Вспомогательные функции ============ +%% ============ Вспомогательные функции ==================== target_exists(event, EventId) -> case core_event:get_by_id(EventId) of @@ -176,7 +197,7 @@ target_exists(calendar, CalendarId) -> case core_calendar:get_by_id(CalendarId) of {ok, _} -> true; _ -> false - end; % ← точка с запятой здесь! + end; target_exists(_, _) -> false. is_admin(UserId) -> diff --git a/src/logic/logic_ticket.erl b/src/logic/logic_ticket.erl index 5037211..cffcde7 100644 --- a/src/logic/logic_ticket.erl +++ b/src/logic/logic_ticket.erl @@ -1,89 +1,110 @@ -module(logic_ticket). -include("records.hrl"). --export([report_error/3, get_ticket/2, list_tickets/1, list_tickets_by_status/2]). --export([update_status/3, assign_ticket/3, resolve_ticket/3, close_ticket/2]). --export([get_statistics/1]). +-export([report_error/3, + get_ticket/2, + list_tickets/1, + list_tickets_by_status/2, + update_status/3, + assign_ticket/3, + resolve_ticket/3, + close_ticket/2, + get_statistics/1]). %% Зарегистрировать ошибку (создать или обновить тикет) report_error(ErrorMessage, Stacktrace, Context) -> - case core_ticket:create_or_update(ErrorMessage, Stacktrace, Context) of - {ok, Ticket} -> - % Если это новый тикет, уведомляем администраторов (заглушка) - case Ticket#ticket.count of - 1 -> notify_admins(Ticket); - _ -> ok - end, - {ok, Ticket}; - Error -> Error + Existing = [T || T <- core_ticket:list_all(), T#ticket.error_message =:= ErrorMessage], + case Existing of + [Ticket] -> + % Увеличить счётчик и обновить last_seen + Updated = Ticket#ticket{ + count = Ticket#ticket.count + 1, + last_seen = calendar:universal_time() + }, + mnesia:dirty_write(Updated), + {ok, Updated}; + [] -> + Data = #{ + <<"error_message">> => ErrorMessage, + <<"stacktrace">> => Stacktrace, + <<"context">> => list_to_binary(io_lib:format("~p", [Context])) + }, + case core_ticket:create_ticket(Data) of + {ok, Ticket} = Result -> + % Уведомление администраторов (заглушка) + notify_admins(Ticket), + Result; + Error -> Error + end end. %% Получить тикет (только для админов) get_ticket(AdminId, TicketId) -> case is_admin(AdminId) of - true -> core_ticket:get_by_id(TicketId); + true -> core_ticket:get_by_id(TicketId); false -> {error, access_denied} end. %% Список всех тикетов (только для админов) list_tickets(AdminId) -> case is_admin(AdminId) of - true -> core_ticket:list_all(); + true -> core_ticket:list_all(); false -> {error, access_denied} end. %% Список тикетов по статусу (только для админов) list_tickets_by_status(AdminId, Status) -> case is_admin(AdminId) of - true -> core_ticket:list_by_status(Status); + true -> + All = core_ticket:list_all(), + [T || T <- All, T#ticket.status =:= Status]; false -> {error, access_denied} end. %% Обновить статус тикета update_status(AdminId, TicketId, Status) -> case is_admin(AdminId) of - true -> core_ticket:update_status(TicketId, Status); + true -> core_ticket:update_ticket(TicketId, #{<<"status">> => Status}); false -> {error, access_denied} end. %% Назначить тикет администратору assign_ticket(AdminId, TicketId, AssignToId) -> case is_admin(AdminId) of - true -> core_ticket:assign(TicketId, AssignToId); + true -> core_ticket:update_ticket(TicketId, #{<<"assigned_to">> => AssignToId}); false -> {error, access_denied} end. %% Отметить тикет как решённый с примечанием resolve_ticket(AdminId, TicketId, ResolutionNote) -> case is_admin(AdminId) of - true -> - case core_ticket:add_resolution(TicketId, ResolutionNote) of - {ok, _Ticket} -> - core_ticket:update_status(TicketId, resolved); - Error -> Error - end; + true -> + core_ticket:update_ticket(TicketId, #{ + <<"status">> => <<"closed">>, + <<"resolution_note">> => ResolutionNote + }); false -> {error, access_denied} end. %% Закрыть тикет close_ticket(AdminId, TicketId) -> case is_admin(AdminId) of - true -> core_ticket:update_status(TicketId, closed); + true -> core_ticket:update_ticket(TicketId, #{<<"status">> => <<"closed">>}); false -> {error, access_denied} end. %% Получить статистику по тикетам get_statistics(AdminId) -> case is_admin(AdminId) of - true -> - {ok, AllTickets} = core_ticket:list_all(), - Open = length([T || T <- AllTickets, T#ticket.status =:= open]), - InProgress = length([T || T <- AllTickets, T#ticket.status =:= in_progress]), - Resolved = length([T || T <- AllTickets, T#ticket.status =:= resolved]), - Closed = length([T || T <- AllTickets, T#ticket.status =:= closed]), - TotalErrors = lists:sum([T#ticket.count || T <- AllTickets]), + true -> + All = core_ticket:list_all(), + Open = length([T || T <- All, T#ticket.status =:= open]), + InProgress = length([T || T <- All, T#ticket.status =:= in_progress]), + Resolved = length([T || T <- All, T#ticket.status =:= resolved]), + Closed = length([T || T <- All, T#ticket.status =:= closed]), + TotalErrors = lists:sum([T#ticket.count || T <- All]), #{ - total_tickets => length(AllTickets), + total_tickets => length(All), open => Open, in_progress => InProgress, resolved => Resolved, @@ -102,6 +123,4 @@ is_admin(UserId) -> end. notify_admins(_Ticket) -> - % Заглушка для уведомлений администраторов - % В будущем здесь будет отправка email/websocket ok. \ No newline at end of file diff --git a/test/api/api_admin_tests.erl b/test/api/api_admin_tests.erl index 0af5ae8..9e5d442 100644 --- a/test/api/api_admin_tests.erl +++ b/test/api/api_admin_tests.erl @@ -3,24 +3,154 @@ test() -> io:format("Testing admin panel API...~n"), + AdminURL = "http://localhost:8445", + % Получаем admin-токен через test runner (уже проверенный) 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", []}, [], []), + %% TEST 1: Admin healthcheck (public) + io:format(" TEST 1: Admin healthcheck... "), + {ok, {{_, 200, _}, _, _}} = httpc:request(get, {AdminURL ++ "/v1/admin/health", []}, [], []), io:format("OK~n"), - % TEST 2: Admin stats - io:format(" TEST 2: Admin stats... "), + %% TEST 2: Admin login (дополнительная проверка) + io:format(" TEST 2: Admin login (attempt)... "), + LoginBody = jsx:encode(#{<<"email">> => <<"global_admin@test.com">>, <<"password">> => <<"admin123">>}), + case httpc:request(post, {AdminURL ++ "/v1/admin/login", [], "application/json", LoginBody}, [], []) of + {ok, {{_, 200, _}, _, _}} -> + io:format("OK (logged in)~n"); + _ -> + io:format("SKIPPED (credentials not found, using runner token)~n") + end, + + %% TEST 3: Admin stats + io:format(" TEST 3: Admin stats... "), {ok, {{_, 200, _}, _, _}} = httpc:request(get, - {"http://localhost:8445/admin/stats", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + {AdminURL ++ "/v1/admin/stats", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), io:format("OK~n"), - % TEST 3: List users - io:format(" TEST 3: List users... "), + %% TEST 4: List users + io:format(" TEST 4: List users... "), {ok, {{_, 200, _}, _, _}} = httpc:request(get, - {"http://localhost:8445/admin/users", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + {AdminURL ++ "/v1/admin/users", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + io:format("OK~n"), + + %% TEST 5: Get user by ID + io:format(" TEST 5: Get user by ID... "), + UserId = api_test_runner:get_user_id(), + {ok, {{_, 200, _}, _, _}} = httpc:request(get, + {AdminURL ++ "/v1/admin/users/" ++ binary_to_list(UserId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + io:format("OK~n"), + + %% TEST 6: List reports + io:format(" TEST 6: List reports... "), + {ok, {{_, 200, _}, _, _}} = httpc:request(get, + {AdminURL ++ "/v1/admin/reports", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + io:format("OK~n"), + + %% TEST 7: List banned words + io:format(" TEST 7: List banned words... "), + {ok, {{_, 200, _}, _, _}} = httpc:request(get, + {AdminURL ++ "/v1/admin/banned-words", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + io:format("OK~n"), + + %% TEST 8: Add banned word + io:format(" TEST 8: Add banned word... "), + BannedWordBody = jsx:encode(#{<<"word">> => <<"test_banned_word">>}), + {ok, {{_, 201, _}, _, _}} = httpc:request(post, + {AdminURL ++ "/v1/admin/banned-words", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", BannedWordBody}, [], []), + io:format("OK~n"), + + %% TEST 9: Delete banned word + io:format(" TEST 9: Delete banned word... "), + {ok, {{_, 200, _}, _, _}} = httpc:request(delete, + {AdminURL ++ "/v1/admin/banned-words/test_banned_word", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + io:format("OK~n"), + + %% TEST 10: List tickets + io:format(" TEST 10: List tickets... "), + {ok, {{_, 200, _}, _, _}} = httpc:request(get, + {AdminURL ++ "/v1/admin/tickets", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + io:format("OK~n"), + + %% TEST 11: Create ticket + io:format(" TEST 11: Create ticket... "), + TicketBody = jsx:encode(#{<<"error_message">> => <<"Test error">>, <<"stacktrace">> => <<"trace">>}), + {ok, {{_, 201, _}, _, TicketResp}} = httpc:request(post, + {AdminURL ++ "/v1/admin/tickets", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", TicketBody}, [], []), + #{<<"id">> := TicketId} = jsx:decode(list_to_binary(TicketResp), [return_maps]), + io:format("OK~n"), + + %% TEST 12: Get ticket by ID + io:format(" TEST 12: Get ticket by ID... "), + {ok, {{_, 200, _}, _, _}} = httpc:request(get, + {AdminURL ++ "/v1/admin/tickets/" ++ binary_to_list(TicketId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + io:format("OK~n"), + + %% TEST 13: Update ticket + io:format(" TEST 13: Update ticket... "), + UpdateTicketBody = jsx:encode(#{<<"status">> => <<"closed">>}), + {ok, {{_, 200, _}, _, _}} = httpc:request(put, + {AdminURL ++ "/v1/admin/tickets/" ++ binary_to_list(TicketId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", UpdateTicketBody}, [], []), + io:format("OK~n"), + + %% TEST 14: Delete ticket + io:format(" TEST 14: Delete ticket... "), + {ok, {{_, 200, _}, _, _}} = httpc:request(delete, + {AdminURL ++ "/v1/admin/tickets/" ++ binary_to_list(TicketId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + io:format("OK~n"), + + %% TEST 15: Ticket stats + io:format(" TEST 15: Ticket stats... "), + {ok, {{_, 200, _}, _, _}} = httpc:request(get, + {AdminURL ++ "/v1/admin/tickets/stats", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + io:format("OK~n"), + + %% TEST 16: List subscriptions + io:format(" TEST 16: List subscriptions... "), + {ok, {{_, 200, _}, _, _}} = httpc:request(get, + {AdminURL ++ "/v1/admin/subscriptions", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + io:format("OK~n"), + + %% TEST 17: Create subscription + io:format(" TEST 17: Create subscription... "), + SubBody = jsx:encode(#{<<"user_id">> => UserId, <<"plan">> => <<"monthly">>}), + {ok, {{_, 201, _}, _, SubResp}} = httpc:request(post, + {AdminURL ++ "/v1/admin/subscriptions", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", SubBody}, [], []), + #{<<"id">> := SubId} = jsx:decode(list_to_binary(SubResp), [return_maps]), + io:format("OK~n"), + + %% TEST 18: Get subscription by ID + io:format(" TEST 18: Get subscription by ID... "), + {ok, {{_, 200, _}, _, _}} = httpc:request(get, + {AdminURL ++ "/v1/admin/subscriptions/" ++ binary_to_list(SubId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + io:format("OK~n"), + + %% TEST 19: Update subscription + io:format(" TEST 19: Update subscription... "), + UpdateSubBody = jsx:encode(#{<<"status">> => <<"cancelled">>}), + {ok, {{_, 200, _}, _, _}} = httpc:request(put, + {AdminURL ++ "/v1/admin/subscriptions/" ++ binary_to_list(SubId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", UpdateSubBody}, [], []), + io:format("OK~n"), + + %% TEST 20: Delete subscription + io:format(" TEST 20: Delete subscription... "), + {ok, {{_, 200, _}, _, _}} = httpc:request(delete, + {AdminURL ++ "/v1/admin/subscriptions/" ++ binary_to_list(SubId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + io:format("OK~n"), + + %% TEST 21: Moderation - block user + io:format(" TEST 21: Moderation - block user... "), + ModBody = jsx:encode(#{<<"action">> => <<"block">>}), + {ok, {{_, 200, _}, _, _}} = httpc:request(put, + {AdminURL ++ "/v1/admin/user/" ++ binary_to_list(UserId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", ModBody}, [], []), + io:format("OK~n"), + + %% TEST 22: Moderation - unblock user + io:format(" TEST 22: Moderation - unblock user... "), + UnblockBody = jsx:encode(#{<<"action">> => <<"unblock">>}), + {ok, {{_, 200, _}, _, _}} = httpc:request(put, + {AdminURL ++ "/v1/admin/user/" ++ binary_to_list(UserId), [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], "application/json", UnblockBody}, [], []), io:format("OK~n"), io:format("~n✅ Admin API tests passed!~n"), diff --git a/test/api/api_moderation_tests.erl b/test/api/api_moderation_tests.erl index a5d33d1..6acdf2e 100644 --- a/test/api/api_moderation_tests.erl +++ b/test/api/api_moderation_tests.erl @@ -2,52 +2,70 @@ -export([test/0]). -define(BASE_URL, "http://localhost:8080"). +-define(ADMIN_BASE_URL, "http://localhost:8445"). test() -> io:format("Testing moderation API...~n"), - AdminToken = api_test_runner:get_admin_token(), - UserToken = api_test_runner:get_user_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">>), + %% Создаём календарь и событие через пользовательский API + 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">>), + #{title => <<"Mod Event">>, + start_time => <<"2026-06-01T10:00:00Z">>, + duration => 60}, + UserToken), + <<"id">>), - % TEST 1: Create report + %% 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">>), + #{target_type => <<"event">>, + target_id => EventId, + reason => <<"Inappropriate">>}, + UserToken), + <<"id">>), io:format("OK~n"), - % TEST 2: Admin views reports + %% TEST 2: Admin views reports (через админский URL, прямой httpc) io:format(" TEST 2: Admin views reports... "), - {ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/admin/reports", AdminToken), + {ok, {{_, 200, _}, _, _}} = httpc:request(get, + {?ADMIN_BASE_URL ++ "/v1/admin/reports", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), io:format("OK~n"), - % TEST 3: Admin resolves report + %% 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), + {ok, {{_, 200, _}, _, _}} = httpc:request(put, + {?ADMIN_BASE_URL ++ "/v1/admin/reports/" ++ binary_to_list(ReportId), + [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], + "application/json", + jsx:encode(#{status => <<"reviewed">>})}, [], []), io:format("OK~n"), - % TEST 4: Add banned word + %% 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), + {ok, {{_, 201, _}, _, _}} = httpc:request(post, + {?ADMIN_BASE_URL ++ "/v1/admin/banned-words", + [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], + "application/json", + jsx:encode(#{<<"word">> => <<"badword">>})}, [], []), io:format("OK~n"), - % TEST 5: List banned words + %% TEST 5: List banned words (админ) io:format(" TEST 5: List banned words... "), - {ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/admin/banned-words", AdminToken), + {ok, {{_, 200, _}, _, _}} = httpc:request(get, + {?ADMIN_BASE_URL ++ "/v1/admin/banned-words", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), io:format("OK~n"), - % TEST 6: Remove banned word + %% 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), + {ok, {{_, 200, _}, _, _}} = httpc:request(delete, + {?ADMIN_BASE_URL ++ "/v1/admin/banned-words/badword", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), io:format("OK~n"), io:format("~n✅ Moderation API tests passed!~n"), diff --git a/test/api/api_tickets_tests.erl b/test/api/api_tickets_tests.erl index 4c8b785..d09849b 100644 --- a/test/api/api_tickets_tests.erl +++ b/test/api/api_tickets_tests.erl @@ -1,37 +1,78 @@ -module(api_tickets_tests). -export([test/0]). --define(BASE_URL, "http://localhost:8080"). +-define(ADMIN_BASE_URL, "http://localhost:8445"). test() -> io:format("Testing tickets API...~n"), - + Token = api_test_runner:get_user_token(), AdminToken = api_test_runner:get_admin_token(), - UserToken = api_test_runner:get_user_token(), - % TEST 1: Report error - io:format(" TEST 1: Report error... "), + %% TEST 1: Create ticket (user) + io:format(" TEST 1: Create ticket...~n"), + io:format(" POST /v1/tickets~n"), 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"), + #{error_message => <<"Bug">>, + stacktrace => <<"Something broke">>}, + Token), + <<"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 2: Get my tickets (user) + io:format(" TEST 2: Get my tickets...~n"), + io:format(" GET /v1/tickets~n"), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_get("/v1/tickets", Token), + 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 3: Get single ticket (user) + io:format(" TEST 3: Get single ticket...~n"), + io:format(" GET /v1/tickets/~s~n", [TicketId]), + {ok, {{_, 200, _}, _, _}} = api_test_runner:http_get( + "/v1/tickets/" ++ binary_to_list(TicketId), + Token), + 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"), + %% TEST 4: Admin lists all tickets + io:format(" TEST 4: Admin lists all tickets...~n"), + io:format(" GET ~s/v1/admin/tickets~n", [?ADMIN_BASE_URL]), + {ok, {{_, 200, _}, _, _}} = httpc:request(get, + {?ADMIN_BASE_URL ++ "/v1/admin/tickets", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + io:format(" OK~n"), + + %% TEST 5: Admin updates ticket status + io:format(" TEST 5: Admin updates ticket status...~n"), + io:format(" PUT ~s/v1/admin/tickets/~s~n", [?ADMIN_BASE_URL, TicketId]), + {ok, {{_, 200, _}, _, _}} = httpc:request(put, + {?ADMIN_BASE_URL ++ "/v1/admin/tickets/" ++ binary_to_list(TicketId), + [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], + "application/json", + jsx:encode(#{status => <<"in_progress">>})}, [], []), + io:format(" OK~n"), + + %% TEST 6: Admin assigns ticket + io:format(" TEST 6: Admin assigns ticket...~n"), + io:format(" PUT ~s/v1/admin/tickets/~s~n", [?ADMIN_BASE_URL, TicketId]), + {ok, {{_, 200, _}, _, _}} = httpc:request(put, + {?ADMIN_BASE_URL ++ "/v1/admin/tickets/" ++ binary_to_list(TicketId), + [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}], + "application/json", + jsx:encode(#{assigned_to => AdminToken})}, [], []), + io:format(" OK~n"), + + %% TEST 7: Admin views ticket stats + io:format(" TEST 7: Admin views ticket stats...~n"), + io:format(" GET ~s/v1/admin/tickets/stats~n", [?ADMIN_BASE_URL]), + {ok, {{_, 200, _}, _, _}} = httpc:request(get, + {?ADMIN_BASE_URL ++ "/v1/admin/tickets/stats", [{"Authorization", "Bearer " ++ binary_to_list(AdminToken)}]}, [], []), + io:format(" OK~n"), + + %% TEST 8: Admin deletes ticket + io:format(" TEST 8: Admin deletes ticket...~n"), + io:format(" DELETE ~s/v1/admin/tickets/~s~n", [?ADMIN_BASE_URL, TicketId]), + {ok, {{_, 200, _}, _, _}} = httpc:request(delete, + {?ADMIN_BASE_URL ++ "/v1/admin/tickets/" ++ binary_to_list(TicketId), [{"Authorization", "Bearer " ++ binary_to_list(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/unit/admin_handler_report_by_id_tests.erl b/test/unit/admin_handler_report_by_id_tests.erl index a9e7724..eb0ce89 100644 --- a/test/unit/admin_handler_report_by_id_tests.erl +++ b/test/unit/admin_handler_report_by_id_tests.erl @@ -46,7 +46,7 @@ test_get_report() -> target_type = <<"event">>, target_id = <<"e1">>, reason = <<"spam">>, - status = <<"new">>, + status = pending, created_at = {{2026,4,26},{12,0,0}}, resolved_at = undefined }, @@ -55,7 +55,7 @@ test_get_report() -> {ok, _, _} = admin_handler_report_by_id:init(req, []), {Status, _, RespBody, _} = erase(test_reply), ?assertEqual(200, Status), - #{<<"id">> := <<"r1">>, <<"status">> := <<"new">>} = jsx:decode(RespBody, [return_maps]). + #{<<"id">> := <<"r1">>, <<"status">> := <<"pending">>} = jsx:decode(RespBody, [return_maps]). %% GET – не найдено test_get_report_not_found() -> @@ -94,9 +94,9 @@ test_update_report() -> fun(id, _) -> <<"r1">> end), ok = meck:expect(cowboy_req, read_body, fun(Req) -> {ok, jsx:encode(#{<<"status">> => <<"reviewed">>}), Req} end), - Updated = #report{id = <<"r1">>, status = <<"reviewed">>}, + Updated = #report{id = <<"r1">>, status = reviewed}, ok = meck:expect(core_report, update_status, - fun(<<"r1">>, <<"reviewed">>) -> {ok, Updated} end), + fun(<<"r1">>, reviewed, <<"adm1">>) -> {ok, Updated} end), {ok, _, _} = admin_handler_report_by_id:init(req, []), {Status, _, RespBody, _} = erase(test_reply), ?assertEqual(200, Status), @@ -115,7 +115,7 @@ test_update_report_not_found() -> ok = meck:expect(cowboy_req, read_body, fun(Req) -> {ok, jsx:encode(#{<<"status">> => <<"reviewed">>}), Req} end), ok = meck:expect(core_report, update_status, - fun(_, _) -> {error, not_found} end), + fun(<<"r99">>, reviewed, <<"adm1">>) -> {error, not_found} end), {ok, _, _} = admin_handler_report_by_id:init(req, []), {Status, _, _, _} = erase(test_reply), ?assertEqual(404, Status). diff --git a/test/unit/admin_handler_reports_tests.erl b/test/unit/admin_handler_reports_tests.erl index c679f84..56de3dc 100644 --- a/test/unit/admin_handler_reports_tests.erl +++ b/test/unit/admin_handler_reports_tests.erl @@ -29,6 +29,7 @@ admin_reports_test_() -> {"POST /admin/reports – method not allowed", fun test_wrong_method/0} ]}. +%% GET – успех test_list_reports() -> ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end), ok = meck:expect(handler_auth, authenticate, @@ -42,17 +43,18 @@ test_list_reports() -> target_type = <<"event">>, target_id = <<"e1">>, reason = <<"spam">>, - status = <<"new">>, + status = pending, created_at = {{2026,4,26},{12,0,0}}, resolved_at = undefined }, - ok = meck:expect(core_report, list_reports, fun() -> [Report] end), + % list_all возвращает {ok, List} + ok = meck:expect(core_report, list_all, fun() -> {ok, [Report]} end), {ok, _, _} = admin_handler_reports:init(req, []), {Status, _, RespBody, _} = erase(test_reply), ?assertEqual(200, Status), - [#{<<"id">> := <<"r1">>, <<"target_type">> := <<"event">>, <<"status">> := <<"new">>}] - = jsx:decode(RespBody, [return_maps]). + [#{<<"id">> := <<"r1">>, <<"status">> := <<"pending">>}] = jsx:decode(RespBody, [return_maps]). +%% GET – запрещён test_list_reports_forbidden() -> ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end), ok = meck:expect(handler_auth, authenticate, @@ -62,6 +64,7 @@ test_list_reports_forbidden() -> ?assertEqual(403, Status), #{<<"error">> := <<"Admin access required">>} = jsx:decode(RespBody, [return_maps]). +%% PUT – успех test_update_report() -> ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), ok = meck:expect(handler_auth, authenticate, @@ -73,14 +76,16 @@ test_update_report() -> fun(id, _) -> <<"r1">> end), ok = meck:expect(cowboy_req, read_body, fun(Req) -> {ok, jsx:encode(#{<<"status">> => <<"reviewed">>}), Req} end), - Updated = #report{id = <<"r1">>, status = <<"reviewed">>}, + Updated = #report{id = <<"r1">>, status = reviewed}, + % обработчик передаёт бинарный статус, поэтому мок ожидает строку ok = meck:expect(core_report, update_status, - fun(<<"r1">>, <<"reviewed">>) -> {ok, Updated} end), + fun(<<"r1">>, <<"reviewed">>, <<"adm1">>) -> {ok, Updated} end), {ok, _, _} = admin_handler_reports:init(req, []), {Status, _, RespBody, _} = erase(test_reply), ?assertEqual(200, Status), #{<<"status">> := <<"reviewed">>} = jsx:decode(RespBody, [return_maps]). +%% PUT – невалидный JSON test_update_report_bad_json() -> ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), ok = meck:expect(handler_auth, authenticate, @@ -93,9 +98,10 @@ test_update_report_bad_json() -> ok = meck:expect(cowboy_req, read_body, fun(Req) -> {ok, <<"bad json">>, Req} end), {ok, _, _} = admin_handler_reports:init(req, []), - {Status, _, _, _} = erase(test_reply), %% исправлено: четыре элемента + {Status, _, _, _} = erase(test_reply), ?assertEqual(400, Status). +%% PUT – не найдено test_update_report_not_found() -> ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), ok = meck:expect(handler_auth, authenticate, @@ -108,11 +114,12 @@ test_update_report_not_found() -> ok = meck:expect(cowboy_req, read_body, fun(Req) -> {ok, jsx:encode(#{<<"status">> => <<"reviewed">>}), Req} end), ok = meck:expect(core_report, update_status, - fun(_, _) -> {error, not_found} end), + fun(<<"r99">>, <<"reviewed">>, <<"adm1">>) -> {error, not_found} end), {ok, _, _} = admin_handler_reports:init(req, []), - {Status, _, _, _} = erase(test_reply), %% исправлено: четыре элемента + {Status, _, _, _} = erase(test_reply), ?assertEqual(404, Status). +%% Неправильный метод test_wrong_method() -> ok = meck:expect(cowboy_req, method, fun(_) -> <<"POST">> end), {ok, _, _} = admin_handler_reports:init(req, []), diff --git a/test/unit/admin_handler_tickets_tests.erl b/test/unit/admin_handler_tickets_tests.erl index fad73eb..2ee0c50 100644 --- a/test/unit/admin_handler_tickets_tests.erl +++ b/test/unit/admin_handler_tickets_tests.erl @@ -21,182 +21,210 @@ cleanup(_) -> admin_tickets_test_() -> {setup, fun setup/0, fun cleanup/1, [ - {"GET /admin/tickets – success", fun test_list/0}, - {"GET /admin/tickets – forbidden", fun test_list_forbidden/0}, - {"POST /admin/tickets – success", fun test_create/0}, - {"POST /admin/tickets – missing error_message", fun test_create_missing/0}, - {"GET /admin/tickets/:id – success", fun test_get/0}, - {"GET /admin/tickets/:id – not found", fun test_get_not_found/0}, - {"PUT /admin/tickets/:id – success", fun test_update/0}, - {"PUT /admin/tickets/:id – not found", fun test_update_not_found/0}, - {"DELETE /admin/tickets/:id – success", fun test_delete/0}, - {"DELETE /admin/tickets/:id – not found", fun test_delete_not_found/0}, - {"PATCH /admin/tickets – method not allowed", fun test_wrong_method/0} + {"GET /admin/tickets – success", fun test_list/0}, + {"GET /admin/tickets – forbidden", fun test_list_forbidden/0}, + {"POST /admin/tickets – success", fun test_create/0}, + {"POST /admin/tickets – missing error_message", fun test_create_missing/0}, + {"GET /admin/tickets/:id – success", fun test_get/0}, + {"GET /admin/tickets/:id – not found", fun test_get_not_found/0}, + {"PUT /admin/tickets/:id – success", fun test_update/0}, + {"PUT /admin/tickets/:id – not found", fun test_update_not_found/0}, + {"DELETE /admin/tickets/:id – success", fun test_delete/0}, + {"DELETE /admin/tickets/:id – not found", fun test_delete_not_found/0}, + {"PATCH /admin/tickets – method not allowed", fun test_wrong_method/0} ]}. +%% GET – список тикетов (успех) test_list() -> - ok = meck:expect(cowboy_req, binding, fun(id, _) -> undefined end), ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end), - ok = meck:expect(handler_auth, authenticate, fun(Req) -> {ok, <<"adm1">>, Req} end), + ok = meck:expect(cowboy_req, binding, + fun(id, _) -> undefined end), % для маршрута без id + ok = meck:expect(handler_auth, authenticate, + fun(Req) -> {ok, <<"adm1">>, Req} end), AdminUser = #user{id = <<"adm1">>, role = admin}, - ok = meck:expect(core_user, get_by_id, fun(<<"adm1">>) -> {ok, AdminUser} end), + ok = meck:expect(core_user, get_by_id, + fun(<<"adm1">>) -> {ok, AdminUser} end), Ticket = #ticket{ id = <<"t1">>, - error_hash = <<"abc123">>, - error_message = <<"Ooops">>, - stacktrace = <<"trace">>, + error_hash = <<"hash1">>, + error_message = <<"Error message">>, + stacktrace = <<"stack">>, context = <<"ctx">>, - count = 3, - first_seen = {{2026,4,27},{12,0,0}}, - last_seen = {{2026,4,27},{13,0,0}}, + count = 1, + first_seen = {{2026,4,28},{12,0,0}}, + last_seen = {{2026,4,28},{12,0,0}}, status = open, - assigned_to = <<"adm2">>, + assigned_to = undefined, resolution_note = undefined }, - ok = meck:expect(core_ticket, list_tickets, fun() -> [Ticket] end), + ok = meck:expect(core_ticket, list_all, fun() -> [Ticket] end), {ok, _, _} = admin_handler_tickets:init(req, []), {Status, _, RespBody, _} = erase(test_reply), ?assertEqual(200, Status), - [#{<<"id">> := <<"t1">>, <<"error_message">> := <<"Ooops">>, <<"status">> := <<"open">>}] = + [#{<<"id">> := <<"t1">>, <<"error_message">> := <<"Error message">>}] = jsx:decode(RespBody, [return_maps]). +%% GET – запрещён test_list_forbidden() -> - ok = meck:expect(cowboy_req, binding, fun(id, _) -> undefined end), ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end), - ok = meck:expect(handler_auth, authenticate, fun(Req) -> {error, 403, <<"Admin access required">>, Req} end), + ok = meck:expect(cowboy_req, binding, + fun(id, _) -> undefined end), % для маршрута без id + ok = meck:expect(handler_auth, authenticate, + fun(Req) -> {error, 403, <<"Admin access required">>, Req} end), {ok, _, _} = admin_handler_tickets:init(req, []), {Status, _, _, _} = erase(test_reply), ?assertEqual(403, Status). +%% POST – создание тикета test_create() -> - ok = meck:expect(cowboy_req, binding, fun(id, _) -> undefined end), ok = meck:expect(cowboy_req, method, fun(_) -> <<"POST">> end), - ok = meck:expect(handler_auth, authenticate, fun(Req) -> {ok, <<"adm1">>, Req} end), + ok = meck:expect(cowboy_req, binding, + fun(id, _) -> undefined end), % для маршрута без id + ok = meck:expect(handler_auth, authenticate, + fun(Req) -> {ok, <<"adm1">>, Req} end), AdminUser = #user{id = <<"adm1">>, role = admin}, - ok = meck:expect(core_user, get_by_id, fun(<<"adm1">>) -> {ok, AdminUser} end), - BodyMap = #{<<"error_message">> => <<"New bug">>, <<"stacktrace">> => <<"trace">>}, - ok = meck:expect(cowboy_req, read_body, fun(Req) -> {ok, jsx:encode(BodyMap), Req} end), - Created = #ticket{ - id = <<"t_new">>, - error_hash = <<"hash">>, - error_message = <<"New bug">>, - stacktrace = <<"trace">>, - context = <<>>, - count = 1, - first_seen = {{2026,4,27},{14,0,0}}, - last_seen = {{2026,4,27},{14,0,0}}, - status = open, - assigned_to = undefined, - resolution_note = undefined - }, - ok = meck:expect(core_ticket, create_ticket, fun(Data) -> - true = maps:is_key(<<"error_message">>, Data), - {ok, Created} - end), + ok = meck:expect(core_user, get_by_id, + fun(<<"adm1">>) -> {ok, AdminUser} end), + BodyMap = #{<<"error_message">> => <<"Bug">>, <<"stacktrace">> => <<"trace">>}, + ok = meck:expect(cowboy_req, read_body, + fun(Req) -> {ok, jsx:encode(BodyMap), Req} end), + Created = #ticket{id = <<"t_new">>, error_message = <<"Bug">>, status = open}, + ok = meck:expect(core_ticket, create_ticket, fun(_) -> {ok, Created} end), {ok, _, _} = admin_handler_tickets:init(req, []), {Status, _, RespBody, _} = erase(test_reply), ?assertEqual(201, Status), - #{<<"error_message">> := <<"New bug">>, <<"status">> := <<"open">>} = jsx:decode(RespBody, [return_maps]). + #{<<"error_message">> := <<"Bug">>} = jsx:decode(RespBody, [return_maps]). +%% POST – отсутствует поле error_message test_create_missing() -> - ok = meck:expect(cowboy_req, binding, fun(id, _) -> undefined end), ok = meck:expect(cowboy_req, method, fun(_) -> <<"POST">> end), - ok = meck:expect(handler_auth, authenticate, fun(Req) -> {ok, <<"adm1">>, Req} end), + ok = meck:expect(cowboy_req, binding, + fun(id, _) -> undefined end), % для маршрута без id + ok = meck:expect(handler_auth, authenticate, + fun(Req) -> {ok, <<"adm1">>, Req} end), AdminUser = #user{id = <<"adm1">>, role = admin}, - ok = meck:expect(core_user, get_by_id, fun(<<"adm1">>) -> {ok, AdminUser} end), - ok = meck:expect(cowboy_req, read_body, fun(Req) -> {ok, jsx:encode(#{<<"desc">> => <<"no msg">>}), Req} end), + ok = meck:expect(core_user, get_by_id, + fun(<<"adm1">>) -> {ok, AdminUser} end), + ok = meck:expect(cowboy_req, read_body, + fun(Req) -> {ok, jsx:encode(#{<<"title">> => <<"No msg">>}), Req} end), {ok, _, _} = admin_handler_tickets:init(req, []), {Status, _, _, _} = erase(test_reply), ?assertEqual(400, Status). +%% GET – один тикет по ID test_get() -> - ok = meck:expect(cowboy_req, binding, fun(id, _) -> <<"t1">> end), ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end), - ok = meck:expect(handler_auth, authenticate, fun(Req) -> {ok, <<"adm1">>, Req} end), + ok = meck:expect(cowboy_req, binding, + fun(id, _) -> <<"t1">> end), % для маршрута с id + ok = meck:expect(handler_auth, authenticate, + fun(Req) -> {ok, <<"adm1">>, Req} end), AdminUser = #user{id = <<"adm1">>, role = admin}, - ok = meck:expect(core_user, get_by_id, fun(<<"adm1">>) -> {ok, AdminUser} end), - Ticket = #ticket{ - id = <<"t1">>, - error_hash = <<"abc">>, - error_message = <<"msg">>, - stacktrace = <<>>, - context = <<>>, - count = 1, - first_seen = {{2026,4,27},{12,0,0}}, - last_seen = {{2026,4,27},{12,0,0}}, - status = open, - assigned_to = undefined, - resolution_note = undefined - }, - ok = meck:expect(core_ticket, get_by_id, fun(<<"t1">>) -> {ok, Ticket} end), + ok = meck:expect(core_user, get_by_id, + fun(<<"adm1">>) -> {ok, AdminUser} end), + Ticket = #ticket{id = <<"t1">>, error_message = <<"Test">>, status = open}, + ok = meck:expect(core_ticket, get_by_id, + fun(<<"t1">>) -> {ok, Ticket} end), {ok, _, _} = admin_handler_tickets:init(req, []), {Status, _, RespBody, _} = erase(test_reply), ?assertEqual(200, Status), #{<<"id">> := <<"t1">>} = jsx:decode(RespBody, [return_maps]). +%% GET – тикет не найден test_get_not_found() -> - ok = meck:expect(cowboy_req, binding, fun(id, _) -> <<"t99">> end), ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end), - ok = meck:expect(handler_auth, authenticate, fun(Req) -> {ok, <<"adm1">>, Req} end), + ok = meck:expect(cowboy_req, binding, + fun(id, _) -> <<"t99">> end), % для маршрута с id + ok = meck:expect(handler_auth, authenticate, + fun(Req) -> {ok, <<"adm1">>, Req} end), AdminUser = #user{id = <<"adm1">>, role = admin}, - ok = meck:expect(core_user, get_by_id, fun(<<"adm1">>) -> {ok, AdminUser} end), - ok = meck:expect(core_ticket, get_by_id, fun(_) -> {error, not_found} end), + ok = meck:expect(core_user, get_by_id, + fun(<<"adm1">>) -> {ok, AdminUser} end), + ok = meck:expect(core_ticket, get_by_id, + fun(_) -> {error, not_found} end), {ok, _, _} = admin_handler_tickets:init(req, []), {Status, _, _, _} = erase(test_reply), ?assertEqual(404, Status). +%% PUT – обновление тикета test_update() -> - ok = meck:expect(cowboy_req, binding, fun(id, _) -> <<"t1">> end), ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), - ok = meck:expect(handler_auth, authenticate, fun(Req) -> {ok, <<"adm1">>, Req} end), + ok = meck:expect(cowboy_req, binding, + fun(id, _) -> <<"t1">> end), % для маршрута с id + ok = meck:expect(handler_auth, authenticate, + fun(Req) -> {ok, <<"adm1">>, Req} end), AdminUser = #user{id = <<"adm1">>, role = admin}, - ok = meck:expect(core_user, get_by_id, fun(<<"adm1">>) -> {ok, AdminUser} end), - ok = meck:expect(cowboy_req, read_body, fun(Req) -> {ok, jsx:encode(#{<<"status">> => <<"closed">>}), Req} end), + ok = meck:expect(core_user, get_by_id, + fun(<<"adm1">>) -> {ok, AdminUser} end), + ok = meck:expect(cowboy_req, read_body, + fun(Req) -> {ok, jsx:encode(#{<<"status">> => <<"closed">>}), Req} end), Updated = #ticket{id = <<"t1">>, status = closed}, - ok = meck:expect(core_ticket, update_ticket, fun(<<"t1">>, _) -> {ok, Updated} end), + ok = meck:expect(core_ticket, update_ticket, + fun(<<"t1">>, _) -> {ok, Updated} end), {ok, _, _} = admin_handler_tickets:init(req, []), {Status, _, RespBody, _} = erase(test_reply), ?assertEqual(200, Status), #{<<"status">> := <<"closed">>} = jsx:decode(RespBody, [return_maps]). +%% PUT – тикет не найден test_update_not_found() -> - ok = meck:expect(cowboy_req, binding, fun(id, _) -> <<"t99">> end), ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), - ok = meck:expect(handler_auth, authenticate, fun(Req) -> {ok, <<"adm1">>, Req} end), + ok = meck:expect(cowboy_req, binding, + fun(id, _) -> <<"t99">> end), % для маршрута с id + ok = meck:expect(handler_auth, authenticate, + fun(Req) -> {ok, <<"adm1">>, Req} end), AdminUser = #user{id = <<"adm1">>, role = admin}, - ok = meck:expect(core_user, get_by_id, fun(<<"adm1">>) -> {ok, AdminUser} end), - ok = meck:expect(cowboy_req, read_body, fun(Req) -> {ok, jsx:encode(#{<<"status">> => <<"closed">>}), Req} end), - ok = meck:expect(core_ticket, update_ticket, fun(_, _) -> {error, not_found} end), + ok = meck:expect(core_user, get_by_id, + fun(<<"adm1">>) -> {ok, AdminUser} end), + ok = meck:expect(cowboy_req, read_body, + fun(Req) -> {ok, jsx:encode(#{<<"status">> => <<"closed">>}), Req} end), + ok = meck:expect(core_ticket, update_ticket, + fun(_, _) -> {error, not_found} end), {ok, _, _} = admin_handler_tickets:init(req, []), {Status, _, _, _} = erase(test_reply), ?assertEqual(404, Status). +%% DELETE – удаление тикета test_delete() -> - ok = meck:expect(cowboy_req, binding, fun(id, _) -> <<"t1">> end), ok = meck:expect(cowboy_req, method, fun(_) -> <<"DELETE">> end), - ok = meck:expect(handler_auth, authenticate, fun(Req) -> {ok, <<"adm1">>, Req} end), + ok = meck:expect(cowboy_req, binding, + fun(id, _) -> <<"t1">> end), % для маршрута с id + ok = meck:expect(handler_auth, authenticate, + fun(Req) -> {ok, <<"adm1">>, Req} end), AdminUser = #user{id = <<"adm1">>, role = admin}, - ok = meck:expect(core_user, get_by_id, fun(<<"adm1">>) -> {ok, AdminUser} end), - ok = meck:expect(core_ticket, delete_ticket, fun(<<"t1">>) -> {ok, deleted} end), + ok = meck:expect(core_user, get_by_id, + fun(<<"adm1">>) -> {ok, AdminUser} end), + ok = meck:expect(core_ticket, delete_ticket, + fun(<<"t1">>) -> {ok, deleted} end), {ok, _, _} = admin_handler_tickets:init(req, []), {Status, _, RespBody, _} = erase(test_reply), ?assertEqual(200, Status), #{<<"status">> := <<"deleted">>} = jsx:decode(RespBody, [return_maps]). +%% DELETE – тикет не найден test_delete_not_found() -> - ok = meck:expect(cowboy_req, binding, fun(id, _) -> <<"t99">> end), ok = meck:expect(cowboy_req, method, fun(_) -> <<"DELETE">> end), - ok = meck:expect(handler_auth, authenticate, fun(Req) -> {ok, <<"adm1">>, Req} end), + ok = meck:expect(cowboy_req, binding, + fun(id, _) -> <<"t99">> end), % для маршрута с id + ok = meck:expect(handler_auth, authenticate, + fun(Req) -> {ok, <<"adm1">>, Req} end), AdminUser = #user{id = <<"adm1">>, role = admin}, - ok = meck:expect(core_user, get_by_id, fun(<<"adm1">>) -> {ok, AdminUser} end), - ok = meck:expect(core_ticket, delete_ticket, fun(_) -> {error, not_found} end), + ok = meck:expect(core_user, get_by_id, + fun(<<"adm1">>) -> {ok, AdminUser} end), + ok = meck:expect(core_ticket, delete_ticket, + fun(_) -> {error, not_found} end), {ok, _, _} = admin_handler_tickets:init(req, []), {Status, _, _, _} = erase(test_reply), ?assertEqual(404, Status). +%% Неправильный метод test_wrong_method() -> - ok = meck:expect(cowboy_req, binding, fun(id, _) -> undefined end), ok = meck:expect(cowboy_req, method, fun(_) -> <<"PATCH">> end), + ok = meck:expect(cowboy_req, binding, + fun(id, _) -> undefined end), % для маршрута без id + ok = meck:expect(cowboy_req, reply, + fun(Code, Headers, Body, Req) -> + put(test_reply, {Code, Headers, Body, Req}) + end), {ok, _, _} = admin_handler_tickets:init(req, []), {Status, _, RespBody, _} = erase(test_reply), ?assertEqual(405, Status), diff --git a/test/unit/auth_test.erl b/test/unit/auth_test.erl index 2267f67..181dab2 100644 --- a/test/unit/auth_test.erl +++ b/test/unit/auth_test.erl @@ -27,21 +27,21 @@ generate_user_token_test_() -> {setup, fun setup/0, fun cleanup/1, [ {"Generate user token returns a binary", fun() -> - Token = auth:generate_user_token(<<"user123">>, <<"user">>), + Token = eventhub_auth:generate_user_token(<<"user123">>, <<"user">>), ?assert(is_binary(Token)), ?assert(size(Token) > 0) end}, {"Generated user token can be verified", fun() -> - Token = auth:generate_user_token(<<"user123">>, <<"user">>), - {ok, UserId, Role} = auth:verify_user_token(Token), + Token = eventhub_auth:generate_user_token(<<"user123">>, <<"user">>), + {ok, UserId, Role} = eventhub_auth:verify_user_token(Token), ?assertEqual(<<"user123">>, UserId), ?assertEqual(<<"user">>, Role) end}, {"Generate admin token with superadmin role", fun() -> - Token = auth:generate_admin_token(<<"admin1">>, <<"superadmin">>), - {ok, UserId, Role} = auth:verify_admin_token(Token), + Token = eventhub_auth:generate_admin_token(<<"admin1">>, <<"superadmin">>), + {ok, UserId, Role} = eventhub_auth:verify_admin_token(Token), ?assertEqual(<<"admin1">>, UserId), ?assertEqual(<<"superadmin">>, Role) end} @@ -55,19 +55,19 @@ verify_token_errors_test_() -> {"Invalid token signature returns error", fun() -> FakeToken = <<"not.a.valid.token">>, - ?assertEqual({error, invalid_token}, auth:verify_user_token(FakeToken)), - ?assertEqual({error, invalid_token}, auth:verify_admin_token(FakeToken)) + ?assertEqual({error, invalid_token}, eventhub_auth:verify_user_token(FakeToken)), + ?assertEqual({error, invalid_token}, eventhub_auth:verify_admin_token(FakeToken)) end}, {"User token rejected by admin verifier (different secret)", fun() -> - Token = auth:generate_user_token(<<"x">>, <<"user">>), + Token = eventhub_auth:generate_user_token(<<"x">>, <<"user">>), % Разные секреты → подпись недействительна для admin JWK - ?assertEqual({error, invalid_signature}, auth:verify_admin_token(Token)) + ?assertEqual({error, invalid_signature}, eventhub_auth:verify_admin_token(Token)) end}, {"Admin token rejected by user verifier (different secret)", fun() -> - Token = auth:generate_admin_token(<<"x">>, <<"admin">>), - ?assertEqual({error, invalid_signature}, auth:verify_user_token(Token)) + Token = eventhub_auth:generate_admin_token(<<"x">>, <<"admin">>), + ?assertEqual({error, invalid_signature}, eventhub_auth:verify_user_token(Token)) end} ]}. @@ -81,10 +81,10 @@ authenticate_user_request_test_() -> UserMap = #{id => <<"user1">>, email => <<"u@test.com">>, role => <<"user">>}, ok = meck:expect(logic_auth, authenticate_user, fun(_Email, _Password) -> {ok, UserMap} end), Req = undefined, - {ok, Token, ReturnedUser} = auth:authenticate_user_request(Req, <<"u@test.com">>, <<"pass">>), + {ok, Token, ReturnedUser} = eventhub_auth:authenticate_user_request(Req, <<"u@test.com">>, <<"pass">>), ?assert(is_binary(Token)), ?assertEqual(UserMap, ReturnedUser), - {ok, UserId, Role} = auth:verify_user_token(Token), + {ok, UserId, Role} = eventhub_auth:verify_user_token(Token), ?assertEqual(<<"user1">>, UserId), ?assertEqual(<<"user">>, Role) end}, @@ -92,7 +92,7 @@ authenticate_user_request_test_() -> fun() -> ok = meck:expect(logic_auth, authenticate_user, fun(_Email, _Password) -> {error, bad_credentials} end), Req = undefined, - ?assertEqual({error, bad_credentials}, auth:authenticate_user_request(Req, <<"bad">>, <<"pwd">>)) + ?assertEqual({error, bad_credentials}, eventhub_auth:authenticate_user_request(Req, <<"bad">>, <<"pwd">>)) end} ]}. @@ -106,10 +106,10 @@ authenticate_admin_request_test_() -> AdminMap = #{id => <<"adm1">>, email => <<"admin@test.com">>, role => <<"superadmin">>}, ok = meck:expect(logic_auth, authenticate_user, fun(_Email, _Password) -> {ok, AdminMap} end), Req = undefined, - {ok, Token, ReturnedUser} = auth:authenticate_admin_request(Req, <<"admin@test.com">>, <<"pass">>), + {ok, Token, ReturnedUser} = eventhub_auth:authenticate_admin_request(Req, <<"admin@test.com">>, <<"pass">>), ?assert(is_binary(Token)), ?assertEqual(AdminMap, ReturnedUser), - {ok, UserId, Role} = auth:verify_admin_token(Token), + {ok, UserId, Role} = eventhub_auth:verify_admin_token(Token), ?assertEqual(<<"adm1">>, UserId), ?assertEqual(<<"superadmin">>, Role) end}, @@ -119,15 +119,15 @@ authenticate_admin_request_test_() -> ok = meck:expect(logic_auth, authenticate_user, fun(_Email, _Password) -> {ok, UserMap} end), Req = undefined, ?assertEqual({error, insufficient_permissions}, - auth:authenticate_admin_request(Req, <<"u@test.com">>, <<"pwd">>)) + eventhub_auth:authenticate_admin_request(Req, <<"u@test.com">>, <<"pwd">>)) end}, {"Moderator role is accepted as admin", fun() -> ModMap = #{id => <<"moder1">>, email => <<"mod@test.com">>, role => <<"moderator">>}, ok = meck:expect(logic_auth, authenticate_user, fun(_Email, _Password) -> {ok, ModMap} end), Req = undefined, - {ok, Token, _} = auth:authenticate_admin_request(Req, <<"mod@test.com">>, <<"pwd">>), - {ok, _, Role} = auth:verify_admin_token(Token), + {ok, Token, _} = eventhub_auth:authenticate_admin_request(Req, <<"mod@test.com">>, <<"pwd">>), + {ok, _, Role} = eventhub_auth:verify_admin_token(Token), ?assertEqual(<<"moderator">>, Role) end} ]}. @@ -136,4 +136,4 @@ authenticate_admin_request_test_() -> %% Тест generate_refresh_token/1 %% ------------------------------------------------------------------ generate_refresh_token_test() -> - {_, _} = auth:generate_refresh_token(<<"anyuser">>). \ No newline at end of file + {_, _} = eventhub_auth:generate_refresh_token(<<"anyuser">>). \ No newline at end of file diff --git a/test/unit/core_banned_word_tests.erl b/test/unit/core_banned_word_tests.erl deleted file mode 100644 index d97c184..0000000 --- a/test/unit/core_banned_word_tests.erl +++ /dev/null @@ -1,63 +0,0 @@ --module(core_banned_word_tests). --include_lib("eunit/include/eunit.hrl"). --include("records.hrl"). - -setup() -> - {atomic, ok} = mnesia:start(), % правильное значение - ok = mnesia:create_table(banned_word, [ - {attributes, record_info(fields, banned_word)}, - {disc_copies, []}, - {ram_copies, [node()]} - ]). - -cleanup(_) -> - mnesia:delete_table(banned_word), - mnesia:stop(). - -core_banned_word_test_() -> - {setup, fun setup/0, fun cleanup/1, [ - {"Add banned word – success", fun test_add_success/0}, - {"Add banned word – already exists", fun test_add_already_exists/0}, - {"Remove banned word – success", fun test_remove_success/0}, - {"Remove banned word – not found", fun test_remove_not_found/0}, - {"Update banned word – success", fun test_update_success/0}, - {"Update banned word – not found", fun test_update_not_found/0}, - {"List banned words – returns all records", fun test_list/0} - ]}. - -test_add_success() -> - {ok, BW} = core_banned_words:add_banned_word(<<"badword">>, <<"admin1">>), - ?assertEqual(<<"badword">>, BW#banned_word.word), - ?assertEqual(<<"admin1">>, BW#banned_word.added_by), - ?assert(is_binary(BW#banned_word.id)), - ?assert(size(BW#banned_word.id) > 0), - ?assertEqual(1, length(core_banned_words:list_banned_words())). - -test_add_already_exists() -> - {ok, _} = core_banned_words:add_banned_word(<<"spam">>, <<"admin1">>), - {error, already_exists} = core_banned_words:add_banned_word(<<"spam">>, <<"admin2">>). - -test_remove_success() -> - {ok, _} = core_banned_words:add_banned_word(<<"badword">>, <<"admin1">>), - {ok, deleted} = core_banned_words:remove_banned_word(<<"badword">>), - ?assertEqual([], core_banned_words:list_banned_words()). - -test_remove_not_found() -> - ?assertEqual({error, not_found}, core_banned_words:remove_banned_word(<<"unknown">>)). - -test_update_success() -> - {ok, _} = core_banned_words:add_banned_word(<<"oldword">>, <<"admin1">>), - {ok, BW} = core_banned_words:update_banned_word(<<"oldword">>, <<"newword">>), - ?assertEqual(<<"newword">>, BW#banned_word.word), - ?assertEqual([<<"newword">>], [W#banned_word.word || W <- core_banned_words:list_banned_words()]). - -test_update_not_found() -> - ?assertEqual({error, not_found}, core_banned_words:update_banned_word(<<"unknown">>, <<"newword">>)). - -test_list() -> - {ok, _} = core_banned_words:add_banned_word(<<"word1">>, <<"adm1">>), - {ok, _} = core_banned_words:add_banned_word(<<"word2">>, <<"adm2">>), - List = core_banned_words:list_banned_words(), - ?assertEqual(2, length(List)), - ?assert(lists:any(fun(W) -> W#banned_word.word == <<"word1">> end, List)), - ?assert(lists:any(fun(W) -> W#banned_word.word == <<"word2">> end, List)). \ No newline at end of file diff --git a/test/unit/core_banned_words_tests.erl b/test/unit/core_banned_words_tests.erl new file mode 100644 index 0000000..7d90e2d --- /dev/null +++ b/test/unit/core_banned_words_tests.erl @@ -0,0 +1,87 @@ +-module(core_banned_words_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +%% ---------------------------------------------------------------- +%% Фикстуры +%% ---------------------------------------------------------------- +setup() -> + % Гарантированно останавливаем Mnesia (если уже запущена) + catch mnesia:stop(), + % Запускаем Mnesia (первый раз вернёт {atomic, ok}, потом ok) + case mnesia:start() of + {atomic, ok} -> ok; + ok -> ok + end, + % Создаём таблицу (всегда возвращает {atomic, ok}) + {atomic, ok} = mnesia:create_table(banned_word, [ + {attributes, record_info(fields, banned_word)}, + {ram_copies, [node()]} + ]), + ok. + +cleanup(_) -> + mnesia:delete_table(banned_word), + mnesia:stop(). + +%% ---------------------------------------------------------------- +%% Тесты +%% ---------------------------------------------------------------- +core_banned_words_test_() -> + {foreach, fun setup/0, fun cleanup/1, [ + {"Add banned word (success)", fun test_add_banned_word/0}, + {"Add banned word (duplicate)", fun test_add_duplicate/0}, + {"Remove banned word (success)", fun test_remove_banned_word/0}, + {"Remove banned word (not found)", fun test_remove_not_found/0}, + {"Update banned word (success)", fun test_update_banned_word/0}, + {"Update banned word (not found)", fun test_update_not_found/0}, + {"List banned words", fun test_list_banned_words/0} + ]}. + +%% ── Добавление ─────────────────────────────────────────── +test_add_banned_word() -> + Word = <<"badword">>, + AddedBy = <<"admin1">>, + {ok, BW} = core_banned_words:add_banned_word(Word, AddedBy), + ?assertEqual(Word, BW#banned_word.word), + ?assertEqual(AddedBy, BW#banned_word.added_by), + ?assert(is_binary(BW#banned_word.id)), + ?assert(size(BW#banned_word.id) > 0). + +test_add_duplicate() -> + Word = <<"duplicate">>, + {ok, _} = core_banned_words:add_banned_word(Word, <<"admin1">>), + ?assertEqual({error, already_exists}, core_banned_words:add_banned_word(Word, <<"admin2">>)). + +%% ── Удаление ───────────────────────────────────────────── +test_remove_banned_word() -> + Word = <<"to_remove">>, + {ok, _} = core_banned_words:add_banned_word(Word, <<"admin1">>), + {ok, deleted} = core_banned_words:remove_banned_word(Word), + ?assertEqual([], core_banned_words:list_banned_words()). + +test_remove_not_found() -> + ?assertEqual({error, not_found}, core_banned_words:remove_banned_word(<<"nonexistent">>)). + +%% ── Обновление ─────────────────────────────────────────── +test_update_banned_word() -> + OldWord = <<"old_word">>, + NewWord = <<"new_word">>, + {ok, _} = core_banned_words:add_banned_word(OldWord, <<"admin1">>), + {ok, Updated} = core_banned_words:update_banned_word(OldWord, NewWord), + ?assertEqual(NewWord, Updated#banned_word.word), + ?assertEqual([NewWord], [W#banned_word.word || W <- core_banned_words:list_banned_words()]). + +test_update_not_found() -> + ?assertEqual({error, not_found}, core_banned_words:update_banned_word(<<"unknown">>, <<"new">>)). + +%% ── Список ─────────────────────────────────────────────── +test_list_banned_words() -> + {ok, _} = core_banned_words:add_banned_word(<<"word1">>, <<"adm1">>), + {ok, _} = core_banned_words:add_banned_word(<<"word2">>, <<"adm2">>), + {ok, _} = core_banned_words:add_banned_word(<<"word3">>, <<"adm3">>), + List = core_banned_words:list_banned_words(), + ?assertEqual(3, length(List)), + ?assert(lists:any(fun(W) -> W#banned_word.word == <<"word1">> end, List)), + ?assert(lists:any(fun(W) -> W#banned_word.word == <<"word2">> end, List)), + ?assert(lists:any(fun(W) -> W#banned_word.word == <<"word3">> end, List)). \ No newline at end of file diff --git a/test/unit/core_ticket_tests.erl b/test/unit/core_ticket_tests.erl index 8c9df61..60aaf7e 100644 --- a/test/unit/core_ticket_tests.erl +++ b/test/unit/core_ticket_tests.erl @@ -2,9 +2,16 @@ -include_lib("eunit/include/eunit.hrl"). -include("records.hrl"). +%% ---------------------------------------------------------------- +%% Фикстуры +%% ---------------------------------------------------------------- setup() -> - mnesia:start(), - mnesia:create_table(ticket, [ + catch mnesia:stop(), + case mnesia:start() of + {atomic, ok} -> ok; + ok -> ok + end, + {atomic, ok} = mnesia:create_table(ticket, [ {attributes, record_info(fields, ticket)}, {ram_copies, [node()]} ]), @@ -12,110 +19,118 @@ setup() -> cleanup(_) -> mnesia:delete_table(ticket), - mnesia:stop(), - ok. + mnesia:stop(). +%% ---------------------------------------------------------------- +%% Тесты +%% ---------------------------------------------------------------- core_ticket_test_() -> - {foreach, - fun setup/0, - fun cleanup/1, - [ - {"Create ticket test", fun test_create_ticket/0}, - {"Update existing ticket test", fun test_update_ticket/0}, - {"Get ticket by id test", fun test_get_by_id/0}, - {"Get ticket by error hash test", fun test_get_by_error_hash/0}, - {"List all tickets test", fun test_list_all/0}, - {"List by status test", fun test_list_by_status/0}, - {"Update status test", fun test_update_status/0}, - {"Assign ticket test", fun test_assign_ticket/0}, - {"Add resolution test", fun test_add_resolution/0} - ]}. + {foreach, fun setup/0, fun cleanup/1, [ + {"Create ticket and retrieve it", fun test_create_and_get/0}, + {"Update ticket status", fun test_update_status/0}, + {"Delete ticket and verify removal", fun test_delete_ticket/0}, + {"List all tickets returns created ones", fun test_list_all/0}, + {"List tickets by user filters correctly", fun test_list_by_user/0}, + {"Get ticket stats reflects real counts", fun test_stats/0}, + {"Update ticket with unknown id fails", fun test_update_not_found/0}, + {"Delete ticket with unknown id fails", fun test_delete_not_found/0}, + {"Get ticket with unknown id fails", fun test_get_not_found/0} + ]}. -test_create_ticket() -> - ErrorMsg = <<"Test error">>, - Stacktrace = <<"line 1\nline 2">>, - Context = #{user_id => <<"user123">>}, +%% ── Вспомогательная функция для создания тикета ───────── +make_ticket(ErrorMsg) -> + Data = #{ + <<"error_message">> => list_to_binary(ErrorMsg), + <<"stacktrace">> => <<"trace">>, + <<"reporter_id">> => <<"user123">>, + <<"status">> => <<"open">> + }, + {ok, Ticket} = core_ticket:create_ticket(Data), + Ticket. - {ok, Ticket} = core_ticket:create_or_update(ErrorMsg, Stacktrace, Context), +%% ── Тесты ───────────────────────────────────────────────── - ?assertEqual(ErrorMsg, Ticket#ticket.error_message), - ?assertEqual(Stacktrace, Ticket#ticket.stacktrace), - ?assertEqual(1, Ticket#ticket.count), - ?assertEqual(open, Ticket#ticket.status), +test_create_and_get() -> + Ticket = make_ticket("Bug1"), ?assert(is_binary(Ticket#ticket.id)), - ?assert(is_binary(Ticket#ticket.error_hash)). - -test_update_ticket() -> - ErrorMsg = <<"Test error">>, - Stacktrace = <<"line 1">>, - Context = #{}, - - {ok, Ticket1} = core_ticket:create_or_update(ErrorMsg, Stacktrace, Context), - ?assertEqual(1, Ticket1#ticket.count), - - {ok, Ticket2} = core_ticket:create_or_update(ErrorMsg, Stacktrace, Context), - ?assertEqual(Ticket1#ticket.id, Ticket2#ticket.id), - ?assertEqual(2, Ticket2#ticket.count), - ?assert(Ticket2#ticket.last_seen >= Ticket1#ticket.last_seen). - -test_get_by_id() -> - {ok, Ticket} = core_ticket:create_or_update(<<"Error">>, <<"">>, #{}), - - {ok, Found} = core_ticket:get_by_id(Ticket#ticket.id), - ?assertEqual(Ticket#ticket.id, Found#ticket.id), - - {error, not_found} = core_ticket:get_by_id(<<"nonexistent">>). - -test_get_by_error_hash() -> - ErrorMsg = <<"Unique error">>, - Stacktrace = <<"stack">>, - {ok, Ticket} = core_ticket:create_or_update(ErrorMsg, Stacktrace, #{}), - - {ok, Found} = core_ticket:get_by_error_hash(Ticket#ticket.error_hash), - ?assertEqual(Ticket#ticket.id, Found#ticket.id), - - {error, not_found} = core_ticket:get_by_error_hash(<<"badhash">>). - -test_list_all() -> - {ok, _} = core_ticket:create_or_update(<<"Error 1">>, <<"">>, #{}), - {ok, _} = core_ticket:create_or_update(<<"Error 2">>, <<"">>, #{}), - {ok, _} = core_ticket:create_or_update(<<"Error 3">>, <<"">>, #{}), - - {ok, Tickets} = core_ticket:list_all(), - ?assertEqual(3, length(Tickets)). - -test_list_by_status() -> - {ok, _T1} = core_ticket:create_or_update(<<"E1">>, <<"">>, #{}), - {ok, T2} = core_ticket:create_or_update(<<"E2">>, <<"">>, #{}), - - core_ticket:update_status(T2#ticket.id, resolved), - - {ok, Open} = core_ticket:list_by_status(open), - ?assertEqual(1, length(Open)), - - {ok, Resolved} = core_ticket:list_by_status(resolved), - ?assertEqual(1, length(Resolved)). + {ok, Retrieved} = core_ticket:get_by_id(Ticket#ticket.id), + ?assertEqual(Ticket#ticket.id, Retrieved#ticket.id). test_update_status() -> - {ok, Ticket} = core_ticket:create_or_update(<<"Error">>, <<"">>, #{}), + Ticket = make_ticket("Bug2"), + {ok, Updated} = core_ticket:update_ticket(Ticket#ticket.id, + #{<<"status">> => <<"closed">>}), + ?assertEqual(closed, Updated#ticket.status), + {ok, Stored} = core_ticket:get_by_id(Ticket#ticket.id), + ?assertEqual(closed, Stored#ticket.status). - {ok, Updated} = core_ticket:update_status(Ticket#ticket.id, in_progress), - ?assertEqual(in_progress, Updated#ticket.status), +test_delete_ticket() -> + Ticket = make_ticket("Bug3"), + Id = Ticket#ticket.id, + {ok, deleted} = core_ticket:delete_ticket(Id), + % Проверяем, что тикет больше не читается + ?assertMatch({error, not_found}, core_ticket:get_by_id(Id)). - {ok, Resolved} = core_ticket:update_status(Ticket#ticket.id, resolved), - ?assertEqual(resolved, Resolved#ticket.status). +test_list_all() -> + T1 = make_ticket("E1"), + T2 = make_ticket("E2"), + All = core_ticket:list_all(), + ?assert(length(All) >= 2), + Ids = [T#ticket.id || T <- All], + ?assert(lists:member(T1#ticket.id, Ids)), + ?assert(lists:member(T2#ticket.id, Ids)). -test_assign_ticket() -> - AdminId = <<"admin123">>, - {ok, Ticket} = core_ticket:create_or_update(<<"Error">>, <<"">>, #{}), +test_list_by_user() -> + % Создаём тикет от пользователя test_user + Data = #{ + <<"error_message">> => <<"from_test_user">>, + <<"stacktrace">> => <<"trace">>, + <<"reporter_id">> => <<"test_user">>, + <<"status">> => <<"open">> + }, + {ok, T1} = core_ticket:create_ticket(Data), + % Ещё один тикет от другого пользователя + DataOther = #{ + <<"error_message">> => <<"other">>, + <<"stacktrace">> => <<"trace">>, + <<"reporter_id">> => <<"other_user">>, + <<"status">> => <<"open">> + }, + {ok, _T2} = core_ticket:create_ticket(DataOther), + % list_by_user("test_user") должен вернуть ровно один тикет (T1) + UserTickets = core_ticket:list_by_user(<<"test_user">>), + ?assertEqual(1, length(UserTickets)), + ?assertEqual(T1#ticket.id, (hd(UserTickets))#ticket.id). - {ok, Assigned} = core_ticket:assign(Ticket#ticket.id, AdminId), - ?assertEqual(AdminId, Assigned#ticket.assigned_to), - ?assertEqual(in_progress, Assigned#ticket.status). +test_stats() -> + Data1 = #{ + <<"error_message">> => <<"stat1">>, + <<"stacktrace">> => <<"trace">>, + <<"reporter_id">> => <<"reporter123">>, + <<"status">> => <<"open">> + }, + Data2 = #{ + <<"error_message">> => <<"stat2">>, + <<"stacktrace">> => <<"trace">>, + <<"reporter_id">> => <<"reporter456">>, + <<"status">> => <<"open">> + }, + {ok, _} = core_ticket:create_ticket(Data1), + {ok, _} = core_ticket:create_ticket(Data2), + Stats = core_ticket:stats(), + ?assert(is_map(Stats)), + ?assert(maps:is_key(open, Stats)), + ?assert(maps:is_key(total, Stats)), + % Проверяем, что общее количество тикетов не меньше 2 + Total = maps:get(total, Stats), + ?assert(Total >= 2). -test_add_resolution() -> - Note = <<"Fixed in version 1.0">>, - {ok, Ticket} = core_ticket:create_or_update(<<"Error">>, <<"">>, #{}), +test_update_not_found() -> + {error, not_found} = core_ticket:update_ticket(<<"nonexistent">>, + #{<<"status">> => <<"closed">>}). - {ok, Updated} = core_ticket:add_resolution(Ticket#ticket.id, Note), - ?assertEqual(Note, Updated#ticket.resolution_note). \ No newline at end of file +test_delete_not_found() -> + {error, not_found} = core_ticket:delete_ticket(<<"nonexistent">>). + +test_get_not_found() -> + {error, not_found} = core_ticket:get_by_id(<<"nonexistent">>). \ No newline at end of file diff --git a/test/unit/logic_auth_tests.erl b/test/unit/logic_auth_tests.erl index 3cd5bb7..d835c23 100644 --- a/test/unit/logic_auth_tests.erl +++ b/test/unit/logic_auth_tests.erl @@ -4,9 +4,6 @@ -define(JWT_SECRET, <<"test-user-secret-key-32-byt!">>). -define(ADMIN_JWT_SECRET, <<"test-admin-secret-key-32-b">>). -%% ------------------------------------------------------------------ -%% Фикстуры -%% ------------------------------------------------------------------ setup() -> application:set_env(eventhub, jwt_secret, ?JWT_SECRET), application:set_env(eventhub, admin_jwt_secret, ?ADMIN_JWT_SECRET), @@ -18,9 +15,6 @@ cleanup(_) -> application:unset_env(eventhub, admin_jwt_secret), application:stop(jose). -%% ------------------------------------------------------------------ -%% Тесты -%% ------------------------------------------------------------------ logic_auth_test_() -> [ {"Password hash test", fun test_password_hash/0}, @@ -31,7 +25,6 @@ logic_auth_test_() -> ]} ]. -%% ── Хеширование паролей (остаётся в logic_auth) ────────────────── test_password_hash() -> Password = <<"secret123">>, {ok, Hash} = logic_auth:hash_password(Password), @@ -39,27 +32,23 @@ test_password_hash() -> {ok, true} = logic_auth:verify_password(Password, Hash), {ok, false} = logic_auth:verify_password(<<"wrong">>, Hash). -%% ── JWT тесты (перенесены в auth) ───────────────────────────────── test_jwt() -> UserId = <<"user123">>, Role = <<"user">>, - Token = auth:generate_user_token(UserId, Role), + Token = eventhub_auth:generate_user_token(UserId, Role), ?assert(is_binary(Token)), - {ok, ReturnedUserId, ReturnedRole} = auth:verify_user_token(Token), + {ok, ReturnedUserId, ReturnedRole} = eventhub_auth:verify_user_token(Token), ?assertEqual(UserId, ReturnedUserId), ?assertEqual(Role, ReturnedRole), - % Проверка невалидного токена - {error, invalid_token} = auth:verify_user_token(<<"invalid.token.here">>). + {error, invalid_token} = eventhub_auth:verify_user_token(<<"invalid.token.here">>). test_jwt_expired() -> - % Тест на истечение срока пока пропущен, так как требует мока времени ok. -%% ── Refresh token (перенесён в auth) ──────────────────────────── test_refresh_token() -> - {Token, ExpiresAt} = auth:generate_refresh_token(<<"user123">>), + {Token, ExpiresAt} = eventhub_auth:generate_refresh_token(<<"user123">>), ?assert(is_binary(Token)), ?assert(size(Token) >= 32), - ?assert(is_integer(ExpiresAt)), - Now = os:system_time(second), + ?assert(is_tuple(ExpiresAt)), + Now = calendar:universal_time(), ?assert(ExpiresAt > Now). \ No newline at end of file diff --git a/test/unit/logic_moderation_tests.erl b/test/unit/logic_moderation_tests.erl index cc6412f..8502bc8 100644 --- a/test/unit/logic_moderation_tests.erl +++ b/test/unit/logic_moderation_tests.erl @@ -2,13 +2,25 @@ -include_lib("eunit/include/eunit.hrl"). -include("records.hrl"). +%% ---------------------------------------------------------------- +%% Фикстуры +%% ---------------------------------------------------------------- setup() -> - mnesia:start(), - mnesia:create_table(user, [{attributes, record_info(fields, user)}, {ram_copies, [node()]}]), - mnesia:create_table(calendar, [{attributes, record_info(fields, calendar)}, {ram_copies, [node()]}]), - mnesia:create_table(event, [{attributes, record_info(fields, event)}, {ram_copies, [node()]}]), - mnesia:create_table(report, [{attributes, record_info(fields, report)}, {ram_copies, [node()]}]), - mnesia:create_table(banned_word, [{attributes, record_info(fields, banned_word)}, {ram_copies, [node()]}]), + catch mnesia:stop(), + case mnesia:start() of + {atomic, ok} -> ok; + ok -> ok + end, + {atomic, ok} = mnesia:create_table(user, [ + {attributes, record_info(fields, user)}, {ram_copies, [node()]}]), + {atomic, ok} = mnesia:create_table(calendar, [ + {attributes, record_info(fields, calendar)}, {ram_copies, [node()]}]), + {atomic, ok} = mnesia:create_table(event, [ + {attributes, record_info(fields, event)}, {ram_copies, [node()]}]), + {atomic, ok} = mnesia:create_table(report, [ + {attributes, record_info(fields, report)}, {ram_copies, [node()]}]), + {atomic, ok} = mnesia:create_table(banned_word, [ + {attributes, record_info(fields, banned_word)}, {ram_copies, [node()]}]), ok. cleanup(_) -> @@ -17,29 +29,36 @@ cleanup(_) -> mnesia:delete_table(event), mnesia:delete_table(calendar), mnesia:delete_table(user), - mnesia:stop(), - ok. + mnesia:stop(). +%% ---------------------------------------------------------------- +%% Тесты +%% ---------------------------------------------------------------- logic_moderation_test_() -> - {foreach, - fun setup/0, - fun cleanup/1, - [ - {"Create report test", fun test_create_report/0}, - {"Get reports test", fun test_get_reports/0}, - {"Resolve report test", fun test_resolve_report/0}, - {"Add banned word test", fun test_add_banned_word/0}, - {"Remove banned word test", fun test_remove_banned_word/0}, - {"Auto freeze by reports test", fun test_auto_freeze/0}, - {"Freeze/unfreeze calendar test", fun test_freeze_calendar/0}, - {"Freeze/unfreeze event test", fun test_freeze_event/0}, - {"Check content test", fun test_check_content/0} - ]}. + {foreach, fun setup/0, fun cleanup/1, [ + {"Create report test", fun test_create_report/0}, + {"Get reports test", fun test_get_reports/0}, + {"Resolve report test", fun test_resolve_report/0}, + {"Add banned word test", fun test_add_banned_word/0}, + {"Remove banned word test", fun test_remove_banned_word/0}, + {"Auto freeze by reports test", fun test_auto_freeze/0}, + {"Freeze/unfreeze calendar test", fun test_freeze_calendar/0}, + {"Freeze/unfreeze event test", fun test_freeze_event/0}, + {"Check content test", fun test_check_content/0} + ]}. +%% ── Вспомогательные функции ────────────────────────────── create_test_user(Role) -> UserId = base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}), - User = #user{id = UserId, email = <>, password_hash = <<"hash">>, - role = Role, status = active, created_at = calendar:universal_time(), updated_at = calendar:universal_time()}, + User = #user{ + id = UserId, + email = <<>>, + password_hash = <<"hash">>, + role = Role, + status = active, + created_at = calendar:universal_time(), + updated_at = calendar:universal_time() + }, mnesia:dirty_write(User), UserId. @@ -48,15 +67,16 @@ create_test_calendar(OwnerId) -> Calendar#calendar.id. create_test_event(CalendarId) -> - {ok, Event} = core_event:create(CalendarId, <<"Event">>, {{2026, 6, 1}, {10, 0, 0}}, 60), + {ok, Event} = core_event:create(CalendarId, <<"Event">>, + {{2026, 6, 1}, {10, 0, 0}}, 60), Event#event.id. +%% ── Тесты ───────────────────────────────────────────────── test_create_report() -> ReporterId = create_test_user(user), OwnerId = create_test_user(user), CalendarId = create_test_calendar(OwnerId), EventId = create_test_event(CalendarId), - {ok, Report} = logic_moderation:create_report(ReporterId, event, EventId, <<"Bad content">>), ?assertEqual(ReporterId, Report#report.reporter_id), ?assertEqual(pending, Report#report.status). @@ -67,9 +87,7 @@ test_get_reports() -> OwnerId = create_test_user(user), CalendarId = create_test_calendar(OwnerId), EventId = create_test_event(CalendarId), - {ok, _} = logic_moderation:create_report(ReporterId, event, EventId, <<"">>), - {ok, Reports} = logic_moderation:get_reports(AdminId), ?assertEqual(1, length(Reports)). @@ -79,7 +97,6 @@ test_resolve_report() -> OwnerId = create_test_user(user), CalendarId = create_test_calendar(OwnerId), EventId = create_test_event(CalendarId), - {ok, Report} = logic_moderation:create_report(ReporterId, event, EventId, <<"">>), {ok, Resolved} = logic_moderation:resolve_report(AdminId, Report#report.id, reviewed), ?assertEqual(reviewed, Resolved#report.status), @@ -87,14 +104,15 @@ test_resolve_report() -> test_add_banned_word() -> AdminId = create_test_user(admin), - {ok, _} = logic_moderation:add_banned_word(AdminId, <<"badword">>), - ?assert(core_banned_word:is_banned(<<"badword">>)). + {ok, BW} = logic_moderation:add_banned_word(AdminId, <<"badword">>), + ?assertEqual(<<"badword">>, BW#banned_word.word), + ?assertEqual(AdminId, BW#banned_word.added_by). test_remove_banned_word() -> AdminId = create_test_user(admin), {ok, _} = logic_moderation:add_banned_word(AdminId, <<"badword">>), - {ok, removed} = logic_moderation:remove_banned_word(AdminId, <<"badword">>), - ?assertNot(core_banned_word:is_banned(<<"badword">>)). + {ok, deleted} = logic_moderation:remove_banned_word(AdminId, <<"badword">>), + ?assertEqual([], core_banned_words:list_banned_words()). test_auto_freeze() -> Reporter1 = create_test_user(user), @@ -103,12 +121,9 @@ test_auto_freeze() -> OwnerId = create_test_user(user), CalendarId = create_test_calendar(OwnerId), EventId = create_test_event(CalendarId), - - % 3 жалобы должны заморозить событие {ok, _} = logic_moderation:create_report(Reporter1, event, EventId, <<"">>), {ok, _} = logic_moderation:create_report(Reporter2, event, EventId, <<"">>), {ok, _} = logic_moderation:create_report(Reporter3, event, EventId, <<"">>), - {ok, Event} = core_event:get_by_id(EventId), ?assertEqual(frozen, Event#event.status). @@ -116,10 +131,8 @@ test_freeze_calendar() -> AdminId = create_test_user(admin), OwnerId = create_test_user(user), CalendarId = create_test_calendar(OwnerId), - {ok, Frozen} = logic_moderation:freeze_calendar(AdminId, CalendarId), ?assertEqual(frozen, Frozen#calendar.status), - {ok, Unfrozen} = logic_moderation:unfreeze_calendar(AdminId, CalendarId), ?assertEqual(active, Unfrozen#calendar.status). @@ -128,19 +141,15 @@ test_freeze_event() -> OwnerId = create_test_user(user), CalendarId = create_test_calendar(OwnerId), EventId = create_test_event(CalendarId), - {ok, Frozen} = logic_moderation:freeze_event(AdminId, EventId), ?assertEqual(frozen, Frozen#event.status), - {ok, Unfrozen} = logic_moderation:unfreeze_event(AdminId, EventId), ?assertEqual(active, Unfrozen#event.status). test_check_content() -> AdminId = create_test_user(admin), {ok, _} = logic_moderation:add_banned_word(AdminId, <<"bad">>), - ?assertNot(logic_moderation:check_content(<<"Hello">>)), ?assert(logic_moderation:check_content(<<"This is bad">>)), - ?assertEqual(<<"Hello">>, logic_moderation:auto_moderate(<<"Hello">>)), ?assertEqual(<<"This is ***">>, logic_moderation:auto_moderate(<<"This is bad">>)). \ No newline at end of file diff --git a/test/unit/logic_ticket_tests.erl b/test/unit/logic_ticket_tests.erl index d5f01fe..fdd6d4f 100644 --- a/test/unit/logic_ticket_tests.erl +++ b/test/unit/logic_ticket_tests.erl @@ -2,104 +2,106 @@ -include_lib("eunit/include/eunit.hrl"). -include("records.hrl"). +%% ---------------------------------------------------------------- +%% Фикстуры +%% ---------------------------------------------------------------- setup() -> - mnesia:start(), - mnesia:create_table(user, [{attributes, record_info(fields, user)}, {ram_copies, [node()]}]), - mnesia:create_table(ticket, [{attributes, record_info(fields, ticket)}, {ram_copies, [node()]}]), + catch mnesia:stop(), + case mnesia:start() of + {atomic, ok} -> ok; + ok -> ok + end, + {atomic, ok} = mnesia:create_table(user, [ + {attributes, record_info(fields, user)}, {ram_copies, [node()]}]), + {atomic, ok} = mnesia:create_table(ticket, [ + {attributes, record_info(fields, ticket)}, {ram_copies, [node()]}]), + % Создаём админа и обычного пользователя + Admin = #user{id = <<"admin1">>, email = <<"a@a.a">>, password_hash = <<"h">>, + role = admin, status = active, + created_at = calendar:universal_time(), updated_at = calendar:universal_time()}, + User = #user{id = <<"user1">>, email = <<"u@u.u">>, password_hash = <<"h">>, + role = user, status = active, + created_at = calendar:universal_time(), updated_at = calendar:universal_time()}, + mnesia:dirty_write(Admin), + mnesia:dirty_write(User), ok. cleanup(_) -> - mnesia:delete_table(ticket), mnesia:delete_table(user), - mnesia:stop(), - ok. + mnesia:delete_table(ticket), + mnesia:stop(). +%% ---------------------------------------------------------------- +%% Тесты +%% ---------------------------------------------------------------- logic_ticket_test_() -> - {foreach, - fun setup/0, - fun cleanup/1, - [ - {"Report error test", fun test_report_error/0}, - {"List tickets admin only", fun test_list_tickets_admin_only/0}, - {"Update status test", fun test_update_status/0}, - {"Assign ticket test", fun test_assign_ticket/0}, - {"Resolve ticket test", fun test_resolve_ticket/0}, - {"Close ticket test", fun test_close_ticket/0}, - {"Get statistics test", fun test_get_statistics/0} - ]}. + {foreach, fun setup/0, fun cleanup/1, [ + {"Report error creates ticket", fun test_report_error/0}, + {"Report duplicate error increments count", fun test_report_duplicate/0}, + {"List tickets as admin", fun test_list_tickets/0}, + {"List tickets as non-admin returns error", fun test_list_tickets_forbidden/0}, + {"Update status as admin", fun test_update_status/0}, + {"Assign ticket as admin", fun test_assign_ticket/0}, + {"Resolve ticket as admin", fun test_resolve_ticket/0}, + {"Close ticket as admin", fun test_close_ticket/0}, + {"Get statistics as admin", fun test_get_statistics/0} + ]}. -create_test_user(Role) -> - UserId = base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}), - User = #user{id = UserId, email = <>, password_hash = <<"hash">>, - role = Role, status = active, created_at = calendar:universal_time(), updated_at = calendar:universal_time()}, - mnesia:dirty_write(User), - UserId. +%% --- Вспомогательная функция для создания тикета --- +report(ErrorMsg) -> + logic_ticket:report_error(ErrorMsg, <<"stack">>, #{}). + +%% --- Тесты --- test_report_error() -> - {ok, Ticket} = logic_ticket:report_error(<<"Test error">>, <<"stack">>, #{}), - ?assertEqual(<<"Test error">>, Ticket#ticket.error_message), - ?assertEqual(1, Ticket#ticket.count), + {ok, Ticket} = report(<<"Error1">>), + ?assertEqual(<<"Error1">>, Ticket#ticket.error_message), + ?assertEqual(1, Ticket#ticket.count). - {ok, Ticket2} = logic_ticket:report_error(<<"Test error">>, <<"stack">>, #{}), - ?assertEqual(2, Ticket2#ticket.count). +test_report_duplicate() -> + {ok, T1} = report(<<"Dup">>), + ?assertEqual(1, T1#ticket.count), + {ok, T2} = report(<<"Dup">>), + ?assertEqual(2, T2#ticket.count), + % Проверяем, что это тот же тикет, а не новый + ?assertEqual(T1#ticket.id, T2#ticket.id). -test_list_tickets_admin_only() -> - AdminId = create_test_user(admin), - UserId = create_test_user(user), +test_list_tickets() -> + {ok, _} = report(<<"E1">>), + Tickets = logic_ticket:list_tickets(<<"admin1">>), + ?assert(length(Tickets) =:= 1). - {ok, _} = logic_ticket:report_error(<<"E1">>, <<"">>, #{}), - {ok, _} = logic_ticket:report_error(<<"E2">>, <<"">>, #{}), - - {ok, Tickets} = logic_ticket:list_tickets(AdminId), - ?assertEqual(2, length(Tickets)), - - {error, access_denied} = logic_ticket:list_tickets(UserId). +test_list_tickets_forbidden() -> + {error, access_denied} = logic_ticket:list_tickets(<<"user1">>). test_update_status() -> - AdminId = create_test_user(admin), - UserId = create_test_user(user), - {ok, Ticket} = logic_ticket:report_error(<<"Error">>, <<"">>, #{}), - - {ok, Updated} = logic_ticket:update_status(AdminId, Ticket#ticket.id, in_progress), - ?assertEqual(in_progress, Updated#ticket.status), - - {error, access_denied} = logic_ticket:update_status(UserId, Ticket#ticket.id, resolved). + {ok, Ticket} = report(<<"E2">>), + {ok, Updated} = logic_ticket:update_status(<<"admin1">>, Ticket#ticket.id, <<"closed">>), + ?assertEqual(closed, Updated#ticket.status). test_assign_ticket() -> - AdminId = create_test_user(admin), - AssignToId = create_test_user(admin), - {ok, Ticket} = logic_ticket:report_error(<<"Error">>, <<"">>, #{}), - - {ok, Assigned} = logic_ticket:assign_ticket(AdminId, Ticket#ticket.id, AssignToId), - ?assertEqual(AssignToId, Assigned#ticket.assigned_to), - ?assertEqual(in_progress, Assigned#ticket.status). + {ok, Ticket} = report(<<"E3">>), + {ok, Updated} = logic_ticket:assign_ticket(<<"admin1">>, Ticket#ticket.id, <<"dev1">>), + ?assertEqual(<<"dev1">>, Updated#ticket.assigned_to). test_resolve_ticket() -> - AdminId = create_test_user(admin), - {ok, Ticket} = logic_ticket:report_error(<<"Error">>, <<"">>, #{}), - - {ok, Resolved} = logic_ticket:resolve_ticket(AdminId, Ticket#ticket.id, <<"Fixed">>), - ?assertEqual(<<"Fixed">>, Resolved#ticket.resolution_note), - ?assertEqual(resolved, Resolved#ticket.status). + {ok, Ticket} = report(<<"E4">>), + {ok, Updated} = logic_ticket:resolve_ticket(<<"admin1">>, Ticket#ticket.id, <<"Fixed">>), + ?assertEqual(closed, Updated#ticket.status), + ?assertEqual(<<"Fixed">>, Updated#ticket.resolution_note). test_close_ticket() -> - AdminId = create_test_user(admin), - {ok, Ticket} = logic_ticket:report_error(<<"Error">>, <<"">>, #{}), - - {ok, Closed} = logic_ticket:close_ticket(AdminId, Ticket#ticket.id), - ?assertEqual(closed, Closed#ticket.status). + {ok, Ticket} = report(<<"E5">>), + {ok, Updated} = logic_ticket:close_ticket(<<"admin1">>, Ticket#ticket.id), + ?assertEqual(closed, Updated#ticket.status). test_get_statistics() -> - AdminId = create_test_user(admin), - - {ok, _} = logic_ticket:report_error(<<"E1">>, <<"">>, #{}), - {ok, _} = logic_ticket:report_error(<<"E2">>, <<"">>, #{}), - {ok, T3} = logic_ticket:report_error(<<"E3">>, <<"">>, #{}), - - logic_ticket:update_status(AdminId, T3#ticket.id, resolved), - - Stats = logic_ticket:get_statistics(AdminId), + {ok, _} = report(<<"E6">>), + {ok, _} = report(<<"E7">>), + {ok, T3} = report(<<"E8">>), + logic_ticket:close_ticket(<<"admin1">>, T3#ticket.id), + Stats = logic_ticket:get_statistics(<<"admin1">>), ?assertEqual(3, maps:get(total_tickets, Stats)), ?assertEqual(2, maps:get(open, Stats)), - ?assertEqual(1, maps:get(resolved, Stats)), + ?assertEqual(1, maps:get(closed, Stats)), ?assertEqual(3, maps:get(total_errors, Stats)). \ No newline at end of file