feat: upsert stats counters, daily buckets and ETS tops for admin metrics. Refs EventHub/EventHubBack#42 #43 #44 #45 #46
CI / test (push) Failing after 25m40s
CI / deploy-ift (push) Has been skipped
CI / e2e-ift (push) Has been skipped
CI / deploy-stage (push) Has been skipped
CI / e2e-stage (push) Has been skipped

This commit is contained in:
2026-07-17 23:10:00 +03:00
parent f2445db66c
commit dd754e28cc
20 changed files with 1885 additions and 408 deletions
@@ -0,0 +1,73 @@
-module(core_admin_stats_index_tests).
-include_lib("eunit/include/eunit.hrl").
-include("records.hrl").
-define(TABLES, [report, ticket, user]).
-define(ADMIN_A, <<"adm_a">>).
-define(ADMIN_B, <<"adm_b">>).
setup() ->
eh_test_support:start_mnesia(),
eh_test_support:ensure_tables(?TABLES),
ok.
cleanup(_) ->
eh_test_support:delete_tables(?TABLES),
eh_test_support:stop_mnesia(),
ok.
admin_stats_index_test_() ->
{foreach, fun setup/0, fun cleanup/1, [
{"reports resolved_by index filters by admin", fun test_reports_by_admin/0},
{"tickets assigned_to index filters by admin", fun test_tickets_by_admin/0}
]}.
test_reports_by_admin() ->
Now = calendar:universal_time(),
ok = mnesia:dirty_write(#report{
id = <<"r1">>, reporter_id = <<"u1">>, target_type = calendar,
target_id = <<"c1">>, reason = <<"spam">>, status = reviewed,
created_at = Now, resolved_at = Now, resolved_by = ?ADMIN_A
}),
ok = mnesia:dirty_write(#report{
id = <<"r2">>, reporter_id = <<"u1">>, target_type = calendar,
target_id = <<"c2">>, reason = <<"spam">>, status = reviewed,
created_at = Now, resolved_at = Now, resolved_by = ?ADMIN_B
}),
ok = mnesia:dirty_write(#report{
id = <<"r3">>, reporter_id = <<"u1">>, target_type = event,
target_id = <<"e1">>, reason = <<"spam">>, status = dismissed,
created_at = Now, resolved_at = Now, resolved_by = ?ADMIN_A
}),
?assertEqual(1, core_report:count_reports_resolved_by_admin(?ADMIN_A, reviewed)),
?assertEqual(1, core_report:count_reports_resolved_by_admin(?ADMIN_A, dismissed)),
?assertEqual(1, core_report:count_reports_resolved_by_admin(?ADMIN_B, reviewed)),
?assertEqual(0, core_report:count_reports_resolved_by_admin(?ADMIN_B, dismissed)).
test_tickets_by_admin() ->
Now = calendar:universal_time(),
ok = mnesia:dirty_write(#ticket{
id = <<"t1">>, reporter_id = <<"u1">>, error_hash = <<"h1">>,
error_message = <<"e">>, stacktrace = <<>>, context = <<>>,
count = 1, first_seen = Now, last_seen = Now, status = open,
assigned_to = ?ADMIN_A, resolution_note = <<>>, closed_at = undefined,
source = <<"backend">>
}),
ok = mnesia:dirty_write(#ticket{
id = <<"t2">>, reporter_id = <<"u1">>, error_hash = <<"h2">>,
error_message = <<"e">>, stacktrace = <<>>, context = <<>>,
count = 1, first_seen = Now, last_seen = Now, status = resolved,
assigned_to = ?ADMIN_A, resolution_note = <<>>, closed_at = Now,
source = <<"backend">>
}),
ok = mnesia:dirty_write(#ticket{
id = <<"t3">>, reporter_id = <<"u1">>, error_hash = <<"h3">>,
error_message = <<"e">>, stacktrace = <<>>, context = <<>>,
count = 1, first_seen = Now, last_seen = Now, status = open,
assigned_to = ?ADMIN_B, resolution_note = <<>>, closed_at = undefined,
source = <<"backend">>
}),
?assertEqual(2, core_ticket:count_tickets_by_admin(?ADMIN_A, all)),
?assertEqual(1, core_ticket:count_tickets_by_admin(?ADMIN_A, open)),
?assertEqual(1, core_ticket:count_tickets_by_admin(?ADMIN_A, resolved)),
?assertEqual(1, core_ticket:count_tickets_by_admin(?ADMIN_B, all)).
+23 -2
View File
@@ -72,8 +72,27 @@ stop_mnesia() ->
ensure_tables(Tables) when is_list(Tables) ->
lists:foreach(fun ensure_table/1, Tables),
maybe_add_admin_stats_indexes(Tables),
ok.
maybe_add_admin_stats_indexes(Tables) ->
case lists:member(report, Tables) of
true -> add_index(report, resolved_by);
false -> ok
end,
case lists:member(ticket, Tables) of
true -> add_index(ticket, assigned_to);
false -> ok
end.
add_index(Table, Attr) ->
case catch mnesia:add_table_index(Table, Attr) of
{atomic, ok} -> ok;
{aborted, {already_exists, _}} -> ok;
{aborted, {already_exists, _, _}} -> ok;
_ -> ok
end.
ensure_table(Table) ->
case lists:member(Table, mnesia:system_info(tables)) of
true ->
@@ -133,8 +152,10 @@ table_opts(admin_audit) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, admin_audit)}];
table_opts(notification) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, notification)}];
table_opts(stats) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, stats)}];
table_opts(stats_counter) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, stats_counter)}];
table_opts(stats_daily) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, stats_daily)}];
table_opts(session) ->
[{ram_copies, [node()]}, {attributes, record_info(fields, session)}];
table_opts(verification) ->
+137
View File
@@ -0,0 +1,137 @@
-module(stats_collector_tests).
-include_lib("eunit/include/eunit.hrl").
-include("records.hrl").
-define(TABLES, [user, event, calendar, booking, review, report, ticket,
subscription, stats_counter, stats_daily]).
setup() ->
eh_test_support:start_mnesia(),
eh_test_support:ensure_tables(?TABLES),
{ok, _} = stats_collector:start_link(),
ok = stats_collector:subscribe(),
ok.
cleanup(_) ->
case whereis(stats_collector) of
undefined -> ok;
Pid ->
unlink(Pid),
exit(Pid, kill),
wait_dead(Pid, 50)
end,
eh_test_support:delete_tables(?TABLES),
eh_test_support:stop_mnesia(),
ok.
wait_dead(_Pid, 0) -> ok;
wait_dead(Pid, N) ->
case is_process_alive(Pid) of
true ->
timer:sleep(20),
wait_dead(Pid, N - 1);
false -> ok
end.
stats_collector_test_() ->
{foreach, fun setup/0, fun cleanup/1, [
{"backfill from existing rows", fun test_backfill/0},
{"live write updates dims and daily", fun test_live_write/0},
{"status transition moves counters", fun test_status_move/0},
{"core_stats reads via collector", fun test_core_stats_api/0},
{"ticket avg resolution from counters", fun test_ticket_avg_resolution/0},
{"report avg resolution from counters", fun test_report_avg_resolution/0}
]}.
test_backfill() ->
Now = calendar:universal_time(),
ok = mnesia:dirty_write(eh_test_support:make_user(#{
id => <<"u1">>, status => pending, role => user, created_at => Now
})),
ok = mnesia:dirty_write(eh_test_support:make_user(#{
id => <<"u2">>, status => active, role => user, created_at => Now
})),
ok = stats_collector:rebuild(),
?assertEqual(1, core_stats:with_counter({user_by_status, pending}, fun() -> -1 end)),
?assertEqual(1, core_stats:with_counter({user_by_status, active}, fun() -> -1 end)),
{Date, _} = Now,
From = {Date, {0, 0, 0}},
To = {Date, {23, 59, 59}},
Daily = core_stats:with_daily(users_created, From, To, fun() -> [] end),
?assertEqual([{Date, 2}], Daily).
test_live_write() ->
Now = calendar:universal_time(),
ok = mnesia:dirty_write(eh_test_support:make_user(#{
id => <<"live1">>, status => active, created_at => Now
})),
wait_counter({user_by_status, active}, 1, 20),
?assertEqual(1, core_stats:with_counter({user_by_status, active}, fun() -> 0 end)).
test_status_move() ->
Now = calendar:universal_time(),
User = eh_test_support:make_user(#{
id => <<"move1">>, status => pending, created_at => Now
}),
ok = mnesia:dirty_write(User),
wait_counter({user_by_status, pending}, 1, 20),
ok = mnesia:dirty_write(User#user{status = active}),
wait_counter({user_by_status, pending}, 0, 20),
wait_counter({user_by_status, active}, 1, 20),
?assertEqual(0, core_stats:with_counter({user_by_status, pending}, fun() -> -1 end)),
?assertEqual(1, core_stats:with_counter({user_by_status, active}, fun() -> -1 end)).
test_core_stats_api() ->
?assert(core_stats:available()),
Now = calendar:universal_time(),
ok = mnesia:dirty_write(#report{
id = <<"r1">>,
reporter_id = <<"u1">>,
target_type = calendar,
target_id = <<"c1">>,
reason = <<"spam">>,
status = pending,
created_at = Now,
resolved_at = undefined,
resolved_by = <<>>
}),
wait_counter({report_by_status, pending}, 1, 20),
{ok, Dim} = core_stats:get_dim(report, status),
?assertEqual(1, proplists:get_value(pending, Dim, 0)).
test_ticket_avg_resolution() ->
First = {{2026, 1, 1}, {0, 0, 0}},
Closed = {{2026, 1, 1}, {2, 0, 0}}, %% 2 hours
ok = mnesia:dirty_write(#ticket{
id = <<"t_avg">>, reporter_id = <<"u1">>, error_hash = <<"h">>,
error_message = <<"e">>, stacktrace = <<>>, context = <<>>,
count = 1, first_seen = First, last_seen = Closed, status = closed,
assigned_to = <<>>, resolution_note = <<>>, closed_at = Closed,
source = <<"backend">>
}),
wait_counter(ticket_resolved_count, 1, 20),
Avg = core_ticket:avg_resolution_time(),
?assertEqual(2.0, Avg).
test_report_avg_resolution() ->
Created = {{2026, 1, 1}, {0, 0, 0}},
Resolved = {{2026, 1, 1}, {3, 0, 0}}, %% 3 hours
ok = mnesia:dirty_write(#report{
id = <<"r_avg">>, reporter_id = <<"u1">>, target_type = calendar,
target_id = <<"c1">>, reason = <<"spam">>, status = reviewed,
created_at = Created, resolved_at = Resolved, resolved_by = <<"adm">>
}),
wait_counter({report_resolved_count, reviewed}, 1, 20),
Avg = core_report:avg_resolution_time(reviewed),
?assertEqual(3.0, Avg).
wait_counter(Key, Expected, 0) ->
Actual = core_stats:with_counter(Key, fun() -> -999 end),
?assertEqual(Expected, Actual);
wait_counter(Key, Expected, N) ->
case core_stats:with_counter(Key, fun() -> -999 end) of
Expected -> ok;
_ ->
timer:sleep(25),
wait_counter(Key, Expected, N - 1)
end.
+198
View File
@@ -0,0 +1,198 @@
-module(stats_tops_tests).
-include_lib("eunit/include/eunit.hrl").
-include("records.hrl").
-define(TABLES, [event, calendar, review, report, stats_counter, stats_daily]).
setup() ->
eh_test_support:start_mnesia(),
eh_test_support:ensure_tables(?TABLES),
{ok, _} = stats_collector:start_link(),
ok = stats_collector:subscribe(),
ok.
cleanup(_) ->
case whereis(stats_collector) of
undefined -> ok;
Pid ->
unlink(Pid),
exit(Pid, kill),
wait_dead(Pid, 50)
end,
eh_test_support:delete_tables(?TABLES),
eh_test_support:stop_mnesia(),
ok.
wait_dead(_Pid, 0) -> ok;
wait_dead(Pid, N) ->
case is_process_alive(Pid) of
true -> timer:sleep(20), wait_dead(Pid, N - 1);
false -> ok
end.
stats_tops_test_() ->
{foreach, fun setup/0, fun cleanup/1, [
{"top events by rating order", fun test_event_rating_top/0},
{"top calendars by rating and reviews", fun test_calendar_tops/0},
{"rating update reorders top", fun test_rating_update/0},
{"review target tops all/pos/neg", fun test_review_target_tops/0},
{"report target tops", fun test_report_target_tops/0},
{"calendar pos/neg from reviews", fun test_calendar_pos_neg/0}
]}.
test_event_rating_top() ->
Now = calendar:universal_time(),
ok = mnesia:dirty_write(make_event(<<"e_low">>, 2.0, Now)),
ok = mnesia:dirty_write(make_event(<<"e_high">>, 5.0, Now)),
ok = mnesia:dirty_write(make_event(<<"e_mid">>, 3.5, Now)),
wait_top_event(<<"e_high">>, 30),
Top = core_event:get_top_events_by_rating(2),
Ids = [E#event.id || E <- Top],
?assertEqual([<<"e_high">>, <<"e_mid">>], Ids).
test_calendar_tops() ->
Now = calendar:universal_time(),
ok = mnesia:dirty_write(make_calendar(<<"c1">>, 4.0, 10, Now)),
ok = mnesia:dirty_write(make_calendar(<<"c2">>, 5.0, 2, Now)),
ok = mnesia:dirty_write(make_calendar(<<"c3">>, 3.0, 20, Now)),
wait_top_cal_rating(<<"c2">>, 30),
ByRating = [C#calendar.id || C <- core_calendar:get_top_calendars_by_rating(2)],
?assertEqual([<<"c2">>, <<"c1">>], ByRating),
ByReviews = [C#calendar.id || C <- core_calendar:get_top_calendars_by_reviews(2)],
?assertEqual([<<"c3">>, <<"c1">>], ByReviews).
test_rating_update() ->
Now = calendar:universal_time(),
E1 = make_event(<<"eu1">>, 4.0, Now),
E2 = make_event(<<"eu2">>, 3.0, Now),
ok = mnesia:dirty_write(E1),
ok = mnesia:dirty_write(E2),
wait_top_event(<<"eu1">>, 30),
ok = mnesia:dirty_write(E2#event{rating_avg = 4.5}),
wait_top_event(<<"eu2">>, 30),
Top = core_event:get_top_events_by_rating(1),
?assertEqual([<<"eu2">>], [E#event.id || E <- Top]).
test_review_target_tops() ->
Now = calendar:universal_time(),
ok = mnesia:dirty_write(make_review(<<"r1">>, calendar, <<"cA">>, 5, Now)),
ok = mnesia:dirty_write(make_review(<<"r2">>, calendar, <<"cA">>, 5, Now)),
ok = mnesia:dirty_write(make_review(<<"r3">>, event, <<"eA">>, 1, Now)),
ok = mnesia:dirty_write(make_review(<<"r4">>, calendar, <<"cB">>, 2, Now)),
wait_review_top({calendar, <<"cA">>}, 30),
All = core_review:get_top_targets_by_reviews(3),
?assertMatch([{calendar, <<"cA">>, 2} | _], All),
AllKeys = [{T, I} || {T, I, _} <- All],
?assert(lists:member({event, <<"eA">>}, AllKeys)),
?assert(lists:member({calendar, <<"cB">>}, AllKeys)),
Pos = core_review:get_top_targets_by_positive_reviews(1),
?assertEqual([{calendar, <<"cA">>, 2}], Pos),
Neg = core_review:get_top_targets_by_negative_reviews(2),
NegIds = [{T, I} || {T, I, _} <- Neg],
?assert(lists:member({event, <<"eA">>}, NegIds)),
?assert(lists:member({calendar, <<"cB">>}, NegIds)).
test_report_target_tops() ->
Now = calendar:universal_time(),
ok = mnesia:dirty_write(make_report(<<"p1">>, calendar, <<"cX">>, Now)),
ok = mnesia:dirty_write(make_report(<<"p2">>, calendar, <<"cX">>, Now)),
ok = mnesia:dirty_write(make_report(<<"p3">>, event, <<"eX">>, Now)),
wait_report_top({calendar, <<"cX">>}, 30),
Top = core_report:get_top_targets_by_reports(2),
?assertEqual([{calendar, <<"cX">>, 2}, {event, <<"eX">>, 1}], Top).
test_calendar_pos_neg() ->
Now = calendar:universal_time(),
ok = mnesia:dirty_write(make_calendar(<<"cp1">>, 0.0, 0, Now)),
ok = mnesia:dirty_write(make_calendar(<<"cp2">>, 0.0, 0, Now)),
ok = mnesia:dirty_write(make_review(<<"rp1">>, calendar, <<"cp1">>, 5, Now)),
ok = mnesia:dirty_write(make_review(<<"rp2">>, calendar, <<"cp1">>, 4, Now)),
ok = mnesia:dirty_write(make_review(<<"rn1">>, calendar, <<"cp2">>, 1, Now)),
wait_cal_pos(<<"cp1">>, 30),
Pos = [C#calendar.id || C <- core_calendar:get_top_calendars_by_positive_reviews(1)],
?assertEqual([<<"cp1">>], Pos),
Neg = [C#calendar.id || C <- core_calendar:get_top_calendars_by_negative_reviews(1)],
?assertEqual([<<"cp2">>], Neg).
make_review(Id, Type, TargetId, Rating, Now) ->
#review{
id = Id, user_id = <<"u1">>, target_type = Type, target_id = TargetId,
rating = Rating, comment = <<>>, status = visible, reason = <<>>,
likes = 0, dislikes = 0, created_at = Now, updated_at = Now
}.
make_report(Id, Type, TargetId, Now) ->
#report{
id = Id, reporter_id = <<"u1">>, target_type = Type, target_id = TargetId,
reason = <<"spam">>, status = pending, created_at = Now,
resolved_at = undefined, resolved_by = <<>>
}.
make_event(Id, Avg, Now) ->
#event{
id = Id, calendar_id = <<"cal">>, title = Id, description = <<>>,
event_type = single, start_time = Now, duration = 60,
recurrence_rule = <<>>, master_id = <<>>, is_instance = false,
specialist_id = <<>>, location = undefined, tags = [],
capacity = 10, online_link = <<>>, status = active, reason = <<>>,
rating_avg = Avg, rating_count = 1, attachments = [], edit_history = [],
created_at = Now, updated_at = Now
}.
make_calendar(Id, Avg, Count, Now) ->
#calendar{
id = Id, owner_id = <<"u1">>, title = Id, description = <<>>,
short_name = Id, category = <<>>, color = <<>>, image_url = <<>>,
settings = #{}, tags = [], type = personal, confirmation = auto,
rating_avg = Avg, rating_count = Count, status = active, reason = <<>>,
created_at = Now, updated_at = Now
}.
wait_top_event(ExpectedId, 0) ->
Top = core_event:get_top_events_by_rating(1),
?assertEqual([ExpectedId], [E#event.id || E <- Top]);
wait_top_event(ExpectedId, N) ->
case core_event:get_top_events_by_rating(1) of
[#event{id = ExpectedId}] -> ok;
_ -> timer:sleep(25), wait_top_event(ExpectedId, N - 1)
end.
wait_top_cal_rating(ExpectedId, 0) ->
Top = core_calendar:get_top_calendars_by_rating(1),
?assertEqual([ExpectedId], [C#calendar.id || C <- Top]);
wait_top_cal_rating(ExpectedId, N) ->
case core_calendar:get_top_calendars_by_rating(1) of
[#calendar{id = ExpectedId}] -> ok;
_ -> timer:sleep(25), wait_top_cal_rating(ExpectedId, N - 1)
end.
wait_review_top(Expected, 0) ->
case core_review:get_top_targets_by_reviews(1) of
[{T, I, _} | _] -> ?assertEqual(Expected, {T, I});
Other -> ?assertEqual(Expected, Other)
end;
wait_review_top(Expected = {T, I}, N) ->
case core_review:get_top_targets_by_reviews(1) of
[{T, I, _} | _] -> ok;
_ -> timer:sleep(25), wait_review_top(Expected, N - 1)
end.
wait_report_top(Expected, 0) ->
case core_report:get_top_targets_by_reports(1) of
[{T, I, _} | _] -> ?assertEqual(Expected, {T, I});
Other -> ?assertEqual(Expected, Other)
end;
wait_report_top(Expected = {T, I}, N) ->
case core_report:get_top_targets_by_reports(1) of
[{T, I, _} | _] -> ok;
_ -> timer:sleep(25), wait_report_top(Expected, N - 1)
end.
wait_cal_pos(ExpectedId, 0) ->
Top = core_calendar:get_top_calendars_by_positive_reviews(1),
?assertEqual([ExpectedId], [C#calendar.id || C <- Top]);
wait_cal_pos(ExpectedId, N) ->
case core_calendar:get_top_calendars_by_positive_reviews(1) of
[#calendar{id = ExpectedId}] -> ok;
_ -> timer:sleep(25), wait_cal_pos(ExpectedId, N - 1)
end.