fix(push): stop pushes for read, silent and muted messages (#3033)

This commit is contained in:
Hampus
2026-09-29 15:58:46 +02:00
committed by GitHub
parent 9908518f5b
commit b30ea361d3
45 changed files with 3393 additions and 268 deletions
@@ -0,0 +1,451 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(guild_push_engagement_tests).
-include_lib("eunit/include/eunit.hrl").
-define(GUILD_ID, 7000).
-define(CHANNEL_ID, 7100).
-define(USER, 10).
-define(AUTHOR, 20).
-define(MSG, 7200).
-define(GRACE_MS, 200).
desktop_active() ->
#{status => online, afk => false, mobile => false}.
phone_backgrounded() ->
#{status => online, afk => true, mobile => true}.
desktop_invisible() ->
#{status => invisible, afk => false, mobile => false}.
user_data() ->
#{<<"id">> => integer_to_binary(?USER), <<"username">> => <<"reader">>}.
published_presence(PresenceSessions) ->
presence_payload:build(
user_data(),
presence_status:get_current_status(PresenceSessions),
presence_status:get_flattened_mobile(PresenceSessions),
presence_status:get_flattened_afk(PresenceSessions),
null
).
guild_state(SessionPids) ->
#{
id => ?GUILD_ID,
data => #{
<<"guild">> => #{
<<"id">> => ?GUILD_ID,
<<"name">> => <<"Guild">>,
<<"owner_id">> => ?USER,
<<"default_message_notifications">> => 0
},
<<"members">> => #{?USER => #{<<"user">> => user_data(), <<"roles">> => []}},
<<"channels">> => [#{<<"id">> => ?CHANNEL_ID, <<"name">> => <<"general">>}],
<<"channel_index">> => #{
?CHANNEL_ID => #{<<"id">> => ?CHANNEL_ID, <<"name">> => <<"general">>}
},
<<"roles">> => [],
<<"role_index">> => #{}
},
sessions => maps:map(
fun(Sid, Pid) -> #{session_id => Sid, user_id => ?USER, pid => Pid} end,
SessionPids
),
member_presence => ets:new(test_member_presence, [set, public]),
presence_subscriptions => #{?USER => 1},
member_list_subscriptions => guild_member_list_subs:new()
}.
apply_presence(PresenceSessions, State) ->
{noreply, NewState} = guild_presence:handle_bus_presence(
?USER, published_presence(PresenceSessions), State
),
NewState.
report_push_holds(PresenceSessions, State) ->
maps:fold(fun report_push_hold/3, State, PresenceSessions).
report_push_hold(SessionId, PresenceSession, State) ->
ok = session_lifecycle:send_guild_push_hold(
{self(), make_ref()}, PresenceSession#{id => SessionId}
),
receive
{'$gen_cast', Msg} ->
{noreply, NewState} = guild:handle_cast(Msg, State),
NewState
after 1000 -> error(no_push_hold_reported)
end.
apply_session_presence(PresenceSessions, State) ->
report_push_holds(PresenceSessions, apply_presence(PresenceSessions, State)).
guild_message() ->
#{
<<"id">> => integer_to_binary(?MSG),
<<"channel_id">> => integer_to_binary(?CHANNEL_ID),
<<"author">> => #{<<"id">> => integer_to_binary(?AUTHOR)},
<<"content">> => <<"hi">>
}.
dm_message() ->
#{
<<"id">> => integer_to_binary(?MSG),
<<"channel_id">> => <<"5">>,
<<"channel_type">> => 1,
<<"author">> => #{<<"id">> => integer_to_binary(?AUTHOR)},
<<"content">> => <<"hi">>
}.
desktop_away() ->
#{status => online, afk => true, mobile => false}.
desktop_offline() ->
#{status => offline, afk => false, mobile => false}.
fake_guild(State) ->
spawn(fun() -> fake_guild_loop(State) end).
fake_guild_loop(State) ->
receive
{'$gen_call', From, Request} ->
{reply, Reply, State} = guild:handle_call(Request, From, State),
gen_server:reply(From, Reply),
fake_guild_loop(State);
{set_state, NewState} ->
fake_guild_loop(NewState);
stop ->
ok
end.
send_held_message(State) ->
Guild = fake_guild(State),
ok = guild_dispatch_push:collect_and_send_push_notifications(
guild_message(), ?GUILD_ID, State#{guild_pid => Guild}
),
Guild.
idle_session() ->
spawn(fun() ->
receive
stop -> ok
end
end).
with_push_spy(Fun) ->
Self = self(),
Previous = application:get_env(fluxer_gateway, guild_push_offline_grace_recheck_ms),
ok = application:set_env(fluxer_gateway, guild_push_offline_grace_recheck_ms, ?GRACE_MS),
ok = meck:new(push, [passthrough, no_link]),
try
ok = meck:expect(push, handle_message_create, fun(Params) ->
Self ! {pushed, maps:get(user_ids, Params)},
ok
end),
Fun()
after
meck:unload(push),
case Previous of
{ok, Value} ->
application:set_env(fluxer_gateway, guild_push_offline_grace_recheck_ms, Value);
undefined ->
application:unset_env(fluxer_gateway, guild_push_offline_grace_recheck_ms)
end
end.
immediate_pushes() ->
receive
{pushed, UserIds} -> [UserIds | immediate_pushes()]
after 0 -> []
end.
push_within(Ms) ->
receive
{pushed, UserIds} -> UserIds
after Ms -> none
end.
desktop_plus_backgrounded_phone_still_publishes_online_mobile_not_afk_test() ->
PresenceSessions = #{
<<"desktop">> => desktop_active(), <<"phone">> => phone_backgrounded()
},
Payload = published_presence(PresenceSessions),
?assertEqual(<<"online">>, maps:get(<<"status">>, Payload)),
?assertEqual(true, maps:get(<<"mobile">>, Payload)),
?assertEqual(false, maps:get(<<"afk">>, Payload)),
?assertEqual(
{ok, Payload},
presence_broadcast:current_visible_presence(#{
sessions => PresenceSessions, user_data => user_data(), custom_status => null
})
).
guild_message_is_held_through_grace_for_a_user_active_on_desktop_with_a_backgrounded_phone_test() ->
Desktop = idle_session(),
Phone = idle_session(),
State0 = guild_state(#{<<"desktop">> => Desktop, <<"phone">> => Phone}),
try
with_push_spy(fun() ->
State = apply_session_presence(
#{<<"desktop">> => desktop_active(), <<"phone">> => phone_backgrounded()},
State0
),
Guild = send_held_message(State),
?assertEqual([], immediate_pushes()),
?assertEqual(none, push_within(?GRACE_MS * 3)),
Guild ! stop
end)
after
Desktop ! stop,
Phone ! stop,
ets:delete(maps:get(member_presence, State0))
end.
guild_message_is_held_through_grace_for_a_user_active_on_desktop_only_test() ->
Desktop = idle_session(),
State0 = guild_state(#{<<"desktop">> => Desktop}),
try
with_push_spy(fun() ->
State = apply_presence(#{<<"desktop">> => desktop_active()}, State0),
ok = guild_dispatch_push:collect_and_send_push_notifications(
guild_message(), ?GUILD_ID, State
),
?assertEqual([], immediate_pushes()),
Desktop ! stop,
?assertEqual([?USER], push_within(?GRACE_MS * 10))
end)
after
Desktop ! stop,
ets:delete(maps:get(member_presence, State0))
end.
invisible_desktop_is_published_as_offline_test() ->
Payload = published_presence(#{<<"desktop">> => desktop_invisible()}),
?assertEqual(<<"offline">>, maps:get(<<"status">>, Payload)),
?assertEqual(
not_found,
presence_broadcast:current_visible_presence(#{
sessions => #{<<"desktop">> => desktop_invisible()},
user_data => user_data(),
custom_status => null
})
).
guild_message_to_a_user_on_desktop_with_a_backgrounded_phone_is_released_when_the_desktop_ends_test() ->
Desktop = idle_session(),
Phone = idle_session(),
State0 = guild_state(#{<<"desktop">> => Desktop, <<"phone">> => Phone}),
try
with_push_spy(fun() ->
State = apply_session_presence(
#{<<"desktop">> => desktop_active(), <<"phone">> => phone_backgrounded()},
State0
),
ok = guild_dispatch_push:collect_and_send_push_notifications(
guild_message(), ?GUILD_ID, State
),
?assertEqual([], immediate_pushes()),
Desktop ! stop,
?assertEqual([?USER], push_within(?GRACE_MS * 10))
end)
after
Desktop ! stop,
Phone ! stop,
ets:delete(maps:get(member_presence, State0))
end.
guild_message_is_pushed_immediately_to_a_user_on_a_backgrounded_phone_only_test() ->
Phone = idle_session(),
State0 = guild_state(#{<<"phone">> => Phone}),
try
with_push_spy(fun() ->
State = apply_session_presence(#{<<"phone">> => phone_backgrounded()}, State0),
ok = guild_dispatch_push:collect_and_send_push_notifications(
guild_message(), ?GUILD_ID, State
),
?assertEqual([[?USER]], immediate_pushes())
end)
after
Phone ! stop,
ets:delete(maps:get(member_presence, State0))
end.
guild_message_is_held_through_grace_for_a_user_active_on_an_invisible_desktop_test() ->
Desktop = idle_session(),
State0 = guild_state(#{<<"desktop">> => Desktop}),
try
with_push_spy(fun() ->
Online = apply_session_presence(#{<<"desktop">> => desktop_active()}, State0),
State = apply_session_presence(#{<<"desktop">> => desktop_invisible()}, Online),
?assertMatch(
#{<<"status">> := <<"offline">>},
guild_state_member:lookup_presence(maps:get(member_presence, State), ?USER)
),
Guild = send_held_message(State),
?assertEqual([], immediate_pushes()),
?assertEqual(none, push_within(?GRACE_MS * 3)),
Guild ! stop
end)
after
Desktop ! stop,
ets:delete(maps:get(member_presence, State0))
end.
dm_path_buffers_the_push_for_an_active_invisible_desktop_test() ->
with_push_spy(fun() ->
State = presence_update:handle_message_create_event(dm_message(), #{
user_id => ?USER,
sessions => #{<<"desktop">> => desktop_invisible()},
push_buffer => []
}),
?assertEqual(1, length(maps:get(push_buffer, State))),
?assertEqual([], immediate_pushes())
end).
dm_path_buffers_the_push_for_an_active_desktop_with_a_backgrounded_phone_test() ->
with_push_spy(fun() ->
State = presence_update:handle_message_create_event(dm_message(), #{
user_id => ?USER,
sessions => #{
<<"desktop">> => desktop_active(), <<"phone">> => phone_backgrounded()
},
push_buffer => []
}),
?assertEqual(1, length(maps:get(push_buffer, State))),
?assertEqual([], immediate_pushes())
end).
session_reports_a_push_hold_change_to_its_guilds_test() ->
Base = #{
id => <<"desktop">>,
status => online,
afk => false,
mobile => false,
presence_pid => undefined,
guilds => #{?GUILD_ID => {self(), make_ref()}}
},
{noreply, Afk} = session_lifecycle:handle_presence_update_cast(#{afk => true}, Base),
?assertEqual(
[{set_session_push_hold, <<"desktop">>, false}], received_casts()
),
{noreply, _Idle} = session_lifecycle:handle_presence_update_cast(#{status => idle}, Afk),
?assertEqual([], received_casts()),
{noreply, _Back} = session_lifecycle:handle_presence_update_cast(
#{afk => false, status => invisible}, Afk
),
?assertEqual(
[{set_session_push_hold, <<"desktop">>, true}], received_casts()
).
a_foreground_phone_never_holds_guild_pushes_test() ->
?assertEqual(
[{set_session_push_hold, <<"phone">>, false}],
reported_push_hold(#{id => <<"phone">>, status => online, afk => false, mobile => true})
).
a_dnd_desktop_holds_guild_pushes_test() ->
?assertEqual(
[{set_session_push_hold, <<"desktop">>, true}],
reported_push_hold(#{id => <<"desktop">>, status => dnd, afk => false, mobile => false})
).
a_bot_session_never_reports_a_push_hold_test() ->
?assertEqual(
[],
reported_push_hold(#{
id => <<"bot">>, status => online, afk => false, mobile => false, bot => true
})
).
a_desktop_back_from_afk_on_dnd_holds_guild_pushes_again_test() ->
Base = #{
id => <<"desktop">>,
status => online,
afk => true,
mobile => false,
presence_pid => undefined,
guilds => #{?GUILD_ID => {self(), make_ref()}}
},
{noreply, _Dnd} = session_lifecycle:handle_presence_update_cast(
#{afk => false, status => dnd}, Base
),
?assertEqual([{set_session_push_hold, <<"desktop">>, true}], received_casts()).
only_sessions_that_reported_no_hold_are_released_at_grace_test() ->
State = #{
sessions => #{
<<"released">> => #{push_hold => false},
<<"holding">> => #{push_hold => true},
<<"never_reported">> => #{}
}
},
?assertEqual(
[<<"released">>],
guild_sessions:released_push_holds(
[<<"released">>, <<"holding">>, <<"never_reported">>, <<"gone">>], State
)
).
reported_push_hold(Session) ->
ok = session_lifecycle:send_guild_push_hold({self(), make_ref()}, Session),
received_casts().
received_casts() ->
receive
{'$gen_cast', Msg} -> [Msg | received_casts()]
after 0 -> []
end.
guild_message_to_a_user_on_desktop_with_a_backgrounded_phone_is_released_when_the_desktop_stops_holding_test() ->
Desktop = idle_session(),
Phone = idle_session(),
State0 = guild_state(#{<<"desktop">> => Desktop, <<"phone">> => Phone}),
try
with_push_spy(fun() ->
State = apply_session_presence(
#{<<"desktop">> => desktop_active(), <<"phone">> => phone_backgrounded()},
State0
),
Guild = send_held_message(State),
?assertEqual([], immediate_pushes()),
Guild ! {set_state, report_push_hold(<<"desktop">>, desktop_offline(), State)},
?assertEqual([?USER], push_within(?GRACE_MS * 10)),
?assert(is_process_alive(Desktop)),
Guild ! stop
end)
after
Desktop ! stop,
Phone ! stop,
ets:delete(maps:get(member_presence, State0))
end.
guild_message_to_a_user_on_an_invisible_desktop_is_released_when_the_desktop_goes_afk_test() ->
Desktop = idle_session(),
State0 = guild_state(#{<<"desktop">> => Desktop}),
try
with_push_spy(fun() ->
State = apply_session_presence(#{<<"desktop">> => desktop_invisible()}, State0),
Guild = send_held_message(State),
?assertEqual([], immediate_pushes()),
Guild ! {set_state, report_push_hold(<<"desktop">>, desktop_away(), State)},
?assertEqual([?USER], push_within(?GRACE_MS * 10)),
Guild ! stop
end)
after
Desktop ! stop,
ets:delete(maps:get(member_presence, State0))
end.
manually_idle_desktop_does_not_hold_guild_pushes_test() ->
Base = #{
id => <<"desktop">>,
status => online,
afk => false,
mobile => false,
presence_pid => undefined,
guilds => #{?GUILD_ID => {self(), make_ref()}}
},
{noreply, _Idle} = session_lifecycle:handle_presence_update_cast(#{status => idle}, Base),
?assertEqual([{set_session_push_hold, <<"desktop">>, false}], received_casts()).
@@ -0,0 +1,85 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(guild_push_silent_tests).
-include_lib("eunit/include/eunit.hrl").
-define(GUILD_ID, 7300).
-define(CHANNEL_ID, 7400).
-define(USER, 30).
-define(AUTHOR, 40).
-define(SUPPRESS_NOTIFICATIONS, 4096).
member() ->
#{<<"user">> => #{<<"id">> => integer_to_binary(?USER)}, <<"roles">> => []}.
guild_state(MembersTab) ->
#{
id => ?GUILD_ID,
data => #{
<<"guild">> => #{
<<"id">> => ?GUILD_ID,
<<"name">> => <<"Guild">>,
<<"owner_id">> => ?USER,
<<"default_message_notifications">> => 0
},
<<"channels">> => [#{<<"id">> => ?CHANNEL_ID, <<"name">> => <<"general">>}],
<<"channel_index">> => #{
?CHANNEL_ID => #{<<"id">> => ?CHANNEL_ID, <<"name">> => <<"general">>}
},
<<"roles">> => [],
<<"role_index">> => #{},
members_ets => MembersTab
},
sessions => #{}
}.
guild_message(Id, Flags) ->
#{
<<"id">> => integer_to_binary(Id),
<<"channel_id">> => integer_to_binary(?CHANNEL_ID),
<<"author">> => #{<<"id">> => integer_to_binary(?AUTHOR)},
<<"content">> => <<"<@30> hi @everyone">>,
<<"mentions">> => [#{<<"id">> => integer_to_binary(?USER)}],
<<"mention_everyone">> => true,
<<"flags">> => Flags
}.
pushed_message_ids(Messages) ->
Self = self(),
MembersTab = ets:new(guild_push_silent_members, [set, public]),
true = ets:insert(MembersTab, {?USER, member()}),
ok = meck:new(push, [passthrough, no_link]),
try
ok = meck:expect(push, handle_message_create, fun(Params) ->
Self ! {pushed, maps:get(<<"id">>, maps:get(message_data, Params))},
ok
end),
lists:foreach(
fun(Message) ->
ok = guild_dispatch_push:maybe_send_push_notifications(
message_create, Message, ?GUILD_ID, guild_state(MembersTab)
)
end,
Messages
),
lists:sort(collect_pushed(1000))
after
meck:unload(push),
ets:delete(MembersTab)
end.
collect_pushed(Timeout) ->
receive
{pushed, Id} -> [binary_to_integer(Id) | collect_pushed(300)]
after Timeout -> []
end.
a_silent_guild_message_mentioning_the_member_is_not_pushed_test() ->
Messages = [
guild_message(1, ?SUPPRESS_NOTIFICATIONS),
guild_message(2, ?SUPPRESS_NOTIFICATIONS bor 4),
guild_message(3, 0),
guild_message(4, 4)
],
?assertEqual([3, 4], pushed_message_ids(Messages)).
@@ -0,0 +1,327 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(push_outbox_read_tests).
-include_lib("eunit/include/eunit.hrl").
-define(USER, 10).
-define(OTHER, 11).
-define(CHANNEL, 5).
-define(MESSAGE, 100).
a_read_during_an_in_flight_message_job_clears_again_after_it_completes_test() ->
with_outbox(#{}, fun() ->
ok = push_outbox:enqueue(message_job([?USER], ?CHANNEL, ?MESSAGE)),
{MessageWorker, _MessageBody} = await_request(<<"push.job.message">>),
ok = push_outbox:truncate_read(?USER, ?CHANNEL, ?MESSAGE),
ok = push_job_publisher:publish_clear(?USER, ?CHANNEL, ?MESSAGE),
{ClearWorker, ClearBody} = await_request(<<"push.job.clear">>),
?assertNot(maps:is_key(<<"after_message_id">>, json:decode(ClearBody))),
ClearWorker ! release,
_ = await_delivered(1),
?assertEqual(nothing, next_request(100)),
MessageWorker ! release,
{FollowupWorker, FollowupBody} = await_request(<<"push.job.clear">>),
?assertEqual(
followup_fields(?USER, ?CHANNEL, ?MESSAGE, ?MESSAGE),
clear_body_fields(FollowupBody)
),
FollowupWorker ! release,
Stats = await_delivered(3),
?assertEqual(1, maps:get(followup_clears, Stats)),
?assertEqual(nothing, next_request(100))
end).
reads_during_an_in_flight_job_coalesce_into_one_clear_at_the_highest_read_test() ->
with_outbox(#{}, fun() ->
ok = push_outbox:enqueue(message_job([?USER, ?OTHER], ?CHANNEL, ?MESSAGE)),
{MessageWorker, _} = await_request(<<"push.job.message">>),
ok = push_outbox:truncate_read(?USER, ?CHANNEL, ?MESSAGE),
ok = push_outbox:truncate_read(?USER, ?CHANNEL, ?MESSAGE + 5),
ok = push_outbox:truncate_read(?USER, ?CHANNEL + 1, ?MESSAGE + 5),
ok = push_outbox:truncate_read(?OTHER, ?CHANNEL, ?MESSAGE - 1),
MessageWorker ! release,
{FollowupWorker, FollowupBody} = await_request(<<"push.job.clear">>),
?assertEqual(
followup_fields(?USER, ?CHANNEL, ?MESSAGE + 5, ?MESSAGE),
clear_body_fields(FollowupBody)
),
FollowupWorker ! release,
Stats = await_delivered(2),
?assertEqual(1, maps:get(followup_clears, Stats)),
?assertEqual(nothing, next_request(100))
end).
a_failed_in_flight_job_clears_the_reader_and_retries_without_them_test() ->
with_outbox(#{}, fun() ->
ok = push_outbox:enqueue(message_job([?USER, ?OTHER], ?CHANNEL, ?MESSAGE)),
{MessageWorker, _} = await_request(<<"push.job.message">>),
ok = push_outbox:truncate_read(?USER, ?CHANNEL, ?MESSAGE),
MessageWorker ! fail,
{FollowupWorker, FollowupBody} = await_request(<<"push.job.clear">>),
?assertEqual(
followup_fields(?USER, ?CHANNEL, ?MESSAGE, ?MESSAGE),
clear_body_fields(FollowupBody)
),
FollowupWorker ! release,
{RetryWorker, RetryBody} = await_request(<<"push.job.message">>),
?assertEqual([integer_to_binary(?OTHER)], body_user_ids(RetryBody)),
RetryWorker ! release,
Stats = await_delivered(2),
?assertEqual(1, maps:get(retries, Stats)),
?assertEqual(1, maps:get(truncations, Stats)),
?assertEqual(nothing, next_request(100))
end).
a_read_across_in_flight_jobs_of_one_channel_clears_once_after_the_last_completes_test() ->
with_outbox(#{}, fun() ->
ok = push_outbox:enqueue(message_job([?USER], ?CHANNEL, ?MESSAGE)),
{FirstWorker, _} = await_request(<<"push.job.message">>),
ok = push_outbox:enqueue(message_job([?USER, ?OTHER], ?CHANNEL, ?MESSAGE + 1)),
{SecondWorker, _} = await_request(<<"push.job.message">>),
ok = push_outbox:truncate_read(?USER, ?CHANNEL, ?MESSAGE + 1),
FirstWorker ! release,
_ = await_delivered(1),
?assertEqual(nothing, next_request(100)),
SecondWorker ! release,
{FollowupWorker, FollowupBody} = await_request(<<"push.job.clear">>),
?assertEqual(
followup_fields(?USER, ?CHANNEL, ?MESSAGE + 1, ?MESSAGE + 1),
clear_body_fields(FollowupBody)
),
FollowupWorker ! release,
Stats = await_delivered(3),
?assertEqual(1, maps:get(followup_clears, Stats)),
?assertEqual(nothing, next_request(100))
end).
an_outbox_state_from_before_follow_ups_still_follows_up_test() ->
with_outbox(#{}, fun() ->
ok = push_outbox:enqueue(message_job([?USER], ?CHANNEL, ?MESSAGE)),
{MessageWorker, _} = await_request(<<"push.job.message">>),
_ = sys:replace_state(push_outbox, fun(State) -> maps:remove(followups, State) end),
ok = push_outbox:truncate_read(?USER, ?CHANNEL, ?MESSAGE),
MessageWorker ! release,
{FollowupWorker, _} = await_request(<<"push.job.clear">>),
FollowupWorker ! release,
Stats = await_delivered(2),
?assertEqual(1, maps:get(followup_clears, Stats))
end).
a_hot_loaded_outbox_finishes_an_in_flight_job_that_nobody_read_test() ->
with_outbox(#{}, fun() ->
ok = push_outbox:enqueue(message_job([?USER], ?CHANNEL, ?MESSAGE)),
{MessageWorker, _} = await_request(<<"push.job.message">>),
_ = sys:replace_state(push_outbox, fun(State) -> maps:remove(followups, State) end),
Outbox = whereis(push_outbox),
MessageWorker ! release,
Stats = await_delivered(1),
?assertEqual(Outbox, whereis(push_outbox)),
?assertEqual(0, maps:get(followup_clears, Stats)),
?assertEqual(nothing, next_request(100))
end).
a_read_in_another_channel_does_not_follow_up_an_in_flight_job_test() ->
with_outbox(#{}, fun() ->
ok = push_outbox:enqueue(message_job([?USER], ?CHANNEL, ?MESSAGE)),
{MessageWorker, _} = await_request(<<"push.job.message">>),
ok = push_outbox:truncate_read(?USER, ?CHANNEL + 1, ?MESSAGE + 5),
MessageWorker ! release,
Stats = await_delivered(1),
?assertEqual(0, maps:get(followup_clears, Stats)),
?assertEqual(nothing, next_request(100))
end).
a_read_by_someone_the_job_is_not_for_does_not_follow_up_test() ->
with_outbox(#{}, fun() ->
ok = push_outbox:enqueue(message_job([?USER], ?CHANNEL, ?MESSAGE)),
{MessageWorker, _} = await_request(<<"push.job.message">>),
ok = push_outbox:truncate_read(?OTHER, ?CHANNEL, ?MESSAGE),
MessageWorker ! release,
Stats = await_delivered(1),
?assertEqual(0, maps:get(followup_clears, Stats)),
?assertEqual(nothing, next_request(100))
end).
reads_during_in_flight_jobs_in_two_channels_clear_each_channel_test() ->
with_outbox(#{}, fun() ->
ok = push_outbox:enqueue(message_job([?USER], ?CHANNEL, ?MESSAGE)),
{FirstWorker, _} = await_request(<<"push.job.message">>),
ok = push_outbox:enqueue(message_job([?USER], ?CHANNEL + 1, ?MESSAGE + 50)),
{SecondWorker, _} = await_request(<<"push.job.message">>),
ok = push_outbox:truncate_read(?USER, ?CHANNEL, ?MESSAGE),
ok = push_outbox:truncate_read(?USER, ?CHANNEL + 1, ?MESSAGE + 50),
FirstWorker ! release,
{FirstFollowup, FirstBody} = await_request(<<"push.job.clear">>),
?assertEqual(
followup_fields(?USER, ?CHANNEL, ?MESSAGE, ?MESSAGE),
clear_body_fields(FirstBody)
),
FirstFollowup ! release,
SecondWorker ! release,
{SecondFollowup, SecondBody} = await_request(<<"push.job.clear">>),
?assertEqual(
followup_fields(?USER, ?CHANNEL + 1, ?MESSAGE + 50, ?MESSAGE + 50),
clear_body_fields(SecondBody)
),
SecondFollowup ! release,
Stats = await_delivered(4),
?assertEqual(2, maps:get(followup_clears, Stats))
end).
reads_arriving_out_of_order_still_clear_at_the_highest_read_test() ->
with_outbox(#{}, fun() ->
ok = push_outbox:enqueue(message_job([?USER], ?CHANNEL, ?MESSAGE)),
{MessageWorker, _} = await_request(<<"push.job.message">>),
ok = push_outbox:truncate_read(?USER, ?CHANNEL, ?MESSAGE + 5),
ok = push_outbox:truncate_read(?USER, ?CHANNEL, ?MESSAGE),
MessageWorker ! release,
{FollowupWorker, FollowupBody} = await_request(<<"push.job.clear">>),
?assertEqual(
followup_fields(?USER, ?CHANNEL, ?MESSAGE + 5, ?MESSAGE),
clear_body_fields(FollowupBody)
),
FollowupWorker ! release,
_ = await_delivered(2)
end).
no_follow_up_clear_is_sent_while_clears_are_disabled_test() ->
with_outbox(#{push_enrolled_clear_notifications_enabled => false}, fun() ->
ok = push_outbox:enqueue(message_job([?USER], ?CHANNEL, ?MESSAGE)),
{MessageWorker, _} = await_request(<<"push.job.message">>),
ok = push_outbox:truncate_read(?USER, ?CHANNEL, ?MESSAGE),
MessageWorker ! release,
Stats = await_delivered(1),
?assertEqual(0, maps:get(followup_clears, Stats)),
?assertEqual(nothing, next_request(100))
end).
a_read_removes_the_reader_from_a_message_job_still_queued_test() ->
with_outbox(#{push_outbox_max_inflight => 1}, fun() ->
ok = push_outbox:enqueue(message_job([?OTHER], ?CHANNEL + 1, ?MESSAGE)),
{Blocker, _} = await_request(<<"push.job.message">>),
ok = push_outbox:enqueue(message_job([?USER, ?OTHER], ?CHANNEL, ?MESSAGE)),
ok = push_outbox:truncate_read(?USER, ?CHANNEL, ?MESSAGE),
Queued = push_outbox:stats(),
?assertEqual(1, maps:get(truncations, Queued)),
?assertEqual(1, maps:get(depth, Queued)),
Blocker ! release,
{Worker, Body} = await_request(<<"push.job.message">>),
?assertEqual([integer_to_binary(?OTHER)], body_user_ids(Body)),
Worker ! release,
Stats = await_delivered(2),
?assertEqual(1, maps:get(truncations, Stats))
end).
a_read_drops_a_queued_message_job_it_was_the_only_reader_of_test() ->
with_outbox(#{push_outbox_max_inflight => 1}, fun() ->
ok = push_outbox:enqueue(message_job([?OTHER], ?CHANNEL + 1, ?MESSAGE)),
{Blocker, _} = await_request(<<"push.job.message">>),
ok = push_outbox:enqueue(message_job([?USER], ?CHANNEL, ?MESSAGE)),
ok = push_outbox:truncate_read(?USER, ?CHANNEL, ?MESSAGE),
?assertEqual(0, maps:get(depth, push_outbox:stats())),
Blocker ! release,
_ = await_delivered(1),
?assertEqual(nothing, next_request(200)),
?assertEqual(1, maps:get(truncations, push_outbox:stats()))
end).
with_outbox(Env, Fun) ->
Self = self(),
Modules = [fluxer_gateway_env, gateway_node_router, push_job_publisher],
lists:foreach(fun(Module) -> ok = meck:new(Module, [passthrough, no_link]) end, Modules),
try
ok = meck:expect(fluxer_gateway_env, get_optional, fun(Key) ->
maps:get(Key, Env, undefined)
end),
ok = meck:expect(fluxer_gateway_env, get, fun(Key) -> maps:get(Key, Env, undefined) end),
ok = meck:expect(gateway_node_router, active_nodes, fun(push) -> [node()] end),
ok = meck:expect(push_job_publisher, request, fun(Subject, Body, _Timeout) ->
Self ! {request, self(), Subject, Body},
receive
release -> ok;
fail -> {error, rejected}
end
end),
ok = application:set_env(fluxer_gateway, push_outbox_retry_base_ms, 10),
{ok, Outbox} = push_outbox:start_link(),
unlink(Outbox),
try
Fun()
after
gen_server:stop(Outbox)
end
after
ok = application:unset_env(fluxer_gateway, push_outbox_retry_base_ms),
lists:foreach(fun meck:unload/1, Modules),
drain_requests()
end.
message_job(UserIds, ChannelId, MessageId) ->
Job = #{
<<"v">> => 1,
<<"channel_id">> => integer_to_binary(ChannelId),
<<"message_id">> => integer_to_binary(MessageId),
<<"user_ids">> => [integer_to_binary(UserId) || UserId <- UserIds]
},
#{
kind => message,
subject => <<"push.job.message">>,
job => Job,
body => iolist_to_binary(json:encode(Job)),
user_ids => UserIds,
channel_id => ChannelId,
message_id => MessageId
}.
await_request(Subject) ->
receive
{request, Worker, Subject, Body} -> {Worker, Body}
after 2000 -> error({no_request, Subject})
end.
next_request(Timeout) ->
receive
{request, _Worker, Subject, Body} -> {Subject, Body}
after Timeout -> nothing
end.
await_delivered(Count) ->
await_delivered(Count, 100).
await_delivered(Count, 0) ->
error({not_delivered, Count, push_outbox:stats()});
await_delivered(Count, Attempts) ->
Stats = push_outbox:stats(),
case maps:get(delivered, Stats) of
Count ->
Stats;
_ ->
receive
after 20 -> await_delivered(Count, Attempts - 1)
end
end.
followup_fields(UserId, ChannelId, MessageId, AfterMessageId) ->
#{
<<"user_id">> => integer_to_binary(UserId),
<<"channel_id">> => integer_to_binary(ChannelId),
<<"message_id">> => integer_to_binary(MessageId),
<<"after_message_id">> => integer_to_binary(AfterMessageId)
}.
clear_body_fields(Body) ->
maps:with(
[<<"user_id">>, <<"channel_id">>, <<"message_id">>, <<"after_message_id">>],
json:decode(Body)
).
body_user_ids(Body) ->
maps:get(<<"user_ids">>, json:decode(Body)).
drain_requests() ->
receive
{request, Worker, _Subject, _Body} ->
Worker ! release,
drain_requests()
after 0 -> ok
end.
+487
View File
@@ -0,0 +1,487 @@
%% SPDX-License-Identifier: AGPL-3.0-or-later
-module(push_read_dm_tests).
-typing([eqwalizer]).
-include_lib("eunit/include/eunit.hrl").
-define(USER, 910001).
-define(PARTNER, 910002).
-define(DM, 910005).
-define(PARTNER_MSG, 910100).
-define(REPLY_MSG, 910101).
-define(DESKTOP, <<"desktop">>).
a_dm_read_by_replying_on_desktop_is_not_pushed_when_desktop_goes_afk_test() ->
Pushed = with_desktop_presence(fun(PresencePid, _SessionPid) ->
dispatch(PresencePid, message_create, dm_message(?PARTNER_MSG, ?PARTNER)),
?assertEqual([?PARTNER_MSG], buffered_message_ids(PresencePid)),
dispatch(PresencePid, message_create, dm_message(?REPLY_MSG, ?USER)),
?assertEqual([], push_buffer(PresencePid)),
gen_server:cast(PresencePid, {presence_update, desktop_request(true)}),
sync(PresencePid),
pushed_message_ids([])
end),
?assertEqual([], Pushed).
a_dm_read_by_replying_on_desktop_is_not_pushed_when_desktop_disconnects_test() ->
Pushed = with_desktop_presence(fun(PresencePid, SessionPid) ->
dispatch(PresencePid, message_create, dm_message(?PARTNER_MSG, ?PARTNER)),
dispatch(PresencePid, message_create, dm_message(?REPLY_MSG, ?USER)),
?assertEqual([], push_buffer(PresencePid)),
MRef = monitor(process, PresencePid),
SessionPid ! stop,
receive
{'DOWN', MRef, process, PresencePid, normal} -> ok
after 2000 ->
?assert(false)
end,
pushed_message_ids([])
end),
?assertEqual([], Pushed).
a_reply_keeps_buffered_dms_newer_than_the_reply_and_in_other_channels_test() ->
Buffered = with_desktop_presence(fun(PresencePid, _SessionPid) ->
dispatch(PresencePid, message_create, dm_message(?PARTNER_MSG, ?PARTNER)),
dispatch(PresencePid, message_create, (dm_message(?PARTNER_MSG + 5, ?PARTNER))#{
<<"channel_id">> => integer_to_binary(?DM + 1)
}),
dispatch(PresencePid, message_create, dm_message(?REPLY_MSG + 5, ?PARTNER)),
dispatch(PresencePid, message_create, dm_message(?REPLY_MSG, ?USER)),
lists:sort(buffered_message_ids(PresencePid))
end),
?assertEqual([?PARTNER_MSG + 5, ?REPLY_MSG + 5], Buffered).
an_unread_dm_is_pushed_with_its_buffer_time_when_desktop_goes_afk_test() ->
Self = self(),
Before = erlang:system_time(millisecond),
with_desktop_presence(fun(PresencePid, _SessionPid) ->
ok = meck:expect(push, handle_buffered_message_creates, fun(ParamsList) ->
lists:foreach(fun(Params) -> Self ! {pushed_params, Params} end, ParamsList)
end),
dispatch(PresencePid, message_create, dm_message(?PARTNER_MSG, ?PARTNER)),
gen_server:cast(PresencePid, {presence_update, desktop_request(true)}),
sync(PresencePid)
end),
receive
{pushed_params, #{message_data := MessageData, buffered_at := BufferedAt}} ->
?assertEqual(integer_to_binary(?PARTNER_MSG), maps:get(<<"id">>, MessageData)),
?assert(BufferedAt >= Before),
?assert(BufferedAt =< erlang:system_time(millisecond))
after 0 ->
erlang:error(nothing_pushed)
end.
a_dm_older_than_the_reply_arriving_after_it_is_not_pushed_test() ->
Pushed = with_desktop_presence(fun(PresencePid, _SessionPid) ->
dispatch(PresencePid, message_create, dm_message(?REPLY_MSG, ?USER)),
dispatch(PresencePid, message_create, dm_message(?PARTNER_MSG, ?PARTNER)),
?assertEqual([], push_buffer(PresencePid)),
gen_server:cast(PresencePid, {presence_update, desktop_request(true)}),
sync(PresencePid),
dispatch(PresencePid, message_create, dm_message(?PARTNER_MSG - 1, ?PARTNER)),
dispatch(PresencePid, message_create, dm_message(?REPLY_MSG + 1, ?PARTNER)),
pushed_message_ids([])
end),
?assertEqual([?REPLY_MSG + 1], Pushed).
a_buffered_dm_without_a_buffer_time_is_still_pushed_test() ->
Pushed = with_desktop_presence(fun(PresencePid, _SessionPid) ->
sys:replace_state(PresencePid, fun(State) ->
State#{
push_buffer := [
#{
channel_id => ?DM,
message_id => ?PARTNER_MSG,
params => #{message_data => dm_message(?PARTNER_MSG, ?PARTNER)}
}
]
}
end),
gen_server:cast(PresencePid, {presence_update, desktop_request(true)}),
sync(PresencePid),
pushed_message_ids([])
end),
?assertEqual([?PARTNER_MSG], Pushed).
a_message_ack_drops_the_buffered_dm_push_test() ->
Pushed = with_desktop_presence(fun(PresencePid, _SessionPid) ->
dispatch(PresencePid, message_create, dm_message(?PARTNER_MSG, ?PARTNER)),
dispatch(PresencePid, message_ack, #{
<<"channel_id">> => integer_to_binary(?DM),
<<"message_id">> => integer_to_binary(?PARTNER_MSG)
}),
?assertEqual([], push_buffer(PresencePid)),
gen_server:cast(PresencePid, {presence_update, desktop_request(true)}),
sync(PresencePid),
pushed_message_ids([])
end),
?assertEqual([], Pushed).
a_flush_hands_every_buffered_dm_to_push_in_one_batch_test() ->
Self = self(),
with_desktop_presence(fun(PresencePid, _SessionPid) ->
ok = meck:expect(push, handle_buffered_message_creates, fun(ParamsList) ->
Self ! {batch, [maps:get(<<"id">>, maps:get(message_data, P)) || P <- ParamsList]},
ok
end),
dispatch(PresencePid, message_create, dm_message(?PARTNER_MSG, ?PARTNER)),
dispatch(PresencePid, message_create, dm_message(?PARTNER_MSG + 1, ?PARTNER)),
gen_server:cast(PresencePid, {presence_update, desktop_request(true)}),
sync(PresencePid)
end),
receive
{batch, Ids} ->
?assertEqual(
[integer_to_binary(?PARTNER_MSG), integer_to_binary(?PARTNER_MSG + 1)], Ids
)
after 0 ->
erlang:error(no_batch)
end,
receive
{batch, _} -> erlang:error(second_batch)
after 0 -> ok
end.
read_marks_are_capped_at_32_channels_dropping_the_oldest_test() ->
Marks = with_desktop_presence(fun(PresencePid, _SessionPid) ->
lists:foreach(
fun(N) -> ack(PresencePid, ?DM + N, ?PARTNER_MSG + N) end,
lists:seq(1, 33)
),
maps:get(push_read_marks, sys:get_state(PresencePid))
end),
?assertEqual(32, map_size(Marks)),
?assertNot(maps:is_key(?DM + 1, Marks)),
?assertEqual(?PARTNER_MSG + 2, maps:get(?DM + 2, Marks)),
?assertEqual(?PARTNER_MSG + 33, maps:get(?DM + 33, Marks)).
a_lower_ack_after_a_reply_does_not_lower_the_read_mark_test() ->
Pushed = with_desktop_presence(fun(PresencePid, _SessionPid) ->
dispatch(PresencePid, message_create, dm_message(?REPLY_MSG, ?USER)),
ack(PresencePid, ?DM, ?PARTNER_MSG - 10),
dispatch(PresencePid, message_create, dm_message(?PARTNER_MSG, ?PARTNER)),
gen_server:cast(PresencePid, {presence_update, desktop_request(true)}),
sync(PresencePid),
pushed_message_ids([])
end),
?assertEqual([], Pushed).
a_create_arriving_after_its_own_ack_is_not_pushed_test() ->
Pushed = with_desktop_presence(fun(PresencePid, _SessionPid) ->
ack(PresencePid, ?DM, ?PARTNER_MSG),
dispatch(PresencePid, message_create, dm_message(?PARTNER_MSG, ?PARTNER)),
gen_server:cast(PresencePid, {presence_update, desktop_request(true)}),
sync(PresencePid),
pushed_message_ids([])
end),
?assertEqual([], Pushed).
stale_read_state_fetches_are_capped_per_node_and_fail_open_past_the_cap_test() ->
Flushes = 100,
{Fetchers, Published, InFlight, Late} = with_stale_read_state_mocks(fun() ->
lists:foreach(
fun(N) ->
spawn(fun() ->
push:handle_buffered_message_creates([stale_params(?USER + N)])
end)
end,
lists:seq(1, Flushes)
),
{Blocked, Immediate} = collect_read_state_traffic(Flushes, [], 0),
Held = read_state_fetches_in_flight(),
lists:foreach(fun(Pid) -> Pid ! release end, Blocked),
wait_for_released_fetch_slots(50),
{Blocked, Immediate, Held, collect_published(0)}
end),
?assertEqual(64, length(Fetchers)),
?assertEqual(64, InFlight),
?assertEqual(Flushes - 64, Published),
?assertEqual(0, Late),
?assertEqual(0, read_state_fetches_in_flight()).
with_stale_read_state_mocks(Fun) ->
Self = self(),
ok = push_worker_pool:init_counter(),
true = ets:delete(push_worker_counter, read_state_fetches_in_flight),
ok = meck:new(fluxer_gateway_env, [passthrough, no_link]),
ok = meck:new(gateway_node_router, [passthrough, no_link]),
ok = meck:new(rpc_client, [passthrough, no_link]),
try
ok = meck:expect(fluxer_gateway_env, get, fun
(push_enabled) -> true;
(Key) -> meck:passthrough([Key])
end),
ok = meck:expect(gateway_node_router, owner_node_result, fun(_Key, push) ->
Self ! published,
{error, test}
end),
ok = meck:expect(rpc_client, call, fun(_Request, _Timeout) ->
Self ! {fetching, self()},
receive
release -> {ok, #{<<"last_message_id">> => integer_to_binary(?PARTNER_MSG)}}
after 5000 ->
{error, timeout}
end
end),
Fun()
after
meck:unload(rpc_client),
meck:unload(gateway_node_router),
meck:unload(fluxer_gateway_env)
end.
stale_params(UserId) ->
#{
user_ids => [UserId],
guild_id => 0,
author_id => ?PARTNER,
message_data => dm_message(?PARTNER_MSG, ?PARTNER),
buffered_at => erlang:system_time(millisecond) - push_outbox:max_age_ms() - 1000
}.
collect_read_state_traffic(Expected, Fetchers, Published) when
length(Fetchers) + Published >= Expected
->
{Fetchers, Published};
collect_read_state_traffic(Expected, Fetchers, Published) ->
receive
{fetching, Pid} -> collect_read_state_traffic(Expected, [Pid | Fetchers], Published);
published -> collect_read_state_traffic(Expected, Fetchers, Published + 1)
after 2000 ->
{Fetchers, Published}
end.
collect_published(Count) ->
receive
published -> collect_published(Count + 1)
after 200 ->
Count
end.
wait_for_released_fetch_slots(0) ->
ok;
wait_for_released_fetch_slots(Attempts) ->
case read_state_fetches_in_flight() of
0 ->
ok;
_ ->
timer:sleep(20),
wait_for_released_fetch_slots(Attempts - 1)
end.
read_state_fetches_in_flight() ->
case ets:lookup(push_worker_counter, read_state_fetches_in_flight) of
[{_, InFlight}] -> InFlight;
[] -> 0
end.
an_outbox_read_watermark_filters_a_later_job_for_the_read_message_test() ->
{Sent, Stats} = with_outbox(60000, fun() ->
ok = push_outbox:truncate_read(?USER, ?DM, ?PARTNER_MSG),
sync(whereis(push_outbox)),
ok = push_outbox:enqueue(outbox_job())
end),
?assertEqual([], Sent),
?assertEqual(1, maps:get(truncations, Stats)).
an_outbox_forgets_a_read_once_the_watermark_is_pruned_test() ->
{Sent, Stats} = with_outbox(50, fun() ->
ok = push_outbox:truncate_read(?USER, ?DM, ?PARTNER_MSG),
Outbox = whereis(push_outbox),
sync(Outbox),
timer:sleep(120),
Outbox ! prune,
sync(Outbox),
ok = push_outbox:enqueue(outbox_job())
end),
?assertEqual([<<"rpc.push.message">>], Sent),
?assertEqual(0, maps:get(truncations, Stats)).
with_desktop_presence(Fun) ->
maybe_start(presence_bus),
maybe_start(presence_cache),
Self = self(),
ok = meck:new(push, [passthrough, no_link]),
try
ok = meck:expect(push, handle_message_create, fun(Params) ->
Self ! {pushed, maps:get(message_data, Params)},
ok
end),
ok = meck:expect(push, handle_buffered_message_creates, fun(ParamsList) ->
lists:foreach(
fun(Params) -> Self ! {pushed, maps:get(message_data, Params)} end, ParamsList
)
end),
{ok, PresencePid} = presence:start_link(presence_data()),
unlink(PresencePid),
SessionPid = start_session(PresencePid),
try
Fun(PresencePid, SessionPid)
after
SessionPid ! stop,
stop_quietly(PresencePid),
_ = pushed_message_ids([])
end
after
meck:unload(push)
end.
with_outbox(MaxAgeMs, Fun) ->
Self = self(),
undefined = whereis(push_outbox),
ok = meck:new(fluxer_gateway_env, [passthrough, no_link]),
ok = meck:new(gateway_node_router, [passthrough, no_link]),
ok = meck:new(push_job_publisher, [passthrough, no_link]),
try
ok = meck:expect(fluxer_gateway_env, get_optional, fun
(push_outbox_max_age_ms) -> MaxAgeMs;
(Key) -> meck:passthrough([Key])
end),
ok = meck:expect(gateway_node_router, active_nodes, fun(push) -> [node()] end),
ok = meck:expect(push_job_publisher, request, fun(Subject, _Body, _Timeout) ->
Self ! {sent, Subject},
ok
end),
{ok, Outbox} = push_outbox:start_link(),
unlink(Outbox),
try
Fun(),
timer:sleep(100),
Stats = push_outbox:stats(),
{sent_subjects([]), Stats}
after
stop_quietly(Outbox)
end
after
meck:unload(push_job_publisher),
meck:unload(gateway_node_router),
meck:unload(fluxer_gateway_env)
end.
dispatch(PresencePid, Event, Data) ->
gen_server:cast(PresencePid, {dispatch, Event, Data}),
sync(PresencePid).
ack(PresencePid, ChannelId, MessageId) ->
dispatch(PresencePid, message_ack, #{
<<"channel_id">> => integer_to_binary(ChannelId),
<<"message_id">> => integer_to_binary(MessageId)
}).
push_buffer(PresencePid) ->
maps:get(push_buffer, sys:get_state(PresencePid)).
buffered_message_ids(PresencePid) ->
[maps:get(message_id, Entry) || Entry <- push_buffer(PresencePid)].
sync(Pid) ->
_ = sys:get_state(Pid),
ok.
pushed_message_ids(Acc) ->
receive
{pushed, MessageData} ->
Id = binary_to_integer(maps:get(<<"id">>, MessageData)),
pushed_message_ids([Id | Acc])
after 0 ->
lists:reverse(Acc)
end.
sent_subjects(Acc) ->
receive
{sent, Subject} -> sent_subjects([Subject | Acc])
after 0 ->
lists:reverse(Acc)
end.
dm_message(MessageId, AuthorId) ->
#{
<<"id">> => integer_to_binary(MessageId),
<<"channel_id">> => integer_to_binary(?DM),
<<"channel_type">> => 1,
<<"author">> => #{<<"id">> => integer_to_binary(AuthorId)},
<<"content">> => <<"hi">>
}.
outbox_job() ->
Job = #{<<"user_ids">> => [integer_to_binary(?USER)]},
#{
kind => message,
subject => <<"rpc.push.message">>,
job => Job,
body => iolist_to_binary(json:encode(Job)),
user_ids => [?USER],
channel_id => ?DM,
message_id => ?PARTNER_MSG
}.
desktop_request(Afk) ->
#{session_id => ?DESKTOP, status => online, afk => Afk, mobile => false}.
start_session(PresencePid) ->
Parent = self(),
Pid = spawn(fun() ->
Reply = gen_server:call(
PresencePid,
{session_connect, #{
session_id => ?DESKTOP,
status => online,
afk => false,
mobile => false,
socket_pid => undefined
}},
5000
),
Parent ! {session_connected, self(), Reply},
session_loop()
end),
receive
{session_connected, Pid, {ok, _Sessions}} -> Pid
after 2000 ->
erlang:error(session_connect_timeout)
end.
session_loop() ->
receive
stop -> ok;
_ -> session_loop()
end.
stop_quietly(Pid) ->
try gen_server:stop(Pid) of
ok -> ok
catch
exit:_ -> ok
end.
maybe_start(Name) ->
case whereis(Name) of
undefined ->
case Name:start_link() of
{ok, Pid} ->
unlink(Pid),
ok;
{error, {already_started, _Pid}} ->
ok
end;
_ ->
ok
end.
presence_data() ->
#{
user_id => ?USER,
user_data => #{
<<"id">> => integer_to_binary(?USER),
<<"username">> => <<"test">>,
<<"discriminator">> => <<"0001">>,
<<"avatar">> => null,
<<"flags">> => 0
},
guild_ids => [],
friend_ids => [],
group_dm_recipients => #{},
status => online,
custom_status => null
}.
@@ -82,7 +82,7 @@ stale_guild_connect_timeout_cannot_abort_a_later_connect_test() ->
try
State0 = stale_timer_state(<<"stale-connect">>, GuildId, 4242),
{State1, FirstTimeout} = begin_guild_connect(GuildId, State0, TestRef, Tracer),
State2 = complete_guild_connect(GuildId, GuildPid, State1),
State2 = complete_guild_connect(GuildId, GuildPid, State1, TestRef),
{State3, SecondTimeout} = begin_guild_connect(GuildId, State2, TestRef, Tracer),
{noreply, State4} = session:handle_info(FirstTimeout, State3),
?assertEqual(0, maps:get(GuildId, maps:get(guild_connect_inflight, State4), missing)),
@@ -108,7 +108,7 @@ guild_connect_success_cancels_the_connect_timeout_test() ->
?assertMatch({session_connect_async, _}, await_stub_cast(TestRef, 2000)),
{_Token, TimerRef} = maps:get(GuildId, maps:get(guild_connect_timers, State1)),
?assert(is_integer(erlang:read_timer(TimerRef))),
State2 = complete_guild_connect(GuildId, GuildPid, State1),
State2 = complete_guild_connect(GuildId, GuildPid, State1, TestRef),
?assertEqual(#{}, maps:get(guild_connect_timers, State2)),
?assertEqual(false, erlang:read_timer(TimerRef))
after
@@ -307,11 +307,13 @@ begin_guild_connect(GuildId, State, TestRef, Tracer) ->
?assertMatch({session_connect_async, _}, await_stub_cast(TestRef, 2000)),
{State1, await_connect_timeout(Tracer, 2000)}.
complete_guild_connect(GuildId, GuildPid, State) ->
complete_guild_connect(GuildId, GuildPid, State, TestRef) ->
{noreply, State1} = session_connection_guild:handle_guild_connect_result(
GuildId, 0, {ok, GuildPid, guild_state_payload(GuildId)}, State
),
?assertMatch({GuildPid, _}, maps:get(GuildId, maps:get(guilds, State1))),
SessionId = maps:get(id, State),
?assertMatch({set_session_push_hold, SessionId, _}, await_stub_cast(TestRef, 2000)),
dropped_guild_state(GuildId, State1).
stale_timer_state(SessionId, GuildId, UserId) ->
@@ -304,6 +304,32 @@ handle_resume_restores_resume_status_after_offline_timer_test() ->
?assert(false)
end.
handle_resume_after_the_offline_timer_reports_the_push_hold_to_guilds_test() ->
Parent = self(),
Guild = spawn(fun() ->
receive
{'$gen_cast', Msg} -> Parent ! {guild_cast, Msg}
end
end),
State0 = resume_test_state(#{
status => offline,
resume_status => online,
presence_pid => self(),
guilds => #{1 => {Guild, make_ref()}}
}),
{reply, {ok, _Missed, 0}, State1} = session_lifecycle:handle_resume(0, self(), State0),
?assertEqual(online, maps:get(status, State1)),
receive
{guild_cast, Msg} ->
?assertEqual({set_session_push_hold, <<"session-resume-test">>, true}, Msg)
after 200 ->
?assert(false)
end,
receive
{'$gen_call', {Worker, Tag}, {session_connect, _PresenceUpdate}} -> Worker ! {Tag, ok}
after 200 -> ok
end.
handle_resume_cancels_pending_offline_timer_test() ->
Token = make_ref(),
TimerRef = erlang:send_after(5000, self(), {resume_offline_timeout, Token}),