%%%------------------------------------------------------------------- %%% Shared fixtures for EventHub EUnit tests. %%% - In-memory Mnesia tables (ram_copies), isolated per suite %%% - Thin cowboy_req fake via meck (HTTP boundary only) %%% - Real JWT + admin seed for auth_admin without mocking domain %%%------------------------------------------------------------------- -module(eh_test_support). -include("records.hrl"). -export([ start_mnesia/0, stop_mnesia/0, ensure_tables/1, clear_tables/1, delete_tables/1, seed_admin/0, seed_admin/1, make_admin/1, make_user/1, ensure_jwt/0, admin_token/1, with_cowboy/1, update_cowboy/1, unload_cowboy/0, last_reply/0, last_status/0, last_body/0, last_json/0, call/2, days_from_now/1, future_start/0 ]). -define(JWT_SECRET, <<"test-user-secret-key-32-byt!">>). -define(ADMIN_JWT_SECRET, <<"test-admin-secret-key-32-b">>). -define(DEFAULT_ADMIN_ID, <<"adm_test_1">>). -define(REQ_KEY, eh_test_req). -define(REPLY_KEY, eh_test_reply). %%%=================================================================== %%% Mnesia %%%=================================================================== start_mnesia() -> application:load(mnesia), catch mnesia:stop(), Base = case os:getenv("TMPDIR") of false -> case os:getenv("TEMP") of false -> "/tmp"; Temp -> Temp end; TmpDir -> TmpDir end, Tmp = filename:join(Base, "eh_eunit_" ++ integer_to_list(erlang:unique_integer([positive]))), ok = filelib:ensure_dir(filename:join(Tmp, "dummy")), application:set_env(mnesia, dir, Tmp), _ = mnesia:delete_schema([node()]), ok = mnesia:create_schema([node()]), case mnesia:start() of ok -> ok; {error, {already_started, mnesia}} -> ok; Other -> error({mnesia_start_failed, Other}) end, ok. stop_mnesia() -> catch mnesia:stop(), catch mnesia:delete_schema([node()]), ok. ensure_tables(Tables) when is_list(Tables) -> lists:foreach(fun ensure_table/1, Tables), ok. ensure_table(Table) -> case lists:member(Table, mnesia:system_info(tables)) of true -> ok; false -> case mnesia:create_table(Table, table_opts(Table)) of {atomic, ok} -> ok; {aborted, {already_exists, Table}} -> ok; {aborted, Reason} -> error({create_table_failed, Table, Reason}) end end. clear_tables(Tables) when is_list(Tables) -> lists:foreach(fun(T) -> case lists:member(T, mnesia:system_info(tables)) of true -> {atomic, ok} = mnesia:clear_table(T); false -> ok end end, Tables), ok. delete_tables(Tables) when is_list(Tables) -> lists:foreach(fun(T) -> catch mnesia:delete_table(T) end, Tables), ok. table_opts(user) -> [{ram_copies, [node()]}, {attributes, record_info(fields, user)}]; table_opts(admin) -> [{ram_copies, [node()]}, {attributes, record_info(fields, admin)}]; table_opts(calendar) -> [{ram_copies, [node()]}, {attributes, record_info(fields, calendar)}]; table_opts(calendar_share) -> [{ram_copies, [node()]}, {attributes, record_info(fields, calendar_share)}]; table_opts(calendar_specialist) -> [{ram_copies, [node()]}, {attributes, record_info(fields, calendar_specialist)}]; table_opts(event) -> [{ram_copies, [node()]}, {attributes, record_info(fields, event)}]; table_opts(recurrence_exception) -> [{ram_copies, [node()]}, {attributes, record_info(fields, recurrence_exception)}]; table_opts(booking) -> [{ram_copies, [node()]}, {attributes, record_info(fields, booking)}]; table_opts(review) -> [{ram_copies, [node()]}, {attributes, record_info(fields, review)}]; table_opts(report) -> [{ram_copies, [node()]}, {attributes, record_info(fields, report)}]; table_opts(banned_word) -> [{ram_copies, [node()]}, {attributes, record_info(fields, banned_word)}]; table_opts(automod_settings) -> [{ram_copies, [node()]}, {attributes, record_info(fields, automod_settings)}]; table_opts(automod_hit) -> [{ram_copies, [node()]}, {attributes, record_info(fields, automod_hit)}]; table_opts(ticket) -> [{ram_copies, [node()]}, {attributes, record_info(fields, ticket)}]; table_opts(subscription) -> [{ram_copies, [node()]}, {attributes, record_info(fields, subscription)}]; 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(session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, session)}]; table_opts(verification) -> [{ram_copies, [node()]}, {attributes, record_info(fields, verification)}]; table_opts(admin_session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, admin_session)}]; table_opts(auth_session) -> [{ram_copies, [node()]}, {attributes, record_info(fields, auth_session)}]; table_opts(node_metric) -> [{ram_copies, [node()]}, {local_content, true}, {attributes, record_info(fields, node_metric)}]; table_opts(schema_migration) -> [{ram_copies, [node()]}, {attributes, record_info(fields, schema_migration)}]. %%%=================================================================== %%% Domain seeds %%%=================================================================== seed_admin() -> seed_admin(#{}). seed_admin(Opts) when is_map(Opts) -> Admin = make_admin(Opts), ok = mnesia:dirty_write(Admin), Admin. make_admin(Opts) when is_map(Opts) -> Now = calendar:universal_time(), #admin{ id = maps:get(id, Opts, ?DEFAULT_ADMIN_ID), email = maps:get(email, Opts, <<"admin@test.local">>), password_hash = maps:get(password_hash, Opts, <<"hash">>), role = maps:get(role, Opts, admin), status = maps:get(status, Opts, active), nickname = maps:get(nickname, Opts, <<"admin">>), avatar_url = maps:get(avatar_url, Opts, default), timezone = maps:get(timezone, Opts, <<"UTC">>), language = maps:get(language, Opts, <<"ru">>), phone = maps:get(phone, Opts, <<>>), preferences = maps:get(preferences, Opts, #{}), last_login = maps:get(last_login, Opts, Now), created_at = maps:get(created_at, Opts, Now), updated_at = maps:get(updated_at, Opts, Now) }. make_user(Opts) when is_map(Opts) -> Now = calendar:universal_time(), #user{ id = maps:get(id, Opts, <<"user_test_1">>), email = maps:get(email, Opts, <<"user@test.local">>), password_hash = maps:get(password_hash, Opts, <<"hash">>), role = maps:get(role, Opts, user), status = maps:get(status, Opts, active), reason = maps:get(reason, Opts, <<>>), nickname = maps:get(nickname, Opts, <<"user">>), avatar_url = maps:get(avatar_url, Opts, default), timezone = maps:get(timezone, Opts, <<"UTC">>), language = maps:get(language, Opts, <<"ru">>), social_links = maps:get(social_links, Opts, []), phone = maps:get(phone, Opts, <<>>), preferences = maps:get(preferences, Opts, #{}), last_login = maps:get(last_login, Opts, Now), created_at = maps:get(created_at, Opts, Now), updated_at = maps:get(updated_at, Opts, Now) }. %%%=================================================================== %%% JWT %%%=================================================================== ensure_jwt() -> 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. admin_token(AdminId) when is_binary(AdminId) -> ensure_jwt(), eventhub_auth:generate_admin_token(AdminId, <<"admin">>). %%%=================================================================== %%% Cowboy request fake (meck at HTTP boundary only) %%%=================================================================== %% Opts: %% method :: binary() %% path :: binary() %% bindings :: #{atom() => binary() | undefined} %% qs :: [{binary(), binary()}] %% body :: binary() %% has_body :: boolean() %% headers :: #{binary() => binary()} %% peer :: inet:ip_address() | {inet:ip_address(), inet:port_number()} %% auth :: binary() AdminId | {bearer, Token} | none with_cowboy(Opts) when is_map(Opts) -> unload_cowboy(), ok = meck:new(cowboy_req, [non_strict]), State = normalize_req(Opts), put(?REQ_KEY, State), erase(?REPLY_KEY), install_cowboy_expects(), req. update_cowboy(Opts) when is_map(Opts) -> Cur = case get(?REQ_KEY) of undefined -> #{}; M when is_map(M) -> M end, put(?REQ_KEY, maps:merge(Cur, normalize_req(Opts))), erase(?REPLY_KEY), req. unload_cowboy() -> catch meck:unload(cowboy_req), erase(?REQ_KEY), erase(?REPLY_KEY), ok. last_reply() -> case get(?REPLY_KEY) of undefined -> error(no_reply); Reply -> Reply end. last_status() -> {Status, _, _} = last_reply(), Status. last_body() -> {_, _, Body} = last_reply(), Body. last_json() -> jsx:decode(last_body(), [return_maps]). call(Module, Opts) when is_atom(Module), is_map(Opts) -> Req = with_cowboy(Opts), {ok, _, _} = Module:init(Req, []), last_reply(). %%%=================================================================== %%% Internal %%%=================================================================== normalize_req(Opts) -> Auth = maps:get(auth, Opts, none), Headers0 = maps:get(headers, Opts, #{<<"x-real-ip">> => <<"127.0.0.1">>}), {AuthHdr, Token} = case Auth of none -> {undefined, undefined}; {bearer, T} when is_binary(T) -> {{bearer, T}, T}; AdminId when is_binary(AdminId) -> T = admin_token(AdminId), {{bearer, T}, T} end, Headers = case AuthHdr of undefined -> Headers0; _ -> Headers0#{<<"authorization">> => <<"Bearer ", Token/binary>>} end, #{ method => maps:get(method, Opts, <<"GET">>), path => maps:get(path, Opts, <<"/v1/admin">>), bindings => maps:get(bindings, Opts, #{}), qs => maps:get(qs, Opts, []), body => maps:get(body, Opts, <<>>), has_body => maps:get(has_body, Opts, maps:is_key(body, Opts)), headers => Headers, peer => maps:get(peer, Opts, {127, 0, 0, 1}), auth_parsed => AuthHdr }. install_cowboy_expects() -> ok = meck:expect(cowboy_req, method, fun(_) -> maps:get(method, get(?REQ_KEY)) end), ok = meck:expect(cowboy_req, path, fun(_) -> maps:get(path, get(?REQ_KEY)) end), ok = meck:expect(cowboy_req, binding, fun(Key, _) -> maps:get(Key, maps:get(bindings, get(?REQ_KEY)), undefined) end), ok = meck:expect(cowboy_req, parse_qs, fun(_) -> maps:get(qs, get(?REQ_KEY)) end), ok = meck:expect(cowboy_req, read_body, fun(Req) -> {ok, maps:get(body, get(?REQ_KEY)), Req} end), ok = meck:expect(cowboy_req, has_body, fun(_) -> maps:get(has_body, get(?REQ_KEY)) end), ok = meck:expect(cowboy_req, header, fun(Name, _) -> maps:get(Name, maps:get(headers, get(?REQ_KEY)), undefined) end), ok = meck:expect(cowboy_req, parse_header, fun (<<"authorization">>, _) -> maps:get(auth_parsed, get(?REQ_KEY), undefined); (_, _) -> undefined end), ok = meck:expect(cowboy_req, peer, fun(_) -> maps:get(peer, get(?REQ_KEY)) end), ok = meck:expect(cowboy_req, reply, fun(Code, Headers, Body, Req) -> put(?REPLY_KEY, {Code, Headers, Body}), Req end), ok. %%%=================================================================== %%% Time helpers %%%=================================================================== %% @doc Calendar datetime N days from now (UTC). days_from_now(Days) when is_integer(Days) -> Sec = calendar:datetime_to_gregorian_seconds(calendar:universal_time()) + (Days * 86400), calendar:gregorian_seconds_to_datetime(Sec). %% @doc Convenient future start (~30 days ahead, noon UTC). future_start() -> {{Y, M, D}, _} = days_from_now(30), {{Y, M, D}, {12, 0, 0}}.