mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-07 19:22:14 +09:00
fix(push): stop pushes for read, silent and muted messages (#3033)
This commit is contained in:
@@ -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.
|
||||
@@ -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}),
|
||||
|
||||
Reference in New Issue
Block a user