mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
492 lines
18 KiB
Erlang
492 lines
18 KiB
Erlang
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
-module(guild_voice_move_tests).
|
|
-typing([eqwalizer]).
|
|
|
|
-include_lib("eunit/include/eunit.hrl").
|
|
|
|
token_rpc_data() ->
|
|
#{
|
|
<<"token">> => <<"tok">>,
|
|
<<"endpoint">> => <<"wss://voice.example">>,
|
|
<<"connectionId">> => <<"new-conn">>,
|
|
<<"regionId">> => <<"us-east">>,
|
|
<<"serverId">> => <<"voice-1">>
|
|
}.
|
|
|
|
test_member(UserId) ->
|
|
#{
|
|
<<"user">> => #{<<"id">> => integer_to_binary(UserId)},
|
|
<<"mute">> => false,
|
|
<<"deaf">> => false
|
|
}.
|
|
|
|
existing_voice_state(ConnId, UserId, ChannelIdBin) ->
|
|
#{
|
|
<<"user_id">> => integer_to_binary(UserId),
|
|
<<"guild_id">> => <<"999">>,
|
|
<<"channel_id">> => ChannelIdBin,
|
|
<<"connection_id">> => ConnId,
|
|
<<"session_id">> => <<"sess1">>,
|
|
<<"self_mute">> => false,
|
|
<<"self_deaf">> => true,
|
|
<<"self_video">> => false,
|
|
<<"self_stream">> => false,
|
|
<<"is_mobile">> => false,
|
|
<<"suppress">> => false,
|
|
<<"mute">> => false,
|
|
<<"deaf">> => false,
|
|
<<"viewer_stream_keys">> => [],
|
|
<<"e2ee_capable">> => false,
|
|
<<"member">> => test_member(UserId),
|
|
<<"version">> => 3
|
|
}.
|
|
|
|
base_move_state(VoiceStates) ->
|
|
#{
|
|
id => 999,
|
|
data => #{
|
|
<<"id">> => <<"999">>,
|
|
<<"guild">> => #{<<"owner_id">> => <<"10">>},
|
|
<<"channels">> => [
|
|
#{<<"id">> => <<"100">>, <<"type">> => 2},
|
|
#{<<"id">> => <<"200">>, <<"type">> => 2}
|
|
],
|
|
<<"members">> => [test_member(10)]
|
|
},
|
|
sessions => #{},
|
|
voice_states => VoiceStates,
|
|
pending_voice_connections => #{}
|
|
}.
|
|
|
|
move_context() ->
|
|
#{
|
|
user_id => 10,
|
|
session_id => <<"sess1">>,
|
|
connection_id => <<"old-conn">>,
|
|
self_mute => false,
|
|
self_deaf => true,
|
|
self_video => false,
|
|
self_stream => false,
|
|
is_mobile => false,
|
|
viewer_stream_keys => undefined,
|
|
latitude => <<"1.0">>,
|
|
longitude => <<"2.0">>,
|
|
e2ee_capable => false,
|
|
bot => false
|
|
}.
|
|
|
|
with_token_rpc(Result, Fun) ->
|
|
drain_stale_dispatches(),
|
|
meck:new(rpc_client, [passthrough, no_link, non_strict]),
|
|
meck:expect(rpc_client, call, fun(_Request) -> Result end),
|
|
try
|
|
Fun()
|
|
after
|
|
meck:unload(rpc_client)
|
|
end.
|
|
|
|
with_guild_voice_server_stub(Fun) ->
|
|
meck:new(guild_voice_server, [passthrough, no_link]),
|
|
meck:expect(guild_voice_server, resolve, fun(_GuildId, GuildPid) -> GuildPid end),
|
|
try
|
|
Fun()
|
|
after
|
|
meck:unload(guild_voice_server)
|
|
end.
|
|
|
|
drain_stale_dispatches() ->
|
|
receive
|
|
{'$gen_cast', {dispatch, _Event, _Payload}} ->
|
|
drain_stale_dispatches()
|
|
after 0 ->
|
|
ok
|
|
end.
|
|
|
|
assert_no_dispatch() ->
|
|
receive
|
|
{'$gen_cast', {dispatch, Event, Payload}} ->
|
|
?assert(false, {unexpected_dispatch, Event, Payload})
|
|
after 50 ->
|
|
ok
|
|
end.
|
|
|
|
reply_map(Reply) when is_map(Reply) -> Reply.
|
|
|
|
run_client_move(VoiceStates, State) ->
|
|
{reply, Reply, NewState} = guild_voice_connection_move:handle_client_channel_move(
|
|
move_context(), 200, test_member(10), <<"old-conn">>, VoiceStates, State
|
|
),
|
|
{Reply, NewState}.
|
|
|
|
client_move_token_failure_preserves_connection_test() ->
|
|
with_token_rpc({error, timeout}, fun client_move_token_failure_scenario/0).
|
|
|
|
client_move_token_failure_scenario() ->
|
|
ExistingVS = existing_voice_state(<<"old-conn">>, 10, <<"100">>),
|
|
VoiceStates = #{<<"old-conn">> => ExistingVS},
|
|
State = base_move_state(VoiceStates),
|
|
{Error, NewState} = run_client_move(VoiceStates, State),
|
|
?assertMatch({error, _, voice_token_failed}, Error),
|
|
?assertEqual(
|
|
ExistingVS,
|
|
maps:get(<<"old-conn">>, maps:get(voice_states, NewState))
|
|
),
|
|
?assertEqual(#{}, maps:get(pending_voice_connections, NewState)),
|
|
assert_no_dispatch().
|
|
|
|
client_move_success_disconnects_and_stores_pending_test() ->
|
|
with_token_rpc({ok, token_rpc_data()}, fun client_move_success_scenario/0).
|
|
|
|
client_move_success_scenario() ->
|
|
ExistingVS = existing_voice_state(<<"old-conn">>, 10, <<"100">>),
|
|
VoiceStates = #{<<"old-conn">> => ExistingVS},
|
|
State = base_move_state(VoiceStates),
|
|
{Reply0, NewState} = run_client_move(VoiceStates, State),
|
|
Reply = reply_map(Reply0),
|
|
?assertEqual(true, maps:get(success, Reply)),
|
|
?assertEqual(true, maps:get(needs_token, Reply)),
|
|
?assertEqual(<<"tok">>, maps:get(token, Reply)),
|
|
?assertEqual(<<"new-conn">>, maps:get(connection_id, Reply)),
|
|
?assertNot(maps:is_key(<<"old-conn">>, maps:get(voice_states, NewState))),
|
|
Pending = maps:get(pending_voice_connections, NewState),
|
|
?assert(maps:is_key(<<"new-conn">>, Pending)),
|
|
Meta = maps:get(<<"new-conn">>, Pending),
|
|
?assert(is_binary(maps:get(token_nonce, Meta))),
|
|
?assert(maps:get(expires_at, Meta) > maps:get(created_at, Meta)),
|
|
?assertEqual(
|
|
ok,
|
|
guild_voice_connection_pending:validate_pending_nonce_and_expiry(
|
|
maps:get(token_nonce, Meta), Meta
|
|
)
|
|
).
|
|
|
|
client_move_reply_voice_state_is_complete_and_coordinate_free_test() ->
|
|
with_token_rpc({ok, token_rpc_data()}, fun client_move_reply_shape_scenario/0).
|
|
|
|
client_move_reply_shape_scenario() ->
|
|
ExistingVS = existing_voice_state(<<"old-conn">>, 10, <<"100">>),
|
|
VoiceStates = #{<<"old-conn">> => ExistingVS},
|
|
State = base_move_state(VoiceStates),
|
|
{Reply0, NewState} = run_client_move(VoiceStates, State),
|
|
Reply = reply_map(Reply0),
|
|
ReplyVS = maps:get(voice_state, Reply),
|
|
?assertNot(maps:is_key(<<"latitude">>, ReplyVS)),
|
|
?assertNot(maps:is_key(<<"longitude">>, ReplyVS)),
|
|
?assertEqual(<<"200">>, maps:get(<<"channel_id">>, ReplyVS)),
|
|
?assertEqual(<<"new-conn">>, maps:get(<<"connection_id">>, ReplyVS)),
|
|
?assertEqual(true, maps:get(<<"self_deaf">>, ReplyVS)),
|
|
assert_complete_voice_state_fields(ReplyVS),
|
|
PendingVS = maps:get(
|
|
voice_state, maps:get(<<"new-conn">>, maps:get(pending_voice_connections, NewState))
|
|
),
|
|
?assertEqual(<<"1.0">>, maps:get(<<"latitude">>, PendingVS)).
|
|
|
|
assert_complete_voice_state_fields(VS) ->
|
|
Fields = [
|
|
<<"member">>,
|
|
<<"mute">>,
|
|
<<"deaf">>,
|
|
<<"suppress">>,
|
|
<<"viewer_stream_keys">>,
|
|
<<"e2ee_capable">>,
|
|
<<"version">>,
|
|
<<"session_id">>
|
|
],
|
|
lists:foreach(fun(Field) -> ?assert(maps:is_key(Field, VS), {missing, Field}) end, Fields).
|
|
|
|
moderator_move_reply_includes_session_data_test() ->
|
|
ExistingVS = existing_voice_state(<<"old-conn">>, 10, <<"100">>),
|
|
VoiceStates = #{<<"old-conn">> => ExistingVS},
|
|
State = base_move_state(VoiceStates),
|
|
{reply, Reply0, NewState} = guild_voice_move_execute:handle_move(
|
|
#{<<"old-conn">> => ExistingVS}, 200, 10, 10, <<"old-conn">>, VoiceStates, State
|
|
),
|
|
Reply = reply_map(Reply0),
|
|
?assertEqual(true, maps:get(success, Reply)),
|
|
?assertEqual(true, maps:get(needs_token, Reply)),
|
|
[SessionInfo] = maps:get(session_data, Reply),
|
|
?assertEqual(<<"old-conn">>, maps:get(connection_id, SessionInfo)),
|
|
?assertEqual(<<"sess1">>, maps:get(session_id, SessionInfo)),
|
|
?assertEqual(true, maps:get(self_deaf, SessionInfo)),
|
|
?assertEqual(false, maps:get(e2ee_capable, SessionInfo)),
|
|
?assertNot(maps:is_key(<<"old-conn">>, maps:get(voice_states, NewState))).
|
|
|
|
moderator_move_to_unknown_channel_fails_test() ->
|
|
ExistingVS = existing_voice_state(<<"old-conn">>, 10, <<"100">>),
|
|
VoiceStates = #{<<"old-conn">> => ExistingVS},
|
|
State = base_move_state(VoiceStates),
|
|
{reply, Error, _} = guild_voice_move_execute:handle_move(
|
|
#{<<"old-conn">> => ExistingVS}, 12345, 10, 10, <<"old-conn">>, VoiceStates, State
|
|
),
|
|
?assertMatch({error, _, voice_channel_not_found}, Error).
|
|
|
|
moderator_move_without_connections_fails_test() ->
|
|
State = base_move_state(#{}),
|
|
{reply, ErrorWithConn, _} = guild_voice_move_execute:handle_move(
|
|
#{}, 200, 10, 10, <<"old-conn">>, #{}, State
|
|
),
|
|
?assertMatch({error, _, voice_connection_not_found}, ErrorWithConn),
|
|
{reply, ErrorNoConn, _} = guild_voice_move_execute:handle_move(
|
|
#{}, 200, 10, 10, null, #{}, State
|
|
),
|
|
?assertMatch({error, _, voice_user_not_in_voice}, ErrorNoConn).
|
|
|
|
moderator_disconnect_move_purges_counts_cache_test() ->
|
|
RegionId = <<"region-move-test">>,
|
|
VS = (existing_voice_state(<<"move-count-conn">>, 10, <<"100">>))#{
|
|
<<"region_id">> => RegionId,
|
|
<<"server_id">> => <<"server-move-test">>
|
|
},
|
|
_ = voice_state_counts_cache:upsert_voice_state(VS),
|
|
?assertEqual(1, region_count(RegionId)),
|
|
VoiceStates = #{<<"move-count-conn">> => VS},
|
|
State = base_move_state(VoiceStates),
|
|
{reply, Reply0, NewState} = guild_voice_move_execute:handle_move(
|
|
VoiceStates, null, 10, 20, <<"move-count-conn">>, VoiceStates, State
|
|
),
|
|
Reply = reply_map(Reply0),
|
|
?assertEqual(true, maps:get(success, Reply)),
|
|
?assertEqual(10, maps:get(user_id, Reply)),
|
|
?assertEqual(0, region_count(RegionId)),
|
|
?assertNot(maps:is_key(<<"move-count-conn">>, maps:get(voice_states, NewState))).
|
|
|
|
region_count(RegionId) ->
|
|
Counts = voice_state_counts_cache:get_local_counts(),
|
|
Regions = maps:get(<<"regions">>, Counts, []),
|
|
lists:foldl(
|
|
fun(Entry, Acc) -> add_region_entry_count(Entry, RegionId, Acc) end,
|
|
0,
|
|
Regions
|
|
).
|
|
|
|
add_region_entry_count(Entry, RegionId, Acc) ->
|
|
case maps:get(<<"region_id">>, Entry, undefined) of
|
|
RegionId -> Acc + maps:get(<<"voice_state_count">>, Entry, 0);
|
|
_ -> Acc
|
|
end.
|
|
|
|
guild_server_stub(StateData, TestPid) ->
|
|
spawn_link(fun() -> guild_server_stub_loop(StateData, TestPid) end).
|
|
|
|
guild_server_stub_loop(StateData, TestPid) ->
|
|
receive
|
|
{'$gen_call', From, {get_voice_guild_state}} ->
|
|
gen:reply(From, undefined),
|
|
guild_server_stub_loop(StateData, TestPid);
|
|
{'$gen_call', From, {get_sessions}} ->
|
|
gen:reply(From, StateData),
|
|
guild_server_stub_loop(StateData, TestPid);
|
|
{'$gen_call', From, {store_pending_connection, ConnId, Meta}} ->
|
|
TestPid ! {stored_pending, ConnId, Meta},
|
|
gen:reply(From, ok),
|
|
guild_server_stub_loop(StateData, TestPid)
|
|
after 5000 ->
|
|
ok
|
|
end.
|
|
|
|
receive_stored_pending() ->
|
|
receive
|
|
{stored_pending, ConnId, Meta} -> {ConnId, Meta}
|
|
after 1000 ->
|
|
error(pending_connection_not_stored)
|
|
end.
|
|
|
|
receive_voice_server_update() ->
|
|
receive
|
|
{'$gen_cast', {dispatch, voice_server_update, Payload}} -> Payload
|
|
after 1000 ->
|
|
error(voice_server_update_not_dispatched)
|
|
end.
|
|
|
|
send_moderator_move_update(StateData) ->
|
|
ExistingVS = existing_voice_state(<<"old-conn">>, 10, <<"100">>),
|
|
StubPid = guild_server_stub(StateData, self()),
|
|
SessionInfo = guild_voice_state:extract_session_info_from_voice_state(
|
|
<<"old-conn">>, ExistingVS
|
|
),
|
|
ok = guild_voice_move_execute:send_single_voice_server_update(
|
|
999, 200, SessionInfo, StubPid
|
|
),
|
|
receive_stored_pending().
|
|
|
|
moderator_move_token_delivery_stores_restorable_pending_test() ->
|
|
with_token_rpc({ok, token_rpc_data()}, fun() ->
|
|
with_guild_voice_server_stub(fun moderator_move_pending_scenario/0)
|
|
end).
|
|
|
|
moderator_move_pending_scenario() ->
|
|
StateData = (base_move_state(#{}))#{
|
|
sessions => #{<<"sess1">> => #{pid => self(), user_id => 10}}
|
|
},
|
|
{ConnId, Meta} = send_moderator_move_update(StateData),
|
|
?assertEqual(<<"new-conn">>, ConnId),
|
|
?assert(is_binary(maps:get(token_nonce, Meta))),
|
|
?assert(is_integer(maps:get(created_at, Meta))),
|
|
?assert(maps:get(expires_at, Meta) > maps:get(created_at, Meta)),
|
|
?assertEqual(10, maps:get(user_id, Meta)),
|
|
?assertEqual(200, maps:get(channel_id, Meta)),
|
|
?assertEqual(true, maps:get(self_deaf, Meta)),
|
|
?assertEqual(
|
|
ok,
|
|
guild_voice_connection_pending:validate_pending_nonce_and_expiry(
|
|
maps:get(token_nonce, Meta), Meta
|
|
)
|
|
),
|
|
?assertEqual(
|
|
{error, voice_nonce_mismatch},
|
|
guild_voice_connection_pending:validate_pending_nonce_and_expiry(
|
|
<<"wrong-nonce">>, Meta
|
|
)
|
|
),
|
|
Payload = receive_voice_server_update(),
|
|
?assertEqual(<<"tok">>, maps:get(<<"token">>, Payload)),
|
|
?assertEqual(<<"new-conn">>, maps:get(<<"connection_id">>, Payload)),
|
|
assert_pending_restores(ConnId, Meta).
|
|
|
|
assert_pending_restores(ConnId, Meta) ->
|
|
RestoreState = #{
|
|
id => 999,
|
|
sessions => #{},
|
|
voice_states => #{},
|
|
pending_voice_connections => #{ConnId => Meta}
|
|
},
|
|
{ok, RestoredVoiceStates, _RestoredState} =
|
|
guild_voice_connection_pending:maybe_restore_pending_connection(
|
|
ConnId, 200, 10, #{}, RestoreState
|
|
),
|
|
RestoredVS = maps:get(ConnId, RestoredVoiceStates),
|
|
?assertEqual(<<"200">>, maps:get(<<"channel_id">>, RestoredVS)),
|
|
?assertEqual(<<"10">>, maps:get(<<"user_id">>, RestoredVS)),
|
|
?assertEqual(true, maps:get(<<"self_deaf">>, RestoredVS)),
|
|
?assert(maps:is_key(<<"member">>, RestoredVS)),
|
|
?assert(maps:is_key(<<"version">>, RestoredVS)).
|
|
|
|
moderator_move_pending_sweep_expires_stale_entries_test() ->
|
|
with_token_rpc({ok, token_rpc_data()}, fun() ->
|
|
with_guild_voice_server_stub(fun moderator_move_sweep_scenario/0)
|
|
end).
|
|
|
|
moderator_move_sweep_scenario() ->
|
|
{ConnId, Meta} = send_moderator_move_update(base_move_state(#{})),
|
|
ExpiredMeta = Meta#{
|
|
created_at => maps:get(created_at, Meta) - 600000,
|
|
expires_at => maps:get(expires_at, Meta) - 600000
|
|
},
|
|
SweepState = #{
|
|
id => 999,
|
|
sessions => #{},
|
|
voice_states => #{},
|
|
pending_voice_connections => #{ConnId => ExpiredMeta}
|
|
},
|
|
SweptState = guild_voice_connection_pending:sweep_expired_pending_joins(SweepState),
|
|
?assertEqual(#{}, maps:get(pending_voice_connections, SweptState)),
|
|
?assertEqual(
|
|
{error, voice_pending_expired},
|
|
guild_voice_connection_pending:validate_pending_nonce_and_expiry(
|
|
maps:get(token_nonce, ExpiredMeta), ExpiredMeta
|
|
)
|
|
).
|
|
|
|
private_member(UserId) ->
|
|
#{
|
|
<<"user">> => #{<<"id">> => integer_to_binary(UserId)},
|
|
<<"roles">> => [],
|
|
<<"mute">> => false,
|
|
<<"deaf">> => false
|
|
}.
|
|
|
|
private_move_state(VoiceStates, VirtualAccess) ->
|
|
#{
|
|
id => 999,
|
|
data => #{
|
|
<<"id">> => <<"999">>,
|
|
<<"guild">> => #{<<"owner_id">> => <<"20">>},
|
|
<<"roles">> => [#{<<"id">> => <<"999">>, <<"permissions">> => <<"0">>}],
|
|
<<"members">> => [private_member(10), private_member(20)],
|
|
<<"channels">> => [
|
|
#{<<"id">> => <<"100">>, <<"type">> => 2, <<"permission_overwrites">> => []},
|
|
#{<<"id">> => <<"200">>, <<"type">> => 2, <<"permission_overwrites">> => []}
|
|
]
|
|
},
|
|
sessions => #{<<"s1">> => #{pid => self(), user_id => 10}},
|
|
voice_states => VoiceStates,
|
|
pending_voice_connections => #{},
|
|
virtual_channel_access => VirtualAccess
|
|
}.
|
|
|
|
receive_channel_dispatch(Event) ->
|
|
receive
|
|
{'$gen_cast', {dispatch, Event, Payload}} -> Payload
|
|
after 1000 ->
|
|
error({dispatch_not_received, Event})
|
|
end.
|
|
|
|
moderator_move_into_private_channel_grants_lease_test() ->
|
|
drain_stale_dispatches(),
|
|
ExistingVS = existing_voice_state(<<"old-conn">>, 10, <<"100">>),
|
|
VoiceStates = #{<<"old-conn">> => ExistingVS},
|
|
State = private_move_state(VoiceStates, #{}),
|
|
{reply, Reply0, NewState} = guild_voice_move_execute:handle_move(
|
|
VoiceStates, 200, 10, 20, <<"old-conn">>, VoiceStates, State
|
|
),
|
|
Reply = reply_map(Reply0),
|
|
?assertEqual(true, maps:get(success, Reply)),
|
|
?assertEqual(true, maps:get(needs_token, Reply)),
|
|
?assert(guild_virtual_channel_access:has_virtual_access(10, 200, NewState)),
|
|
?assert(guild_virtual_channel_access:is_move_pending(10, 200, NewState)),
|
|
Payload = receive_channel_dispatch(channel_create),
|
|
?assertEqual(<<"200">>, maps:get(<<"id">>, Payload)),
|
|
?assertEqual(<<"999">>, maps:get(<<"guild_id">>, Payload)).
|
|
|
|
moderator_move_out_of_leased_channel_revokes_lease_test() ->
|
|
drain_stale_dispatches(),
|
|
ExistingVS = existing_voice_state(<<"old-conn">>, 10, <<"100">>),
|
|
VoiceStates = #{<<"old-conn">> => ExistingVS},
|
|
State = private_move_state(VoiceStates, #{10 => sets:from_list([100])}),
|
|
{reply, Reply0, NewState} = guild_voice_move_execute:handle_move(
|
|
VoiceStates, 200, 10, 20, <<"old-conn">>, VoiceStates, State
|
|
),
|
|
?assertEqual(true, maps:get(success, reply_map(Reply0))),
|
|
?assertNot(guild_virtual_channel_access:has_virtual_access(10, 100, NewState)),
|
|
?assert(guild_virtual_channel_access:has_virtual_access(10, 200, NewState)),
|
|
DeletePayload = receive_channel_dispatch(channel_delete),
|
|
?assertEqual(<<"100">>, maps:get(<<"id">>, DeletePayload)),
|
|
?assertEqual(<<"999">>, maps:get(<<"guild_id">>, DeletePayload)).
|
|
|
|
moderator_move_without_lease_dispatches_no_channel_delete_test() ->
|
|
drain_stale_dispatches(),
|
|
ExistingVS = existing_voice_state(<<"old-conn">>, 10, <<"100">>),
|
|
VoiceStates = #{<<"old-conn">> => ExistingVS},
|
|
State = (base_move_state(VoiceStates))#{
|
|
sessions => #{<<"s1">> => #{pid => self(), user_id => 10}}
|
|
},
|
|
{reply, Reply0, _NewState} = guild_voice_move_execute:handle_move(
|
|
VoiceStates, 200, 10, 10, <<"old-conn">>, VoiceStates, State
|
|
),
|
|
?assertEqual(true, maps:get(success, reply_map(Reply0))),
|
|
assert_no_channel_delete().
|
|
|
|
assert_no_channel_delete() ->
|
|
receive
|
|
{'$gen_cast', {dispatch, channel_delete, Payload}} ->
|
|
?assert(false, {unexpected_channel_delete, Payload})
|
|
after 50 ->
|
|
ok
|
|
end.
|
|
|
|
self_move_out_of_leased_channel_revokes_lease_test() ->
|
|
with_token_rpc({ok, token_rpc_data()}, fun self_move_revokes_lease_scenario/0).
|
|
|
|
self_move_revokes_lease_scenario() ->
|
|
ExistingVS = existing_voice_state(<<"old-conn">>, 10, <<"100">>),
|
|
VoiceStates = #{<<"old-conn">> => ExistingVS},
|
|
State = (base_move_state(VoiceStates))#{
|
|
sessions => #{<<"s1">> => #{pid => self(), user_id => 10}},
|
|
virtual_channel_access => #{10 => sets:from_list([100])}
|
|
},
|
|
{_Reply, NewState} = run_client_move(VoiceStates, State),
|
|
?assertNot(guild_virtual_channel_access:has_virtual_access(10, 100, NewState)),
|
|
DeletePayload = receive_channel_dispatch(channel_delete),
|
|
?assertEqual(<<"100">>, maps:get(<<"id">>, DeletePayload)).
|