mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 13:05:32 +09:00
feat(voice): add opt-in peer-to-peer calls and country rollouts (#3367)
This commit is contained in:
1 parent
e5a568acca
commit
bef5e33eec
215 files changed
+15840
-2693
No files matched your search
@@ -0,0 +1,697 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(call_p2p_tests).
|
||||
-typing([eqwalizer]).
|
||||
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
agreed_joiners_form_a_mesh_without_pending_connections_test() ->
|
||||
with_stubs(fun() ->
|
||||
State = join_all([{1, true}, {2, true}], call_state()),
|
||||
?assertEqual([true, true], p2p_flags(State)),
|
||||
?assertEqual(#{}, maps:get(pending_connections, State)),
|
||||
?assertEqual([], drain_voice_server_updates())
|
||||
end).
|
||||
|
||||
sfu_join_keeps_its_pending_connection_test() ->
|
||||
with_stubs(fun() ->
|
||||
State = join_all([{1, false}], call_state()),
|
||||
?assert(maps:is_key(connection_id(1), maps:get(pending_connections, State)))
|
||||
end).
|
||||
|
||||
race_sfu_join_after_p2p_start_is_rejected_test() ->
|
||||
with_stubs(fun() ->
|
||||
State = join_all([{1, true}], call_state()),
|
||||
?assertEqual(
|
||||
{reply, {error, voice_p2p_consent_required}, State}, join(2, false, State)
|
||||
),
|
||||
?assertEqual([], drain_voice_server_updates())
|
||||
end).
|
||||
|
||||
race_p2p_join_after_sfu_start_is_rejected_test() ->
|
||||
with_stubs(fun() ->
|
||||
State = join_all([{2, false}], call_state()),
|
||||
?assertEqual(
|
||||
{reply, {error, voice_p2p_consent_required}, State}, join(1, true, State)
|
||||
),
|
||||
?assertEqual([], drain_voice_server_updates())
|
||||
end).
|
||||
|
||||
race_async_join_producing_mixed_states_is_rejected_test() ->
|
||||
with_stubs(fun() ->
|
||||
State = join_all([{1, true}], call_state()),
|
||||
?assertEqual(
|
||||
{noreply, State},
|
||||
call_voice:handle_join_async(
|
||||
2, voice_state(2, false), session_id(2), erlang:get(call_p2p_sink), State
|
||||
)
|
||||
),
|
||||
receive
|
||||
{call_join_result, 1, Result} ->
|
||||
?assertEqual({error, voice_p2p_consent_required}, Result)
|
||||
after 1000 ->
|
||||
error(call_join_result_not_sent)
|
||||
end,
|
||||
?assertEqual([], drain_voice_server_updates())
|
||||
end).
|
||||
|
||||
fifth_joiner_over_the_cap_is_rejected_test() ->
|
||||
with_stubs(fun() ->
|
||||
Mesh = join_all([{UserId, true} || UserId <- [1, 2, 3, 4]], call_state()),
|
||||
?assertEqual([true, true, true, true], p2p_flags(Mesh)),
|
||||
?assertEqual({reply, {error, voice_channel_full}, Mesh}, join(5, true, Mesh)),
|
||||
?assertEqual([], drain_voice_server_updates())
|
||||
end).
|
||||
|
||||
race_join_past_the_configured_cap_is_rejected_test() ->
|
||||
with_stubs(fun() ->
|
||||
Mesh = join_all([{1, true}, {2, true}], call_state()),
|
||||
{reply, ok, Full} = call:handle_call(capped_join(3, 3), from(), Mesh),
|
||||
?assertEqual([true, true, true], p2p_flags(Full)),
|
||||
?assertEqual(
|
||||
{reply, {error, voice_channel_full}, Full},
|
||||
call:handle_call(capped_join(4, 3), from(), Full)
|
||||
),
|
||||
?assertNot(maps:is_key(4, maps:get(voice_states, Full))),
|
||||
?assertEqual(#{}, maps:get(pending_connections, Full)),
|
||||
?assertEqual([], drain_voice_server_updates())
|
||||
end).
|
||||
|
||||
join_without_a_cap_falls_back_to_the_hard_ceiling_test() ->
|
||||
with_stubs(fun() ->
|
||||
Mesh = join_all([{UserId, true} || UserId <- [1, 2, 3]], call_state()),
|
||||
{reply, ok, Full} = call:handle_call(
|
||||
{join, 4, voice_state(4, true), session_id(4), erlang:get(call_p2p_sink),
|
||||
connection_id(4)},
|
||||
from(),
|
||||
Mesh
|
||||
),
|
||||
?assertEqual([true, true, true, true], p2p_flags(Full))
|
||||
end).
|
||||
|
||||
session_p2p_grant_passes_the_cap_to_the_call_join_test() ->
|
||||
with_stubs(fun() ->
|
||||
meck:new(gateway_rpc_call_lookup, [passthrough, no_link]),
|
||||
try
|
||||
{reply, #{success := true}, _State} = dm_join(true, [voice_state(20, true)]),
|
||||
?assertMatch(
|
||||
[{join, 10, #{<<"p2p">> := true}, <<"sess">>, _Pid, _ConnectionId, 3}],
|
||||
[
|
||||
Request
|
||||
|| {_Caller, {gateway_rpc_call_lookup, safe_gen_server_call, [_, Request, _]},
|
||||
_Result} <- meck:history(gateway_rpc_call_lookup),
|
||||
element(1, Request) =:= join
|
||||
]
|
||||
)
|
||||
after
|
||||
meck:unload(gateway_rpc_call_lookup)
|
||||
end
|
||||
end).
|
||||
|
||||
rejected_join_rolls_back_without_retrying_test() ->
|
||||
with_stubs(fun() ->
|
||||
erlang:put(call_p2p_join_reply, {error, voice_p2p_consent_required}),
|
||||
ok = dm_voice_token:join_or_create_call(
|
||||
100, 10, voice_state(10, false), <<"sess">>, self(), undefined
|
||||
),
|
||||
?assertEqual(
|
||||
[
|
||||
{voice_rejected, voice_p2p_consent_required},
|
||||
{call_force_disconnect, 100, connection_id(10)}
|
||||
],
|
||||
drain_session_casts()
|
||||
),
|
||||
?assertEqual(1, meck:num_calls(call_manager, lookup, '_'))
|
||||
end).
|
||||
|
||||
explicit_p2p_false_update_converts_test() ->
|
||||
with_stubs(fun() ->
|
||||
State0 = join_all([{1, true}, {2, true}], call_state()),
|
||||
{reply, ok, State1} = call:handle_call(
|
||||
{update_voice_state, 1, voice_state(1, false)}, from(), State0
|
||||
),
|
||||
?assertEqual([false, false], p2p_flags(State1)),
|
||||
?assertMatch(#{<<"p2p">> := false}, receive_voice_state_update(connection_id(2))),
|
||||
?assertEqual(
|
||||
[connection_id(1), connection_id(2)], lists:sort(drain_voice_server_updates())
|
||||
)
|
||||
end).
|
||||
|
||||
update_keeping_p2p_keeps_the_mesh_test() ->
|
||||
with_stubs(fun() ->
|
||||
State0 = join_all([{1, true}, {2, true}], call_state()),
|
||||
{reply, ok, State1} = call:handle_call(
|
||||
{update_voice_state, 1, (voice_state(1, true))#{<<"self_mute">> => true}},
|
||||
from(),
|
||||
State0
|
||||
),
|
||||
?assertEqual([true, true], p2p_flags(State1)),
|
||||
?assertEqual([], drain_voice_server_updates())
|
||||
end).
|
||||
|
||||
stale_p2p_update_after_conversion_stays_sfu_test() ->
|
||||
with_stubs(fun() ->
|
||||
Mesh = join_all([{1, true}, {2, true}], call_state()),
|
||||
{reply, ok, State0} = call:handle_call(
|
||||
{update_voice_state, 2, voice_state(2, false)}, from(), Mesh
|
||||
),
|
||||
?assertEqual(2, length(drain_voice_server_updates())),
|
||||
ok = flush_user_dispatches(),
|
||||
{reply, ok, State1} = call:handle_call(
|
||||
{update_voice_state, 1, voice_state(1, true)}, from(), State0
|
||||
),
|
||||
?assertEqual([false, false], p2p_flags(State1)),
|
||||
?assertMatch(#{<<"p2p">> := false}, receive_voice_state_update(connection_id(1))),
|
||||
?assertEqual([], drain_voice_server_updates())
|
||||
end).
|
||||
|
||||
same_region_update_converts_a_mesh_test() ->
|
||||
with_stubs(fun() ->
|
||||
State0 = join_all([{1, true}, {2, true}], (call_state())#{region => <<"us-east">>}),
|
||||
ok = flush_user_dispatches(),
|
||||
{reply, ok, State1} = call:handle_call({update_region, <<"us-east">>}, from(), State0),
|
||||
?assertEqual([false, false], p2p_flags(State1)),
|
||||
?assertEqual(
|
||||
[connection_id(1), connection_id(2)], lists:sort(drain_voice_server_updates())
|
||||
),
|
||||
?assertEqual(
|
||||
[<<"us-east">>, <<"us-east">>],
|
||||
[maps:get(<<"rtc_region">>, Request) || Request <- token_requests()]
|
||||
),
|
||||
?assertMatch(
|
||||
#{voice_states := [#{<<"p2p">> := false}, #{<<"p2p">> := false}]},
|
||||
receive_user_dispatch(call_update)
|
||||
)
|
||||
end).
|
||||
|
||||
same_region_update_leaves_an_sfu_call_alone_test() ->
|
||||
with_stubs(fun() ->
|
||||
State0 = join_all([{1, false}, {2, false}], (call_state())#{region => <<"us-east">>}),
|
||||
{reply, ok, _State1} = call:handle_call({update_region, <<"us-east">>}, from(), State0),
|
||||
?assertEqual([], drain_voice_server_updates())
|
||||
end).
|
||||
|
||||
call_event_and_handoff_keep_the_p2p_flag_test() ->
|
||||
with_stubs(fun() ->
|
||||
State = join_all([{1, true}], call_state()),
|
||||
?assertMatch(
|
||||
#{voice_states := [#{<<"p2p">> := true}]}, call_state:build_call_event(State)
|
||||
),
|
||||
Restored = call_handoff:restore_state(call_handoff:export_state(State)),
|
||||
?assertEqual([true], p2p_flags(Restored))
|
||||
end).
|
||||
|
||||
signal_relay_allowed_test() ->
|
||||
with_stubs(fun() ->
|
||||
State = join_all([{1, true}, {2, true}], call_state()),
|
||||
Data = #{<<"type">> => <<"answer">>, <<"sdp">> => <<"v=0">>},
|
||||
{noreply, State} = call:handle_cast(
|
||||
{voice_signal, session_id(1), connection_id(2), Data}, State
|
||||
),
|
||||
?assertEqual(
|
||||
#{
|
||||
<<"channel_id">> => <<"1">>,
|
||||
<<"from">> => connection_id(1),
|
||||
<<"user_id">> => <<"1">>,
|
||||
<<"data">> => Data
|
||||
},
|
||||
receive_voice_signal()
|
||||
)
|
||||
end).
|
||||
|
||||
signal_relay_denied_test() ->
|
||||
with_stubs(fun() ->
|
||||
Mesh = join_all([{1, true}, {2, true}], call_state()),
|
||||
Data = #{<<"type">> => <<"candidate">>},
|
||||
Denied = [
|
||||
{voice_signal, session_id(9), connection_id(2), Data},
|
||||
{voice_signal, session_id(1), connection_id(1), Data},
|
||||
{voice_signal, session_id(1), <<"unknown">>, Data}
|
||||
],
|
||||
[{noreply, Mesh} = call:handle_cast(Signal, Mesh) || Signal <- Denied],
|
||||
{reply, ok, Sfu} = call:handle_call({update_region, <<"us-east">>}, from(), Mesh),
|
||||
{noreply, Sfu} = call:handle_cast(
|
||||
{voice_signal, session_id(1), connection_id(2), Data}, Sfu
|
||||
),
|
||||
assert_no_voice_signal()
|
||||
end).
|
||||
|
||||
session_p2p_start_without_a_call_test() ->
|
||||
with_stubs(fun() ->
|
||||
{reply, #{success := true, connection_id := ConnectionId}, State} = dm_join(
|
||||
true, not_found
|
||||
),
|
||||
?assertMatch(
|
||||
#{
|
||||
<<"p2p">> := true,
|
||||
<<"p2p_initiator">> := true,
|
||||
<<"p2p_participant_count">> := 1,
|
||||
<<"country_code">> := <<"SE">>
|
||||
},
|
||||
lists:last(token_requests())
|
||||
),
|
||||
?assertEqual(
|
||||
true, maps:get(<<"p2p">>, maps:get(ConnectionId, maps:get(dm_voice_states, State)))
|
||||
),
|
||||
Update = receive_session_voice_server_update(),
|
||||
?assertEqual(
|
||||
#{
|
||||
<<"p2p">> => true,
|
||||
<<"ice_servers">> => [#{<<"urls">> => [<<"stun:stun.example:3478">>]}],
|
||||
<<"connection_id">> => ConnectionId,
|
||||
<<"channel_id">> => <<"100">>
|
||||
},
|
||||
Update
|
||||
)
|
||||
end).
|
||||
|
||||
session_p2p_join_of_an_existing_mesh_test() ->
|
||||
with_stubs(fun() ->
|
||||
{reply, #{success := true}, _State} = dm_join(true, [voice_state(20, true)]),
|
||||
?assertMatch(
|
||||
#{
|
||||
<<"p2p">> := true,
|
||||
<<"p2p_initiator">> := false,
|
||||
<<"p2p_participant_count">> := 2
|
||||
},
|
||||
lists:last(token_requests())
|
||||
),
|
||||
?assertMatch(#{<<"p2p">> := true}, receive_session_voice_server_update())
|
||||
end).
|
||||
|
||||
session_join_from_a_second_device_counts_the_user_once_test() ->
|
||||
with_stubs(fun() ->
|
||||
{reply, #{success := true}, _State} = dm_join(
|
||||
true, [voice_state(20, true), voice_state(10, true)]
|
||||
),
|
||||
?assertMatch(
|
||||
#{
|
||||
<<"p2p">> := true,
|
||||
<<"p2p_initiator">> := false,
|
||||
<<"p2p_participant_count">> := 2
|
||||
},
|
||||
lists:last(token_requests())
|
||||
)
|
||||
end).
|
||||
|
||||
session_join_of_an_sfu_call_does_not_ask_for_p2p_test() ->
|
||||
with_stubs(fun() ->
|
||||
{reply, #{success := true, connection_id := ConnectionId}, State} = dm_join(
|
||||
true, [voice_state(20, false)]
|
||||
),
|
||||
?assertNot(maps:is_key(<<"p2p">>, lists:last(token_requests()))),
|
||||
?assertEqual(
|
||||
false, maps:get(<<"p2p">>, maps:get(ConnectionId, maps:get(dm_voice_states, State)))
|
||||
),
|
||||
Update = receive_session_voice_server_update(),
|
||||
?assertEqual(<<"tok">>, maps:get(<<"token">>, Update)),
|
||||
?assertNot(maps:is_key(<<"p2p">>, Update))
|
||||
end).
|
||||
|
||||
session_join_of_a_mesh_without_agreement_is_rejected_test() ->
|
||||
with_stubs(fun() ->
|
||||
?assertMatch(
|
||||
{reply, {error, permission_denied, voice_p2p_consent_required}, #{
|
||||
dm_voice_states := #{}
|
||||
}},
|
||||
dm_join_reply(undefined, [voice_state(20, true)])
|
||||
),
|
||||
?assertEqual([], token_requests()),
|
||||
assert_no_session_voice_server_update()
|
||||
end).
|
||||
|
||||
session_bot_join_of_a_mesh_is_rejected_test() ->
|
||||
with_stubs(fun() ->
|
||||
erlang:put(call_p2p_call_voice_states, [voice_state(20, true)]),
|
||||
State = dm_session_state(#{}),
|
||||
?assertMatch(
|
||||
{reply, {error, permission_denied, voice_p2p_consent_required}, _State},
|
||||
dm_voice_token:get_dm_voice_token_and_create_state(
|
||||
(dm_request(true, State))#{bot => true}
|
||||
)
|
||||
)
|
||||
end).
|
||||
|
||||
session_join_of_a_full_mesh_is_rejected_test() ->
|
||||
with_stubs(fun() ->
|
||||
Mesh = [voice_state(UserId, true) || UserId <- [20, 21, 22, 23]],
|
||||
?assertMatch(
|
||||
{reply, {error, permission_denied, voice_channel_full}, _State},
|
||||
dm_join_reply(true, Mesh)
|
||||
),
|
||||
?assertEqual([], token_requests())
|
||||
end).
|
||||
|
||||
session_join_past_the_configured_cap_is_rejected_as_full_test() ->
|
||||
with_stubs(fun() ->
|
||||
erlang:put(call_p2p_stub_mode, channel_full),
|
||||
?assertMatch(
|
||||
{reply, {error, permission_denied, voice_channel_full}, #{dm_voice_states := #{}}},
|
||||
dm_join_reply(true, [voice_state(20, true), voice_state(21, true)])
|
||||
),
|
||||
?assertMatch(
|
||||
#{
|
||||
<<"p2p">> := true,
|
||||
<<"p2p_initiator">> := false,
|
||||
<<"p2p_participant_count">> := 3
|
||||
},
|
||||
lists:last(token_requests())
|
||||
),
|
||||
assert_no_session_voice_server_update()
|
||||
end).
|
||||
|
||||
session_declined_grant_into_a_mesh_is_rejected_test() ->
|
||||
with_stubs(fun() ->
|
||||
erlang:put(call_p2p_stub_mode, decline),
|
||||
?assertMatch(
|
||||
{reply, {error, voice_error, voice_p2p_unavailable}, #{dm_voice_states := #{}}},
|
||||
dm_join_reply(true, [voice_state(20, true)])
|
||||
),
|
||||
?assertMatch(
|
||||
#{<<"p2p">> := true, <<"p2p_initiator">> := false}, lists:last(token_requests())
|
||||
),
|
||||
assert_no_session_voice_server_update()
|
||||
end).
|
||||
|
||||
session_declined_grant_without_a_call_joins_sfu_test() ->
|
||||
with_stubs(fun() ->
|
||||
erlang:put(call_p2p_stub_mode, decline),
|
||||
{reply, #{success := true, connection_id := ConnectionId}, State} = dm_join(
|
||||
true, not_found
|
||||
),
|
||||
?assertEqual(
|
||||
false, maps:get(<<"p2p">>, maps:get(ConnectionId, maps:get(dm_voice_states, State)))
|
||||
),
|
||||
?assertEqual(<<"tok">>, maps:get(<<"token">>, receive_session_voice_server_update()))
|
||||
end).
|
||||
|
||||
session_update_with_explicit_p2p_false_clears_the_flag_test() ->
|
||||
with_stubs(fun() ->
|
||||
?assertEqual(false, dm_updated_p2p(false)),
|
||||
?assertEqual(true, dm_updated_p2p(undefined)),
|
||||
?assertEqual(true, dm_updated_p2p(true))
|
||||
end).
|
||||
|
||||
dm_updated_p2p(P2p) ->
|
||||
Existing = (voice_state(10, true))#{
|
||||
<<"channel_id">> => <<"100">>, <<"session_id">> => <<"sess">>
|
||||
},
|
||||
ConnectionId = connection_id(10),
|
||||
State = dm_session_state(#{ConnectionId => Existing}),
|
||||
{reply, #{success := true}, NewState} = dm_voice_connect:handle_dm_connect_or_update(
|
||||
(dm_request(P2p, State))#{
|
||||
connection_id => ConnectionId, viewer_stream_keys => undefined
|
||||
}
|
||||
),
|
||||
maps:get(<<"p2p">>, maps:get(ConnectionId, maps:get(dm_voice_states, NewState))).
|
||||
|
||||
dm_join_reply(P2p, CallVoiceStates) ->
|
||||
erlang:put(call_p2p_call_voice_states, CallVoiceStates),
|
||||
State = dm_session_state(#{}),
|
||||
dm_voice_token:get_dm_voice_token_and_create_state(dm_request(P2p, State)).
|
||||
|
||||
dm_join(P2p, CallVoiceStates) ->
|
||||
Reply = dm_join_reply(P2p, CallVoiceStates),
|
||||
receive
|
||||
{'$gen_cast', {call_monitor, 100, _CallPid}} -> Reply
|
||||
after 1000 ->
|
||||
error(call_not_joined)
|
||||
end.
|
||||
|
||||
dm_session_state(VoiceStates) ->
|
||||
#{
|
||||
dm_voice_states => VoiceStates,
|
||||
channels => #{100 => #{<<"type">> => 1, <<"recipient_ids">> => [20]}},
|
||||
user_id => 10,
|
||||
id => <<"sess">>,
|
||||
session_pid => erlang:get(call_p2p_sink)
|
||||
}.
|
||||
|
||||
dm_request(P2p, State) ->
|
||||
#{
|
||||
user_id => 10,
|
||||
channel_id => 100,
|
||||
session_id => <<"sess">>,
|
||||
connection_id => null,
|
||||
self_mute => false,
|
||||
self_deaf => false,
|
||||
self_video => false,
|
||||
self_stream => false,
|
||||
viewer_stream_keys => [],
|
||||
is_mobile => false,
|
||||
latitude => null,
|
||||
longitude => null,
|
||||
e2ee_capable => false,
|
||||
bot => false,
|
||||
p2p => P2p,
|
||||
country_code => <<"SE">>,
|
||||
voice_states => maps:get(dm_voice_states, State),
|
||||
state => State
|
||||
}.
|
||||
|
||||
join_all(Joins, State0) ->
|
||||
lists:foldl(
|
||||
fun({UserId, P2p}, State) ->
|
||||
{reply, ok, NewState} = join(UserId, P2p, State),
|
||||
NewState
|
||||
end,
|
||||
State0,
|
||||
Joins
|
||||
).
|
||||
|
||||
join(UserId, P2p, State) ->
|
||||
call_voice:handle_join_internal(
|
||||
UserId,
|
||||
voice_state(UserId, P2p),
|
||||
session_id(UserId),
|
||||
erlang:get(call_p2p_sink),
|
||||
connection_id(UserId),
|
||||
undefined,
|
||||
State
|
||||
).
|
||||
|
||||
capped_join(UserId, MaxParticipants) ->
|
||||
{join, UserId, voice_state(UserId, true), session_id(UserId), erlang:get(call_p2p_sink),
|
||||
connection_id(UserId), MaxParticipants}.
|
||||
|
||||
p2p_flags(State) ->
|
||||
[
|
||||
maps:get(<<"p2p">>, VoiceState)
|
||||
|| {_UserId, VoiceState} <- lists:keysort(1, maps:to_list(maps:get(voice_states, State)))
|
||||
].
|
||||
|
||||
voice_state(UserId, P2p) ->
|
||||
#{
|
||||
<<"user_id">> => integer_to_binary(UserId),
|
||||
<<"channel_id">> => <<"1">>,
|
||||
<<"connection_id">> => connection_id(UserId),
|
||||
<<"session_id">> => session_id(UserId),
|
||||
<<"p2p">> => P2p
|
||||
}.
|
||||
|
||||
connection_id(UserId) ->
|
||||
<<"conn-", (integer_to_binary(UserId))/binary>>.
|
||||
|
||||
session_id(UserId) ->
|
||||
<<"sess-", (integer_to_binary(UserId))/binary>>.
|
||||
|
||||
from() ->
|
||||
{self(), make_ref()}.
|
||||
|
||||
call_state() ->
|
||||
#{
|
||||
channel_id => 1,
|
||||
message_id => 1,
|
||||
region => undefined,
|
||||
ringing => [],
|
||||
pending_ringing => [],
|
||||
recipients => [1],
|
||||
voice_states => #{},
|
||||
sessions => #{},
|
||||
pending_connections => #{},
|
||||
initiator_ready => true,
|
||||
ringing_timers => #{},
|
||||
idle_timer => undefined,
|
||||
created_at => 0,
|
||||
participants_history => sets:new(),
|
||||
last_call_event => undefined
|
||||
}.
|
||||
|
||||
token_requests() ->
|
||||
[
|
||||
Request
|
||||
|| {_Pid, {rpc_client, call, [#{<<"type">> := <<"voice_get_token">>} = Request]}, _Result} <-
|
||||
meck:history(rpc_client)
|
||||
].
|
||||
|
||||
drain_voice_server_updates() ->
|
||||
receive
|
||||
{'$gen_cast', {dispatch, voice_server_update, Payload}} ->
|
||||
?assertEqual(<<"tok">>, maps:get(<<"token">>, Payload)),
|
||||
?assertNot(maps:is_key(<<"p2p">>, Payload)),
|
||||
[maps:get(<<"connection_id">>, Payload) | drain_voice_server_updates()]
|
||||
after 200 ->
|
||||
[]
|
||||
end.
|
||||
|
||||
receive_session_voice_server_update() ->
|
||||
receive
|
||||
{'$gen_cast', {dispatch, voice_server_update, Payload}} -> Payload
|
||||
after 1000 ->
|
||||
error(voice_server_update_not_dispatched)
|
||||
end.
|
||||
|
||||
assert_no_session_voice_server_update() ->
|
||||
receive
|
||||
{'$gen_cast', {dispatch, voice_server_update, Payload}} ->
|
||||
error({unexpected_voice_server_update, Payload})
|
||||
after 100 ->
|
||||
ok
|
||||
end.
|
||||
|
||||
drain_session_casts() ->
|
||||
receive
|
||||
{'$gen_cast', Cast} -> [Cast | drain_session_casts()]
|
||||
after 100 ->
|
||||
[]
|
||||
end.
|
||||
|
||||
receive_voice_state_update(ConnectionId) ->
|
||||
case receive_user_dispatch(voice_state_update) of
|
||||
#{<<"connection_id">> := ConnectionId} = VoiceState -> VoiceState;
|
||||
_Other -> receive_voice_state_update(ConnectionId)
|
||||
end.
|
||||
|
||||
receive_user_dispatch(Event) ->
|
||||
receive
|
||||
{user_dispatch, Event, Data} -> Data
|
||||
after 1000 ->
|
||||
error({user_dispatch_not_received, Event})
|
||||
end.
|
||||
|
||||
flush_user_dispatches() ->
|
||||
receive
|
||||
{user_dispatch, _Event, _Data} -> flush_user_dispatches()
|
||||
after 100 ->
|
||||
ok
|
||||
end.
|
||||
|
||||
receive_voice_signal() ->
|
||||
receive
|
||||
{'$gen_cast', {dispatch, voice_signal, Payload}} -> Payload
|
||||
after 1000 ->
|
||||
error(voice_signal_not_dispatched)
|
||||
end.
|
||||
|
||||
assert_no_voice_signal() ->
|
||||
receive
|
||||
{'$gen_cast', {dispatch, voice_signal, Payload}} ->
|
||||
error({unexpected_voice_signal, Payload})
|
||||
after 100 ->
|
||||
ok
|
||||
end.
|
||||
|
||||
with_stubs(Fun) ->
|
||||
Test = self(),
|
||||
Sink = spawn(fun() -> sink(Test) end),
|
||||
erlang:put(call_p2p_sink, Sink),
|
||||
erlang:erase(call_p2p_call_voice_states),
|
||||
erlang:erase(call_p2p_stub_mode),
|
||||
erlang:erase(call_p2p_join_reply),
|
||||
meck:new(rpc_client, [passthrough, no_link, non_strict]),
|
||||
meck:expect(rpc_client, call, fun rpc_reply/1),
|
||||
meck:new(presence_manager, [passthrough, no_link]),
|
||||
meck:expect(presence_manager, dispatch_to_user, fun(_UserId, Event, Data) ->
|
||||
Sink ! {user_dispatch, Event, Data},
|
||||
ok
|
||||
end),
|
||||
CallPid = spawn(fun call_stub/0),
|
||||
meck:new(call_manager, [passthrough, no_link]),
|
||||
meck:expect(call_manager, lookup, fun(_ChannelId) ->
|
||||
case erlang:get(call_p2p_call_voice_states) of
|
||||
not_found ->
|
||||
{error, not_found};
|
||||
VoiceStates when is_list(VoiceStates) ->
|
||||
CallPid ! {voice_states, VoiceStates},
|
||||
{ok, CallPid};
|
||||
undefined ->
|
||||
CallPid ! {join_reply, erlang:get(call_p2p_join_reply)},
|
||||
{ok, CallPid}
|
||||
end
|
||||
end),
|
||||
try
|
||||
Fun()
|
||||
after
|
||||
CallPid ! stop,
|
||||
meck:unload(call_manager),
|
||||
meck:unload(presence_manager),
|
||||
meck:unload(rpc_client),
|
||||
stop_sink(Sink)
|
||||
end.
|
||||
|
||||
sink(Test) ->
|
||||
receive
|
||||
Message ->
|
||||
Test ! Message,
|
||||
sink(Test)
|
||||
after 30000 ->
|
||||
ok
|
||||
end.
|
||||
|
||||
stop_sink(Sink) ->
|
||||
exit(Sink, kill),
|
||||
flush_mailbox().
|
||||
|
||||
flush_mailbox() ->
|
||||
receive
|
||||
_Message -> flush_mailbox()
|
||||
after 0 ->
|
||||
ok
|
||||
end.
|
||||
|
||||
call_stub() ->
|
||||
call_stub([], ok).
|
||||
|
||||
call_stub(VoiceStates, JoinReply) ->
|
||||
receive
|
||||
{voice_states, NewVoiceStates} ->
|
||||
call_stub(NewVoiceStates, JoinReply);
|
||||
{join_reply, undefined} ->
|
||||
call_stub(VoiceStates, JoinReply);
|
||||
{join_reply, NewJoinReply} ->
|
||||
call_stub(VoiceStates, NewJoinReply);
|
||||
{'$gen_call', From, {get_state}} ->
|
||||
gen_server:reply(From, {ok, #{voice_states => VoiceStates, region => undefined}}),
|
||||
call_stub(VoiceStates, JoinReply);
|
||||
{'$gen_call', From, _Request} ->
|
||||
gen_server:reply(From, JoinReply),
|
||||
call_stub(VoiceStates, JoinReply);
|
||||
stop ->
|
||||
ok;
|
||||
_Other ->
|
||||
call_stub(VoiceStates, JoinReply)
|
||||
after 30000 ->
|
||||
ok
|
||||
end.
|
||||
|
||||
rpc_reply(#{<<"type">> := <<"voice_get_token">>} = Request) ->
|
||||
ConnectionId = maps:get(<<"connection_id">>, Request, <<"conn-new">>),
|
||||
case {maps:get(<<"p2p">>, Request, false), erlang:get(call_p2p_stub_mode)} of
|
||||
{true, channel_full} ->
|
||||
{ok, #{<<"p2p">> => false, <<"declineReason">> => <<"channel_full">>}};
|
||||
{true, Mode} when Mode =/= decline ->
|
||||
{ok, #{
|
||||
<<"p2p">> => true,
|
||||
<<"connectionId">> => ConnectionId,
|
||||
<<"iceServers">> => [#{<<"urls">> => [<<"stun:stun.example:3478">>]}],
|
||||
<<"maxParticipants">> => 3
|
||||
}};
|
||||
_ ->
|
||||
{ok, #{
|
||||
<<"token">> => <<"tok">>,
|
||||
<<"endpoint">> => <<"wss://voice.example">>,
|
||||
<<"connectionId">> => ConnectionId
|
||||
}}
|
||||
end;
|
||||
rpc_reply(_Request) ->
|
||||
{ok, #{}}.
|
||||
@@ -86,7 +86,7 @@ join_from_ringing_adds_voice_state_test() ->
|
||||
initiator_ready => true
|
||||
}),
|
||||
{reply, ok, NewState} = call_voice:handle_join_internal(
|
||||
UserId, VoiceState, SessionId, self(), ConnectionId, State
|
||||
UserId, VoiceState, SessionId, self(), ConnectionId, undefined, State
|
||||
),
|
||||
?assert(maps:is_key(UserId, maps:get(voice_states, NewState))),
|
||||
?assertNot(lists:member(UserId, maps:get(ringing, NewState))).
|
||||
@@ -165,7 +165,7 @@ leave_then_rejoin_keeps_voice_state_test() ->
|
||||
)
|
||||
),
|
||||
{reply, ok, Rejoined} = call_voice:handle_join_internal(
|
||||
UserId, VoiceState, SessionId, self(), <<"conn-42">>, PostLeave
|
||||
UserId, VoiceState, SessionId, self(), <<"conn-42">>, undefined, PostLeave
|
||||
),
|
||||
?assert(maps:is_key(UserId, maps:get(voice_states, Rejoined))).
|
||||
|
||||
|
||||
@@ -263,6 +263,26 @@ check_presence_rate_limit_soft_drops_only_presence_test() ->
|
||||
?assertEqual(1, length(maps:get(events, RateLimitState)))
|
||||
end).
|
||||
|
||||
check_voice_signal_rate_limit_allows_trickle_bursts_test() ->
|
||||
with_rate_limits_enabled(fun() ->
|
||||
Now = erlang:system_time(millisecond),
|
||||
State = fun(Count) ->
|
||||
(gateway_handler:new_state())#{
|
||||
rate_limit_state => #{
|
||||
events => [],
|
||||
op_events => #{voice_signal => lists:duplicate(Count, Now - 1000)}
|
||||
}
|
||||
}
|
||||
end,
|
||||
?assertMatch(
|
||||
{ok, _}, gateway_handler_rate_limit:check_rate_limit(State(239), voice_signal)
|
||||
),
|
||||
?assertMatch(
|
||||
{opcode_rate_limited, _},
|
||||
gateway_handler_rate_limit:check_rate_limit(State(240), voice_signal)
|
||||
)
|
||||
end).
|
||||
|
||||
enqueue_voice_update_keeps_latest_update_for_connection_test() ->
|
||||
Queue0 = queue:new(),
|
||||
Data1 = #{
|
||||
@@ -506,6 +526,47 @@ voice_state_update_with_non_map_payload_closes_test() ->
|
||||
),
|
||||
?assertEqual(constants:close_code_to_num(decode_error), CloseCode).
|
||||
|
||||
voice_signal_is_cast_to_the_session_test() ->
|
||||
State = (new_json_state())#{session_pid => self()},
|
||||
Data = #{<<"channel_id">> => <<"100">>, <<"to">> => <<"conn-b">>, <<"data">> => #{}},
|
||||
?assertEqual(
|
||||
{ok, State},
|
||||
gateway_handler_dispatch:handle_opcode(voice_signal, #{<<"d">> => Data}, State)
|
||||
),
|
||||
receive
|
||||
{'$gen_cast', {voice_signal, Data}} -> ok
|
||||
after 1000 ->
|
||||
error(voice_signal_not_cast)
|
||||
end.
|
||||
|
||||
voice_signal_with_non_map_payload_is_dropped_test() ->
|
||||
State = (new_json_state())#{session_pid => self()},
|
||||
?assertEqual(
|
||||
{ok, State},
|
||||
gateway_handler_dispatch:handle_opcode(
|
||||
voice_signal, #{<<"d">> => <<"not-a-map">>}, State
|
||||
)
|
||||
).
|
||||
|
||||
websocket_info_gateway_error_emits_an_error_frame_test() ->
|
||||
{[{text, Frame}], _State} = gateway_handler:websocket_info(
|
||||
{gateway_error, voice_p2p_consent_required}, new_json_state()
|
||||
),
|
||||
?assertEqual(
|
||||
#{
|
||||
<<"op">> => 12,
|
||||
<<"d">> => #{
|
||||
<<"code">> => <<"VOICE_P2P_CONSENT_REQUIRED">>,
|
||||
<<"message">> => <<"This call is peer-to-peer and requires agreement to join">>
|
||||
}
|
||||
},
|
||||
json:decode(Frame)
|
||||
).
|
||||
|
||||
voice_signal_opcode_is_thirteen_test() ->
|
||||
?assertEqual(voice_signal, constants:gateway_opcode(13)),
|
||||
?assertEqual(13, constants:opcode_to_num(voice_signal)).
|
||||
|
||||
websocket_handle_rejects_compressed_frame_past_max_payload_test() ->
|
||||
{ok, Compressed, _} = gateway_compress:compress(
|
||||
binary:copy(<<"a">>, constants:max_payload_size() * 64),
|
||||
|
||||
@@ -46,6 +46,7 @@ broadcast_payload(VoiceState) ->
|
||||
<<"suppress">> => false,
|
||||
<<"viewer_stream_keys">> => [],
|
||||
<<"e2ee_capable">> => false,
|
||||
<<"p2p">> => false,
|
||||
<<"version">> => 0
|
||||
},
|
||||
VoiceState
|
||||
|
||||
@@ -0,0 +1,681 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(guild_voice_p2p_tests).
|
||||
-typing([eqwalizer]).
|
||||
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
-define(GUILD_ID, 999).
|
||||
-define(CHANNEL_ID, 100).
|
||||
-define(OTHER_CHANNEL_ID, 200).
|
||||
-define(PINNED_CHANNEL_ID, 300).
|
||||
|
||||
p2p_start_in_an_empty_channel_test() ->
|
||||
with_stubs(grant, fun(State0) ->
|
||||
{Reply, State1} = join(10, true, State0),
|
||||
ConnectionId = maps:get(connection_id, Reply),
|
||||
?assertMatch(#{success := true, p2p := true, ice_servers := [_]}, Reply),
|
||||
?assertNot(maps:is_key(token, Reply)),
|
||||
?assertNot(maps:is_key(endpoint, Reply)),
|
||||
?assertEqual(true, p2p_flag(ConnectionId, State1)),
|
||||
?assertEqual(#{}, maps:get(pending_voice_connections, State1)),
|
||||
?assertMatch(
|
||||
#{
|
||||
<<"p2p">> := true,
|
||||
<<"p2p_initiator">> := true,
|
||||
<<"p2p_participant_count">> := 1,
|
||||
<<"country_code">> := <<"SE">>
|
||||
},
|
||||
last_token_request()
|
||||
),
|
||||
?assertEqual([{ConnectionId, true}], drain_voice_state_updates())
|
||||
end).
|
||||
|
||||
agreed_joiner_joins_the_mesh_test() ->
|
||||
with_stubs(grant, fun(State0) ->
|
||||
{ReplyA, State1} = join(10, true, State0),
|
||||
{ReplyB, State2} = join(11, true, State1),
|
||||
?assertMatch(#{p2p := true}, ReplyB),
|
||||
?assertMatch(
|
||||
#{
|
||||
<<"p2p">> := true,
|
||||
<<"p2p_initiator">> := false,
|
||||
<<"p2p_participant_count">> := 2
|
||||
},
|
||||
last_token_request()
|
||||
),
|
||||
?assertEqual(true, p2p_flag(maps:get(connection_id, ReplyA), State2)),
|
||||
?assertEqual(true, p2p_flag(maps:get(connection_id, ReplyB), State2)),
|
||||
?assertEqual([], served_voice_server_updates(State2))
|
||||
end).
|
||||
|
||||
non_agreeing_joiner_is_rejected_and_nobody_is_converted_test() ->
|
||||
with_stubs(grant, fun(State0) ->
|
||||
{Mesh, State1} = join_mesh([10, 11], State0),
|
||||
Requests = token_request_count(),
|
||||
{Reply, State2} = join(12, undefined, State1),
|
||||
?assertEqual({error, permission_denied, voice_p2p_consent_required}, Reply),
|
||||
?assertEqual(Requests, token_request_count()),
|
||||
assert_untouched(Mesh, State1, State2)
|
||||
end).
|
||||
|
||||
bot_is_rejected_like_a_non_agreeing_joiner_test() ->
|
||||
with_stubs(grant, fun(State0) ->
|
||||
{Mesh, State1} = join_mesh([10, 11], State0),
|
||||
{reply, Reply, State2} = guild_voice_connection:voice_state_update(
|
||||
(request(12, true))#{bot => true}, State1
|
||||
),
|
||||
?assertEqual({error, permission_denied, voice_p2p_consent_required}, Reply),
|
||||
assert_untouched(Mesh, State1, State2)
|
||||
end).
|
||||
|
||||
fifth_joiner_is_rejected_as_full_test() ->
|
||||
with_stubs(grant, fun(State0) ->
|
||||
{Mesh, State1} = join_mesh([10, 11, 12, 13], State0),
|
||||
?assertEqual(4, length(Mesh)),
|
||||
Requests = token_request_count(),
|
||||
{Reply, State2} = join(14, true, State1),
|
||||
?assertEqual({error, permission_denied, voice_channel_full}, Reply),
|
||||
?assertEqual(Requests, token_request_count()),
|
||||
assert_untouched(Mesh, State1, State2)
|
||||
end).
|
||||
|
||||
joiner_past_the_configured_cap_is_rejected_as_full_test() ->
|
||||
with_stubs({cap, 2}, fun(State0) ->
|
||||
{Mesh, State1} = join_mesh([10, 11], State0),
|
||||
{Reply, State2} = join(12, true, State1),
|
||||
?assertMatch(
|
||||
#{
|
||||
<<"p2p">> := true,
|
||||
<<"p2p_initiator">> := false,
|
||||
<<"p2p_participant_count">> := 3
|
||||
},
|
||||
last_token_request()
|
||||
),
|
||||
?assertEqual({error, permission_denied, voice_channel_full}, Reply),
|
||||
assert_untouched(Mesh, State1, State2)
|
||||
end).
|
||||
|
||||
mesh_fills_up_to_the_configured_cap_test() ->
|
||||
with_stubs({cap, 3}, fun(State0) ->
|
||||
{[_, _, _] = Mesh, State1} = join_mesh([10, 11, 12], State0),
|
||||
[?assertEqual(true, p2p_flag(ConnectionId, State1)) || ConnectionId <- Mesh],
|
||||
{Reply, _State2} = join(13, true, State1),
|
||||
?assertEqual({error, permission_denied, voice_channel_full}, Reply)
|
||||
end).
|
||||
|
||||
declined_grant_into_a_live_mesh_is_rejected_test() ->
|
||||
with_stubs(grant, fun(State0) ->
|
||||
{Mesh, State1} = join_mesh([10, 11], State0),
|
||||
erlang:put(p2p_stub_mode, decline),
|
||||
{Reply, State2} = join(12, true, State1),
|
||||
?assertMatch(#{<<"p2p">> := true, <<"p2p_initiator">> := false}, last_token_request()),
|
||||
?assertEqual({error, voice_error, voice_p2p_unavailable}, Reply),
|
||||
assert_untouched(Mesh, State1, State2)
|
||||
end).
|
||||
|
||||
declined_grant_on_an_empty_channel_joins_sfu_test() ->
|
||||
with_stubs(decline, fun(State0) ->
|
||||
{Reply, State1} = join(10, true, State0),
|
||||
?assertMatch(#{<<"p2p">> := true, <<"p2p_initiator">> := true}, last_token_request()),
|
||||
?assertMatch(#{success := true, token := _, endpoint := _}, Reply),
|
||||
?assertNot(maps:is_key(p2p, Reply)),
|
||||
?assertEqual(#{}, maps:get(voice_states, State1)),
|
||||
?assertEqual(
|
||||
[maps:get(connection_id, Reply)],
|
||||
maps:keys(maps:get(pending_voice_connections, State1))
|
||||
)
|
||||
end).
|
||||
|
||||
pinned_empty_channel_joins_a_non_agreeing_joiner_on_sfu_test() ->
|
||||
with_stubs(grant, fun(State0) ->
|
||||
{reply, Reply, _State1} = guild_voice_connection:voice_state_update(
|
||||
(request(10, undefined))#{channel_id => ?PINNED_CHANNEL_ID}, State0
|
||||
),
|
||||
?assertMatch(#{success := true, token := _}, Reply),
|
||||
?assertNot(maps:is_key(<<"p2p">>, last_token_request()))
|
||||
end).
|
||||
|
||||
pinned_channel_joins_sfu_when_the_grant_is_declined_test() ->
|
||||
with_stubs(decline, fun(State0) ->
|
||||
{reply, Reply, _State1} = guild_voice_connection:voice_state_update(
|
||||
(request(10, true))#{channel_id => ?PINNED_CHANNEL_ID}, State0
|
||||
),
|
||||
?assertMatch(#{success := true, token := _}, Reply)
|
||||
end).
|
||||
|
||||
pending_join_blocks_a_p2p_start_test() ->
|
||||
with_stubs(grant, fun(State0) ->
|
||||
{_Reply, State1} = join(10, undefined, State0),
|
||||
?assertEqual(1, maps:size(maps:get(pending_voice_connections, State1))),
|
||||
{Reply, _State2} = join(11, true, State1),
|
||||
?assertMatch(#{token := _}, Reply),
|
||||
?assertNot(maps:is_key(<<"p2p">>, last_token_request()))
|
||||
end).
|
||||
|
||||
explicit_p2p_false_update_converts_test() ->
|
||||
with_stubs(grant, fun(State0) ->
|
||||
{[ConnectionA | _] = Mesh, State1} = join_mesh([10, 11], State0),
|
||||
{reply, #{success := true}, State2} = guild_voice_connection:voice_state_update(
|
||||
(request(10, false))#{connection_id => ConnectionA}, State1
|
||||
),
|
||||
assert_converted(Mesh, State2)
|
||||
end).
|
||||
|
||||
explicit_p2p_false_update_is_ignored_in_a_pinned_channel_test() ->
|
||||
with_stubs(grant, fun(State0) ->
|
||||
{[ConnectionA | _] = Mesh, State1} = join_mesh([10, 11], ?PINNED_CHANNEL_ID, State0),
|
||||
_ = drain_voice_state_updates(),
|
||||
{reply, #{success := true}, State2} = guild_voice_connection:voice_state_update(
|
||||
(request(10, false))#{
|
||||
channel_id => ?PINNED_CHANNEL_ID, connection_id => ConnectionA
|
||||
},
|
||||
State1
|
||||
),
|
||||
assert_untouched(Mesh, State1, State2)
|
||||
end).
|
||||
|
||||
update_without_p2p_keeps_the_mesh_test() ->
|
||||
with_stubs(grant, fun(State0) ->
|
||||
{[ConnectionA, ConnectionB], State1} = join_mesh([10, 11], State0),
|
||||
{reply, #{success := true}, State2} = guild_voice_connection:voice_state_update(
|
||||
(request(10, undefined))#{connection_id => ConnectionA, self_mute => true},
|
||||
State1
|
||||
),
|
||||
?assertEqual(true, p2p_flag(ConnectionA, State2)),
|
||||
?assertEqual(true, p2p_flag(ConnectionB, State2)),
|
||||
?assertEqual(
|
||||
true,
|
||||
maps:get(<<"self_mute">>, maps:get(ConnectionA, maps:get(voice_states, State2)))
|
||||
)
|
||||
end).
|
||||
|
||||
region_switch_converts_test() ->
|
||||
with_stubs(grant, fun(State0) ->
|
||||
{Mesh, State1} = join_mesh([10, 11], State0),
|
||||
{reply, #{success := true}, State2} = guild_voice_region:switch_voice_region_handler(
|
||||
#{channel_id => ?CHANNEL_ID}, State1
|
||||
),
|
||||
assert_marked_sfu(Mesh, State2),
|
||||
GuildPid = maps:get(guild_pid, State2),
|
||||
GuildPid ! {serve, State2},
|
||||
ok = guild_voice_region:switch_voice_region(?GUILD_ID, ?CHANNEL_ID, GuildPid),
|
||||
?assertEqual(lists:sort(Mesh), lists:sort(drain_voice_server_updates(length(Mesh))))
|
||||
end).
|
||||
|
||||
region_switch_keeps_a_pinned_channel_p2p_test() ->
|
||||
with_stubs(grant, fun(State0) ->
|
||||
{Mesh, State1} = join_mesh([10, 11], ?PINNED_CHANNEL_ID, State0),
|
||||
_ = drain_voice_state_updates(),
|
||||
{reply, #{success := true}, State2} = guild_voice_region:switch_voice_region_handler(
|
||||
#{channel_id => ?PINNED_CHANNEL_ID}, State1
|
||||
),
|
||||
GuildPid = maps:get(guild_pid, State2),
|
||||
GuildPid ! {serve, State2},
|
||||
ok = guild_voice_region:switch_voice_region(?GUILD_ID, ?PINNED_CHANNEL_ID, GuildPid),
|
||||
assert_untouched(Mesh, State1, State2)
|
||||
end).
|
||||
|
||||
livekit_restore_into_a_mesh_is_refused_test() ->
|
||||
with_stubs(grant, fun(State0) ->
|
||||
{Mesh, State1} = join_mesh([10, 11], State0),
|
||||
Stale = (maps:get(hd(Mesh), maps:get(voice_states, State1)))#{
|
||||
<<"connection_id">> => <<"stale">>, <<"user_id">> => <<"12">>, <<"p2p">> => false
|
||||
},
|
||||
State2 = State1#{
|
||||
recently_disconnected_voice_states => #{
|
||||
<<"stale">> => #{
|
||||
voice_state => Stale, disconnected_at => erlang:system_time(millisecond)
|
||||
}
|
||||
}
|
||||
},
|
||||
{reply, Reply, State3} =
|
||||
guild_voice_connection:confirm_voice_connection_from_livekit(
|
||||
#{connection_id => <<"stale">>}, State2
|
||||
),
|
||||
?assertEqual({error, permission_denied, voice_p2p_consent_required}, Reply),
|
||||
?assertNot(maps:is_key(<<"stale">>, maps:get(voice_states, State3))),
|
||||
assert_untouched(Mesh, State2, State3)
|
||||
end).
|
||||
|
||||
livekit_activation_into_a_mesh_is_refused_test() ->
|
||||
with_stubs(grant, fun(State0) ->
|
||||
{Mesh, State1} = join_mesh([10, 11], State0),
|
||||
State2 = guild_voice_connection_pending:store_pending(
|
||||
<<"late">>,
|
||||
#{
|
||||
user_id => 12,
|
||||
guild_id => ?GUILD_ID,
|
||||
channel_id => ?CHANNEL_ID,
|
||||
session_id => session_id(12),
|
||||
token_nonce => <<"nonce">>,
|
||||
expires_at => erlang:system_time(millisecond) + 30000
|
||||
},
|
||||
State1
|
||||
),
|
||||
{reply, Reply, State3} =
|
||||
guild_voice_connection:confirm_voice_connection_from_livekit(
|
||||
#{connection_id => <<"late">>, token_nonce => <<"nonce">>}, State2
|
||||
),
|
||||
?assertEqual({error, permission_denied, voice_p2p_consent_required}, Reply),
|
||||
?assertEqual(lists:sort(Mesh), lists:sort(maps:keys(maps:get(voice_states, State3)))),
|
||||
?assertEqual(#{}, maps:get(pending_voice_connections, State3)),
|
||||
assert_untouched(Mesh, State1, State3)
|
||||
end).
|
||||
|
||||
explicit_switch_converts_without_a_voice_server_test() ->
|
||||
with_stubs(grant, fun(State0) ->
|
||||
{[ConnectionA | _] = Mesh, State1} = join_mesh([10, 11], State0),
|
||||
Self = self(),
|
||||
Guild = spawn(fun() ->
|
||||
{reply, Reply, State2} = guild_voice_connection:voice_state_update(
|
||||
(request(10, false))#{connection_id => ConnectionA},
|
||||
maps:remove(guild_pid, State1)
|
||||
),
|
||||
Self ! {converted, Reply, State2},
|
||||
guild_stub_loop(State2)
|
||||
end),
|
||||
receive
|
||||
{converted, Reply, State2} ->
|
||||
?assertMatch(#{success := true}, Reply),
|
||||
assert_marked_sfu(Mesh, State2),
|
||||
?assertEqual(lists:sort(Mesh), lists:sort(drain_voice_server_updates(2)))
|
||||
after 2000 ->
|
||||
error(explicit_switch_did_not_convert)
|
||||
end,
|
||||
Guild ! stop
|
||||
end).
|
||||
|
||||
moderator_move_into_a_mesh_is_refused_test() ->
|
||||
with_stubs(grant, fun(State0) ->
|
||||
{Mesh, State1} = join_mesh([10, 11], State0),
|
||||
State2 = with_sfu_voice_state(<<"moved">>, 12, ?OTHER_CHANNEL_ID, State1),
|
||||
{reply, Reply, State3} = guild_voice:move_member(
|
||||
move_request(12, ?CHANNEL_ID), State2
|
||||
),
|
||||
?assertEqual({error, permission_denied, voice_p2p_consent_required}, Reply),
|
||||
?assert(maps:is_key(<<"moved">>, maps:get(voice_states, State3))),
|
||||
assert_untouched(Mesh, State2, State3)
|
||||
end).
|
||||
|
||||
moderator_move_into_an_empty_pinned_channel_moves_on_sfu_test() ->
|
||||
with_stubs(grant, fun(State0) ->
|
||||
State1 = with_sfu_voice_state(<<"moved">>, 12, ?OTHER_CHANNEL_ID, State0),
|
||||
{reply, Reply, _State2} = guild_voice:move_member(
|
||||
move_request(12, ?PINNED_CHANNEL_ID), State1
|
||||
),
|
||||
?assertMatch(#{success := true, needs_token := true}, Reply)
|
||||
end).
|
||||
|
||||
moderator_move_out_of_a_mesh_gets_an_sfu_grant_test() ->
|
||||
with_stubs(grant, fun(State0) ->
|
||||
{[ConnectionA, ConnectionB], State1} = join_mesh([10, 11], State0),
|
||||
{reply, #{success := true, needs_token := true, session_data := SessionData}, State2} =
|
||||
guild_voice:move_member(move_request(11, ?OTHER_CHANNEL_ID), State1),
|
||||
?assertEqual([ConnectionA], maps:keys(maps:get(voice_states, State2))),
|
||||
?assertEqual(true, p2p_flag(ConnectionA, State2)),
|
||||
GuildPid = maps:get(guild_pid, State2),
|
||||
GuildPid ! {serve, State2},
|
||||
ok = guild_voice_move_execute:send_single_voice_server_update(
|
||||
?GUILD_ID, ?OTHER_CHANNEL_ID, hd(SessionData), GuildPid
|
||||
),
|
||||
?assertEqual([ConnectionB], drain_voice_server_updates(1)),
|
||||
?assertNot(maps:is_key(<<"p2p">>, last_token_request()))
|
||||
end).
|
||||
|
||||
client_move_into_a_mesh_joins_it_test() ->
|
||||
with_stubs(grant, fun(State0) ->
|
||||
{[ConnectionA], State1} = join_mesh([10], State0),
|
||||
State2 = with_sfu_voice_state(<<"moved">>, 12, ?OTHER_CHANNEL_ID, State1),
|
||||
{reply, Reply, State3} = guild_voice_connection:voice_state_update(
|
||||
(request(12, true))#{connection_id => <<"moved">>}, State2
|
||||
),
|
||||
?assertMatch(#{success := true, p2p := true, connection_id := <<"moved">>}, Reply),
|
||||
?assertEqual(true, p2p_flag(<<"moved">>, State3)),
|
||||
?assertEqual(true, p2p_flag(ConnectionA, State3)),
|
||||
?assertEqual(#{}, maps:get(pending_voice_connections, State3))
|
||||
end).
|
||||
|
||||
client_move_into_a_mesh_without_agreement_is_rejected_test() ->
|
||||
with_stubs(grant, fun(State0) ->
|
||||
{Mesh, State1} = join_mesh([10], State0),
|
||||
State2 = with_sfu_voice_state(<<"moved">>, 12, ?OTHER_CHANNEL_ID, State1),
|
||||
{reply, Reply, State3} = guild_voice_connection:voice_state_update(
|
||||
(request(12, undefined))#{connection_id => <<"moved">>}, State2
|
||||
),
|
||||
?assertEqual({error, permission_denied, voice_p2p_consent_required}, Reply),
|
||||
?assertEqual(
|
||||
<<"200">>,
|
||||
maps:get(<<"channel_id">>, maps:get(<<"moved">>, maps:get(voice_states, State3)))
|
||||
),
|
||||
assert_untouched(Mesh, State2, State3)
|
||||
end).
|
||||
|
||||
signal_relay_allowed_test() ->
|
||||
with_stubs(grant, fun(State0) ->
|
||||
{[ConnectionA, ConnectionB], State1} = join_mesh([10, 11], State0),
|
||||
Data = #{<<"type">> => <<"offer">>, <<"sdp">> => <<"v=0">>, <<"id">> => 7},
|
||||
{noreply, State1} = guild_voice_handler:handle_cast(
|
||||
{voice_signal, signal(<<"sess-10">>, ConnectionB, Data)}, State1
|
||||
),
|
||||
?assertEqual(
|
||||
#{
|
||||
<<"guild_id">> => <<"999">>,
|
||||
<<"channel_id">> => <<"100">>,
|
||||
<<"from">> => ConnectionA,
|
||||
<<"user_id">> => <<"10">>,
|
||||
<<"data">> => Data
|
||||
},
|
||||
receive_voice_signal()
|
||||
)
|
||||
end).
|
||||
|
||||
signal_relay_denied_test() ->
|
||||
with_stubs(grant, fun(State0) ->
|
||||
{[ConnectionA, ConnectionB], State1} = join_mesh([10, 11], State0),
|
||||
Data = #{<<"type">> => <<"candidate">>},
|
||||
Denied = [
|
||||
signal(<<"sess-12">>, ConnectionB, Data),
|
||||
signal(<<"sess-10">>, ConnectionA, Data),
|
||||
signal(<<"sess-10">>, <<"unknown">>, Data),
|
||||
(signal(<<"sess-10">>, ConnectionB, Data))#{channel_id => 200}
|
||||
],
|
||||
[
|
||||
{noreply, State1} = guild_voice_handler:handle_cast({voice_signal, Request}, State1)
|
||||
|| Request <- Denied
|
||||
],
|
||||
State2 = guild_voice_region:switch_to_sfu(?CHANNEL_ID, State1),
|
||||
{noreply, State2} = guild_voice_handler:handle_cast(
|
||||
{voice_signal, signal(<<"sess-10">>, ConnectionB, Data)}, State2
|
||||
),
|
||||
assert_no_voice_signal(),
|
||||
?assertEqual(2, length(served_voice_server_updates(State2)))
|
||||
end).
|
||||
|
||||
signal(SessionId, To, Data) ->
|
||||
#{session_id => SessionId, channel_id => ?CHANNEL_ID, to => To, data => Data}.
|
||||
|
||||
with_sfu_voice_state(ConnectionId, UserId, ChannelId, State) ->
|
||||
VoiceState = voice_state_utils:complete_voice_state(#{
|
||||
<<"guild_id">> => <<"999">>,
|
||||
<<"channel_id">> => integer_to_binary(ChannelId),
|
||||
<<"user_id">> => integer_to_binary(UserId),
|
||||
<<"connection_id">> => ConnectionId,
|
||||
<<"session_id">> => session_id(UserId),
|
||||
<<"member">> => member(UserId)
|
||||
}),
|
||||
VoiceStates = maps:get(voice_states, State),
|
||||
State#{voice_states => VoiceStates#{ConnectionId => VoiceState}}.
|
||||
|
||||
move_request(UserId, ChannelId) ->
|
||||
#{
|
||||
user_id => UserId,
|
||||
moderator_id => 10,
|
||||
channel_id => ChannelId,
|
||||
mute => false,
|
||||
deaf => false
|
||||
}.
|
||||
|
||||
assert_converted(Mesh, State) ->
|
||||
assert_marked_sfu(Mesh, State),
|
||||
?assertEqual(lists:sort(Mesh), lists:sort(served_voice_server_updates(State))).
|
||||
|
||||
assert_marked_sfu(Mesh, State) ->
|
||||
[?assertEqual(false, p2p_flag(ConnectionId, State)) || ConnectionId <- Mesh],
|
||||
Broadcasts = drain_voice_state_updates(),
|
||||
[?assert(lists:member({ConnectionId, false}, Broadcasts)) || ConnectionId <- Mesh].
|
||||
|
||||
assert_untouched(Mesh, Before, After) ->
|
||||
VoiceStates = maps:get(voice_states, Before),
|
||||
[
|
||||
?assertEqual(
|
||||
maps:get(ConnectionId, VoiceStates),
|
||||
maps:get(ConnectionId, maps:get(voice_states, After))
|
||||
)
|
||||
|| ConnectionId <- Mesh
|
||||
],
|
||||
?assertEqual(
|
||||
maps:get(pending_voice_connections, Before), maps:get(pending_voice_connections, After)
|
||||
),
|
||||
Broadcasts = drain_voice_state_updates(),
|
||||
[?assertNot(lists:member({ConnectionId, false}, Broadcasts)) || ConnectionId <- Mesh],
|
||||
?assertEqual([], served_voice_server_updates(After, 0)).
|
||||
|
||||
served_voice_server_updates(State) ->
|
||||
served_voice_server_updates(
|
||||
State,
|
||||
maps:size(
|
||||
voice_state_utils:channel_voice_states(?CHANNEL_ID, maps:get(voice_states, State))
|
||||
)
|
||||
).
|
||||
|
||||
served_voice_server_updates(State, Expected) ->
|
||||
maps:get(guild_pid, State) ! {serve, State},
|
||||
drain_voice_server_updates(Expected).
|
||||
|
||||
drain_voice_server_updates(0) ->
|
||||
receive
|
||||
{'$gen_cast', {dispatch, voice_server_update, Payload}} ->
|
||||
error({unexpected_voice_server_update, Payload})
|
||||
after 100 ->
|
||||
[]
|
||||
end;
|
||||
drain_voice_server_updates(Remaining) ->
|
||||
receive
|
||||
{'$gen_cast', {dispatch, voice_server_update, Payload}} ->
|
||||
?assertEqual(<<"tok">>, maps:get(<<"token">>, Payload)),
|
||||
?assertEqual(<<"wss://voice.example">>, maps:get(<<"endpoint">>, Payload)),
|
||||
?assertNot(maps:is_key(<<"p2p">>, Payload)),
|
||||
[maps:get(<<"connection_id">>, Payload) | drain_voice_server_updates(Remaining - 1)]
|
||||
after 300 ->
|
||||
[]
|
||||
end.
|
||||
|
||||
drain_voice_state_updates() ->
|
||||
receive
|
||||
{'$gen_cast', {dispatch, voice_state_update, {pre_encoded, Encoded}}} ->
|
||||
#{<<"connection_id">> := ConnectionId, <<"p2p">> := P2p} = json:decode(Encoded),
|
||||
[{ConnectionId, P2p} | drain_voice_state_updates()]
|
||||
after 200 ->
|
||||
[]
|
||||
end.
|
||||
|
||||
receive_voice_signal() ->
|
||||
receive
|
||||
{'$gen_cast', {dispatch, voice_signal, Payload}} -> Payload
|
||||
after 1000 ->
|
||||
error(voice_signal_not_dispatched)
|
||||
end.
|
||||
|
||||
assert_no_voice_signal() ->
|
||||
receive
|
||||
{'$gen_cast', {dispatch, voice_signal, Payload}} ->
|
||||
error({unexpected_voice_signal, Payload})
|
||||
after 100 ->
|
||||
ok
|
||||
end.
|
||||
|
||||
p2p_flag(ConnectionId, State) ->
|
||||
maps:get(<<"p2p">>, maps:get(ConnectionId, maps:get(voice_states, State))).
|
||||
|
||||
join_mesh(UserIds, State0) ->
|
||||
join_mesh(UserIds, ?CHANNEL_ID, State0).
|
||||
|
||||
join_mesh(UserIds, ChannelId, State0) ->
|
||||
lists:foldl(
|
||||
fun(UserId, {ConnectionIds, State}) ->
|
||||
{reply, #{p2p := true, connection_id := ConnectionId}, NewState} =
|
||||
guild_voice_connection:voice_state_update(
|
||||
(request(UserId, true))#{channel_id => ChannelId}, State
|
||||
),
|
||||
{ConnectionIds ++ [ConnectionId], NewState}
|
||||
end,
|
||||
{[], State0},
|
||||
UserIds
|
||||
).
|
||||
|
||||
join(UserId, P2p, State) ->
|
||||
{reply, Reply, NewState} = guild_voice_connection:voice_state_update(
|
||||
request(UserId, P2p), State
|
||||
),
|
||||
{Reply, NewState}.
|
||||
|
||||
request(UserId, P2p) ->
|
||||
#{
|
||||
user_id => UserId,
|
||||
channel_id => ?CHANNEL_ID,
|
||||
session_id => session_id(UserId),
|
||||
p2p => P2p,
|
||||
country_code => <<"SE">>
|
||||
}.
|
||||
|
||||
session_id(UserId) ->
|
||||
<<"sess-", (integer_to_binary(UserId))/binary>>.
|
||||
|
||||
last_token_request() ->
|
||||
lists:last(token_requests()).
|
||||
|
||||
token_request_count() ->
|
||||
length(token_requests()).
|
||||
|
||||
token_requests() ->
|
||||
[
|
||||
Request
|
||||
|| {_Pid, {rpc_client, call, [#{<<"type">> := <<"voice_get_token">>} = Request]}, _Result} <-
|
||||
meck:history(rpc_client)
|
||||
].
|
||||
|
||||
with_stubs(Mode, Fun) ->
|
||||
erlang:put(p2p_stub_mode, Mode),
|
||||
meck:new(rpc_client, [passthrough, no_link, non_strict]),
|
||||
meck:expect(rpc_client, call, fun rpc_reply/1),
|
||||
meck:new(guild_voice_server, [passthrough, no_link]),
|
||||
meck:expect(guild_voice_server, resolve, fun(_GuildId, GuildPid) -> GuildPid end),
|
||||
GuildPid = spawn(fun guild_stub/0),
|
||||
Test = self(),
|
||||
Sink = spawn(fun() -> sink(Test) end),
|
||||
try
|
||||
Fun(base_state(GuildPid, Sink))
|
||||
after
|
||||
GuildPid ! stop,
|
||||
meck:unload(guild_voice_server),
|
||||
meck:unload(rpc_client),
|
||||
stop_sink(Sink)
|
||||
end.
|
||||
|
||||
sink(Test) ->
|
||||
receive
|
||||
Message ->
|
||||
Test ! Message,
|
||||
sink(Test)
|
||||
after 30000 ->
|
||||
ok
|
||||
end.
|
||||
|
||||
stop_sink(Sink) ->
|
||||
exit(Sink, kill),
|
||||
flush_mailbox().
|
||||
|
||||
flush_mailbox() ->
|
||||
receive
|
||||
_Message -> flush_mailbox()
|
||||
after 0 ->
|
||||
ok
|
||||
end.
|
||||
|
||||
rpc_reply(#{<<"type">> := <<"voice_get_token">>} = Request) ->
|
||||
ConnectionId = maps:get(
|
||||
<<"connection_id">>,
|
||||
Request,
|
||||
<<"conn-", (integer_to_binary(erlang:unique_integer([positive])))/binary>>
|
||||
),
|
||||
case {maps:get(<<"p2p">>, Request, false), erlang:get(p2p_stub_mode)} of
|
||||
{true, grant} ->
|
||||
p2p_grant(ConnectionId);
|
||||
{true, {cap, Max}} ->
|
||||
case maps:get(<<"p2p_participant_count">>, Request) > Max of
|
||||
true -> {ok, #{<<"p2p">> => false, <<"declineReason">> => <<"channel_full">>}};
|
||||
false -> p2p_grant(ConnectionId)
|
||||
end;
|
||||
_ ->
|
||||
{ok, #{
|
||||
<<"token">> => <<"tok">>,
|
||||
<<"endpoint">> => <<"wss://voice.example">>,
|
||||
<<"connectionId">> => ConnectionId,
|
||||
<<"regionId">> => <<"us-east">>,
|
||||
<<"serverId">> => <<"voice-1">>
|
||||
}}
|
||||
end;
|
||||
rpc_reply(_Request) ->
|
||||
{ok, #{}}.
|
||||
|
||||
p2p_grant(ConnectionId) ->
|
||||
{ok, #{
|
||||
<<"p2p">> => true,
|
||||
<<"connectionId">> => ConnectionId,
|
||||
<<"iceServers">> => [#{<<"urls">> => [<<"stun:stun.example:3478">>]}]
|
||||
}}.
|
||||
|
||||
guild_stub() ->
|
||||
receive
|
||||
{serve, State} -> guild_stub_loop(State);
|
||||
stop -> ok
|
||||
after 30000 ->
|
||||
ok
|
||||
end.
|
||||
|
||||
guild_stub_loop(State) ->
|
||||
receive
|
||||
{serve, NewState} ->
|
||||
guild_stub_loop(NewState);
|
||||
{'$gen_call', From, {get_voice_guild_state}} ->
|
||||
gen_server:reply(From, State),
|
||||
guild_stub_loop(State);
|
||||
{'$gen_call', From, _Request} ->
|
||||
gen_server:reply(From, ok),
|
||||
guild_stub_loop(State);
|
||||
stop ->
|
||||
ok;
|
||||
_Other ->
|
||||
guild_stub_loop(State)
|
||||
after 30000 ->
|
||||
ok
|
||||
end.
|
||||
|
||||
base_state(GuildPid, Sink) ->
|
||||
UserIds = [10, 11, 12, 13, 14],
|
||||
#{
|
||||
id => ?GUILD_ID,
|
||||
guild_pid => GuildPid,
|
||||
data => #{
|
||||
<<"id">> => <<"999">>,
|
||||
<<"guild">> => #{<<"owner_id">> => <<"10">>},
|
||||
<<"channels">> => [
|
||||
#{<<"id">> => <<"100">>, <<"type">> => 2, <<"user_limit">> => 0},
|
||||
#{<<"id">> => <<"200">>, <<"type">> => 2, <<"user_limit">> => 0},
|
||||
#{
|
||||
<<"id">> => <<"300">>,
|
||||
<<"type">> => 2,
|
||||
<<"user_limit">> => 0,
|
||||
<<"rtc_p2p">> => true
|
||||
}
|
||||
],
|
||||
<<"members">> => [member(UserId) || UserId <- UserIds]
|
||||
},
|
||||
sessions => maps:from_list([
|
||||
{session_id(UserId), #{user_id => UserId, pid => Sink, pending_connect => false}}
|
||||
|| UserId <- UserIds
|
||||
]),
|
||||
voice_states => #{},
|
||||
pending_voice_connections => #{},
|
||||
test_perm_fun => fun(_UserId) ->
|
||||
constants:view_channel_permission() bor constants:connect_permission()
|
||||
end
|
||||
}.
|
||||
|
||||
member(UserId) ->
|
||||
#{
|
||||
<<"user">> => #{<<"id">> => integer_to_binary(UserId)},
|
||||
<<"mute">> => false,
|
||||
<<"deaf">> => false
|
||||
}.
|
||||
@@ -284,6 +284,40 @@ build_session_data_normalizes_current_user_for_ready_test() ->
|
||||
?assertEqual(1, maps:get(<<"mention_flags">>, UserData)),
|
||||
?assertEqual(false, maps:get(<<"is_staff">>, UserData)).
|
||||
|
||||
build_session_data_stores_the_geoip_country_code_test() ->
|
||||
UserDataMap = #{
|
||||
<<"id">> => <<"123">>,
|
||||
<<"username">> => <<"tester">>,
|
||||
<<"discriminator">> => <<"0001">>,
|
||||
<<"avatar">> => null,
|
||||
<<"flags">> => 0
|
||||
},
|
||||
IdentifyData = #{properties => #{}, token => <<"token">>, presence => null},
|
||||
Data = fun(CountryCode) ->
|
||||
#{
|
||||
<<"guilds">> => [],
|
||||
<<"user">> => UserDataMap,
|
||||
<<"user_settings">> => null,
|
||||
<<"geoip_country_code">> => CountryCode
|
||||
}
|
||||
end,
|
||||
CountryCode = fun(Value) ->
|
||||
maps:get(
|
||||
geoip_country_code, build_test_session_data(Data(Value), IdentifyData, UserDataMap)
|
||||
)
|
||||
end,
|
||||
?assertEqual(<<"SE">>, CountryCode(<<"SE">>)),
|
||||
?assertEqual(undefined, CountryCode(null)),
|
||||
?assertEqual(
|
||||
undefined,
|
||||
maps:get(
|
||||
geoip_country_code,
|
||||
build_test_session_data(
|
||||
maps:remove(<<"geoip_country_code">>, Data(null)), IdentifyData, UserDataMap
|
||||
)
|
||||
)
|
||||
).
|
||||
|
||||
build_session_data_filters_guilds_for_identify_shard_test() ->
|
||||
Guild0 = guild_id_for_shard(0, 4, 0),
|
||||
Guild1 = guild_id_for_shard(1, 4, 0),
|
||||
|
||||
@@ -0,0 +1,242 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(voice_p2p_tests).
|
||||
-typing([eqwalizer]).
|
||||
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
peer(ConnectionId, SessionId, P2p) ->
|
||||
#{
|
||||
<<"connection_id">> => ConnectionId,
|
||||
<<"session_id">> => SessionId,
|
||||
<<"user_id">> => <<"10">>,
|
||||
<<"p2p">> => P2p
|
||||
}.
|
||||
|
||||
mesh(Count) ->
|
||||
[
|
||||
peer(integer_to_binary(Index), <<"sess-", (integer_to_binary(Index))/binary>>, true)
|
||||
|| Index <- lists:seq(1, Count)
|
||||
].
|
||||
|
||||
agreed_requires_an_explicit_true_from_a_user_test() ->
|
||||
?assert(voice_p2p:agreed(true, false)),
|
||||
?assertNot(voice_p2p:agreed(true, true)),
|
||||
?assertNot(voice_p2p:agreed(false, false)),
|
||||
?assertNot(voice_p2p:agreed(undefined, false)),
|
||||
?assertNot(voice_p2p:agreed(<<"true">>, false)).
|
||||
|
||||
declined_requires_an_explicit_false_test() ->
|
||||
?assert(voice_p2p:declined(false)),
|
||||
?assertNot(voice_p2p:declined(true)),
|
||||
?assertNot(voice_p2p:declined(undefined)),
|
||||
?assertNot(voice_p2p:declined(null)).
|
||||
|
||||
channel_mode_test() ->
|
||||
?assertEqual(empty, voice_p2p:channel_mode([])),
|
||||
?assertEqual(p2p, voice_p2p:channel_mode(mesh(2))),
|
||||
?assertEqual(sfu, voice_p2p:channel_mode([peer(<<"a">>, <<"s">>, false), #{}])),
|
||||
?assertEqual(mixed, voice_p2p:channel_mode([peer(<<"a">>, <<"s">>, false) | mesh(1)])).
|
||||
|
||||
pinned_reads_the_channel_flag_test() ->
|
||||
?assert(voice_p2p:pinned(#{<<"rtc_p2p">> => true})),
|
||||
?assertNot(voice_p2p:pinned(#{<<"rtc_p2p">> => false})),
|
||||
?assertNot(voice_p2p:pinned(#{})),
|
||||
?assertNot(voice_p2p:pinned(undefined)).
|
||||
|
||||
join_decision_for_a_live_mesh_test() ->
|
||||
?assertEqual({p2p, joiner, 2}, voice_p2p:join_decision(true, mesh(1), false, false)),
|
||||
?assertEqual({p2p, joiner, 4}, voice_p2p:join_decision(true, mesh(3), false, false)),
|
||||
?assertEqual(
|
||||
{reject, voice_p2p_consent_required},
|
||||
voice_p2p:join_decision(false, mesh(3), false, false)
|
||||
),
|
||||
?assertEqual(
|
||||
{reject, voice_p2p_consent_required},
|
||||
voice_p2p:join_decision(false, mesh(4), false, false)
|
||||
),
|
||||
?assertEqual(
|
||||
{reject, voice_channel_full}, voice_p2p:join_decision(true, mesh(4), false, false)
|
||||
).
|
||||
|
||||
join_decision_for_an_empty_channel_test() ->
|
||||
?assertEqual({p2p, initiator, 1}, voice_p2p:join_decision(true, [], false, false)),
|
||||
?assertEqual({p2p, initiator, 1}, voice_p2p:join_decision(true, [], false, true)),
|
||||
?assertEqual(sfu, voice_p2p:join_decision(false, [], false, false)),
|
||||
?assertEqual(sfu, voice_p2p:join_decision(false, [], false, true)),
|
||||
?assertEqual(sfu, voice_p2p:join_decision(true, [], true, false)),
|
||||
?assertEqual(sfu, voice_p2p:join_decision(false, [], true, true)).
|
||||
|
||||
join_decision_for_an_sfu_call_ignores_the_flag_test() ->
|
||||
Sfu = [peer(<<"a">>, <<"s">>, false)],
|
||||
?assertEqual(sfu, voice_p2p:join_decision(true, Sfu, false, false)),
|
||||
?assertEqual(sfu, voice_p2p:join_decision(false, Sfu, false, true)).
|
||||
|
||||
admit_test() ->
|
||||
Grant = {p2p, <<"conn">>, [], 2},
|
||||
Token = #{token => <<"tok">>},
|
||||
?assertEqual(Grant, voice_p2p:admit({p2p, initiator, 1}, Grant)),
|
||||
?assertEqual(Grant, voice_p2p:admit({p2p, joiner, 3}, Grant)),
|
||||
?assertEqual(sfu, voice_p2p:admit({p2p, initiator, 1}, Token)),
|
||||
?assertEqual({reject, voice_p2p_unavailable}, voice_p2p:admit({p2p, joiner, 3}, Token)),
|
||||
?assertEqual(sfu, voice_p2p:admit(sfu, Token)),
|
||||
?assertEqual({reject, voice_token_failed}, voice_p2p:admit(sfu, Grant)).
|
||||
|
||||
admit_maps_a_size_decline_to_channel_full_test() ->
|
||||
Declined = {declined, voice_channel_full},
|
||||
?assertEqual({reject, voice_channel_full}, voice_p2p:admit({p2p, joiner, 3}, Declined)),
|
||||
?assertEqual({reject, voice_channel_full}, voice_p2p:admit({p2p, initiator, 1}, Declined)),
|
||||
?assertEqual({reject, voice_token_failed}, voice_p2p:admit(sfu, Declined)).
|
||||
|
||||
invariant_violation_test() ->
|
||||
?assertEqual(ok, voice_p2p:invariant_violation([])),
|
||||
?assertEqual(ok, voice_p2p:invariant_violation(mesh(4))),
|
||||
?assertEqual({error, voice_channel_full}, voice_p2p:invariant_violation(mesh(5))),
|
||||
?assertEqual(
|
||||
{error, voice_p2p_consent_required},
|
||||
voice_p2p:invariant_violation([peer(<<"a">>, <<"s">>, false) | mesh(1)])
|
||||
),
|
||||
?assertEqual(ok, voice_p2p:invariant_violation([peer(<<"a">>, <<"s">>, false)])).
|
||||
|
||||
invariant_violation_holds_a_mesh_to_the_configured_cap_test() ->
|
||||
?assertEqual(ok, voice_p2p:invariant_violation(mesh(2), 2)),
|
||||
?assertEqual({error, voice_channel_full}, voice_p2p:invariant_violation(mesh(3), 2)),
|
||||
?assertEqual(ok, voice_p2p:invariant_violation(mesh(4), undefined)),
|
||||
?assertEqual({error, voice_channel_full}, voice_p2p:invariant_violation(mesh(5), 9)),
|
||||
?assertEqual(ok, voice_p2p:invariant_violation([peer(<<"a">>, <<"s">>, false)], 1)).
|
||||
|
||||
reports_rejection_test() ->
|
||||
?assert(voice_p2p:reports_rejection(voice_p2p_consent_required)),
|
||||
?assert(voice_p2p:reports_rejection(voice_p2p_unavailable)),
|
||||
?assert(voice_p2p:reports_rejection(voice_channel_full)),
|
||||
?assertNot(voice_p2p:reports_rejection(voice_token_failed)).
|
||||
|
||||
to_sfu_clears_the_flag_and_bumps_the_version_test() ->
|
||||
VoiceState = (peer(<<"a">>, <<"s">>, true))#{<<"version">> => 3},
|
||||
Converted = voice_p2p:to_sfu(VoiceState),
|
||||
?assertEqual(false, maps:get(<<"p2p">>, Converted)),
|
||||
?assertEqual(4, maps:get(<<"version">>, Converted)).
|
||||
|
||||
add_to_token_request_test() ->
|
||||
Request = #{<<"type">> => <<"voice_get_token">>},
|
||||
?assertEqual(Request, voice_p2p:add_to_token_request(Request, sfu, <<"SE">>)),
|
||||
?assertEqual(
|
||||
Request#{
|
||||
<<"p2p">> => true,
|
||||
<<"p2p_initiator">> => true,
|
||||
<<"p2p_participant_count">> => 1,
|
||||
<<"country_code">> => <<"SE">>
|
||||
},
|
||||
voice_p2p:add_to_token_request(Request, {p2p, initiator, 1}, <<"SE">>)
|
||||
),
|
||||
?assertEqual(
|
||||
Request#{
|
||||
<<"p2p">> => true, <<"p2p_initiator">> => false, <<"p2p_participant_count">> => 3
|
||||
},
|
||||
voice_p2p:add_to_token_request(Request, {p2p, joiner, 3}, undefined)
|
||||
).
|
||||
|
||||
grant_reads_a_p2p_grant_test() ->
|
||||
Stun = #{<<"urls">> => [<<"stun:stun.example:3478">>], <<"ignored">> => true},
|
||||
Data = #{<<"p2p">> => true, <<"connectionId">> => <<"conn-1">>, <<"iceServers">> => [Stun]},
|
||||
?assertEqual(
|
||||
{p2p, <<"conn-1">>, [#{<<"urls">> => [<<"stun:stun.example:3478">>]}], 4},
|
||||
voice_p2p:grant(Data)
|
||||
).
|
||||
|
||||
grant_reads_the_configured_cap_test() ->
|
||||
Data = #{<<"p2p">> => true, <<"connectionId">> => <<"conn-1">>, <<"iceServers">> => []},
|
||||
?assertEqual(
|
||||
{p2p, <<"conn-1">>, [], 3}, voice_p2p:grant(Data#{<<"maxParticipants">> => 3})
|
||||
),
|
||||
?assertEqual(
|
||||
{p2p, <<"conn-1">>, [], 4}, voice_p2p:grant(Data#{<<"maxParticipants">> => 9})
|
||||
),
|
||||
?assertEqual(
|
||||
{p2p, <<"conn-1">>, [], 4}, voice_p2p:grant(Data#{<<"maxParticipants">> => 0})
|
||||
).
|
||||
|
||||
grant_keeps_only_urls_of_each_ice_server_test() ->
|
||||
Server = #{
|
||||
<<"urls">> => [<<"stun:stun.example:3478">>],
|
||||
<<"username">> => <<"user">>,
|
||||
<<"credential">> => <<"secret">>
|
||||
},
|
||||
Data = #{
|
||||
<<"p2p">> => true, <<"connectionId">> => <<"conn-1">>, <<"iceServers">> => [Server]
|
||||
},
|
||||
?assertEqual(
|
||||
{p2p, <<"conn-1">>, [#{<<"urls">> => [<<"stun:stun.example:3478">>]}], 4},
|
||||
voice_p2p:grant(Data)
|
||||
).
|
||||
|
||||
grant_reads_a_size_decline_test() ->
|
||||
?assertEqual(
|
||||
{declined, voice_channel_full},
|
||||
voice_p2p:grant(#{<<"p2p">> => false, <<"declineReason">> => <<"channel_full">>})
|
||||
),
|
||||
?assertEqual(
|
||||
sfu, voice_p2p:grant(#{<<"p2p">> => false, <<"declineReason">> => <<"unknown">>})
|
||||
).
|
||||
|
||||
grant_reads_a_token_response_as_sfu_test() ->
|
||||
?assertEqual(
|
||||
sfu,
|
||||
voice_p2p:grant(#{
|
||||
<<"token">> => <<"tok">>,
|
||||
<<"endpoint">> => <<"wss://voice.example">>,
|
||||
<<"connectionId">> => <<"conn-1">>
|
||||
})
|
||||
).
|
||||
|
||||
voice_server_update_shape_test() ->
|
||||
IceServers = [#{<<"urls">> => [<<"stun:stun.example:3478">>]}],
|
||||
?assertEqual(
|
||||
#{
|
||||
<<"p2p">> => true,
|
||||
<<"ice_servers">> => IceServers,
|
||||
<<"connection_id">> => <<"conn-1">>,
|
||||
<<"channel_id">> => <<"100">>,
|
||||
<<"guild_id">> => <<"999">>
|
||||
},
|
||||
voice_p2p:voice_server_update(<<"conn-1">>, 100, 999, IceServers)
|
||||
),
|
||||
?assertNot(
|
||||
maps:is_key(
|
||||
<<"guild_id">>, voice_p2p:voice_server_update(<<"conn-1">>, 100, null, IceServers)
|
||||
)
|
||||
).
|
||||
|
||||
signal_route_allows_a_mesh_peer_test() ->
|
||||
[Sender, Target] = mesh(2),
|
||||
?assertEqual({ok, Sender, Target}, voice_p2p:signal_route(mesh(2), <<"sess-1">>, <<"2">>)).
|
||||
|
||||
signal_route_denies_a_sender_outside_the_mesh_test() ->
|
||||
?assertEqual(error, voice_p2p:signal_route(mesh(2), <<"sess-9">>, <<"2">>)).
|
||||
|
||||
signal_route_denies_an_unknown_target_test() ->
|
||||
?assertEqual(error, voice_p2p:signal_route(mesh(2), <<"sess-1">>, <<"9">>)).
|
||||
|
||||
signal_route_denies_the_senders_own_connection_test() ->
|
||||
?assertEqual(error, voice_p2p:signal_route(mesh(2), <<"sess-1">>, <<"1">>)).
|
||||
|
||||
signal_route_denies_sfu_voice_states_test() ->
|
||||
Sfu = [peer(<<"1">>, <<"sess-1">>, false), peer(<<"2">>, <<"sess-2">>, false)],
|
||||
?assertEqual(error, voice_p2p:signal_route(Sfu, <<"sess-1">>, <<"2">>)),
|
||||
Mixed = [peer(<<"1">>, <<"sess-1">>, true), peer(<<"2">>, <<"sess-2">>, false)],
|
||||
?assertEqual(error, voice_p2p:signal_route(Mixed, <<"sess-1">>, <<"2">>)).
|
||||
|
||||
signal_payload_shape_test() ->
|
||||
[Sender | _] = mesh(1),
|
||||
Data = #{<<"type">> => <<"offer">>, <<"sdp">> => <<"v=0">>},
|
||||
?assertEqual(
|
||||
#{
|
||||
<<"guild_id">> => <<"999">>,
|
||||
<<"channel_id">> => <<"100">>,
|
||||
<<"from">> => <<"1">>,
|
||||
<<"user_id">> => <<"10">>,
|
||||
<<"data">> => Data
|
||||
},
|
||||
voice_p2p:signal_payload(999, 100, Sender, Data)
|
||||
),
|
||||
?assertNot(maps:is_key(<<"guild_id">>, voice_p2p:signal_payload(null, 100, Sender, Data))).
|
||||
Reference in new issue
Block a user