%% Copyright (C) 2026 Fluxer Contributors %% %% This file is part of Fluxer. %% %% Fluxer is free software: you can redistribute it and/or modify %% it under the terms of the GNU Affero General Public License as published by %% the Free Software Foundation, either version 3 of the License, or %% (at your option) any later version. %% %% Fluxer is distributed in the hope that it will be useful, %% but WITHOUT ANY WARRANTY; without even the implied warranty of %% MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the %% GNU Affero General Public License for more details. %% %% You should have received a copy of the GNU Affero General Public License %% along with Fluxer. If not, see . -module(guild_request_members). -export([ handle_request/3 ]). -define(CHUNK_SIZE, 1000). -define(MAX_USER_IDS, 100). -define(MAX_NONCE_LENGTH, 32). -define(FULL_MEMBER_LIST_LIMIT, 100000). -define(DEFAULT_QUERY_LIMIT, 25). -define(MAX_MEMBER_QUERY_LIMIT, 100). -define(REQUEST_MEMBERS_RATE_LIMIT_TABLE, guild_request_members_rate_limit). -define(REQUEST_MEMBERS_RATE_LIMIT_WINDOW_MS, 10000). -define(REQUEST_MEMBERS_RATE_LIMIT_MAX_EVENTS, 5). -define(REQUEST_MEMBERS_GUILD_RATE_LIMIT_TABLE, guild_request_members_guild_rate_limit). -define(REQUEST_MEMBERS_GUILD_RATE_LIMIT_WINDOW_MS, 10000). -define(REQUEST_MEMBERS_GUILD_RATE_LIMIT_MAX_EVENTS, 25). -type session_state() :: map(). -type request_data() :: map(). -type member() :: map(). -type presence() :: map(). -ifdef(TEST). -include_lib("eunit/include/eunit.hrl"). -endif. -spec handle_request(request_data(), pid(), session_state()) -> ok | {error, atom()}. handle_request(Data, SocketPid, SessionState) when is_map(Data), is_pid(SocketPid) -> case parse_request(Data) of {ok, Request} -> process_request(Request, SocketPid, SessionState); {error, Reason} -> {error, Reason} end; handle_request(_, _, _) -> {error, invalid_request}. -spec parse_request(request_data()) -> {ok, map()} | {error, atom()}. parse_request(Data) -> GuildIdRaw = maps:get(<<"guild_id">>, Data, undefined), Query = maps:get(<<"query">>, Data, <<>>), Limit = maps:get(<<"limit">>, Data, 0), UserIdsRaw = maps:get(<<"user_ids">>, Data, []), Presences = maps:get(<<"presences">>, Data, false), Nonce = maps:get(<<"nonce">>, Data, null), NormalizedNonce = normalize_nonce(Nonce), case validate_guild_id(GuildIdRaw) of {ok, GuildId} -> case validate_user_ids(UserIdsRaw) of {ok, UserIds} -> {ok, #{ guild_id => GuildId, query => ensure_binary(Query), limit => ensure_limit(Limit), user_ids => UserIds, presences => Presences =:= true, nonce => NormalizedNonce }}; {error, Reason} -> {error, Reason} end; {error, Reason} -> {error, Reason} end. -spec validate_guild_id(term()) -> {ok, integer()} | {error, atom()}. validate_guild_id(GuildId) when is_integer(GuildId), GuildId > 0 -> {ok, GuildId}; validate_guild_id(GuildId) when is_binary(GuildId) -> case validation:validate_snowflake(<<"guild_id">>, GuildId) of {ok, Id} -> {ok, Id}; {error, _, _} -> {error, invalid_guild_id} end; validate_guild_id(_) -> {error, invalid_guild_id}. -spec validate_user_ids(term()) -> {ok, [integer()]} | {error, atom()}. validate_user_ids(UserIds) when is_list(UserIds) -> case length(UserIds) > ?MAX_USER_IDS of true -> {error, too_many_user_ids}; false -> ParsedIds = lists:filtermap( fun(Id) -> case parse_user_id(Id) of {ok, ParsedId} -> {true, ParsedId}; error -> false end end, UserIds ), {ok, ParsedIds} end; validate_user_ids(_) -> {ok, []}. -spec parse_user_id(term()) -> {ok, integer()} | error. parse_user_id(Id) when is_integer(Id), Id > 0 -> {ok, Id}; parse_user_id(Id) when is_binary(Id) -> case type_conv:to_integer(Id) of undefined -> error; ParsedId when ParsedId > 0 -> {ok, ParsedId}; _ -> error end; parse_user_id(_) -> error. -spec ensure_binary(term()) -> binary(). ensure_binary(Value) when is_binary(Value) -> Value; ensure_binary(_) -> <<>>. -spec ensure_limit(term()) -> non_neg_integer(). ensure_limit(Limit) when is_integer(Limit), Limit >= 0 -> min(Limit, ?MAX_MEMBER_QUERY_LIMIT); ensure_limit(_) -> 0. -spec normalize_nonce(term()) -> binary() | null. normalize_nonce(Nonce) when is_binary(Nonce), byte_size(Nonce) =< ?MAX_NONCE_LENGTH -> Nonce; normalize_nonce(_) -> null. -spec process_request(map(), pid(), session_state()) -> ok | {error, atom()}. process_request(Request, SocketPid, SessionState) -> #{guild_id := GuildId, query := Query, limit := Limit, user_ids := UserIds} = Request, UserIdBin = maps:get(user_id, SessionState), UserId = type_conv:to_integer(UserIdBin), case check_request_rate_limit(UserId) of ok -> case check_guild_request_rate_limit(GuildId) of ok -> case check_permission(UserId, GuildId, Query, Limit, UserIds, SessionState) of ok -> fetch_and_send_members(Request, SocketPid, SessionState); {error, Reason} -> {error, Reason} end; {error, Reason} -> {error, Reason} end; {error, Reason} -> {error, Reason} end. -spec check_request_rate_limit(integer() | undefined) -> ok | {error, atom()}. check_request_rate_limit(UserId) when is_integer(UserId), UserId > 0 -> ensure_request_rate_limit_table(), Now = erlang:system_time(millisecond), case ets:lookup(?REQUEST_MEMBERS_RATE_LIMIT_TABLE, UserId) of [] -> ets:insert(?REQUEST_MEMBERS_RATE_LIMIT_TABLE, {UserId, [Now]}), ok; [{UserId, Timestamps}] -> RecentTimestamps = [T || T <- Timestamps, (Now - T) < ?REQUEST_MEMBERS_RATE_LIMIT_WINDOW_MS], case length(RecentTimestamps) >= ?REQUEST_MEMBERS_RATE_LIMIT_MAX_EVENTS of true -> {error, rate_limited}; false -> ets:insert(?REQUEST_MEMBERS_RATE_LIMIT_TABLE, {UserId, [Now | RecentTimestamps]}), ok end end; check_request_rate_limit(_) -> {error, invalid_session}. -spec check_guild_request_rate_limit(integer()) -> ok | {error, atom()}. check_guild_request_rate_limit(GuildId) when is_integer(GuildId), GuildId > 0 -> ensure_guild_request_rate_limit_table(), Now = erlang:system_time(millisecond), case ets:lookup(?REQUEST_MEMBERS_GUILD_RATE_LIMIT_TABLE, GuildId) of [] -> ets:insert(?REQUEST_MEMBERS_GUILD_RATE_LIMIT_TABLE, {GuildId, [Now]}), ok; [{GuildId, Timestamps}] -> RecentTimestamps = [T || T <- Timestamps, (Now - T) < ?REQUEST_MEMBERS_GUILD_RATE_LIMIT_WINDOW_MS], case length(RecentTimestamps) >= ?REQUEST_MEMBERS_GUILD_RATE_LIMIT_MAX_EVENTS of true -> {error, rate_limited}; false -> ets:insert( ?REQUEST_MEMBERS_GUILD_RATE_LIMIT_TABLE, {GuildId, [Now | RecentTimestamps]} ), ok end end; check_guild_request_rate_limit(_) -> {error, invalid_guild_id}. -spec ensure_request_rate_limit_table() -> ok. ensure_request_rate_limit_table() -> case ets:whereis(?REQUEST_MEMBERS_RATE_LIMIT_TABLE) of undefined -> try ets:new(?REQUEST_MEMBERS_RATE_LIMIT_TABLE, [named_table, public, set]), ok catch error:badarg -> ok end; _ -> ok end. -spec ensure_guild_request_rate_limit_table() -> ok. ensure_guild_request_rate_limit_table() -> case ets:whereis(?REQUEST_MEMBERS_GUILD_RATE_LIMIT_TABLE) of undefined -> try ets:new(?REQUEST_MEMBERS_GUILD_RATE_LIMIT_TABLE, [named_table, public, set]), ok catch error:badarg -> ok end; _ -> ok end. -spec check_permission( integer(), integer(), binary(), non_neg_integer(), [integer()], session_state() ) -> ok | {error, atom()}. check_permission(UserId, GuildId, Query, Limit, UserIds, SessionState) -> RequiresPermission = Query =:= <<>> andalso Limit =:= 0 andalso UserIds =:= [], case RequiresPermission of false -> ok; true -> case lookup_guild(GuildId, SessionState) of {ok, GuildPid} -> check_management_permission(UserId, GuildId, GuildPid); {error, _} -> {error, guild_not_found} end end. -spec check_management_permission(integer(), integer(), pid()) -> ok | {error, atom()}. check_management_permission(UserId, _GuildId, GuildPid) -> ManageRoles = constants:manage_roles_permission(), KickMembers = constants:kick_members_permission(), BanMembers = constants:ban_members_permission(), RequiredPermission = ManageRoles bor KickMembers bor BanMembers, PermRequest = #{ user_id => UserId, permission => RequiredPermission, channel_id => undefined }, case gen_server:call(GuildPid, {check_permission, PermRequest}, 5000) of #{has_permission := true} -> ok; #{has_permission := false} -> {error, missing_permission}; _ -> {error, permission_check_failed} end. -spec lookup_guild(integer(), session_state()) -> {ok, pid()} | {error, not_found}. lookup_guild(GuildId, SessionState) -> Guilds = maps:get(guilds, SessionState, #{}), case maps:get(GuildId, Guilds, undefined) of {Pid, _Ref} when is_pid(Pid) -> {ok, Pid}; undefined -> case guild_manager:lookup(GuildId) of {ok, Pid} when is_pid(Pid) -> {ok, Pid}; _ -> {error, not_found} end; _ -> {error, not_found} end. -spec fetch_and_send_members(map(), pid(), session_state()) -> ok | {error, atom()}. fetch_and_send_members(Request, _SocketPid, SessionState) -> #{ guild_id := GuildId, query := Query, limit := Limit, user_ids := UserIds, presences := Presences, nonce := Nonce } = Request, SessionId = maps:get(session_id, SessionState), case lookup_guild(GuildId, SessionState) of {ok, GuildPid} -> Members = fetch_members(GuildPid, Query, Limit, UserIds), PresencesList = maybe_fetch_presences(Presences, GuildPid, Members), send_member_chunks(GuildPid, SessionId, Members, PresencesList, Nonce), ok; {error, Reason} -> {error, Reason} end. -spec fetch_members(pid(), binary(), non_neg_integer(), [integer()]) -> [member()]. fetch_members(GuildPid, _Query, _Limit, UserIds) when UserIds =/= [] -> fetch_members_by_user_ids(GuildPid, UserIds); fetch_members(GuildPid, Query, Limit, []) -> ActualLimit = resolve_member_limit(Query, Limit), case gen_server:call(GuildPid, {list_guild_members, #{limit => ActualLimit, offset => 0}}, 10000) of #{members := AllMembers} -> case Query of <<>> -> lists:sublist(AllMembers, ActualLimit); _ -> filter_members_by_query(AllMembers, Query, ActualLimit) end; _ -> [] end. -spec fetch_members_by_user_ids(pid(), [integer()]) -> [member()]. fetch_members_by_user_ids(GuildPid, UserIds) -> lists:filtermap( fun(UserId) -> try case gen_server:call(GuildPid, {get_guild_member, #{user_id => UserId}}, 5000) of #{success := true, member_data := Member} when is_map(Member) -> {true, Member}; _ -> false end catch exit:_ -> false end end, lists:usort(UserIds) ). -spec resolve_member_limit(binary(), non_neg_integer()) -> pos_integer(). resolve_member_limit(<<>>, 0) -> ?FULL_MEMBER_LIST_LIMIT; resolve_member_limit(_Query, 0) -> ?DEFAULT_QUERY_LIMIT; resolve_member_limit(_Query, Limit) -> Limit. -spec filter_members_by_query([member()], binary(), non_neg_integer()) -> [member()]. filter_members_by_query(Members, Query, Limit) -> NormalizedQuery = string:lowercase(binary_to_list(Query)), Matches = lists:filter( fun(Member) -> DisplayName = get_display_name(Member), NormalizedName = string:lowercase(binary_to_list(DisplayName)), lists:prefix(NormalizedQuery, NormalizedName) end, Members ), lists:sublist(Matches, Limit). -spec get_display_name(member()) -> binary(). get_display_name(Member) when is_map(Member) -> Nick = maps:get(<<"nick">>, Member, undefined), case Nick of undefined -> get_fallback_name(Member); null -> get_fallback_name(Member); _ when is_binary(Nick) -> Nick; _ -> get_fallback_name(Member) end; get_display_name(_) -> <<>>. -spec get_fallback_name(member()) -> binary(). get_fallback_name(Member) -> User = maps:get(<<"user">>, Member, #{}), GlobalName = maps:get(<<"global_name">>, User, undefined), case GlobalName of undefined -> get_username(User); null -> get_username(User); _ when is_binary(GlobalName) -> GlobalName; _ -> get_username(User) end. -spec get_username(map()) -> binary(). get_username(User) -> Username = maps:get(<<"username">>, User, <<>>), case Username of null -> <<>>; undefined -> <<>>; _ when is_binary(Username) -> Username; _ -> <<>> end. -spec extract_user_id(member()) -> integer() | undefined. extract_user_id(Member) when is_map(Member) -> User = maps:get(<<"user">>, Member, #{}), map_utils:get_integer(User, <<"id">>, undefined); extract_user_id(_) -> undefined. -spec maybe_fetch_presences(boolean(), pid(), [member()]) -> [presence()]. maybe_fetch_presences(false, _GuildPid, _Members) -> []; maybe_fetch_presences(true, _GuildPid, Members) -> UserIds = lists:filtermap( fun(Member) -> case extract_user_id(Member) of undefined -> false; UserId -> {true, UserId} end end, Members ), case UserIds of [] -> []; _ -> Cached = presence_cache:bulk_get(UserIds), [P || P <- Cached, presence_visible(P)] end. -spec presence_visible(presence()) -> boolean(). presence_visible(P) -> Status = maps:get(<<"status">>, P, <<"offline">>), Status =/= <<"offline">> andalso Status =/= <<"invisible">>. -spec send_member_chunks(pid(), binary(), [member()], [presence()], term()) -> ok. send_member_chunks(GuildPid, SessionId, Members, Presences, Nonce) -> TotalChunks = max(1, (length(Members) + ?CHUNK_SIZE - 1) div ?CHUNK_SIZE), MemberChunks = chunk_list(Members, ?CHUNK_SIZE), PresenceChunks = chunk_presences(Presences, MemberChunks), lists:foldl( fun({MemberChunk, PresenceChunk}, ChunkIndex) -> ChunkData = build_chunk_data( MemberChunk, PresenceChunk, ChunkIndex, TotalChunks, Nonce ), gen_server:cast(GuildPid, {send_members_chunk, SessionId, ChunkData}), ChunkIndex + 1 end, 0, lists:zip(MemberChunks, PresenceChunks) ), ok. -spec build_chunk_data([member()], [presence()], non_neg_integer(), non_neg_integer(), term()) -> map(). build_chunk_data(Members, Presences, ChunkIndex, TotalChunks, Nonce) -> Base = #{ <<"members">> => Members, <<"chunk_index">> => ChunkIndex, <<"chunk_count">> => TotalChunks }, WithPresences = case Presences of [] -> Base; _ -> maps:put(<<"presences">>, Presences, Base) end, case Nonce of null -> WithPresences; _ -> maps:put(<<"nonce">>, Nonce, WithPresences) end. -spec chunk_list([T], pos_integer()) -> [[T]] when T :: term(). chunk_list([], _Size) -> [[]]; chunk_list(List, Size) -> chunk_list(List, Size, []). -spec chunk_list([T], pos_integer(), [[T]]) -> [[T]] when T :: term(). chunk_list([], _Size, Acc) -> lists:reverse(Acc); chunk_list(List, Size, Acc) -> {Chunk, Rest} = lists:split(min(Size, length(List)), List), chunk_list(Rest, Size, [Chunk | Acc]). -spec chunk_presences([presence()], [[member()]]) -> [[presence()]]. chunk_presences(Presences, MemberChunks) -> lists:map( fun(MemberChunk) -> ChunkUserIds = sets:from_list([extract_user_id(M) || M <- MemberChunk]), lists:filter( fun(Presence) -> User = maps:get(<<"user">>, Presence, #{}), UserId = map_utils:get_integer(User, <<"id">>, undefined), UserId =/= undefined andalso sets:is_element(UserId, ChunkUserIds) end, Presences ) end, MemberChunks ). -ifdef(TEST). parse_request_valid_test() -> Data = #{ <<"guild_id">> => <<"123456789">>, <<"query">> => <<"test">>, <<"limit">> => 10, <<"presences">> => true, <<"nonce">> => <<"abc123">> }, {ok, Request} = parse_request(Data), ?assertEqual(123456789, maps:get(guild_id, Request)), ?assertEqual(<<"test">>, maps:get(query, Request)), ?assertEqual(10, maps:get(limit, Request)), ?assertEqual(true, maps:get(presences, Request)), ?assertEqual(<<"abc123">>, maps:get(nonce, Request)). parse_request_with_user_ids_test() -> Data = #{ <<"guild_id">> => <<"123">>, <<"user_ids">> => [<<"1">>, <<"2">>, <<"3">>] }, {ok, Request} = parse_request(Data), ?assertEqual([1, 2, 3], maps:get(user_ids, Request)). parse_request_invalid_guild_id_test() -> Data = #{<<"guild_id">> => <<"invalid">>}, {error, invalid_guild_id} = parse_request(Data). chunk_list_test() -> ?assertEqual([[1, 2], [3, 4], [5]], chunk_list([1, 2, 3, 4, 5], 2)), ?assertEqual([[1, 2, 3]], chunk_list([1, 2, 3], 5)), ?assertEqual([[]], chunk_list([], 5)). filter_members_by_query_test() -> Members = [ #{<<"user">> => #{<<"id">> => <<"1">>, <<"username">> => <<"alice">>}}, #{<<"user">> => #{<<"id">> => <<"2">>, <<"username">> => <<"bob">>}}, #{<<"user">> => #{<<"id">> => <<"3">>, <<"username">> => <<"alicia">>}} ], Results = filter_members_by_query(Members, <<"ali">>, 10), ?assertEqual(2, length(Results)). display_name_priority_test() -> MemberWithNick = #{ <<"user">> => #{<<"username">> => <<"user">>, <<"global_name">> => <<"Global">>}, <<"nick">> => <<"Nick">> }, ?assertEqual(<<"Nick">>, get_display_name(MemberWithNick)), MemberWithGlobal = #{ <<"user">> => #{<<"username">> => <<"user">>, <<"global_name">> => <<"Global">>} }, ?assertEqual(<<"Global">>, get_display_name(MemberWithGlobal)), MemberWithUsername = #{ <<"user">> => #{<<"username">> => <<"user">>} }, ?assertEqual(<<"user">>, get_display_name(MemberWithUsername)). normalize_nonce_test() -> ?assertEqual(<<"abc">>, normalize_nonce(<<"abc">>)), ?assertEqual(null, normalize_nonce(<<"this_nonce_is_way_too_long_to_be_valid">>)), ?assertEqual(null, normalize_nonce(undefined)). validate_user_ids_too_many_test() -> UserIds = lists:seq(1, 101), ?assertEqual({error, too_many_user_ids}, validate_user_ids(UserIds)). validate_user_ids_exactly_max_test() -> UserIds = lists:seq(1, 100), {ok, Parsed} = validate_user_ids(UserIds), ?assertEqual(100, length(Parsed)). validate_user_ids_non_list_test() -> {ok, []} = validate_user_ids(not_a_list). validate_user_ids_filters_invalid_test() -> {ok, Parsed} = validate_user_ids([<<"1">>, <<"invalid">>, 3, -5, 0]), ?assertEqual([1, 3], Parsed). validate_user_ids_empty_test() -> {ok, []} = validate_user_ids([]). parse_user_id_integer_test() -> ?assertEqual({ok, 42}, parse_user_id(42)). parse_user_id_binary_test() -> ?assertEqual({ok, 123}, parse_user_id(<<"123">>)). parse_user_id_zero_test() -> ?assertEqual(error, parse_user_id(0)). parse_user_id_negative_test() -> ?assertEqual(error, parse_user_id(-1)). parse_user_id_invalid_binary_test() -> ?assertEqual(error, parse_user_id(<<"abc">>)). parse_user_id_other_type_test() -> ?assertEqual(error, parse_user_id(1.5)). ensure_binary_binary_test() -> ?assertEqual(<<"hello">>, ensure_binary(<<"hello">>)). ensure_binary_integer_test() -> ?assertEqual(<<>>, ensure_binary(42)). ensure_binary_undefined_test() -> ?assertEqual(<<>>, ensure_binary(undefined)). ensure_limit_valid_test() -> ?assertEqual(10, ensure_limit(10)). ensure_limit_zero_test() -> ?assertEqual(0, ensure_limit(0)). ensure_limit_negative_test() -> ?assertEqual(0, ensure_limit(-1)). ensure_limit_non_integer_test() -> ?assertEqual(0, ensure_limit(<<"10">>)). ensure_limit_clamped_test() -> ?assertEqual(?MAX_MEMBER_QUERY_LIMIT, ensure_limit(?MAX_MEMBER_QUERY_LIMIT + 1)). resolve_member_limit_full_scan_test() -> ?assertEqual(?FULL_MEMBER_LIST_LIMIT, resolve_member_limit(<<>>, 0)). resolve_member_limit_query_default_test() -> ?assertEqual(?DEFAULT_QUERY_LIMIT, resolve_member_limit(<<"ab">>, 0)). resolve_member_limit_explicit_test() -> ?assertEqual(25, resolve_member_limit(<<"ab">>, 25)). check_request_rate_limit_allows_initial_request_test() -> UserId = 987654321, clear_request_rate_limit(UserId), ?assertEqual(ok, check_request_rate_limit(UserId)), clear_request_rate_limit(UserId). check_request_rate_limit_blocks_burst_test() -> UserId = 987654322, clear_request_rate_limit(UserId), ensure_request_rate_limit_table(), Now = erlang:system_time(millisecond), Timestamps = lists:duplicate(?REQUEST_MEMBERS_RATE_LIMIT_MAX_EVENTS, Now - 1000), ets:insert(?REQUEST_MEMBERS_RATE_LIMIT_TABLE, {UserId, Timestamps}), ?assertEqual({error, rate_limited}, check_request_rate_limit(UserId)), clear_request_rate_limit(UserId). check_request_rate_limit_invalid_user_test() -> ?assertEqual({error, invalid_session}, check_request_rate_limit(undefined)). check_guild_request_rate_limit_allows_initial_request_test() -> GuildId = 87654321, clear_guild_request_rate_limit(GuildId), ?assertEqual(ok, check_guild_request_rate_limit(GuildId)), clear_guild_request_rate_limit(GuildId). check_guild_request_rate_limit_blocks_burst_test() -> GuildId = 87654322, clear_guild_request_rate_limit(GuildId), ensure_guild_request_rate_limit_table(), Now = erlang:system_time(millisecond), Timestamps = lists:duplicate(?REQUEST_MEMBERS_GUILD_RATE_LIMIT_MAX_EVENTS, Now - 1000), ets:insert(?REQUEST_MEMBERS_GUILD_RATE_LIMIT_TABLE, {GuildId, Timestamps}), ?assertEqual({error, rate_limited}, check_guild_request_rate_limit(GuildId)), clear_guild_request_rate_limit(GuildId). check_guild_request_rate_limit_invalid_guild_test() -> ?assertEqual({error, invalid_guild_id}, check_guild_request_rate_limit(undefined)). clear_request_rate_limit(UserId) -> ensure_request_rate_limit_table(), ets:delete(?REQUEST_MEMBERS_RATE_LIMIT_TABLE, UserId). clear_guild_request_rate_limit(GuildId) -> ensure_guild_request_rate_limit_table(), ets:delete(?REQUEST_MEMBERS_GUILD_RATE_LIMIT_TABLE, GuildId). validate_guild_id_integer_test() -> ?assertEqual({ok, 123}, validate_guild_id(123)). validate_guild_id_zero_test() -> ?assertEqual({error, invalid_guild_id}, validate_guild_id(0)). validate_guild_id_negative_test() -> ?assertEqual({error, invalid_guild_id}, validate_guild_id(-1)). validate_guild_id_atom_test() -> ?assertEqual({error, invalid_guild_id}, validate_guild_id(undefined)). build_chunk_data_basic_test() -> Members = [#{<<"user">> => #{<<"id">> => <<"1">>}}], Result = build_chunk_data(Members, [], 0, 1, null), ?assertEqual(Members, maps:get(<<"members">>, Result)), ?assertEqual(0, maps:get(<<"chunk_index">>, Result)), ?assertEqual(1, maps:get(<<"chunk_count">>, Result)), ?assertNot(maps:is_key(<<"presences">>, Result)), ?assertNot(maps:is_key(<<"nonce">>, Result)). build_chunk_data_with_presences_test() -> Members = [#{<<"user">> => #{<<"id">> => <<"1">>}}], Presences = [#{<<"user">> => #{<<"id">> => <<"1">>}, <<"status">> => <<"online">>}], Result = build_chunk_data(Members, Presences, 0, 1, null), ?assertEqual(Presences, maps:get(<<"presences">>, Result)), ?assertNot(maps:is_key(<<"nonce">>, Result)). build_chunk_data_with_nonce_test() -> Members = [], Result = build_chunk_data(Members, [], 0, 1, <<"my_nonce">>), ?assertEqual(<<"my_nonce">>, maps:get(<<"nonce">>, Result)). build_chunk_data_with_presences_and_nonce_test() -> Members = [#{<<"user">> => #{<<"id">> => <<"1">>}}], Presences = [#{<<"user">> => #{<<"id">> => <<"1">>}, <<"status">> => <<"online">>}], Result = build_chunk_data(Members, Presences, 2, 5, <<"nonce1">>), ?assertEqual(Presences, maps:get(<<"presences">>, Result)), ?assertEqual(<<"nonce1">>, maps:get(<<"nonce">>, Result)), ?assertEqual(2, maps:get(<<"chunk_index">>, Result)), ?assertEqual(5, maps:get(<<"chunk_count">>, Result)). chunk_presences_aligns_with_member_chunks_test() -> Members1 = [ #{<<"user">> => #{<<"id">> => <<"1">>}}, #{<<"user">> => #{<<"id">> => <<"2">>}} ], Members2 = [ #{<<"user">> => #{<<"id">> => <<"3">>}} ], Presences = [ #{<<"user">> => #{<<"id">> => <<"1">>}, <<"status">> => <<"online">>}, #{<<"user">> => #{<<"id">> => <<"3">>}, <<"status">> => <<"idle">>} ], Result = chunk_presences(Presences, [Members1, Members2]), ?assertEqual(2, length(Result)), [P1, P2] = Result, ?assertEqual(1, length(P1)), ?assertEqual(1, length(P2)). chunk_presences_empty_presences_test() -> Members = [#{<<"user">> => #{<<"id">> => <<"1">>}}], Result = chunk_presences([], [Members]), ?assertEqual([[]], Result). chunk_presences_no_matching_presences_test() -> Members = [#{<<"user">> => #{<<"id">> => <<"1">>}}], Presences = [#{<<"user">> => #{<<"id">> => <<"999">>}, <<"status">> => <<"online">>}], Result = chunk_presences(Presences, [Members]), ?assertEqual([[]], Result). filter_members_by_query_case_insensitive_test() -> Members = [ #{<<"user">> => #{<<"id">> => <<"1">>, <<"username">> => <<"Alice">>}}, #{<<"user">> => #{<<"id">> => <<"2">>, <<"username">> => <<"bob">>}} ], Results = filter_members_by_query(Members, <<"ALICE">>, 10), ?assertEqual(1, length(Results)). filter_members_by_query_respects_limit_test() -> Members = [ #{<<"user">> => #{<<"id">> => <<"1">>, <<"username">> => <<"alice1">>}}, #{<<"user">> => #{<<"id">> => <<"2">>, <<"username">> => <<"alice2">>}}, #{<<"user">> => #{<<"id">> => <<"3">>, <<"username">> => <<"alice3">>}} ], Results = filter_members_by_query(Members, <<"alice">>, 2), ?assertEqual(2, length(Results)). filter_members_by_query_empty_query_matches_all_test() -> Members = [ #{<<"user">> => #{<<"id">> => <<"1">>, <<"username">> => <<"alice">>}}, #{<<"user">> => #{<<"id">> => <<"2">>, <<"username">> => <<"bob">>}} ], Results = filter_members_by_query(Members, <<>>, 10), ?assertEqual(2, length(Results)). filter_members_by_query_no_match_test() -> Members = [ #{<<"user">> => #{<<"id">> => <<"1">>, <<"username">> => <<"alice">>}} ], Results = filter_members_by_query(Members, <<"zzz">>, 10), ?assertEqual(0, length(Results)). filter_members_by_query_matches_nick_test() -> Members = [ #{ <<"user">> => #{<<"id">> => <<"1">>, <<"username">> => <<"alice">>}, <<"nick">> => <<"SuperNick">> } ], Results = filter_members_by_query(Members, <<"super">>, 10), ?assertEqual(1, length(Results)). get_display_name_null_nick_test() -> Member = #{ <<"user">> => #{<<"username">> => <<"user">>}, <<"nick">> => null }, ?assertEqual(<<"user">>, get_display_name(Member)). get_display_name_non_binary_nick_test() -> Member = #{ <<"user">> => #{<<"username">> => <<"user">>}, <<"nick">> => 12345 }, ?assertEqual(<<"user">>, get_display_name(Member)). get_display_name_non_map_test() -> ?assertEqual(<<>>, get_display_name(not_a_map)). get_display_name_null_global_name_test() -> Member = #{<<"user">> => #{<<"username">> => <<"user">>, <<"global_name">> => null}}, ?assertEqual(<<"user">>, get_display_name(Member)). get_display_name_non_binary_global_name_test() -> Member = #{<<"user">> => #{<<"username">> => <<"user">>, <<"global_name">> => 12345}}, ?assertEqual(<<"user">>, get_display_name(Member)). get_username_null_test() -> ?assertEqual(<<>>, get_username(#{<<"username">> => null})). get_username_undefined_test() -> ?assertEqual(<<>>, get_username(#{<<"username">> => undefined})). get_username_non_binary_test() -> ?assertEqual(<<>>, get_username(#{<<"username">> => 12345})). get_username_missing_test() -> ?assertEqual(<<>>, get_username(#{})). extract_user_id_valid_test() -> ?assertEqual(42, extract_user_id(#{<<"user">> => #{<<"id">> => <<"42">>}})). extract_user_id_missing_user_test() -> ?assertEqual(undefined, extract_user_id(#{})). extract_user_id_non_map_test() -> ?assertEqual(undefined, extract_user_id(not_a_map)). presence_visible_online_test() -> ?assertEqual(true, presence_visible(#{<<"status">> => <<"online">>})). presence_visible_idle_test() -> ?assertEqual(true, presence_visible(#{<<"status">> => <<"idle">>})). presence_visible_dnd_test() -> ?assertEqual(true, presence_visible(#{<<"status">> => <<"dnd">>})). presence_visible_offline_test() -> ?assertEqual(false, presence_visible(#{<<"status">> => <<"offline">>})). presence_visible_invisible_test() -> ?assertEqual(false, presence_visible(#{<<"status">> => <<"invisible">>})). presence_visible_missing_status_test() -> ?assertEqual(false, presence_visible(#{})). normalize_nonce_exactly_max_length_test() -> Nonce = list_to_binary(lists:duplicate(32, $a)), ?assertEqual(Nonce, normalize_nonce(Nonce)). normalize_nonce_one_over_max_test() -> Nonce = list_to_binary(lists:duplicate(33, $a)), ?assertEqual(null, normalize_nonce(Nonce)). normalize_nonce_empty_binary_test() -> ?assertEqual(<<>>, normalize_nonce(<<>>)). normalize_nonce_integer_test() -> ?assertEqual(null, normalize_nonce(42)). normalize_nonce_null_atom_test() -> ?assertEqual(null, normalize_nonce(null)). parse_request_defaults_test() -> Data = #{<<"guild_id">> => 12345}, {ok, Request} = parse_request(Data), ?assertEqual(12345, maps:get(guild_id, Request)), ?assertEqual(<<>>, maps:get(query, Request)), ?assertEqual(0, maps:get(limit, Request)), ?assertEqual([], maps:get(user_ids, Request)), ?assertEqual(false, maps:get(presences, Request)), ?assertEqual(null, maps:get(nonce, Request)). parse_request_non_binary_query_test() -> Data = #{<<"guild_id">> => 123, <<"query">> => 42}, {ok, Request} = parse_request(Data), ?assertEqual(<<>>, maps:get(query, Request)). parse_request_negative_limit_test() -> Data = #{<<"guild_id">> => 123, <<"limit">> => -5}, {ok, Request} = parse_request(Data), ?assertEqual(0, maps:get(limit, Request)). parse_request_presences_not_true_test() -> Data = #{<<"guild_id">> => 123, <<"presences">> => <<"yes">>}, {ok, Request} = parse_request(Data), ?assertEqual(false, maps:get(presences, Request)). parse_request_missing_guild_id_test() -> Data = #{<<"query">> => <<"test">>}, ?assertEqual({error, invalid_guild_id}, parse_request(Data)). handle_request_invalid_data_test() -> ?assertEqual({error, invalid_request}, handle_request(not_a_map, self(), #{})). chunk_list_single_element_test() -> ?assertEqual([[1]], chunk_list([1], 1)). chunk_list_exact_multiple_test() -> ?assertEqual([[1, 2], [3, 4]], chunk_list([1, 2, 3, 4], 2)). chunk_list_large_size_test() -> ?assertEqual([[1, 2, 3]], chunk_list([1, 2, 3], 1000)). -endif.