f4d89a262c
Канон PRODUCT-AI (Spec#22): пилотные метрики L1-подсказок для расчёта
голосового гейта L2 (>=20% активных пользователей с >=2 подтверждённых
ghost-бронирований в месяц). Локальные счётчики в Mnesia, без LLM.
- таблица ai_hint_metric (миграция 20260820180000): счётчики
{user, день UTC, pattern, kind}; ленивый prune при >500 строк на
пользователя, retention 92 дня
- POST /v1/ai/hint-metrics (Bearer): батч <=20 событий shown/confirm/dismiss,
202 {accepted:N}; ETS rate limit 30 req/min на пользователя
- GET /v1/admin/ai-metrics/stats (админ-порт): by_kind, active_users_30d,
users_with_confirms_ge2_30d, gate_ratio_30d, top_patterns_by_confirm
- eunit logic_ai_metrics_tests (5/5); синхронизация списка миграций
в migration_engine_tests
Refs EventHub/EventHubBack#78
84 lines
2.9 KiB
Erlang
Executable File
84 lines
2.9 KiB
Erlang
Executable File
-module(migration_engine_tests).
|
|
-include_lib("eunit/include/eunit.hrl").
|
|
-include("records.hrl").
|
|
|
|
-define(LOCK_VERSION, "__migration_lock__").
|
|
%% Keep in sync with migration_engine:?ALL_MIGRATIONS (atom → string version).
|
|
-define(ALL_VERSIONS, [
|
|
"20260501120000_base_schema",
|
|
"20260504150000_test_migration",
|
|
"20260716230000_ticket_source_and_hash_index",
|
|
"20260717180000_stats_counters",
|
|
"20260717190000_admin_stats_indexes",
|
|
"20260719210000_review_vote",
|
|
"20260720210000_calendar_follow",
|
|
"20260722150000_calendar_specialist_id",
|
|
"20260722190000_specialist_invite",
|
|
"20260730120000_recurrence_exception_bag",
|
|
"20260814193000_waitlist_entry",
|
|
"20260815200000_push_subscription",
|
|
"20260815220000_calendar_share_invite",
|
|
"20260816180000_auth_session_device",
|
|
"20260817140000_month_snapshot",
|
|
"20260820180000_ai_hint_metric"
|
|
]).
|
|
|
|
setup() ->
|
|
mnesia:stop(),
|
|
mnesia:delete_schema([node()]),
|
|
mnesia:create_schema([node()]),
|
|
mnesia:start(),
|
|
mnesia:create_table(schema_migration, [
|
|
{disc_copies, [node()]},
|
|
{attributes, record_info(fields, schema_migration)},
|
|
{type, set}
|
|
]),
|
|
{ok, _} = migration_engine:start_link(),
|
|
ok.
|
|
|
|
cleanup(_) ->
|
|
catch gen_server:stop(migration_engine),
|
|
catch mnesia:delete_table(schema_migration),
|
|
mnesia:stop(),
|
|
mnesia:delete_schema([node()]),
|
|
ok.
|
|
|
|
migration_engine_test_() ->
|
|
%% Disc Mnesia on slow FS (WSL /mnt/c) needs more than eunit's 5s default.
|
|
{timeout, 60, {foreach, fun setup/0, fun cleanup/1, [
|
|
{"ensure_applied applies pending migrations", fun test_ensure_applied/0},
|
|
{"ensure_applied is idempotent", fun test_ensure_applied_idempotent/0},
|
|
{"lock record is not treated as migration", fun test_lock_not_applied/0},
|
|
{"join node waits until migrations applied", fun test_join_wait/0}
|
|
]}}.
|
|
|
|
test_ensure_applied() ->
|
|
?assertEqual(ok, migration_engine:ensure_applied()),
|
|
Status = migration_engine:status(),
|
|
?assertEqual([], maps:get(pending, Status)),
|
|
Applied = maps:get(applied, Status),
|
|
lists:foreach(fun(V) ->
|
|
?assert(lists:member(V, Applied))
|
|
end, ?ALL_VERSIONS).
|
|
|
|
test_ensure_applied_idempotent() ->
|
|
?assertEqual(ok, migration_engine:ensure_applied()),
|
|
?assertEqual(ok, migration_engine:ensure_applied()).
|
|
|
|
test_lock_not_applied() ->
|
|
?assertEqual(ok, migration_engine:ensure_applied()),
|
|
Status = migration_engine:status(),
|
|
?assertNot(lists:member(?LOCK_VERSION, maps:get(applied, Status))).
|
|
|
|
test_join_wait() ->
|
|
Now = calendar:universal_time(),
|
|
mnesia:dirty_write(#schema_migration{version = ?LOCK_VERSION, applied_at = Now}),
|
|
spawn(fun() ->
|
|
timer:sleep(100),
|
|
lists:foreach(fun(V) ->
|
|
mnesia:dirty_write(#schema_migration{version = V, applied_at = Now})
|
|
end, ?ALL_VERSIONS),
|
|
mnesia:dirty_delete({schema_migration, ?LOCK_VERSION})
|
|
end),
|
|
?assertEqual(ok, migration_engine:ensure_applied()).
|