diff --git a/scripts/fix-rebar-unit-symlink.sh b/scripts/fix-rebar-unit-symlink.sh index aa3a95b..1d074a3 100644 --- a/scripts/fix-rebar-unit-symlink.sh +++ b/scripts/fix-rebar-unit-symlink.sh @@ -1,17 +1,24 @@ #!/usr/bin/env bash set -euo pipefail -# Rebar3 на WSL+Windows иногда оставляет symlink вместо каталога. -TEST_DIR="/home/alexc/eventhub-wsl-data/_build/test/lib/eventhub/test" +# Rebar3 на WSL+ext4 _build оставляет битые symlink unit|api (относительный путь не резолвится). +# Заменяем на полную копию из репозитория. + +PROJ="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)" +BUILD_DIR="$(readlink -f "${PROJ}/_build" 2>/dev/null || echo "${PROJ}/_build")" +TEST_DIR="${BUILD_DIR}/test/lib/eventhub/test" mkdir -p "${TEST_DIR}" for name in unit api; do target="${TEST_DIR}/${name}" - if [ -L "${target}" ] || [ -f "${target}" ]; then - rm -f "${target}" + src="${PROJ}/test/${name}" + if [ ! -d "${src}" ]; then + echo "fix-rebar-unit-symlink: нет каталога ${src}" >&2 + exit 1 fi - mkdir -p "${target}" - echo "Fixed: ${target}" + rm -rf "${target}" + cp -a "${src}" "${target}" + echo "Copied: ${src} -> ${target}" ls -ld "${target}" done diff --git a/scripts/run-wsl-tests.sh b/scripts/run-wsl-tests.sh new file mode 100644 index 0000000..3c9e65c --- /dev/null +++ b/scripts/run-wsl-tests.sh @@ -0,0 +1,54 @@ +#!/usr/bin/env bash +# Запуск тестов только в Linux (WSL dev): asdf OTP 28 + rebar 3.27. +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJ="$(cd "${SCRIPT_DIR}/.." && pwd)" + +# shellcheck source=scripts/wsl-dev-env.sh +source "${SCRIPT_DIR}/wsl-dev-env.sh" + +cd "${PROJ}" +bash "${SCRIPT_DIR}/wsl-ext4-dirs.sh" 2>/dev/null || true +bash "${SCRIPT_DIR}/fix-rebar-unit-symlink.sh" + +echo "=== env ===" +erl -noshell -eval 'io:format("OTP ~s~n", [erlang:system_info(otp_release)]), halt().' +rebar3 --version +echo "rebar3: $(command -v rebar3)" +echo "erl: $(command -v erl)" + +echo "=== compile ===" +rebar3 compile + +bash "${SCRIPT_DIR}/fix-rebar-unit-symlink.sh" + +echo "=== eunit: admin_utils_tests ===" +rebar3 eunit --module=admin_utils_tests --verbose + +echo "=== eunit: logic_ticket_tests ===" +rebar3 eunit --module=logic_ticket_tests --verbose + +bash "${SCRIPT_DIR}/fix-rebar-unit-symlink.sh" + +port_busy() { + ss -tln 2>/dev/null | grep -q ":$1 " +} + +run_ct() { + local case_name="$1" + echo "=== ct: ${case_name} ===" + rebar3 ct --suite=api_admins_SUITE --case="${case_name}" --verbose +} + +if port_busy 8080 || port_busy 8445; then + echo "=== ct: SKIP (порты 8080/8445 заняты — остановите eventhub и повторите) ===" + ss -tln 2>/dev/null | grep -E ':8080|:8445' || true + exit 0 +fi + +run_ct admin_test_stats +bash "${SCRIPT_DIR}/fix-rebar-unit-symlink.sh" +run_ct admin_test_audit + +echo "=== ALL PASSED ===" diff --git a/scripts/wsl-dev-env.sh b/scripts/wsl-dev-env.sh new file mode 100644 index 0000000..89c2b4a --- /dev/null +++ b/scripts/wsl-dev-env.sh @@ -0,0 +1,22 @@ +#!/usr/bin/env bash +# Linux-only WSL dev: asdf OTP 28 + rebar 3.27 (без Windows erl/rebar3 в PATH). +set -euo pipefail + +ASDF_DIR="${HOME}/.asdf" +ERLANG_VER="${ERLANG_VER:-28.2}" +REBAR_VER="${REBAR_VER:-3.27.0}" + +ERL_BIN="${ASDF_DIR}/installs/erlang/${ERLANG_VER}/bin" +REBAR_BIN="${ASDF_DIR}/installs/rebar/${REBAR_VER}/bin" + +if [ ! -x "${ERL_BIN}/erl" ]; then + echo "wsl-dev-env: нет ${ERL_BIN}/erl (asdf install erlang ${ERLANG_VER})" >&2 + exit 1 +fi +if [ ! -x "${REBAR_BIN}/rebar3" ]; then + echo "wsl-dev-env: нет ${REBAR_BIN}/rebar3 (asdf install rebar ${REBAR_VER})" >&2 + exit 1 +fi + +# Только Linux PATH — без /mnt/c/.../Erlang OTP и rebar3 +export PATH="${ERL_BIN}:${REBAR_BIN}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" diff --git a/src/handlers/handler_utils.erl b/src/handlers/handler_utils.erl index e98a26e..c3d4a49 100644 --- a/src/handlers/handler_utils.erl +++ b/src/handlers/handler_utils.erl @@ -318,7 +318,8 @@ ticket_to_json(Ticket) -> 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 + resolution_note => Ticket#ticket.resolution_note, + closed_at => datetime_to_iso8601(Ticket#ticket.closed_at) }. %% @doc Преобразует #calendar{} в JSON-карту. diff --git a/src/infra/admin_utils.erl b/src/infra/admin_utils.erl index 997beb6..a9661f5 100644 --- a/src/infra/admin_utils.erl +++ b/src/infra/admin_utils.erl @@ -50,15 +50,26 @@ get_permissions(_) -> []. %% -- работа с IP ----------------------------------------------------------- %% @doc Возвращает IP-адрес клиента в виде бинарной строки (напр. <<"127.0.0.1">>). -%% Сохранена для обратной совместимости; внутренне вызывает ip_to_binary/1. +%% Сначала читает заголовки прокси, затем fallback на cowboy_req:peer/1. -spec client_ip(cowboy_req:req()) -> binary(). client_ip(Req) -> - ip_to_binary(cowboy_req:peer(Req)). + case forwarded_client_ip(Req) of + undefined -> ip_to_binary(cowboy_req:peer(Req)); + Ip -> Ip + end. -%% @doc Преобразует IP-адрес из кортежа {A,B,C,D} в бинарную строку. --spec ip_to_binary({byte(), byte(), byte(), byte()}) -> binary(). -ip_to_binary({A, B, C, D}) -> - list_to_binary(io_lib:format("~B.~B.~B.~B", [A, B, C, D])); +%% @doc Преобразует IP-адрес в бинарную строку. +-spec ip_to_binary(term()) -> binary(). +ip_to_binary(IP) when is_tuple(IP) -> + case tuple_size(IP) of + 4 -> list_to_binary(inet:ntoa(IP)); + 8 -> list_to_binary(inet:ntoa(IP)); + _ -> <<"unknown">> + end; +ip_to_binary(IP) when is_binary(IP) -> + trim_ip_binary(IP); +ip_to_binary(IP) when is_list(IP) -> + list_to_binary(string:trim(IP)); ip_to_binary(_) -> <<"unknown">>. @@ -97,8 +108,31 @@ log_admin_action(AdminId, Action, EntityType, EntityId, Reason, Req) -> {ok, Admin} -> Email = Admin#admin.email, Role = atom_to_binary(Admin#admin.role, utf8), - Ip = ip_to_binary(cowboy_req:peer(Req)), + Ip = client_ip(Req), core_admin_audit:log(AdminId, Email, Role, Action, EntityType, EntityId, Ip, Reason), ok; _ -> ok - end. \ No newline at end of file + end. + +forwarded_client_ip(Req) -> + pick_forwarded_ip(Req, [<<"x-forwarded-for">>, <<"x-real-ip">>]). + + +pick_forwarded_ip(_Req, []) -> + undefined; +pick_forwarded_ip(Req, [Header | Rest]) -> + case cowboy_req:header(Header, Req) of + undefined -> pick_forwarded_ip(Req, Rest); + Value -> first_forwarded_ip(Value) + end. + + +first_forwarded_ip(Value) -> + case binary:split(Value, <<",">>) of + [Head | _] -> trim_ip_binary(Head); + [] -> undefined + end. + + +trim_ip_binary(Ip) -> + list_to_binary(string:trim(binary_to_list(Ip))). \ No newline at end of file diff --git a/src/logic/logic_stats.erl b/src/logic/logic_stats.erl index 26bc047..9fc47ac 100644 --- a/src/logic/logic_stats.erl +++ b/src/logic/logic_stats.erl @@ -148,9 +148,60 @@ trunc_hours(Hours) -> round(Hours * 100) / 100. %%%------------------------------------------------------------------- -%%% @doc Сбор активности администраторов (заглушка). +%%% @doc Сбор активности администраторов из записей аудита. %%% @end %%%------------------------------------------------------------------- -spec collect_admin_activity() -> [map()]. collect_admin_activity() -> - []. \ No newline at end of file + Audits = core_admin_audit:list(), + Grouped = group_audit_by_admin(Audits), + Activity = maps:fold(fun build_admin_activity_entry/3, [], Grouped), + lists:sort(fun sort_by_actions_desc/2, Activity). + +group_audit_by_admin(Audits) -> + lists:foldl(fun aggregate_audit_entry/2, #{}, Audits). + +aggregate_audit_entry(#admin_audit{admin_id = AdminId, action = Action, timestamp = Ts}, Acc) -> + Entry = maps:get(AdminId, Acc, #{actions => 0, last_login => undefined}), + Actions = maps:get(actions, Entry) + 1, + LastLogin = case Action of + <<"login">> -> latest_datetime(maps:get(last_login, Entry), Ts); + _ -> maps:get(last_login, Entry) + end, + maps:put(AdminId, #{actions => Actions, last_login => LastLogin}, Acc). + +build_admin_activity_entry(AdminId, #{actions := Actions, last_login := LastLogin}, Acc) -> + case core_admin:get_by_id(AdminId) of + {ok, Admin} -> + [#{ + <<"admin_id">> => AdminId, + <<"email">> => Admin#admin.email, + <<"nickname">> => Admin#admin.nickname, + <<"role">> => atom_to_binary(Admin#admin.role, utf8), + <<"actions">> => Actions, + <<"last_login">> => format_datetime(maybe_last_login(LastLogin, Admin#admin.last_login)) + } | Acc]; + _ -> + Acc + end. + +maybe_last_login(undefined, AdminLastLogin) -> AdminLastLogin; +maybe_last_login(LoginFromAudit, _) -> LoginFromAudit. + +latest_datetime(undefined, Ts) -> Ts; +latest_datetime(Existing, Ts) -> + case calendar:datetime_to_gregorian_seconds(Ts) >= + calendar:datetime_to_gregorian_seconds(Existing) of + true -> Ts; + false -> Existing + end. + +sort_by_actions_desc(#{<<"actions">> := A}, #{<<"actions">> := B}) -> + A >= B. + +format_datetime(undefined) -> <<"">>; +format_datetime(Bin) when is_binary(Bin) -> Bin; +format_datetime({{Y, M, D}, {H, Mi, S}}) -> + iolist_to_binary( + io_lib:format("~4..0B-~2..0B-~2..0BT~2..0B:~2..0B:~2..0BZ", + [Y, M, D, H, Mi, S])). \ No newline at end of file diff --git a/src/logic/logic_ticket.erl b/src/logic/logic_ticket.erl index f73fe44..93f2551 100644 --- a/src/logic/logic_ticket.erl +++ b/src/logic/logic_ticket.erl @@ -26,7 +26,10 @@ report_error(ErrorMessage, Stacktrace, Context) -> mnesia:dirty_write(Updated), {ok, Updated}; [] -> + ReporterId = maps:get(<<"reporter_id">>, Context, + maps:get(reporter_id, Context, <<"anonymous">>)), Data = #{ + <<"reporter_id">> => ReporterId, <<"error_message">> => ErrorMessage, <<"stacktrace">> => Stacktrace, <<"context">> => list_to_binary(io_lib:format("~p", [Context])) @@ -75,6 +78,8 @@ list_tickets_by_status(AdminId, Status) -> end. %% Обновить статус тикета +update_status(AdminId, TicketId, closed) -> + close_ticket(AdminId, TicketId); update_status(AdminId, TicketId, Status) -> case admin_utils:is_admin(AdminId) of true -> core_ticket:update_ticket(TicketId, #{<<"status">> => Status}); @@ -94,7 +99,8 @@ resolve_ticket(AdminId, TicketId, ResolutionNote) -> true -> core_ticket:update_ticket(TicketId, #{ <<"status">> => <<"resolved">>, - <<"resolution_note">> => ResolutionNote + <<"resolution_note">> => ResolutionNote, + <<"closed_at">> => calendar:universal_time() }); false -> {error, access_denied} end. @@ -102,7 +108,7 @@ resolve_ticket(AdminId, TicketId, ResolutionNote) -> %% Закрыть тикет close_ticket(AdminId, TicketId) -> case admin_utils:is_admin(AdminId) of - true -> core_ticket:update_ticket(TicketId, #{<<"status">> => <<"closed">>}); + true -> core_ticket:close(TicketId, AdminId); false -> {error, access_denied} end. diff --git a/test/api/admins/admin_audit_tests.erl b/test/api/admins/admin_audit_tests.erl index 7b1484f..b45a725 100644 --- a/test/api/admins/admin_audit_tests.erl +++ b/test/api/admins/admin_audit_tests.erl @@ -42,6 +42,7 @@ test() -> % ── Основные тесты ── test_list_audit(SuperToken), test_list_admin_forbidden(AdminToken), + test_login_audit_ip(SuperToken), % ── Фильтрация ── test_filter_audit_by_admin_id(SuperToken, AdminId), @@ -85,6 +86,23 @@ test_list_admin_forbidden(Token) -> ?assertMatch({ok, 403, _, _}, Resp), ct:pal(" OK: got 403"). +%% @doc Логин с X-Forwarded-For записывает IP клиента в аудит. +test_login_audit_ip(Token) -> + ct:pal(" TEST: Login audit stores X-Forwarded-For IP"), + Email = api_test_runner:admin_super_email(), + Password = api_test_runner:admin_super_password(), + Body = jsx:encode(#{<<"email">> => Email, <<"password">> => Password}), + {ok, 200, _, _} = api_test_runner:admin_request( + post, <<"/v1/admin/login">>, <<>>, Body, + [{"X-Forwarded-For", "203.0.113.99"}] + ), + Records = api_test_runner:admin_get(<<"/v1/admin/audit?action=login">>, Token), + Forwarded = [R || R <- Records, maps:get(<<"ip">>, R) =:= <<"203.0.113.99">>], + ?assert(length(Forwarded) >= 1), + Login = hd(Forwarded), + ?assertEqual(<<"203.0.113.99">>, maps:get(<<"ip">>, Login)), + ct:pal(" OK: ip=~s", [maps:get(<<"ip">>, Login)]). + %% ── Фильтрация ── %% @doc Фильтрация по admin_id. diff --git a/test/api/admins/admin_stats_tests.erl b/test/api/admins/admin_stats_tests.erl index 998580b..f3194c2 100644 --- a/test/api/admins/admin_stats_tests.erl +++ b/test/api/admins/admin_stats_tests.erl @@ -70,6 +70,8 @@ test() -> test_stats_with_dates(SuperToken), test_node_metrics_history(SuperToken), + test_avg_ticket_resolution_after_close(SuperToken, UserToken), + test_admin_activity(SuperToken), % Детальная статистика test_user_stats(SuperToken), @@ -116,6 +118,7 @@ test_stats_for_role(RoleName, Token, Strictness) -> ?assert(maps:is_key(<<"tickets_by_day">>, Stats)), ?assert(maps:is_key(<<"subscriptions_by_day">>, Stats)), ?assert(maps:is_key(<<"pending_users_by_day">>, Stats)), + ?assert(maps:is_key(<<"avg_ticket_resolution_h">>, Stats)), % Проверка, что они действительно списки ?assert(is_list(maps:get(<<"registrations_by_day">>, Stats))), ?assert(is_list(maps:get(<<"events_by_day">>, Stats))), @@ -244,4 +247,35 @@ test_node_metrics_history(Token) -> ?assert(maps:is_key(<<"memory_total">>, First)), ?assert(maps:is_key(<<"memory_available">>, First)), ?assert(maps:is_key(<<"cpu_utilization">>, First)), - ct:pal(" OK: ~p metrics received", [length(Metrics)]). \ No newline at end of file + ct:pal(" OK: ~p metrics received", [length(Metrics)]). + +test_avg_ticket_resolution_after_close(Token, UserToken) -> + ct:pal(" TEST: avg_ticket_resolution_h after closing ticket"), + Ticket = api_test_runner:client_post(<<"/v1/tickets">>, UserToken, + #{<<"error_message">> => <<"Resolution metric">>, <<"stacktrace">> => <<"trace">>}), + #{<<"id">> := TicketId} = Ticket, + Path = <<"/v1/admin/tickets/", TicketId/binary>>, + timer:sleep(20000), + Closed = api_test_runner:admin_put(Path, Token, #{<<"status">> => <<"closed">>}), + ?assertEqual(<<"closed">>, maps:get(<<"status">>, Closed)), + ?assertNotEqual(<<"1970-01-01T00:00:00Z">>, maps:get(<<"closed_at">>, Closed)), + Stats = api_test_runner:admin_get(<<"/v1/admin/stats">>, Token), + Avg = maps:get(<<"avg_ticket_resolution_h">>, Stats), + ?assert(is_number(Avg)), + ?assert(Avg > 0.0), + ct:pal(" OK: avg_ticket_resolution_h=~p", [Avg]). + +test_admin_activity(Token) -> + ct:pal(" TEST: admin_activity in dashboard stats"), + Stats = api_test_runner:admin_get(<<"/v1/admin/stats">>, Token), + Activity = maps:get(<<"admin_activity">>, Stats), + ?assert(is_list(Activity)), + ?assert(length(Activity) >= 1), + First = hd(Activity), + ?assert(maps:is_key(<<"admin_id">>, First)), + ?assert(maps:is_key(<<"email">>, First)), + ?assert(maps:is_key(<<"role">>, First)), + ?assert(maps:is_key(<<"actions">>, First)), + ?assert(maps:is_key(<<"last_login">>, First)), + ?assert(maps:get(<<"actions">>, First) >= 1), + ct:pal(" OK: ~p admin activity rows", [length(Activity)]). \ No newline at end of file diff --git a/test/api/api_test_runner.erl b/test/api/api_test_runner.erl index 62dd2ea..9bfa8b0 100644 --- a/test/api/api_test_runner.erl +++ b/test/api/api_test_runner.erl @@ -23,10 +23,13 @@ register_and_login/2, create_calendar/2, create_event/3 - , get_admin_refresh_token/0]). + , get_admin_refresh_token/0 + , admin_super_email/0 + , admin_super_password/0]). -export([ admin_request/3, admin_request/4, + admin_request/5, client_request/3, client_request/4 ]). @@ -175,7 +178,12 @@ admin_request(Method, Path, Token) -> -spec admin_request(atom(), binary(), binary(), binary()) -> {ok, integer(), proplists:proplist(), binary()} | {error, term()}. admin_request(Method, Path, Token, Body) -> - request(get_admin_url(), Method, Path, Token, Body, "ADMIN"). + admin_request(Method, Path, Token, Body, []). + +-spec admin_request(atom(), binary(), binary(), binary(), [{string(), string()}]) -> + {ok, integer(), proplists:proplist(), binary()} | {error, term()}. +admin_request(Method, Path, Token, Body, ExtraHeaders) -> + request(get_admin_url(), Method, Path, Token, Body, "ADMIN", ExtraHeaders). -spec client_request(atom(), binary(), binary()) -> {ok, integer(), proplists:proplist(), binary()} | {error, term()}. client_request(Method, Path, Token) -> @@ -183,7 +191,7 @@ client_request(Method, Path, Token) -> -spec client_request(atom(), binary(), binary(), binary()) -> {ok, integer(), proplists:proplist(), binary()} | {error, term()}. client_request(Method, Path, Token, Body) -> - request(get_base_url(), Method, Path, Token, Body, "CLIENT"). + request(get_base_url(), Method, Path, Token, Body, "CLIENT", []). %%%=================================================================== %%% Внутренняя реализация HTTP-запроса @@ -191,12 +199,17 @@ client_request(Method, Path, Token, Body) -> -spec request(string(), atom(), binary(), binary(), binary(), string()) -> {ok, integer(), proplists:proplist(), binary()} | {error, term()}. request(BaseUrl, Method, Path, Token, Body, Prefix) -> + request(BaseUrl, Method, Path, Token, Body, Prefix, []). + +-spec request(string(), atom(), binary(), binary(), binary(), string(), [{string(), string()}]) -> + {ok, integer(), proplists:proplist(), binary()} | {error, term()}. +request(BaseUrl, Method, Path, Token, Body, Prefix, ExtraHeaders) -> URL = BaseUrl ++ binary_to_list(Path), - Headers0 = [], - Headers = case Token of - <<>> -> Headers0; % пустой токен – не добавляем Authorization - _ -> [{"Authorization", "Bearer " ++ binary_to_list(Token)}] - end, + AuthHeaders = case Token of + <<>> -> []; + _ -> [{"Authorization", "Bearer " ++ binary_to_list(Token)}] + end, + Headers = ExtraHeaders ++ AuthHeaders, ct:pal("~s REQUEST: ~s ~s", [Prefix, Method, URL]), RequestArg = case Method of get -> {URL, Headers}; diff --git a/test/unit/admin_utils_tests.erl b/test/unit/admin_utils_tests.erl new file mode 100644 index 0000000..ef12322 --- /dev/null +++ b/test/unit/admin_utils_tests.erl @@ -0,0 +1,37 @@ +-module(admin_utils_tests). +-include_lib("eunit/include/eunit.hrl"). + +client_ip_test_() -> + {setup, + fun() -> ok = meck:new(cowboy_req, [non_strict]) end, + fun(_) -> meck:unload(cowboy_req) end, + [ + {"Uses X-Forwarded-For when present", fun test_forwarded_for/0}, + {"Falls back to X-Real-IP", fun test_real_ip/0}, + {"Falls back to peer address", fun test_peer_fallback/0}, + {"ip_to_binary handles IPv4 tuple", fun test_ipv4_tuple/0} + ]}. + +test_forwarded_for() -> + ok = meck:expect(cowboy_req, header, fun + (<<"x-forwarded-for">>, _) -> <<"203.0.113.10, 10.0.0.1">>; + (_, _) -> undefined + end), + ok = meck:expect(cowboy_req, peer, fun(_) -> {10, 0, 0, 2} end), + ?assertEqual(<<"203.0.113.10">>, admin_utils:client_ip(req)). + +test_real_ip() -> + ok = meck:expect(cowboy_req, header, fun + (<<"x-real-ip">>, _) -> <<"198.51.100.5">>; + (_, _) -> undefined + end), + ok = meck:expect(cowboy_req, peer, fun(_) -> {10, 0, 0, 2} end), + ?assertEqual(<<"198.51.100.5">>, admin_utils:client_ip(req)). + +test_peer_fallback() -> + ok = meck:expect(cowboy_req, header, fun(_, _) -> undefined end), + ok = meck:expect(cowboy_req, peer, fun(_) -> {127, 0, 0, 1} end), + ?assertEqual(<<"127.0.0.1">>, admin_utils:client_ip(req)). + +test_ipv4_tuple() -> + ?assertEqual(<<"192.168.1.1">>, admin_utils:ip_to_binary({192, 168, 1, 1})). diff --git a/test/unit/logic_ticket_tests.erl b/test/unit/logic_ticket_tests.erl index fdd6d4f..2f596bc 100644 --- a/test/unit/logic_ticket_tests.erl +++ b/test/unit/logic_ticket_tests.erl @@ -15,20 +15,24 @@ setup() -> {attributes, record_info(fields, user)}, {ram_copies, [node()]}]), {atomic, ok} = mnesia:create_table(ticket, [ {attributes, record_info(fields, ticket)}, {ram_copies, [node()]}]), - % Создаём админа и обычного пользователя - Admin = #user{id = <<"admin1">>, email = <<"a@a.a">>, password_hash = <<"h">>, - role = admin, status = active, - created_at = calendar:universal_time(), updated_at = calendar:universal_time()}, + {atomic, ok} = mnesia:create_table(admin, [ + {attributes, record_info(fields, admin)}, {ram_copies, [node()]}]), + Now = calendar:universal_time(), + Admin = #admin{id = <<"admin1">>, email = <<"a@a.a">>, password_hash = <<"h">>, + role = admin, status = active, nickname = <<>>, avatar_url = default, + timezone = <<"UTC">>, language = <<"ru">>, phone = <<>>, + preferences = #{}, last_login = Now, created_at = Now, updated_at = Now}, User = #user{id = <<"user1">>, email = <<"u@u.u">>, password_hash = <<"h">>, role = user, status = active, - created_at = calendar:universal_time(), updated_at = calendar:universal_time()}, + created_at = Now, updated_at = Now}, mnesia:dirty_write(Admin), mnesia:dirty_write(User), ok. cleanup(_) -> - mnesia:delete_table(user), - mnesia:delete_table(ticket), + catch mnesia:delete_table(admin), + catch mnesia:delete_table(user), + catch mnesia:delete_table(ticket), mnesia:stop(). %% ---------------------------------------------------------------- @@ -49,7 +53,7 @@ logic_ticket_test_() -> %% --- Вспомогательная функция для создания тикета --- report(ErrorMsg) -> - logic_ticket:report_error(ErrorMsg, <<"stack">>, #{}). + logic_ticket:report_error(ErrorMsg, <<"stack">>, #{<<"reporter_id">> => <<"user1">>}). %% --- Тесты --- @@ -87,13 +91,14 @@ test_assign_ticket() -> test_resolve_ticket() -> {ok, Ticket} = report(<<"E4">>), {ok, Updated} = logic_ticket:resolve_ticket(<<"admin1">>, Ticket#ticket.id, <<"Fixed">>), - ?assertEqual(closed, Updated#ticket.status), + ?assertEqual(resolved, Updated#ticket.status), ?assertEqual(<<"Fixed">>, Updated#ticket.resolution_note). test_close_ticket() -> {ok, Ticket} = report(<<"E5">>), {ok, Updated} = logic_ticket:close_ticket(<<"admin1">>, Ticket#ticket.id), - ?assertEqual(closed, Updated#ticket.status). + ?assertEqual(closed, Updated#ticket.status), + ?assertNotEqual({{1970, 1, 1}, {0, 0, 0}}, Updated#ticket.closed_at). test_get_statistics() -> {ok, _} = report(<<"E6">>),