From a4a7daa5e0bc95d182f118f1c6e02337731c49e5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=90=D0=BB=D0=B5=D0=BA=D1=81=D0=B5=D0=B9=20=D0=A1=D0=B0?= =?UTF-8?q?=D0=B1=D0=B8=D0=BB=D0=B8=D0=BD?= Date: Tue, 21 Apr 2026 15:06:57 +0300 Subject: [PATCH] Stage 7 --- include/records.hrl | 1 + src/core/core_ticket.erl | 143 +++++++++++++ src/eventhub_app.erl | 8 + src/handlers/handler_ticket_by_id.erl | 162 +++++++++++++++ src/handlers/handler_ticket_stats.erl | 37 ++++ src/handlers/handler_tickets.erl | 118 +++++++++++ src/logic/logic_ticket.erl | 107 ++++++++++ test/core_ticket_tests.erl | 121 +++++++++++ test/logic_ticket_tests.erl | 105 ++++++++++ test/scripts/test_tickets_api.sh | 282 ++++++++++++++++++++++++++ 10 files changed, 1084 insertions(+) create mode 100644 src/core/core_ticket.erl create mode 100644 src/handlers/handler_ticket_by_id.erl create mode 100644 src/handlers/handler_ticket_stats.erl create mode 100644 src/handlers/handler_tickets.erl create mode 100644 src/logic/logic_ticket.erl create mode 100644 test/core_ticket_tests.erl create mode 100644 test/logic_ticket_tests.erl create mode 100644 test/scripts/test_tickets_api.sh diff --git a/include/records.hrl b/include/records.hrl index 2ec14e1..5fb1300 100644 --- a/include/records.hrl +++ b/include/records.hrl @@ -127,6 +127,7 @@ error_hash :: binary(), error_message :: binary(), stacktrace :: binary(), + context :: binary(), % ← новое поле (term_to_binary) count :: non_neg_integer(), first_seen :: calendar:datetime(), last_seen :: calendar:datetime(), diff --git a/src/core/core_ticket.erl b/src/core/core_ticket.erl new file mode 100644 index 0000000..4e000ba --- /dev/null +++ b/src/core/core_ticket.erl @@ -0,0 +1,143 @@ +-module(core_ticket). +-include("records.hrl"). + +-export([create_or_update/3, get_by_id/1, get_by_error_hash/1, list_all/0, list_by_status/1]). +-export([update_status/2, assign/2, add_resolution/2]). +-export([generate_id/0, generate_error_hash/2]). + +%% Создать или обновить тикет (группировка по хэшу ошибки) +create_or_update(ErrorMessage, Stacktrace, Context) -> + ErrorHash = generate_error_hash(ErrorMessage, Stacktrace), + case get_by_error_hash(ErrorHash) of + {error, not_found} -> + % Создаём новый тикет + Id = generate_id(), + Now = calendar:universal_time(), + Ticket = #ticket{ + id = Id, + error_hash = ErrorHash, + error_message = ErrorMessage, + stacktrace = Stacktrace, + context = term_to_binary(Context), + count = 1, + first_seen = Now, + last_seen = Now, + status = open, + assigned_to = undefined, + resolution_note = undefined + }, + F = fun() -> + mnesia:write(Ticket), + {ok, Ticket} + end, + case mnesia:transaction(F) of + {atomic, Result} -> Result; + {aborted, Reason} -> {error, Reason} + end; + {ok, Ticket} -> + % Обновляем существующий + F = fun() -> + Updated = Ticket#ticket{ + count = Ticket#ticket.count + 1, + last_seen = calendar:universal_time() + }, + mnesia:write(Updated), + {ok, Updated} + end, + case mnesia:transaction(F) of + {atomic, Result} -> Result; + {aborted, Reason} -> {error, Reason} + end + end. + +%% Получение тикета по ID +get_by_id(Id) -> + case mnesia:dirty_read(ticket, Id) of + [] -> {error, not_found}; + [Ticket] -> {ok, Ticket} + end. + +%% Получение тикета по хэшу ошибки +get_by_error_hash(ErrorHash) -> + Match = #ticket{error_hash = ErrorHash, _ = '_'}, + case mnesia:dirty_match_object(Match) of + [] -> {error, not_found}; + [Ticket] -> {ok, Ticket} + end. + +%% Список всех тикетов +list_all() -> + Match = #ticket{_ = '_'}, + Tickets = mnesia:dirty_match_object(Match), + {ok, lists:sort(fun(A, B) -> A#ticket.last_seen >= B#ticket.last_seen end, Tickets)}. + +%% Список тикетов по статусу +list_by_status(Status) -> + Match = #ticket{status = Status, _ = '_'}, + Tickets = mnesia:dirty_match_object(Match), + {ok, lists:sort(fun(A, B) -> A#ticket.last_seen >= B#ticket.last_seen end, Tickets)}. + +%% Обновление статуса тикета +update_status(Id, Status) when Status =:= open; Status =:= in_progress; Status =:= resolved; Status =:= closed -> + F = fun() -> + case mnesia:read(ticket, Id) of + [] -> + {error, not_found}; + [Ticket] -> + Updated = Ticket#ticket{status = Status}, + mnesia:write(Updated), + {ok, Updated} + end + end, + case mnesia:transaction(F) of + {atomic, Result} -> Result; + {aborted, Reason} -> {error, Reason} + end. + +%% Назначить тикет администратору +assign(Id, AdminId) -> + F = fun() -> + case mnesia:read(ticket, Id) of + [] -> + {error, not_found}; + [Ticket] -> + Updated = Ticket#ticket{ + assigned_to = AdminId, + status = case Ticket#ticket.status of + open -> in_progress; + S -> S + end + }, + mnesia:write(Updated), + {ok, Updated} + end + end, + case mnesia:transaction(F) of + {atomic, Result} -> Result; + {aborted, Reason} -> {error, Reason} + end. + +%% Добавить примечание о решении +add_resolution(Id, Note) -> + F = fun() -> + case mnesia:read(ticket, Id) of + [] -> + {error, not_found}; + [Ticket] -> + Updated = Ticket#ticket{resolution_note = Note}, + mnesia:write(Updated), + {ok, Updated} + end + end, + case mnesia:transaction(F) of + {atomic, Result} -> Result; + {aborted, Reason} -> {error, Reason} + end. + +%% Внутренние функции +generate_id() -> + base64:encode(crypto:strong_rand_bytes(16), #{mode => urlsafe, padding => false}). + +generate_error_hash(ErrorMessage, Stacktrace) -> + Data = iolist_to_binary([ErrorMessage, "\n", Stacktrace]), + base64:encode(crypto:hash(sha256, Data), #{mode => urlsafe, padding => false}). \ No newline at end of file diff --git a/src/eventhub_app.erl b/src/eventhub_app.erl index bbe4fbe..de47911 100644 --- a/src/eventhub_app.erl +++ b/src/eventhub_app.erl @@ -44,11 +44,19 @@ start_http() -> {"/v1/reviews", handler_reviews, []}, {"/v1/reviews/:id", handler_review_by_id, []}, {"/v1/reports", handler_reports, []}, + {"/v1/tickets", handler_tickets, []}, + + % Админские маршруты - более конкретные ПЕРЕД общими {"/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/:target_type/:id", handler_admin_moderation, []} ]} ]), diff --git a/src/handlers/handler_ticket_by_id.erl b/src/handlers/handler_ticket_by_id.erl new file mode 100644 index 0000000..c511bdf --- /dev/null +++ b/src/handlers/handler_ticket_by_id.erl @@ -0,0 +1,162 @@ +-module(handler_ticket_by_id). +-include("records.hrl"). + +-export([init/2]). + +init(Req, Opts) -> + handle(Req, Opts). + +handle(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> get_ticket(Req); + <<"PUT">> -> update_ticket(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +%% GET /v1/admin/tickets/:id - получить тикет +get_ticket(Req) -> + case handler_auth:authenticate(Req) of + {ok, AdminId, Req1} -> + TicketId = cowboy_req:binding(id, Req1), + case logic_ticket:get_ticket(AdminId, TicketId) of + {ok, Ticket} -> + Response = ticket_to_json(Ticket), + send_json(Req1, 200, Response); + {error, access_denied} -> + send_error(Req1, 403, <<"Admin access required">>); + {error, not_found} -> + send_error(Req1, 404, <<"Ticket not found">>); + {error, _} -> + send_error(Req1, 500, <<"Internal server error">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +%% PUT /v1/admin/tickets/:id - обновить тикет +update_ticket(Req) -> + case handler_auth:authenticate(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 + Decoded when is_map(Decoded) -> + handle_ticket_action(AdminId, TicketId, Decoded, Req2); + _ -> + send_error(Req2, 400, <<"Invalid JSON">>) + catch + _:_ -> + send_error(Req2, 400, <<"Invalid JSON format">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +%% Обработка действий с тикетом +handle_ticket_action(AdminId, TicketId, Body, Req) -> + case maps:get(<<"action">>, Body, undefined) of + <<"status">> -> + case maps:get(<<"status">>, Body, undefined) of + StatusBin when StatusBin =:= <<"open">>; + StatusBin =:= <<"in_progress">>; + StatusBin =:= <<"resolved">>; + StatusBin =:= <<"closed">> -> + Status = binary_to_atom(StatusBin), + case logic_ticket:update_status(AdminId, TicketId, Status) of + {ok, Ticket} -> + Response = ticket_to_json(Ticket), + send_json(Req, 200, Response); + {error, access_denied} -> + send_error(Req, 403, <<"Admin access required">>); + {error, not_found} -> + send_error(Req, 404, <<"Ticket not found">>); + {error, _} -> + send_error(Req, 500, <<"Internal server error">>) + end; + _ -> + send_error(Req, 400, <<"Invalid status">>) + end; + <<"assign">> -> + case maps:get(<<"admin_id">>, Body, undefined) of + undefined -> + send_error(Req, 400, <<"Missing admin_id field">>); + AssignToId -> + case logic_ticket:assign_ticket(AdminId, TicketId, AssignToId) of + {ok, Ticket} -> + Response = ticket_to_json(Ticket), + send_json(Req, 200, Response); + {error, access_denied} -> + send_error(Req, 403, <<"Admin access required">>); + {error, not_found} -> + send_error(Req, 404, <<"Ticket not found">>); + {error, _} -> + send_error(Req, 500, <<"Internal server error">>) + end + end; + <<"resolve">> -> + Note = maps:get(<<"note">>, Body, <<"">>), + case logic_ticket:resolve_ticket(AdminId, TicketId, Note) of + {ok, Ticket} -> + Response = ticket_to_json(Ticket), + send_json(Req, 200, Response); + {error, access_denied} -> + send_error(Req, 403, <<"Admin access required">>); + {error, not_found} -> + send_error(Req, 404, <<"Ticket not found">>); + {error, _} -> + send_error(Req, 500, <<"Internal server error">>) + end; + <<"close">> -> + case logic_ticket:close_ticket(AdminId, TicketId) of + {ok, Ticket} -> + Response = ticket_to_json(Ticket), + send_json(Req, 200, Response); + {error, access_denied} -> + send_error(Req, 403, <<"Admin access required">>); + {error, not_found} -> + send_error(Req, 404, <<"Ticket not found">>); + {error, _} -> + send_error(Req, 500, <<"Internal server error">>) + end; + _ -> + send_error(Req, 400, <<"Invalid action">>) + end. + +%% Вспомогательные функции +ticket_to_json(Ticket) -> + Context = try binary_to_term(Ticket#ticket.context) of + C -> C + catch + _:_ -> #{} + end, + + #{ + id => Ticket#ticket.id, + error_hash => Ticket#ticket.error_hash, + error_message => Ticket#ticket.error_message, + stacktrace => Ticket#ticket.stacktrace, + context => Context, + count => Ticket#ticket.count, + first_seen => datetime_to_iso8601(Ticket#ticket.first_seen), + last_seen => datetime_to_iso8601(Ticket#ticket.last_seen), + status => Ticket#ticket.status, + assigned_to => Ticket#ticket.assigned_to, + resolution_note => Ticket#ticket.resolution_note + }. + +datetime_to_iso8601({{Year, Month, Day}, {Hour, Minute, Second}}) -> + iolist_to_binary(io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", + [Year, Month, Day, Hour, Minute, Second])). + +binary_to_atom(<<"open">>) -> open; +binary_to_atom(<<"in_progress">>) -> in_progress; +binary_to_atom(<<"resolved">>) -> resolved; +binary_to_atom(<<"closed">>) -> closed. + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). \ No newline at end of file diff --git a/src/handlers/handler_ticket_stats.erl b/src/handlers/handler_ticket_stats.erl new file mode 100644 index 0000000..7c42f19 --- /dev/null +++ b/src/handlers/handler_ticket_stats.erl @@ -0,0 +1,37 @@ +-module(handler_ticket_stats). +-include("records.hrl"). + +-export([init/2]). + +init(Req, Opts) -> + handle(Req, Opts). + +handle(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> get_statistics(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +%% GET /v1/admin/tickets/stats - статистика по тикетам +get_statistics(Req) -> + case handler_auth:authenticate(Req) of + {ok, AdminId, Req1} -> + case logic_ticket:get_statistics(AdminId) of + Stats when is_map(Stats) -> + send_json(Req1, 200, Stats); + {error, access_denied} -> + send_error(Req1, 403, <<"Admin access required">>); + {error, _} -> + send_error(Req1, 500, <<"Internal server error">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +send_json(Req, Status, Data) -> + Body = jsx:encode(Data), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). \ No newline at end of file diff --git a/src/handlers/handler_tickets.erl b/src/handlers/handler_tickets.erl new file mode 100644 index 0000000..08166e4 --- /dev/null +++ b/src/handlers/handler_tickets.erl @@ -0,0 +1,118 @@ +-module(handler_tickets). +-include("records.hrl"). + +-export([init/2]). + +init(Req, Opts) -> + handle(Req, Opts). + +handle(Req, _Opts) -> + case cowboy_req:method(Req) of + <<"GET">> -> list_tickets(Req); + <<"POST">> -> report_error(Req); + _ -> send_error(Req, 405, <<"Method not allowed">>) + end. + +%% POST /v1/tickets - сообщить об ошибке (доступно всем) +report_error(Req) -> + case handler_auth:authenticate(Req) of + {ok, UserId, Req1} -> + {ok, Body, Req2} = cowboy_req:read_body(Req1), + try jsx:decode(Body, [return_maps]) of + Decoded when is_map(Decoded) -> + case Decoded of + #{<<"error_message">> := ErrorMessage} -> + Stacktrace = maps:get(<<"stacktrace">>, Decoded, <<"">>), + Context = maps:get(<<"context">>, Decoded, #{}), + + case logic_ticket:report_error(ErrorMessage, Stacktrace, Context) of + {ok, Ticket} -> + Response = ticket_to_json(Ticket), + send_json(Req2, 201, Response); + {error, _} -> + send_error(Req2, 500, <<"Internal server error">>) + end; + _ -> + send_error(Req2, 400, <<"Missing error_message field">>) + end; + _ -> + send_error(Req2, 400, <<"Invalid JSON">>) + catch + _:_ -> + send_error(Req2, 400, <<"Invalid JSON format">>) + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +%% GET /v1/admin/tickets - список тикетов (только админ) +list_tickets(Req) -> + case handler_auth:authenticate(Req) of + {ok, AdminId, Req1} -> + Qs = cowboy_req:parse_qs(Req1), + case proplists:get_value(<<"status">>, Qs) of + undefined -> + case logic_ticket:list_tickets(AdminId) of + {ok, Tickets} -> + Response = [ticket_to_json(T) || T <- Tickets], + send_json(Req1, 200, Response); + {error, access_denied} -> + send_error(Req1, 403, <<"Admin access required">>); + {error, _} -> + send_error(Req1, 500, <<"Internal server error">>) + end; + StatusBin -> + Status = parse_status(StatusBin), + case logic_ticket:list_tickets_by_status(AdminId, Status) of + {ok, Tickets} -> + Response = [ticket_to_json(T) || T <- Tickets], + send_json(Req1, 200, Response); + {error, access_denied} -> + send_error(Req1, 403, <<"Admin access required">>); + {error, _} -> + send_error(Req1, 500, <<"Internal server error">>) + end + end; + {error, Code, Message, Req1} -> + send_error(Req1, Code, Message) + end. + +%% Вспомогательные функции +parse_status(<<"open">>) -> open; +parse_status(<<"in_progress">>) -> in_progress; +parse_status(<<"resolved">>) -> resolved; +parse_status(<<"closed">>) -> closed; +parse_status(_) -> open. + +ticket_to_json(Ticket) -> + Context = try binary_to_term(Ticket#ticket.context) of + C -> C + catch + _:_ -> #{} + end, + + #{ + id => Ticket#ticket.id, + error_hash => Ticket#ticket.error_hash, + error_message => Ticket#ticket.error_message, + stacktrace => Ticket#ticket.stacktrace, + context => Context, + count => Ticket#ticket.count, + first_seen => datetime_to_iso8601(Ticket#ticket.first_seen), + last_seen => datetime_to_iso8601(Ticket#ticket.last_seen), + status => Ticket#ticket.status, + assigned_to => Ticket#ticket.assigned_to, + resolution_note => Ticket#ticket.resolution_note + }. + +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). + +send_error(Req, Status, Message) -> + Body = jsx:encode(#{error => Message}), + cowboy_req:reply(Status, #{<<"content-type">> => <<"application/json">>}, Body, Req). \ No newline at end of file diff --git a/src/logic/logic_ticket.erl b/src/logic/logic_ticket.erl new file mode 100644 index 0000000..68f3f42 --- /dev/null +++ b/src/logic/logic_ticket.erl @@ -0,0 +1,107 @@ +-module(logic_ticket). +-include("records.hrl"). + +-export([report_error/3, get_ticket/2, list_tickets/1, list_tickets_by_status/2]). +-export([update_status/3, assign_ticket/3, resolve_ticket/3, close_ticket/2]). +-export([get_statistics/1]). + +%% Зарегистрировать ошибку (создать или обновить тикет) +report_error(ErrorMessage, Stacktrace, Context) -> + case core_ticket:create_or_update(ErrorMessage, Stacktrace, Context) of + {ok, Ticket} -> + % Если это новый тикет, уведомляем администраторов (заглушка) + case Ticket#ticket.count of + 1 -> notify_admins(Ticket); + _ -> ok + end, + {ok, Ticket}; + Error -> Error + end. + +%% Получить тикет (только для админов) +get_ticket(AdminId, TicketId) -> + case is_admin(AdminId) of + true -> core_ticket:get_by_id(TicketId); + false -> {error, access_denied} + end. + +%% Список всех тикетов (только для админов) +list_tickets(AdminId) -> + case is_admin(AdminId) of + true -> core_ticket:list_all(); + false -> {error, access_denied} + end. + +%% Список тикетов по статусу (только для админов) +list_tickets_by_status(AdminId, Status) -> + case is_admin(AdminId) of + true -> core_ticket:list_by_status(Status); + false -> {error, access_denied} + end. + +%% Обновить статус тикета +update_status(AdminId, TicketId, Status) -> + case is_admin(AdminId) of + true -> core_ticket:update_status(TicketId, Status); + false -> {error, access_denied} + end. + +%% Назначить тикет администратору +assign_ticket(AdminId, TicketId, AssignToId) -> + case is_admin(AdminId) of + true -> core_ticket:assign(TicketId, AssignToId); + false -> {error, access_denied} + end. + +%% Отметить тикет как решённый с примечанием +resolve_ticket(AdminId, TicketId, ResolutionNote) -> + case is_admin(AdminId) of + true -> + case core_ticket:add_resolution(TicketId, ResolutionNote) of + {ok, Ticket} -> + core_ticket:update_status(TicketId, resolved); + Error -> Error + end; + false -> {error, access_denied} + end. + +%% Закрыть тикет +close_ticket(AdminId, TicketId) -> + case is_admin(AdminId) of + true -> core_ticket:update_status(TicketId, closed); + false -> {error, access_denied} + end. + +%% Получить статистику по тикетам +get_statistics(AdminId) -> + case is_admin(AdminId) of + true -> + {ok, AllTickets} = core_ticket:list_all(), + Open = length([T || T <- AllTickets, T#ticket.status =:= open]), + InProgress = length([T || T <- AllTickets, T#ticket.status =:= in_progress]), + Resolved = length([T || T <- AllTickets, T#ticket.status =:= resolved]), + Closed = length([T || T <- AllTickets, T#ticket.status =:= closed]), + TotalErrors = lists:sum([T#ticket.count || T <- AllTickets]), + #{ + total_tickets => length(AllTickets), + open => Open, + in_progress => InProgress, + resolved => Resolved, + closed => Closed, + total_errors => TotalErrors + }; + false -> {error, access_denied} + end. + +%% ============ Вспомогательные функции ============ + +is_admin(UserId) -> + case core_user:get_by_id(UserId) of + {ok, User} -> User#user.role =:= admin; + _ -> false + end. + +notify_admins(_Ticket) -> + % Заглушка для уведомлений администраторов + % В будущем здесь будет отправка email/websocket + ok. \ No newline at end of file diff --git a/test/core_ticket_tests.erl b/test/core_ticket_tests.erl new file mode 100644 index 0000000..9d7ed82 --- /dev/null +++ b/test/core_ticket_tests.erl @@ -0,0 +1,121 @@ +-module(core_ticket_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +setup() -> + mnesia:start(), + mnesia:create_table(ticket, [ + {attributes, record_info(fields, ticket)}, + {ram_copies, [node()]} + ]), + ok. + +cleanup(_) -> + mnesia:delete_table(ticket), + mnesia:stop(), + ok. + +core_ticket_test_() -> + {foreach, + fun setup/0, + fun cleanup/1, + [ + {"Create ticket test", fun test_create_ticket/0}, + {"Update existing ticket test", fun test_update_ticket/0}, + {"Get ticket by id test", fun test_get_by_id/0}, + {"Get ticket by error hash test", fun test_get_by_error_hash/0}, + {"List all tickets test", fun test_list_all/0}, + {"List by status test", fun test_list_by_status/0}, + {"Update status test", fun test_update_status/0}, + {"Assign ticket test", fun test_assign_ticket/0}, + {"Add resolution test", fun test_add_resolution/0} + ]}. + +test_create_ticket() -> + ErrorMsg = <<"Test error">>, + Stacktrace = <<"line 1\nline 2">>, + Context = #{user_id => <<"user123">>}, + + {ok, Ticket} = core_ticket:create_or_update(ErrorMsg, Stacktrace, Context), + + ?assertEqual(ErrorMsg, Ticket#ticket.error_message), + ?assertEqual(Stacktrace, Ticket#ticket.stacktrace), + ?assertEqual(1, Ticket#ticket.count), + ?assertEqual(open, Ticket#ticket.status), + ?assert(is_binary(Ticket#ticket.id)), + ?assert(is_binary(Ticket#ticket.error_hash)). + +test_update_ticket() -> + ErrorMsg = <<"Test error">>, + Stacktrace = <<"line 1">>, + Context = #{}, + + {ok, Ticket1} = core_ticket:create_or_update(ErrorMsg, Stacktrace, Context), + ?assertEqual(1, Ticket1#ticket.count), + + {ok, Ticket2} = core_ticket:create_or_update(ErrorMsg, Stacktrace, Context), + ?assertEqual(Ticket1#ticket.id, Ticket2#ticket.id), + ?assertEqual(2, Ticket2#ticket.count), + ?assert(Ticket2#ticket.last_seen >= Ticket1#ticket.last_seen). + +test_get_by_id() -> + {ok, Ticket} = core_ticket:create_or_update(<<"Error">>, <<"">>, #{}), + + {ok, Found} = core_ticket:get_by_id(Ticket#ticket.id), + ?assertEqual(Ticket#ticket.id, Found#ticket.id), + + {error, not_found} = core_ticket:get_by_id(<<"nonexistent">>). + +test_get_by_error_hash() -> + ErrorMsg = <<"Unique error">>, + Stacktrace = <<"stack">>, + {ok, Ticket} = core_ticket:create_or_update(ErrorMsg, Stacktrace, #{}), + + {ok, Found} = core_ticket:get_by_error_hash(Ticket#ticket.error_hash), + ?assertEqual(Ticket#ticket.id, Found#ticket.id), + + {error, not_found} = core_ticket:get_by_error_hash(<<"badhash">>). + +test_list_all() -> + {ok, _} = core_ticket:create_or_update(<<"Error 1">>, <<"">>, #{}), + {ok, _} = core_ticket:create_or_update(<<"Error 2">>, <<"">>, #{}), + {ok, _} = core_ticket:create_or_update(<<"Error 3">>, <<"">>, #{}), + + {ok, Tickets} = core_ticket:list_all(), + ?assertEqual(3, length(Tickets)). + +test_list_by_status() -> + {ok, T1} = core_ticket:create_or_update(<<"E1">>, <<"">>, #{}), + {ok, T2} = core_ticket:create_or_update(<<"E2">>, <<"">>, #{}), + + core_ticket:update_status(T2#ticket.id, resolved), + + {ok, Open} = core_ticket:list_by_status(open), + ?assertEqual(1, length(Open)), + + {ok, Resolved} = core_ticket:list_by_status(resolved), + ?assertEqual(1, length(Resolved)). + +test_update_status() -> + {ok, Ticket} = core_ticket:create_or_update(<<"Error">>, <<"">>, #{}), + + {ok, Updated} = core_ticket:update_status(Ticket#ticket.id, in_progress), + ?assertEqual(in_progress, Updated#ticket.status), + + {ok, Resolved} = core_ticket:update_status(Ticket#ticket.id, resolved), + ?assertEqual(resolved, Resolved#ticket.status). + +test_assign_ticket() -> + AdminId = <<"admin123">>, + {ok, Ticket} = core_ticket:create_or_update(<<"Error">>, <<"">>, #{}), + + {ok, Assigned} = core_ticket:assign(Ticket#ticket.id, AdminId), + ?assertEqual(AdminId, Assigned#ticket.assigned_to), + ?assertEqual(in_progress, Assigned#ticket.status). + +test_add_resolution() -> + Note = <<"Fixed in version 1.0">>, + {ok, Ticket} = core_ticket:create_or_update(<<"Error">>, <<"">>, #{}), + + {ok, Updated} = core_ticket:add_resolution(Ticket#ticket.id, Note), + ?assertEqual(Note, Updated#ticket.resolution_note). \ No newline at end of file diff --git a/test/logic_ticket_tests.erl b/test/logic_ticket_tests.erl new file mode 100644 index 0000000..d5f01fe --- /dev/null +++ b/test/logic_ticket_tests.erl @@ -0,0 +1,105 @@ +-module(logic_ticket_tests). +-include_lib("eunit/include/eunit.hrl"). +-include("records.hrl"). + +setup() -> + mnesia:start(), + mnesia:create_table(user, [{attributes, record_info(fields, user)}, {ram_copies, [node()]}]), + mnesia:create_table(ticket, [{attributes, record_info(fields, ticket)}, {ram_copies, [node()]}]), + ok. + +cleanup(_) -> + mnesia:delete_table(ticket), + mnesia:delete_table(user), + mnesia:stop(), + ok. + +logic_ticket_test_() -> + {foreach, + fun setup/0, + fun cleanup/1, + [ + {"Report error test", fun test_report_error/0}, + {"List tickets admin only", fun test_list_tickets_admin_only/0}, + {"Update status test", fun test_update_status/0}, + {"Assign ticket test", fun test_assign_ticket/0}, + {"Resolve ticket test", fun test_resolve_ticket/0}, + {"Close ticket test", fun test_close_ticket/0}, + {"Get statistics test", fun test_get_statistics/0} + ]}. + +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_report_error() -> + {ok, Ticket} = logic_ticket:report_error(<<"Test error">>, <<"stack">>, #{}), + ?assertEqual(<<"Test error">>, Ticket#ticket.error_message), + ?assertEqual(1, Ticket#ticket.count), + + {ok, Ticket2} = logic_ticket:report_error(<<"Test error">>, <<"stack">>, #{}), + ?assertEqual(2, Ticket2#ticket.count). + +test_list_tickets_admin_only() -> + AdminId = create_test_user(admin), + UserId = create_test_user(user), + + {ok, _} = logic_ticket:report_error(<<"E1">>, <<"">>, #{}), + {ok, _} = logic_ticket:report_error(<<"E2">>, <<"">>, #{}), + + {ok, Tickets} = logic_ticket:list_tickets(AdminId), + ?assertEqual(2, length(Tickets)), + + {error, access_denied} = logic_ticket:list_tickets(UserId). + +test_update_status() -> + AdminId = create_test_user(admin), + UserId = create_test_user(user), + {ok, Ticket} = logic_ticket:report_error(<<"Error">>, <<"">>, #{}), + + {ok, Updated} = logic_ticket:update_status(AdminId, Ticket#ticket.id, in_progress), + ?assertEqual(in_progress, Updated#ticket.status), + + {error, access_denied} = logic_ticket:update_status(UserId, Ticket#ticket.id, resolved). + +test_assign_ticket() -> + AdminId = create_test_user(admin), + AssignToId = create_test_user(admin), + {ok, Ticket} = logic_ticket:report_error(<<"Error">>, <<"">>, #{}), + + {ok, Assigned} = logic_ticket:assign_ticket(AdminId, Ticket#ticket.id, AssignToId), + ?assertEqual(AssignToId, Assigned#ticket.assigned_to), + ?assertEqual(in_progress, Assigned#ticket.status). + +test_resolve_ticket() -> + AdminId = create_test_user(admin), + {ok, Ticket} = logic_ticket:report_error(<<"Error">>, <<"">>, #{}), + + {ok, Resolved} = logic_ticket:resolve_ticket(AdminId, Ticket#ticket.id, <<"Fixed">>), + ?assertEqual(<<"Fixed">>, Resolved#ticket.resolution_note), + ?assertEqual(resolved, Resolved#ticket.status). + +test_close_ticket() -> + AdminId = create_test_user(admin), + {ok, Ticket} = logic_ticket:report_error(<<"Error">>, <<"">>, #{}), + + {ok, Closed} = logic_ticket:close_ticket(AdminId, Ticket#ticket.id), + ?assertEqual(closed, Closed#ticket.status). + +test_get_statistics() -> + AdminId = create_test_user(admin), + + {ok, _} = logic_ticket:report_error(<<"E1">>, <<"">>, #{}), + {ok, _} = logic_ticket:report_error(<<"E2">>, <<"">>, #{}), + {ok, T3} = logic_ticket:report_error(<<"E3">>, <<"">>, #{}), + + logic_ticket:update_status(AdminId, T3#ticket.id, resolved), + + Stats = logic_ticket:get_statistics(AdminId), + ?assertEqual(3, maps:get(total_tickets, Stats)), + ?assertEqual(2, maps:get(open, Stats)), + ?assertEqual(1, maps:get(resolved, Stats)), + ?assertEqual(3, maps:get(total_errors, Stats)). \ No newline at end of file diff --git a/test/scripts/test_tickets_api.sh b/test/scripts/test_tickets_api.sh new file mode 100644 index 0000000..73f3a81 --- /dev/null +++ b/test/scripts/test_tickets_api.sh @@ -0,0 +1,282 @@ +#!/bin/bash + +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +BASE_URL="http://localhost:8080" + +log_info() { echo -e "${BLUE}[INFO]${NC} $1"; } +log_success() { echo -e "${GREEN}[SUCCESS]${NC} $1"; } +log_error() { echo -e "${RED}[ERROR]${NC} $1"; } + +extract_json() { + echo "$1" | grep -o "\"$2\":\"[^\"]*\"" | head -1 | sed "s/\"$2\":\"//;s/\"$//" +} + +extract_json_number() { + echo "$1" | grep -o "\"$2\":[0-9]*" | head -1 | sed "s/\"$2\"://" +} + +http_post() { + local url=$1; local data=$2; local token=$3 + if [ -n "$token" ]; then + curl -s -X POST "$url" -H "Content-Type: application/json" -H "Authorization: Bearer $token" -d "$data" + else + curl -s -X POST "$url" -H "Content-Type: application/json" -d "$data" + fi +} + +http_get() { + local url=$1; local token=$2 + if [ -n "$token" ]; then + curl -s -X GET "$url" -H "Authorization: Bearer $token" + else + curl -s -X GET "$url" + fi +} + +http_put() { + local url=$1; local data=$2; local token=$3 + curl -s -X PUT "$url" -H "Content-Type: application/json" -H "Authorization: Bearer $token" -d "$data" +} + +echo "============================================================" +echo " EVENTHUB TICKETS API TEST SCRIPT" +echo "============================================================" +echo "" + +log_info "Checking if server is running..." +if ! curl -s "$BASE_URL/health" | grep -q "ok"; then + log_error "Server is not running" + exit 1 +fi +log_success "Server is running" + +echo "" +log_info "============================================================" +log_info "STEP 1: Create test users" +log_info "============================================================" + +# Админ (первый пользователь) +ADMIN_EMAIL="ticket_admin_$(date +%s)@example.com" +ADMIN_PASSWORD="admin123" + +log_info "Creating admin user..." +response=$(http_post "$BASE_URL/v1/register" "{\"email\":\"$ADMIN_EMAIL\",\"password\":\"$ADMIN_PASSWORD\"}" "") +ADMIN_TOKEN=$(extract_json "$response" "token") +ADMIN_ID=$(extract_json "$response" "id") +log_success "Admin created" + +# Обычный пользователь +USER_EMAIL="ticket_user_$(date +%s)@example.com" +USER_PASSWORD="user123" + +log_info "Creating regular user..." +response=$(http_post "$BASE_URL/v1/register" "{\"email\":\"$USER_EMAIL\",\"password\":\"$USER_PASSWORD\"}" "") +USER_TOKEN=$(extract_json "$response" "token") +USER_ID=$(extract_json "$response" "id") +log_success "User created" + +echo "" +log_info "============================================================" +log_info "TEST 1: Report error (user)" +log_info "============================================================" + +log_info "User reporting error..." +response=$(http_post "$BASE_URL/v1/tickets" \ + "{\"error_message\":\"Test error occurred\",\"stacktrace\":\"line 1\\nline 2\",\"context\":{\"user_id\":\"$USER_ID\"}}" "$USER_TOKEN") +TICKET1_ID=$(extract_json "$response" "id") + +if [ -n "$TICKET1_ID" ]; then + log_success "Ticket created: $TICKET1_ID" +else + log_error "Failed to create ticket: $response" +fi + +echo "" +log_info "============================================================" +log_info "TEST 2: Report same error again (should increment count)" +log_info "============================================================" + +log_info "User reporting same error..." +response=$(http_post "$BASE_URL/v1/tickets" \ + "{\"error_message\":\"Test error occurred\",\"stacktrace\":\"line 1\\nline 2\"}" "$USER_TOKEN") +COUNT=$(extract_json_number "$response" "count") + +if [ "$COUNT" -eq 2 ]; then + log_success "Ticket count incremented to $COUNT" +else + log_error "Count should be 2, got $COUNT" +fi + +echo "" +log_info "============================================================" +log_info "TEST 3: Report different error" +log_info "============================================================" + +log_info "User reporting different error..." +response=$(http_post "$BASE_URL/v1/tickets" \ + "{\"error_message\":\"Another error\"}" "$USER_TOKEN") +TICKET2_ID=$(extract_json "$response" "id") + +if [ -n "$TICKET2_ID" ] && [ "$TICKET2_ID" != "$TICKET1_ID" ]; then + log_success "New ticket created: $TICKET2_ID" +else + log_error "Failed to create new ticket" +fi + +echo "" +log_info "============================================================" +log_info "TEST 4: Admin views all tickets" +log_info "============================================================" + +log_info "Admin getting all tickets..." +response=$(http_get "$BASE_URL/v1/admin/tickets" "$ADMIN_TOKEN") +TICKET_COUNT=$(echo "$response" | grep -o "\"id\"" | wc -l) + +if [ "$TICKET_COUNT" -eq 2 ]; then + log_success "Admin sees $TICKET_COUNT tickets" +else + log_error "Admin should see 2 tickets, found $TICKET_COUNT" +fi + +echo "" +log_info "============================================================" +log_info "TEST 5: User cannot view tickets" +log_info "============================================================" + +log_info "User trying to view tickets..." +response=$(http_get "$BASE_URL/v1/admin/tickets" "$USER_TOKEN") + +if echo "$response" | grep -q "Admin access required"; then + log_success "User correctly denied access" +else + log_error "User should be denied: $response" +fi + +echo "" +log_info "============================================================" +log_info "TEST 6: Admin views tickets by status" +log_info "============================================================" + +log_info "Admin getting open tickets..." +response=$(http_get "$BASE_URL/v1/admin/tickets?status=open" "$ADMIN_TOKEN") +OPEN_COUNT=$(echo "$response" | grep -o "\"id\"" | wc -l) + +if [ "$OPEN_COUNT" -eq 2 ]; then + log_success "Found $OPEN_COUNT open tickets" +else + log_error "Should find 2 open tickets, found $OPEN_COUNT" +fi + +echo "" +log_info "============================================================" +log_info "TEST 7: Admin updates ticket status" +log_info "============================================================" + +log_info "Admin marking ticket as in_progress..." +response=$(http_put "$BASE_URL/v1/admin/tickets/$TICKET1_ID" \ + "{\"action\":\"status\",\"status\":\"in_progress\"}" "$ADMIN_TOKEN") + +STATUS=$(echo "$response" | grep -o "\"status\":\"[^\"]*\"" | sed 's/"status":"//;s/"//') +if [ "$STATUS" = "in_progress" ]; then + log_success "Ticket status updated to in_progress" +else + log_error "Failed to update status: $response" +fi + +echo "" +log_info "============================================================" +log_info "TEST 8: Admin assigns ticket" +log_info "============================================================" + +log_info "Admin assigning ticket..." +response=$(http_put "$BASE_URL/v1/admin/tickets/$TICKET1_ID" \ + "{\"action\":\"assign\",\"admin_id\":\"$ADMIN_ID\"}" "$ADMIN_TOKEN") + +ASSIGNED=$(echo "$response" | grep -o "\"assigned_to\":\"[^\"]*\"" | sed 's/"assigned_to":"//;s/"//') +if [ "$ASSIGNED" = "$ADMIN_ID" ]; then + log_success "Ticket assigned to admin" +else + log_error "Failed to assign ticket: $response" +fi + +echo "" +log_info "============================================================" +log_info "TEST 9: Admin resolves ticket" +log_info "============================================================" + +log_info "Admin resolving ticket..." +response=$(http_put "$BASE_URL/v1/admin/tickets/$TICKET1_ID" \ + "{\"action\":\"resolve\",\"note\":\"Fixed in version 1.0\"}" "$ADMIN_TOKEN") + +STATUS=$(echo "$response" | grep -o "\"status\":\"[^\"]*\"" | sed 's/"status":"//;s/"//') +NOTE=$(echo "$response" | grep -o "\"resolution_note\":\"[^\"]*\"" | sed 's/"resolution_note":"//;s/"//') + +if [ "$STATUS" = "resolved" ] && [ "$NOTE" = "Fixed in version 1.0" ]; then + log_success "Ticket resolved with note" +else + log_error "Failed to resolve ticket: $response" +fi + +echo "" +log_info "============================================================" +log_info "TEST 10: Admin closes ticket" +log_info "============================================================" + +log_info "Admin closing ticket..." +response=$(http_put "$BASE_URL/v1/admin/tickets/$TICKET1_ID" \ + "{\"action\":\"close\"}" "$ADMIN_TOKEN") + +STATUS=$(echo "$response" | grep -o "\"status\":\"[^\"]*\"" | sed 's/"status":"//;s/"//') +if [ "$STATUS" = "closed" ]; then + log_success "Ticket closed" +else + log_error "Failed to close ticket: $response" +fi + +echo "" +log_info "============================================================" +log_info "TEST 11: Admin views statistics" +log_info "============================================================" + +log_info "Admin getting statistics..." +response=$(http_get "$BASE_URL/v1/admin/tickets/stats" "$ADMIN_TOKEN") + +TOTAL=$(extract_json_number "$response" "total_tickets") +OPEN=$(extract_json_number "$response" "open") +CLOSED=$(extract_json_number "$response" "closed") + +if [ "$TOTAL" -eq 2 ] && [ "$OPEN" -eq 1 ] && [ "$CLOSED" -eq 1 ]; then + log_success "Statistics: total=$TOTAL, open=$OPEN, closed=$CLOSED" +else + log_error "Statistics incorrect: $response" +fi + +echo "" +log_info "============================================================" +log_info "TEST 12: Get single ticket" +log_info "============================================================" + +log_info "Admin getting ticket $TICKET2_ID..." +response=$(http_get "$BASE_URL/v1/admin/tickets/$TICKET2_ID" "$ADMIN_TOKEN") + +if echo "$response" | grep -q "$TICKET2_ID"; then + log_success "Ticket retrieved" +else + log_error "Failed to get ticket: $response" +fi + +echo "" +echo "============================================================" +log_success "TICKETS API TESTS COMPLETED!" +echo "============================================================" +echo "" +echo "Summary of created resources:" +echo " Admin: $ADMIN_EMAIL" +echo " User: $USER_EMAIL" +echo " Tickets: $TICKET1_ID, $TICKET2_ID" +echo "" \ No newline at end of file