mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
chore(gateway): delete modules with no callers (#2494)
This commit is contained in:
@@ -1,306 +0,0 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(fluxer_gateway_crypto).
|
||||
-typing([eqwalizer]).
|
||||
|
||||
-export([
|
||||
init/0,
|
||||
decrypt/2,
|
||||
encrypt/2,
|
||||
derive_shared_secret/2,
|
||||
generate_keypair/0,
|
||||
get_public_key/0,
|
||||
new_crypto_state/1,
|
||||
is_encrypted_frame/1,
|
||||
unwrap_encrypted_frame/1,
|
||||
wrap_encrypted_frame/1
|
||||
]).
|
||||
|
||||
-export_type([keypair/0, crypto_state/0]).
|
||||
|
||||
-define(KEYPAIR_KEY, {?MODULE, instance_keypair}).
|
||||
-define(ENCRYPTED_FRAME_PREFIX, 16#FE).
|
||||
-define(NONCE_SIZE, 12).
|
||||
-define(TAG_SIZE, 16).
|
||||
-define(KEY_SIZE, 32).
|
||||
|
||||
-type keypair() :: #{public := binary(), private := binary()}.
|
||||
-type crypto_state() :: #{
|
||||
shared_secret := binary(),
|
||||
send_counter := non_neg_integer(),
|
||||
recv_counter := non_neg_integer()
|
||||
}.
|
||||
|
||||
-spec init() -> ok.
|
||||
init() ->
|
||||
case persistent_term:get(?KEYPAIR_KEY, undefined) of
|
||||
undefined ->
|
||||
Keypair = generate_keypair(),
|
||||
persistent_term:put(?KEYPAIR_KEY, Keypair),
|
||||
ok;
|
||||
_ ->
|
||||
ok
|
||||
end.
|
||||
|
||||
-spec generate_keypair() -> keypair().
|
||||
generate_keypair() ->
|
||||
{Public0, Private0} = crypto:generate_key(ecdh, x25519),
|
||||
Public = key_material(Public0),
|
||||
Private = key_material(Private0),
|
||||
#{public => Public, private => Private}.
|
||||
|
||||
-spec key_material(term()) -> binary().
|
||||
key_material(Value) when is_binary(Value) ->
|
||||
Value;
|
||||
key_material(Value) ->
|
||||
error({invalid_key_material, Value}).
|
||||
|
||||
-spec get_public_key() -> binary() | undefined.
|
||||
get_public_key() ->
|
||||
case persistent_term:get(?KEYPAIR_KEY, undefined) of
|
||||
undefined -> undefined;
|
||||
#{public := Public} -> Public
|
||||
end.
|
||||
|
||||
-spec derive_shared_secret(binary(), keypair()) -> {ok, binary()} | {error, term()}.
|
||||
derive_shared_secret(PeerPublic, #{private := Private}) when
|
||||
byte_size(PeerPublic) =:= ?KEY_SIZE
|
||||
->
|
||||
try
|
||||
SharedSecret = crypto:compute_key(ecdh, PeerPublic, Private, x25519),
|
||||
{ok, SharedSecret}
|
||||
catch
|
||||
error:Reason ->
|
||||
{error, {key_exchange_failed, Reason}}
|
||||
end;
|
||||
derive_shared_secret(PeerPublic, _Keypair) ->
|
||||
{error, {invalid_peer_key_size, byte_size(PeerPublic)}}.
|
||||
|
||||
-spec new_crypto_state(binary()) -> crypto_state().
|
||||
new_crypto_state(SharedSecret) when byte_size(SharedSecret) =:= ?KEY_SIZE ->
|
||||
#{
|
||||
shared_secret => SharedSecret,
|
||||
send_counter => 0,
|
||||
recv_counter => 0
|
||||
}.
|
||||
|
||||
-spec encrypt(binary(), crypto_state()) -> {ok, binary(), crypto_state()} | {error, term()}.
|
||||
encrypt(Plaintext, #{shared_secret := Key, send_counter := Counter} = State) ->
|
||||
try
|
||||
Nonce = counter_to_nonce(Counter),
|
||||
AAD = <<>>,
|
||||
{Ciphertext, Tag} = crypto:crypto_one_time_aead(
|
||||
aes_256_gcm,
|
||||
Key,
|
||||
Nonce,
|
||||
Plaintext,
|
||||
AAD,
|
||||
?TAG_SIZE,
|
||||
true
|
||||
),
|
||||
Encrypted = <<Nonce/binary, Tag/binary, Ciphertext/binary>>,
|
||||
NewState = State#{send_counter => Counter + 1},
|
||||
{ok, Encrypted, NewState}
|
||||
catch
|
||||
error:Reason ->
|
||||
{error, {encrypt_failed, Reason}}
|
||||
end.
|
||||
|
||||
-spec decrypt(binary(), crypto_state()) -> {ok, binary(), crypto_state()} | {error, term()}.
|
||||
decrypt(Data, #{shared_secret := Key, recv_counter := Counter} = State) ->
|
||||
MinSize = ?NONCE_SIZE + ?TAG_SIZE,
|
||||
case byte_size(Data) > MinSize of
|
||||
false ->
|
||||
{error, {invalid_encrypted_data, too_short}};
|
||||
true ->
|
||||
decrypt_sized_data(Data, Key, Counter, State)
|
||||
end.
|
||||
|
||||
-spec decrypt_sized_data(binary(), binary(), non_neg_integer(), crypto_state()) ->
|
||||
{ok, binary(), crypto_state()} | {error, term()}.
|
||||
decrypt_sized_data(Data, Key, Counter, State) ->
|
||||
<<Nonce:?NONCE_SIZE/binary, Tag:?TAG_SIZE/binary, Ciphertext/binary>> = Data,
|
||||
ExpectedNonce = counter_to_nonce(Counter),
|
||||
case validate_nonce(Nonce, ExpectedNonce, Counter) of
|
||||
{ok, ActualCounter} ->
|
||||
do_decrypt(Ciphertext, Key, Nonce, Tag, State, ActualCounter);
|
||||
{error, Reason} ->
|
||||
{error, Reason}
|
||||
end.
|
||||
|
||||
-spec do_decrypt(binary(), binary(), binary(), binary(), crypto_state(), non_neg_integer()) ->
|
||||
{ok, binary(), crypto_state()} | {error, term()}.
|
||||
do_decrypt(Ciphertext, Key, Nonce, Tag, State, ActualCounter) ->
|
||||
AAD = <<>>,
|
||||
try
|
||||
case
|
||||
crypto:crypto_one_time_aead(
|
||||
aes_256_gcm,
|
||||
Key,
|
||||
Nonce,
|
||||
Ciphertext,
|
||||
AAD,
|
||||
Tag,
|
||||
false
|
||||
)
|
||||
of
|
||||
Plaintext when is_binary(Plaintext) ->
|
||||
NewState = State#{recv_counter => ActualCounter + 1},
|
||||
{ok, Plaintext, NewState};
|
||||
error ->
|
||||
{error, authentication_failed}
|
||||
end
|
||||
catch
|
||||
error:Reason ->
|
||||
{error, {decrypt_failed, Reason}}
|
||||
end.
|
||||
|
||||
-spec counter_to_nonce(non_neg_integer()) -> binary().
|
||||
counter_to_nonce(Counter) ->
|
||||
<<0:32, Counter:64/big-unsigned-integer>>.
|
||||
|
||||
-spec validate_nonce(binary(), binary(), non_neg_integer()) ->
|
||||
{ok, non_neg_integer()} | {error, term()}.
|
||||
validate_nonce(Nonce, ExpectedNonce, Counter) when Nonce =:= ExpectedNonce ->
|
||||
{ok, Counter};
|
||||
validate_nonce(Nonce, _ExpectedNonce, Counter) ->
|
||||
<<_Prefix:4/binary, ReceivedCounter:64/big-unsigned-integer>> = Nonce,
|
||||
MaxWindow = 32,
|
||||
case ReceivedCounter > Counter andalso ReceivedCounter =< Counter + MaxWindow of
|
||||
true ->
|
||||
{ok, ReceivedCounter};
|
||||
false ->
|
||||
{error, {nonce_mismatch, Counter, ReceivedCounter}}
|
||||
end.
|
||||
|
||||
-spec is_encrypted_frame(binary()) -> boolean().
|
||||
is_encrypted_frame(<<?ENCRYPTED_FRAME_PREFIX, _Rest/binary>>) ->
|
||||
true;
|
||||
is_encrypted_frame(_) ->
|
||||
false.
|
||||
|
||||
-spec unwrap_encrypted_frame(binary()) -> {ok, binary()} | {error, not_encrypted}.
|
||||
unwrap_encrypted_frame(<<?ENCRYPTED_FRAME_PREFIX, Data/binary>>) ->
|
||||
{ok, Data};
|
||||
unwrap_encrypted_frame(_) ->
|
||||
{error, not_encrypted}.
|
||||
|
||||
-spec wrap_encrypted_frame(binary()) -> binary().
|
||||
wrap_encrypted_frame(Data) ->
|
||||
<<?ENCRYPTED_FRAME_PREFIX, Data/binary>>.
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
generate_keypair_test() ->
|
||||
Keypair = generate_keypair(),
|
||||
?assert(is_map(Keypair)),
|
||||
?assertEqual(?KEY_SIZE, byte_size(maps:get(public, Keypair))),
|
||||
?assertEqual(?KEY_SIZE, byte_size(maps:get(private, Keypair))).
|
||||
|
||||
derive_shared_secret_test() ->
|
||||
Keypair1 = generate_keypair(),
|
||||
Keypair2 = generate_keypair(),
|
||||
{ok, Secret1} = derive_shared_secret(maps:get(public, Keypair2), Keypair1),
|
||||
{ok, Secret2} = derive_shared_secret(maps:get(public, Keypair1), Keypair2),
|
||||
?assertEqual(Secret1, Secret2),
|
||||
?assertEqual(?KEY_SIZE, byte_size(Secret1)).
|
||||
|
||||
derive_shared_secret_invalid_key_test() ->
|
||||
Keypair = generate_keypair(),
|
||||
Result = derive_shared_secret(<<"short">>, Keypair),
|
||||
?assertMatch({error, {invalid_peer_key_size, _}}, Result).
|
||||
|
||||
new_crypto_state_test() ->
|
||||
Secret = crypto:strong_rand_bytes(?KEY_SIZE),
|
||||
State = new_crypto_state(Secret),
|
||||
?assertEqual(Secret, maps:get(shared_secret, State)),
|
||||
?assertEqual(0, maps:get(send_counter, State)),
|
||||
?assertEqual(0, maps:get(recv_counter, State)).
|
||||
|
||||
encrypt_decrypt_roundtrip_test() ->
|
||||
Secret = crypto:strong_rand_bytes(?KEY_SIZE),
|
||||
State = new_crypto_state(Secret),
|
||||
Plaintext = <<"hello world">>,
|
||||
{ok, Ciphertext, State2} = encrypt(Plaintext, State),
|
||||
?assert(byte_size(Ciphertext) > byte_size(Plaintext)),
|
||||
?assertEqual(1, maps:get(send_counter, State2)),
|
||||
{ok, Decrypted, State3} = decrypt(Ciphertext, State),
|
||||
?assertEqual(Plaintext, Decrypted),
|
||||
?assertEqual(1, maps:get(recv_counter, State3)).
|
||||
|
||||
encrypt_multiple_messages_test() ->
|
||||
Secret = crypto:strong_rand_bytes(?KEY_SIZE),
|
||||
SendState = new_crypto_state(Secret),
|
||||
RecvState = new_crypto_state(Secret),
|
||||
Messages = [<<"msg1">>, <<"msg2">>, <<"msg3">>],
|
||||
{FinalSendState, FinalRecvState, DecryptedMsgs} = lists:foldl(
|
||||
fun(Msg, {SS, RS, Acc}) ->
|
||||
{ok, Cipher, SS2} = encrypt(Msg, SS),
|
||||
{ok, Plain, RS2} = decrypt(Cipher, RS),
|
||||
{SS2, RS2, [Plain | Acc]}
|
||||
end,
|
||||
{SendState, RecvState, []},
|
||||
Messages
|
||||
),
|
||||
?assertEqual(3, maps:get(send_counter, FinalSendState)),
|
||||
?assertEqual(3, maps:get(recv_counter, FinalRecvState)),
|
||||
?assertEqual(Messages, lists:reverse(DecryptedMsgs)).
|
||||
|
||||
decrypt_tampered_test() ->
|
||||
Secret = crypto:strong_rand_bytes(?KEY_SIZE),
|
||||
State = new_crypto_state(Secret),
|
||||
{ok, Ciphertext, _} = encrypt(<<"hello">>, State),
|
||||
Rest = binary:part(Ciphertext, 1, byte_size(Ciphertext) - 1),
|
||||
Tampered = <<(binary:first(Ciphertext) bxor 1), Rest/binary>>,
|
||||
Result = decrypt(Tampered, State),
|
||||
?assertMatch({error, _}, Result).
|
||||
|
||||
decrypt_too_short_test() ->
|
||||
Secret = crypto:strong_rand_bytes(?KEY_SIZE),
|
||||
State = new_crypto_state(Secret),
|
||||
Result = decrypt(<<"short">>, State),
|
||||
?assertMatch({error, {invalid_encrypted_data, too_short}}, Result).
|
||||
|
||||
is_encrypted_frame_test() ->
|
||||
?assertEqual(true, is_encrypted_frame(<<16#FE, "data">>)),
|
||||
?assertEqual(false, is_encrypted_frame(<<"data">>)),
|
||||
?assertEqual(false, is_encrypted_frame(<<16#FF, "data">>)),
|
||||
?assertEqual(false, is_encrypted_frame(<<>>)).
|
||||
|
||||
unwrap_encrypted_frame_test() ->
|
||||
?assertEqual({ok, <<"data">>}, unwrap_encrypted_frame(<<16#FE, "data">>)),
|
||||
?assertEqual({error, not_encrypted}, unwrap_encrypted_frame(<<"data">>)).
|
||||
|
||||
wrap_encrypted_frame_test() ->
|
||||
?assertEqual(<<16#FE, "data">>, wrap_encrypted_frame(<<"data">>)).
|
||||
|
||||
counter_to_nonce_test() ->
|
||||
Nonce0 = counter_to_nonce(0),
|
||||
?assertEqual(?NONCE_SIZE, byte_size(Nonce0)),
|
||||
?assertEqual(<<0:32, 0:64>>, Nonce0),
|
||||
Nonce1 = counter_to_nonce(1),
|
||||
?assertEqual(<<0:32, 1:64>>, Nonce1).
|
||||
|
||||
init_creates_keypair_test() ->
|
||||
persistent_term:erase(?KEYPAIR_KEY),
|
||||
ok = init(),
|
||||
Public = get_public_key(),
|
||||
case Public of
|
||||
PublicBin when is_binary(PublicBin) ->
|
||||
?assertEqual(?KEY_SIZE, byte_size(PublicBin));
|
||||
undefined ->
|
||||
?assert(false)
|
||||
end,
|
||||
persistent_term:erase(?KEYPAIR_KEY).
|
||||
|
||||
init_idempotent_test() ->
|
||||
persistent_term:erase(?KEYPAIR_KEY),
|
||||
ok = init(),
|
||||
Public1 = get_public_key(),
|
||||
ok = init(),
|
||||
Public2 = get_public_key(),
|
||||
?assertEqual(Public1, Public2),
|
||||
persistent_term:erase(?KEYPAIR_KEY).
|
||||
|
||||
-endif.
|
||||
@@ -1,145 +0,0 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(guild_common).
|
||||
-typing([eqwalizer]).
|
||||
|
||||
-export([
|
||||
safe_call/3,
|
||||
parse_event_data/1,
|
||||
relay_upsert_voice_state/2,
|
||||
strip_members/1
|
||||
]).
|
||||
|
||||
-spec safe_call(pid(), term(), timeout()) -> term().
|
||||
safe_call(Pid, Msg, Timeout) when is_pid(Pid) ->
|
||||
try gen_server:call(Pid, Msg, Timeout) of
|
||||
Reply -> Reply
|
||||
catch
|
||||
exit:{timeout, _} -> {error, timeout};
|
||||
exit:{noproc, _} -> {error, noproc};
|
||||
exit:{normal, _} -> {error, noproc};
|
||||
_:Reason -> {error, Reason}
|
||||
end.
|
||||
|
||||
-spec parse_event_data
|
||||
(binary()) -> term();
|
||||
(map()) -> map().
|
||||
parse_event_data(EventData) when is_binary(EventData) ->
|
||||
json:decode(EventData);
|
||||
parse_event_data(EventData) when is_map(EventData) ->
|
||||
EventData.
|
||||
|
||||
-spec relay_upsert_voice_state(term(), map()) -> map().
|
||||
relay_upsert_voice_state(VoiceState, State) when is_map(VoiceState) ->
|
||||
ConnectionId = maps:get(<<"connection_id">>, VoiceState, undefined),
|
||||
case ConnectionId of
|
||||
undefined ->
|
||||
State;
|
||||
_ ->
|
||||
upsert_voice_state(ConnectionId, VoiceState, State)
|
||||
end;
|
||||
relay_upsert_voice_state(_, State) ->
|
||||
State.
|
||||
|
||||
-spec upsert_voice_state(term(), map(), map()) -> map().
|
||||
upsert_voice_state(ConnectionId, VoiceState, State) ->
|
||||
VoiceStates0 = maps:get(voice_states, State, #{}),
|
||||
ChannelId = maps:get(<<"channel_id">>, VoiceState, null),
|
||||
VoiceStates =
|
||||
case ChannelId of
|
||||
null -> maps:remove(ConnectionId, VoiceStates0);
|
||||
_ -> VoiceStates0#{ConnectionId => VoiceState}
|
||||
end,
|
||||
State#{voice_states => VoiceStates}.
|
||||
|
||||
-spec strip_members(term()) -> term().
|
||||
strip_members(Data) when is_map(Data) ->
|
||||
Data1 = maps:remove(<<"members">>, Data),
|
||||
maps:remove(<<"member_role_index">>, Data1);
|
||||
strip_members(Data) ->
|
||||
Data.
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
slow_gen_call_handler() ->
|
||||
receive
|
||||
{'$gen_call', _From, _Msg} ->
|
||||
ok = gateway_retry_timer:wait(5000)
|
||||
after 30000 ->
|
||||
ok
|
||||
end.
|
||||
|
||||
safe_call_timeout_test() ->
|
||||
Pid = spawn(fun slow_gen_call_handler/0),
|
||||
Result = safe_call(Pid, ping, 50),
|
||||
?assertEqual({error, timeout}, Result),
|
||||
exit(Pid, kill),
|
||||
ok.
|
||||
|
||||
safe_call_noproc_test() ->
|
||||
Pid = spawn(fun() -> ok end),
|
||||
ok = gateway_retry_timer:wait(50),
|
||||
Result = safe_call(Pid, ping, 100),
|
||||
?assertMatch({error, _}, Result),
|
||||
ok.
|
||||
|
||||
parse_event_data_binary_test() ->
|
||||
Binary = <<"{\"key\":\"value\"}">>,
|
||||
Result = parse_event_data(Binary),
|
||||
?assertEqual(#{<<"key">> => <<"value">>}, Result).
|
||||
|
||||
parse_event_data_map_test() ->
|
||||
Map = #{<<"key">> => <<"value">>},
|
||||
Result = parse_event_data(Map),
|
||||
?assertEqual(Map, Result).
|
||||
|
||||
relay_upsert_voice_state_adds_state_test() ->
|
||||
VoiceState = #{
|
||||
<<"connection_id">> => <<"conn-1">>,
|
||||
<<"channel_id">> => <<"100">>,
|
||||
<<"user_id">> => <<"42">>
|
||||
},
|
||||
State0 = #{voice_states => #{}},
|
||||
State1 = relay_upsert_voice_state(VoiceState, State0),
|
||||
VoiceStates = maps:get(voice_states, State1),
|
||||
?assertEqual(VoiceState, maps:get(<<"conn-1">>, VoiceStates)).
|
||||
|
||||
relay_upsert_voice_state_removes_on_null_channel_test() ->
|
||||
Existing = #{<<"connection_id">> => <<"conn-1">>, <<"channel_id">> => <<"100">>},
|
||||
State0 = #{voice_states => #{<<"conn-1">> => Existing}},
|
||||
RemoveState = #{<<"connection_id">> => <<"conn-1">>, <<"channel_id">> => null},
|
||||
State1 = relay_upsert_voice_state(RemoveState, State0),
|
||||
VoiceStates = maps:get(voice_states, State1),
|
||||
?assertEqual(false, maps:is_key(<<"conn-1">>, VoiceStates)).
|
||||
|
||||
relay_upsert_voice_state_no_connection_id_test() ->
|
||||
State0 = #{voice_states => #{}},
|
||||
State1 = relay_upsert_voice_state(#{<<"channel_id">> => <<"100">>}, State0),
|
||||
?assertEqual(State0, State1).
|
||||
|
||||
relay_upsert_voice_state_non_map_test() ->
|
||||
State0 = #{voice_states => #{}},
|
||||
State1 = relay_upsert_voice_state(not_a_map, State0),
|
||||
?assertEqual(State0, State1).
|
||||
|
||||
strip_members_test() ->
|
||||
Data = #{
|
||||
<<"members">> => [#{<<"user">> => #{<<"id">> => <<"1">>}}],
|
||||
<<"member_role_index">> => #{1 => [<<"role1">>]},
|
||||
<<"channels">> => [#{<<"id">> => <<"10">>}],
|
||||
<<"roles">> => [#{<<"id">> => <<"role1">>}]
|
||||
},
|
||||
Stripped = #{} = strip_members(Data),
|
||||
?assertEqual(false, maps:is_key(<<"members">>, Stripped)),
|
||||
?assertEqual(false, maps:is_key(<<"member_role_index">>, Stripped)),
|
||||
?assertEqual([#{<<"id">> => <<"10">>}], maps:get(<<"channels">>, Stripped)),
|
||||
?assertEqual([#{<<"id">> => <<"role1">>}], maps:get(<<"roles">>, Stripped)).
|
||||
|
||||
strip_members_empty_test() ->
|
||||
?assertEqual(#{}, strip_members(#{})).
|
||||
|
||||
strip_members_non_map_test() ->
|
||||
?assertEqual(not_a_map, strip_members(not_a_map)).
|
||||
|
||||
-endif.
|
||||
@@ -1,30 +0,0 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(push_core).
|
||||
-typing([eqwalizer]).
|
||||
|
||||
-export([handle_message_create/1]).
|
||||
-export([sync_user_guild_settings/3]).
|
||||
-export([sync_user_blocked_ids/2]).
|
||||
-export([invalidate_user_subscriptions/1]).
|
||||
|
||||
-spec handle_message_create(map()) -> ok.
|
||||
handle_message_create(Params) ->
|
||||
case fluxer_gateway_env:get(push_enabled) of
|
||||
true ->
|
||||
push:handle_message_create(Params);
|
||||
false ->
|
||||
ok
|
||||
end.
|
||||
|
||||
-spec sync_user_guild_settings(integer(), integer(), map()) -> ok.
|
||||
sync_user_guild_settings(UserId, GuildId, UserGuildSettings) ->
|
||||
push:sync_user_guild_settings(UserId, GuildId, UserGuildSettings).
|
||||
|
||||
-spec sync_user_blocked_ids(integer(), [integer()]) -> ok.
|
||||
sync_user_blocked_ids(UserId, BlockedIds) ->
|
||||
push:sync_user_blocked_ids(UserId, BlockedIds).
|
||||
|
||||
-spec invalidate_user_subscriptions(integer()) -> ok.
|
||||
invalidate_user_subscriptions(UserId) ->
|
||||
push:invalidate_user_subscriptions(UserId).
|
||||
@@ -1,308 +0,0 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(gateway_runtime_probe).
|
||||
-typing([eqwalizer]).
|
||||
|
||||
-export([
|
||||
snapshot/0,
|
||||
top_processes/2,
|
||||
sample_processes/2,
|
||||
top_guilds/1,
|
||||
sample_guilds/2,
|
||||
guild_probe/1,
|
||||
logger_status/0,
|
||||
gc_logger/0
|
||||
]).
|
||||
|
||||
-define(MAX_LIMIT, 100).
|
||||
-define(DEFAULT_LIMIT, 20).
|
||||
-define(MAX_SAMPLE_MS, 5000).
|
||||
-define(DEFAULT_SAMPLE_MS, 250).
|
||||
-define(MIN_SAMPLE_MS, 10).
|
||||
-define(STATE_TIMEOUT_MS, 50).
|
||||
-define(MAX_PROCESS_SCAN, 5000).
|
||||
-define(MAX_GUILD_PID_SCAN, 5000).
|
||||
-define(LOGGER_HANDLER, logger_simple_h).
|
||||
-define(LOGGER_GC_MAX_QUEUE, 1000).
|
||||
|
||||
-type metric() :: memory | message_queue_len | reductions | total_heap_size.
|
||||
-type row() :: map().
|
||||
|
||||
-export_type([metric/0, row/0]).
|
||||
|
||||
-spec snapshot() -> map().
|
||||
snapshot() ->
|
||||
#{
|
||||
node => node(),
|
||||
memory => erlang:memory(),
|
||||
process_count => erlang:system_info(process_count),
|
||||
run_queue => erlang:statistics(run_queue),
|
||||
scheduler_wall_time => safe_scheduler_wall_time(),
|
||||
reductions => erlang:statistics(reductions)
|
||||
}.
|
||||
|
||||
-spec top_processes(metric() | term(), pos_integer()) -> [row()].
|
||||
top_processes(memory, Limit) ->
|
||||
top_by_info(memory, Limit);
|
||||
top_processes(message_queue_len, Limit) ->
|
||||
top_by_info(message_queue_len, Limit);
|
||||
top_processes(reductions, Limit) ->
|
||||
top_by_info(reductions, Limit);
|
||||
top_processes(total_heap_size, Limit) ->
|
||||
top_by_info(total_heap_size, Limit);
|
||||
top_processes(_, Limit) ->
|
||||
top_by_info(memory, Limit).
|
||||
|
||||
-spec sample_processes(integer(), pos_integer()) -> [row()].
|
||||
sample_processes(Milliseconds, Limit) ->
|
||||
Pids = process_scan_pids(),
|
||||
Before = reductions_by_pid(Pids),
|
||||
ok = gateway_retry_timer:wait(clamp_ms(Milliseconds)),
|
||||
After = reductions_by_pid(Pids),
|
||||
Rows = [
|
||||
enrich_pid(Pid, #{reduction_delta => Delta})
|
||||
|| {Pid, R1} <- maps:to_list(After),
|
||||
R0 <- [maps:get(Pid, Before, R1)],
|
||||
Delta <- [R1 - R0],
|
||||
Delta > 0
|
||||
],
|
||||
take(Limit, lists:sort(fun row_reduction_delta_ge/2, Rows)).
|
||||
|
||||
-spec top_guilds(pos_integer()) -> [row()].
|
||||
top_guilds(Limit) ->
|
||||
Rows = [guild_row(GuildId, Pid) || {GuildId, Pid} <- local_guild_pids()],
|
||||
take(Limit, lists:sort(fun row_memory_ge/2, Rows)).
|
||||
|
||||
-spec sample_guilds(integer(), pos_integer()) -> [row()].
|
||||
sample_guilds(Milliseconds, Limit) ->
|
||||
Guilds = local_guild_pids(),
|
||||
Before = maps:from_list([{Pid, reductions(Pid)} || {_GuildId, Pid} <- Guilds]),
|
||||
ok = gateway_retry_timer:wait(clamp_ms(Milliseconds)),
|
||||
Rows = [
|
||||
(guild_row(GuildId, Pid))#{
|
||||
reduction_delta => reductions(Pid) - maps:get(Pid, Before, reductions(Pid))
|
||||
}
|
||||
|| {GuildId, Pid} <- Guilds
|
||||
],
|
||||
take(Limit, lists:sort(fun row_reduction_delta_ge/2, Rows)).
|
||||
|
||||
-spec guild_probe(integer()) -> row().
|
||||
guild_probe(GuildId) when is_integer(GuildId) ->
|
||||
try guild_manager:lookup(GuildId) of
|
||||
{ok, Pid} when is_pid(Pid) ->
|
||||
guild_row(GuildId, Pid);
|
||||
Other ->
|
||||
#{guild_id => GuildId, lookup => Other}
|
||||
catch
|
||||
Class:Reason ->
|
||||
#{guild_id => GuildId, lookup => {error, {Class, Reason}}}
|
||||
end.
|
||||
|
||||
-spec logger_status() -> row().
|
||||
logger_status() ->
|
||||
case whereis(?LOGGER_HANDLER) of
|
||||
Pid when is_pid(Pid) -> enrich_pid(Pid, #{handler => ?LOGGER_HANDLER});
|
||||
undefined -> #{handler => ?LOGGER_HANDLER, status => not_found}
|
||||
end.
|
||||
|
||||
-spec gc_logger() -> row().
|
||||
gc_logger() ->
|
||||
case whereis(?LOGGER_HANDLER) of
|
||||
Pid when is_pid(Pid) -> gc_logger_pid(Pid);
|
||||
undefined -> #{handler => ?LOGGER_HANDLER, status => not_found}
|
||||
end.
|
||||
|
||||
-spec gc_logger_pid(pid()) -> row().
|
||||
gc_logger_pid(Pid) ->
|
||||
Before = enrich_pid(Pid, #{handler => ?LOGGER_HANDLER}),
|
||||
case maps:get(message_queue_len, Before, 0) of
|
||||
Len when Len =< ?LOGGER_GC_MAX_QUEUE ->
|
||||
Result = erlang:garbage_collect(Pid, [{type, major}]),
|
||||
#{
|
||||
handler => ?LOGGER_HANDLER,
|
||||
status => ok,
|
||||
result => Result,
|
||||
before => Before,
|
||||
after_info => enrich_pid(Pid, #{handler => ?LOGGER_HANDLER})
|
||||
};
|
||||
Len ->
|
||||
#{
|
||||
handler => ?LOGGER_HANDLER,
|
||||
status => skipped,
|
||||
reason => logger_queue_not_idle,
|
||||
message_queue_len => Len,
|
||||
max_queue => ?LOGGER_GC_MAX_QUEUE,
|
||||
before => Before
|
||||
}
|
||||
end.
|
||||
|
||||
-spec top_by_info(metric(), pos_integer()) -> [row()].
|
||||
top_by_info(Key, Limit) ->
|
||||
Rows = [enrich_pid(Pid, #{}) || Pid <- process_scan_pids()],
|
||||
Sorted = lists:sort(fun(A, B) -> maps:get(Key, A, 0) >= maps:get(Key, B, 0) end, Rows),
|
||||
take(Limit, Sorted).
|
||||
|
||||
-spec local_guild_pids() -> [{integer(), pid()}].
|
||||
local_guild_pids() ->
|
||||
try bounded_ets_rows(guild_pid_cache, ?MAX_GUILD_PID_SCAN) of
|
||||
Rows ->
|
||||
lists:usort([
|
||||
{GuildId, Pid}
|
||||
|| {GuildId, Pid} <- Rows,
|
||||
is_integer(GuildId),
|
||||
is_pid(Pid),
|
||||
node(Pid) =:= node()
|
||||
])
|
||||
catch
|
||||
error:badarg -> []
|
||||
end.
|
||||
|
||||
-spec process_scan_pids() -> [pid()].
|
||||
process_scan_pids() ->
|
||||
lists:sublist(erlang:processes(), ?MAX_PROCESS_SCAN).
|
||||
|
||||
-spec bounded_ets_rows(ets:table(), pos_integer()) -> [term()].
|
||||
bounded_ets_rows(Table, Limit) ->
|
||||
MatchSpec = [{{'$1', '$2'}, [], [{{'$1', '$2'}}]}],
|
||||
case ets:select(Table, MatchSpec, Limit) of
|
||||
{Rows, _Continuation} -> Rows;
|
||||
'$end_of_table' -> []
|
||||
end.
|
||||
|
||||
-spec guild_row(integer(), pid()) -> row().
|
||||
guild_row(GuildId, Pid) ->
|
||||
Base = enrich_pid(Pid, #{guild_id => GuildId}),
|
||||
case safe_state(Pid) of
|
||||
State when is_map(State) ->
|
||||
Data = map_utils:ensure_map(maps:get(data, State, #{})),
|
||||
Guild = map_utils:ensure_map(maps:get(<<"guild">>, Data, #{})),
|
||||
Base#{
|
||||
guild_name => maps:get(<<"name">>, Guild, <<"Unknown">>),
|
||||
member_count => safe_member_count(Data),
|
||||
session_count => map_size(map_utils:ensure_map(maps:get(sessions, State, #{}))),
|
||||
presence_count => map_size(
|
||||
map_utils:ensure_map(maps:get(presences, State, #{}))
|
||||
)
|
||||
};
|
||||
_ ->
|
||||
Base#{state => unavailable}
|
||||
end.
|
||||
|
||||
-spec enrich_pid(pid(), map()) -> row().
|
||||
enrich_pid(Pid, Extra) ->
|
||||
Info = info_map(Pid),
|
||||
maps:merge(Info#{pid => pid_to_list(Pid)}, Extra).
|
||||
|
||||
-spec info_map(pid()) -> map().
|
||||
info_map(Pid) ->
|
||||
case
|
||||
erlang:process_info(Pid, [
|
||||
memory,
|
||||
message_queue_len,
|
||||
reductions,
|
||||
current_function,
|
||||
initial_call,
|
||||
registered_name,
|
||||
total_heap_size,
|
||||
heap_size,
|
||||
stack_size,
|
||||
garbage_collection
|
||||
])
|
||||
of
|
||||
undefined ->
|
||||
#{alive => false};
|
||||
Info when is_list(Info) ->
|
||||
maps:from_list(Info)
|
||||
end.
|
||||
|
||||
-spec safe_state(pid()) -> term().
|
||||
safe_state(Pid) ->
|
||||
try sys:get_state(Pid, ?STATE_TIMEOUT_MS) of
|
||||
State -> State
|
||||
catch
|
||||
_:_ -> unavailable
|
||||
end.
|
||||
|
||||
-spec safe_member_count(term()) -> non_neg_integer().
|
||||
safe_member_count(Data) ->
|
||||
try guild_data_index:member_count(Data) of
|
||||
Count when is_integer(Count), Count >= 0 -> Count;
|
||||
_ -> 0
|
||||
catch
|
||||
_:_ -> 0
|
||||
end.
|
||||
|
||||
-spec reductions_by_pid([pid()]) -> #{pid() => non_neg_integer()}.
|
||||
reductions_by_pid(Pids) ->
|
||||
maps:from_list([{Pid, reductions(Pid)} || Pid <- Pids]).
|
||||
|
||||
-spec reductions(pid()) -> non_neg_integer().
|
||||
reductions(Pid) ->
|
||||
case erlang:process_info(Pid, reductions) of
|
||||
{reductions, R} when is_integer(R), R >= 0 -> R;
|
||||
_ -> 0
|
||||
end.
|
||||
|
||||
-spec safe_scheduler_wall_time() -> term().
|
||||
safe_scheduler_wall_time() ->
|
||||
try erlang:statistics(scheduler_wall_time) of
|
||||
Value -> Value
|
||||
catch
|
||||
_:_ -> unavailable
|
||||
end.
|
||||
|
||||
-spec clamp_ms(term()) -> pos_integer().
|
||||
clamp_ms(Ms) when is_integer(Ms), Ms >= ?MIN_SAMPLE_MS, Ms =< ?MAX_SAMPLE_MS -> Ms;
|
||||
clamp_ms(Ms) when is_integer(Ms), Ms > ?MAX_SAMPLE_MS -> ?MAX_SAMPLE_MS;
|
||||
clamp_ms(_) -> ?DEFAULT_SAMPLE_MS.
|
||||
|
||||
-spec take(term(), [row()]) -> [row()].
|
||||
take(Limit, Rows) when is_integer(Limit), Limit > 0 ->
|
||||
lists:sublist(Rows, min(Limit, ?MAX_LIMIT));
|
||||
take(_, Rows) ->
|
||||
lists:sublist(Rows, ?DEFAULT_LIMIT).
|
||||
|
||||
-spec row_memory_ge(row(), row()) -> boolean().
|
||||
row_memory_ge(A, B) ->
|
||||
maps:get(memory, A, 0) >= maps:get(memory, B, 0).
|
||||
|
||||
-spec row_reduction_delta_ge(row(), row()) -> boolean().
|
||||
row_reduction_delta_ge(A, B) ->
|
||||
maps:get(reduction_delta, A, 0) >= maps:get(reduction_delta, B, 0).
|
||||
|
||||
-ifdef(TEST).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
snapshot_returns_basic_runtime_info_test() ->
|
||||
Snapshot = snapshot(),
|
||||
?assert(is_atom(maps:get(node, Snapshot))),
|
||||
?assert(is_list(maps:get(memory, Snapshot))),
|
||||
?assert(is_integer(maps:get(process_count, Snapshot))).
|
||||
|
||||
top_processes_is_bounded_test() ->
|
||||
Rows = top_processes(memory, 2),
|
||||
?assert(length(Rows) =< 2),
|
||||
?assert(lists:all(fun(Row) -> maps:is_key(pid, Row) end, Rows)).
|
||||
|
||||
sample_processes_is_bounded_test() ->
|
||||
Rows = sample_processes(10, 2),
|
||||
?assert(length(Rows) =< 2).
|
||||
|
||||
process_scan_pids_is_bounded_test() ->
|
||||
?assert(length(process_scan_pids()) =< ?MAX_PROCESS_SCAN).
|
||||
|
||||
bounded_ets_rows_respects_limit_test() ->
|
||||
Table = ets:new(?MODULE, [set]),
|
||||
try
|
||||
true = ets:insert(Table, [{1, self()}, {2, self()}, {3, self()}]),
|
||||
Rows = bounded_ets_rows(Table, 2),
|
||||
?assertEqual(2, length(Rows))
|
||||
after
|
||||
ets:delete(Table)
|
||||
end.
|
||||
|
||||
guild_probe_without_manager_does_not_crash_test() ->
|
||||
Row = guild_probe(1),
|
||||
?assertEqual(1, maps:get(guild_id, Row)).
|
||||
|
||||
-endif.
|
||||
@@ -1,112 +0,0 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(list_ops).
|
||||
-typing([eqwalizer]).
|
||||
|
||||
-export([
|
||||
replace_by_id/3,
|
||||
remove_by_id/2,
|
||||
replace_by_user_id/3,
|
||||
remove_by_user_id/2,
|
||||
bulk_update/2,
|
||||
extract_user_id/1
|
||||
]).
|
||||
|
||||
-export_type([item/0, id/0, item_list/0]).
|
||||
|
||||
-type item() :: map() | term().
|
||||
-type id() :: binary() | integer().
|
||||
-type item_list() :: [item()].
|
||||
|
||||
-spec replace_by_id(term(), id(), item()) -> item_list().
|
||||
replace_by_id(Items, Id, NewItem) when is_list(Items) ->
|
||||
lists:map(
|
||||
fun(Item) -> replace_item_by_id(Item, Id, NewItem) end,
|
||||
Items
|
||||
);
|
||||
replace_by_id(_, _, _) ->
|
||||
[].
|
||||
|
||||
-spec remove_by_id(term(), id()) -> item_list().
|
||||
remove_by_id(Items, Id) when is_list(Items) ->
|
||||
lists:filter(fun(Item) -> keep_item_with_id(Item, Id) end, Items);
|
||||
remove_by_id(_, _) ->
|
||||
[].
|
||||
|
||||
-spec replace_by_user_id(term(), integer(), item()) -> item_list().
|
||||
replace_by_user_id(Items, UserId, NewItem) when is_list(Items), is_integer(UserId) ->
|
||||
lists:map(
|
||||
fun(Item) -> replace_item_by_user_id(Item, UserId, NewItem) end,
|
||||
Items
|
||||
);
|
||||
replace_by_user_id(_, _, _) ->
|
||||
[].
|
||||
|
||||
-spec remove_by_user_id(term(), integer()) -> item_list().
|
||||
remove_by_user_id(Items, UserId) when is_list(Items), is_integer(UserId) ->
|
||||
lists:filter(fun(Item) -> keep_item_with_user_id(Item, UserId) end, Items);
|
||||
remove_by_user_id(_, _) ->
|
||||
[].
|
||||
|
||||
-spec bulk_update(term(), term()) -> item_list().
|
||||
bulk_update(Items, Updates) when is_list(Items), is_list(Updates) ->
|
||||
UpdateMap = lists:foldl(fun add_update_item/2, #{}, Updates),
|
||||
lists:map(fun(Item) -> apply_update_item(Item, UpdateMap) end, Items);
|
||||
bulk_update(Items, _) when is_list(Items) ->
|
||||
Items;
|
||||
bulk_update(_, _) ->
|
||||
[].
|
||||
|
||||
-spec extract_user_id(map() | term()) -> pos_integer() | undefined.
|
||||
extract_user_id(Item) ->
|
||||
UserMap = map_utils:ensure_map(map_utils:get_safe(Item, <<"user">>, #{})),
|
||||
type_conv:extract_id(UserMap, <<"id">>).
|
||||
|
||||
-spec replace_item_by_id(item(), id(), item()) -> item().
|
||||
replace_item_by_id(Item, Id, NewItem) when is_map(Item) ->
|
||||
case maps:get(<<"id">>, Item, undefined) of
|
||||
Id -> NewItem;
|
||||
_ -> Item
|
||||
end;
|
||||
replace_item_by_id(Item, _Id, _NewItem) ->
|
||||
Item.
|
||||
|
||||
-spec keep_item_with_id(item(), id()) -> boolean().
|
||||
keep_item_with_id(Item, Id) when is_map(Item) ->
|
||||
maps:get(<<"id">>, Item, undefined) =/= Id;
|
||||
keep_item_with_id(_Item, _Id) ->
|
||||
true.
|
||||
|
||||
-spec replace_item_by_user_id(item(), integer(), item()) -> item().
|
||||
replace_item_by_user_id(Item, UserId, NewItem) when is_map(Item) ->
|
||||
case extract_user_id(Item) =:= UserId of
|
||||
true -> NewItem;
|
||||
false -> Item
|
||||
end;
|
||||
replace_item_by_user_id(Item, _UserId, _NewItem) ->
|
||||
Item.
|
||||
|
||||
-spec keep_item_with_user_id(item(), integer()) -> boolean().
|
||||
keep_item_with_user_id(Item, UserId) when is_map(Item) ->
|
||||
extract_user_id(Item) =/= UserId;
|
||||
keep_item_with_user_id(_Item, _UserId) ->
|
||||
true.
|
||||
|
||||
-spec add_update_item(item(), map()) -> map().
|
||||
add_update_item(Item, Acc) when is_map(Item) ->
|
||||
case maps:get(<<"id">>, Item, undefined) of
|
||||
undefined -> Acc;
|
||||
ItemId -> Acc#{ItemId => Item}
|
||||
end;
|
||||
add_update_item(_Item, Acc) ->
|
||||
Acc.
|
||||
|
||||
-spec apply_update_item(item(), map()) -> item().
|
||||
apply_update_item(Item, UpdateMap) when is_map(Item) ->
|
||||
ItemId = maps:get(<<"id">>, Item, undefined),
|
||||
case maps:get(ItemId, UpdateMap, undefined) of
|
||||
undefined -> Item;
|
||||
UpdatedItem -> UpdatedItem
|
||||
end;
|
||||
apply_update_item(Item, _UpdateMap) ->
|
||||
Item.
|
||||
@@ -1,150 +0,0 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(list_ops_bulk_tests).
|
||||
-typing([eqwalizer]).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
make_item_with_id(Id) ->
|
||||
#{<<"id">> => Id, <<"data">> => <<"test">>}.
|
||||
|
||||
default_items() ->
|
||||
[
|
||||
make_item_with_id(<<"1">>),
|
||||
make_item_with_id(<<"2">>)
|
||||
].
|
||||
|
||||
bulk_update_multiple_updates_test() ->
|
||||
Items = [
|
||||
make_item_with_id(<<"1">>),
|
||||
make_item_with_id(<<"2">>),
|
||||
make_item_with_id(<<"3">>),
|
||||
make_item_with_id(<<"4">>)
|
||||
],
|
||||
Updates = [
|
||||
#{<<"id">> => <<"2">>, <<"data">> => <<"updated_2">>},
|
||||
#{<<"id">> => <<"4">>, <<"data">> => <<"updated_4">>}
|
||||
],
|
||||
Result = list_ops:bulk_update(Items, Updates),
|
||||
|
||||
?assertEqual(4, length(Result)),
|
||||
?assertEqual(make_item_with_id(<<"1">>), lists:nth(1, Result)),
|
||||
?assertEqual(#{<<"id">> => <<"2">>, <<"data">> => <<"updated_2">>}, lists:nth(2, Result)),
|
||||
?assertEqual(make_item_with_id(<<"3">>), lists:nth(3, Result)),
|
||||
?assertEqual(#{<<"id">> => <<"4">>, <<"data">> => <<"updated_4">>}, lists:nth(4, Result)).
|
||||
|
||||
bulk_update_partial_updates_test() ->
|
||||
Items = [
|
||||
make_item_with_id(<<"1">>),
|
||||
make_item_with_id(<<"2">>),
|
||||
make_item_with_id(<<"3">>)
|
||||
],
|
||||
Updates = [
|
||||
#{<<"id">> => <<"2">>, <<"data">> => <<"updated">>}
|
||||
],
|
||||
Result = list_ops:bulk_update(Items, Updates),
|
||||
|
||||
?assertEqual(3, length(Result)),
|
||||
?assertEqual(make_item_with_id(<<"1">>), lists:nth(1, Result)),
|
||||
?assertEqual(#{<<"id">> => <<"2">>, <<"data">> => <<"updated">>}, lists:nth(2, Result)),
|
||||
?assertEqual(make_item_with_id(<<"3">>), lists:nth(3, Result)).
|
||||
|
||||
bulk_update_no_matches_test() ->
|
||||
Items = default_items(),
|
||||
Updates = [
|
||||
#{<<"id">> => <<"99">>, <<"data">> => <<"new">>},
|
||||
#{<<"id">> => <<"98">>, <<"data">> => <<"new2">>}
|
||||
],
|
||||
Result = list_ops:bulk_update(Items, Updates),
|
||||
|
||||
?assertEqual(Items, Result).
|
||||
|
||||
bulk_update_empty_lists_test() ->
|
||||
?assertEqual([], list_ops:bulk_update([], [])),
|
||||
?assertEqual([], list_ops:bulk_update([], [make_item_with_id(<<"1">>)])),
|
||||
|
||||
Items = [make_item_with_id(<<"1">>)],
|
||||
?assertEqual(Items, list_ops:bulk_update(Items, [])).
|
||||
|
||||
bulk_update_updates_without_id_test() ->
|
||||
Items = default_items(),
|
||||
Updates = [
|
||||
#{<<"name">> => <<"no_id">>},
|
||||
#{<<"id">> => <<"2">>, <<"data">> => <<"updated">>}
|
||||
],
|
||||
Result = list_ops:bulk_update(Items, Updates),
|
||||
|
||||
?assertEqual(2, length(Result)),
|
||||
?assertEqual(make_item_with_id(<<"1">>), lists:nth(1, Result)),
|
||||
?assertEqual(#{<<"id">> => <<"2">>, <<"data">> => <<"updated">>}, lists:nth(2, Result)).
|
||||
|
||||
bulk_update_mixed_items_list_test() ->
|
||||
Items = [
|
||||
make_item_with_id(<<"1">>),
|
||||
<<"non_map_item">>,
|
||||
make_item_with_id(<<"2">>),
|
||||
{tuple, item}
|
||||
],
|
||||
Updates = [
|
||||
#{<<"id">> => <<"2">>, <<"data">> => <<"updated">>}
|
||||
],
|
||||
Result = list_ops:bulk_update(Items, Updates),
|
||||
|
||||
?assertEqual(4, length(Result)),
|
||||
?assertEqual(make_item_with_id(<<"1">>), lists:nth(1, Result)),
|
||||
?assertEqual(<<"non_map_item">>, lists:nth(2, Result)),
|
||||
?assertEqual(#{<<"id">> => <<"2">>, <<"data">> => <<"updated">>}, lists:nth(3, Result)),
|
||||
?assertEqual({tuple, item}, lists:nth(4, Result)).
|
||||
|
||||
bulk_update_mixed_updates_list_test() ->
|
||||
Items = default_items(),
|
||||
Updates = [
|
||||
<<"non_map">>,
|
||||
#{<<"id">> => <<"1">>, <<"data">> => <<"updated">>},
|
||||
{tuple},
|
||||
#{<<"no_id">> => <<"field">>}
|
||||
],
|
||||
Result = list_ops:bulk_update(Items, Updates),
|
||||
|
||||
?assertEqual(2, length(Result)),
|
||||
?assertEqual(#{<<"id">> => <<"1">>, <<"data">> => <<"updated">>}, lists:nth(1, Result)),
|
||||
?assertEqual(make_item_with_id(<<"2">>), lists:nth(2, Result)).
|
||||
|
||||
bulk_update_duplicate_ids_in_updates_test() ->
|
||||
Items = default_items(),
|
||||
Updates = [
|
||||
#{<<"id">> => <<"1">>, <<"data">> => <<"first_update">>},
|
||||
#{<<"id">> => <<"1">>, <<"data">> => <<"second_update">>}
|
||||
],
|
||||
Result = list_ops:bulk_update(Items, Updates),
|
||||
|
||||
?assertEqual(2, length(Result)),
|
||||
?assertEqual(
|
||||
#{<<"id">> => <<"1">>, <<"data">> => <<"second_update">>}, lists:nth(1, Result)
|
||||
),
|
||||
?assertEqual(make_item_with_id(<<"2">>), lists:nth(2, Result)).
|
||||
|
||||
bulk_update_invalid_input_test() ->
|
||||
Items = [make_item_with_id(<<"1">>)],
|
||||
|
||||
?assertEqual(Items, list_ops:bulk_update(Items, not_a_list)),
|
||||
?assertEqual(Items, list_ops:bulk_update(Items, undefined)),
|
||||
?assertEqual(Items, list_ops:bulk_update(Items, #{})),
|
||||
|
||||
?assertEqual([], list_ops:bulk_update(not_a_list, [make_item_with_id(<<"1">>)])),
|
||||
?assertEqual([], list_ops:bulk_update(undefined, [])).
|
||||
|
||||
bulk_update_item_without_id_preserved_test() ->
|
||||
Items = [
|
||||
make_item_with_id(<<"1">>),
|
||||
#{<<"name">> => <<"no_id_item">>},
|
||||
make_item_with_id(<<"2">>)
|
||||
],
|
||||
Updates = [
|
||||
#{<<"id">> => <<"1">>, <<"data">> => <<"updated">>}
|
||||
],
|
||||
Result = list_ops:bulk_update(Items, Updates),
|
||||
|
||||
?assertEqual(3, length(Result)),
|
||||
?assertEqual(#{<<"id">> => <<"1">>, <<"data">> => <<"updated">>}, lists:nth(1, Result)),
|
||||
?assertEqual(#{<<"name">> => <<"no_id_item">>}, lists:nth(2, Result)),
|
||||
?assertEqual(make_item_with_id(<<"2">>), lists:nth(3, Result)).
|
||||
@@ -1,57 +0,0 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(list_ops_extract_tests).
|
||||
-typing([eqwalizer]).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
extract_user_id_valid_structure_test() ->
|
||||
Item = #{<<"user">> => #{<<"id">> => <<"12345">>}},
|
||||
?assertEqual(12345, list_ops:extract_user_id(Item)).
|
||||
|
||||
extract_user_id_missing_user_test() ->
|
||||
Item = #{<<"other">> => <<"field">>},
|
||||
?assertEqual(undefined, list_ops:extract_user_id(Item)).
|
||||
|
||||
extract_user_id_missing_id_test() ->
|
||||
Item = #{<<"user">> => #{<<"name">> => <<"alice">>}},
|
||||
?assertEqual(undefined, list_ops:extract_user_id(Item)).
|
||||
|
||||
extract_user_id_non_map_test() ->
|
||||
?assertEqual(undefined, list_ops:extract_user_id(<<"string">>)),
|
||||
?assertEqual(undefined, list_ops:extract_user_id([list])),
|
||||
?assertEqual(undefined, list_ops:extract_user_id({tuple})),
|
||||
?assertEqual(undefined, list_ops:extract_user_id(undefined)),
|
||||
?assertEqual(undefined, list_ops:extract_user_id(123)).
|
||||
|
||||
extract_user_id_user_not_map_test() ->
|
||||
Item = #{<<"user">> => <<"not_a_map">>},
|
||||
?assertEqual(undefined, list_ops:extract_user_id(Item)).
|
||||
|
||||
extract_user_id_nested_structure_test() ->
|
||||
Item = #{
|
||||
<<"user">> => #{
|
||||
<<"id">> => <<"999">>,
|
||||
<<"name">> => <<"bob">>,
|
||||
<<"extra">> => #{<<"nested">> => <<"data">>}
|
||||
},
|
||||
<<"role">> => <<"admin">>
|
||||
},
|
||||
?assertEqual(999, list_ops:extract_user_id(Item)).
|
||||
|
||||
extract_user_id_empty_id_test() ->
|
||||
Item = #{<<"user">> => #{<<"id">> => <<>>}},
|
||||
?assertEqual(undefined, list_ops:extract_user_id(Item)).
|
||||
|
||||
extract_user_id_empty_user_map_test() ->
|
||||
Item = #{<<"user">> => #{}},
|
||||
?assertEqual(undefined, list_ops:extract_user_id(Item)).
|
||||
|
||||
extract_user_id_invalid_id_format_test() ->
|
||||
Item = #{<<"user">> => #{<<"id">> => <<"not_a_number">>}},
|
||||
?assertEqual(undefined, list_ops:extract_user_id(Item)).
|
||||
|
||||
extract_user_id_rejects_zero_and_leading_zero_test() ->
|
||||
?assertEqual(undefined, list_ops:extract_user_id(#{<<"user">> => #{<<"id">> => <<"0">>}})),
|
||||
?assertEqual(
|
||||
undefined, list_ops:extract_user_id(#{<<"user">> => #{<<"id">> => <<"001">>}})
|
||||
).
|
||||
@@ -1,163 +0,0 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(list_ops_tests).
|
||||
-typing([eqwalizer]).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
make_item_with_id(Id) ->
|
||||
#{<<"id">> => Id, <<"data">> => <<"test">>}.
|
||||
|
||||
replace_by_id_success_test() ->
|
||||
NewItem = #{<<"id">> => <<"2">>, <<"data">> => <<"updated">>},
|
||||
assert_replace_default_id_two(
|
||||
[
|
||||
make_item_with_id(<<"1">>),
|
||||
NewItem,
|
||||
make_item_with_id(<<"3">>)
|
||||
],
|
||||
NewItem
|
||||
).
|
||||
|
||||
replace_by_id_no_match_test() ->
|
||||
NewItem = #{<<"id">> => <<"99">>, <<"data">> => <<"new">>},
|
||||
assert_replace_by_id_no_change(<<"99">>, NewItem).
|
||||
|
||||
replace_by_id_empty_list_test() ->
|
||||
Result = list_ops:replace_by_id([], <<"1">>, #{<<"id">> => <<"1">>}),
|
||||
?assertEqual([], Result).
|
||||
|
||||
replace_by_id_mixed_list_test() ->
|
||||
Items = [
|
||||
make_item_with_id(<<"1">>),
|
||||
<<"non_map_item">>,
|
||||
make_item_with_id(<<"2">>),
|
||||
{tuple, item},
|
||||
make_item_with_id(<<"3">>)
|
||||
],
|
||||
NewItem = #{<<"id">> => <<"2">>, <<"data">> => <<"replaced">>},
|
||||
Result = list_ops:replace_by_id(Items, <<"2">>, NewItem),
|
||||
|
||||
?assertEqual(5, length(Result)),
|
||||
?assertEqual(make_item_with_id(<<"1">>), lists:nth(1, Result)),
|
||||
?assertEqual(<<"non_map_item">>, lists:nth(2, Result)),
|
||||
?assertEqual(NewItem, lists:nth(3, Result)),
|
||||
?assertEqual({tuple, item}, lists:nth(4, Result)),
|
||||
?assertEqual(make_item_with_id(<<"3">>), lists:nth(5, Result)).
|
||||
|
||||
replace_by_id_integer_id_test() ->
|
||||
Items = [
|
||||
#{<<"id">> => 1, <<"data">> => <<"a">>},
|
||||
#{<<"id">> => 2, <<"data">> => <<"b">>}
|
||||
],
|
||||
NewItem = #{<<"id">> => 2, <<"data">> => <<"updated">>},
|
||||
Result = list_ops:replace_by_id(Items, 2, NewItem),
|
||||
|
||||
?assertEqual(2, length(Result)),
|
||||
?assertEqual(#{<<"id">> => 1, <<"data">> => <<"a">>}, lists:nth(1, Result)),
|
||||
?assertEqual(NewItem, lists:nth(2, Result)).
|
||||
|
||||
replace_by_id_invalid_input_test() ->
|
||||
?assertEqual([], list_ops:replace_by_id(not_a_list, <<"1">>, #{})),
|
||||
?assertEqual([], list_ops:replace_by_id(#{}, <<"1">>, #{})),
|
||||
?assertEqual([], list_ops:replace_by_id(undefined, <<"1">>, #{})).
|
||||
|
||||
replace_by_id_item_without_id_test() ->
|
||||
Items = [
|
||||
#{<<"id">> => <<"1">>},
|
||||
#{<<"name">> => <<"no_id">>},
|
||||
#{<<"id">> => <<"2">>}
|
||||
],
|
||||
NewItem = #{<<"id">> => <<"2">>, <<"updated">> => true},
|
||||
Result = list_ops:replace_by_id(Items, <<"2">>, NewItem),
|
||||
|
||||
?assertEqual(3, length(Result)),
|
||||
?assertEqual(#{<<"id">> => <<"1">>}, lists:nth(1, Result)),
|
||||
?assertEqual(#{<<"name">> => <<"no_id">>}, lists:nth(2, Result)),
|
||||
?assertEqual(NewItem, lists:nth(3, Result)).
|
||||
|
||||
remove_by_id_success_test() ->
|
||||
assert_remove_default_id_two(
|
||||
[
|
||||
make_item_with_id(<<"1">>),
|
||||
make_item_with_id(<<"3">>)
|
||||
]
|
||||
).
|
||||
|
||||
remove_by_id_no_match_test() ->
|
||||
assert_remove_by_id_no_change(<<"99">>).
|
||||
|
||||
remove_by_id_multiple_matches_test() ->
|
||||
Items = [
|
||||
make_item_with_id(<<"1">>),
|
||||
#{<<"id">> => <<"2">>, <<"version">> => 1},
|
||||
#{<<"id">> => <<"2">>, <<"version">> => 2},
|
||||
make_item_with_id(<<"3">>)
|
||||
],
|
||||
Result = list_ops:remove_by_id(Items, <<"2">>),
|
||||
|
||||
?assertEqual(2, length(Result)),
|
||||
?assertEqual(make_item_with_id(<<"1">>), lists:nth(1, Result)),
|
||||
?assertEqual(make_item_with_id(<<"3">>), lists:nth(2, Result)).
|
||||
|
||||
remove_by_id_empty_list_test() ->
|
||||
Result = list_ops:remove_by_id([], <<"1">>),
|
||||
?assertEqual([], Result).
|
||||
|
||||
remove_by_id_mixed_list_test() ->
|
||||
Items = [
|
||||
make_item_with_id(<<"1">>),
|
||||
<<"non_map">>,
|
||||
make_item_with_id(<<"2">>),
|
||||
[list, item],
|
||||
make_item_with_id(<<"3">>)
|
||||
],
|
||||
Result = list_ops:remove_by_id(Items, <<"2">>),
|
||||
|
||||
?assertEqual(4, length(Result)),
|
||||
?assertEqual(make_item_with_id(<<"1">>), lists:nth(1, Result)),
|
||||
?assertEqual(<<"non_map">>, lists:nth(2, Result)),
|
||||
?assertEqual([list, item], lists:nth(3, Result)),
|
||||
?assertEqual(make_item_with_id(<<"3">>), lists:nth(4, Result)).
|
||||
|
||||
remove_by_id_invalid_input_test() ->
|
||||
?assertEqual([], list_ops:remove_by_id(not_a_list, <<"1">>)),
|
||||
?assertEqual([], list_ops:remove_by_id(undefined, <<"1">>)),
|
||||
?assertEqual([], list_ops:remove_by_id(123, <<"1">>)).
|
||||
|
||||
remove_by_id_all_items_match_test() ->
|
||||
Items = [
|
||||
make_item_with_id(<<"1">>),
|
||||
make_item_with_id(<<"1">>),
|
||||
make_item_with_id(<<"1">>)
|
||||
],
|
||||
Result = list_ops:remove_by_id(Items, <<"1">>),
|
||||
?assertEqual([], Result).
|
||||
|
||||
id_items(Ids) ->
|
||||
[make_item_with_id(Id) || Id <- Ids].
|
||||
|
||||
default_id_items() ->
|
||||
id_items([<<"1">>, <<"2">>]).
|
||||
|
||||
default_three_id_items() ->
|
||||
id_items([<<"1">>, <<"2">>, <<"3">>]).
|
||||
|
||||
assert_replace_by_id(Expected, Items, Id, NewItem) ->
|
||||
?assertEqual(Expected, list_ops:replace_by_id(Items, Id, NewItem)).
|
||||
|
||||
assert_replace_default_id_two(Expected, NewItem) ->
|
||||
assert_replace_by_id(Expected, default_three_id_items(), <<"2">>, NewItem).
|
||||
|
||||
assert_replace_by_id_no_change(Id, NewItem) ->
|
||||
Items = default_id_items(),
|
||||
assert_replace_by_id(Items, Items, Id, NewItem).
|
||||
|
||||
assert_remove_by_id(Expected, Items, Id) ->
|
||||
?assertEqual(Expected, list_ops:remove_by_id(Items, Id)).
|
||||
|
||||
assert_remove_default_id_two(Expected) ->
|
||||
assert_remove_by_id(Expected, default_three_id_items(), <<"2">>).
|
||||
|
||||
assert_remove_by_id_no_change(Id) ->
|
||||
Items = default_id_items(),
|
||||
assert_remove_by_id(Items, Items, Id).
|
||||
@@ -1,171 +0,0 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(list_ops_user_tests).
|
||||
-typing([eqwalizer]).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
make_item_with_user_id(UserId) ->
|
||||
#{
|
||||
<<"user">> => #{<<"id">> => integer_to_binary(UserId)},
|
||||
<<"data">> => <<"member">>
|
||||
}.
|
||||
|
||||
replace_by_user_id_success_test() ->
|
||||
NewItem = #{
|
||||
<<"user">> => #{<<"id">> => <<"200">>},
|
||||
<<"data">> => <<"updated">>
|
||||
},
|
||||
assert_replace_by_user_id(
|
||||
[
|
||||
make_item_with_user_id(100),
|
||||
NewItem,
|
||||
make_item_with_user_id(300)
|
||||
],
|
||||
user_items([100, 200, 300]),
|
||||
200,
|
||||
NewItem
|
||||
).
|
||||
|
||||
replace_by_user_id_no_match_test() ->
|
||||
Items = user_items([100, 200]),
|
||||
NewItem = make_item_with_user_id(999),
|
||||
assert_replace_by_user_id(Items, Items, 999, NewItem).
|
||||
|
||||
replace_by_user_id_empty_list_test() ->
|
||||
Result = list_ops:replace_by_user_id([], 100, make_item_with_user_id(100)),
|
||||
?assertEqual([], Result).
|
||||
|
||||
replace_by_user_id_nested_extraction_test() ->
|
||||
Items = [
|
||||
#{
|
||||
<<"user">> => #{<<"id">> => <<"123">>, <<"name">> => <<"alice">>},
|
||||
<<"role">> => <<"admin">>
|
||||
},
|
||||
#{
|
||||
<<"user">> => #{<<"id">> => <<"456">>, <<"name">> => <<"bob">>},
|
||||
<<"role">> => <<"user">>
|
||||
}
|
||||
],
|
||||
NewItem = #{<<"user">> => #{<<"id">> => <<"456">>}, <<"role">> => <<"moderator">>},
|
||||
Result = list_ops:replace_by_user_id(Items, 456, NewItem),
|
||||
|
||||
?assertEqual(2, length(Result)),
|
||||
?assertEqual(lists:nth(1, Items), lists:nth(1, Result)),
|
||||
?assertEqual(NewItem, lists:nth(2, Result)).
|
||||
|
||||
replace_by_user_id_mixed_list_test() ->
|
||||
Items = [
|
||||
make_item_with_user_id(100),
|
||||
<<"string_item">>,
|
||||
make_item_with_user_id(200),
|
||||
{tuple},
|
||||
#{<<"other">> => <<"map">>}
|
||||
],
|
||||
NewItem = make_item_with_user_id(200),
|
||||
Result = list_ops:replace_by_user_id(Items, 200, NewItem),
|
||||
|
||||
?assertEqual(5, length(Result)),
|
||||
?assertEqual(make_item_with_user_id(100), lists:nth(1, Result)),
|
||||
?assertEqual(<<"string_item">>, lists:nth(2, Result)),
|
||||
?assertEqual(NewItem, lists:nth(3, Result)),
|
||||
?assertEqual({tuple}, lists:nth(4, Result)),
|
||||
?assertEqual(#{<<"other">> => <<"map">>}, lists:nth(5, Result)).
|
||||
|
||||
replace_by_user_id_invalid_structure_test() ->
|
||||
Items = [
|
||||
#{<<"user">> => <<"not_a_map">>, <<"data">> => <<"x">>},
|
||||
#{<<"no_user_key">> => <<"y">>},
|
||||
make_item_with_user_id(100)
|
||||
],
|
||||
NewItem = make_item_with_user_id(100),
|
||||
Result = list_ops:replace_by_user_id(Items, 100, NewItem),
|
||||
|
||||
?assertEqual(3, length(Result)),
|
||||
?assertEqual(lists:nth(1, Items), lists:nth(1, Result)),
|
||||
?assertEqual(lists:nth(2, Items), lists:nth(2, Result)),
|
||||
?assertEqual(NewItem, lists:nth(3, Result)).
|
||||
|
||||
replace_by_user_id_invalid_input_test() ->
|
||||
?assertEqual([], list_ops:replace_by_user_id(not_a_list, 100, #{})),
|
||||
?assertEqual([], list_ops:replace_by_user_id(undefined, 100, #{})),
|
||||
?assertEqual(
|
||||
[], list_ops:replace_by_user_id([make_item_with_user_id(100)], invalid_user_id(), #{})
|
||||
).
|
||||
|
||||
remove_by_user_id_success_test() ->
|
||||
assert_remove_by_user_id(
|
||||
[
|
||||
make_item_with_user_id(100),
|
||||
make_item_with_user_id(300)
|
||||
],
|
||||
user_items([100, 200, 300]),
|
||||
200
|
||||
).
|
||||
|
||||
remove_by_user_id_no_match_test() ->
|
||||
Items = user_items([100, 200]),
|
||||
assert_remove_by_user_id(Items, Items, 999).
|
||||
|
||||
remove_by_user_id_multiple_matches_test() ->
|
||||
Items = [
|
||||
make_item_with_user_id(100),
|
||||
#{<<"user">> => #{<<"id">> => <<"200">>}, <<"version">> => 1},
|
||||
#{<<"user">> => #{<<"id">> => <<"200">>}, <<"version">> => 2},
|
||||
make_item_with_user_id(300)
|
||||
],
|
||||
Result = list_ops:remove_by_user_id(Items, 200),
|
||||
|
||||
?assertEqual(2, length(Result)),
|
||||
?assertEqual(make_item_with_user_id(100), lists:nth(1, Result)),
|
||||
?assertEqual(make_item_with_user_id(300), lists:nth(2, Result)).
|
||||
|
||||
remove_by_user_id_empty_list_test() ->
|
||||
Result = list_ops:remove_by_user_id([], 100),
|
||||
?assertEqual([], Result).
|
||||
|
||||
remove_by_user_id_mixed_list_test() ->
|
||||
Items = [
|
||||
make_item_with_user_id(100),
|
||||
<<"non_map">>,
|
||||
make_item_with_user_id(200),
|
||||
[list],
|
||||
#{<<"invalid">> => <<"structure">>}
|
||||
],
|
||||
Result = list_ops:remove_by_user_id(Items, 200),
|
||||
|
||||
?assertEqual(4, length(Result)),
|
||||
?assertEqual(make_item_with_user_id(100), lists:nth(1, Result)),
|
||||
?assertEqual(<<"non_map">>, lists:nth(2, Result)),
|
||||
?assertEqual([list], lists:nth(3, Result)),
|
||||
?assertEqual(#{<<"invalid">> => <<"structure">>}, lists:nth(4, Result)).
|
||||
|
||||
remove_by_user_id_invalid_nested_structure_test() ->
|
||||
Items = [
|
||||
#{<<"user">> => <<"not_a_map">>},
|
||||
#{<<"no_user">> => <<"field">>},
|
||||
#{<<"user">> => #{<<"no_id">> => <<"field">>}},
|
||||
make_item_with_user_id(100)
|
||||
],
|
||||
Result = list_ops:remove_by_user_id(Items, 999),
|
||||
|
||||
?assertEqual(4, length(Result)),
|
||||
?assertEqual(Items, Result).
|
||||
|
||||
remove_by_user_id_invalid_input_test() ->
|
||||
?assertEqual([], list_ops:remove_by_user_id(not_a_list, 100)),
|
||||
?assertEqual([], list_ops:remove_by_user_id(undefined, 100)),
|
||||
?assertEqual(
|
||||
[], list_ops:remove_by_user_id([make_item_with_user_id(100)], invalid_user_id())
|
||||
).
|
||||
|
||||
invalid_user_id() ->
|
||||
eqwalizer:dynamic_cast(<<"not_integer">>).
|
||||
|
||||
user_items(UserIds) ->
|
||||
[make_item_with_user_id(UserId) || UserId <- UserIds].
|
||||
|
||||
assert_replace_by_user_id(Expected, Items, UserId, NewItem) ->
|
||||
?assertEqual(Expected, list_ops:replace_by_user_id(Items, UserId, NewItem)).
|
||||
|
||||
assert_remove_by_user_id(Expected, Items, UserId) ->
|
||||
?assertEqual(Expected, list_ops:remove_by_user_id(Items, UserId)).
|
||||
Reference in New Issue
Block a user