mirror of
https://github.com/fluxerapp/fluxer
synced 2026-10-17 21:15:32 +09:00
feat(threads): gateway, messages and push thread support (#3250)
This commit is contained in:
1 parent
4e730832c7
commit
5f4295e399
108 files changed
+15857
-176
No files matched your search
@@ -0,0 +1,331 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(channel_threads_config_tests).
|
||||
-typing([eqwalizer]).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
-define(KEY, channel_threads_config).
|
||||
-define(PULLED_KEY, {channel_threads_config, pulled}).
|
||||
|
||||
config_fails_closed_when_nothing_is_stored_test() ->
|
||||
with_stored(undefined, fun() ->
|
||||
?assertEqual(channel_threads_config:default_config(), channel_threads_config:config()),
|
||||
?assertEqual(0, channel_threads_config:version()),
|
||||
?assertNot(channel_threads_config:ever_enabled()),
|
||||
?assertNot(channel_threads_config:guild_active(1)),
|
||||
?assertNot(channel_threads_config:user_active(1)),
|
||||
?assertNot(channel_threads_config:user_excluded(1))
|
||||
end).
|
||||
|
||||
validate_accepts_a_full_wire_config_test() ->
|
||||
{ok, Config} = channel_threads_config:validate_config(full_wire()),
|
||||
?assertMatch(
|
||||
#{
|
||||
enabled := true,
|
||||
config_version := 7,
|
||||
ever_enabled := true,
|
||||
guild_basis_points := 250,
|
||||
guild_salt := <<"guild salt">>,
|
||||
user_basis_points := 10000,
|
||||
user_salt := <<"user-salt">>
|
||||
},
|
||||
Config
|
||||
),
|
||||
?assertEqual(#{<<"10">> => true, <<"11">> => true}, maps:get(enabled_guilds, Config)),
|
||||
?assertEqual(#{<<"12">> => true}, maps:get(disabled_guilds, Config)),
|
||||
?assertEqual(#{<<"20">> => true}, maps:get(included_users, Config)),
|
||||
?assertEqual(#{<<"21">> => true}, maps:get(excluded_users, Config)).
|
||||
|
||||
validate_fills_defaults_and_ignores_unknown_keys_test() ->
|
||||
{ok, Config} = channel_threads_config:validate_config(#{
|
||||
<<"enabled">> => true, <<"future_field">> => 1
|
||||
}),
|
||||
Default = channel_threads_config:default_config(),
|
||||
?assertEqual(Default#{enabled := true}, Config),
|
||||
?assertEqual(<<"channel-threads-guild-v1">>, maps:get(guild_salt, Config)),
|
||||
?assertEqual(<<"channel-threads-user-v1">>, maps:get(user_salt, Config)).
|
||||
|
||||
validate_rejects_every_malformed_field_test() ->
|
||||
Invalid = [
|
||||
{<<"enabled">>, <<"true">>},
|
||||
{<<"ever_enabled">>, 1},
|
||||
{<<"config_version">>, -1},
|
||||
{<<"config_version">>, 1.5},
|
||||
{<<"guild_basis_points">>, 10001},
|
||||
{<<"user_basis_points">>, -1},
|
||||
{<<"guild_salt">>, <<>>},
|
||||
{<<"user_salt">>, binary:copy(<<"a">>, 65)},
|
||||
{<<"guild_salt">>, <<"caf", 16#c3, 16#a9>>},
|
||||
{<<"user_salt">>, <<"tab\there">>},
|
||||
{<<"enabled_guild_ids">>, <<"1">>},
|
||||
{<<"disabled_guild_ids">>, [1]},
|
||||
{<<"included_user_ids">>, [<<"12a">>]},
|
||||
{<<"excluded_user_ids">>, [<<>>]},
|
||||
{<<"included_user_ids">>, [binary:copy(<<"1">>, 21)]},
|
||||
{<<"enabled_guild_ids">>, [integer_to_binary(N) || N <- lists:seq(1, 1001)]}
|
||||
],
|
||||
lists:foreach(
|
||||
fun({Key, Value} = Case) ->
|
||||
?assertMatch(
|
||||
{Case, {error, {invalid_field, Key, _}}},
|
||||
{Case, channel_threads_config:validate_config(#{Key => Value})}
|
||||
)
|
||||
end,
|
||||
Invalid
|
||||
).
|
||||
|
||||
validate_rejection_never_carries_the_ids_test() ->
|
||||
Ids = [integer_to_binary(N) || N <- lists:seq(1, 1001)],
|
||||
?assertEqual(
|
||||
{error, {invalid_field, <<"excluded_user_ids">>, 1001}},
|
||||
channel_threads_config:validate_config(#{<<"excluded_user_ids">> => Ids})
|
||||
),
|
||||
?assertEqual(
|
||||
{error, {invalid_field, <<"enabled_guild_ids">>, 2}},
|
||||
channel_threads_config:validate_config(#{<<"enabled_guild_ids">> => [<<"1">>, <<"x">>]})
|
||||
),
|
||||
?assertEqual(
|
||||
{error, {invalid_field, <<"guild_salt">>, undefined}},
|
||||
channel_threads_config:validate_config(#{<<"guild_salt">> => <<>>})
|
||||
).
|
||||
|
||||
validate_accepts_the_boundaries_test() ->
|
||||
{ok, _} = channel_threads_config:validate_config(#{
|
||||
<<"guild_basis_points">> => 10000,
|
||||
<<"user_basis_points">> => 0,
|
||||
<<"guild_salt">> => binary:copy(<<"~">>, 64),
|
||||
<<"included_user_ids">> => [<<"18446744073709551615">>],
|
||||
<<"disabled_guild_ids">> => [integer_to_binary(N) || N <- lists:seq(1, 1000)]
|
||||
}).
|
||||
|
||||
guild_precedence_is_excluded_then_included_then_bucket_test() ->
|
||||
Config = config(#{
|
||||
<<"guild_basis_points">> => 10000,
|
||||
<<"enabled_guild_ids">> => [<<"5">>],
|
||||
<<"disabled_guild_ids">> => [<<"5">>, <<"6">>]
|
||||
}),
|
||||
?assertNot(channel_threads_config:guild_active(Config, 5)),
|
||||
?assertNot(channel_threads_config:guild_active(Config, 6)),
|
||||
?assert(channel_threads_config:guild_active(Config, 7)),
|
||||
Included = config(#{<<"enabled_guild_ids">> => [<<"8">>]}),
|
||||
?assert(channel_threads_config:guild_active(Included, 8)),
|
||||
?assertNot(channel_threads_config:guild_active(Included, 9)).
|
||||
|
||||
guild_bucket_threshold_matches_the_shared_vectors_test() ->
|
||||
?assertNot(
|
||||
channel_threads_config:guild_active(config(#{<<"guild_basis_points">> => 6741}), 1)
|
||||
),
|
||||
?assert(
|
||||
channel_threads_config:guild_active(config(#{<<"guild_basis_points">> => 6742}), 1)
|
||||
),
|
||||
?assertNot(
|
||||
channel_threads_config:guild_active(config(#{<<"guild_basis_points">> => 9122}), 0)
|
||||
),
|
||||
?assert(
|
||||
channel_threads_config:guild_active(config(#{<<"guild_basis_points">> => 9123}), 0)
|
||||
).
|
||||
|
||||
user_bucket_threshold_matches_the_shared_vectors_test() ->
|
||||
?assertNot(
|
||||
channel_threads_config:user_active(config(#{<<"user_basis_points">> => 3403}), 1)
|
||||
),
|
||||
?assert(channel_threads_config:user_active(config(#{<<"user_basis_points">> => 3404}), 1)).
|
||||
|
||||
user_precedence_and_exclusion_test() ->
|
||||
Config = config(#{
|
||||
<<"user_basis_points">> => 10000,
|
||||
<<"included_user_ids">> => [<<"1">>],
|
||||
<<"excluded_user_ids">> => [<<"1">>, <<"2">>]
|
||||
}),
|
||||
?assertNot(channel_threads_config:user_active(Config, 1)),
|
||||
?assertNot(channel_threads_config:user_active(Config, 2)),
|
||||
?assert(channel_threads_config:user_active(Config, 3)),
|
||||
?assert(channel_threads_config:user_excluded(Config, 2)),
|
||||
?assertNot(channel_threads_config:user_excluded(Config, 3)).
|
||||
|
||||
disabled_config_is_inactive_for_everyone_test() ->
|
||||
{ok, Config} = channel_threads_config:validate_config(
|
||||
(full_wire())#{<<"enabled">> => false}
|
||||
),
|
||||
?assertNot(channel_threads_config:guild_active(Config, 10)),
|
||||
?assertNot(channel_threads_config:user_active(Config, 20)),
|
||||
?assert(channel_threads_config:user_excluded(Config, 21)).
|
||||
|
||||
zero_basis_points_never_buckets_in_test() ->
|
||||
Config = config(#{}),
|
||||
?assertEqual([], [
|
||||
Id
|
||||
|| Id <- lists:seq(1, 2000), channel_threads_config:guild_active(Config, Id)
|
||||
]),
|
||||
?assertEqual([], [
|
||||
Id
|
||||
|| Id <- lists:seq(1, 2000), channel_threads_config:user_active(Config, Id)
|
||||
]).
|
||||
|
||||
store_keeps_the_version_monotonic_test() ->
|
||||
with_stored(undefined, fun() ->
|
||||
V5 = config(#{<<"config_version">> => 5}),
|
||||
V4 = config(#{<<"config_version">> => 4, <<"enabled">> => false}),
|
||||
Default = channel_threads_config:default_config(),
|
||||
?assertEqual(updated, channel_threads_config:store_validated_config(Default, V5, nats)),
|
||||
?assertEqual(5, channel_threads_config:version()),
|
||||
?assertEqual(stale, channel_threads_config:store_validated_config(V5, V4, api)),
|
||||
?assertEqual(5, channel_threads_config:version()),
|
||||
?assert(maps:get(enabled, channel_threads_config:config())),
|
||||
?assertEqual(unchanged, channel_threads_config:store_validated_config(V5, V5, api))
|
||||
end).
|
||||
|
||||
store_keeps_ever_enabled_sticky_test() ->
|
||||
with_stored(undefined, fun() ->
|
||||
Enabled = config(#{<<"config_version">> => 1, <<"ever_enabled">> => true}),
|
||||
Killed = config(#{
|
||||
<<"config_version">> => 2, <<"enabled">> => false, <<"ever_enabled">> => false
|
||||
}),
|
||||
Default = channel_threads_config:default_config(),
|
||||
updated = channel_threads_config:store_validated_config(Default, Enabled, api),
|
||||
updated = channel_threads_config:store_validated_config(Enabled, Killed, api),
|
||||
?assert(channel_threads_config:ever_enabled()),
|
||||
?assertNot(maps:get(enabled, channel_threads_config:config())),
|
||||
?assertEqual(
|
||||
unchanged,
|
||||
channel_threads_config:store_validated_config(
|
||||
channel_threads_config:config(), Killed, api
|
||||
)
|
||||
)
|
||||
end).
|
||||
|
||||
nats_payload_is_validated_before_it_is_stored_test() ->
|
||||
with_stored(undefined, fun() ->
|
||||
Payload = iolist_to_binary(
|
||||
json:encode(#{
|
||||
<<"type">> => <<"channel_threads_config">>,
|
||||
<<"config">> => #{<<"enabled">> => true, <<"config_version">> => 3}
|
||||
})
|
||||
),
|
||||
?assertEqual(updated, channel_threads_config:apply_nats_payload(Payload)),
|
||||
?assertEqual(3, channel_threads_config:version()),
|
||||
Bad = iolist_to_binary(
|
||||
json:encode(#{
|
||||
<<"config">> => #{<<"enabled">> => <<"yes">>, <<"config_version">> => 9}
|
||||
})
|
||||
),
|
||||
?assertEqual(rejected, channel_threads_config:apply_nats_payload(Bad)),
|
||||
?assertEqual(rejected, channel_threads_config:apply_nats_payload(<<"not json">>)),
|
||||
?assertEqual(rejected, channel_threads_config:apply_nats_payload(<<"[1]">>)),
|
||||
?assertEqual(3, channel_threads_config:version())
|
||||
end).
|
||||
|
||||
a_default_pull_counts_as_loaded_test() ->
|
||||
with_stored(undefined, fun() ->
|
||||
?assertNot(channel_threads_config:loaded()),
|
||||
Default = iolist_to_binary(
|
||||
json:encode(#{<<"config">> => #{<<"enabled">> => false, <<"config_version">> => 0}})
|
||||
),
|
||||
?assertEqual(unchanged, channel_threads_config:apply_nats_payload(Default)),
|
||||
?assertEqual(channel_threads_config:default_config(), channel_threads_config:config()),
|
||||
?assert(channel_threads_config:loaded()),
|
||||
?assertNot(channel_threads_config:enabled())
|
||||
end).
|
||||
|
||||
a_rejected_pull_is_not_loaded_test() ->
|
||||
with_stored(undefined, fun() ->
|
||||
?assertEqual(rejected, channel_threads_config:apply_nats_payload(<<"not json">>)),
|
||||
?assertNot(channel_threads_config:loaded())
|
||||
end).
|
||||
|
||||
fields_changed_are_split_by_dimension_test() ->
|
||||
Base = config(#{}),
|
||||
GuildOnly = config(#{<<"enabled_guild_ids">> => [<<"1">>]}),
|
||||
UserOnly = config(#{<<"user_basis_points">> => 5}),
|
||||
VersionOnly = config(#{<<"config_version">> => 9}),
|
||||
Disabled = config(#{<<"enabled">> => false}),
|
||||
?assert(channel_threads_config:guild_fields_changed(Base, GuildOnly)),
|
||||
?assertNot(channel_threads_config:user_fields_changed(Base, GuildOnly)),
|
||||
?assert(channel_threads_config:user_fields_changed(Base, UserOnly)),
|
||||
?assertNot(channel_threads_config:guild_fields_changed(Base, UserOnly)),
|
||||
?assertNot(channel_threads_config:guild_fields_changed(Base, VersionOnly)),
|
||||
?assertNot(channel_threads_config:user_fields_changed(Base, VersionOnly)),
|
||||
?assert(channel_threads_config:guild_fields_changed(Base, Disabled)),
|
||||
?assert(channel_threads_config:user_fields_changed(Base, Disabled)).
|
||||
|
||||
identify_os_label_buckets_client_strings_test() ->
|
||||
Cases = [
|
||||
{<<"Windows">>, <<"windows">>},
|
||||
{<<"Mac OS X">>, <<"macos">>},
|
||||
{<<"macOS">>, <<"macos">>},
|
||||
{<<"Darwin">>, <<"macos">>},
|
||||
{<<"Linux">>, <<"linux">>},
|
||||
{<<"Android">>, <<"android">>},
|
||||
{<<"iOS">>, <<"ios">>},
|
||||
{<<"iPadOS">>, <<"ios">>},
|
||||
{<<"FreeBSD">>, <<"linux">>},
|
||||
{<<"TempleOS">>, <<"other">>},
|
||||
{<<>>, <<"other">>},
|
||||
{undefined, <<"other">>}
|
||||
],
|
||||
lists:foreach(
|
||||
fun({Input, Expected}) ->
|
||||
?assertEqual(
|
||||
{Input, Expected}, {Input, channel_threads_config:identify_os_label(Input)}
|
||||
)
|
||||
end,
|
||||
Cases
|
||||
).
|
||||
|
||||
note_identify_counts_by_capability_and_os_test() ->
|
||||
Key = {channel_threads_config, identify_counts},
|
||||
Previous = persistent_term:get(Key, undefined),
|
||||
persistent_term:put(Key, counters:new(12, [write_concurrency])),
|
||||
try
|
||||
ok = channel_threads_config:note_identify(true, #{<<"os">> => <<"Windows">>}),
|
||||
ok = channel_threads_config:note_identify(true, #{<<"os">> => <<"Windows">>}),
|
||||
ok = channel_threads_config:note_identify(false, #{<<"os">> => <<"Android">>}),
|
||||
ok = channel_threads_config:note_identify(false, #{}),
|
||||
Counts = channel_threads_config:identify_counts(),
|
||||
?assertEqual(12, map_size(Counts)),
|
||||
?assertEqual(2, maps:get({true, <<"windows">>}, Counts)),
|
||||
?assertEqual(1, maps:get({false, <<"android">>}, Counts)),
|
||||
?assertEqual(1, maps:get({false, <<"other">>}, Counts)),
|
||||
?assertEqual(0, maps:get({true, <<"android">>}, Counts))
|
||||
after
|
||||
restore(Key, Previous)
|
||||
end.
|
||||
|
||||
config(Overrides) ->
|
||||
{ok, Config} = channel_threads_config:validate_config(
|
||||
maps:merge(#{<<"enabled">> => true}, Overrides)
|
||||
),
|
||||
Config.
|
||||
|
||||
full_wire() ->
|
||||
#{
|
||||
<<"enabled">> => true,
|
||||
<<"config_version">> => 7,
|
||||
<<"ever_enabled">> => true,
|
||||
<<"guild_basis_points">> => 250,
|
||||
<<"guild_salt">> => <<"guild salt">>,
|
||||
<<"enabled_guild_ids">> => [<<"10">>, <<"11">>],
|
||||
<<"disabled_guild_ids">> => [<<"12">>],
|
||||
<<"user_basis_points">> => 10000,
|
||||
<<"user_salt">> => <<"user-salt">>,
|
||||
<<"included_user_ids">> => [<<"20">>],
|
||||
<<"excluded_user_ids">> => [<<"21">>]
|
||||
}.
|
||||
|
||||
with_stored(Value, Fun) ->
|
||||
Previous = persistent_term:get(?KEY, undefined),
|
||||
PreviousPulled = persistent_term:get(?PULLED_KEY, undefined),
|
||||
restore(?KEY, Value),
|
||||
restore(?PULLED_KEY, undefined),
|
||||
try
|
||||
Fun()
|
||||
after
|
||||
restore(?KEY, Previous),
|
||||
restore(?PULLED_KEY, PreviousPulled)
|
||||
end.
|
||||
|
||||
restore(Key, undefined) ->
|
||||
_ = persistent_term:erase(Key),
|
||||
ok;
|
||||
restore(Key, Value) ->
|
||||
persistent_term:put(Key, Value).
|
||||
@@ -0,0 +1,296 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(channel_threads_flip_tests).
|
||||
-typing([eqwalizer]).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
guild_fan_out_touches_only_guilds_whose_activity_changed_test() ->
|
||||
Previous = config(#{<<"enabled_guild_ids">> => [<<"1">>, <<"2">>]}),
|
||||
Current = config(#{<<"enabled_guild_ids">> => [<<"2">>, <<"3">>]}),
|
||||
Candidates = [{guild, Id, self()} || Id <- [1, 2, 3, 4]] ++ [{user, 1, self()}],
|
||||
?assertEqual(
|
||||
[{guild, 1, self()}, {guild, 3, self()}],
|
||||
channel_threads_flip:affected_targets(Previous, Current, Candidates)
|
||||
).
|
||||
|
||||
user_fan_out_touches_only_users_whose_activity_or_exclusion_changed_test() ->
|
||||
Previous = config(#{<<"included_user_ids">> => [<<"1">>]}),
|
||||
Current = config(#{
|
||||
<<"included_user_ids">> => [<<"1">>, <<"2">>], <<"excluded_user_ids">> => [<<"3">>]
|
||||
}),
|
||||
Candidates = [{user, Id, self()} || Id <- [1, 2, 3, 4]] ++ [{guild, 2, self()}],
|
||||
?assertEqual(
|
||||
[{user, 2, self()}, {user, 3, self()}],
|
||||
channel_threads_flip:affected_targets(Previous, Current, Candidates)
|
||||
).
|
||||
|
||||
user_only_change_also_recomputes_active_guilds_test() ->
|
||||
Previous = config(#{<<"enabled_guild_ids">> => [<<"1">>]}),
|
||||
Current = Previous#{excluded_users := #{<<"7">> => true}, config_version := 2},
|
||||
Candidates = [{guild, 1, self()}, {guild, 2, self()}, {user, 7, self()}],
|
||||
?assertEqual(
|
||||
[{viewers, 1, self()}, {user, 7, self()}],
|
||||
channel_threads_flip:affected_targets(Previous, Current, Candidates)
|
||||
).
|
||||
|
||||
guild_flip_is_not_queued_behind_a_viewer_sweep_test() ->
|
||||
Sweep = [{viewers, Id, self()} || Id <- lists:seq(1, 100)],
|
||||
Pending0 = channel_threads_flip:schedule(Sweep, 0, empty(), fun() -> 0 end),
|
||||
Pending1 = channel_threads_flip:schedule([{guild, 500, self()}], 10, Pending0, fun() ->
|
||||
0
|
||||
end),
|
||||
{Due, _Pending2} = channel_threads_flip:take_due(Pending1, 10, limits(10, 1000)),
|
||||
?assert(lists:member({guild, 500, self()}, Due)),
|
||||
?assertEqual(10, length([T || {viewers, _, _} = T <- Due])).
|
||||
|
||||
first_load_flips_guilds_but_not_users_test() ->
|
||||
Previous = channel_threads_config:default_config(),
|
||||
Current = config(#{
|
||||
<<"config_version">> => 3,
|
||||
<<"enabled_guild_ids">> => [<<"1">>],
|
||||
<<"user_basis_points">> => 10000
|
||||
}),
|
||||
Candidates = [{guild, 1, self()}, {guild, 2, self()}, {user, 7, self()}],
|
||||
?assertEqual(
|
||||
[{guild, 1, self()}],
|
||||
channel_threads_flip:affected_targets(Previous, Current, Candidates)
|
||||
).
|
||||
|
||||
version_only_change_touches_nothing_test() ->
|
||||
Previous = config(#{<<"guild_basis_points">> => 10000, <<"user_basis_points">> => 10000}),
|
||||
Current = Previous#{config_version := 42},
|
||||
Candidates =
|
||||
[{guild, Id, self()} || Id <- lists:seq(1, 50)] ++
|
||||
[{user, Id, self()} || Id <- lists:seq(1, 50)],
|
||||
?assertEqual([], channel_threads_flip:affected_targets(Previous, Current, Candidates)).
|
||||
|
||||
changes_while_disabled_touch_nothing_test() ->
|
||||
Previous = config(#{<<"enabled">> => false}),
|
||||
Current = config(#{
|
||||
<<"enabled">> => false,
|
||||
<<"enabled_guild_ids">> => [<<"1">>],
|
||||
<<"included_user_ids">> => [<<"1">>],
|
||||
<<"excluded_user_ids">> => [<<"2">>]
|
||||
}),
|
||||
Candidates = [{guild, 1, self()}, {user, 1, self()}, {user, 2, self()}],
|
||||
?assertEqual([], channel_threads_flip:affected_targets(Previous, Current, Candidates)).
|
||||
|
||||
disabled_config_change_skips_registry_scan_test() ->
|
||||
process_registry:init(),
|
||||
Entries = [{{guild, 9200001}, self()}, {{presence, 9200001}, self()}],
|
||||
ets:insert(process_registry_table, Entries),
|
||||
try
|
||||
Previous = config(#{<<"enabled">> => false}),
|
||||
Current = config(#{
|
||||
<<"enabled">> => false,
|
||||
<<"enabled_guild_ids">> => [<<"9200001">>],
|
||||
<<"included_user_ids">> => [<<"9200001">>]
|
||||
}),
|
||||
State0 = #{pending => empty(), timer => undefined, healed => #{}},
|
||||
?assertEqual(
|
||||
{noreply, State0},
|
||||
channel_threads_flip:handle_cast({config_changed, Previous, Current}, State0)
|
||||
)
|
||||
after
|
||||
[ets:delete(process_registry_table, Key) || {Key, _} <- Entries]
|
||||
end.
|
||||
|
||||
global_kill_flips_every_active_guild_test() ->
|
||||
Previous = config(#{<<"guild_basis_points">> => 10000}),
|
||||
Current = Previous#{enabled := false, config_version := 2},
|
||||
Candidates = [{guild, Id, self()} || Id <- lists:seq(1, 20)],
|
||||
?assertEqual(
|
||||
Candidates, channel_threads_flip:affected_targets(Previous, Current, Candidates)
|
||||
).
|
||||
|
||||
schedule_applies_jitter_and_dedupes_targets_test() ->
|
||||
Jitters = counter_jitter([500, 100, 900]),
|
||||
Pending0 = channel_threads_flip:schedule(
|
||||
[{guild, 1, self()}, {guild, 2, self()}, {guild, 1, self()}, {user, 1, self()}],
|
||||
1000,
|
||||
empty(),
|
||||
Jitters
|
||||
),
|
||||
?assertEqual(#{{guild, 1} => 1500, {guild, 2} => 1100, {user, 1} => 1900}, keys(Pending0)),
|
||||
{Due, Pending1} = channel_threads_flip:take_due(Pending0, 1600, limits(10, 10)),
|
||||
?assertEqual([{guild, 2, self()}, {guild, 1, self()}], Due),
|
||||
?assertEqual(#{{user, 1} => 1900}, keys(Pending1)).
|
||||
|
||||
rescheduling_a_restarted_process_keeps_its_slot_and_takes_the_new_pid_test() ->
|
||||
Stale = spawn(fun() -> ok end),
|
||||
Pending0 = channel_threads_flip:schedule([{guild, 1, Stale}], 0, empty(), fun() -> 500 end),
|
||||
Pending1 = channel_threads_flip:schedule([{guild, 1, self()}], 100, Pending0, fun() ->
|
||||
900
|
||||
end),
|
||||
?assertEqual(#{{guild, 1} => 500}, keys(Pending1)),
|
||||
?assertEqual(
|
||||
{[{guild, 1, self()}], empty()},
|
||||
channel_threads_flip:take_due(Pending1, 500, limits(10, 10))
|
||||
).
|
||||
|
||||
take_due_respects_the_batch_size_test() ->
|
||||
Pending0 = channel_threads_flip:schedule(
|
||||
[{guild, Id, self()} || Id <- lists:seq(1, 25)], 0, empty(), fun() -> 0 end
|
||||
),
|
||||
{First, Pending1} = channel_threads_flip:take_due(Pending0, 0, limits(10, 1000)),
|
||||
{Second, Pending2} = channel_threads_flip:take_due(Pending1, 0, limits(10, 1000)),
|
||||
{Third, Pending3} = channel_threads_flip:take_due(Pending2, 0, limits(10, 1000)),
|
||||
?assertEqual([10, 10, 5], [length(First), length(Second), length(Third)]),
|
||||
?assertEqual(#{}, keys(Pending3)),
|
||||
?assertEqual(
|
||||
lists:seq(1, 25), lists:sort([Id || {guild, Id, _} <- First ++ Second ++ Third])
|
||||
).
|
||||
|
||||
user_flips_drain_in_large_batches_behind_throttled_guilds_test() ->
|
||||
Targets =
|
||||
[{guild, Id, self()} || Id <- lists:seq(1, 50)] ++
|
||||
[{user, Id, self()} || Id <- lists:seq(1, 2500)],
|
||||
Pending0 = channel_threads_flip:schedule(Targets, 0, empty(), fun() -> 0 end),
|
||||
{First, Pending1} = channel_threads_flip:take_due(Pending0, 0, limits(10, 1000)),
|
||||
?assertEqual({10, 1000}, kind_counts(First)),
|
||||
{Second, Pending2} = channel_threads_flip:take_due(Pending1, 0, limits(10, 1000)),
|
||||
{Third, Pending3} = channel_threads_flip:take_due(Pending2, 0, limits(10, 1000)),
|
||||
?assertEqual([{10, 1000}, {10, 500}], [kind_counts(Second), kind_counts(Third)]),
|
||||
?assertEqual(20, map_size(keys(Pending3))),
|
||||
?assert(lists:all(fun({Kind, _Id}) -> Kind =:= guild end, maps:keys(keys(Pending3)))).
|
||||
|
||||
self_heal_flips_immediately_once_per_cooldown_test() ->
|
||||
Pending0 = channel_threads_flip:schedule(
|
||||
[{guild, 7, self()}, {guild, 8, self()}], 0, empty(), fun() -> 50000 end
|
||||
),
|
||||
State0 = #{pending => Pending0, timer => undefined, healed => #{}},
|
||||
{Healed1, Pending1} = channel_threads_flip:heal(7, self(), 1000, State0),
|
||||
?assertEqual(ok, receive_flip()),
|
||||
?assertEqual(#{{guild, 8} => 50000}, keys(Pending1)),
|
||||
State1 = State0#{healed := Healed1, pending := Pending1},
|
||||
{Healed2, Pending2} = channel_threads_flip:heal(7, self(), 20000, State1),
|
||||
?assertEqual(none, receive_flip()),
|
||||
?assertEqual(Pending1, Pending2),
|
||||
{_Healed3, _Pending3} = channel_threads_flip:heal(
|
||||
7, self(), 40000, State1#{healed := Healed2}
|
||||
),
|
||||
?assertEqual(ok, receive_flip()).
|
||||
|
||||
config_change_flips_only_local_registry_targets_test() ->
|
||||
process_registry:init(),
|
||||
Parent = self(),
|
||||
[LocalGuild, OtherGuild, LocalUser, ExcludedUser, Session] =
|
||||
[spawn(fun() -> forward(Parent) end) || _ <- lists:seq(1, 5)],
|
||||
Remote = remote_pid(),
|
||||
Entries = [
|
||||
{{guild, 9100001}, LocalGuild},
|
||||
{{guild, 9100002}, Remote},
|
||||
{{guild, 9100003}, OtherGuild},
|
||||
{{guild, 9100004}, loading},
|
||||
{{guild, <<"9100005">>}, OtherGuild},
|
||||
{{presence, 9100001}, LocalUser},
|
||||
{{presence, 9100002}, ExcludedUser},
|
||||
{{presence, 9100003}, Remote},
|
||||
{{session, 9100001}, Session}
|
||||
],
|
||||
ets:insert(process_registry_table, Entries),
|
||||
try
|
||||
Previous = config(#{}),
|
||||
Current = config(#{
|
||||
<<"enabled_guild_ids">> => [<<"9100001">>, <<"9100002">>, <<"9100004">>],
|
||||
<<"included_user_ids">> => [<<"9100001">>, <<"9100003">>],
|
||||
<<"excluded_user_ids">> => [<<"9100002">>]
|
||||
}),
|
||||
State0 = #{pending => empty(), timer => undefined, healed => #{}},
|
||||
{noreply, #{pending := Scheduled, timer := Timer}} =
|
||||
channel_threads_flip:handle_cast({config_changed, Previous, Current}, State0),
|
||||
_ = erlang:cancel_timer(Timer),
|
||||
Expected = [
|
||||
{guild, 9100001, LocalGuild},
|
||||
{user, 9100001, LocalUser},
|
||||
{user, 9100002, ExcludedUser}
|
||||
],
|
||||
?assertEqual(lists:sort(Expected), lists:sort(ours(Scheduled))),
|
||||
Due = channel_threads_flip:schedule(
|
||||
Expected, erlang:monotonic_time(millisecond), empty(), fun() -> 0 end
|
||||
),
|
||||
{noreply, #{pending := Rest}} =
|
||||
channel_threads_flip:handle_info(flip_tick, State0#{pending := Due}),
|
||||
?assertEqual(#{}, keys(Rest)),
|
||||
Version = channel_threads_config:version(),
|
||||
?assertEqual(
|
||||
lists:sort([
|
||||
{LocalGuild, {thread_gate_flip, Version}},
|
||||
{LocalUser, {thread_user_flip, Version}},
|
||||
{ExcludedUser, {thread_user_flip, Version}}
|
||||
]),
|
||||
lists:sort(collect(3))
|
||||
),
|
||||
?assertEqual([], collect(1))
|
||||
after
|
||||
[ets:delete(process_registry_table, Key) || {Key, _} <- Entries],
|
||||
[exit(Pid, kill) || Pid <- [LocalGuild, OtherGuild, LocalUser, ExcludedUser, Session]]
|
||||
end.
|
||||
|
||||
ours(#{due := Due}) ->
|
||||
[
|
||||
{Kind, Id, Pid}
|
||||
|| KindDue <- maps:values(Due),
|
||||
{_At, {Kind, Id}, Pid} <- gb_sets:to_list(KindDue),
|
||||
Id div 100 =:= 91000
|
||||
].
|
||||
|
||||
limits(Guilds, Users) ->
|
||||
#{guild => Guilds, viewers => Guilds, user => Users}.
|
||||
|
||||
kind_counts(Targets) ->
|
||||
{length([T || {guild, _, _} = T <- Targets]), length([T || {user, _, _} = T <- Targets])}.
|
||||
|
||||
forward(Parent) ->
|
||||
receive
|
||||
Msg ->
|
||||
Parent ! {self(), Msg},
|
||||
forward(Parent)
|
||||
end.
|
||||
|
||||
collect(0) ->
|
||||
[];
|
||||
collect(N) ->
|
||||
receive
|
||||
{Pid, {Tag, _} = Msg} when
|
||||
is_pid(Pid), (Tag =:= thread_gate_flip orelse Tag =:= thread_user_flip)
|
||||
->
|
||||
[{Pid, Msg} | collect(N - 1)]
|
||||
after 200 -> []
|
||||
end.
|
||||
|
||||
remote_pid() ->
|
||||
Node = <<"remote@nowhere">>,
|
||||
binary_to_term(
|
||||
<<131, 88, 119, (byte_size(Node)), Node/binary, 1:32, 0:32, 1:32>>
|
||||
).
|
||||
|
||||
receive_flip() ->
|
||||
receive
|
||||
{thread_gate_flip, Version} when is_integer(Version) -> ok
|
||||
after 0 -> none
|
||||
end.
|
||||
|
||||
counter_jitter(Values) ->
|
||||
Ref = make_ref(),
|
||||
put(Ref, Values),
|
||||
fun() ->
|
||||
[Next | Rest] = get(Ref),
|
||||
put(Ref, Rest),
|
||||
Next
|
||||
end.
|
||||
|
||||
empty() ->
|
||||
#{
|
||||
due => #{guild => gb_sets:new(), viewers => gb_sets:new(), user => gb_sets:new()},
|
||||
keys => #{}
|
||||
}.
|
||||
|
||||
keys(#{keys := Keys}) ->
|
||||
maps:map(fun(_Key, {At, _Pid}) -> At end, Keys).
|
||||
|
||||
config(Overrides) ->
|
||||
{ok, Config} = channel_threads_config:validate_config(
|
||||
maps:merge(#{<<"enabled">> => true}, Overrides)
|
||||
),
|
||||
Config.
|
||||
@@ -0,0 +1,41 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(experiment_bucket_tests).
|
||||
-typing([eqwalizer]).
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
-define(VECTORS_PATH, "../packages/schema/src/domains/experiment/ExperimentBucketVectors.json").
|
||||
|
||||
bucket_matches_shared_vectors_test() ->
|
||||
Vectors = read_vectors(),
|
||||
?assertMatch([_ | _], Vectors),
|
||||
lists:foreach(fun run_vector/1, Vectors).
|
||||
|
||||
bucket_stays_below_resolution_test() ->
|
||||
Resolution = experiment_bucket:resolution(),
|
||||
lists:foreach(
|
||||
fun(N) ->
|
||||
Bucket = experiment_bucket:bucket(integer_to_binary(N), <<"range-check">>),
|
||||
?assert(Bucket >= 0 andalso Bucket < Resolution)
|
||||
end,
|
||||
lists:seq(0, 500)
|
||||
).
|
||||
|
||||
read_vectors() ->
|
||||
case file:read_file(?VECTORS_PATH) of
|
||||
{ok, Contents} -> decode_vectors(Contents);
|
||||
{error, Reason} -> erlang:error({bucket_vectors_unreadable, ?VECTORS_PATH, Reason})
|
||||
end.
|
||||
|
||||
decode_vectors(Contents) ->
|
||||
case json:decode(Contents) of
|
||||
[_ | _] = Vectors -> Vectors;
|
||||
_ -> erlang:error({bucket_vectors_empty, ?VECTORS_PATH})
|
||||
end.
|
||||
|
||||
run_vector([Salt, Id, Expected] = Vector) when
|
||||
is_binary(Salt), is_binary(Id), is_integer(Expected)
|
||||
->
|
||||
?assertEqual({Vector, Expected}, {Vector, experiment_bucket:bucket(Id, Salt)});
|
||||
run_vector(Vector) ->
|
||||
erlang:error({bucket_vector_malformed, Vector}).
|
||||
@@ -518,6 +518,125 @@ websocket_handle_rejects_compressed_frame_past_max_payload_test() ->
|
||||
?assertEqual(constants:close_code_to_num(decode_error), CloseCode),
|
||||
?assertEqual(<<"Payload too large">>, Reason).
|
||||
|
||||
forum_unreads_gate_off_closes_like_an_unknown_opcode_test() ->
|
||||
Previous = persistent_term:get(channel_threads_config, undefined),
|
||||
persistent_term:put(
|
||||
channel_threads_config, (channel_threads_config:default_config())#{config_version => 1}
|
||||
),
|
||||
try
|
||||
State = (new_json_state())#{session_pid => self()},
|
||||
Unknown = gateway_handler_dispatch:handle_opcode(
|
||||
not_an_opcode, #{<<"d">> => #{}}, State
|
||||
),
|
||||
?assertEqual({[{close, 4001, <<"Unknown opcode">>}], State}, Unknown),
|
||||
?assertEqual(
|
||||
Unknown,
|
||||
gateway_handler_dispatch:handle_opcode(
|
||||
request_forum_unreads,
|
||||
#{<<"d">> => #{<<"guild_id">> => <<"1">>, <<"channel_id">> => <<"2">>}},
|
||||
State
|
||||
)
|
||||
),
|
||||
?assertEqual(
|
||||
Unknown, gateway_handler_dispatch:handle_request_forum_unreads(#{}, self(), State)
|
||||
),
|
||||
?assertEqual(
|
||||
Unknown, gateway_handler:websocket_info(forum_unreads_unknown_opcode, State)
|
||||
)
|
||||
after
|
||||
case Previous of
|
||||
undefined -> persistent_term:erase(channel_threads_config);
|
||||
_ -> persistent_term:put(channel_threads_config, Previous)
|
||||
end
|
||||
end.
|
||||
|
||||
forum_unreads_closes_like_an_unknown_opcode_once_a_default_config_is_pulled_test() ->
|
||||
Key = channel_threads_config,
|
||||
PulledKey = {channel_threads_config, pulled},
|
||||
Previous = persistent_term:get(Key, undefined),
|
||||
PreviousPulled = persistent_term:get(PulledKey, undefined),
|
||||
persistent_term:put(Key, channel_threads_config:default_config()),
|
||||
persistent_term:put(PulledKey, true),
|
||||
try
|
||||
Bot = fake_session(#{user_id => <<"6">>, bot => true, thread_channels_capable => true}),
|
||||
State = (new_json_state())#{session_pid => Bot},
|
||||
Unknown = {[{close, 4001, <<"Unknown opcode">>}], State},
|
||||
?assertEqual(
|
||||
Unknown,
|
||||
gateway_handler_dispatch:handle_opcode(
|
||||
request_forum_unreads,
|
||||
#{<<"d">> => #{<<"guild_id">> => <<"1">>, <<"channel_id">> => <<"2">>}},
|
||||
State
|
||||
)
|
||||
),
|
||||
?assertEqual(
|
||||
Unknown, gateway_handler_dispatch:handle_request_forum_unreads(#{}, Bot, State)
|
||||
),
|
||||
?assertNot(received_unknown_opcode())
|
||||
after
|
||||
case Previous of
|
||||
undefined -> persistent_term:erase(Key);
|
||||
_ -> persistent_term:put(Key, Previous)
|
||||
end,
|
||||
case PreviousPulled of
|
||||
undefined -> persistent_term:erase(PulledKey);
|
||||
_ -> persistent_term:put(PulledKey, PreviousPulled)
|
||||
end
|
||||
end.
|
||||
|
||||
forum_unreads_gate_on_closes_non_viewers_on_every_path_test() ->
|
||||
with_threads_enabled(fun() ->
|
||||
NonViewer = fake_session(#{user_id => <<"5">>, thread_channels_capable => false}),
|
||||
State = (new_json_state())#{session_pid => NonViewer},
|
||||
{ok, _} = gateway_handler_dispatch:handle_request_forum_unreads(#{}, NonViewer, State),
|
||||
?assert(received_unknown_opcode()),
|
||||
Full = State#{request_worker_max => 1, request_workers => #{make_ref() => #{}}},
|
||||
{ok, Full} = gateway_handler_dispatch:handle_request_forum_unreads(
|
||||
#{}, NonViewer, Full
|
||||
),
|
||||
?assert(received_unknown_opcode()),
|
||||
Dead = spawn(fun() -> ok end),
|
||||
DeadState = State#{session_pid => Dead},
|
||||
{ok, _} = gateway_handler_dispatch:handle_request_forum_unreads(#{}, Dead, DeadState),
|
||||
?assert(received_unknown_opcode()),
|
||||
Viewer = fake_session(#{user_id => <<"6">>, bot => true}),
|
||||
ViewerFull = Full#{session_pid => Viewer},
|
||||
{ok, ViewerFull} = gateway_handler_dispatch:handle_request_forum_unreads(
|
||||
#{}, Viewer, ViewerFull
|
||||
),
|
||||
?assertNot(received_unknown_opcode())
|
||||
end).
|
||||
|
||||
with_threads_enabled(Fun) ->
|
||||
Previous = persistent_term:get(channel_threads_config, undefined),
|
||||
persistent_term:put(
|
||||
channel_threads_config, (channel_threads_config:default_config())#{enabled => true}
|
||||
),
|
||||
try
|
||||
Fun()
|
||||
after
|
||||
case Previous of
|
||||
undefined -> persistent_term:erase(channel_threads_config);
|
||||
_ -> persistent_term:put(channel_threads_config, Previous)
|
||||
end
|
||||
end.
|
||||
|
||||
fake_session(SessionState) ->
|
||||
spawn(fun Loop() ->
|
||||
receive
|
||||
{'$gen_call', From, {get_state}} ->
|
||||
gen_server:reply(From, SessionState),
|
||||
Loop()
|
||||
after 2000 -> ok
|
||||
end
|
||||
end).
|
||||
|
||||
received_unknown_opcode() ->
|
||||
receive
|
||||
forum_unreads_unknown_opcode -> true
|
||||
after 300 -> false
|
||||
end.
|
||||
|
||||
new_json_state() ->
|
||||
(gateway_handler:new_state())#{
|
||||
version => 1, encoding => json, compress_ctx => gateway_compress:new_context(none)
|
||||
|
||||
@@ -25,3 +25,31 @@ resolve_channel_mentions_skips_invalid_channel_ids_test() ->
|
||||
#{<<"channels">> => []},
|
||||
gateway_rpc_guild_channels:handle(<<"guild.resolve_channel_mentions">>, Params)
|
||||
).
|
||||
|
||||
resolve_channel_mentions_from_snapshot_skips_forum_and_media_test() ->
|
||||
GuildId = 7301,
|
||||
ok = guild_permission_cache:put_data(GuildId, #{
|
||||
<<"guild">> => #{<<"owner_id">> => <<"1">>},
|
||||
<<"roles">> => [#{<<"id">> => <<"7301">>, <<"permissions">> => <<"1024">>}],
|
||||
<<"members">> => [],
|
||||
<<"channels">> => [
|
||||
#{<<"id">> => <<"10">>, <<"name">> => <<"text">>, <<"type">> => 0},
|
||||
#{<<"id">> => <<"11">>, <<"name">> => <<"forum">>, <<"type">> => 15},
|
||||
#{<<"id">> => <<"12">>, <<"name">> => <<"media">>, <<"type">> => 16}
|
||||
]
|
||||
}),
|
||||
try
|
||||
Params = #{
|
||||
<<"guild_id">> => <<"7301">>, <<"channel_ids">> => [<<"10">>, <<"11">>, <<"12">>]
|
||||
},
|
||||
?assertEqual(
|
||||
#{
|
||||
<<"channels">> => [
|
||||
#{<<"id">> => <<"10">>, <<"name">> => <<"text">>, <<"type">> => 0}
|
||||
]
|
||||
},
|
||||
gateway_rpc_guild_channels:handle(<<"guild.resolve_channel_mentions">>, Params)
|
||||
)
|
||||
after
|
||||
guild_permission_cache:delete(GuildId)
|
||||
end.
|
||||
@@ -0,0 +1,115 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(gateway_rpc_guild_lifecycle_tests).
|
||||
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
with_guild_pid(Fun) ->
|
||||
meck:new(gateway_rpc_guild_infra, [passthrough, no_link]),
|
||||
meck:expect(gateway_rpc_guild_infra, with_guild_unchecked, fun(_GuildId, GuildFun) ->
|
||||
GuildFun(self())
|
||||
end),
|
||||
try
|
||||
Fun()
|
||||
after
|
||||
meck:unload(gateway_rpc_guild_infra)
|
||||
end.
|
||||
|
||||
dispatch_many_casts_every_event_in_order_test() ->
|
||||
with_guild_pid(fun() ->
|
||||
Params = #{
|
||||
<<"guild_id">> => <<"1">>,
|
||||
<<"events">> => [
|
||||
#{<<"event">> => <<"THREAD_UPDATE">>, <<"data">> => #{<<"id">> => <<"5">>}},
|
||||
#{
|
||||
<<"event">> => <<"THREAD_MEMBER_UPDATE">>,
|
||||
<<"data">> => #{<<"id">> => <<"5">>}
|
||||
},
|
||||
#{<<"event">> => <<"MESSAGE_CREATE">>, <<"data">> => #{<<"id">> => <<"6">>}}
|
||||
]
|
||||
},
|
||||
?assertEqual(
|
||||
true, gateway_rpc_guild_lifecycle:handle(<<"guild.dispatch_many">>, Params)
|
||||
),
|
||||
receive
|
||||
{'$gen_cast', {dispatch_many, Requests}} ->
|
||||
?assertEqual(
|
||||
[
|
||||
#{event => thread_update, data => #{<<"id">> => <<"5">>}},
|
||||
#{event => thread_member_update, data => #{<<"id">> => <<"5">>}},
|
||||
#{event => message_create, data => #{<<"id">> => <<"6">>}}
|
||||
],
|
||||
Requests
|
||||
)
|
||||
after 1000 -> ?assert(false)
|
||||
end
|
||||
end).
|
||||
|
||||
dispatch_many_rejects_malformed_payloads_without_casting_test() ->
|
||||
with_guild_pid(fun() ->
|
||||
Malformed = [
|
||||
#{
|
||||
<<"guild_id">> => <<"1">>,
|
||||
<<"events">> => [#{<<"event">> => <<"THREAD_UPDATE">>}]
|
||||
},
|
||||
#{
|
||||
<<"guild_id">> => <<"1">>,
|
||||
<<"events">> => [
|
||||
#{<<"event">> => <<"THREAD_UPDATE">>, <<"data">> => #{}},
|
||||
#{<<"event">> => 7, <<"data">> => #{}}
|
||||
]
|
||||
},
|
||||
#{<<"guild_id">> => <<"1">>, <<"events">> => #{}},
|
||||
#{<<"guild_id">> => <<"1">>}
|
||||
],
|
||||
lists:foreach(
|
||||
fun(Params) ->
|
||||
?assertError(
|
||||
{gateway_rpc_error, validation_invalid_params},
|
||||
gateway_rpc_guild_lifecycle:handle(<<"guild.dispatch_many">>, Params)
|
||||
)
|
||||
end,
|
||||
Malformed
|
||||
),
|
||||
?assertError(
|
||||
{validation, validation_invalid_snowflake},
|
||||
gateway_rpc_guild_lifecycle:handle(
|
||||
<<"guild.dispatch_many">>, #{<<"guild_id">> => <<"0">>, <<"events">> => []}
|
||||
)
|
||||
),
|
||||
receive
|
||||
{'$gen_cast', {dispatch_many, _}} -> ?assert(false)
|
||||
after 50 -> ok
|
||||
end
|
||||
end).
|
||||
|
||||
dispatch_many_reaches_an_overloaded_guild_test() ->
|
||||
GuildId = 987654321,
|
||||
guild_manager_cache:ensure_guild_pid_cache(),
|
||||
ok = guild_ets_owner:ensure_table(guild_health_status, [named_table, public, set]),
|
||||
Pending = {make_ref(), erlang:monotonic_time(millisecond), 2500},
|
||||
true = ets:insert(guild_pid_cache, {GuildId, self()}),
|
||||
true = ets:insert(guild_health_status, {self(), GuildId, true, undefined, Pending}),
|
||||
try
|
||||
?assert(guild_health:is_overloaded(self())),
|
||||
Params = #{
|
||||
<<"guild_id">> => integer_to_binary(GuildId),
|
||||
<<"events">> => [
|
||||
#{<<"event">> => <<"THREAD_CREATE">>, <<"data">> => #{<<"id">> => <<"5">>}},
|
||||
#{
|
||||
<<"event">> => <<"THREAD_MEMBER_UPDATE">>,
|
||||
<<"data">> => #{<<"id">> => <<"5">>}
|
||||
}
|
||||
]
|
||||
},
|
||||
?assertEqual(
|
||||
true, gateway_rpc_guild_lifecycle:handle(<<"guild.dispatch_many">>, Params)
|
||||
),
|
||||
receive
|
||||
{'$gen_cast', {dispatch_many, Requests}} -> ?assertEqual(2, length(Requests))
|
||||
after 1000 -> ?assert(false)
|
||||
end
|
||||
after
|
||||
ets:delete(guild_health_status, self()),
|
||||
ets:delete(guild_pid_cache, GuildId)
|
||||
end.
|
||||
@@ -276,3 +276,36 @@ state_for_unavailability_transition_test(GuildId, NonStaffPid, StaffPid) ->
|
||||
}.
|
||||
|
||||
-endif.
|
||||
|
||||
guild_create_resend_carries_threads_only_for_viewers_test() ->
|
||||
Tab = guild_thread_store:new(),
|
||||
try
|
||||
Data = guild_data_index:normalize_map(#{
|
||||
<<"guild">> => #{<<"id">> => <<"77">>, <<"owner_id">> => <<"1">>},
|
||||
<<"roles">> => [#{<<"id">> => <<"77">>, <<"permissions">> => <<"1024">>}],
|
||||
<<"channels">> => [#{<<"id">> => <<"5">>, <<"type">> => 0}],
|
||||
<<"members">> => [#{<<"user">> => #{<<"id">> => <<"9">>}, <<"roles">> => []}],
|
||||
<<"emojis">> => [],
|
||||
<<"stickers">> => []
|
||||
}),
|
||||
State = #{
|
||||
id => 77,
|
||||
data => Data#{thread_store => Tab, thread_gate => #{active => true, version => 1}}
|
||||
},
|
||||
Viewer = #{user_id => 9, pid => self(), thread_viewer => true},
|
||||
ok = guild_availability:send_guild_create_to_session(Viewer, 77, State, []),
|
||||
?assert(maps:is_key(<<"threads">>, received_guild_create())),
|
||||
ok = guild_availability:send_guild_create_to_session(
|
||||
Viewer#{thread_viewer => false}, 77, State, []
|
||||
),
|
||||
?assertNot(maps:is_key(<<"threads">>, received_guild_create()))
|
||||
after
|
||||
guild_thread_store:destroy(Tab)
|
||||
end.
|
||||
|
||||
received_guild_create() ->
|
||||
receive
|
||||
{'$gen_cast', {dispatch, guild_create, GuildState}} when is_map(GuildState) ->
|
||||
GuildState
|
||||
after 1000 -> erlang:error(guild_create_not_sent)
|
||||
end.
|
||||
@@ -16,6 +16,38 @@ finalize_pending_session_tracks_connected_user_test() ->
|
||||
?assertEqual(2, maps:get(UserId, maps:get(presence_subscriptions, State1))),
|
||||
flush_connect_result().
|
||||
|
||||
finalize_records_the_thread_viewer_flag_test() ->
|
||||
Key = channel_threads_config,
|
||||
Previous = persistent_term:get(Key, undefined),
|
||||
persistent_term:put(Key, (channel_threads_config:default_config())#{
|
||||
enabled => true, included_users => #{<<"10">> => true}
|
||||
}),
|
||||
try
|
||||
SessionId = <<"s-thread">>,
|
||||
Attempt = 2,
|
||||
State0 = finalize_state(SessionId, 10, Attempt, true, #{}, sets:new(), #{}),
|
||||
State1 = State0#{data => #{thread_gate => #{active => true, version => 1}}},
|
||||
Computed0 = finalize_computed(SessionId, 10),
|
||||
Request = (maps:get(request, Computed0))#{thread_channels_capable => true},
|
||||
Computed = Computed0#{request => Request, thread_viewer => true},
|
||||
State2 = guild_connect_async:finalize_session_connect_async(
|
||||
SessionId, Attempt, {ok, #{}}, Computed, State1
|
||||
),
|
||||
Session = maps:get(SessionId, maps:get(sessions, State2)),
|
||||
?assertEqual(true, maps:get(thread_viewer, Session)),
|
||||
?assertEqual(true, maps:get(thread_capable, Session)),
|
||||
receive
|
||||
{thread_flip_resend, SessionId} -> ?assert(false)
|
||||
after 0 -> ok
|
||||
end,
|
||||
flush_connect_result()
|
||||
after
|
||||
case Previous of
|
||||
undefined -> persistent_term:erase(Key);
|
||||
_ -> persistent_term:put(Key, Previous)
|
||||
end
|
||||
end.
|
||||
|
||||
finalize_connected_session_does_not_double_count_test() ->
|
||||
SessionId = <<"s1">>,
|
||||
UserId = 10,
|
||||
|
||||
@@ -31,6 +31,31 @@ read_model_preserves_query_results_test() ->
|
||||
cleanup_read_model(State)
|
||||
end.
|
||||
|
||||
read_model_resolves_thread_channels_test() ->
|
||||
State0 = read_model_state(),
|
||||
Store = guild_thread_store:new(),
|
||||
ok = guild_thread_store:put_thread(Store, #{
|
||||
<<"id">> => <<"600">>, <<"parent_id">> => <<"500">>, <<"type">> => 11
|
||||
}),
|
||||
#{data := Data} = State0,
|
||||
State = State0#{
|
||||
data => Data#{thread_gate => #{active => true, version => 1}, thread_store => Store}
|
||||
},
|
||||
Request = #{user_id => 200, channel_id => 600},
|
||||
try
|
||||
ok = guild_read_model:put_state(State),
|
||||
{reply, Expected, _} = guild_data:get_auth_context(Request, State),
|
||||
?assertMatch(
|
||||
#{auth_context := #{<<"parent_channel">> := #{<<"id">> := 500}}}, Expected
|
||||
),
|
||||
?assertEqual(
|
||||
{ok, Expected}, guild_read_model:query(100, {get_guild_auth_context, Request})
|
||||
)
|
||||
after
|
||||
cleanup_read_model(State),
|
||||
guild_thread_store:destroy(Store)
|
||||
end.
|
||||
|
||||
read_model_uses_live_member_rows_without_copying_members_test() ->
|
||||
State = read_model_state(),
|
||||
#{data := #{members_ets := Tab}} = State,
|
||||
|
||||
@@ -26,7 +26,9 @@ transfer_test_() ->
|
||||
instantiate(fun abort_after_forwarding_reports_the_forwarded_count/1),
|
||||
instantiate(fun late_source_stop_keeps_the_committed_target/1),
|
||||
instantiate(fun controller_death_after_commit_keeps_the_target/1),
|
||||
instantiate(fun lost_guard_aborts_before_other_nodes_route_to_the_target/1)
|
||||
instantiate(fun lost_guard_aborts_before_other_nodes_route_to_the_target/1),
|
||||
instantiate(fun thread_state_survives_the_frozen_export/1),
|
||||
instantiate(fun stale_thread_sessions_do_not_break_a_sessionless_restore/1)
|
||||
]}.
|
||||
|
||||
instantiate(Test) ->
|
||||
@@ -242,6 +244,47 @@ lost_guard_aborts_before_other_nodes_route_to_the_target(#{
|
||||
?assertEqual(false, guild_handoff_freeze:is_frozen(Src)),
|
||||
?assertEqual([a], gen_server:call(Src, get_log, 5000)).
|
||||
|
||||
thread_state_survives_the_frozen_export(#{src := Src, src_shard := SrcShard}) ->
|
||||
ok = gen_server:call(Src, {put_session, <<"s1">>, #{pid => self(), user_id => 7}}),
|
||||
ok = gen_server:call(Src, install_threads),
|
||||
Result = guild_handoff_freeze:transfer(?GUILD_ID, Src, SrcShard, node(), #{}),
|
||||
?assertMatch({ok, #{new_pid := _}}, Result),
|
||||
{ok, #{new_pid := NewPid}} = Result,
|
||||
?assertEqual(ok, wait_dead(Src)),
|
||||
State = guild_init:init_base_state(gen_server:call(NewPid, get_export)),
|
||||
try
|
||||
?assertMatch(#{<<"id">> := 10}, guild_thread_gate:thread(10, State)),
|
||||
?assert(guild_thread_gate:active(State)),
|
||||
?assertEqual([<<"s1">>], maps:keys(maps:get(thread_resend_pending, State))),
|
||||
?assertEqual([<<"s1">>], maps:keys(maps:get(thread_sync_pending, State))),
|
||||
?assertEqual([10], maps:keys(maps:get(thread_list_dirty, State))),
|
||||
?assertEqual(
|
||||
thread_member_list_flush, receive_tagged(thread_member_list_flush, 2000)
|
||||
)
|
||||
after
|
||||
guild_thread_store:destroy(guild_thread_gate:store(State))
|
||||
end.
|
||||
|
||||
stale_thread_sessions_do_not_break_a_sessionless_restore(#{src := Src, src_shard := SrcShard}) ->
|
||||
ok = gen_server:call(Src, {put_session, <<"s1">>, #{pid => self(), user_id => 7}}),
|
||||
ok = gen_server:call(Src, install_threads),
|
||||
Opts = #{transfer_sessions => false},
|
||||
{ok, #{new_pid := NewPid}} =
|
||||
guild_handoff_freeze:transfer(?GUILD_ID, Src, SrcShard, node(), Opts),
|
||||
Export = gen_server:call(NewPid, get_export),
|
||||
?assertEqual(#{}, maps:get(sessions, Export)),
|
||||
?assertEqual([<<"s1">>], maps:get(thread_handoff_resends, Export)),
|
||||
State = guild_init:init_base_state(Export),
|
||||
try
|
||||
?assertMatch(#{<<"id">> := 10}, guild_thread_gate:thread(10, State)),
|
||||
Resent = guild_thread_flip:resend(<<"s1">>, State),
|
||||
?assertNot(maps:is_key(thread_resend_pending, Resent)),
|
||||
Synced = guild_thread_subscriptions:handle_tick(<<"s1">>, Resent),
|
||||
?assertNot(maps:is_key(thread_sync_pending, Synced))
|
||||
after
|
||||
guild_thread_store:destroy(guild_thread_gate:store(State))
|
||||
end.
|
||||
|
||||
hold(Pid, Ms) ->
|
||||
Test = self(),
|
||||
spawn(fun() ->
|
||||
@@ -373,6 +416,18 @@ handle_call(flush_down_on_terminate, _From, #{role := guild} = State) ->
|
||||
{reply, ok, State#{flush_down => {Helper, erlang:monitor(process, Helper)}}};
|
||||
handle_call(cast_late_on_terminate, _From, #{role := guild} = State) ->
|
||||
{reply, ok, State#{cast_late => true}};
|
||||
handle_call(install_threads, _From, #{role := guild, data := Data} = State) ->
|
||||
Tab = guild_thread_store:new(),
|
||||
ok = guild_thread_store:put_thread(Tab, #{<<"id">> => 10, <<"parent_id">> => 1}),
|
||||
{reply, ok, State#{
|
||||
data => Data#{thread_store => Tab, thread_gate => #{active => true, version => 1}},
|
||||
thread_load => #{status => ready, queue => [], queued => 0, attempt => 0},
|
||||
thread_resend_pending => #{<<"s1">> => true},
|
||||
thread_sync_pending => #{<<"s1">> => scheduled},
|
||||
thread_list_dirty => #{10 => true}
|
||||
}};
|
||||
handle_call(get_export, _From, #{role := guild} = State) ->
|
||||
{reply, maps:remove(role, State), State};
|
||||
handle_call(get_sessions, _From, #{role := guild, sessions := Sessions} = State) ->
|
||||
{reply, Sessions, State};
|
||||
handle_call(get_log, _From, #{role := guild} = State) ->
|
||||
|
||||
@@ -126,3 +126,59 @@ session_loop() ->
|
||||
after infinity ->
|
||||
ok
|
||||
end.
|
||||
|
||||
export_handoff_state_replaces_the_thread_store_with_rows_test() ->
|
||||
Tab = guild_thread_store:new(),
|
||||
ok = guild_thread_store:put_thread(Tab, #{<<"id">> => 10, <<"parent_id">> => 1}),
|
||||
try
|
||||
State = #{
|
||||
id => 12345,
|
||||
data => #{thread_store => Tab, thread_gate => #{active => true, version => 1}},
|
||||
sessions => #{}
|
||||
},
|
||||
Data = maps:get(data, guild_handoff:export_handoff_state(State)),
|
||||
?assertNot(maps:is_key(thread_store, Data)),
|
||||
?assertEqual(#{active => true, version => 1}, maps:get(thread_gate, Data)),
|
||||
?assertEqual(guild_thread_store:export(Tab), maps:get(thread_rows, Data)),
|
||||
?assert(is_integer(maps:get(thread_rows_at, Data)))
|
||||
after
|
||||
guild_thread_store:destroy(Tab)
|
||||
end.
|
||||
|
||||
init_from_export_queues_a_flip_when_the_gate_disagrees_with_config_test() ->
|
||||
Tab = guild_thread_store:new(),
|
||||
ok = guild_thread_store:put_thread(Tab, #{<<"id">> => 10, <<"parent_id">> => 1}),
|
||||
Key = channel_threads_config,
|
||||
Previous = persistent_term:get(Key, undefined),
|
||||
try
|
||||
Exported = guild_handoff:export_handoff_state(#{
|
||||
id => 12345,
|
||||
data => #{thread_store => Tab, thread_gate => #{active => true, version => 4}},
|
||||
sessions => #{}
|
||||
}),
|
||||
persistent_term:put(Key, (channel_threads_config:default_config())#{
|
||||
enabled => true, config_version => 5
|
||||
}),
|
||||
_ = guild_init:init_base_state(Exported),
|
||||
?assertEqual([5], drain_flips([])),
|
||||
persistent_term:put(Key, (channel_threads_config:default_config())#{
|
||||
enabled => true, config_version => 5, enabled_guilds => #{<<"12345">> => true}
|
||||
}),
|
||||
_ = guild_init:init_base_state(Exported),
|
||||
?assertEqual([], drain_flips([])),
|
||||
persistent_term:erase(Key),
|
||||
_ = guild_init:init_base_state(Exported),
|
||||
?assertEqual([], drain_flips([]))
|
||||
after
|
||||
guild_thread_store:destroy(Tab),
|
||||
case Previous of
|
||||
undefined -> persistent_term:erase(Key);
|
||||
_ -> persistent_term:put(Key, Previous)
|
||||
end
|
||||
end.
|
||||
|
||||
drain_flips(Acc) ->
|
||||
receive
|
||||
{thread_gate_flip, Version} -> drain_flips([Version | Acc])
|
||||
after 0 -> lists:reverse(Acc)
|
||||
end.
|
||||
@@ -47,6 +47,10 @@ normalize_transferred_guild_state_keeps_only_transferable_fields_test() ->
|
||||
sessions => #{<<"s1">> => #{pid => self()}},
|
||||
voice_states => #{<<"v1">> => #{}},
|
||||
virtual_channel_access => #{10 => sets:from_list([20])},
|
||||
thread_handoff_resends => [<<"s1">>],
|
||||
thread_handoff_syncs => [<<"s2">>],
|
||||
thread_handoff_lists => [30],
|
||||
thread_resend_pending => #{<<"s1">> => true},
|
||||
member_list_engine => make_ref(),
|
||||
channel_member_list_engines => #{<<"500">> => make_ref()},
|
||||
voice_server_pid => self()
|
||||
@@ -60,6 +64,10 @@ normalize_transferred_guild_state_keeps_only_transferable_fields_test() ->
|
||||
?assertEqual(#{<<"v1">> => #{}}, maps:get(voice_states, Normalized)),
|
||||
?assertNot(maps:is_key(member_list_engine, Normalized)),
|
||||
?assertNot(maps:is_key(channel_member_list_engines, Normalized)),
|
||||
?assertEqual([<<"s1">>], maps:get(thread_handoff_resends, Normalized)),
|
||||
?assertEqual([<<"s2">>], maps:get(thread_handoff_syncs, Normalized)),
|
||||
?assertEqual([30], maps:get(thread_handoff_lists, Normalized)),
|
||||
?assertNot(maps:is_key(thread_resend_pending, Normalized)),
|
||||
?assertNot(maps:is_key(voice_server_pid, Normalized)).
|
||||
|
||||
register_and_monitor_race_kills_duplicate_test_() ->
|
||||
|
||||
@@ -205,7 +205,7 @@ fresh_pending_sibling_keeps_mutual_members() ->
|
||||
?assertEqual(
|
||||
[?USER_C],
|
||||
guild_subscription_mutual_channels:filter_member_ids(
|
||||
?USER_A, [?USER_C], State
|
||||
#{}, ?USER_A, [?USER_C], State
|
||||
)
|
||||
)
|
||||
end)
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
%% SPDX-License-Identifier: AGPL-3.0-or-later
|
||||
|
||||
-module(guild_thread_push_tests).
|
||||
|
||||
-include_lib("eunit/include/eunit.hrl").
|
||||
|
||||
-define(G, 1).
|
||||
-define(A, 10).
|
||||
-define(B, 11).
|
||||
-define(C, 12).
|
||||
-define(OUT, 13).
|
||||
-define(AUTHOR, 14).
|
||||
-define(TEXT, 100).
|
||||
-define(THREAD, 500).
|
||||
-define(VIEW, 1024).
|
||||
-define(SEND, 2048).
|
||||
-define(CONFIG_KEY, channel_threads_config).
|
||||
|
||||
with_config(Fun) ->
|
||||
Previous = persistent_term:get(?CONFIG_KEY, undefined),
|
||||
Config = (channel_threads_config:default_config())#{
|
||||
enabled => true,
|
||||
included_users =>
|
||||
#{integer_to_binary(Id) => true || Id <- [?A, ?B, ?C, ?AUTHOR]}
|
||||
},
|
||||
persistent_term:put(?CONFIG_KEY, Config),
|
||||
try
|
||||
Fun()
|
||||
after
|
||||
case Previous of
|
||||
undefined -> persistent_term:erase(?CONFIG_KEY);
|
||||
_ -> persistent_term:put(?CONFIG_KEY, Previous)
|
||||
end
|
||||
end.
|
||||
|
||||
raw_data() ->
|
||||
Gid = integer_to_binary(?G),
|
||||
Member = fun(Id) ->
|
||||
#{
|
||||
<<"user">> => #{<<"id">> => integer_to_binary(Id)},
|
||||
<<"roles">> => [],
|
||||
<<"joined_at">> => <<"2026-01-01T00:00:00Z">>
|
||||
}
|
||||
end,
|
||||
#{
|
||||
<<"guild">> => #{<<"id">> => Gid, <<"owner_id">> => <<"999">>, <<"features">> => []},
|
||||
<<"roles">> => [
|
||||
#{
|
||||
<<"id">> => Gid,
|
||||
<<"permissions">> => integer_to_binary(?VIEW bor ?SEND),
|
||||
<<"position">> => 0
|
||||
}
|
||||
],
|
||||
<<"channels">> => [
|
||||
#{<<"id">> => <<"100">>, <<"type">> => 0, <<"permission_overwrites">> => []}
|
||||
],
|
||||
<<"members">> => [Member(Id) || Id <- [?A, ?B, ?C, ?OUT, ?AUTHOR]],
|
||||
<<"emojis">> => [],
|
||||
<<"stickers">> => []
|
||||
}.
|
||||
|
||||
thread_member(UserId) ->
|
||||
#{
|
||||
<<"id">> => integer_to_binary(?THREAD),
|
||||
<<"user_id">> => integer_to_binary(UserId),
|
||||
<<"join_timestamp">> => <<"2026-01-01T00:00:00.000Z">>,
|
||||
<<"flags">> => 1
|
||||
}.
|
||||
|
||||
state(Active) ->
|
||||
Payload = guild_thread_load:load_payload(#{
|
||||
<<"threads">> => [
|
||||
#{
|
||||
<<"id">> => integer_to_binary(?THREAD),
|
||||
<<"guild_id">> => integer_to_binary(?G),
|
||||
<<"parent_id">> => integer_to_binary(?TEXT),
|
||||
<<"type">> => 11,
|
||||
<<"name">> => <<"ideas">>,
|
||||
<<"thread_metadata">> => #{<<"archived">> => false, <<"locked">> => false}
|
||||
}
|
||||
],
|
||||
<<"thread_members">> => [thread_member(?A), thread_member(?OUT)]
|
||||
}),
|
||||
Base = #{
|
||||
id => ?G,
|
||||
member_count => 5,
|
||||
data => guild_data_index:normalize_map(raw_data()),
|
||||
sessions => #{
|
||||
<<"b">> => #{session_id => <<"b">>, user_id => ?B, pid => self()},
|
||||
<<"a">> => #{session_id => <<"a">>, user_id => ?A, pid => self()}
|
||||
}
|
||||
},
|
||||
Gate =
|
||||
case Active of
|
||||
true ->
|
||||
#{
|
||||
thread_gate => #{active => true, version => 1},
|
||||
thread_tainted => true,
|
||||
thread_load => {ok, Payload}
|
||||
};
|
||||
false ->
|
||||
#{}
|
||||
end,
|
||||
guild_thread_load:install(Gate, Base).
|
||||
|
||||
with_state(Active, Fun) ->
|
||||
with_config(fun() ->
|
||||
State = state(Active),
|
||||
try
|
||||
Fun(State)
|
||||
after
|
||||
guild_thread_store:destroy(guild_thread_gate:store(State))
|
||||
end
|
||||
end).
|
||||
|
||||
mention_request(ChannelId, Overrides) ->
|
||||
maps:merge(
|
||||
#{
|
||||
channel_id => ChannelId,
|
||||
author_id => ?AUTHOR,
|
||||
mention_everyone => false,
|
||||
mention_here => false,
|
||||
role_ids => [],
|
||||
user_ids => []
|
||||
},
|
||||
Overrides
|
||||
).
|
||||
|
||||
everyone(Request, State) ->
|
||||
{reply, #{everyone_user_ids := Ids}, _} =
|
||||
guild_members_mutation:resolve_mention_sources(Request, State),
|
||||
lists:sort(Ids).
|
||||
|
||||
everyone_in_a_thread_reaches_recipient_active_members_only_test() ->
|
||||
with_state(true, fun(State) ->
|
||||
Request = mention_request(?THREAD, #{mention_everyone => true}),
|
||||
?assertEqual([?A], everyone(Request, State)),
|
||||
?assertEqual(
|
||||
[?A, ?B, ?C, ?OUT],
|
||||
everyone(mention_request(?TEXT, #{mention_everyone => true}), State)
|
||||
)
|
||||
end).
|
||||
|
||||
here_in_a_thread_needs_membership_and_a_connection_test() ->
|
||||
with_state(true, fun(State) ->
|
||||
Request = mention_request(?THREAD, #{mention_here => true}),
|
||||
?assertEqual([?A], everyone(Request, State)),
|
||||
?assertEqual([?A, ?B], everyone(mention_request(?TEXT, #{mention_here => true}), State))
|
||||
end).
|
||||
|
||||
direct_mentions_in_a_thread_skip_inactive_recipients_test() ->
|
||||
with_state(true, fun(State) ->
|
||||
{reply, #{direct_user_ids := Ids}, _} =
|
||||
guild_members_mutation:resolve_mention_sources(
|
||||
mention_request(?THREAD, #{user_ids => [?B, ?OUT]}), State
|
||||
),
|
||||
?assertEqual([?B], lists:sort(Ids))
|
||||
end).
|
||||
|
||||
paged_everyone_in_a_thread_lists_members_only_test() ->
|
||||
with_state(true, fun(State) ->
|
||||
Request = (mention_request(?THREAD, #{mention_everyone => true}))#{
|
||||
limit => 100, cursor => undefined
|
||||
},
|
||||
{reply, #{mentions := Entries}, _} =
|
||||
guild_members_mutation:resolve_mention_sources_page(Request, State),
|
||||
?assertEqual([?A], lists:sort([entry_user_id(E) || E <- Entries]))
|
||||
end).
|
||||
|
||||
inactive_guild_mentions_are_unchanged_test() ->
|
||||
with_state(false, fun(State) ->
|
||||
?assertEqual(undefined, guild_thread_push:mention_universe(?THREAD, State)),
|
||||
?assertEqual(
|
||||
[?A, ?B, ?C, ?OUT],
|
||||
everyone(mention_request(?TEXT, #{mention_everyone => true}), State)
|
||||
)
|
||||
end).
|
||||
|
||||
passive_threads_follow_membership_test() ->
|
||||
with_state(true, fun(State) ->
|
||||
?assertMatch(
|
||||
[#{<<"name">> := <<"ideas">>}],
|
||||
guild_thread_push:passive_threads(#{thread_viewer => true}, ?A, State)
|
||||
),
|
||||
?assertEqual(
|
||||
[], guild_thread_push:passive_threads(#{thread_viewer => true}, ?B, State)
|
||||
),
|
||||
?assertEqual([], guild_thread_push:passive_threads(#{}, ?A, State))
|
||||
end).
|
||||
|
||||
entry_user_id(#{user_id := UserId}) -> UserId;
|
||||
entry_user_id(#{<<"user_id">> := UserId}) -> snowflake_id:parse_optional(UserId);
|
||||
entry_user_id(UserId) when is_integer(UserId) -> UserId;
|
||||
entry_user_id(Other) -> Other.
|
||||
File diff suppressed because it is too large.
Load diff
@@ -31,6 +31,9 @@ serialize_state_test() ->
|
||||
?assertEqual({2, 8}, maps:get(shard, Serialized)),
|
||||
?assert(sets:is_element(123, maps:get(active_guilds, Serialized))),
|
||||
?assertEqual(10, maps:get(seq, Serialized)),
|
||||
?assertEqual(false, maps:get(thread_channels_capable, Serialized)),
|
||||
Capable = session_lifecycle:serialize_state(State#{thread_channels_capable => true}),
|
||||
?assertEqual(true, maps:get(thread_channels_capable, Capable)),
|
||||
ok.
|
||||
|
||||
serialize_transfer_state_includes_resume_fields_test() ->
|
||||
@@ -55,6 +58,7 @@ serialize_transfer_state_includes_resume_fields_test() ->
|
||||
ignored_events => #{<<"TYPING_START">> => true},
|
||||
initial_guild_id => 123,
|
||||
debounce_reactions => true,
|
||||
thread_channels_capable => true,
|
||||
channels => #{},
|
||||
relationships => #{},
|
||||
seq => 10,
|
||||
@@ -73,7 +77,8 @@ serialize_transfer_state_includes_resume_fields_test() ->
|
||||
?assertEqual(10, maps:get(seq, TransferState)),
|
||||
?assertEqual(8, maps:get(ack_seq, TransferState)),
|
||||
?assertEqual(8, maps:get(replay_floor, TransferState)),
|
||||
?assertEqual([#{seq => 9}], maps:get(buffer, TransferState)).
|
||||
?assertEqual([#{seq => 9}], maps:get(buffer, TransferState)),
|
||||
?assertEqual(true, maps:get(thread_channels_capable, TransferState)).
|
||||
|
||||
serialize_transfer_state_strips_socket_pid_test() ->
|
||||
State = #{
|
||||
@@ -108,7 +113,8 @@ serialize_transfer_state_strips_socket_pid_test() ->
|
||||
collected_presences => []
|
||||
},
|
||||
TransferState = session_lifecycle:serialize_transfer_state(State),
|
||||
?assertEqual(undefined, maps:get(socket_pid, TransferState)).
|
||||
?assertEqual(undefined, maps:get(socket_pid, TransferState)),
|
||||
?assertEqual(false, maps:get(thread_channels_capable, TransferState)).
|
||||
|
||||
serialize_transfer_state_preserves_deque_replay_buffer_test() ->
|
||||
Deque = lists:foldl(
|
||||
|
||||
@@ -15,6 +15,62 @@ should_debounce_reactions_test() ->
|
||||
?assertEqual(false, session_manager_shard_drain:should_debounce_reactions(#{flags => -1})),
|
||||
ok.
|
||||
|
||||
thread_channels_capable_reads_identify_flag_bit_two_test() ->
|
||||
?assertEqual(false, session_manager_shard_drain:thread_channels_capable(#{})),
|
||||
?assertEqual(false, session_manager_shard_drain:thread_channels_capable(#{flags => 0})),
|
||||
?assertEqual(false, session_manager_shard_drain:thread_channels_capable(#{flags => 2})),
|
||||
?assertEqual(true, session_manager_shard_drain:thread_channels_capable(#{flags => 4})),
|
||||
?assertEqual(true, session_manager_shard_drain:thread_channels_capable(#{flags => 6})),
|
||||
?assertEqual(false, session_manager_shard_drain:thread_channels_capable(#{flags => -4})),
|
||||
?assertEqual(
|
||||
false, session_manager_shard_drain:thread_channels_capable(#{flags => <<"4">>})
|
||||
).
|
||||
|
||||
build_session_data_carries_thread_channels_capability_test() ->
|
||||
UserDataMap = #{
|
||||
<<"id">> => <<"123">>,
|
||||
<<"username">> => <<"tester">>,
|
||||
<<"discriminator">> => <<"0001">>,
|
||||
<<"avatar">> => null,
|
||||
<<"flags">> => 0
|
||||
},
|
||||
Data = #{<<"guilds">> => [], <<"user">> => UserDataMap, <<"user_settings">> => null},
|
||||
Base = #{properties => #{}, token => <<"token">>, presence => null},
|
||||
Capable = build_test_session_data(Data, Base#{flags => 6}, UserDataMap),
|
||||
Incapable = build_test_session_data(Data, Base#{flags => 2}, UserDataMap),
|
||||
Unflagged = build_test_session_data(Data, Base, UserDataMap),
|
||||
?assertEqual(true, maps:get(thread_channels_capable, Capable)),
|
||||
?assertEqual(true, maps:get(debounce_reactions, Capable)),
|
||||
?assertEqual(false, maps:get(thread_channels_capable, Incapable)),
|
||||
?assertEqual(false, maps:get(thread_channels_capable, Unflagged)).
|
||||
|
||||
session_rpc_sends_thread_channels_capable_only_when_flagged_test() ->
|
||||
meck:new(api_rpc_client, [passthrough, no_link]),
|
||||
Self = self(),
|
||||
meck:expect(api_rpc_client, call_with_retry, fun(Request, _Config) ->
|
||||
Self ! {session_rpc_request, Request},
|
||||
{ok, #{}}
|
||||
end),
|
||||
try
|
||||
Identify = #{token => <<"token">>, properties => #{}},
|
||||
{ok, _} = session_manager_shard_drain:fetch_rpc_data(
|
||||
#{identify_data => Identify#{flags => 4}, version => 1}, <<"127.0.0.1">>
|
||||
),
|
||||
?assertMatch(#{<<"thread_channels_capable">> := true}, receive_session_rpc_request()),
|
||||
{ok, _} = session_manager_shard_drain:fetch_rpc_data(
|
||||
#{identify_data => Identify#{flags => 2}, version => 1}, <<"127.0.0.1">>
|
||||
),
|
||||
?assertNot(maps:is_key(<<"thread_channels_capable">>, receive_session_rpc_request()))
|
||||
after
|
||||
meck:unload(api_rpc_client)
|
||||
end.
|
||||
|
||||
receive_session_rpc_request() ->
|
||||
receive
|
||||
{session_rpc_request, Request} -> Request
|
||||
after 1000 -> erlang:error(session_rpc_request_not_sent)
|
||||
end.
|
||||
|
||||
reconnect_drain_casts_each_active_session_test() ->
|
||||
TestPid = self(),
|
||||
SessionPidA = spawn(fun() -> reconnect_drain_test_session(TestPid) end),
|
||||
|
||||
File diff suppressed because it is too large.
Load diff
Reference in new issue
Block a user