diff --git a/include/records.hrl b/include/records.hrl index 5fb1300..61f9c98 100644 --- a/include/records.hrl +++ b/include/records.hrl @@ -118,7 +118,9 @@ -record(banned_word, { id :: binary(), - word :: binary() + word :: binary(), + added_by :: binary() | undefined, % id администратора, добавившего слово + added_at :: calendar:datetime() | undefined }). %% ------------------- Баг-трекер -------------------------------------- diff --git a/src/core/core_banned_word.erl b/src/core/core_banned_word.erl deleted file mode 100644 index deac56c..0000000 --- a/src/core/core_banned_word.erl +++ /dev/null @@ -1,85 +0,0 @@ --module(core_banned_word). --include("records.hrl"). - --export([add/1, remove/1, list_all/0, is_banned/1]). --export([check_text/1, filter_text/1]). - -%% Добавить слово в бан-лист -add(Word) when is_binary(Word) -> - WordLower = string:lowercase(Word), - case is_banned(WordLower) of - true -> {error, already_exists}; - false -> - BannedWord = #banned_word{ - id = generate_id(), - word = WordLower - }, - F = fun() -> - mnesia:write(BannedWord), - {ok, BannedWord} - end, - case mnesia:transaction(F) of - {atomic, Result} -> Result; - {aborted, Reason} -> {error, Reason} - end - end. - -%% Удалить слово из бан-листа -remove(Word) when is_binary(Word) -> - WordLower = string:lowercase(Word), - Match = #banned_word{word = WordLower, _ = '_'}, - case mnesia:dirty_match_object(Match) of - [] -> {error, not_found}; - [BannedWord] -> - F = fun() -> - mnesia:delete_object(BannedWord), - {ok, removed} - end, - case mnesia:transaction(F) of - {atomic, Result} -> Result; - {aborted, Reason} -> {error, Reason} - end - end. - -%% Список всех запрещённых слов -list_all() -> - Match = #banned_word{_ = '_'}, - Words = mnesia:dirty_match_object(Match), - {ok, [W#banned_word.word || W <- Words]}. - -%% Проверить, является ли слово запрещённым -is_banned(Word) when is_binary(Word) -> - WordLower = string:lowercase(Word), - Match = #banned_word{word = WordLower, _ = '_'}, - case mnesia:dirty_match_object(Match) of - [] -> false; - _ -> true - end. - -%% Проверить текст на наличие запрещённых слов -check_text(Text) when is_binary(Text) -> - TextLower = string:lowercase(Text), - Words = string:split(TextLower, " ", all), - {ok, BannedWords} = list_all(), - lists:any(fun(W) -> lists:member(W, BannedWords) end, Words). - -%% Отфильтровать запрещённые слова (заменить на ***) -filter_text(Text) when is_binary(Text) -> - {ok, BannedWords} = list_all(), - Words = binary:split(Text, <<" ">>, [global]), - Filtered = lists:map(fun(W) -> - case lists:member(string:lowercase(W), BannedWords) of - true -> <<"***">>; - false -> W - end - end, Words), - iolist_to_binary(join_binary(Filtered, <<" ">>)). - -join_binary([], _) -> []; -join_binary([H], _) -> [H]; -join_binary([H|T], Sep) -> - [H, Sep | join_binary(T, Sep)]. - -%% Внутренние функции -generate_id() -> - base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}). \ No newline at end of file diff --git a/src/core/core_banned_words.erl b/src/core/core_banned_words.erl new file mode 100644 index 0000000..decbb85 --- /dev/null +++ b/src/core/core_banned_words.erl @@ -0,0 +1,54 @@ +-module(core_banned_words). +-export([list_banned_words/0, + add_banned_word/2, + remove_banned_word/1, + update_banned_word/2]). + +-include("records.hrl"). + +list_banned_words() -> + mnesia:dirty_match_object(#banned_word{_ = '_'}). + +add_banned_word(Word, AddedBy) -> + Id = generate_id(), + Now = calendar:universal_time(), + BW = #banned_word{id = Id, word = Word, added_by = AddedBy, added_at = Now}, + case mnesia:transaction(fun() -> + case mnesia:match_object(#banned_word{word = Word, _ = '_'}) of + [] -> mnesia:write(BW), {ok, BW}; + _ -> mnesia:abort(already_exists) + end + end) of + {atomic, {ok, BWRec}} -> {ok, BWRec}; + {aborted, already_exists} -> {error, already_exists}; + {aborted, Reason} -> {error, Reason} + end. + +remove_banned_word(Word) -> + case mnesia:transaction(fun() -> + case mnesia:match_object(#banned_word{word = Word, _ = '_'}) of + [Rec] -> mnesia:delete_object(Rec), {ok, deleted}; + [] -> mnesia:abort(not_found) + end + end) of + {atomic, {ok, deleted}} -> {ok, deleted}; + {aborted, not_found} -> {error, not_found} + end. + +update_banned_word(OldWord, NewWord) -> + case mnesia:transaction(fun() -> + case mnesia:match_object(#banned_word{word = OldWord, _ = '_'}) of + [Rec] -> + Updated = Rec#banned_word{word = NewWord}, + mnesia:write(Updated), + {ok, Updated}; + [] -> + mnesia:abort(not_found) + end + end) of + {atomic, {ok, UpdatedRec}} -> {ok, UpdatedRec}; + {aborted, not_found} -> {error, not_found} + end. + +generate_id() -> + base64:encode(crypto:strong_rand_bytes(9)). \ No newline at end of file diff --git a/src/core/core_user.erl b/src/core/core_user.erl index 9998e39..cd26cca 100644 --- a/src/core/core_user.erl +++ b/src/core/core_user.erl @@ -4,6 +4,7 @@ -export([create/2, get_by_id/1, get_by_email/1, update/2, delete/1]). -export([email_exists/1]). -export([generate_id/0]). +-export([list_users/0]). %% Создание пользователя create(Email, Password) -> @@ -86,6 +87,22 @@ update(Id, Updates) -> delete(Id) -> update(Id, [{status, deleted}]). +list_users() -> + Users = mnesia:dirty_match_object(#user{_ = '_'}), + ActiveUsers = [U || U <- Users, U#user.status =/= deleted], + {ok, [user_to_map(U) || U <- ActiveUsers]}. + +user_to_map(User) -> + #{ + id => User#user.id, + email => User#user.email, + password_hash => User#user.password_hash, + role => User#user.role, + status => User#user.status, + created_at => User#user.created_at, + updated_at => User#user.updated_at + }. + %% Внутренние функции 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 be3c64c..3488691 100644 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -1,21 +1,18 @@ -module(eventhub_app). -behaviour(application). - -export([start/2, stop/1]). start(_StartType, _StartArgs) -> pg:start_link(), application:ensure_all_started(mnesia), application:ensure_all_started(cowboy), - case infra_sup:start_link() of {ok, Pid} -> ok = infra_mnesia:init_tables(), ok = infra_mnesia:wait_for_tables(), connect_nodes(), - start_http(), - start_admin_http(), - % Запускаем сборщик метрик Prometheus + start_http(), % Пользовательский API (8080) + start_admin_http(), % Административный API (8445) application:ensure_all_started(prometheus), application:ensure_all_started(prometheus_cowboy), {ok, Pid}; @@ -23,12 +20,13 @@ start(_StartType, _StartArgs) -> Error end. -stop(_State) -> - ok. +stop(_State) -> ok. +%% =================================================================== +%% Пользовательский HTTP (порт 8080) — только публичные эндпоинты +%% =================================================================== start_http() -> Port = application:get_env(eventhub, http_port, 8080), - Dispatch = cowboy_router:compile([ {'_', [ {"/metrics/[:registry]", prometheus_cowboy2_handler, []}, @@ -52,83 +50,66 @@ start_http() -> {"/v1/reviews/:id", handler_review_by_id, []}, {"/v1/reports", handler_reports, []}, {"/v1/tickets", handler_tickets, []}, - {"/v1/subscription", handler_subscription, []}, - - % Админские маршруты - более конкретные ПЕРЕД общими - {"/v1/admin/reports", handler_reports, []}, - {"/v1/admin/reports/:id", handler_report_by_id, []}, - {"/v1/admin/reviews/:id", handler_admin_reviews, []}, - {"/v1/admin/banned-words", handler_banned_words, []}, - {"/v1/admin/banned-words/:word", handler_banned_words, []}, - {"/v1/admin/tickets/stats", handler_ticket_stats, []}, - {"/v1/admin/tickets/:id", handler_ticket_by_id, []}, - {"/v1/admin/tickets", handler_tickets, []}, - {"/v1/admin/subscriptions", handler_admin_subscriptions, []}, - {"/v1/admin/subscriptions/:id", handler_admin_subscriptions, []}, - - % Общий маршрут для заморозки (должен быть последним) - {"/v1/admin/:target_type/:id", handler_admin_moderation, []} + {"/v1/subscription", handler_subscription, []} ]} ]), - - Middlewares = [ - cowboy_router, - cowboy_handler - ], - + Middlewares = [cowboy_router, cowboy_handler], Env = #{dispatch => Dispatch}, - - cowboy:start_clear(http, [{port, Port}], #{ - env => Env, - middlewares => Middlewares - }), - + cowboy:start_clear(http, [{port, Port}], #{env => Env, middlewares => Middlewares}), io:format("HTTP server started on port ~p~n", [Port]). +%% =================================================================== +%% Административный HTTP (порт 8445) — все админские эндпоинты +%% =================================================================== start_admin_http() -> Port = application:get_env(eventhub, admin_http_port, 8445), - Dispatch = cowboy_router:compile([ {'_', [ - {"/admin/health", admin_handler_health, []}, - {"/admin/stats", admin_handler_stats, []}, - {"/admin/users", admin_handler_users, []}, - {"/admin/users/:id", admin_handler_user_by_id, []} + % ================== БАЗОВЫЕ ================== + {"/v1/admin/health", admin_handler_health, []}, + {"/v1/admin/stats", admin_handler_stats, []}, + {"/v1/admin/login", admin_handler_login, []}, + % ================== ПОЛЬЗОВАТЕЛИ ================== + {"/v1/admin/users", admin_handler_users, []}, + {"/v1/admin/users/:id", admin_handler_user_by_id, []}, + % ================== ОТЧЁТЫ ================== + {"/v1/admin/reports", admin_handler_reports, []}, + {"/v1/admin/reports/:id", admin_handler_report_by_id, []}, + % ================== ОТЗЫВЫ ================== + {"/v1/admin/reviews/:id", admin_handler_reviews, []}, + % ================== БАН-СЛОВА ================== + {"/v1/admin/banned-words", admin_handler_banned_words, []}, + {"/v1/admin/banned-words/:word", admin_handler_banned_words, []}, + % ================== ТИКЕТЫ ================== + {"/v1/admin/tickets/stats", admin_handler_ticket_stats, []}, + {"/v1/admin/tickets/:id", admin_handler_ticket_by_id, []}, + {"/v1/admin/tickets", admin_handler_tickets, []}, + % ================== ПОДПИСКИ ================== + {"/v1/admin/subscriptions", admin_handler_subscriptions, []}, + {"/v1/admin/subscriptions/:id", admin_handler_subscriptions, []}, + % ================== МОДЕРАЦИЯ (общий маршрут) ================== + {"/v1/admin/:target_type/:id", admin_handler_moderation, []} ]} ]), - Middlewares = [ - cowboy_router, - cowboy_handler - ], - + Middlewares = [cowboy_router, cowboy_handler], Env = #{dispatch => Dispatch}, - - cowboy:start_clear(admin_http, [{port, Port}], #{ - env => Env, - middlewares => Middlewares - }), - + cowboy:start_clear(admin_http, [{port, Port}], #{env => Env, middlewares => Middlewares}), io:format("Admin HTTP server started on port ~p~n", [Port]), % WebSocket для пользователей - WsDispatch = cowboy_router:compile([ - {'_', [{"/ws", ws_handler, []}]} - ]), - cowboy:start_clear(ws, [{port, 8081}], #{ - env => #{dispatch => WsDispatch} - }), + WsDispatch = cowboy_router:compile([{'_', [{"/ws", ws_handler, []}]}]), + cowboy:start_clear(ws, [{port, 8081}], #{env => #{dispatch => WsDispatch}}), % WebSocket для админов - AdminWsDispatch = cowboy_router:compile([ - {'_', [{"/admin/ws", admin_ws_handler, []}]} - ]), - cowboy:start_clear(admin_ws, [{port, 8446}], #{ - env => #{dispatch => AdminWsDispatch} - }), + AdminWsDispatch = cowboy_router:compile([{'_', [{"/admin/ws", admin_ws_handler, []}]}]), + cowboy:start_clear(admin_ws, [{port, 8446}], #{env => #{dispatch => AdminWsDispatch}}), io:format("WebSocket started on ports 8081 (user) and 8446 (admin)~n"). +%% =================================================================== +%% Ручное подключение к нодам кластера (запасной вариант) +%% =================================================================== connect_nodes() -> case os:getenv("JOIN_NODES") of false -> ok; diff --git a/src/handlers/admin/admin_handler_banned_words.erl b/src/handlers/admin/admin_handler_banned_words.erl new file mode 100644 index 0000000..78c2ab3 --- /dev/null +++ b/src/handlers/admin/admin_handler_banned_words.erl @@ -0,0 +1,136 @@ +-module(admin_handler_banned_words). +-behaviour(cowboy_handler). +-export([init/2]). + +-include("records.hrl"). + +init(Req, _Opts) -> + case cowboy_req:binding(word, Req) of + undefined -> handle_collection(Req); + Word -> handle_item(Word, Req) + end. + +handle_collection(Req) -> + case cowboy_req:method(Req) of + <<"GET">> -> list_banned_words(Req); + <<"POST">> -> add_banned_word(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +handle_item(Word, Req) -> + case cowboy_req:method(Req) of + <<"DELETE">> -> delete_banned_word(Word, Req); + <<"PUT">> -> update_banned_word(Word, Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +list_banned_words(Req) -> + case auth_admin(Req) of + {ok, _AdminId, Req1} -> + Words = core_banned_words:list_banned_words(), + send_json(Req1, 200, [banned_word_to_json(W) || W <- Words]); + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +add_banned_word(Req) -> + case auth_admin(Req) of + {ok, AdminId, Req1} -> + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + #{<<"word">> := NewWord} -> + case core_banned_words:add_banned_word(NewWord, AdminId) of + {ok, WordRec} -> + send_json(Req2, 201, banned_word_to_json(WordRec)); + {error, already_exists} -> + send_error(Req2, 409, <<"Word already exists">>); + {error, _} -> + send_error(Req2, 500, <<"Internal server error">>) + end; + _ -> + send_error(Req2, 400, <<"Missing 'word' field">>) + catch + _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +delete_banned_word(Word, Req) -> + case auth_admin(Req) of + {ok, _AdminId, Req1} -> + case core_banned_words:remove_banned_word(Word) of + {ok, deleted} -> + send_json(Req1, 200, #{status => <<"deleted">>}); + {error, not_found} -> + send_error(Req1, 404, <<"Word not found">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +update_banned_word(Word, Req) -> + case auth_admin(Req) of + {ok, _AdminId, Req1} -> + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + #{<<"word">> := NewWord} -> + case core_banned_words:update_banned_word(Word, NewWord) of + {ok, WordRec} -> + send_json(Req2, 200, banned_word_to_json(WordRec)); + {error, not_found} -> + send_error(Req2, 404, <<"Word not found">>); + {error, _} -> + send_error(Req2, 500, <<"Internal server error">>) + end; + _ -> + send_error(Req2, 400, <<"Missing 'word' field">>) + catch + _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +auth_admin(Req) -> + case handler_auth:authenticate(Req) of + {ok, AdminId, Req1} -> + case is_admin(AdminId) of + true -> {ok, AdminId, Req1}; + false -> {error, 403, <<"Admin access required">>, Req1} + end; + {error, Code, Message, Req1} -> + {error, Code, Message, Req1} + end. + +is_admin(UserId) -> + case core_user:get_by_id(UserId) of + {ok, User} -> + Role = User#user.role, + Role =:= admin orelse Role =:= superadmin orelse + Role =:= moderator orelse Role =:= support; + _ -> false + end. + +banned_word_to_json(BW) -> + #{ + id => BW#banned_word.id, + word => BW#banned_word.word, + added_by => BW#banned_word.added_by, + added_at => datetime_to_iso8601(BW#banned_word.added_at) + }. + +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])); +datetime_to_iso8601(undefined) -> undefined. + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_login.erl b/src/handlers/admin/admin_handler_login.erl new file mode 100644 index 0000000..1a4d407 --- /dev/null +++ b/src/handlers/admin/admin_handler_login.erl @@ -0,0 +1,51 @@ +-module(admin_handler_login). +-behaviour(cowboy_handler). +-export([init/2]). + +init(Req0, State) -> + case cowboy_req:method(Req0) of + <<"POST">> -> + case cowboy_req:has_body(Req0) of + true -> + {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 + {ok, Token, User} -> + Resp = jsx:encode(#{ + <<"token">> => Token, + <<"user">> => #{ + <<"id">> => maps:get(id, User), + <<"email">> => maps:get(email, User), + <<"role">> => maps:get(role, User) + } + }), + Req2 = cowboy_req:reply(200, #{ + <<"content-type">> => <<"application/json">>, + <<"access-control-allow-origin">> => <<"*">> + }, Resp, Req1), + {ok, Req2, State}; + {error, insufficient_permissions} -> + error_response(403, insufficient_permissions, Req1, State); + {error, Reason} -> + error_response(401, Reason, Req1, State) + end; + _ -> + error_response(400, <<"invalid_request">>, Req1, State) + catch + _:_ -> error_response(400, <<"invalid_request">>, Req1, State) + end; + false -> + error_response(400, <<"Missing request body">>, Req0, State) + end; + _ -> + error_response(405, <<"Method not allowed">>, Req0, State) + end. + +error_response(Code, Reason, Req, State) -> + Body = jsx:encode(#{<<"error">> => Reason}), + Req2 = cowboy_req:reply(Code, #{ + <<"content-type">> => <<"application/json">>, + <<"access-control-allow-origin">> => <<"*">> + }, Body, Req), + {ok, Req2, State}. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_moderation.erl b/src/handlers/admin/admin_handler_moderation.erl new file mode 100644 index 0000000..b2153f3 --- /dev/null +++ b/src/handlers/admin/admin_handler_moderation.erl @@ -0,0 +1,154 @@ +-module(admin_handler_moderation). +-behaviour(cowboy_handler). +-export([init/2]). + +-include("records.hrl"). + +-define(VALID_TARGETS, [<<"calendar">>, <<"event">>, <<"review">>, <<"user">>]). + +init(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"PUT">> -> moderate(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +moderate(Req) -> + case auth_admin(Req) of + {ok, _AdminId, Req1} -> + TargetType = cowboy_req:binding(target_type, Req1), + TargetId = cowboy_req:binding(id, Req1), + case lists:member(TargetType, ?VALID_TARGETS) of + true -> + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + #{<<"action">> := Action} -> + apply_moderation(TargetType, TargetId, Action, Req2); + _ -> + send_error(Req2, 400, <<"Missing 'action' field">>) + catch + _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) + end; + false -> + send_error(Req1, 400, <<"Invalid target_type">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +apply_moderation(<<"calendar">>, Id, Action, Req) -> + handle_calendar(Id, Action, Req); +apply_moderation(<<"event">>, Id, Action, Req) -> + handle_event(Id, Action, Req); +apply_moderation(<<"review">>, Id, Action, Req) -> + handle_review(Id, Action, Req); +apply_moderation(<<"user">>, Id, Action, Req) -> + handle_user(Id, Action, Req). + +handle_calendar(Id, <<"freeze">>, Req) -> + case core_calendar:freeze(Id) of + {ok, Calendar} -> send_json(Req, 200, calendar_to_json(Calendar)); + {error, not_found} -> send_error(Req, 404, <<"Calendar not found">>) + end; +handle_calendar(Id, <<"unfreeze">>, Req) -> + case core_calendar:unfreeze(Id) of + {ok, Calendar} -> send_json(Req, 200, calendar_to_json(Calendar)); + {error, not_found} -> send_error(Req, 404, <<"Calendar not found">>) + end; +handle_calendar(_Id, _Action, Req) -> + send_error(Req, 400, <<"Invalid action for calendar">>). + +handle_event(Id, <<"freeze">>, Req) -> + case core_event:freeze(Id) of + {ok, Event} -> send_json(Req, 200, event_to_json(Event)); + {error, not_found} -> send_error(Req, 404, <<"Event not found">>) + end; +handle_event(Id, <<"unfreeze">>, Req) -> + case core_event:unfreeze(Id) of + {ok, Event} -> send_json(Req, 200, event_to_json(Event)); + {error, not_found} -> send_error(Req, 404, <<"Event not found">>) + end; +handle_event(_Id, _Action, Req) -> + send_error(Req, 400, <<"Invalid action for event">>). + +handle_review(Id, <<"hide">>, Req) -> + case core_review:hide(Id) of + {ok, Review} -> send_json(Req, 200, review_to_json(Review)); + {error, not_found} -> send_error(Req, 404, <<"Review not found">>) + end; +handle_review(Id, <<"show">>, Req) -> + case core_review:show(Id) of + {ok, Review} -> send_json(Req, 200, review_to_json(Review)); + {error, not_found} -> send_error(Req, 404, <<"Review not found">>) + end; +handle_review(_Id, _Action, Req) -> + send_error(Req, 400, <<"Invalid action for review">>). + +handle_user(Id, <<"block">>, Req) -> + case core_user:block(Id) of + {ok, User} -> send_json(Req, 200, user_to_json(User)); + {error, not_found} -> send_error(Req, 404, <<"User not found">>) + end; +handle_user(Id, <<"unblock">>, Req) -> + case core_user:unblock(Id) of + {ok, User} -> send_json(Req, 200, user_to_json(User)); + {error, not_found} -> send_error(Req, 404, <<"User not found">>) + end; +handle_user(_Id, _Action, Req) -> + send_error(Req, 400, <<"Invalid action for user">>). + +auth_admin(Req) -> + case handler_auth:authenticate(Req) of + {ok, AdminId, Req1} -> + case is_admin(AdminId) of + true -> {ok, AdminId, Req1}; + false -> {error, 403, <<"Admin access required">>, Req1} + end; + {error, Code, Message, Req1} -> + {error, Code, Message, Req1} + end. + +is_admin(UserId) -> + case core_user:get_by_id(UserId) of + {ok, User} -> + Role = User#user.role, + Role =:= admin orelse Role =:= superadmin orelse + Role =:= moderator orelse Role =:= support; + _ -> false + end. + +calendar_to_json(C) -> + #{ + id => C#calendar.id, + title => C#calendar.title, + status => atom_to_binary(C#calendar.status, utf8) + }. + +event_to_json(E) -> + #{ + id => E#event.id, + title => E#event.title, + status => atom_to_binary(E#event.status, utf8) + }. + +review_to_json(R) -> + #{ + id => R#review.id, + status => atom_to_binary(R#review.status, utf8) + }. + +user_to_json(U) -> + #{ + id => U#user.id, + email => U#user.email, + status => atom_to_binary(U#user.status, utf8) + }. + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_report_by_id.erl b/src/handlers/admin/admin_handler_report_by_id.erl new file mode 100644 index 0000000..5fa1109 --- /dev/null +++ b/src/handlers/admin/admin_handler_report_by_id.erl @@ -0,0 +1,96 @@ +-module(admin_handler_report_by_id). +-behaviour(cowboy_handler). +-export([init/2]). + +-include("records.hrl"). + +init(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> get_report(Req); + <<"PUT">> -> update_report(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +get_report(Req) -> + case handler_auth:authenticate(Req) of + {ok, AdminId, Req1} -> + case is_admin(AdminId) of + true -> + ReportId = cowboy_req:binding(id, Req1), + case core_report:get_by_id(ReportId) of + {ok, Report} -> + send_json(Req1, 200, report_to_json(Report)); + {error, not_found} -> + send_error(Req1, 404, <<"Report not found">>) + end; + false -> + send_error(Req1, 403, <<"Admin access required">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +update_report(Req) -> + case handler_auth:authenticate(Req) of + {ok, AdminId, Req1} -> + case is_admin(AdminId) of + true -> + ReportId = cowboy_req:binding(id, Req1), + {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 + {ok, Report} -> + send_json(Req2, 200, report_to_json(Report)); + {error, not_found} -> + send_error(Req2, 404, <<"Report not found">>); + {error, _} -> + send_error(Req2, 500, <<"Internal server error">>) + end; + _ -> + send_error(Req2, 400, <<"Missing status field">>) + catch + _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) + end; + false -> + send_error(Req1, 403, <<"Admin access required">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +is_admin(UserId) -> + case core_user:get_by_id(UserId) of + {ok, User} -> + Role = User#user.role, + Role =:= admin orelse Role =:= superadmin orelse + Role =:= moderator orelse Role =:= support; + _ -> false + end. + +report_to_json(R) -> + #{ + id => R#report.id, + reporter_id => R#report.reporter_id, + target_type => R#report.target_type, + target_id => R#report.target_id, + reason => R#report.reason, + status => R#report.status, + created_at => datetime_to_iso8601(R#report.created_at), + resolved_at => datetime_to_iso8601(R#report.resolved_at) + }. + +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])); +datetime_to_iso8601(undefined) -> undefined. + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_reports.erl b/src/handlers/admin/admin_handler_reports.erl new file mode 100644 index 0000000..49a2891 --- /dev/null +++ b/src/handlers/admin/admin_handler_reports.erl @@ -0,0 +1,91 @@ +-module(admin_handler_reports). +-behaviour(cowboy_handler). +-export([init/2]). + +-include("records.hrl"). %% ← обязательно для #user{} и #report{} + +init(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> list_reports(Req); + <<"PUT">> -> update_report(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +list_reports(Req) -> + case handler_auth:authenticate(Req) of + {ok, AdminId, Req1} -> + case is_admin(AdminId) of + true -> + Reports = core_report:list_reports(), + send_json(Req1, 200, [report_to_json(R) || R <- Reports]); + false -> + send_error(Req1, 403, <<"Admin access required">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +update_report(Req) -> + case handler_auth:authenticate(Req) of + {ok, AdminId, Req1} -> + case is_admin(AdminId) of + true -> + ReportId = cowboy_req:binding(id, Req1), + {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 + {ok, Report} -> + send_json(Req2, 200, report_to_json(Report)); + {error, not_found} -> + send_error(Req2, 404, <<"Report not found">>); + {error, _} -> + send_error(Req2, 500, <<"Internal server error">>) + end; + _ -> + send_error(Req2, 400, <<"Missing status field">>) + catch + _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) + end; + false -> + send_error(Req1, 403, <<"Admin access required">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +is_admin(UserId) -> + case core_user:get_by_id(UserId) of + {ok, User} -> + Role = User#user.role, + Role =:= admin orelse Role =:= superadmin orelse + Role =:= moderator orelse Role =:= support; + _ -> false + end. + +report_to_json(R) -> + #{ + id => R#report.id, + reporter_id => R#report.reporter_id, + target_type => R#report.target_type, + target_id => R#report.target_id, + reason => R#report.reason, + status => R#report.status, + created_at => datetime_to_iso8601(R#report.created_at), + resolved_at => datetime_to_iso8601(R#report.resolved_at) + }. + +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])); +datetime_to_iso8601(undefined) -> undefined. + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_reviews.erl b/src/handlers/admin/admin_handler_reviews.erl new file mode 100644 index 0000000..78d24d1 --- /dev/null +++ b/src/handlers/admin/admin_handler_reviews.erl @@ -0,0 +1,97 @@ +-module(admin_handler_reviews). +-behaviour(cowboy_handler). +-export([init/2]). + +-include("records.hrl"). + +init(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> get_review(Req); + <<"PUT">> -> update_review(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +get_review(Req) -> + case handler_auth:authenticate(Req) of + {ok, AdminId, Req1} -> + case is_admin(AdminId) of + true -> + ReviewId = cowboy_req:binding(id, Req1), + case core_review:get_by_id(ReviewId) of + {ok, Review} -> + send_json(Req1, 200, review_to_json(Review)); + {error, not_found} -> + send_error(Req1, 404, <<"Review not found">>) + end; + false -> + send_error(Req1, 403, <<"Admin access required">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +update_review(Req) -> + case handler_auth:authenticate(Req) of + {ok, AdminId, Req1} -> + case is_admin(AdminId) of + true -> + ReviewId = cowboy_req:binding(id, Req1), + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + #{<<"status">> := NewStatus} -> + case core_review:update_status(ReviewId, NewStatus) of + {ok, Review} -> + send_json(Req2, 200, review_to_json(Review)); + {error, not_found} -> + send_error(Req2, 404, <<"Review not found">>); + {error, _} -> + send_error(Req2, 500, <<"Internal server error">>) + end; + _ -> + send_error(Req2, 400, <<"Missing status field">>) + catch + _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) + end; + false -> + send_error(Req1, 403, <<"Admin access required">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +is_admin(UserId) -> + case core_user:get_by_id(UserId) of + {ok, User} -> + Role = User#user.role, + Role =:= admin orelse Role =:= superadmin orelse + Role =:= moderator orelse Role =:= support; + _ -> false + end. + +review_to_json(R) -> + #{ + id => R#review.id, + user_id => R#review.user_id, + target_type => R#review.target_type, + target_id => R#review.target_id, + rating => R#review.rating, + comment => R#review.comment, + status => R#review.status, + created_at => datetime_to_iso8601(R#review.created_at), + updated_at => datetime_to_iso8601(R#review.updated_at) + }. + +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])); +datetime_to_iso8601(undefined) -> undefined. + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_stats.erl b/src/handlers/admin/admin_handler_stats.erl index d8b9415..56fc2af 100644 --- a/src/handlers/admin/admin_handler_stats.erl +++ b/src/handlers/admin/admin_handler_stats.erl @@ -1,6 +1,5 @@ -module(admin_handler_stats). -include("records.hrl"). - -export([init/2]). -export([count_users/0, count_calendars/0, count_events/0, count_bookings/0, count_reviews/0, count_reports/0, count_tickets/0, count_subscriptions/0]). @@ -41,33 +40,21 @@ get_stats(Req) -> %% Вспомогательные функции is_admin(UserId) -> case core_user:get_by_id(UserId) of - {ok, User} -> User#user.role =:= admin; + {ok, User} -> + Role = User#user.role, + Role =:= admin orelse Role =:= superadmin orelse + Role =:= moderator orelse Role =:= support; _ -> false end. -count_users() -> - length(mnesia:dirty_match_object(#user{_ = '_'})). - -count_calendars() -> - length(mnesia:dirty_match_object(#calendar{_ = '_'})). - -count_events() -> - length(mnesia:dirty_match_object(#event{is_instance = false, _ = '_'})). - -count_bookings() -> - length(mnesia:dirty_match_object(#booking{_ = '_'})). - -count_reviews() -> - length(mnesia:dirty_match_object(#review{_ = '_'})). - -count_reports() -> - length(mnesia:dirty_match_object(#report{_ = '_'})). - -count_tickets() -> - length(mnesia:dirty_match_object(#ticket{_ = '_'})). - -count_subscriptions() -> - length(mnesia:dirty_match_object(#subscription{_ = '_'})). +count_users() -> length(mnesia:dirty_match_object(#user{_ = '_'})). +count_calendars() -> length(mnesia:dirty_match_object(#calendar{_ = '_'})). +count_events() -> length(mnesia:dirty_match_object(#event{is_instance = false, _ = '_'})). +count_bookings() -> length(mnesia:dirty_match_object(#booking{_ = '_'})). +count_reviews() -> length(mnesia:dirty_match_object(#review{_ = '_'})). +count_reports() -> length(mnesia:dirty_match_object(#report{_ = '_'})). +count_tickets() -> length(mnesia:dirty_match_object(#ticket{_ = '_'})). +count_subscriptions() -> length(mnesia:dirty_match_object(#subscription{_ = '_'})). send_json(Req, Status, Data) -> Body = jsx:encode(Data), diff --git a/src/handlers/admin/admin_handler_subscriptions.erl b/src/handlers/admin/admin_handler_subscriptions.erl new file mode 100644 index 0000000..67849af --- /dev/null +++ b/src/handlers/admin/admin_handler_subscriptions.erl @@ -0,0 +1,160 @@ +-module(admin_handler_subscriptions). +-behaviour(cowboy_handler). +-export([init/2]). + +-include("records.hrl"). + +init(Req, _Opts) -> + case cowboy_req:binding(id, Req) of + undefined -> + handle_collection(Req); + _SubscriptionId -> + handle_item(Req) + end. + +handle_collection(Req) -> + case cowboy_req:method(Req) of + <<"GET">> -> list_subscriptions(Req); + <<"POST">> -> create_subscription(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +handle_item(Req) -> + SubscriptionId = cowboy_req:binding(id, Req), + case cowboy_req:method(Req) of + <<"GET">> -> get_subscription(SubscriptionId, Req); + <<"PUT">> -> update_subscription(SubscriptionId, Req); + <<"DELETE">> -> delete_subscription(SubscriptionId, Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +list_subscriptions(Req) -> + case auth_admin(Req) of + {ok, _AdminId, Req1} -> + Subscriptions = core_subscription:list_subscriptions(), + send_json(Req1, 200, [subscription_to_json(S) || S <- Subscriptions]); + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +create_subscription(Req) -> + case auth_admin(Req) of + {ok, AdminId, Req1} -> + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + #{<<"user_id">> := _UserId} = Data -> + SubscriptionData = maps:merge(#{ + <<"status">> => <<"active">>, + <<"trial_used">> => false + }, Data), + case core_subscription:create_subscription(SubscriptionData) of + {ok, Subscription} -> + send_json(Req2, 201, subscription_to_json(Subscription)); + {error, Reason} -> + send_error(Req2, 500, Reason) + end; + _ -> + send_error(Req2, 400, <<"Missing 'user_id' field">>) + catch + _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +get_subscription(SubscriptionId, Req) -> + case auth_admin(Req) of + {ok, _AdminId, Req1} -> + case core_subscription:get_by_id(SubscriptionId) of + {ok, Subscription} -> + send_json(Req1, 200, subscription_to_json(Subscription)); + {error, not_found} -> + send_error(Req1, 404, <<"Subscription not found">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +update_subscription(SubscriptionId, Req) -> + case auth_admin(Req) of + {ok, _AdminId, Req1} -> + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + UpdatesMap when is_map(UpdatesMap) -> + case core_subscription:update_subscription(SubscriptionId, UpdatesMap) of + {ok, Subscription} -> + send_json(Req2, 200, subscription_to_json(Subscription)); + {error, not_found} -> + send_error(Req2, 404, <<"Subscription not found">>); + {error, Reason} -> + send_error(Req2, 500, Reason) + end; + _ -> + send_error(Req2, 400, <<"Invalid JSON">>) + catch + _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +delete_subscription(SubscriptionId, Req) -> + case auth_admin(Req) of + {ok, _AdminId, Req1} -> + case core_subscription:delete_subscription(SubscriptionId) of + {ok, deleted} -> + send_json(Req1, 200, #{status => <<"deleted">>}); + {error, not_found} -> + send_error(Req1, 404, <<"Subscription not found">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +auth_admin(Req) -> + case handler_auth:authenticate(Req) of + {ok, AdminId, Req1} -> + case is_admin(AdminId) of + true -> {ok, AdminId, Req1}; + false -> {error, 403, <<"Admin access required">>, Req1} + end; + {error, Code, Message, Req1} -> + {error, Code, Message, Req1} + end. + +is_admin(UserId) -> + case core_user:get_by_id(UserId) of + {ok, User} -> + Role = User#user.role, + Role =:= admin orelse Role =:= superadmin orelse + Role =:= moderator orelse Role =:= support; + _ -> false + end. + +subscription_to_json(S) -> + #{ + id => S#subscription.id, + user_id => S#subscription.user_id, + plan => atom_to_binary(S#subscription.plan, utf8), + status => atom_to_binary(S#subscription.status, utf8), + trial_used => S#subscription.trial_used, + started_at => datetime_to_iso8601(S#subscription.started_at), + expires_at => datetime_to_iso8601(S#subscription.expires_at), + created_at => datetime_to_iso8601(S#subscription.created_at), + updated_at => datetime_to_iso8601(S#subscription.updated_at) + }. + +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])); +datetime_to_iso8601(undefined) -> undefined. + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_ticket_by_id.erl b/src/handlers/admin/admin_handler_ticket_by_id.erl new file mode 100644 index 0000000..a1ad5a9 --- /dev/null +++ b/src/handlers/admin/admin_handler_ticket_by_id.erl @@ -0,0 +1,115 @@ +-module(admin_handler_ticket_by_id). +-behaviour(cowboy_handler). +-export([init/2]). + +-include("records.hrl"). + +init(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> get_ticket(Req); + <<"PUT">> -> update_ticket(Req); + <<"DELETE">> -> delete_ticket(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +get_ticket(Req) -> + case auth_admin(Req) of + {ok, _AdminId, Req1} -> + TicketId = cowboy_req:binding(id, Req1), + case core_ticket:get_by_id(TicketId) of + {ok, Ticket} -> + send_json(Req1, 200, ticket_to_json(Ticket)); + {error, not_found} -> + send_error(Req1, 404, <<"Ticket not found">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +update_ticket(Req) -> + case auth_admin(Req) of + {ok, _AdminId, Req1} -> + TicketId = cowboy_req:binding(id, Req1), + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + UpdatesMap when is_map(UpdatesMap) -> + case core_ticket:update_ticket(TicketId, UpdatesMap) of + {ok, Ticket} -> + send_json(Req2, 200, ticket_to_json(Ticket)); + {error, not_found} -> + send_error(Req2, 404, <<"Ticket not found">>); + {error, Reason} -> + send_error(Req2, 500, Reason) + end; + _ -> + send_error(Req2, 400, <<"Invalid JSON">>) + catch + _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +delete_ticket(Req) -> + case auth_admin(Req) of + {ok, _AdminId, Req1} -> + TicketId = cowboy_req:binding(id, Req1), + case core_ticket:delete_ticket(TicketId) of + {ok, deleted} -> + send_json(Req1, 200, #{status => <<"deleted">>}); + {error, not_found} -> + send_error(Req1, 404, <<"Ticket not found">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +auth_admin(Req) -> + case handler_auth:authenticate(Req) of + {ok, AdminId, Req1} -> + case is_admin(AdminId) of + true -> {ok, AdminId, Req1}; + false -> {error, 403, <<"Admin access required">>, Req1} + end; + {error, Code, Message, Req1} -> + {error, Code, Message, Req1} + end. + +is_admin(UserId) -> + case core_user:get_by_id(UserId) of + {ok, User} -> + Role = User#user.role, + Role =:= admin orelse Role =:= superadmin orelse + Role =:= moderator orelse Role =:= support; + _ -> false + end. + +ticket_to_json(T) -> + #{ + 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])); +datetime_to_iso8601(undefined) -> undefined. + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_ticket_stats.erl b/src/handlers/admin/admin_handler_ticket_stats.erl new file mode 100644 index 0000000..9146f9d --- /dev/null +++ b/src/handlers/admin/admin_handler_ticket_stats.erl @@ -0,0 +1,50 @@ +-module(admin_handler_ticket_stats). +-behaviour(cowboy_handler). +-export([init/2]). + +-include("records.hrl"). % ← добавлено + +init(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> get_stats(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +get_stats(Req) -> + case auth_admin(Req) of + {ok, _AdminId, Req1} -> + Stats = core_ticket:stats(), + send_json(Req1, 200, Stats); + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +auth_admin(Req) -> + case handler_auth:authenticate(Req) of + {ok, AdminId, Req1} -> + case is_admin(AdminId) of + true -> {ok, AdminId, Req1}; + false -> {error, 403, <<"Admin access required">>, Req1} + end; + {error, Code, Message, Req1} -> + {error, Code, Message, Req1} + end. + +is_admin(UserId) -> + case core_user:get_by_id(UserId) of + {ok, User} -> + Role = User#user.role, + Role =:= admin orelse Role =:= superadmin orelse + Role =:= moderator orelse Role =:= support; + _ -> false + end. + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_tickets.erl b/src/handlers/admin/admin_handler_tickets.erl new file mode 100644 index 0000000..c05f350 --- /dev/null +++ b/src/handlers/admin/admin_handler_tickets.erl @@ -0,0 +1,160 @@ +-module(admin_handler_tickets). +-behaviour(cowboy_handler). +-export([init/2]). + +-include("records.hrl"). + +init(Req, _Opts) -> + case cowboy_req:binding(id, Req) of + undefined -> handle_collection(Req); + TicketId -> handle_item(TicketId, Req) + end. + +handle_collection(Req) -> + case cowboy_req:method(Req) of + <<"GET">> -> list_tickets(Req); + <<"POST">> -> create_ticket(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +handle_item(TicketId, Req) -> + case cowboy_req:method(Req) of + <<"GET">> -> get_ticket(TicketId, Req); + <<"PUT">> -> update_ticket(TicketId, Req); + <<"DELETE">> -> delete_ticket(TicketId, Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +list_tickets(Req) -> + case auth_admin(Req) of + {ok, _AdminId, Req1} -> + Tickets = core_ticket:list_tickets(), + send_json(Req1, 200, [ticket_to_json(T) || T <- Tickets]); + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +create_ticket(Req) -> + case auth_admin(Req) of + {ok, AdminId, Req1} -> + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + #{<<"error_message">> := _} = Data -> + % Администратор может указать error_hash, stacktrace, context, status + TicketData = maps:merge(#{ + <<"status">> => <<"open">>, + <<"assigned_to">> => undefined + }, 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. + +get_ticket(TicketId, Req) -> + case auth_admin(Req) of + {ok, _AdminId, Req1} -> + case core_ticket:get_by_id(TicketId) of + {ok, Ticket} -> + send_json(Req1, 200, ticket_to_json(Ticket)); + {error, not_found} -> + send_error(Req1, 404, <<"Ticket not found">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +update_ticket(TicketId, Req) -> + case auth_admin(Req) of + {ok, _AdminId, Req1} -> + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + UpdatesMap when is_map(UpdatesMap) -> + case core_ticket:update_ticket(TicketId, UpdatesMap) of + {ok, Ticket} -> + send_json(Req2, 200, ticket_to_json(Ticket)); + {error, not_found} -> + send_error(Req2, 404, <<"Ticket not found">>); + {error, Reason} -> + send_error(Req2, 500, Reason) + end; + _ -> + send_error(Req2, 400, <<"Invalid JSON">>) + catch + _:_ -> send_error(Req2, 400, <<"Invalid JSON">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +delete_ticket(TicketId, Req) -> + case auth_admin(Req) of + {ok, _AdminId, Req1} -> + case core_ticket:delete_ticket(TicketId) of + {ok, deleted} -> + send_json(Req1, 200, #{status => <<"deleted">>}); + {error, not_found} -> + send_error(Req1, 404, <<"Ticket not found">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +auth_admin(Req) -> + case handler_auth:authenticate(Req) of + {ok, AdminId, Req1} -> + case is_admin(AdminId) of + true -> {ok, AdminId, Req1}; + false -> {error, 403, <<"Admin access required">>, Req1} + end; + {error, Code, Message, Req1} -> + {error, Code, Message, Req1} + end. + +is_admin(UserId) -> + case core_user:get_by_id(UserId) of + {ok, User} -> + Role = User#user.role, + Role =:= admin orelse Role =:= superadmin orelse + Role =:= moderator orelse Role =:= support; + _ -> false + end. + +ticket_to_json(T) -> + #{ + 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])); +datetime_to_iso8601(undefined) -> undefined. + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/admin/admin_handler_user_by_id.erl b/src/handlers/admin/admin_handler_user_by_id.erl index 79eda72..af94c40 100644 --- a/src/handlers/admin/admin_handler_user_by_id.erl +++ b/src/handlers/admin/admin_handler_user_by_id.erl @@ -1,6 +1,5 @@ -module(admin_handler_user_by_id). -include("records.hrl"). - -export([init/2]). -export([user_to_json/1, convert_updates/1]). @@ -9,10 +8,10 @@ init(Req, Opts) -> handle(Req, _Opts) -> case cowboy_req:method(Req) of - <<"GET">> -> get_user(Req); - <<"PUT">> -> update_user(Req); + <<"GET">> -> get_user(Req); + <<"PUT">> -> update_user(Req); <<"DELETE">> -> delete_user(Req); - _ -> send_error(Req, 405, <<"Method not allowed">>) + _ -> send_error(Req, 405, <<"Method not allowed">>) end. get_user(Req) -> @@ -22,11 +21,10 @@ get_user(Req) -> true -> UserId = cowboy_req:binding(id, Req1), case core_user:get_by_id(UserId) of + {ok, User} when User#user.status =:= deleted -> + send_error(Req1, 404, <<"User not found">>); {ok, User} -> - case User#user.status of - deleted -> send_error(Req1, 404, <<"User not found">>); - _ -> send_json(Req1, 200, user_to_json(User)) - end; + send_json(Req1, 200, user_to_json(User)); {error, not_found} -> send_error(Req1, 404, <<"User not found">>) end; @@ -47,9 +45,8 @@ update_user(Req) -> try jsx:decode(Body, [return_maps]) of Decoded when is_map(Decoded) -> Updates = maps:to_list(Decoded), - % Преобразуем бинарные значения в атомы где нужно - ConvertedUpdates = convert_updates(Updates), - case core_user:update(UserId, ConvertedUpdates) of + Converted = convert_updates(Updates), + case core_user:update(UserId, Converted) of {ok, User} -> send_json(Req2, 200, user_to_json(User)); {error, not_found} -> @@ -69,16 +66,6 @@ update_user(Req) -> send_error(Req1, Code, Message) end. -convert_updates(Updates) -> - lists:map(fun - ({<<"status">>, <<"active">>}) -> {status, active}; - ({<<"status">>, <<"frozen">>}) -> {status, frozen}; - ({<<"status">>, <<"deleted">>}) -> {status, deleted}; - ({<<"role">>, <<"user">>}) -> {role, user}; - ({<<"role">>, <<"admin">>}) -> {role, admin}; - (Other) -> Other - end, Updates). - delete_user(Req) -> case handler_auth:authenticate(Req) of {ok, AdminId, Req1} -> @@ -106,10 +93,10 @@ is_admin(UserId) -> user_to_json(User) -> #{ - 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 => datetime_to_iso8601(User#user.created_at), updated_at => datetime_to_iso8601(User#user.updated_at) }. @@ -118,6 +105,13 @@ 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])). +convert_updates(Updates) -> + lists:map(fun + ({<<"status">>, Value}) -> {status, binary_to_existing_atom(Value)}; + ({<<"role">>, Value}) -> {role, binary_to_existing_atom(Value)}; + (Other) -> Other + end, Updates). + send_json(Req, Status, Data) -> Body = jsx:encode(Data), cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), diff --git a/src/handlers/admin/admin_handler_users.erl b/src/handlers/admin/admin_handler_users.erl index 7e18ae6..666c0c0 100644 --- a/src/handlers/admin/admin_handler_users.erl +++ b/src/handlers/admin/admin_handler_users.erl @@ -1,47 +1,30 @@ -module(admin_handler_users). --include("records.hrl"). - +-behaviour(cowboy_handler). -export([init/2]). -init(Req, Opts) -> - handle(Req, Opts). - -handle(Req, _Opts) -> +init(Req, _Opts) -> case cowboy_req:method(Req) of - <<"GET">> -> list_users(Req); - _ -> send_error(Req, 405, <<"Method not allowed">>) - end. - -list_users(Req) -> - case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> - case is_admin(AdminId) of - true -> - Users = mnesia:dirty_match_object(#user{_ = '_'}), - ActiveUsers = [U || U <- Users, U#user.status =/= deleted], - Response = [user_to_json(U) || U <- ActiveUsers], + <<"GET">> -> + case handler_auth:authenticate(Req) of + {ok, _UserId, Req1} -> + {ok, Users} = core_user:list_users(), + Response = [user_to_map(U) || U <- Users], send_json(Req1, 200, Response); - false -> - send_error(Req1, 403, <<"Admin access required">>) + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) + _ -> + send_error(Req, 405, <<"Method not allowed">>) end. -is_admin(UserId) -> - case core_user:get_by_id(UserId) of - {ok, User} -> User#user.role =:= admin; - _ -> false - end. - -user_to_json(User) -> +user_to_map(User) -> #{ - id => User#user.id, - email => User#user.email, - role => User#user.role, - status => User#user.status, - created_at => datetime_to_iso8601(User#user.created_at), - updated_at => datetime_to_iso8601(User#user.updated_at) + id => maps:get(id, User), + email => maps:get(email, User), + role => maps:get(role, User), + status => maps:get(status, User), + created_at => datetime_to_iso8601(maps:get(created_at, User)), + updated_at => datetime_to_iso8601(maps:get(updated_at, User)) }. datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> diff --git a/src/handlers/handler_admin_moderation.erl b/src/handlers/handler_admin_moderation.erl deleted file mode 100644 index 38c365f..0000000 --- a/src/handlers/handler_admin_moderation.erl +++ /dev/null @@ -1,132 +0,0 @@ --module(handler_admin_moderation). --include("records.hrl"). - --export([init/2]). - -init(Req, Opts) -> - handle(Req, Opts). - -handle(Req, _Opts) -> - case cowboy_req:method(Req) of - <<"PUT">> -> moderate(Req); - _ -> send_error(Req, 405, <<"Method not allowed">>) - end. - -%% PUT /v1/admin/:target_type/:id - заморозка/разморозка -moderate(Req) -> - case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> - TargetTypeBin = cowboy_req:binding(target_type, Req1), - TargetId = cowboy_req:binding(id, Req1), - TargetType = parse_target_type(TargetTypeBin), - {ok, Body, Req2} = cowboy_req:read_body(Req1), - try jsx:decode(Body, [return_maps]) of - #{<<"action">> := Action} -> - case {TargetType, Action} of - {calendar, <<"freeze">>} -> - case logic_moderation:freeze_calendar(AdminId, TargetId) of - {ok, Calendar} -> - send_json(Req2, 200, calendar_to_json(Calendar)); - {error, access_denied} -> - send_error(Req2, 403, <<"Admin access required">>); - {error, not_found} -> - send_error(Req2, 404, <<"Calendar not found">>); - {error, _} -> - send_error(Req2, 500, <<"Internal server error">>) - end; - {calendar, <<"unfreeze">>} -> - case logic_moderation:unfreeze_calendar(AdminId, TargetId) of - {ok, Calendar} -> - send_json(Req2, 200, calendar_to_json(Calendar)); - {error, access_denied} -> - send_error(Req2, 403, <<"Admin access required">>); - {error, not_found} -> - send_error(Req2, 404, <<"Calendar not found">>); - {error, _} -> - send_error(Req2, 500, <<"Internal server error">>) - end; - {event, <<"freeze">>} -> - case logic_moderation:freeze_event(AdminId, TargetId) of - {ok, Event} -> - send_json(Req2, 200, event_to_json(Event)); - {error, access_denied} -> - send_error(Req2, 403, <<"Admin access required">>); - {error, not_found} -> - send_error(Req2, 404, <<"Event not found">>); - {error, _} -> - send_error(Req2, 500, <<"Internal server error">>) - end; - {event, <<"unfreeze">>} -> - case logic_moderation:unfreeze_event(AdminId, TargetId) of - {ok, Event} -> - send_json(Req2, 200, event_to_json(Event)); - {error, access_denied} -> - send_error(Req2, 403, <<"Admin access required">>); - {error, not_found} -> - send_error(Req2, 404, <<"Event not found">>); - {error, _} -> - send_error(Req2, 500, <<"Internal server error">>) - end; - _ -> - send_error(Req2, 400, <<"Invalid target_type or action">>) - end; - _ -> - send_error(Req2, 400, <<"Missing action field">>) - catch - _:_ -> - send_error(Req2, 400, <<"Invalid JSON format">>) - end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. - -%% Вспомогательные функции -parse_target_type(<<"calendars">>) -> calendar; -parse_target_type(<<"events">>) -> event; -parse_target_type(_) -> undefined. - -calendar_to_json(Calendar) -> - #{ - id => Calendar#calendar.id, - owner_id => Calendar#calendar.owner_id, - title => Calendar#calendar.title, - description => Calendar#calendar.description, - type => Calendar#calendar.type, - tags => Calendar#calendar.tags, - status => Calendar#calendar.status, - rating_avg => Calendar#calendar.rating_avg, - rating_count => Calendar#calendar.rating_count, - created_at => datetime_to_iso8601(Calendar#calendar.created_at), - updated_at => datetime_to_iso8601(Calendar#calendar.updated_at) - }. - -event_to_json(Event) -> - #{ - id => Event#event.id, - calendar_id => Event#event.calendar_id, - title => Event#event.title, - description => Event#event.description, - event_type => Event#event.event_type, - start_time => datetime_to_iso8601(Event#event.start_time), - duration => Event#event.duration, - status => Event#event.status, - tags => Event#event.tags, - rating_avg => Event#event.rating_avg, - rating_count => Event#event.rating_count, - created_at => datetime_to_iso8601(Event#event.created_at), - updated_at => datetime_to_iso8601(Event#event.updated_at) - }. - -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])). - -send_json(Req, Status, Data) -> - Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), - {ok, Body, []}. - -send_error(Req, Status, Message) -> - Body = jsx:encode(#{error => Message}), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), - {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/handler_admin_reviews.erl b/src/handlers/handler_admin_reviews.erl deleted file mode 100644 index 874526e..0000000 --- a/src/handlers/handler_admin_reviews.erl +++ /dev/null @@ -1,93 +0,0 @@ --module(handler_admin_reviews). --include("records.hrl"). - --export([init/2]). - -init(Req, Opts) -> - handle(Req, Opts). - -handle(Req, _Opts) -> - case cowboy_req:method(Req) of - <<"PUT">> -> moderate_review(Req); - _ -> send_error(Req, 405, <<"Method not allowed">>) - end. - -%% PUT /v1/admin/reviews/:id - скрыть/раскрыть отзыв -moderate_review(Req) -> - case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> - % Проверим роль - case core_user:get_by_id(AdminId) of - {ok, User} -> - io:format("User ~p role: ~p~n", [AdminId, User#user.role]); - _ -> ok - end, - ReviewId = cowboy_req:binding(id, Req1), - {ok, Body, Req2} = cowboy_req:read_body(Req1), - try jsx:decode(Body, [return_maps]) of - #{<<"action">> := Action} -> - case Action of - <<"hide">> -> - case logic_review:hide_review(AdminId, ReviewId) of - {ok, Review} -> - Response = review_to_json(Review), - send_json(Req2, 200, Response); - {error, access_denied} -> - send_error(Req2, 403, <<"Admin access required">>); - {error, not_found} -> - send_error(Req2, 404, <<"Review not found">>); - {error, _} -> - send_error(Req2, 500, <<"Internal server error">>) - end; - <<"unhide">> -> - case logic_review:unhide_review(AdminId, ReviewId) of - {ok, Review} -> - Response = review_to_json(Review), - send_json(Req2, 200, Response); - {error, access_denied} -> - send_error(Req2, 403, <<"Admin access required">>); - {error, not_found} -> - send_error(Req2, 404, <<"Review not found">>); - {error, _} -> - send_error(Req2, 500, <<"Internal server error">>) - end; - _ -> - send_error(Req2, 400, <<"Invalid action. Use 'hide' or 'unhide'">>) - end; - _ -> - send_error(Req2, 400, <<"Missing action field">>) - catch - _:_ -> - send_error(Req2, 400, <<"Invalid JSON format">>) - end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. - -%% Вспомогательные функции -review_to_json(Review) -> - #{ - id => Review#review.id, - user_id => Review#review.user_id, - target_type => Review#review.target_type, - target_id => Review#review.target_id, - rating => Review#review.rating, - comment => Review#review.comment, - status => Review#review.status, - created_at => datetime_to_iso8601(Review#review.created_at), - updated_at => datetime_to_iso8601(Review#review.updated_at) - }. - -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])). - -send_json(Req, Status, Data) -> - Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), - {ok, Body, []}. - -send_error(Req, Status, Message) -> - Body = jsx:encode(#{error => Message}), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), - {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/handler_admin_subscriptions.erl b/src/handlers/handler_admin_subscriptions.erl deleted file mode 100644 index 5870c33..0000000 --- a/src/handlers/handler_admin_subscriptions.erl +++ /dev/null @@ -1,84 +0,0 @@ --module(handler_admin_subscriptions). --include("records.hrl"). - --export([init/2]). - -init(Req, Opts) -> - handle(Req, Opts). - -handle(Req, _Opts) -> - case cowboy_req:method(Req) of - <<"GET">> -> list_subscriptions(Req); - <<"DELETE">> -> cancel_subscription(Req); - _ -> send_error(Req, 405, <<"Method not allowed">>) - end. - -%% GET /v1/admin/subscriptions - список всех подписок -list_subscriptions(Req) -> - case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> - case is_admin(AdminId) of - true -> - {ok, Subscriptions} = core_subscription:list_all(), - Response = [subscription_to_json(S) || S <- Subscriptions], - send_json(Req1, 200, Response); - false -> - send_error(Req1, 403, <<"Admin access required">>) - end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. - -%% DELETE /v1/admin/subscriptions/:id - отменить подписку -cancel_subscription(Req) -> - case handler_auth:authenticate(Req) of - {ok, AdminId, Req1} -> - SubscriptionId = cowboy_req:binding(id, Req1), - case logic_subscription:cancel_subscription(AdminId, SubscriptionId) of - {ok, Subscription} -> - Response = subscription_to_json(Subscription), - send_json(Req1, 200, Response); - {error, access_denied} -> - send_error(Req1, 403, <<"Admin access required">>); - {error, not_found} -> - send_error(Req1, 404, <<"Subscription not found">>); - {error, _} -> - send_error(Req1, 500, <<"Internal server error">>) - end; - {error, Code, Message, Req1} -> - send_error(Req1, Code, Message) - end. - -%% Вспомогательные функции -is_admin(UserId) -> - case core_user:get_by_id(UserId) of - {ok, User} -> User#user.role =:= admin; - _ -> false - end. - -subscription_to_json(Subscription) -> - #{ - id => Subscription#subscription.id, - user_id => Subscription#subscription.user_id, - plan => Subscription#subscription.plan, - status => Subscription#subscription.status, - trial_used => Subscription#subscription.trial_used, - started_at => datetime_to_iso8601(Subscription#subscription.started_at), - expires_at => datetime_to_iso8601(Subscription#subscription.expires_at), - created_at => datetime_to_iso8601(Subscription#subscription.created_at), - updated_at => datetime_to_iso8601(Subscription#subscription.updated_at) - }. - -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])). - -send_json(Req, Status, Data) -> - Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), - {ok, Body, []}. - -send_error(Req, Status, Message) -> - Body = jsx:encode(#{error => Message}), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), - {ok, Body, []}. \ No newline at end of file diff --git a/src/handlers/handler_login.erl b/src/handlers/handler_login.erl index ed38b90..7d6bab3 100644 --- a/src/handlers/handler_login.erl +++ b/src/handlers/handler_login.erl @@ -1,10 +1,11 @@ -module(handler_login). --include("records.hrl"). - +-behaviour(cowboy_handler). -export([init/2]). -init(Req, Opts) -> - handle(Req, Opts). +-include("records.hrl"). %% ← необходим для #session{} + +init(Req0, State) -> + handle(Req0, State). handle(Req, _Opts) -> case cowboy_req:method(Req) of @@ -18,41 +19,31 @@ handle(Req, _Opts) -> _ -> try jsx:decode(Body, [return_maps]) of #{<<"email">> := Email, <<"password">> := Password} -> - case core_user:get_by_email(Email) of - {ok, User} -> - case logic_auth:verify_password(Password, User#user.password_hash) of - {ok, true} -> - case User#user.status of - active -> - Token = logic_auth:generate_jwt(User#user.id, User#user.role), - {RefreshToken, ExpiresAt} = logic_auth:generate_refresh_token(User#user.id), - save_refresh_token(User#user.id, RefreshToken, ExpiresAt), - Response = #{ - user => #{ - id => User#user.id, - email => User#user.email, - role => User#user.role - }, - token => Token, - refresh_token => RefreshToken - }, - send_json(Req1, 200, Response); - frozen -> - send_error(Req1, 403, <<"Account frozen">>); - deleted -> - send_error(Req1, 403, <<"Account deleted">>) - end; - _ -> - send_error(Req1, 401, <<"Invalid credentials">>) - end; - {error, not_found} -> + case auth:authenticate_user_request(Req1, Email, Password) of + {ok, Token, User} -> + {RefreshToken, ExpiresAt} = auth:generate_refresh_token(maps:get(id, User)), + save_refresh_token(maps:get(id, User), RefreshToken, ExpiresAt), + Response = #{ + user => #{ + id => maps:get(id, User), + email => maps:get(email, User), + role => maps:get(role, User) + }, + token => Token, + refresh_token => RefreshToken + }, + send_json(Req1, 200, Response); + {error, frozen} -> + send_error(Req1, 403, <<"Account frozen">>); + {error, deleted} -> + send_error(Req1, 403, <<"Account deleted">>); + {error, _Reason} -> send_error(Req1, 401, <<"Invalid credentials">>) end; _ -> send_error(Req1, 400, <<"Missing email or password">>) catch - _:_ -> - send_error(Req1, 400, <<"Invalid JSON">>) + _:_ -> send_error(Req1, 400, <<"Invalid JSON">>) end end; false -> @@ -63,7 +54,7 @@ handle(Req, _Opts) -> end. save_refresh_token(UserId, Token, ExpiresAt) -> - Session = #session{ + Session = #session{ %% record определён в records.hrl token = Token, user_id = UserId, expires_at = ExpiresAt, @@ -73,10 +64,14 @@ save_refresh_token(UserId, Token, ExpiresAt) -> send_json(Req, Status, Data) -> Body = jsx:encode(Data), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + cowboy_req:reply(Status, #{ + <<"content-type">> => <<"application/json">> + }, Body, Req), {ok, Body, []}. send_error(Req, Status, Message) -> Body = jsx:encode(#{error => Message}), - cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req), + cowboy_req:reply(Status, #{ + <<"content-type">> => <<"application/json">> + }, Body, Req), {ok, Body, []}. \ No newline at end of file diff --git a/src/infra/auth.erl b/src/infra/auth.erl new file mode 100644 index 0000000..e425d23 --- /dev/null +++ b/src/infra/auth.erl @@ -0,0 +1,157 @@ +-module(auth). +-export([ + generate_user_token/2, + generate_admin_token/2, + verify_user_token/1, + verify_admin_token/1, + authenticate_user_request/3, + authenticate_admin_request/3, + generate_refresh_token/1 +]). + +%% ========== КОНФИГУРАЦИЯ СЕКРЕТОВ ========== + +-spec get_user_secret() -> binary(). +get_user_secret() -> + case application:get_env(eventhub, jwt_secret) of + {ok, Secret} when is_binary(Secret) -> Secret; + undefined -> get_user_secret_from_env() + end. + +get_user_secret_from_env() -> + case os:getenv("JWT_SECRET") of + false -> <<"user-secret-key-32-bytes-minimum!">>; + S -> list_to_binary(S) + end. + +-spec get_admin_secret() -> binary(). +get_admin_secret() -> + case application:get_env(eventhub, admin_jwt_secret) of + {ok, Secret} when is_binary(Secret) -> Secret; + undefined -> get_admin_secret_from_env() + end. + +get_admin_secret_from_env() -> + case os:getenv("ADMIN_JWT_SECRET") of + false -> <<"admin-secret-key-32-bytes-minimum!">>; + S -> list_to_binary(S) + end. + +-spec get_user_jwk() -> jose_jwk:key(). +get_user_jwk() -> jose_jwk:from_oct(get_user_secret()). + +-spec get_admin_jwk() -> jose_jwk:key(). +get_admin_jwk() -> jose_jwk:from_oct(get_admin_secret()). + +%% ========== ГЕНЕРАЦИЯ ТОКЕНОВ ========== + +-spec generate_user_token(UserId :: binary(), Role :: binary()) -> binary(). +generate_user_token(UserId, Role) -> + generate_token(get_user_jwk(), UserId, Role, <<"user">>). + +-spec generate_admin_token(UserId :: binary(), Role :: binary()) -> binary(). +generate_admin_token(UserId, Role) -> + generate_token(get_admin_jwk(), UserId, Role, <<"admin">>). + +generate_token(JWK, UserId, Role, Audience) -> + ExpTime = erlang:system_time(second) + 86400, + Claims = #{ + <<"user_id">> => UserId, + <<"role">> => Role, + <<"aud">> => Audience, + <<"exp">> => ExpTime, + <<"iat">> => erlang:system_time(second) + }, + JWT = jose_jwt:sign(JWK, #{<<"alg">> => <<"HS256">>}, Claims), + {_, Token} = jose_jws:compact(JWT), + Token. + +%% ========== ПРОВЕРКА ТОКЕНОВ ========== + +-spec verify_user_token(Token :: binary()) -> + {ok, UserId :: binary(), Role :: binary()} | {error, atom()}. +verify_user_token(Token) -> + verify_token(get_user_jwk(), Token, <<"user">>). + +-spec verify_admin_token(Token :: binary()) -> + {ok, UserId :: binary(), Role :: binary()} | {error, atom()}. +verify_admin_token(Token) -> + verify_token(get_admin_jwk(), Token, <<"admin">>). + +verify_token(JWK, Token, ExpectedAud) -> + try + case jose_jwt:verify(JWK, Token) of + {true, {jose_jwt, Claims}, _} -> + validate_claims(Claims, ExpectedAud); + {true, Claims, _} when is_map(Claims) -> + validate_claims(Claims, ExpectedAud); + _ -> + {error, invalid_signature} + end + catch + _:_ -> {error, invalid_token} + end. + +validate_claims(Claims, ExpectedAud) -> + case maps:find(<<"aud">>, Claims) of + {ok, ExpectedAud} -> + case maps:find(<<"exp">>, Claims) of + {ok, Exp} when is_integer(Exp) -> + Now = erlang:system_time(second), + if + Exp > Now -> + UserId = maps:get(<<"user_id">>, Claims, undefined), + Role = maps:get(<<"role">>, Claims, <<"user">>), + {ok, UserId, Role}; + true -> + {error, expired} + end; + {ok, _Exp} -> {error, expired}; + _ -> {error, no_expiration} + end; + {ok, _} -> {error, invalid_audience}; + error -> {error, missing_audience} + end. + +%% ========== АУТЕНТИФИКАЦИЯ ЗАПРОСА ========== + +-spec authenticate_user_request(Req :: cowboy_req:req(), Email :: binary(), Password :: binary()) -> + {ok, Token :: binary(), User :: map()} | {error, atom()}. +authenticate_user_request(_Req, Email, Password) -> + case logic_auth:authenticate_user(Email, Password) of + {ok, User} -> + UserId = maps:get(id, User), + Role = maps:get(role, User, <<"user">>), + Token = generate_user_token(UserId, Role), + {ok, Token, User}; + Error -> Error + end. + +-spec authenticate_admin_request(Req :: cowboy_req:req(), Email :: binary(), Password :: binary()) -> + {ok, Token :: binary(), User :: map()} | {error, atom()}. +authenticate_admin_request(_Req, Email, Password) -> + case logic_auth:authenticate_user(Email, Password) of + {ok, User} -> + Role = maps:get(role, User, <<"admin">>), + case is_admin_role(Role) of + true -> + UserId = maps:get(id, User), + Token = generate_admin_token(UserId, Role), + {ok, Token, User}; + false -> {error, insufficient_permissions} + end; + Error -> Error + end. + +%% ========== REFRESH TOKEN ========== + +-spec generate_refresh_token(UserId :: binary()) -> {binary(), integer()}. +generate_refresh_token(_UserId) -> + RefreshToken = base64:encode(crypto:strong_rand_bytes(32)), + ExpiresAt = erlang:system_time(second) + 2592000, % 30 дней + {RefreshToken, ExpiresAt}. + +%% ========== ВНУТРЕННИЕ ========== + +is_admin_role(Role) -> + lists:member(Role, [<<"admin">>, <<"superadmin">>, <<"moderator">>, <<"support">>]). \ No newline at end of file diff --git a/test/unit/admin_handler_banned_words_tests.erl b/test/unit/admin_handler_banned_words_tests.erl new file mode 100644 index 0000000..a314b2c --- /dev/null +++ b/test/unit/admin_handler_banned_words_tests.erl @@ -0,0 +1,158 @@ +-module(admin_handler_banned_words_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +setup() -> + ok = meck:new(cowboy_req, [non_strict]), + ok = meck:new(handler_auth, [non_strict]), + ok = meck:new(core_user, [non_strict]), + ok = meck:new(core_banned_words, [non_strict]), + ok = meck:expect(cowboy_req, reply, + fun(Code, Headers, Body, Req) -> + put(test_reply, {Code, Headers, Body, Req}) + end), + ok. + +cleanup(_) -> + meck:unload(core_banned_words), + meck:unload(core_user), + meck:unload(handler_auth), + meck:unload(cowboy_req). + +admin_banned_words_test_() -> + {setup, fun setup/0, fun cleanup/1, [ + {"GET /admin/banned-words – success", fun test_list/0}, + {"GET /admin/banned-words – forbidden", fun test_list_forbidden/0}, + {"POST /admin/banned-words – success", fun test_add/0}, + {"POST /admin/banned-words – missing field", fun test_add_missing/0}, + {"POST /admin/banned-words – already exists", fun test_add_exists/0}, + {"DELETE /admin/banned-words/:word – success", fun test_delete/0}, + {"DELETE /admin/banned-words/:word – not found", fun test_delete_not_found/0}, + {"PUT /admin/banned-words/:word – success", fun test_update/0}, + {"PUT /admin/banned-words/:word – not found", fun test_update_not_found/0}, + {"PATCH /admin/banned-words – method not allowed", fun test_wrong_method/0} + ]}. + +test_list() -> + ok = meck:expect(cowboy_req, binding, fun(word, _) -> undefined end), + ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end), + 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), + Words = [ + #banned_word{id = <<"bw1">>, word = <<"badword">>, added_by = <<"adm1">>, added_at = {{2026,4,27},{12,0,0}}}, + #banned_word{id = <<"bw2">>, word = <<"spamword">>, added_by = <<"adm2">>, added_at = {{2026,4,27},{12,30,0}}} + ], + ok = meck:expect(core_banned_words, list_banned_words, fun() -> Words end), + {ok, _, _} = admin_handler_banned_words:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + Result = jsx:decode(RespBody, [return_maps]), + ?assertEqual(2, length(Result)), + First = hd(Result), + ?assertEqual(<<"badword">>, maps:get(<<"word">>, First)), + ?assertEqual(<<"adm1">>, maps:get(<<"added_by">>, First)). + +test_list_forbidden() -> + ok = meck:expect(cowboy_req, binding, fun(word, _) -> 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, _, _} = admin_handler_banned_words:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(403, Status). + +test_add() -> + ok = meck:expect(cowboy_req, binding, fun(word, _) -> undefined end), + ok = meck:expect(cowboy_req, method, fun(_) -> <<"POST">> end), + 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(#{<<"word">> => <<"banned">>}), Req} end), + NewBW = #banned_word{id = <<"bw_new">>, word = <<"banned">>, added_by = <<"adm1">>, added_at = {{2026,4,27},{13,0,0}}}, + ok = meck:expect(core_banned_words, add_banned_word, fun(<<"banned">>, <<"adm1">>) -> {ok, NewBW} end), + {ok, _, _} = admin_handler_banned_words:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(201, Status), + #{<<"word">> := <<"banned">>, <<"added_by">> := <<"adm1">>} = jsx:decode(RespBody, [return_maps]). + +test_add_missing() -> + ok = meck:expect(cowboy_req, binding, fun(word, _) -> undefined end), + ok = meck:expect(cowboy_req, method, fun(_) -> <<"POST">> end), + 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(#{<<"other">> => <<"data">>}), Req} end), + {ok, _, _} = admin_handler_banned_words:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(400, Status). + +test_add_exists() -> + ok = meck:expect(cowboy_req, binding, fun(word, _) -> undefined end), + ok = meck:expect(cowboy_req, method, fun(_) -> <<"POST">> end), + 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(#{<<"word">> => <<"already">>}), Req} end), + ok = meck:expect(core_banned_words, add_banned_word, fun(_, _) -> {error, already_exists} end), + {ok, _, _} = admin_handler_banned_words:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(409, Status). + +test_delete() -> + ok = meck:expect(cowboy_req, binding, fun(word, _) -> <<"badword">> end), + ok = meck:expect(cowboy_req, method, fun(_) -> <<"DELETE">> end), + 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_banned_words, remove_banned_word, fun(<<"badword">>) -> {ok, deleted} end), + {ok, _, _} = admin_handler_banned_words:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + #{<<"status">> := <<"deleted">>} = jsx:decode(RespBody, [return_maps]). + +test_delete_not_found() -> + ok = meck:expect(cowboy_req, binding, fun(word, _) -> <<"unknown">> end), + ok = meck:expect(cowboy_req, method, fun(_) -> <<"DELETE">> end), + 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_banned_words, remove_banned_word, fun(_) -> {error, not_found} end), + {ok, _, _} = admin_handler_banned_words:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(404, Status). + +test_update() -> + ok = meck:expect(cowboy_req, binding, fun(word, _) -> <<"oldword">> end), + ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), + 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(#{<<"word">> => <<"newword">>}), Req} end), + UpdatedBW = #banned_word{id = <<"bw1">>, word = <<"newword">>, added_by = <<"adm1">>, added_at = {{2026,4,27},{12,0,0}}}, + ok = meck:expect(core_banned_words, update_banned_word, fun(_, _) -> {ok, UpdatedBW} end), + {ok, _, _} = admin_handler_banned_words:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + Resp = jsx:decode(RespBody, [return_maps]), + ?assertEqual(<<"newword">>, maps:get(<<"word">>, Resp)), + ?assertEqual(<<"adm1">>, maps:get(<<"added_by">>, Resp)). + +test_update_not_found() -> + ok = meck:expect(cowboy_req, binding, fun(word, _) -> <<"missing">> end), + ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), + 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(#{<<"word">> => <<"newword">>}), Req} end), + ok = meck:expect(core_banned_words, update_banned_word, fun(_, _) -> {error, not_found} end), + {ok, _, _} = admin_handler_banned_words:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(404, Status). + +test_wrong_method() -> + ok = meck:expect(cowboy_req, binding, fun(word, _) -> undefined end), + ok = meck:expect(cowboy_req, method, fun(_) -> <<"PATCH">> end), + {ok, _, _} = admin_handler_banned_words:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(405, Status), + #{<<"error">> := <<"Method not allowed">>} = jsx:decode(RespBody, [return_maps]). \ No newline at end of file diff --git a/test/unit/admin_handler_health_tests.erl b/test/unit/admin_handler_health_tests.erl new file mode 100644 index 0000000..b02dbf8 --- /dev/null +++ b/test/unit/admin_handler_health_tests.erl @@ -0,0 +1,49 @@ +-module(admin_handler_health_tests). +-include_lib("eunit/include/eunit.hrl"). + +%% ------------------------------------------------------------------ +%% Фикстуры +%% ------------------------------------------------------------------ +setup() -> + ok = meck:new(cowboy_req, [non_strict]), + ok. + +cleanup(_) -> + meck:unload(cowboy_req). + +%% ------------------------------------------------------------------ +%% Тесты +%% ------------------------------------------------------------------ +admin_handler_health_test_() -> + {setup, fun setup/0, fun cleanup/1, [ + {"GET /admin/health returns 200 with status ok", + fun test_health_get/0}, + {"POST /admin/health returns 405 Method not allowed", + fun test_health_post/0} + ]}. + +%% ── Успешный GET ───────────────────────────────────────────── +test_health_get() -> + % Мокаем method → <<"GET">> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end), + % reply/4 будем перехватывать + ok = meck:expect(cowboy_req, reply, + fun(Code, Headers, Body, Req) -> + put(test_reply, {Code, Headers, Body, Req}) + end), + {ok, _, _} = admin_handler_health:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + ?assertEqual(#{<<"status">> => <<"ok">>}, jsx:decode(RespBody, [return_maps])). + +%% ── Метод не разрешён ─────────────────────────────────────── +test_health_post() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"POST">> end), + ok = meck:expect(cowboy_req, reply, + fun(Code, Headers, Body, Req) -> + put(test_reply, {Code, Headers, Body, Req}) + end), + {ok, _, _} = admin_handler_health:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(405, Status), + ?assertEqual(#{<<"error">> => <<"Method not allowed">>}, jsx:decode(RespBody, [return_maps])). \ No newline at end of file diff --git a/test/unit/admin_handler_login_tests.erl b/test/unit/admin_handler_login_tests.erl new file mode 100644 index 0000000..71a4240 --- /dev/null +++ b/test/unit/admin_handler_login_tests.erl @@ -0,0 +1,104 @@ +-module(admin_handler_login_tests). +-include_lib("eunit/include/eunit.hrl"). + +-define(JWT_SECRET, <<"test-user-secret-key-32-byt!">>). +-define(ADMIN_JWT_SECRET, <<"test-admin-secret-key-32-b">>). + +setup() -> + ok = meck:new(logic_auth, [non_strict]), + ok = meck:new(cowboy_req, [non_strict]), + application:set_env(eventhub, jwt_secret, ?JWT_SECRET), + application:set_env(eventhub, admin_jwt_secret, ?ADMIN_JWT_SECRET), + {ok, _} = application:ensure_all_started(jose). + +cleanup(_) -> + application:unset_env(eventhub, jwt_secret), + application:unset_env(eventhub, admin_jwt_secret), + application:stop(jose), + meck:unload(cowboy_req), + meck:unload(logic_auth), + ok. + +admin_handler_login_test_() -> + {setup, fun setup/0, fun cleanup/1, [ + {"Valid admin login returns 200 and token", fun test_valid_admin_login/0}, + {"Invalid credentials return 401", fun test_invalid_credentials/0}, + {"Non‑admin role returns 403", fun test_insufficient_permissions/0}, + {"Malformed JSON returns 400", fun test_malformed_json/0}, + {"Missing body returns 400", fun test_missing_body/0}, + {"Wrong HTTP method returns 405", fun test_wrong_method/0} + ]}. + +%% ── Вспомогательная функция для создания запроса и ожидания reply ── +prepare_req(Method, HasBody, Body) -> + ok = meck:expect(cowboy_req, method, fun(_) -> Method end), + ok = meck:expect(cowboy_req, has_body, fun(_) -> HasBody end), + case {HasBody, Body} of + {true, undefined} -> ok; + {true, _} -> + ok = meck:expect(cowboy_req, read_body, + fun(Req) -> {ok, Body, Req} end); + {false, _} -> ok + end, + % Устанавливаем мок на reply, который сохраняет ответ в словаре процесса + meck:expect(cowboy_req, reply, + fun(Code, Headers, RespBody, Req) -> + put(test_reply, {Code, Headers, RespBody}), + Req + end), + req. + +%% ── Тесты ──────────────────────────────────────────────────── + +test_valid_admin_login() -> + UserMap = #{id => <<"adm1">>, email => <<"admin@test.com">>, role => <<"superadmin">>}, + ok = meck:expect(logic_auth, authenticate_user, + fun(<<"admin@test.com">>, <<"pass">>) -> {ok, UserMap} end), + Req0 = prepare_req(<<"POST">>, true, jsx:encode(#{email => <<"admin@test.com">>, password => <<"pass">>})), + {ok, _, _} = admin_handler_login:init(Req0, []), + {Code, Headers, Body} = get(test_reply), + ?assertEqual(200, Code), + ?assertEqual(<<"application/json">>, maps:get(<<"content-type">>, Headers)), + Resp = jsx:decode(Body, [return_maps]), + ?assert(is_map_key(<<"token">>, Resp)), + ?assertEqual(<<"superadmin">>, maps:get(<<"role">>, maps:get(<<"user">>, Resp))). + +test_invalid_credentials() -> + ok = meck:expect(logic_auth, authenticate_user, + fun(_, _) -> {error, bad_credentials} end), + Req0 = prepare_req(<<"POST">>, true, jsx:encode(#{email => <<"bad@test.com">>, password => <<"wrong">>})), + {ok, _, _} = admin_handler_login:init(Req0, []), + {Code, _, Body} = get(test_reply), + ?assertEqual(401, Code), + #{<<"error">> := <<"bad_credentials">>} = jsx:decode(Body, [return_maps]). + +test_insufficient_permissions() -> + UserMap = #{id => <<"user1">>, email => <<"user@test.com">>, role => <<"user">>}, + ok = meck:expect(logic_auth, authenticate_user, + fun(_, _) -> {ok, UserMap} end), + Req0 = prepare_req(<<"POST">>, true, jsx:encode(#{email => <<"user@test.com">>, password => <<"pass">>})), + {ok, _, _} = admin_handler_login:init(Req0, []), + {Code, _, Body} = get(test_reply), + ?assertEqual(403, Code), + #{<<"error">> := <<"insufficient_permissions">>} = jsx:decode(Body, [return_maps]). + +test_malformed_json() -> + Req0 = prepare_req(<<"POST">>, true, <<"not a json">>), + {ok, _, _} = admin_handler_login:init(Req0, []), + {Code, _, Body} = get(test_reply), + ?assertEqual(400, Code), + #{<<"error">> := <<"invalid_request">>} = jsx:decode(Body, [return_maps]). + +test_missing_body() -> + Req0 = prepare_req(<<"POST">>, false, undefined), + {ok, _, _} = admin_handler_login:init(Req0, []), + {Code, _, Body} = get(test_reply), + ?assertEqual(400, Code), + #{<<"error">> := <<"Missing request body">>} = jsx:decode(Body, [return_maps]). + +test_wrong_method() -> + Req0 = prepare_req(<<"GET">>, false, undefined), + {ok, _, _} = admin_handler_login:init(Req0, []), + {Code, _, Body} = get(test_reply), + ?assertEqual(405, Code), + #{<<"error">> := <<"Method not allowed">>} = jsx:decode(Body, [return_maps]). \ No newline at end of file diff --git a/test/unit/admin_handler_moderation_tests.erl b/test/unit/admin_handler_moderation_tests.erl new file mode 100644 index 0000000..685a3b3 --- /dev/null +++ b/test/unit/admin_handler_moderation_tests.erl @@ -0,0 +1,189 @@ +-module(admin_handler_moderation_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +setup() -> + ok = meck:new(cowboy_req, [non_strict]), + ok = meck:new(handler_auth, [non_strict]), + ok = meck:new(core_user, [non_strict]), + ok = meck:new(core_calendar, [non_strict]), + ok = meck:new(core_event, [non_strict]), + ok = meck:new(core_review, [non_strict]), + ok = meck:expect(cowboy_req, reply, + fun(Code, Headers, Body, Req) -> + put(test_reply, {Code, Headers, Body, Req}) + end), + ok. + +cleanup(_) -> + meck:unload(core_review), + meck:unload(core_event), + meck:unload(core_calendar), + meck:unload(core_user), + meck:unload(handler_auth), + meck:unload(cowboy_req). + +admin_moderation_test_() -> + {setup, fun setup/0, fun cleanup/1, [ + {"Freeze calendar – success", fun test_freeze_calendar/0}, + {"Freeze calendar – not found", fun test_freeze_calendar_not_found/0}, + {"Unfreeze calendar – success", fun test_unfreeze_calendar/0}, + {"Freeze event – success", fun test_freeze_event/0}, + {"Unfreeze event – success", fun test_unfreeze_event/0}, + {"Hide review – success", fun test_hide_review/0}, + {"Show review – success", fun test_show_review/0}, + {"Block user – success", fun test_block_user/0}, + {"Unblock user – success", fun test_unblock_user/0}, + {"Invalid target type", fun test_invalid_target/0}, + {"Invalid action", fun test_invalid_action/0}, + {"Missing action field", fun test_missing_action/0}, + {"Forbidden – non admin", fun test_forbidden/0}, + {"Wrong method – POST", fun test_wrong_method/0} + ]}. + +%% ── Вспомогательные функции ────────────────────────────── +prepare_req(TargetType, TargetId, Action) -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), + 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, binding, + fun(target_type, _) -> TargetType; + (id, _) -> TargetId + end), + ok = meck:expect(cowboy_req, read_body, + fun(Req) -> {ok, jsx:encode(#{<<"action">> => Action}), Req} end). + +%% ── Календари ─────────────────────────────────────────── +test_freeze_calendar() -> + prepare_req(<<"calendar">>, <<"c1">>, <<"freeze">>), + Frozen = #calendar{id = <<"c1">>, title = <<"Test">>, status = frozen}, + ok = meck:expect(core_calendar, freeze, fun(<<"c1">>) -> {ok, Frozen} end), + {ok, _, _} = admin_handler_moderation:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + #{<<"status">> := <<"frozen">>} = jsx:decode(RespBody, [return_maps]). + +test_freeze_calendar_not_found() -> + prepare_req(<<"calendar">>, <<"c99">>, <<"freeze">>), + ok = meck:expect(core_calendar, freeze, fun(_) -> {error, not_found} end), + {ok, _, _} = admin_handler_moderation:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(404, Status). + +test_unfreeze_calendar() -> + prepare_req(<<"calendar">>, <<"c1">>, <<"unfreeze">>), + Unfrozen = #calendar{id = <<"c1">>, title = <<"Test">>, status = active}, + ok = meck:expect(core_calendar, unfreeze, fun(<<"c1">>) -> {ok, Unfrozen} end), + {ok, _, _} = admin_handler_moderation:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + #{<<"status">> := <<"active">>} = jsx:decode(RespBody, [return_maps]). + +%% ── События ───────────────────────────────────────────── +test_freeze_event() -> + prepare_req(<<"event">>, <<"e1">>, <<"freeze">>), + FrozenE = #event{id = <<"e1">>, title = <<"Event1">>, status = frozen}, + ok = meck:expect(core_event, freeze, fun(<<"e1">>) -> {ok, FrozenE} end), + {ok, _, _} = admin_handler_moderation:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + #{<<"status">> := <<"frozen">>} = jsx:decode(RespBody, [return_maps]). + +test_unfreeze_event() -> + prepare_req(<<"event">>, <<"e1">>, <<"unfreeze">>), + UnfrozenE = #event{id = <<"e1">>, title = <<"Event1">>, status = active}, + ok = meck:expect(core_event, unfreeze, fun(<<"e1">>) -> {ok, UnfrozenE} end), + {ok, _, _} = admin_handler_moderation:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + #{<<"status">> := <<"active">>} = jsx:decode(RespBody, [return_maps]). + +%% ── Отзывы ────────────────────────────────────────────── +test_hide_review() -> + prepare_req(<<"review">>, <<"r1">>, <<"hide">>), + Hidden = #review{id = <<"r1">>, status = hidden}, + ok = meck:expect(core_review, hide, fun(<<"r1">>) -> {ok, Hidden} end), + {ok, _, _} = admin_handler_moderation:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + #{<<"status">> := <<"hidden">>} = jsx:decode(RespBody, [return_maps]). + +test_show_review() -> + prepare_req(<<"review">>, <<"r1">>, <<"show">>), + Visible = #review{id = <<"r1">>, status = active}, + ok = meck:expect(core_review, show, fun(<<"r1">>) -> {ok, Visible} end), + {ok, _, _} = admin_handler_moderation:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + #{<<"status">> := <<"active">>} = jsx:decode(RespBody, [return_maps]). + +%% ── Пользователи ──────────────────────────────────────── +test_block_user() -> + prepare_req(<<"user">>, <<"u1">>, <<"block">>), + Blocked = #user{id = <<"u1">>, email = <<"user@test.com">>, status = frozen}, + ok = meck:expect(core_user, block, fun(<<"u1">>) -> {ok, Blocked} end), + {ok, _, _} = admin_handler_moderation:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + #{<<"status">> := <<"frozen">>} = jsx:decode(RespBody, [return_maps]). + +test_unblock_user() -> + prepare_req(<<"user">>, <<"u1">>, <<"unblock">>), + Unblocked = #user{id = <<"u1">>, email = <<"user@test.com">>, status = active}, + ok = meck:expect(core_user, unblock, fun(<<"u1">>) -> {ok, Unblocked} end), + {ok, _, _} = admin_handler_moderation:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + #{<<"status">> := <<"active">>} = jsx:decode(RespBody, [return_maps]). + +%% ── Ошибки ────────────────────────────────────────────── +test_invalid_target() -> + prepare_req(<<"bad_type">>, <<"x">>, <<"freeze">>), + {ok, _, _} = admin_handler_moderation:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(400, Status). + +test_invalid_action() -> + prepare_req(<<"calendar">>, <<"c1">>, <<"delete">>), + {ok, _, _} = admin_handler_moderation:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(400, Status). + +test_missing_action() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), + 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, binding, + fun(target_type, _) -> <<"calendar">>; + (id, _) -> <<"c1">> + end), + ok = meck:expect(cowboy_req, read_body, + fun(Req) -> {ok, jsx:encode(#{<<"other">> => <<"data">>}), Req} end), + {ok, _, _} = admin_handler_moderation:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(400, Status). + +test_forbidden() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), + ok = meck:expect(handler_auth, authenticate, + fun(Req) -> {error, 403, <<"Admin access required">>, Req} end), + {ok, _, _} = admin_handler_moderation:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(403, Status). + +test_wrong_method() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"POST">> end), + ok = meck:expect(cowboy_req, reply, + fun(Code, Headers, Body, Req) -> + put(test_reply, {Code, Headers, Body, Req}) + end), + {ok, _, _} = admin_handler_moderation:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(405, Status), + #{<<"error">> := <<"Method not allowed">>} = jsx:decode(RespBody, [return_maps]). \ 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 new file mode 100644 index 0000000..a9e7724 --- /dev/null +++ b/test/unit/admin_handler_report_by_id_tests.erl @@ -0,0 +1,145 @@ +-module(admin_handler_report_by_id_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +setup() -> + ok = meck:new(cowboy_req, [non_strict]), + ok = meck:new(handler_auth, [non_strict]), + ok = meck:new(core_user, [non_strict]), + ok = meck:new(core_report, [non_strict]), + ok = meck:expect(cowboy_req, reply, + fun(Code, Headers, Body, Req) -> + put(test_reply, {Code, Headers, Body, Req}) + end), + ok. + +cleanup(_) -> + meck:unload(core_report), + meck:unload(core_user), + meck:unload(handler_auth), + meck:unload(cowboy_req). + +admin_report_by_id_test_() -> + {setup, fun setup/0, fun cleanup/1, [ + {"GET /admin/reports/:id – success", fun test_get_report/0}, + {"GET /admin/reports/:id – not found", fun test_get_report_not_found/0}, + {"GET /admin/reports/:id – forbidden", fun test_get_report_forbidden/0}, + {"PUT /admin/reports/:id – success", fun test_update_report/0}, + {"PUT /admin/reports/:id – not found", fun test_update_report_not_found/0}, + {"PUT /admin/reports/:id – bad JSON", fun test_update_report_bad_json/0}, + {"DELETE /admin/reports/:id – method not allowed", fun test_wrong_method/0} + ]}. + +%% GET – успех +test_get_report() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end), + 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, binding, + fun(id, _) -> <<"r1">> end), + Report = #report{ + id = <<"r1">>, + reporter_id = <<"u1">>, + target_type = <<"event">>, + target_id = <<"e1">>, + reason = <<"spam">>, + status = <<"new">>, + created_at = {{2026,4,26},{12,0,0}}, + resolved_at = undefined + }, + ok = meck:expect(core_report, get_by_id, + fun(<<"r1">>) -> {ok, Report} end), + {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]). + +%% GET – не найдено +test_get_report_not_found() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end), + 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, binding, + fun(id, _) -> <<"r99">> end), + ok = meck:expect(core_report, get_by_id, + fun(_) -> {error, not_found} end), + {ok, _, _} = admin_handler_report_by_id:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(404, Status). + +%% GET – запрещён +test_get_report_forbidden() -> + 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, _, _} = admin_handler_report_by_id:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(403, Status). + +%% PUT – успех +test_update_report() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), + 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, binding, + 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">>}, + ok = meck:expect(core_report, update_status, + fun(<<"r1">>, <<"reviewed">>) -> {ok, Updated} end), + {ok, _, _} = admin_handler_report_by_id:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + #{<<"status">> := <<"reviewed">>} = jsx:decode(RespBody, [return_maps]). + +%% PUT – не найдено +test_update_report_not_found() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), + 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, binding, + fun(id, _) -> <<"r99">> end), + 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), + {ok, _, _} = admin_handler_report_by_id:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(404, Status). + +%% PUT – невалидный JSON +test_update_report_bad_json() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), + 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, binding, + fun(id, _) -> <<"r1">> end), + ok = meck:expect(cowboy_req, read_body, + fun(Req) -> {ok, <<"bad json">>, Req} end), + {ok, _, _} = admin_handler_report_by_id:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(400, Status). + +%% Неверный метод +test_wrong_method() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"DELETE">> end), + {ok, _, _} = admin_handler_report_by_id:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(405, Status), + #{<<"error">> := <<"Method not allowed">>} = jsx:decode(RespBody, [return_maps]). \ No newline at end of file diff --git a/test/unit/admin_handler_reports_tests.erl b/test/unit/admin_handler_reports_tests.erl new file mode 100644 index 0000000..c679f84 --- /dev/null +++ b/test/unit/admin_handler_reports_tests.erl @@ -0,0 +1,121 @@ +-module(admin_handler_reports_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +setup() -> + ok = meck:new(cowboy_req, [non_strict]), + ok = meck:new(handler_auth, [non_strict]), + ok = meck:new(core_user, [non_strict]), + ok = meck:new(core_report, [non_strict]), + ok = meck:expect(cowboy_req, reply, + fun(Code, Headers, Body, Req) -> + put(test_reply, {Code, Headers, Body, Req}) + end), + ok. + +cleanup(_) -> + meck:unload(core_report), + meck:unload(core_user), + meck:unload(handler_auth), + meck:unload(cowboy_req). + +admin_reports_test_() -> + {setup, fun setup/0, fun cleanup/1, [ + {"GET /admin/reports – success", fun test_list_reports/0}, + {"GET /admin/reports – forbidden", fun test_list_reports_forbidden/0}, + {"PUT /admin/reports/:id – success", fun test_update_report/0}, + {"PUT /admin/reports/:id – missing status", fun test_update_report_bad_json/0}, + {"PUT /admin/reports/:id – not found", fun test_update_report_not_found/0}, + {"POST /admin/reports – method not allowed", fun test_wrong_method/0} + ]}. + +test_list_reports() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end), + 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), + Report = #report{ + id = <<"r1">>, + reporter_id = <<"u1">>, + target_type = <<"event">>, + target_id = <<"e1">>, + reason = <<"spam">>, + status = <<"new">>, + created_at = {{2026,4,26},{12,0,0}}, + resolved_at = undefined + }, + ok = meck:expect(core_report, list_reports, fun() -> [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]). + +test_list_reports_forbidden() -> + 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, _, _} = admin_handler_reports:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(403, Status), + #{<<"error">> := <<"Admin access required">>} = jsx:decode(RespBody, [return_maps]). + +test_update_report() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), + 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, binding, + 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">>}, + ok = meck:expect(core_report, update_status, + fun(<<"r1">>, <<"reviewed">>) -> {ok, Updated} end), + {ok, _, _} = admin_handler_reports:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + #{<<"status">> := <<"reviewed">>} = jsx:decode(RespBody, [return_maps]). + +test_update_report_bad_json() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), + 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, binding, + fun(id, _) -> <<"r1">> end), + ok = meck:expect(cowboy_req, read_body, + fun(Req) -> {ok, <<"bad json">>, Req} end), + {ok, _, _} = admin_handler_reports:init(req, []), + {Status, _, _, _} = erase(test_reply), %% исправлено: четыре элемента + ?assertEqual(400, Status). + +test_update_report_not_found() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), + 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, binding, + fun(id, _) -> <<"r99">> end), + 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), + {ok, _, _} = admin_handler_reports:init(req, []), + {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, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(405, Status), + #{<<"error">> := <<"Method not allowed">>} = jsx:decode(RespBody, [return_maps]). \ No newline at end of file diff --git a/test/unit/admin_handler_reviews_tests.erl b/test/unit/admin_handler_reviews_tests.erl new file mode 100644 index 0000000..eb90c28 --- /dev/null +++ b/test/unit/admin_handler_reviews_tests.erl @@ -0,0 +1,146 @@ +-module(admin_handler_reviews_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +setup() -> + ok = meck:new(cowboy_req, [non_strict]), + ok = meck:new(handler_auth, [non_strict]), + ok = meck:new(core_user, [non_strict]), + ok = meck:new(core_review, [non_strict]), + ok = meck:expect(cowboy_req, reply, + fun(Code, Headers, Body, Req) -> + put(test_reply, {Code, Headers, Body, Req}) + end), + ok. + +cleanup(_) -> + meck:unload(core_review), + meck:unload(core_user), + meck:unload(handler_auth), + meck:unload(cowboy_req). + +admin_reviews_test_() -> + {setup, fun setup/0, fun cleanup/1, [ + {"GET /admin/reviews/:id – success", fun test_get_review/0}, + {"GET /admin/reviews/:id – not found", fun test_get_review_not_found/0}, + {"GET /admin/reviews/:id – forbidden", fun test_get_review_forbidden/0}, + {"PUT /admin/reviews/:id – success", fun test_update_review/0}, + {"PUT /admin/reviews/:id – not found", fun test_update_review_not_found/0}, + {"PUT /admin/reviews/:id – bad JSON", fun test_update_review_bad_json/0}, + {"DELETE /admin/reviews/:id – method not allowed", fun test_wrong_method/0} + ]}. + +%% GET – успех +test_get_review() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end), + 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, binding, + fun(id, _) -> <<"rv1">> end), + Review = #review{ + id = <<"rv1">>, + user_id = <<"u1">>, + target_type = <<"event">>, + target_id = <<"e1">>, + rating = 5, + comment = <<"Great!">>, + status = <<"active">>, + created_at = {{2026,4,26},{12,0,0}}, + updated_at = {{2026,4,26},{12,0,0}} + }, + ok = meck:expect(core_review, get_by_id, + fun(<<"rv1">>) -> {ok, Review} end), + {ok, _, _} = admin_handler_reviews:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + #{<<"id">> := <<"rv1">>, <<"comment">> := <<"Great!">>, <<"rating">> := 5} = jsx:decode(RespBody, [return_maps]). + +%% GET – не найдено +test_get_review_not_found() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end), + 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, binding, + fun(id, _) -> <<"rv99">> end), + ok = meck:expect(core_review, get_by_id, + fun(_) -> {error, not_found} end), + {ok, _, _} = admin_handler_reviews:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(404, Status). + +%% GET – запрещён +test_get_review_forbidden() -> + 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, _, _} = admin_handler_reviews:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(403, Status). + +%% PUT – успех +test_update_review() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), + 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, binding, + fun(id, _) -> <<"rv1">> end), + ok = meck:expect(cowboy_req, read_body, + fun(Req) -> {ok, jsx:encode(#{<<"status">> => <<"hidden">>}), Req} end), + Updated = #review{id = <<"rv1">>, status = <<"hidden">>}, + ok = meck:expect(core_review, update_status, + fun(<<"rv1">>, <<"hidden">>) -> {ok, Updated} end), + {ok, _, _} = admin_handler_reviews:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + #{<<"status">> := <<"hidden">>} = jsx:decode(RespBody, [return_maps]). + +%% PUT – не найдено +test_update_review_not_found() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), + 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, binding, + fun(id, _) -> <<"rv99">> end), + ok = meck:expect(cowboy_req, read_body, + fun(Req) -> {ok, jsx:encode(#{<<"status">> => <<"hidden">>}), Req} end), + ok = meck:expect(core_review, update_status, + fun(_, _) -> {error, not_found} end), + {ok, _, _} = admin_handler_reviews:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(404, Status). + +%% PUT – невалидный JSON +test_update_review_bad_json() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), + 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, binding, + fun(id, _) -> <<"rv1">> end), + ok = meck:expect(cowboy_req, read_body, + fun(Req) -> {ok, <<"bad json">>, Req} end), + {ok, _, _} = admin_handler_reviews:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(400, Status). + +%% Неверный метод +test_wrong_method() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"DELETE">> end), + {ok, _, _} = admin_handler_reviews:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(405, Status), + #{<<"error">> := <<"Method not allowed">>} = jsx:decode(RespBody, [return_maps]). \ No newline at end of file diff --git a/test/unit/admin_handler_stats_tests.erl b/test/unit/admin_handler_stats_tests.erl index adc208f..8972d7b 100644 --- a/test/unit/admin_handler_stats_tests.erl +++ b/test/unit/admin_handler_stats_tests.erl @@ -2,106 +2,88 @@ -include_lib("eunit/include/eunit.hrl"). -include("records.hrl"). +-define(JWT_SECRET, <<"test-user-secret-key-32-byt!">>). +-define(ADMIN_JWT_SECRET, <<"test-admin-secret-key-32-b">>). + 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(booking, [{attributes, record_info(fields, booking)}, {ram_copies, [node()]}]), - mnesia:create_table(review, [{attributes, record_info(fields, review)}, {ram_copies, [node()]}]), - mnesia:create_table(report, [{attributes, record_info(fields, report)}, {ram_copies, [node()]}]), - mnesia:create_table(ticket, [{attributes, record_info(fields, ticket)}, {ram_copies, [node()]}]), - mnesia:create_table(subscription, [{attributes, record_info(fields, subscription)}, {ram_copies, [node()]}]), + ok = meck:new(cowboy_req, [non_strict]), + ok = meck:new(handler_auth, [non_strict]), + ok = meck:new(core_user, [non_strict]), + ok = meck:new(mnesia, [non_strict]), + ok = meck:expect(mnesia, dirty_match_object, fun(_) -> [] end), + application:set_env(eventhub, jwt_secret, ?JWT_SECRET), + application:set_env(eventhub, admin_jwt_secret, ?ADMIN_JWT_SECRET), + {ok, _} = application:ensure_all_started(jose), ok. cleanup(_) -> - mnesia:delete_table(subscription), - mnesia:delete_table(ticket), - mnesia:delete_table(report), - mnesia:delete_table(review), - mnesia:delete_table(booking), - mnesia:delete_table(event), - mnesia:delete_table(calendar), - mnesia:delete_table(user), - mnesia:stop(), - ok. + application:unset_env(eventhub, jwt_secret), + application:unset_env(eventhub, admin_jwt_secret), + application:stop(jose), + meck:unload(mnesia), + meck:unload(core_user), + meck:unload(handler_auth), + meck:unload(cowboy_req). admin_stats_test_() -> - {foreach, - fun setup/0, - fun cleanup/1, - [ - {"Count users", fun test_count_users/0}, - {"Count calendars", fun test_count_calendars/0}, - {"Count events", fun test_count_events/0}, - {"Count bookings", fun test_count_bookings/0}, - {"Count reviews", fun test_count_reviews/0}, - {"Count reports", fun test_count_reports/0}, - {"Count tickets", fun test_count_tickets/0}, - {"Count subscriptions", fun test_count_subscriptions/0} - ]}. + {setup, fun setup/0, fun cleanup/1, [ + {"GET /admin/stats with admin role returns 200 and dashboard data", + fun test_stats_admin/0}, + {"GET /admin/stats with non-admin role returns 403", + fun test_stats_forbidden/0}, + {"POST /admin/stats returns 405", + fun test_stats_wrong_method/0}, + {"Count functions return 0 with empty DB", + fun test_count_functions/0} + ]}. -create_test_user() -> - UserId = base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}), - User = #user{id = UserId, email = <>, password_hash = <<"hash">>, - role = user, status = active, created_at = calendar:universal_time(), updated_at = calendar:universal_time()}, - mnesia:dirty_write(User), - UserId. +%% ── Успешный GET с ролью админа ──────────────────────────── +test_stats_admin() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end), + ok = meck:expect(handler_auth, authenticate, + fun(Req) -> {ok, <<"adm1">>, Req} end), + % Администратор с ролью superadmin + AdminUser = #user{id = <<"adm1">>, role = superadmin, _ = '_'}, + ok = meck:expect(core_user, get_by_id, + fun(<<"adm1">>) -> {ok, AdminUser} end), + ok = meck:expect(cowboy_req, reply, + fun(Code, Headers, Body, Req) -> + put(test_reply, {Code, Headers, Body, Req}) + end), + {ok, _, _} = admin_handler_stats:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + Stats = jsx:decode(RespBody, [return_maps]), + ?assert(is_map_key(<<"users">>, Stats)), + ?assert(is_map_key(<<"events">>, Stats)). -test_count_users() -> +%% ── Обычный пользователь получает 403 ───────────────────── +test_stats_forbidden() -> + 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, reply, + fun(Code, Headers, Body, Req) -> + put(test_reply, {Code, Headers, Body, Req}) + end), + {ok, _, _} = admin_handler_stats:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(403, Status), + ?assertEqual(#{<<"error">> => <<"Admin access required">>}, jsx:decode(RespBody, [return_maps])). + +%% ── Неверный метод ────────────────────────────────────── +test_stats_wrong_method() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"POST">> end), + ok = meck:expect(cowboy_req, reply, + fun(Code, Headers, Body, Req) -> + put(test_reply, {Code, Headers, Body, Req}) + end), + {ok, _, _} = admin_handler_stats:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(405, Status), + ?assertEqual(#{<<"error">> => <<"Method not allowed">>}, jsx:decode(RespBody, [return_maps])). + +%% ── Функции подсчёта (мок mnesia) ────────────────────── +test_count_functions() -> ?assertEqual(0, admin_handler_stats:count_users()), - create_test_user(), - create_test_user(), - ?assertEqual(2, admin_handler_stats:count_users()). - -test_count_calendars() -> - ?assertEqual(0, admin_handler_stats:count_calendars()), - UserId = create_test_user(), - core_calendar:create(UserId, <<"Cal1">>, <<"">>, manual), - core_calendar:create(UserId, <<"Cal2">>, <<"">>, auto), - ?assertEqual(2, admin_handler_stats:count_calendars()). - -test_count_events() -> - ?assertEqual(0, admin_handler_stats:count_events()), - UserId = create_test_user(), - {ok, Cal} = core_calendar:create(UserId, <<"Cal">>, <<"">>, manual), - core_event:create(Cal#calendar.id, <<"Ev1">>, {{2026,6,1},{10,0,0}}, 60), - core_event:create(Cal#calendar.id, <<"Ev2">>, {{2026,6,2},{10,0,0}}, 60), - ?assertEqual(2, admin_handler_stats:count_events()). - -test_count_bookings() -> - ?assertEqual(0, admin_handler_stats:count_bookings()), - UserId = create_test_user(), - ParticipantId = create_test_user(), - {ok, Cal} = core_calendar:create(UserId, <<"Cal">>, <<"">>, manual), - {ok, Ev} = core_event:create(Cal#calendar.id, <<"Ev">>, {{2026,6,1},{10,0,0}}, 60), - core_booking:create(Ev#event.id, ParticipantId), - core_booking:create(Ev#event.id, ParticipantId), - ?assertEqual(2, admin_handler_stats:count_bookings()). - -test_count_reviews() -> - ?assertEqual(0, admin_handler_stats:count_reviews()), - UserId = create_test_user(), - core_review:create(UserId, calendar, <<"cal1">>, 5, <<"Great">>), - core_review:create(UserId, event, <<"ev1">>, 4, <<"Good">>), - ?assertEqual(2, admin_handler_stats:count_reviews()). - -test_count_reports() -> - ?assertEqual(0, admin_handler_stats:count_reports()), - UserId = create_test_user(), - core_report:create(UserId, event, <<"ev1">>, <<"Bad">>), - core_report:create(UserId, calendar, <<"cal1">>, <<"Spam">>), - ?assertEqual(2, admin_handler_stats:count_reports()). - -test_count_tickets() -> - ?assertEqual(0, admin_handler_stats:count_tickets()), - core_ticket:create_or_update(<<"Error1">>, <<"">>, #{}), - core_ticket:create_or_update(<<"Error2">>, <<"">>, #{}), - ?assertEqual(2, admin_handler_stats:count_tickets()). - -test_count_subscriptions() -> - ?assertEqual(0, admin_handler_stats:count_subscriptions()), - UserId = create_test_user(), - core_subscription:create(UserId, trial, false), - core_subscription:create(UserId, monthly, true), - ?assertEqual(2, admin_handler_stats:count_subscriptions()). \ No newline at end of file + ?assertEqual(0, admin_handler_stats:count_events()). \ No newline at end of file diff --git a/test/unit/admin_handler_subscriptions_tests.erl b/test/unit/admin_handler_subscriptions_tests.erl new file mode 100644 index 0000000..18a8960 --- /dev/null +++ b/test/unit/admin_handler_subscriptions_tests.erl @@ -0,0 +1,219 @@ +-module(admin_handler_subscriptions_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +setup() -> + ok = meck:new(cowboy_req, [non_strict]), + ok = meck:new(handler_auth, [non_strict]), + ok = meck:new(core_user, [non_strict]), + ok = meck:new(core_subscription, [non_strict]), + ok = meck:expect(cowboy_req, reply, + fun(Code, Headers, Body, Req) -> + put(test_reply, {Code, Headers, Body, Req}) + end), + ok. + +cleanup(_) -> + meck:unload(core_subscription), + meck:unload(core_user), + meck:unload(handler_auth), + meck:unload(cowboy_req). + +admin_subscriptions_test_() -> + {setup, fun setup/0, fun cleanup/1, [ + {"GET /admin/subscriptions – success", fun test_list/0}, + {"GET /admin/subscriptions – forbidden", fun test_list_forbidden/0}, + {"POST /admin/subscriptions – success", fun test_create/0}, + {"POST /admin/subscriptions – missing user_id", fun test_create_missing/0}, + {"GET /admin/subscriptions/:id – success", fun test_get/0}, + {"GET /admin/subscriptions/:id – not found", fun test_get_not_found/0}, + {"PUT /admin/subscriptions/:id – success", fun test_update/0}, + {"PUT /admin/subscriptions/:id – not found", fun test_update_not_found/0}, + {"DELETE /admin/subscriptions/:id – success", fun test_delete/0}, + {"DELETE /admin/subscriptions/:id – not found", fun test_delete_not_found/0}, + {"PATCH /admin/subscriptions – 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), + AdminUser = #user{id = <<"adm1">>, role = admin}, + ok = meck:expect(core_user, get_by_id, + fun(<<"adm1">>) -> {ok, AdminUser} end), + Sub1 = #subscription{ + id = <<"s1">>, + user_id = <<"u1">>, + plan = monthly, + status = active, + trial_used = false, + started_at = {{2026,4,27},{12,0,0}}, + expires_at = {{2026,5,27},{12,0,0}}, + created_at = {{2026,4,27},{12,0,0}}, + updated_at = {{2026,4,27},{12,0,0}} + }, + ok = meck:expect(core_subscription, list_subscriptions, fun() -> [Sub1] end), + {ok, _, _} = admin_handler_subscriptions:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + [#{<<"id">> := <<"s1">>, <<"plan">> := <<"monthly">>, <<"status">> := <<"active">>}] = + jsx:decode(RespBody, [return_maps]). + +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, _, _} = admin_handler_subscriptions: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), + AdminUser = #user{id = <<"adm1">>, role = admin}, + ok = meck:expect(core_user, get_by_id, + fun(<<"adm1">>) -> {ok, AdminUser} end), + BodyMap = #{<<"user_id">> => <<"u1">>, <<"plan">> => <<"yearly">>}, + ok = meck:expect(cowboy_req, read_body, + fun(Req) -> {ok, jsx:encode(BodyMap), Req} end), + Created = #subscription{ + id = <<"s_new">>, user_id = <<"u1">>, plan = yearly, status = active, + trial_used = false, + started_at = {{2026,4,27},{14,0,0}}, expires_at = {{2027,4,27},{14,0,0}}, + created_at = {{2026,4,27},{14,0,0}}, updated_at = {{2026,4,27},{14,0,0}} + }, + ok = meck:expect(core_subscription, create_subscription, fun(_) -> {ok, Created} end), + {ok, _, _} = admin_handler_subscriptions:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(201, Status), + #{<<"plan">> := <<"yearly">>, <<"status">> := <<"active">>} = jsx:decode(RespBody, [return_maps]). + +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), + 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(#{<<"plan">> => <<"monthly">>}), Req} end), + {ok, _, _} = admin_handler_subscriptions:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(400, Status). + +%% GET по ID +test_get() -> + ok = meck:expect(cowboy_req, binding, fun(id, _) -> <<"s1">> end), + ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end), + 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), + Sub = #subscription{ + id = <<"s1">>, user_id = <<"u1">>, plan = monthly, status = active, + trial_used = false, + started_at = {{2026,4,27},{12,0,0}}, expires_at = {{2026,5,27},{12,0,0}}, + created_at = {{2026,4,27},{12,0,0}}, updated_at = {{2026,4,27},{12,0,0}} + }, + ok = meck:expect(core_subscription, get_by_id, + fun(<<"s1">>) -> {ok, Sub} end), + {ok, _, _} = admin_handler_subscriptions:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + #{<<"id">> := <<"s1">>, <<"plan">> := <<"monthly">>} = jsx:decode(RespBody, [return_maps]). + +test_get_not_found() -> + ok = meck:expect(cowboy_req, binding, fun(id, _) -> <<"s99">> end), + ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end), + 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_subscription, get_by_id, + fun(_) -> {error, not_found} end), + {ok, _, _} = admin_handler_subscriptions:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(404, Status). + +%% PUT обновление +test_update() -> + ok = meck:expect(cowboy_req, binding, fun(id, _) -> <<"s1">> end), + ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), + 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">> => <<"cancelled">>}), Req} end), + Updated = #subscription{id = <<"s1">>, status = cancelled}, + ok = meck:expect(core_subscription, update_subscription, + fun(<<"s1">>, _) -> {ok, Updated} end), + {ok, _, _} = admin_handler_subscriptions:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + #{<<"status">> := <<"cancelled">>} = jsx:decode(RespBody, [return_maps]). + +test_update_not_found() -> + ok = meck:expect(cowboy_req, binding, fun(id, _) -> <<"s99">> end), + ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), + 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">> => <<"cancelled">>}), Req} end), + ok = meck:expect(core_subscription, update_subscription, + fun(_, _) -> {error, not_found} end), + {ok, _, _} = admin_handler_subscriptions:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(404, Status). + +%% DELETE +test_delete() -> + ok = meck:expect(cowboy_req, binding, fun(id, _) -> <<"s1">> end), + ok = meck:expect(cowboy_req, method, fun(_) -> <<"DELETE">> end), + 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_subscription, delete_subscription, + fun(<<"s1">>) -> {ok, deleted} end), + {ok, _, _} = admin_handler_subscriptions:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + #{<<"status">> := <<"deleted">>} = jsx:decode(RespBody, [return_maps]). + +test_delete_not_found() -> + ok = meck:expect(cowboy_req, binding, fun(id, _) -> <<"s99">> end), + ok = meck:expect(cowboy_req, method, fun(_) -> <<"DELETE">> end), + 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_subscription, delete_subscription, + fun(_) -> {error, not_found} end), + {ok, _, _} = admin_handler_subscriptions: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, _, _} = admin_handler_subscriptions:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(405, Status), + #{<<"error">> := <<"Method not allowed">>} = jsx:decode(RespBody, [return_maps]). \ No newline at end of file diff --git a/test/unit/admin_handler_ticket_by_id_tests.erl b/test/unit/admin_handler_ticket_by_id_tests.erl new file mode 100644 index 0000000..3c59961 --- /dev/null +++ b/test/unit/admin_handler_ticket_by_id_tests.erl @@ -0,0 +1,187 @@ +-module(admin_handler_ticket_by_id_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +setup() -> + ok = meck:new(cowboy_req, [non_strict]), + ok = meck:new(handler_auth, [non_strict]), + ok = meck:new(core_user, [non_strict]), + ok = meck:new(core_ticket, [non_strict]), + ok = meck:expect(cowboy_req, reply, + fun(Code, Headers, Body, Req) -> + put(test_reply, {Code, Headers, Body, Req}) + end), + ok. + +cleanup(_) -> + meck:unload(core_ticket), + meck:unload(core_user), + meck:unload(handler_auth), + meck:unload(cowboy_req). + +admin_ticket_by_id_test_() -> + {setup, fun setup/0, fun cleanup/1, [ + {"GET /admin/tickets/:id – success", fun test_get/0}, + {"GET /admin/tickets/:id – not found", fun test_get_not_found/0}, + {"GET /admin/tickets/:id – forbidden", fun test_get_forbidden/0}, + {"PUT /admin/tickets/:id – success", fun test_update/0}, + {"PUT /admin/tickets/:id – not found", fun test_update_not_found/0}, + {"PUT /admin/tickets/:id – bad JSON", fun test_update_bad_json/0}, + {"DELETE /admin/tickets/:id – success", fun test_delete/0}, + {"DELETE /admin/tickets/:id – not found", fun test_delete_not_found/0}, + {"POST /admin/tickets/:id – method not allowed", fun test_wrong_method/0} + ]}. + +%% GET – успех +test_get() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end), + 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, binding, + fun(id, _) -> <<"t1">> end), + Ticket = #ticket{ + id = <<"t1">>, + error_hash = <<"hash">>, + error_message = <<"msg">>, + stacktrace = <<"trace">>, + context = <<"ctx">>, + count = 5, + first_seen = {{2026,4,27},{12,0,0}}, + last_seen = {{2026,4,27},{13,0,0}}, + status = open, + assigned_to = <<"dev1">>, + resolution_note = undefined + }, + ok = meck:expect(core_ticket, get_by_id, + fun(<<"t1">>) -> {ok, Ticket} end), + {ok, _, _} = admin_handler_ticket_by_id:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + #{<<"id">> := <<"t1">>, <<"error_message">> := <<"msg">>, <<"count">> := 5} = jsx:decode(RespBody, [return_maps]). + +%% GET – не найдено +test_get_not_found() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end), + 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, binding, + fun(id, _) -> <<"t99">> end), + ok = meck:expect(core_ticket, get_by_id, + fun(_) -> {error, not_found} end), + {ok, _, _} = admin_handler_ticket_by_id:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(404, Status). + +%% GET – запрещён +test_get_forbidden() -> + 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, _, _} = admin_handler_ticket_by_id:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(403, Status). + +%% PUT – успех +test_update() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), + 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, binding, + fun(id, _) -> <<"t1">> end), + ok = meck:expect(cowboy_req, read_body, + fun(Req) -> {ok, jsx:encode(#{<<"status">> => <<"closed">>, <<"assigned_to">> => <<"adm2">>}), Req} end), + Updated = #ticket{id = <<"t1">>, status = closed, assigned_to = <<"adm2">>}, + ok = meck:expect(core_ticket, update_ticket, + fun(<<"t1">>, _) -> {ok, Updated} end), + {ok, _, _} = admin_handler_ticket_by_id:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + #{<<"status">> := <<"closed">>, <<"assigned_to">> := <<"adm2">>} = jsx:decode(RespBody, [return_maps]). + +%% PUT – не найдено +test_update_not_found() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), + 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, binding, + fun(id, _) -> <<"t99">> 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_ticket_by_id:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(404, Status). + +%% PUT – невалидный JSON +test_update_bad_json() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), + 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, binding, + fun(id, _) -> <<"t1">> end), + ok = meck:expect(cowboy_req, read_body, + fun(Req) -> {ok, <<"not json">>, Req} end), + {ok, _, _} = admin_handler_ticket_by_id:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(400, Status). + +%% DELETE – успех +test_delete() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"DELETE">> end), + 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, binding, + fun(id, _) -> <<"t1">> end), + ok = meck:expect(core_ticket, delete_ticket, + fun(<<"t1">>) -> {ok, deleted} end), + {ok, _, _} = admin_handler_ticket_by_id: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, method, fun(_) -> <<"DELETE">> end), + 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, binding, + fun(id, _) -> <<"t99">> end), + ok = meck:expect(core_ticket, delete_ticket, + fun(_) -> {error, not_found} end), + {ok, _, _} = admin_handler_ticket_by_id:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(404, Status). + +%% Неверный метод +test_wrong_method() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"POST">> end), + ok = meck:expect(cowboy_req, reply, + fun(Code, Headers, Body, Req) -> + put(test_reply, {Code, Headers, Body, Req}) + end), + {ok, _, _} = admin_handler_ticket_by_id:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(405, Status), + #{<<"error">> := <<"Method not allowed">>} = jsx:decode(RespBody, [return_maps]). \ No newline at end of file diff --git a/test/unit/admin_handler_ticket_stats_tests.erl b/test/unit/admin_handler_ticket_stats_tests.erl new file mode 100644 index 0000000..3581998 --- /dev/null +++ b/test/unit/admin_handler_ticket_stats_tests.erl @@ -0,0 +1,61 @@ +-module(admin_handler_ticket_stats_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +setup() -> + ok = meck:new(cowboy_req, [non_strict]), + ok = meck:new(handler_auth, [non_strict]), + ok = meck:new(core_user, [non_strict]), + ok = meck:new(core_ticket, [non_strict]), + ok = meck:expect(cowboy_req, reply, + fun(Code, Headers, Body, Req) -> + put(test_reply, {Code, Headers, Body, Req}) + end), + ok. + +cleanup(_) -> + meck:unload(core_ticket), + meck:unload(core_user), + meck:unload(handler_auth), + meck:unload(cowboy_req). + +admin_ticket_stats_test_() -> + {setup, fun setup/0, fun cleanup/1, [ + {"GET /admin/tickets/stats – success", fun test_stats/0}, + {"GET /admin/tickets/stats – forbidden", fun test_stats_forbidden/0}, + {"POST /admin/tickets/stats – method not allowed", fun test_wrong_method/0} + ]}. + +test_stats() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end), + 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), + StatsData = #{ + open => 5, + in_progress => 3, + resolved => 12, + closed => 20 + }, + ok = meck:expect(core_ticket, stats, fun() -> StatsData end), + {ok, _, _} = admin_handler_ticket_stats:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + #{<<"open">> := 5, <<"resolved">> := 12} = jsx:decode(RespBody, [return_maps]). + +test_stats_forbidden() -> + 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, _, _} = admin_handler_ticket_stats:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(403, Status). + +test_wrong_method() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"POST">> end), + {ok, _, _} = admin_handler_ticket_stats:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(405, Status), + #{<<"error">> := <<"Method not allowed">>} = jsx:decode(RespBody, [return_maps]). \ No newline at end of file diff --git a/test/unit/admin_handler_tickets_tests.erl b/test/unit/admin_handler_tickets_tests.erl new file mode 100644 index 0000000..fad73eb --- /dev/null +++ b/test/unit/admin_handler_tickets_tests.erl @@ -0,0 +1,203 @@ +-module(admin_handler_tickets_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +setup() -> + ok = meck:new(cowboy_req, [non_strict]), + ok = meck:new(handler_auth, [non_strict]), + ok = meck:new(core_user, [non_strict]), + ok = meck:new(core_ticket, [non_strict]), + ok = meck:expect(cowboy_req, reply, + fun(Code, Headers, Body, Req) -> + put(test_reply, {Code, Headers, Body, Req}) + end), + ok. + +cleanup(_) -> + meck:unload(core_ticket), + meck:unload(core_user), + meck:unload(handler_auth), + meck:unload(cowboy_req). + +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} + ]}. + +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), + 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 = <<"abc123">>, + error_message = <<"Ooops">>, + stacktrace = <<"trace">>, + context = <<"ctx">>, + count = 3, + first_seen = {{2026,4,27},{12,0,0}}, + last_seen = {{2026,4,27},{13,0,0}}, + status = open, + assigned_to = <<"adm2">>, + resolution_note = undefined + }, + ok = meck:expect(core_ticket, list_tickets, fun() -> [Ticket] end), + {ok, _, _} = admin_handler_tickets:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + [#{<<"id">> := <<"t1">>, <<"error_message">> := <<"Ooops">>, <<"status">> := <<"open">>}] = + jsx:decode(RespBody, [return_maps]). + +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, _, _} = admin_handler_tickets:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(403, Status). + +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), + 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, _, _} = admin_handler_tickets:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(201, Status), + #{<<"error_message">> := <<"New bug">>, <<"status">> := <<"open">>} = jsx:decode(RespBody, [return_maps]). + +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), + 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, _, _} = admin_handler_tickets:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(400, Status). + +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), + 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, _, _} = admin_handler_tickets:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + #{<<"id">> := <<"t1">>} = jsx:decode(RespBody, [return_maps]). + +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), + 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, _, _} = admin_handler_tickets:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(404, Status). + +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), + 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), + Updated = #ticket{id = <<"t1">>, status = closed}, + 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]). + +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), + 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, _, _} = admin_handler_tickets:init(req, []), + {Status, _, _, _} = erase(test_reply), + ?assertEqual(404, Status). + +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), + 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, _, _} = admin_handler_tickets:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + #{<<"status">> := <<"deleted">>} = jsx:decode(RespBody, [return_maps]). + +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), + 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, _, _} = 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, _, _} = admin_handler_tickets:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(405, Status), + #{<<"error">> := <<"Method not allowed">>} = jsx:decode(RespBody, [return_maps]). \ No newline at end of file diff --git a/test/unit/admin_handler_user_by_id_tests.erl b/test/unit/admin_handler_user_by_id_tests.erl index d20c8e4..9b2c279 100644 --- a/test/unit/admin_handler_user_by_id_tests.erl +++ b/test/unit/admin_handler_user_by_id_tests.erl @@ -3,25 +3,187 @@ -include("records.hrl"). setup() -> - mnesia:start(), - mnesia:create_table(user, [{attributes, record_info(fields, user)}, {ram_copies, [node()]}]), + ok = meck:new(cowboy_req, [non_strict]), + ok = meck:new(handler_auth, [non_strict]), + ok = meck:new(core_user, [non_strict]), ok. cleanup(_) -> - mnesia:delete_table(user), - mnesia:stop(), - ok. + meck:unload(core_user), + meck:unload(handler_auth), + meck:unload(cowboy_req). admin_user_by_id_test_() -> - {foreach, - fun setup/0, - fun cleanup/1, - [ - {"Convert updates test", fun test_convert_updates/0} - ]}. + {setup, fun setup/0, fun cleanup/1, [ + {"GET /admin/users/:id – success", fun test_get_user/0}, + {"GET /admin/users/:id – not found", fun test_get_user_not_found/0}, + {"GET /admin/users/:id – forbidden", fun test_get_user_forbidden/0}, + {"PUT /admin/users/:id – success", fun test_update_user/0}, + {"PUT /admin/users/:id – not found", fun test_update_user_not_found/0}, + {"DELETE /admin/users/:id – success", fun test_delete_user/0}, + {"DELETE /admin/users/:id – not found", fun test_delete_user_not_found/0}, + {"POST /admin/users/:id – method not allowed", fun test_wrong_method/0}, + {"convert_updates/1", fun test_convert_updates/0} + ]}. +%% ── GET – success ───────────────────────────────────────── +test_get_user() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end), + ok = meck:expect(handler_auth, authenticate, + fun(Req) -> {ok, <<"admin1">>, Req} end), + AdminUser = #user{id = <<"admin1">>, role = admin}, + ok = meck:expect(core_user, get_by_id, + fun(<<"admin1">>) -> {ok, AdminUser}; + (<<"user1">>) -> {ok, #user{id = <<"user1">>, email = <<"u@t.com">>, + role = user, status = active, + created_at = {{2026,4,27},{12,0,0}}, + updated_at = {{2026,4,27},{12,0,0}}}} + end), + ok = meck:expect(cowboy_req, binding, + fun(id, _) -> <<"user1">> end), + ok = meck:expect(cowboy_req, reply, + fun(Code, Headers, Body, Req) -> + put(test_reply, {Code, Headers, Body, Req}) + end), + {ok, _, _} = admin_handler_user_by_id:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + #{<<"id">> := <<"user1">>, <<"email">> := <<"u@t.com">>, <<"role">> := <<"user">>} + = jsx:decode(RespBody, [return_maps]). + +%% ── GET – not found ─────────────────────────────────────── +test_get_user_not_found() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end), + ok = meck:expect(handler_auth, authenticate, + fun(Req) -> {ok, <<"admin1">>, Req} end), + AdminUser = #user{id = <<"admin1">>, role = admin}, + ok = meck:expect(core_user, get_by_id, + fun(<<"admin1">>) -> {ok, AdminUser}; + (_) -> {error, not_found} + end), + ok = meck:expect(cowboy_req, binding, fun(id, _) -> <<"missing">> end), + ok = meck:expect(cowboy_req, reply, + fun(Code, Headers, Body, Req) -> + put(test_reply, {Code, Headers, Body, Req}) + end), + {ok, _, _} = admin_handler_user_by_id:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(404, Status), + #{<<"error">> := <<"User not found">>} = jsx:decode(RespBody, [return_maps]). + +%% ── GET – forbidden ─────────────────────────────────────── +test_get_user_forbidden() -> + 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, reply, + fun(Code, Headers, Body, Req) -> + put(test_reply, {Code, Headers, Body, Req}) + end), + {ok, _, _} = admin_handler_user_by_id:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(403, Status), + #{<<"error">> := <<"Admin access required">>} = jsx:decode(RespBody, [return_maps]). + +%% ── PUT – success ───────────────────────────────────────── +test_update_user() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), + ok = meck:expect(handler_auth, authenticate, + fun(Req) -> {ok, <<"admin1">>, Req} end), + AdminUser = #user{id = <<"admin1">>, role = admin}, + ok = meck:expect(core_user, get_by_id, + fun(<<"admin1">>) -> {ok, AdminUser} end), + User = #user{id = <<"user1">>, email = <<"u@t.com">>, role = user, status = frozen, + created_at = {{2026,4,27},{12,0,0}}, updated_at = {{2026,4,27},{13,0,0}}}, + ok = meck:expect(core_user, update, fun(<<"user1">>, _) -> {ok, User} end), + ok = meck:expect(cowboy_req, binding, fun(id, _) -> <<"user1">> end), + ok = meck:expect(cowboy_req, read_body, fun(Req) -> {ok, jsx:encode(#{status => <<"frozen">>}), Req} end), + ok = meck:expect(cowboy_req, reply, + fun(Code, Headers, Body, Req) -> + put(test_reply, {Code, Headers, Body, Req}) + end), + {ok, _, _} = admin_handler_user_by_id:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + #{<<"status">> := <<"frozen">>} = jsx:decode(RespBody, [return_maps]). + +%% ── PUT – not found ────────────────────────────────────── +test_update_user_not_found() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"PUT">> end), + ok = meck:expect(handler_auth, authenticate, + fun(Req) -> {ok, <<"admin1">>, Req} end), + AdminUser = #user{id = <<"admin1">>, role = admin}, + ok = meck:expect(core_user, get_by_id, + fun(<<"admin1">>) -> {ok, AdminUser} end), + ok = meck:expect(core_user, update, fun(_, _) -> {error, not_found} end), + ok = meck:expect(cowboy_req, binding, fun(id, _) -> <<"missing">> end), + ok = meck:expect(cowboy_req, read_body, fun(Req) -> {ok, <<"{}">>, Req} end), + ok = meck:expect(cowboy_req, reply, + fun(Code, Headers, Body, Req) -> + put(test_reply, {Code, Headers, Body, Req}) + end), + {ok, _, _} = admin_handler_user_by_id:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(404, Status), + #{<<"error">> := <<"User not found">>} = jsx:decode(RespBody, [return_maps]). + +%% ── DELETE – success ───────────────────────────────────── +test_delete_user() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"DELETE">> end), + ok = meck:expect(handler_auth, authenticate, + fun(Req) -> {ok, <<"admin1">>, Req} end), + AdminUser = #user{id = <<"admin1">>, role = admin}, + ok = meck:expect(core_user, get_by_id, + fun(<<"admin1">>) -> {ok, AdminUser} end), + ok = meck:expect(core_user, delete, fun(<<"user1">>) -> {ok, deleted} end), + ok = meck:expect(cowboy_req, binding, fun(id, _) -> <<"user1">> end), + ok = meck:expect(cowboy_req, reply, + fun(Code, Headers, Body, Req) -> + put(test_reply, {Code, Headers, Body, Req}) + end), + {ok, _, _} = admin_handler_user_by_id:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + #{<<"status">> := <<"deleted">>} = jsx:decode(RespBody, [return_maps]). + +%% ── DELETE – not found ─────────────────────────────────── +test_delete_user_not_found() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"DELETE">> end), + ok = meck:expect(handler_auth, authenticate, + fun(Req) -> {ok, <<"admin1">>, Req} end), + AdminUser = #user{id = <<"admin1">>, role = admin}, + ok = meck:expect(core_user, get_by_id, + fun(<<"admin1">>) -> {ok, AdminUser} end), + ok = meck:expect(core_user, delete, fun(_) -> {error, not_found} end), + ok = meck:expect(cowboy_req, binding, fun(id, _) -> <<"missing">> end), + ok = meck:expect(cowboy_req, reply, + fun(Code, Headers, Body, Req) -> + put(test_reply, {Code, Headers, Body, Req}) + end), + {ok, _, _} = admin_handler_user_by_id:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(404, Status), + #{<<"error">> := <<"User not found">>} = jsx:decode(RespBody, [return_maps]). + +%% ── Wrong method ───────────────────────────────────────── +test_wrong_method() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"POST">> end), + ok = meck:expect(cowboy_req, reply, + fun(Code, Headers, Body, Req) -> + put(test_reply, {Code, Headers, Body, Req}) + end), + {ok, _, _} = admin_handler_user_by_id:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(405, Status), + #{<<"error">> := <<"Method not allowed">>} = jsx:decode(RespBody, [return_maps]). + +%% ── convert_updates/1 ──────────────────────────────────── test_convert_updates() -> - Updates = [{<<"status">>, <<"frozen">>}, {<<"role">>, <<"admin">>}, {<<"email">>, <<"test@test.com">>}], + Updates = [ + {<<"status">>, <<"frozen">>}, + {<<"role">>, <<"admin">>}, + {<<"email">>, <<"test@test.com">>} + ], Converted = admin_handler_user_by_id:convert_updates(Updates), ?assertEqual({status, frozen}, lists:keyfind(status, 1, Converted)), ?assertEqual({role, admin}, lists:keyfind(role, 1, Converted)), diff --git a/test/unit/admin_handler_users_tests.erl b/test/unit/admin_handler_users_tests.erl index 3d32a26..602989f 100644 --- a/test/unit/admin_handler_users_tests.erl +++ b/test/unit/admin_handler_users_tests.erl @@ -1,45 +1,65 @@ -module(admin_handler_users_tests). -include_lib("eunit/include/eunit.hrl"). --include("records.hrl"). setup() -> - mnesia:start(), - mnesia:create_table(user, [{attributes, record_info(fields, user)}, {ram_copies, [node()]}]), + ok = meck:new(cowboy_req, [non_strict]), + ok = meck:new(handler_auth, [non_strict]), % вместо auth + ok = meck:new(core_user, [non_strict]), ok. cleanup(_) -> - mnesia:delete_table(user), - mnesia:stop(), - ok. + meck:unload(core_user), + meck:unload(handler_auth), + meck:unload(cowboy_req). admin_users_test_() -> - {foreach, - fun setup/0, - fun cleanup/1, - [ - {"User to JSON conversion", fun test_user_to_json/0}, - {"Is admin check", fun test_is_admin/0} - ]}. + {setup, fun setup/0, fun cleanup/1, [ + {"GET /admin/users with valid admin token returns 200 and list of users", fun test_list_users/0}, + {"GET /admin/users with non-admin token returns 403", fun test_list_users_forbidden/0}, + {"POST /admin/users returns 405", fun test_wrong_method/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. +test_list_users() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"GET">> end), + ok = meck:expect(handler_auth, authenticate, + fun(Req) -> {ok, <<"admin1">>, Req} end), + User = #{ + id => <<"user1">>, + email => <<"user@test.com">>, + role => <<"user">>, + status => <<"active">>, + created_at => {{2025,4,27},{12,0,0}}, + updated_at => {{2025,4,27},{12,30,0}} + }, + ok = meck:expect(core_user, list_users, fun() -> {ok, [User]} end), + ok = meck:expect(cowboy_req, reply, fun(Code, Headers, Body, Req) -> + put(test_reply, {Code, Headers, Body, Req}) + end), + {ok, _, _} = admin_handler_users:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(200, Status), + Users = jsx:decode(RespBody, [return_maps]), + ?assertEqual(1, length(Users)), + ?assertEqual(<<"user1">>, maps:get(<<"id">>, hd(Users))). -test_user_to_json() -> - UserId = create_test_user(user), - {ok, User} = core_user:get_by_id(UserId), - Json = admin_handler_user_by_id:user_to_json(User), - ?assert(is_map(Json)), - ?assertEqual(UserId, maps:get(id, Json)), - ?assertEqual(user, maps:get(role, Json)), - ?assertEqual(active, maps:get(status, Json)). +test_list_users_forbidden() -> + 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, reply, fun(Code, Headers, Body, Req) -> + put(test_reply, {Code, Headers, Body, Req}) + end), + {ok, _, _} = admin_handler_users:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(403, Status), + ?assertEqual(#{<<"error">> => <<"Admin access required">>}, jsx:decode(RespBody, [return_maps])). -test_is_admin() -> - AdminId = create_test_user(admin), - UserId = create_test_user(user), - ?assert(admin_handler_stats:is_admin(AdminId)), - ?assertNot(admin_handler_stats:is_admin(UserId)), - ?assertNot(admin_handler_stats:is_admin(<<"nonexistent">>)). \ No newline at end of file +test_wrong_method() -> + ok = meck:expect(cowboy_req, method, fun(_) -> <<"POST">> end), + ok = meck:expect(cowboy_req, reply, fun(Code, Headers, Body, Req) -> + put(test_reply, {Code, Headers, Body, Req}) + end), + {ok, _, _} = admin_handler_users:init(req, []), + {Status, _, RespBody, _} = erase(test_reply), + ?assertEqual(405, Status), + ?assertEqual(#{<<"error">> => <<"Method not allowed">>}, jsx:decode(RespBody, [return_maps])). \ No newline at end of file diff --git a/test/unit/auth_test.erl b/test/unit/auth_test.erl new file mode 100644 index 0000000..2267f67 --- /dev/null +++ b/test/unit/auth_test.erl @@ -0,0 +1,139 @@ +-module(auth_test). +-include_lib("eunit/include/eunit.hrl"). + +-define(JWT_SECRET, <<"test-user-secret-key-32-byt!">>). +-define(ADMIN_JWT_SECRET, <<"test-admin-secret-key-32-b">>). + +%% ------------------------------------------------------------------ +%% EUnit фикстуры – запуск и остановка моков +%% ------------------------------------------------------------------ +setup() -> + ok = meck:new(logic_auth, [non_strict]), + application:set_env(eventhub, jwt_secret, ?JWT_SECRET), + application:set_env(eventhub, admin_jwt_secret, ?ADMIN_JWT_SECRET), + {ok, _} = application:ensure_all_started(jose), + ok. + +cleanup(_) -> + application:unset_env(eventhub, jwt_secret), + application:unset_env(eventhub, admin_jwt_secret), + application:stop(jose), + meck:unload(logic_auth). + +%% ------------------------------------------------------------------ +%% Тесты генерации токенов +%% ------------------------------------------------------------------ +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">>), + ?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), + ?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), + ?assertEqual(<<"admin1">>, UserId), + ?assertEqual(<<"superadmin">>, Role) + end} + ]}. + +%% ------------------------------------------------------------------ +%% Тесты верификации токенов (граничные случаи) +%% ------------------------------------------------------------------ +verify_token_errors_test_() -> + {setup, fun setup/0, fun cleanup/1, [ + {"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)) + end}, + {"User token rejected by admin verifier (different secret)", + fun() -> + Token = auth:generate_user_token(<<"x">>, <<"user">>), + % Разные секреты → подпись недействительна для admin JWK + ?assertEqual({error, invalid_signature}, 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)) + end} + ]}. + +%% ------------------------------------------------------------------ +%% Тесты для authenticate_user_request/3 +%% ------------------------------------------------------------------ +authenticate_user_request_test_() -> + {setup, fun setup/0, fun cleanup/1, [ + {"Successful user login returns token and user data", + fun() -> + 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">>), + ?assert(is_binary(Token)), + ?assertEqual(UserMap, ReturnedUser), + {ok, UserId, Role} = auth:verify_user_token(Token), + ?assertEqual(<<"user1">>, UserId), + ?assertEqual(<<"user">>, Role) + end}, + {"User login failure propagates error from logic_auth", + 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">>)) + end} + ]}. + +%% ------------------------------------------------------------------ +%% Тесты для authenticate_admin_request/3 +%% ------------------------------------------------------------------ +authenticate_admin_request_test_() -> + {setup, fun setup/0, fun cleanup/1, [ + {"Successful admin login returns admin token", + fun() -> + 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">>), + ?assert(is_binary(Token)), + ?assertEqual(AdminMap, ReturnedUser), + {ok, UserId, Role} = auth:verify_admin_token(Token), + ?assertEqual(<<"adm1">>, UserId), + ?assertEqual(<<"superadmin">>, Role) + end}, + {"Non-admin role is rejected with insufficient_permissions", + fun() -> + UserMap = #{id => <<"simpleuser">>, email => <<"u@test.com">>, role => <<"user">>}, + 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">>)) + 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), + ?assertEqual(<<"moderator">>, Role) + end} + ]}. + +%% ------------------------------------------------------------------ +%% Тест generate_refresh_token/1 +%% ------------------------------------------------------------------ +generate_refresh_token_test() -> + {_, _} = 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 index e75c735..d97c184 100644 --- a/test/unit/core_banned_word_tests.erl +++ b/test/unit/core_banned_word_tests.erl @@ -3,78 +3,61 @@ -include("records.hrl"). setup() -> - mnesia:start(), - mnesia:create_table(banned_word, [ + {atomic, ok} = mnesia:start(), % правильное значение + ok = mnesia:create_table(banned_word, [ {attributes, record_info(fields, banned_word)}, + {disc_copies, []}, {ram_copies, [node()]} - ]), - ok. + ]). cleanup(_) -> mnesia:delete_table(banned_word), - mnesia:stop(), - ok. + mnesia:stop(). core_banned_word_test_() -> - {foreach, - fun setup/0, - fun cleanup/1, - [ - {"Add banned word test", fun test_add_word/0}, - {"Add duplicate word test", fun test_add_duplicate/0}, - {"Remove banned word test", fun test_remove_word/0}, - {"List banned words test", fun test_list_words/0}, - {"Is banned test", fun test_is_banned/0}, - {"Check text test", fun test_check_text/0}, - {"Filter text test", fun test_filter_text/0} - ]}. + {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_word() -> - Word = <<"badword">>, - {ok, BannedWord} = core_banned_word:add(Word), - ?assertEqual(Word, BannedWord#banned_word.word), - ?assert(is_binary(BannedWord#banned_word.id)). +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_duplicate() -> - Word = <<"badword">>, - {ok, _} = core_banned_word:add(Word), - {error, already_exists} = core_banned_word:add(Word), - {error, already_exists} = core_banned_word:add(<<"BADWORD">>). % case insensitive +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_word() -> - Word = <<"badword">>, - {ok, _} = core_banned_word:add(Word), - {ok, removed} = core_banned_word:remove(Word), - {error, not_found} = core_banned_word:remove(<<"nonexistent">>). +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_list_words() -> - {ok, _} = core_banned_word:add(<<"word1">>), - {ok, _} = core_banned_word:add(<<"word2">>), - {ok, _} = core_banned_word:add(<<"word3">>), +test_remove_not_found() -> + ?assertEqual({error, not_found}, core_banned_words:remove_banned_word(<<"unknown">>)). - {ok, Words} = core_banned_word:list_all(), - ?assertEqual(3, length(Words)), - ?assert(lists:member(<<"word1">>, Words)). +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_is_banned() -> - Word = <<"badword">>, - ?assertNot(core_banned_word:is_banned(Word)), - {ok, _} = core_banned_word:add(Word), - ?assert(core_banned_word:is_banned(Word)), - ?assert(core_banned_word:is_banned(<<"BADWORD">>)). % case insensitive +test_update_not_found() -> + ?assertEqual({error, not_found}, core_banned_words:update_banned_word(<<"unknown">>, <<"newword">>)). -test_check_text() -> - {ok, _} = core_banned_word:add(<<"bad">>), - {ok, _} = core_banned_word:add(<<"spam">>), - - ?assertNot(core_banned_word:check_text(<<"Hello world">>)), - ?assert(core_banned_word:check_text(<<"This is bad">>)), - ?assert(core_banned_word:check_text(<<"This is SPAM">>)). - -test_filter_text() -> - {ok, _} = core_banned_word:add(<<"bad">>), - {ok, _} = core_banned_word:add(<<"spam">>), - - ?assertEqual(<<"Hello world">>, core_banned_word:filter_text(<<"Hello world">>)), - ?assertEqual(<<"This is ***">>, core_banned_word:filter_text(<<"This is bad">>)), - ?assertEqual(<<"*** and ***">>, core_banned_word:filter_text(<<"bad and spam">>)). \ No newline at end of file +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/logic_auth_tests.erl b/test/unit/logic_auth_tests.erl index 3bde936..3cd5bb7 100644 --- a/test/unit/logic_auth_tests.erl +++ b/test/unit/logic_auth_tests.erl @@ -1,14 +1,37 @@ -module(logic_auth_tests). -include_lib("eunit/include/eunit.hrl"). +-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), + {ok, _} = application:ensure_all_started(jose), + ok. + +cleanup(_) -> + application:unset_env(eventhub, jwt_secret), + application:unset_env(eventhub, admin_jwt_secret), + application:stop(jose). + +%% ------------------------------------------------------------------ +%% Тесты +%% ------------------------------------------------------------------ logic_auth_test_() -> [ {"Password hash test", fun test_password_hash/0}, - {"JWT generate and verify test", fun test_jwt/0}, - {"JWT expired test", fun test_jwt_expired/0}, - {"Refresh token test", fun test_refresh_token/0} + {setup, fun setup/0, fun cleanup/1, [ + {"JWT generate and verify test", fun test_jwt/0}, + {"JWT expired test", fun test_jwt_expired/0}, + {"Refresh token test", fun test_refresh_token/0} + ]} ]. +%% ── Хеширование паролей (остаётся в logic_auth) ────────────────── test_password_hash() -> Password = <<"secret123">>, {ok, Hash} = logic_auth:hash_password(Password), @@ -16,31 +39,27 @@ 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 = logic_auth:generate_jwt(UserId, Role), + Role = <<"user">>, + Token = auth:generate_user_token(UserId, Role), ?assert(is_binary(Token)), - - {ok, Claims} = logic_auth:verify_jwt(Token), - ?assertEqual(UserId, maps:get(<<"user_id">>, Claims)), - ?assertEqual(<<"user">>, maps:get(<<"role">>, Claims)), - ?assert(maps:is_key(<<"exp">>, Claims)), - ?assert(maps:is_key(<<"iat">>, Claims)), - + {ok, ReturnedUserId, ReturnedRole} = auth:verify_user_token(Token), + ?assertEqual(UserId, ReturnedUserId), + ?assertEqual(Role, ReturnedRole), % Проверка невалидного токена - {error, invalid_token} = logic_auth:verify_jwt(<<"invalid.token.here">>). + {error, invalid_token} = auth:verify_user_token(<<"invalid.token.here">>). test_jwt_expired() -> - % Пропускаем для простоты, так как требует мока времени + % Тест на истечение срока пока пропущен, так как требует мока времени ok. +%% ── Refresh token (перенесён в auth) ──────────────────────────── test_refresh_token() -> - {Token, ExpiresAt} = logic_auth:generate_refresh_token(<<"user123">>), + {Token, ExpiresAt} = auth:generate_refresh_token(<<"user123">>), ?assert(is_binary(Token)), ?assert(size(Token) >= 32), - ?assert(is_tuple(ExpiresAt)), - % Проверяем, что срок действия в будущем - Now = calendar:universal_time(), + ?assert(is_integer(ExpiresAt)), + Now = os:system_time(second), ?assert(ExpiresAt > Now). \ No newline at end of file