PERF: auto join & leave chat channels (#29193)

Chat channels that are linked to a category can be set to automatically join users.

This is handled by subscribing to the following events

- group_destroyed
- user_seen
- user_confirmed_email
- user_added_to_group
- user_removed_from_group
- category_updated
- site_setting_changed (for `chat_allowed_groups`)

As well as a

- hourly background job (`AutoJoinUsers`)
- `CreateCategoryChannel` service
- `UpdateChannel` service

There was however two issues with the current implementation

1. We were triggering a lot of background jobs, mostly because it was decided to batch to auto join/leave into groups of 1000 users, adding a lot of stress to the system
2. We had one "class" (a service or a background job) per "event" and all of them had slightly different ways to select users to join/leave, making it hard to keep everything in sync

This PR "simply" adds two new servicesL `AutoJoinChannels` and `AutoLeaveChannels` that takes care, in an efficient way, of all the cases when users might automatically join a leave a chat channel.

Every other changes come from the fact that we're now always calling either one of those services, depending on the event that happened.

In the making of these classes, a few bugs were encountered and fixed, notably

- A user is only ever able to access chat channels if and only if they're part of a group listed in the `chat_allowed_group` site setting
- A category that has no associated "category groups" is only accessible to staff members (and not "Everyone")
- A silenced user should not be able to automatically join channels
- We should not attempt to automatically join users to deleted chat channels
- There is no need to automatically join users to chat channels that have already more than `max_chat_auto_joined_users` users

Internal - t/135259 & t/70607

* DEV: add specs for auto join/leave channels services

* DEV: less hacky specs

* DEV: no instance variables in specs
This commit is contained in:
Régis Hanol 2024-11-12 05:00:59 +01:00 committed by GitHub
parent 34ed35e174
commit 6dfe2fbe16
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
47 changed files with 835 additions and 2321 deletions

View File

@ -1,19 +0,0 @@
# frozen_string_literal: true
module Jobs
module Chat
class AutoJoinChannelBatch < ::Jobs::Base
def execute(args)
::Chat::AutoJoinChannelBatch.call(params: args) do
on_failure { Rails.logger.error("Failed with unexpected error") }
on_failed_contract do |contract|
Rails.logger.error(contract.errors.full_messages.join(", "))
end
on_model_not_found(:channel) do |params:|
Rails.logger.error("Channel not found (id=#{params.channel_id})")
end
end
end
end
end
end

View File

@ -1,81 +0,0 @@
# NOTE: When changing auto-join logic, make sure to update the `settings.auto_join_users_info` translation as well.
# frozen_string_literal: true
module Jobs
module Chat
class AutoJoinChannelMemberships < ::Jobs::Base
def execute(args)
channel =
::Chat::Channel.includes(:chatable).find_by(
id: args[:chat_channel_id],
auto_join_users: true,
chatable_type: "Category",
)
return if !channel&.chatable
processed =
::Chat::UserChatChannelMembership.where(
chat_channel: channel,
following: true,
join_mode: ::Chat::UserChatChannelMembership.join_modes[:automatic],
).count
auto_join_query(channel).find_in_batches do |batch|
break if processed >= ::SiteSetting.max_chat_auto_joined_users
starts_at = batch.first.query_user_id
ends_at = batch.last.query_user_id
::Jobs.enqueue(
::Jobs::Chat::AutoJoinChannelBatch,
chat_channel_id: channel.id,
starts_at: starts_at,
ends_at: ends_at,
)
processed += batch.size
end
# The Jobs::Chat::AutoJoinChannelBatch job will only do this recalculation
# if it's operating on one user, so we need to make sure we do it for
# the channel here once this job is complete.
::Chat::ChannelMembershipManager.new(channel).recalculate_user_count
end
private
def auto_join_query(channel)
category = channel.chatable
users =
::User
.real
.activated
.not_suspended
.not_staged
.distinct
.select(:id, "users.id AS query_user_id")
.where("last_seen_at > ?", 3.months.ago)
.joins(:user_option)
.where(user_options: { chat_enabled: true })
.joins(<<~SQL)
LEFT OUTER JOIN user_chat_channel_memberships uccm
ON uccm.chat_channel_id = #{channel.id} AND
uccm.user_id = users.id
SQL
.where("uccm.id IS NULL")
if category.read_restricted?
users =
users
.joins(:group_users)
.joins("INNER JOIN category_groups cg ON cg.group_id = group_users.group_id")
.where("cg.category_id = ?", channel.chatable_id)
end
users
end
end
end
end

View File

@ -1,11 +0,0 @@
# frozen_string_literal: true
module Jobs
module Chat
class AutoRemoveMembershipHandleCategoryUpdated < ::Jobs::Base
def execute(args)
::Chat::AutoRemove::HandleCategoryUpdated.call(params: args)
end
end
end
end

View File

@ -1,11 +0,0 @@
# frozen_string_literal: true
module Jobs
module Chat
class AutoRemoveMembershipHandleChatAllowedGroupsChange < ::Jobs::Base
def execute(args)
::Chat::AutoRemove::HandleChatAllowedGroupsChange.call(params: args)
end
end
end
end

View File

@ -1,11 +0,0 @@
# frozen_string_literal: true
module Jobs
module Chat
class AutoRemoveMembershipHandleDestroyedGroup < ::Jobs::Base
def execute(args)
::Chat::AutoRemove::HandleDestroyedGroup.call(params: args)
end
end
end
end

View File

@ -1,11 +0,0 @@
# frozen_string_literal: true
module Jobs
module Chat
class AutoRemoveMembershipHandleUserRemovedFromGroup < ::Jobs::Base
def execute(args)
::Chat::AutoRemove::HandleUserRemovedFromGroup.call(params: args)
end
end
end
end

View File

@ -5,62 +5,11 @@ module Jobs
class AutoJoinUsers < ::Jobs::Scheduled
every 1.hour
LAST_SEEN_DAYS = 30
def execute(_args)
def execute(args = {})
return if !SiteSetting.chat_enabled
allowed_group_permissions = [
CategoryGroup.permission_types[:create_post],
CategoryGroup.permission_types[:full],
]
join_mode = ::Chat::UserChatChannelMembership.join_modes[:automatic]
sql = <<~SQL
WITH users AS (
SELECT id FROM users u
JOIN user_options uo ON uo.user_id = u.id
WHERE id > 0 AND (u.suspended_till IS NULL OR u.suspended_till <= :now)
AND (u.last_seen_at IS NULL OR u.last_seen_at > :last_seen_at)
AND u.active
AND NOT u.staged
AND uo.chat_enabled
AND NOT EXISTS (SELECT 1 FROM anonymous_users a WHERE a.user_id = u.id)
ORDER BY last_seen_at desc
LIMIT :max_users
)
INSERT INTO user_chat_channel_memberships (user_id, chat_channel_id, following, created_at, updated_at, join_mode)
SELECT DISTINCT users.id AS user_id,
chat_channels.id AS chat_channel_id,
TRUE AS following,
:now::timestamp AS created_at,
:now::timestamp AS updated_at,
:join_mode AS join_mode
FROM users
JOIN chat_channels on auto_join_users AND chatable_type = 'Category'
JOIN categories c on c.id = chat_channels.chatable_id
LEFT OUTER JOIN user_chat_channel_memberships uccm ON uccm.chat_channel_id = chat_channels.id
AND uccm.user_id = users.id
LEFT OUTER JOIN group_users gu ON gu.user_id = users.id
LEFT OUTER JOIN category_groups cg ON cg.group_id = gu.group_id
AND cg.permission_type in (:allowed_group_permissions)
AND c.id = cg.category_id
WHERE (cg.group_id is NOT null OR NOT c.read_restricted) AND uccm.id IS NULL
ON CONFLICT DO NOTHING
SQL
DB.exec(
sql,
now: Time.zone.now,
last_seen_at: LAST_SEEN_DAYS.days.ago,
allowed_group_permissions: allowed_group_permissions,
join_mode: join_mode,
max_users: SiteSetting.max_chat_auto_joined_users,
)
::Chat::AutoJoinChannels.call(params: {})
::Chat::AutoLeaveChannels.call(params: { event: args[:event]&.to_sym || :hourly_job })
end
end
end

View File

@ -11,11 +11,12 @@ module Chat
define_method(name) { true }
end
STAFF_GROUP_IDS = Group::AUTO_GROUPS.values_at(:admins, :moderators, :staff)
def allowed_group_ids
return if !read_restricted?
staff_groups = Group::AUTO_GROUPS.slice(:staff, :moderators, :admins).values
category.secure_group_ids.to_a.concat(staff_groups)
STAFF_GROUP_IDS | category.secure_group_ids
end
def title(_ = nil)

View File

@ -7,7 +7,7 @@ module Chat
Chat::UserChatChannelMembership
.joins(:user)
.includes(:user)
.where(user: User.human_users.activated.not_suspended.not_staged)
.where(user: User.real.activated.not_staged.not_suspended.not_silenced)
.where(chat_channel: channel)
query = query.where(following: true) if channel.category_channel?

View File

@ -1,115 +0,0 @@
# frozen_string_literal: true
module Chat
module Action
# There is significant complexity around category channel permissions,
# since they are inferred from [CategoryGroup] records and their corresponding
# permission types.
#
# To be able to join and chat in a channel, a user must either be staff,
# or be in a group that has either `full` or `create_post` permissions
# via [CategoryGroup].
#
# However, there is an edge case. If there are no [CategoryGroup] records
# for a given category, this means that the [Group::AUTO_GROUPS[:everyone]]
# group has `full` access to the channel, therefore everyone can post in
# the chat channel (so long as they are in one of the `SiteSetting.chat_allowed_groups`)
#
# Here, we can efficiently query the channel category permissions and figure
# out which of the users provided should have their [Chat::UserChatChannelMembership]
# records removed based on those security cases.
class CalculateMembershipsForRemoval < Service::ActionBase
option :scoped_users_query
option :channel_ids, [], optional: true
def call
memberships_to_remove = []
scoped_memberships.find_each do |membership|
channel_permission =
channel_permissions_map.find { |cpm| cpm.channel_id == membership.chat_channel_id }
# If there is no channel in the map, this means there are no
# category_groups for the channel.
#
# This in turn means the Everyone group with full permission
# is the only group that can access the channel (no category_group
# record is created in this case), we do not need to remove any users.
next if channel_permission.blank?
group_ids_with_write_permission =
channel_permission.groups_with_write_permissions.to_s.split(",").map(&:to_i)
group_ids_with_read_permission =
channel_permission.groups_with_readonly_permissions.to_s.split(",").map(&:to_i)
# None of the groups on the channel have permission to do anything
# more than read only, remove the membership.
if group_ids_with_write_permission.empty? && group_ids_with_read_permission.any?
memberships_to_remove << membership.id
next
end
# At least one of the groups on the channel can create_post or
# has full permission, remove the membership if the user is in none
# of these groups.
if group_ids_with_write_permission.any?
scoped_user = scoped_users_query.where(id: membership.user_id).first
if !scoped_user&.in_any_groups?(group_ids_with_write_permission)
memberships_to_remove << membership.id
end
end
end
memberships_to_remove
end
private
def channel_permissions_map
@channel_permissions_map ||=
DB.query(<<~SQL, readonly: CategoryGroup.permission_types[:readonly])
WITH category_group_channel_map AS (
SELECT category_groups.group_id,
category_groups.permission_type,
chat_channels.id AS channel_id
FROM category_groups
INNER JOIN categories ON categories.id = category_groups.category_id
INNER JOIN chat_channels ON categories.id = chat_channels.chatable_id
AND chat_channels.chatable_type = 'Category'
)
SELECT chat_channels.id AS channel_id,
chat_channels.chatable_id AS category_id,
(
SELECT string_agg(category_group_channel_map.group_id::varchar, ',')
FROM category_group_channel_map
WHERE category_group_channel_map.permission_type < :readonly AND
category_group_channel_map.channel_id = chat_channels.id
) AS groups_with_write_permissions,
(
SELECT string_agg(category_group_channel_map.group_id::varchar, ',')
FROM category_group_channel_map
WHERE category_group_channel_map.permission_type = :readonly AND
category_group_channel_map.channel_id = chat_channels.id
) AS groups_with_readonly_permissions,
categories.read_restricted
FROM category_group_channel_map
INNER JOIN chat_channels ON chat_channels.id = category_group_channel_map.channel_id
INNER JOIN categories ON categories.id = chat_channels.chatable_id
WHERE chat_channels.chatable_type = 'Category'
#{channel_ids.present? ? "AND chat_channels.id IN (#{channel_ids.join(",")})" : ""}
GROUP BY chat_channels.id, chat_channels.chatable_id, categories.read_restricted
ORDER BY channel_id
SQL
end
def scoped_memberships
@scoped_memberships ||=
Chat::UserChatChannelMembership
.joins(:chat_channel)
.where(user_id: scoped_users_query.select(:id))
.where(chat_channel_id: channel_permissions_map.map(&:channel_id))
end
end
end
end

View File

@ -1,57 +0,0 @@
# frozen_string_literal: true
module Chat
module Action
class CreateMembershipsForAutoJoin < Service::ActionBase
option :channel
option :params
def call
::DB.query_single(<<~SQL, query_args)
INSERT INTO user_chat_channel_memberships (user_id, chat_channel_id, following, created_at, updated_at, join_mode)
SELECT DISTINCT(users.id), :chat_channel_id, TRUE, NOW(), NOW(), :mode
FROM users
INNER JOIN user_options uo ON uo.user_id = users.id
LEFT OUTER JOIN user_chat_channel_memberships uccm ON
uccm.chat_channel_id = :chat_channel_id AND uccm.user_id = users.id
LEFT OUTER JOIN group_users gu ON gu.user_id = users.id
LEFT OUTER JOIN category_groups cg ON cg.group_id = gu.group_id AND
cg.permission_type <= :permission_type
WHERE (users.id >= :start AND users.id <= :end) AND
users.staged IS FALSE AND
users.active AND
NOT EXISTS(SELECT 1 FROM anonymous_users a WHERE a.user_id = users.id) AND
(suspended_till IS NULL OR suspended_till <= :suspended_until) AND
(last_seen_at IS NULL OR last_seen_at > :last_seen_at) AND
uo.chat_enabled AND
(NOT EXISTS(SELECT 1 FROM category_groups WHERE category_id = :channel_category)
OR EXISTS (SELECT 1 FROM category_groups WHERE category_id = :channel_category AND group_id = :everyone AND permission_type <= :permission_type)
OR cg.category_id = :channel_category)
ON CONFLICT DO NOTHING
RETURNING user_chat_channel_memberships.user_id
SQL
end
private
def query_args
{
chat_channel_id: channel.id,
start: params.start_user_id,
end: params.end_user_id,
suspended_until: Time.zone.now,
last_seen_at: 3.months.ago,
channel_category: channel.category.id,
permission_type: CategoryGroup.permission_types[:create_post],
everyone: Group::AUTO_GROUPS[:everyone],
mode: ::Chat::UserChatChannelMembership.join_modes[:automatic],
}
end
end
end
end

View File

@ -8,28 +8,26 @@ module Chat
delegate :chat_channel, :user, to: :channel_membership
def call
return unless chat_channel.direct_message_channel?
return if users_allowing_communication.none?
return if !chat_channel.direct_message_channel?
return if user_ids.empty?
chat_channel
.user_chat_channel_memberships
.where(user: users_allowing_communication)
.where(user_id: user_ids)
.update_all(following: true)
Chat::Publisher.publish_new_channel(chat_channel, users_allowing_communication)
Chat::Publisher.publish_new_channel(chat_channel, user_ids)
end
private
def users_allowing_communication
@users_allowing_communication ||= User.where(id: user_ids).to_a
end
def user_ids
UserCommScreener.new(
acting_user: user,
target_user_ids:
chat_channel.user_chat_channel_memberships.where(following: false).pluck(:user_id),
).allowing_actor_communication + Array.wrap(current_user_id)
@user_ids ||=
UserCommScreener.new(
acting_user: user,
target_user_ids:
chat_channel.user_chat_channel_memberships.where(following: false).pluck(:user_id),
).allowing_actor_communication + Array.wrap(current_user_id)
end
def current_user_id

View File

@ -8,31 +8,29 @@ module Chat
# this in staff actions so it's obvious why these users were
# removed.
class PublishAutoRemovedUser < Service::ActionBase
# @param [Symbol] event_type What caused the users to be removed,
# @param [Symbol] event What caused the users to be removed,
# each handler will define this, e.g. category_updated, user_removed_from_group
# @param [Hash] users_removed_map A hash with channel_id as its keys and an
# array of user_ids who were removed from the channel.
option :event_type
option :event
option :users_removed_map
def call
return if users_removed_map.empty?
users_removed_map.each do |channel_id, user_ids|
users_removed_map.each do |channel_id, all_user_ids|
next if all_user_ids.empty?
job_spacer = JobTimeSpacer.new
user_ids.in_groups_of(1000, false) do |user_id_batch|
job_spacer.enqueue(
Jobs::Chat::KickUsersFromChannel,
{ channel_id: channel_id, user_ids: user_id_batch },
)
all_user_ids.in_groups_of(1000, false) do |user_ids|
job_spacer.enqueue(Jobs::Chat::KickUsersFromChannel, { channel_id:, user_ids: })
end
if user_ids.any?
StaffActionLogger.new(Discourse.system_user).log_custom(
"chat_auto_remove_membership",
{ users_removed: user_ids.length, channel_id: channel_id, event: event_type },
)
end
StaffActionLogger.new(Discourse.system_user).log_custom(
"chat_auto_remove_membership",
{ users_removed: all_user_ids.size, channel_id:, event: },
)
end
end
end

View File

@ -1,17 +0,0 @@
# frozen_string_literal: true
module Chat
module Action
class RemoveMemberships < Service::ActionBase
option :memberships
def call
memberships
.destroy_all
.each_with_object(Hash.new { |h, k| h[k] = [] }) do |obj, hash|
hash[obj.chat_channel_id] << obj.user_id
end
end
end
end
end

View File

@ -1,70 +0,0 @@
# NOTE: When changing auto-join logic, make sure to update the `settings.auto_join_users_info` translation as well.
# frozen_string_literal: true
module Chat
# Service responsible to create memberships for a channel and a section of user ids
#
# @example
# Chat::AutoJoinChannelBatch.call(
# params: {
# channel_id: 1,
# start_user_id: 27,
# end_user_id: 58,
# }
# )
#
class AutoJoinChannelBatch
include Service::Base
params do
# Backward-compatible attributes
attribute :chat_channel_id, :integer
attribute :starts_at, :integer
attribute :ends_at, :integer
# New attributes
attribute :channel_id, :integer
attribute :start_user_id, :integer
attribute :end_user_id, :integer
validates :channel_id, :start_user_id, :end_user_id, presence: true
validates :end_user_id, comparison: { greater_than_or_equal_to: :start_user_id }
# TODO (joffrey): remove after migration is done
before_validation do
self.channel_id ||= chat_channel_id
self.start_user_id ||= starts_at
self.end_user_id ||= ends_at
end
end
model :channel
step :create_memberships
step :recalculate_user_count
step :publish_new_channel
private
def fetch_channel(params:)
::Chat::CategoryChannel.find_by(id: params.channel_id, auto_join_users: true)
end
def create_memberships(channel:, params:)
context[:added_user_ids] = ::Chat::Action::CreateMembershipsForAutoJoin.call(
channel:,
params:,
)
end
def recalculate_user_count(channel:, added_user_ids:)
# Only do this if we are running auto-join for a single user, if we
# are doing it for many then we should do it after all batches are
# complete for the channel in Jobs::AutoJoinChannelMemberships
return unless added_user_ids.one?
::Chat::ChannelMembershipManager.new(channel).recalculate_user_count
end
def publish_new_channel(channel:, added_user_ids:)
::Chat::Publisher.publish_new_channel(channel, User.where(id: added_user_ids))
end
end
end

View File

@ -0,0 +1,105 @@
# frozen_string_literal: true
module Chat
class AutoJoinChannels
include Service::Base
ALLOWED_GROUP_PERMISSIONS = [
CategoryGroup.permission_types[:create_post],
CategoryGroup.permission_types[:full],
]
policy :chat_enabled?
params do
attribute :user_id, :integer
attribute :channel_id, :integer
attribute :category_id, :integer
end
step :create_memberships
private
def chat_enabled?
SiteSetting.chat_enabled
end
def create_memberships(params:)
automatic = ::Chat::UserChatChannelMembership.join_modes[:automatic]
group_permissions = ALLOWED_GROUP_PERMISSIONS
group_ids = SiteSetting.chat_allowed_groups_map
everyone_allowed = group_ids.include?(Group::AUTO_GROUPS[:everyone])
max_users = SiteSetting.max_chat_auto_joined_users
now = Time.zone.now
last_seen_at = 30.days.ago
# used to filter down to a specific user, chat channel, or category
user_sql = params.user_id ? "AND u.id = #{params.user_id}" : ""
channel_sql = params.channel_id ? "AND cc.id = #{params.channel_id}" : ""
category_sql = params.category_id ? "AND c.id = #{params.category_id}" : ""
sql = <<~SQL
WITH chat_users AS ( -- users that are allowed to join chat
SELECT u.id
FROM users u
JOIN user_options uo ON uo.user_id = u.id AND uo.chat_enabled = TRUE
WHERE u.id > 0
AND u.active = TRUE
AND u.staged = FALSE
AND (u.suspended_till IS NULL OR u.suspended_till <= :now)
AND (u.silenced_till IS NULL OR u.silenced_till <= :now)
AND NOT EXISTS (SELECT 1 FROM anonymous_users au WHERE au.user_id = u.id)
AND u.last_seen_at > :last_seen_at
#{user_sql}
#{everyone_allowed ? "" : "AND EXISTS (SELECT 1 FROM group_users gu WHERE gu.user_id = u.id AND gu.group_id IN (:group_ids))"}
ORDER BY u.last_seen_at DESC
LIMIT :max_users
), valid_chat_channels AS ( -- auto joinable chat channels
SELECT cc.id, cc.chatable_id
FROM chat_channels cc
WHERE cc.auto_join_users = TRUE
AND cc.chatable_type = 'Category'
AND cc.deleted_at IS NULL
AND cc.user_count < :max_users
#{channel_sql}
), public AS ( -- public chat channels
SELECT cu.id AS user_id, cc.id AS chat_channel_id
FROM valid_chat_channels cc
CROSS JOIN chat_users cu
JOIN categories c ON c.id = cc.chatable_id AND c.read_restricted = FALSE #{category_sql}
), private AS ( -- private chat channels
SELECT DISTINCT gu.user_id, cc.id AS chat_channel_id
FROM valid_chat_channels cc
JOIN categories c ON c.id = cc.chatable_id AND c.read_restricted = TRUE #{category_sql}
JOIN category_groups cg ON cg.category_id = c.id AND cg.permission_type IN (:group_permissions)
JOIN group_users gu ON gu.group_id = cg.group_id AND gu.user_id IN (SELECT id FROM chat_users)
)
INSERT INTO user_chat_channel_memberships (user_id, chat_channel_id, following, join_mode, created_at, updated_at)
SELECT p.user_id, p.chat_channel_id, TRUE, :automatic, :now, :now
FROM (
SELECT * FROM public
UNION ALL
SELECT * FROM private
) p
LEFT JOIN user_chat_channel_memberships uccm ON uccm.user_id = p.user_id AND uccm.chat_channel_id = p.chat_channel_id
WHERE uccm.user_id IS NULL
RETURNING chat_channel_id, user_id
SQL
channel_to_users = Hash.new { |h, k| h[k] = [] }
args = { now:, last_seen_at:, group_ids:, max_users:, group_permissions:, automatic: }
DB
.query_array(sql, args)
.each { |channel_id, user_id| channel_to_users[channel_id] << user_id }
::Chat::Channel
.where(id: channel_to_users.keys)
.find_each do |channel|
::Chat::ChannelMembershipManager.new(channel).recalculate_user_count
::Chat::Publisher.publish_new_channel(channel, channel_to_users[channel.id])
end
end
end
end

View File

@ -0,0 +1,84 @@
# frozen_string_literal: true
module Chat
class AutoLeaveChannels
include Service::Base
ALLOWED_GROUP_PERMISSIONS = [
CategoryGroup.permission_types[:create_post],
CategoryGroup.permission_types[:full],
]
policy :chat_enabled?
params do
attribute :event
attribute :user_id, :integer
attribute :group_id, :integer
attribute :channel_id, :integer
attribute :category_id, :integer
end
step :remove_memberships
private
def chat_enabled?
SiteSetting.chat_enabled
end
def remove_memberships(params:)
group_ids = SiteSetting.chat_allowed_groups_map
group_permissions = ALLOWED_GROUP_PERMISSIONS
users_removed_map = Hash.new { |h, k| h[k] = [] }
if !group_ids.include?(Group::AUTO_GROUPS[:everyone])
sql = <<~SQL
DELETE FROM user_chat_channel_memberships uccm
WHERE NOT EXISTS (
SELECT 1
FROM group_users gu
WHERE gu.user_id = uccm.user_id
AND gu.group_id IN (:group_ids)
)
RETURNING chat_channel_id, user_id
SQL
DB
.query_array(sql, group_ids:)
.each { |channel_id, user_id| users_removed_map[channel_id] << user_id }
end
user_sql = params.user_id ? "AND u.id = #{params.user_id}" : ""
channel_sql = params.channel_id ? "AND cc.id = #{params.channel_id}" : ""
category_sql = params.category_id ? "AND c.id = #{params.category_id}" : ""
sql = <<~SQL
WITH valid_permissions AS (
SELECT gu.user_id, cg.category_id
FROM group_users gu
JOIN category_groups cg ON cg.group_id = gu.group_id AND cg.permission_type IN (:group_permissions)
)
DELETE FROM user_chat_channel_memberships
WHERE (user_id, chat_channel_id) IN (
SELECT uccm.user_id, uccm.chat_channel_id
FROM user_chat_channel_memberships uccm
JOIN users u ON u.id = uccm.user_id AND u.id > 0 AND u.moderator = FALSE AND u.admin = FALSE #{user_sql}
JOIN chat_channels cc ON cc.id = uccm.chat_channel_id AND cc.chatable_type = 'Category' #{channel_sql}
JOIN categories c ON c.id = cc.chatable_id AND c.read_restricted = TRUE #{category_sql}
LEFT JOIN valid_permissions vp ON vp.user_id = uccm.user_id AND vp.category_id = c.id
WHERE vp.user_id IS NULL
)
RETURNING chat_channel_id, user_id
SQL
DB
.query_array(sql, group_permissions:)
.each { |channel_id, user_id| users_removed_map[channel_id] << user_id }
if users_removed_map.present?
Chat::Action::PublishAutoRemovedUser.call(event: params.event, users_removed_map:)
end
end
end
end

View File

@ -1,81 +0,0 @@
# frozen_string_literal: true
module Chat
module AutoRemove
# Fired from [Jobs::AutoRemoveMembershipHandleCategoryUpdated], which
# in turn is enqueued whenever the [DiscourseEvent] for :category_updated
# is triggered. Any users who can no longer access category-based channels
# based on category_groups and in turn group_users will be removed from
# those chat channels.
#
# If a user is in any groups that have the `full` or `create_post`
# [CategoryGroup#permission_types] or if the category has no groups remaining,
# then the user will remain in the channel.
class HandleCategoryUpdated
include Service::Base
policy :chat_enabled
params do
attribute :category_id, :integer
validates :category_id, presence: true
end
step :assign_defaults
model :category
model :category_channel_ids
model :users
step :remove_users_without_channel_permission
step :publish
private
def assign_defaults
context[:users_removed_map] = {}
end
def chat_enabled
SiteSetting.chat_enabled
end
def fetch_category(params:)
Category.find_by(id: params.category_id)
end
def fetch_category_channel_ids(category:)
Chat::Channel.where(chatable: category).pluck(:id)
end
def fetch_users(category_channel_ids:)
User
.real
.activated
.not_suspended
.not_staged
.joins(:user_chat_channel_memberships)
.where("user_chat_channel_memberships.chat_channel_id IN (?)", category_channel_ids)
.where("NOT admin AND NOT moderator")
end
def remove_users_without_channel_permission(users:, category_channel_ids:)
memberships_to_remove =
Chat::Action::CalculateMembershipsForRemoval.call(
scoped_users_query: users,
channel_ids: category_channel_ids,
)
return if memberships_to_remove.blank?
context[:users_removed_map] = Chat::Action::RemoveMemberships.call(
memberships: Chat::UserChatChannelMembership.where(id: memberships_to_remove),
)
end
def publish(users_removed_map:)
Chat::Action::PublishAutoRemovedUser.call(
event_type: :category_updated,
users_removed_map: users_removed_map,
)
end
end
end
end

View File

@ -1,81 +0,0 @@
# frozen_string_literal: true
module Chat
module AutoRemove
# Fired from [Jobs::AutoRemoveMembershipHandleChatAllowedGroupsChange], which
# in turn is enqueued whenever the [DiscourseEvent] for :site_setting_changed
# is triggered for the chat_allowed_groups setting.
#
# If any of the chat_allowed_groups is the everyone auto group then nothing
# needs to be done.
#
# Otherwise, if there are no longer any chat_allowed_groups, we have to
# remove all non-admin users from category channels. Otherwise we just
# remove the ones who are not in any of the chat_allowed_groups.
#
# Direct message channel memberships are intentionally left alone,
# these are private communications between two people.
class HandleChatAllowedGroupsChange
include Service::Base
policy :chat_enabled
params { attribute :new_allowed_groups, :array }
policy :not_everyone_allowed
model :users
model :memberships_to_remove
step :remove_users_outside_allowed_groups
step :publish
private
def chat_enabled
SiteSetting.chat_enabled
end
def not_everyone_allowed(params:)
params.new_allowed_groups.exclude?(Group::AUTO_GROUPS[:everyone])
end
def fetch_users(params:)
User
.real
.activated
.not_suspended
.not_staged
.where("NOT admin AND NOT moderator")
.joins(:user_chat_channel_memberships)
.distinct
.then do |users|
break users if params.new_allowed_groups.blank?
users.where(<<~SQL, params.new_allowed_groups)
users.id NOT IN (
SELECT DISTINCT group_users.user_id
FROM group_users
WHERE group_users.group_id IN (?)
)
SQL
end
end
def fetch_memberships_to_remove(users:)
Chat::UserChatChannelMembership
.joins(:chat_channel)
.where(user_id: users.pluck(:id))
.where.not(chat_channel: { type: "DirectMessageChannel" })
end
def remove_users_outside_allowed_groups(memberships_to_remove:)
context[:users_removed_map] = Chat::Action::RemoveMemberships.call(
memberships: memberships_to_remove,
)
end
def publish(users_removed_map:)
Chat::Action::PublishAutoRemovedUser.call(
event_type: :chat_allowed_groups_changed,
users_removed_map: users_removed_map,
)
end
end
end
end

View File

@ -1,116 +0,0 @@
# frozen_string_literal: true
module Chat
module AutoRemove
# Fired from [Jobs::AutoRemoveMembershipHandleUserRemovedFromGroup], which
# is in turn enqueued whenever the [DiscourseEvent] for :group_destroyed
# is triggered.
#
# The :group_destroyed event provides us with the user_ids of the former
# GroupUser records so we can scope this better.
#
# Since this could have potential wide-ranging impact, we have to check:
# * The chat_allowed_groups [SiteSetting], and if any of the scoped users
# are still allowed to use public chat channels based on this setting.
# * The channel permissions of all the category chat channels the users
# are a part of, based on [CategoryGroup] records
#
# If a user is in a groups that has the `full` or `create_post`
# [CategoryGroup#permission_types] or if the category has no groups remaining,
# then the user will remain in the channel.
class HandleDestroyedGroup
include Service::Base
policy :chat_enabled
params do
attribute :destroyed_group_user_ids, :array
validates :destroyed_group_user_ids, presence: true
end
step :assign_defaults
policy :not_everyone_allowed
model :scoped_users
step :remove_users_outside_allowed_groups
step :remove_users_without_channel_permission
step :publish
private
def assign_defaults
context[:users_removed_map] = {}
end
def chat_enabled
SiteSetting.chat_enabled
end
def not_everyone_allowed
!SiteSetting.chat_allowed_groups_map.include?(Group::AUTO_GROUPS[:everyone])
end
def fetch_scoped_users(params:)
User
.real
.activated
.not_suspended
.not_staged
.includes(:group_users)
.where("NOT admin AND NOT moderator")
.where(id: params.destroyed_group_user_ids)
.joins(:user_chat_channel_memberships)
.distinct
end
def remove_users_outside_allowed_groups(scoped_users:)
users = scoped_users
# Remove any of these users from all category channels if they
# are not in any of the chat_allowed_groups or if there are no
# chat allowed groups.
if SiteSetting.chat_allowed_groups_map.any?
group_user_sql = <<~SQL
users.id NOT IN (
SELECT DISTINCT group_users.user_id
FROM group_users
WHERE group_users.group_id IN (#{SiteSetting.chat_allowed_groups_map.join(",")})
)
SQL
users = users.where(group_user_sql)
end
user_ids_to_remove = users.pluck(:id)
return if user_ids_to_remove.empty?
memberships_to_remove =
Chat::UserChatChannelMembership
.joins(:chat_channel)
.where(user_id: user_ids_to_remove)
.where.not(chat_channel: { type: "DirectMessageChannel" })
return if memberships_to_remove.empty?
context[:users_removed_map] = Chat::Action::RemoveMemberships.call(
memberships: memberships_to_remove,
)
end
def remove_users_without_channel_permission(scoped_users:)
memberships_to_remove =
Chat::Action::CalculateMembershipsForRemoval.call(scoped_users_query: scoped_users)
return if memberships_to_remove.empty?
context[:users_removed_map] = Chat::Action::RemoveMemberships.call(
memberships: Chat::UserChatChannelMembership.where(id: memberships_to_remove),
)
end
def publish(users_removed_map:)
Chat::Action::PublishAutoRemovedUser.call(
event_type: :destroyed_group,
users_removed_map: users_removed_map,
)
end
end
end
end

View File

@ -1,97 +0,0 @@
# frozen_string_literal: true
module Chat
module AutoRemove
# Fired from [Jobs::AutoRemoveMembershipHandleUserRemovedFromGroup], which
# in turn is enqueued whenever the [DiscourseEvent] for :user_removed_from_group
# is triggered.
#
# Staff users will never be affected by this, they can always chat regardless
# of group permissions.
#
# Since this could have potential wide-ranging impact, we have to check:
# * The chat_allowed_groups [SiteSetting], and if the scoped user
# is still allowed to use public chat channels based on this setting.
# * The channel permissions of all the category chat channels the user
# is a part of, based on [CategoryGroup] records
#
# Direct message channel memberships are intentionally left alone,
# these are private communications between two people.
class HandleUserRemovedFromGroup
include Service::Base
policy :chat_enabled
params do
attribute :user_id, :integer
validates :user_id, presence: true
end
step :assign_defaults
policy :not_everyone_allowed
model :user
policy :user_not_staff
step :remove_if_outside_chat_allowed_groups
step :remove_from_private_channels
step :publish
private
def assign_defaults
context[:users_removed_map] = {}
end
def chat_enabled
SiteSetting.chat_enabled
end
def not_everyone_allowed
!SiteSetting.chat_allowed_groups_map.include?(Group::AUTO_GROUPS[:everyone])
end
def fetch_user(params:)
User.find_by(id: params.user_id)
end
def user_not_staff(user:)
!user.staff?
end
def remove_if_outside_chat_allowed_groups(user:)
if SiteSetting.chat_allowed_groups_map.empty? ||
!GroupUser.exists?(group_id: SiteSetting.chat_allowed_groups_map, user: user)
memberships_to_remove =
Chat::UserChatChannelMembership
.joins(:chat_channel)
.where(user_id: user.id)
.where.not(chat_channel: { type: "DirectMessageChannel" })
return if memberships_to_remove.empty?
context[:users_removed_map] = Chat::Action::RemoveMemberships.call(
memberships: memberships_to_remove,
)
end
end
def remove_from_private_channels(user:)
memberships_to_remove =
Chat::Action::CalculateMembershipsForRemoval.call(
scoped_users_query: User.where(id: user.id),
)
return if memberships_to_remove.empty?
context[:users_removed_map] = Chat::Action::RemoveMemberships.call(
memberships: Chat::UserChatChannelMembership.where(id: memberships_to_remove),
)
end
def publish(users_removed_map:)
Chat::Action::PublishAutoRemovedUser.call(
event_type: :user_removed_from_group,
users_removed_map: users_removed_map,
)
end
end
end
end

View File

@ -54,7 +54,7 @@ module Chat
model :channel, :create_channel
model :membership, :create_membership
end
step :enforce_automatic_channel_memberships
step :auto_join_users_if_needed
private
@ -92,9 +92,8 @@ module Chat
channel.user_chat_channel_memberships.create(user: guardian.user, following: true)
end
def enforce_automatic_channel_memberships(channel:)
return if !channel.auto_join_users?
Chat::ChannelMembershipManager.new(channel).enforce_automatic_channel_memberships
def auto_join_users_if_needed(channel:)
Chat::AutoJoinChannels.call(params: { channel_id: channel.id }) if channel.auto_join_users?
end
end
end

View File

@ -361,10 +361,10 @@ module Chat
NEW_CHANNEL_MESSAGE_BUS_CHANNEL = "/chat/new-channel"
def self.publish_new_channel(chat_channel, users)
def self.publish_new_channel(chat_channel, user_ids)
Chat::UserChatChannelMembership
.includes(:user)
.where(chat_channel: chat_channel, user: users)
.where(chat_channel: chat_channel, user_id: user_ids)
.find_in_batches do |memberships|
memberships.each do |membership|
serialized_channel =

View File

@ -86,8 +86,7 @@ module Chat
end
def auto_join_users_if_needed(channel:)
return unless channel.auto_join_users?
Chat::ChannelMembershipManager.new(channel).enforce_automatic_channel_memberships
Chat::AutoJoinChannels.call(params: { channel_id: channel.id }) if channel.auto_join_users?
end
end
end

View File

@ -64,18 +64,5 @@ module Chat
last_read_message_id: channel.chat_messages.last&.id,
)
end
def enforce_automatic_channel_memberships
Jobs.enqueue(Jobs::Chat::AutoJoinChannelMemberships, chat_channel_id: channel.id)
end
def enforce_automatic_user_membership(user)
Jobs.enqueue(
Jobs::Chat::AutoJoinChannelBatch,
chat_channel_id: channel.id,
starts_at: user.id,
ends_at: user.id,
)
end
end
end

View File

@ -19,12 +19,9 @@ module Chat
category = Category.find_by(id: category_id)
return if category.nil?
chat_channel = category.create_chat_channel!(auto_join_users: true, name: category.name)
category.create_chat_channel!(auto_join_users: true, name: category.name)
category.custom_fields[Chat::HAS_CHAT_ENABLED] = true
category.save!
Chat::ChannelMembershipManager.new(chat_channel).enforce_automatic_channel_memberships
chat_channel
end
end
end

View File

@ -289,10 +289,7 @@ after_initialize do
end
if name == :chat_allowed_groups
Jobs.enqueue(
Jobs::Chat::AutoRemoveMembershipHandleChatAllowedGroupsChange,
new_allowed_groups: new_value,
)
Jobs.enqueue(Jobs::Chat::AutoJoinUsers, event: "chat_allowed_groups_changed")
end
end
@ -301,11 +298,8 @@ after_initialize do
Chat::PostNotificationHandler.new(post, notified).handle
end
on(:group_destroyed) do |group, user_ids|
Jobs.enqueue(
Jobs::Chat::AutoRemoveMembershipHandleDestroyedGroup,
destroyed_group_user_ids: user_ids,
)
on(:group_destroyed) do |group, _user_ids|
Chat::AutoLeaveChannels.call(params: { group_id: group.id, event: :group_destroyed })
end
register_presence_channel_prefix("chat") do |channel_name|
@ -358,52 +352,31 @@ after_initialize do
on(:user_seen) do |user|
if user.last_seen_at == user.first_seen_at
Chat::Channel
.where(auto_join_users: true)
.each do |channel|
Chat::ChannelMembershipManager.new(channel).enforce_automatic_user_membership(user)
end
Chat::AutoJoinChannels.call(params: { user_id: user.id })
end
end
on(:user_confirmed_email) do |user|
if user.active?
Chat::Channel
.where(auto_join_users: true)
.each do |channel|
Chat::ChannelMembershipManager.new(channel).enforce_automatic_user_membership(user)
end
end
Chat::AutoJoinChannels.call(params: { user_id: user.id }) if user.active?
end
on(:user_added_to_group) do |user, group|
channels_to_add =
Chat::Channel
.distinct
.where(auto_join_users: true, chatable_type: "Category")
.joins(
"INNER JOIN category_groups ON category_groups.category_id = chat_channels.chatable_id",
)
.where(category_groups: { group_id: group.id })
channels_to_add.each do |channel|
Chat::ChannelMembershipManager.new(channel).enforce_automatic_user_membership(user)
end
on(:user_added_to_group) do |user, _group|
Chat::AutoJoinChannels.call(params: { user_id: user.id })
end
on(:user_removed_from_group) do |user, group|
Jobs.enqueue(Jobs::Chat::AutoRemoveMembershipHandleUserRemovedFromGroup, user_id: user.id)
on(:user_removed_from_group) do |user, _group|
Chat::AutoLeaveChannels.call(params: { user_id: user.id, event: :user_removed_from_group })
end
on(:category_updated) do |category|
# There's a bug on core where this event is triggered with an `#update` result (true/false)
if category.is_a?(Category) && category_channel = Chat::Channel.find_by(chatable: category)
if category_channel.auto_join_users
Chat::ChannelMembershipManager.new(category_channel).enforce_automatic_channel_memberships
end
next unless category.is_a?(Category)
next unless category_channel = Chat::Channel.find_by(chatable: category)
Jobs.enqueue(Jobs::Chat::AutoRemoveMembershipHandleCategoryUpdated, category_id: category.id)
if category_channel.auto_join_users
Chat::AutoJoinChannels.call(params: { category_id: category.id })
end
Chat::AutoLeaveChannels.call(params: { category_id: category.id, event: :category_updated })
end
# outgoing webhook events

View File

@ -4,24 +4,18 @@ describe Chat::Seeder do
fab!(:staff_category) { Fabricate(:private_category, name: "Staff", group: Group[:staff]) }
fab!(:general_category) { Fabricate(:category, name: "General") }
fab!(:staff_user1) do
Fabricate(:user, last_seen_at: 1.minute.ago, groups: [Group[:staff], Group[:everyone]])
end
fab!(:staff_user2) do
Fabricate(:user, last_seen_at: 1.minute.ago, groups: [Group[:staff], Group[:everyone]])
end
fab!(:regular_user) { Fabricate(:user, last_seen_at: 1.minute.ago, groups: [Group[:everyone]]) }
before do
SiteSetting.staff_category_id = staff_category.id
SiteSetting.general_category_id = general_category.id
Jobs.run_immediately!
end
def assert_channel_was_correctly_seeded(channel, group)
def assert_channel_was_correctly_seeded(channel, group, category)
expect(channel).to be_present
expect(channel.auto_join_users).to eq(true)
expect(channel.name).to eq(category.name)
expect(category.custom_fields[Chat::HAS_CHAT_ENABLED]).to eq(true)
expected_members_count = GroupUser.where(group: group).count
memberships_count =
@ -31,37 +25,24 @@ describe Chat::Seeder do
end
it "seeds default channels" do
last_seen_at = 1.minute.ago
# By default, `chat_allowed_groups` is set to admins, moderators, and TL1
Fabricate(:user, last_seen_at:, groups: [Group[:everyone], Group[:admins]])
Fabricate(:user, last_seen_at:, groups: [Group[:everyone], Group[:moderators]])
Fabricate(:user, last_seen_at:, groups: [Group[:everyone], Group[:trust_level_1]])
Chat::Seeder.new.execute
staff_channel = Chat::Channel.find_by(chatable_id: staff_category)
general_channel = Chat::Channel.find_by(chatable_id: general_category)
assert_channel_was_correctly_seeded(staff_channel, Group[:staff])
assert_channel_was_correctly_seeded(general_channel, Group[:everyone])
assert_channel_was_correctly_seeded(staff_channel, Group[:staff], staff_category)
assert_channel_was_correctly_seeded(general_channel, Group[:everyone], general_category)
expect(staff_category.custom_fields[Chat::HAS_CHAT_ENABLED]).to eq(true)
expect(general_category.reload.custom_fields[Chat::HAS_CHAT_ENABLED]).to eq(true)
expect(SiteSetting.needs_chat_seeded).to eq(false)
end
it "applies a name to the general category channel" do
expected_name = general_category.name
Chat::Seeder.new.execute
general_channel = Chat::Channel.find_by(chatable_id: general_category)
expect(general_channel.name).to eq(expected_name)
end
it "applies a name to the staff category channel" do
expected_name = staff_category.name
Chat::Seeder.new.execute
staff_channel = Chat::Channel.find_by(chatable_id: staff_category)
expect(staff_channel.name).to eq(expected_name)
end
it "does nothing when 'SiteSetting.needs_chat_seeded' is false" do
SiteSetting.needs_chat_seeded = false

View File

@ -1,9 +1,9 @@
# frozen_string_literal: true
describe "Automatic user removal from channels" do
fab!(:user_1) { Fabricate(:user, trust_level: TrustLevel[1]) }
fab!(:user_1) { Fabricate(:user, trust_level: 1) }
let(:user_1_guardian) { Guardian.new(user_1) }
fab!(:user_2) { Fabricate(:user, trust_level: TrustLevel[1]) }
fab!(:user_2) { Fabricate(:user, trust_level: 3) }
fab!(:secret_group) { Fabricate(:group) }
fab!(:private_category) { Fabricate(:private_category, group: secret_group) }
@ -13,7 +13,6 @@ describe "Automatic user removal from channels" do
fab!(:dm_channel) { Fabricate(:direct_message_channel, users: [user_1, user_2]) }
before do
SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:trust_level_1]
SiteSetting.chat_enabled = true
Jobs.run_immediately!
@ -47,35 +46,45 @@ describe "Automatic user removal from channels" do
end
it "does not remove the user who is in one of the chat_allowed_groups" do
user_2.change_trust_level!(TrustLevel[4])
user_2.change_trust_level!(4)
expect { SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:trust_level_3] }.to change {
Chat::UserChatChannelMembership.count
}.by(-2)
}.by(-3)
expect(
Chat::UserChatChannelMembership.exists?(user: user_2, chat_channel: public_channel),
).to eq(true)
end
it "does not remove users from their DM channels" do
it "removes users from their DM channels" do
expect { SiteSetting.chat_allowed_groups = "" }.to change {
Chat::UserChatChannelMembership.count
}.by(-3)
}.by(-5)
expect(Chat::UserChatChannelMembership.exists?(user: user_1, chat_channel: dm_channel)).to eq(
true,
false,
)
expect(Chat::UserChatChannelMembership.exists?(user: user_2, chat_channel: dm_channel)).to eq(
true,
false,
)
end
context "for staff users" do
fab!(:staff_user) { Fabricate(:admin) }
it "does not remove them from public channels" do
it "does not remove them from chat channels" do
public_channel.add(staff_user)
private_channel.add(staff_user)
expect(
Chat::UserChatChannelMembership.where(
user: staff_user,
chat_channel: [public_channel, private_channel],
).count,
).to eq(2)
SiteSetting.chat_allowed_groups = ""
expect(
Chat::UserChatChannelMembership.where(
user: staff_user,
@ -86,6 +95,7 @@ describe "Automatic user removal from channels" do
it "does not remove them from DM channels" do
staff_dm_channel = Fabricate(:direct_message_channel, users: [user_1, staff_user])
expect(
Chat::UserChatChannelMembership.where(
user: staff_user,
@ -105,21 +115,12 @@ describe "Automatic user removal from channels" do
SiteSetting.chat_allowed_groups = group.id
end
it "removes the user from the category channels" do
group.remove(user_1)
expect(
Chat::UserChatChannelMembership.where(
user: user_1,
chat_channel: [public_channel, private_channel],
).count,
).to eq(0)
end
it "removes the user from all channels" do
expect(Chat::UserChatChannelMembership.where(user: user_1).count).to eq(3)
it "does not remove the user from DM channels" do
group.remove(user_1)
expect(
Chat::UserChatChannelMembership.where(user: user_1, chat_channel: dm_channel).count,
).to eq(1)
expect(Chat::UserChatChannelMembership.where(user: user_1).count).to eq(0)
end
context "for staff users" do
@ -155,6 +156,7 @@ describe "Automatic user removal from channels" do
it "does not remove them from the corresponding channel" do
secret_group.remove(user_1)
expect(
Chat::UserChatChannelMembership.exists?(user: user_1, chat_channel: private_channel),
).to eq(true)
@ -167,6 +169,7 @@ describe "Automatic user removal from channels" do
context "when the user is in no other groups that can interact with the channel" do
it "removes them from the corresponding channel" do
secret_group.remove(user_1)
expect(
Chat::UserChatChannelMembership.exists?(user: user_1, chat_channel: private_channel),
).to eq(false)
@ -182,6 +185,7 @@ describe "Automatic user removal from channels" do
context "when the group's permission changes from reply+see to just see for the category" do
it "removes the user from the corresponding category channel" do
private_category.update!(permissions: { secret_group.id => :readonly })
expect(
Chat::UserChatChannelMembership.exists?(user: user_1, chat_channel: private_channel),
).to eq(false)
@ -197,6 +201,7 @@ describe "Automatic user removal from channels" do
secret_group.add(staff_user)
private_channel.add(staff_user)
private_category.update!(permissions: { secret_group.id => :readonly })
expect(
Chat::UserChatChannelMembership.exists?(
user: staff_user,
@ -210,6 +215,7 @@ describe "Automatic user removal from channels" do
context "when the secret_group is no longer allowed to access the private category" do
it "removes the user from the corresponding category channel" do
private_category.update!(permissions: { Group::AUTO_GROUPS[:staff] => :full })
expect(
Chat::UserChatChannelMembership.exists?(user: user_1, chat_channel: private_channel),
).to eq(false)
@ -225,6 +231,7 @@ describe "Automatic user removal from channels" do
secret_group.add(staff_user)
private_channel.add(staff_user)
private_category.update!(permissions: {})
expect(
Chat::UserChatChannelMembership.exists?(
user: staff_user,
@ -238,13 +245,13 @@ describe "Automatic user removal from channels" do
context "when a group is destroyed" do
context "when it was the last group on the private category" do
it "no users are removed because the category defaults to Everyone having full access" do
it "remove users because the category defaults to staff having full access" do
secret_group.destroy!
expect(
Chat::UserChatChannelMembership.exists?(user: user_1, chat_channel: private_channel),
).to eq(true)
expect(Chat::ChannelFetcher.all_secured_channel_ids(user_1_guardian)).to include(
).to eq(false)
expect(Chat::ChannelFetcher.all_secured_channel_ids(user_1_guardian)).to_not include(
private_channel.id,
)

View File

@ -1,34 +0,0 @@
# frozen_string_literal: true
describe Jobs::Chat::AutoJoinChannelBatch do
it "can successfully queue this job" do
expect {
Jobs.enqueue(
described_class,
channel_id: Fabricate(:chat_channel).id,
start_user_id: 0,
end_user_id: 10,
)
}.to change(Jobs::Chat::AutoJoinChannelBatch.jobs, :size).by(1)
end
context "when contract fails" do
before { Jobs.run_immediately! }
it "logs an error" do
Rails.logger.expects(:error).with(regexp_matches(/Channel can't be blank/)).at_least_once
Jobs.enqueue(described_class)
end
end
context "when model is not found" do
before { Jobs.run_immediately! }
it "logs an error" do
Rails.logger.expects(:error).with("Channel not found (id=-999)").at_least_once
Jobs.enqueue(described_class, channel_id: -999, start_user_id: 1, end_user_id: 2)
end
end
end

View File

@ -1,125 +0,0 @@
# frozen_string_literal: true
describe Jobs::Chat::AutoJoinChannelMemberships do
let(:user) { Fabricate(:user, last_seen_at: 15.minutes.ago) }
let(:category) { Fabricate(:category, user: user) }
let(:channel) { Fabricate(:category_channel, auto_join_users: true, chatable: category) }
describe "queues batches to automatically add users to a channel" do
it "queues a batch for users with channel access" do
assert_batches_enqueued(channel, 1)
end
it "does nothing when the channel doesn't exist" do
assert_batches_enqueued(Chat::Channel.new(id: -1), 0)
end
it "does nothing when the chatable is not a category" do
direct_message = Fabricate(:direct_message)
channel.update!(chatable: direct_message)
assert_batches_enqueued(channel, 0)
end
it "excludes users not seen in the last 3 months" do
user.update!(last_seen_at: 3.months.ago)
assert_batches_enqueued(channel, 0)
end
it "excludes users without chat enabled" do
user.user_option.update!(chat_enabled: false)
assert_batches_enqueued(channel, 0)
end
it "respects the max_chat_auto_joined_users setting" do
SiteSetting.max_chat_auto_joined_users = 0
assert_batches_enqueued(channel, 0)
end
it "does nothing when we already reached the max_chat_auto_joined_users limit" do
SiteSetting.max_chat_auto_joined_users = 1
user_2 = Fabricate(:user, last_seen_at: 2.minutes.ago)
Chat::UserChatChannelMembership.create!(
user: user_2,
chat_channel: channel,
following: true,
join_mode: Chat::UserChatChannelMembership.join_modes[:automatic],
)
assert_batches_enqueued(channel, 0)
end
it "ignores users that are already channel members" do
Chat::UserChatChannelMembership.create!(user: user, chat_channel: channel, following: true)
assert_batches_enqueued(channel, 0)
end
it "doesn't queue a batch when the user doesn't follow the channel" do
Chat::UserChatChannelMembership.create!(user: user, chat_channel: channel, following: false)
assert_batches_enqueued(channel, 0)
end
it "skips non-active users" do
user.update!(active: false)
assert_batches_enqueued(channel, 0)
end
it "skips suspended users" do
user.update!(suspended_till: 3.years.from_now)
assert_batches_enqueued(channel, 0)
end
it "skips staged users" do
user.update!(staged: true)
assert_batches_enqueued(channel, 0)
end
context "when the category has read restricted access" do
fab!(:chatters_group) { Fabricate(:group) }
let(:private_category) { Fabricate(:private_category, group: chatters_group) }
let(:channel) { Fabricate(:chat_channel, auto_join_users: true, chatable: private_category) }
it "doesn't queue a batch if the user is not a group member" do
assert_batches_enqueued(channel, 0)
end
context "when the user has category access to a group" do
before { chatters_group.add(user) }
it "queues a batch" do
assert_batches_enqueued(channel, 1)
end
end
end
context "when chatable doesnt exist anymore" do
let(:channel) do
Fabricate(
:category_channel,
auto_join_users: true,
chatable_type: "Category",
chatable_id: -1,
)
end
it "does nothing" do
assert_batches_enqueued(channel, 0)
end
end
end
def assert_batches_enqueued(channel, expected)
expect { subject.execute(chat_channel_id: channel.id) }.to change(
Jobs::Chat::AutoJoinChannelBatch.jobs,
:size,
).by(expected)
end
end

View File

@ -4,21 +4,28 @@ describe Jobs::Chat::AutoJoinUsers do
subject(:job) { described_class.new }
fab!(:channel) { Fabricate(:category_channel, auto_join_users: true) }
fab!(:user) { Fabricate(:user, last_seen_at: 1.minute.ago, active: true) }
fab!(:user) { Fabricate(:user, last_seen_at: 1.minute.ago, trust_level: 1) }
fab!(:group)
fab!(:user_without_chat) do
user = Fabricate(:user)
user.user_option.update!(chat_enabled: false)
user
end
fab!(:stage_user) { Fabricate(:user, staged: true) }
fab!(:staged_user) { Fabricate(:user, staged: true) }
fab!(:suspended_user) { Fabricate(:user, suspended_till: 1.day.from_now) }
fab!(:silenced_user) { Fabricate(:user, silenced_till: 2.day.from_now) }
fab!(:inactive_user) { Fabricate(:user, active: false) }
fab!(:anonymous_user) { Fabricate(:anonymous) }
fab!(:anonymous_user) do
# When using the `anonymous` fabricator, the `::Chat::AutoJoinChannels` is called in the
# `on(:user_added_to_group)` hook **before** the `anonymous_user` record is created in the `after_create` hook
anon = Fabricate(:user)
AnonymousUser.create!(user: anon, master_user: anon, active: true)
anon
end
before { Jobs.run_immediately! }
it "is does not auto join users without permissions" do
it "does not auto join users without permissions" do
channel.category.read_restricted = true
channel.category.set_permissions(group => :full)
channel.category.save!
@ -38,20 +45,14 @@ describe Jobs::Chat::AutoJoinUsers do
it "works for simple workflows" do
# this is just to avoid test fragility, we should always have negative users
bot_id = (User.minimum(:id) - 1)
bot_id = -1 if bot_id > 0
_bot_user = Fabricate(:user, id: bot_id)
membership = Chat::UserChatChannelMembership.find_by(user: user, chat_channel: channel)
expect(membership).to be_nil
_bot = Fabricate(:user, id: [User.minimum(:id), 0].min - 1)
job.execute({})
# should exclude bot / inactive / staged / suspended users
# note category fabricator creates a user so we are stuck with that user in the channel
# excludes bot / chat disabled / inactive / staged / suspended / silenced / anonymous users
expect(
Chat::UserChatChannelMembership.where(chat_channel: channel).pluck(:user_id),
).to contain_exactly(user.id, channel.category.user.id)
).to contain_exactly(user.id)
membership = Chat::UserChatChannelMembership.find_by(user: user, chat_channel: channel)
expect(membership.following).to eq(true)

View File

@ -55,7 +55,7 @@ RSpec.describe Chat::CategoryChannel do
end
context "when channel is not public" do
let(:staff_groups) { Group::AUTO_GROUPS.slice(:staff, :moderators, :admins).values }
let(:staff_groups) { Group::AUTO_GROUPS.values_at(:staff, :moderators, :admins) }
let(:group) { Fabricate(:group) }
let(:private_category) { Fabricate(:private_category, group: group) }
let(:channel) { Fabricate(:category_channel, chatable: private_category) }

View File

@ -183,7 +183,7 @@ describe Chat do
describe "auto-joining users to a channel" do
fab!(:chatters_group) { Fabricate(:group) }
fab!(:user) { Fabricate(:user, last_seen_at: 15.minutes.ago) }
fab!(:user) { Fabricate(:user, last_seen_at: 15.minutes.ago, trust_level: 1) }
let!(:channel) { Fabricate(:category_channel, auto_join_users: true, chatable: category) }
before { Jobs.run_immediately! }
@ -212,7 +212,7 @@ describe Chat do
describe "when a user is created" do
fab!(:category)
let(:user) { Fabricate(:user, last_seen_at: nil, first_seen_at: nil) }
let(:user) { Fabricate(:user, last_seen_at: nil, first_seen_at: nil, trust_level: 1) }
it "queues a job to auto-join the user the first time they log in" do
user.update_last_seen!
@ -220,13 +220,6 @@ describe Chat do
assert_user_following_state(user, channel, following: true)
end
it "does nothing if it's not the first time we see the user" do
user.update!(first_seen_at: 2.minute.ago)
user.update_last_seen!
assert_user_following_state(user, channel, following: false)
end
it "does nothing if auto-join is disabled" do
channel.update!(auto_join_users: false)

View File

@ -78,9 +78,11 @@ describe Chat::ChannelMembershipsQuery do
end
it "returns the membership if the user still has access through a staff group" do
chatters_group.remove(user_1)
user_1.update!(admin: true)
Group.find_by(id: Group::AUTO_GROUPS[:staff]).add(user_1)
chatters_group.remove(user_1)
memberships = described_class.call(channel: channel_1)
expect(memberships.pluck(:user_id)).to include(user_1.id)
@ -291,6 +293,24 @@ describe Chat::ChannelMembershipsQuery do
end
end
context "when user is silenced" do
fab!(:channel_1) { Fabricate(:category_channel) }
fab!(:silenced_user) { Fabricate(:user, silenced_till: 5.days.from_now) }
before do
Chat::UserChatChannelMembership.create(
user: silenced_user,
chat_channel: channel_1,
following: true,
)
end
it "doesnt list silenced users" do
memberships = described_class.call(channel: channel_1)
expect(memberships).to be_blank
end
end
context "when user is inactive" do
fab!(:channel_1) { Fabricate(:category_channel) }
fab!(:inactive_user)

View File

@ -12,7 +12,7 @@ describe UsersController do
end
it "triggers the auto-join process" do
user = Fabricate(:user, last_seen_at: 1.minute.ago, active: false)
user = Fabricate(:user, last_seen_at: 1.minute.ago, active: false, trust_level: 1)
email_token = Fabricate(:email_token, user: user)
put "/u/activate-account/#{email_token.token}"

View File

@ -1,172 +0,0 @@
# frozen_string_literal: true
RSpec.describe Chat::Action::CreateMembershipsForAutoJoin do
subject(:action) { described_class.call(channel:, params:) }
fab!(:channel) { Fabricate(:chat_channel, auto_join_users: true) }
fab!(:user_1) { Fabricate(:user, last_seen_at: 15.minutes.ago) }
let(:start_user_id) { user_1.id }
let(:end_user_id) { user_1.id }
let(:params) { OpenStruct.new(start_user_id: start_user_id, end_user_id: end_user_id) }
it "adds correct members" do
expect(action).to eq([user_1.id])
end
it "sets the reason to automatic" do
action
expect(channel.membership_for(user_1)).to be_automatic
end
context "with others users not in the batch" do
fab!(:user_2) { Fabricate(:user) }
it "adds correct members" do
expect(action).to eq([user_1.id])
end
end
context "with suspended users" do
before { user_1.update!(suspended_till: 1.year.from_now) }
it "skips suspended users" do
expect(action).to eq([])
end
end
context "with users not seen recently" do
before { user_1.update!(last_seen_at: 4.months.ago) }
it "skips users last_seen more than 3 months ago" do
expect(action).to eq([])
end
end
context "with never seen users" do
before { user_1.update!(last_seen_at: nil) }
it "includes users with last_seen set to null" do
expect(action).to eq([user_1.id])
end
end
context "with disabled chat users" do
before { user_1.user_option.update!(chat_enabled: false) }
it "skips users without chat_enabled" do
expect(action).to eq([])
end
end
context "with anonymous users" do
fab!(:user_1) { Fabricate(:anonymous, last_seen_at: 15.minutes.ago) }
it "skips anonymous users" do
expect(action).to eq([])
end
end
context "with inactive users" do
before { user_1.update!(active: false) }
it "skips inactive users" do
expect(action).to eq([])
end
end
context "with staged users" do
before { user_1.update!(staged: true) }
it "skips staged users" do
expect(action).to eq([])
end
end
context "when user is already a member" do
before { channel.add(user_1) }
it "is a noop" do
expect(action).to eq([])
end
end
context "when category is restricted" do
fab!(:user_1) { Fabricate(:user) }
fab!(:user_2) { Fabricate(:user) }
fab!(:group_1) { Fabricate(:group) }
fab!(:channel) { Fabricate(:private_category_channel, group: group_1, auto_join_users: true) }
let(:end_user_id) { user_2.id }
before { group_1.add(user_1) }
it "only joins users with access to the category through the group" do
expect(action).to eq([user_1.id])
end
context "when the user has access through multiple groups" do
fab!(:group_2) { Fabricate(:group) }
before do
channel.category.category_groups.create!(
group_id: group_2.id,
permission_type: CategoryGroup.permission_types[:full],
)
group_2.add(user_1)
end
it "correctly joins the user" do
expect(action).to eq([user_1.id])
end
end
context "when the category group is read only" do
fab!(:channel) { Fabricate(:private_category_channel, auto_join_users: true) }
before do
channel.category.category_groups.create!(
group_id: group_1.id,
permission_type: CategoryGroup.permission_types[:readonly],
)
group_1.add(user_1)
end
it "doesnt join the users of the group" do
expect(action).to eq([])
end
end
context "when the category group has create post permission" do
fab!(:channel) { Fabricate(:private_category_channel, auto_join_users: true) }
before do
channel.category.category_groups.create!(
group_id: group_1.id,
permission_type: CategoryGroup.permission_types[:create_post],
)
group_1.add(user_1)
end
it "correctly joins the user" do
expect(action).to eq([user_1.id])
end
end
context "when user has allowed groups and disallowed groups" do
fab!(:group_2) { Fabricate(:group) }
before do
channel.category.category_groups.create!(
group_id: group_2.id,
permission_type: CategoryGroup.permission_types[:readonly],
)
group_2.add(user_1)
end
it "correctly joins the user" do
expect(action).to eq([user_1.id])
end
end
end
end

View File

@ -41,12 +41,11 @@ RSpec.describe Chat::Action::PublishAndFollowDirectMessageChannel do
end
context "when at least one user allows communication" do
let(:users) { channel.user_chat_channel_memberships.map(&:user) }
before { channel.user_chat_channel_memberships.update_all(following: false) }
it "publishes the channel" do
Chat::Publisher.expects(:publish_new_channel).with(channel, includes(*users))
user_ids = channel.user_chat_channel_memberships.map(&:user_id)
Chat::Publisher.expects(:publish_new_channel).with(channel, includes(*user_ids))
action
end

View File

@ -1,162 +0,0 @@
# frozen_string_literal: true
RSpec.describe Chat::AutoRemove::HandleCategoryUpdated do
describe ".call" do
subject(:result) { described_class.call(params:) }
let(:params) { { category_id: updated_category.id } }
fab!(:updated_category) { Fabricate(:category) }
fab!(:user_1) { Fabricate(:user) }
fab!(:user_2) { Fabricate(:user) }
fab!(:admin_1) { Fabricate(:admin) }
fab!(:admin_2) { Fabricate(:admin) }
fab!(:channel_1) { Fabricate(:chat_channel, chatable: updated_category) }
fab!(:channel_2) { Fabricate(:chat_channel, chatable: updated_category) }
context "when chat is not enabled" do
before { SiteSetting.chat_enabled = false }
it { is_expected.to fail_a_policy(:chat_enabled) }
end
context "when chat is enabled" do
before { SiteSetting.chat_enabled = true }
context "if the category is deleted" do
before { updated_category.destroy! }
it "fails to find category model" do
expect(result).to fail_to_find_a_model(:category)
end
end
context "when there are no channels associated with the category" do
before do
channel_1.destroy!
channel_2.destroy!
end
it "fails to find category_channel_ids model" do
expect(result).to fail_to_find_a_model(:category_channel_ids)
end
end
context "when the category has no more category_group records" do
before do
[user_1, user_2, admin_1, admin_2].each do |user|
channel_1.add(user)
channel_2.add(user)
end
updated_category.category_groups.delete_all
end
it { is_expected.to run_successfully }
it "does not kick any users since the default permission is Everyone (full)" do
expect { result }.not_to change {
Chat::UserChatChannelMembership.where(
user: [user_1, user_2, admin_1, admin_2],
chat_channel: [channel_1, channel_2],
).count
}
end
end
context "when the category still has category_group records" do
before do
[user_1, user_2, admin_1, admin_2].each do |user|
channel_1.add(user)
channel_2.add(user)
end
group_1 = Fabricate(:group)
CategoryGroup.create(
group: group_1,
category: updated_category,
permission_type: CategoryGroup.permission_types[:full],
)
group_2 = Fabricate(:group)
CategoryGroup.create(
group: group_2,
category: updated_category,
permission_type: CategoryGroup.permission_types[:readonly],
)
group_1.add(user_1)
group_2.add(user_1)
end
it { is_expected.to run_successfully }
it "kicks all regular users who are not in any groups with reply + see permissions" do
expect { result }.to change {
Chat::UserChatChannelMembership.where(
user: [user_1, user_2],
chat_channel: [channel_1, channel_2],
).count
}.to 2
end
it "does not kick admin users who are not in any groups with reply + see permissions" do
expect { result }.not_to change {
Chat::UserChatChannelMembership.where(
user: [admin_1, admin_2],
chat_channel: [channel_1, channel_2],
).count
}
end
it "enqueues a job to kick each batch of users from the channel" do
freeze_time
result
expect(
job_enqueued?(
job: Jobs::Chat::KickUsersFromChannel,
at: 5.seconds.from_now,
args: {
user_ids: [user_2.id],
channel_id: channel_1.id,
},
),
).to eq(true)
expect(
job_enqueued?(
job: Jobs::Chat::KickUsersFromChannel,
at: 5.seconds.from_now,
args: {
user_ids: [user_2.id],
channel_id: channel_2.id,
},
),
).to eq(true)
end
it "logs a staff action" do
result
changes =
UserHistory
.where(custom_type: "chat_auto_remove_membership")
.all
.map { |uh| uh.slice(:details, :acting_user_id) }
expect(changes).to match_array(
[
{
details: "users_removed: 1\nchannel_id: #{channel_1.id}\nevent: category_updated",
acting_user_id: Discourse.system_user.id,
},
{
details: "users_removed: 1\nchannel_id: #{channel_2.id}\nevent: category_updated",
acting_user_id: Discourse.system_user.id,
},
],
)
end
end
end
end
end

View File

@ -1,173 +0,0 @@
# frozen_string_literal: true
RSpec.describe Chat::AutoRemove::HandleChatAllowedGroupsChange do
describe ".call" do
subject(:result) { described_class.call(params:) }
let(:params) { { new_allowed_groups: } }
fab!(:user_1) { Fabricate(:user, refresh_auto_groups: true) }
fab!(:user_2) { Fabricate(:user, refresh_auto_groups: true) }
fab!(:admin_1) { Fabricate(:admin) }
fab!(:admin_2) { Fabricate(:admin) }
fab!(:dm_channel_1) { Fabricate(:direct_message_channel, users: [admin_1, user_1]) }
fab!(:dm_channel_2) { Fabricate(:direct_message_channel, users: [user_1, user_2]) }
fab!(:public_channel_1) { Fabricate(:chat_channel) }
fab!(:public_channel_2) { Fabricate(:chat_channel) }
context "when chat is not enabled" do
let(:new_allowed_groups) { "1|2" }
before { SiteSetting.chat_enabled = false }
it { is_expected.to fail_a_policy(:chat_enabled) }
end
context "when chat is enabled" do
before { SiteSetting.chat_enabled = true }
context "when new_allowed_groups is empty" do
let(:new_allowed_groups) { "" }
before do
public_channel_1.add(user_1)
public_channel_1.add(user_2)
public_channel_2.add(user_1)
public_channel_2.add(user_2)
public_channel_1.add(admin_1)
public_channel_1.add(admin_2)
freeze_time
end
it "sets the service result as successful" do
expect(result).to be_a_success
end
it "removes users from all public channels" do
expect { result }.to change {
Chat::UserChatChannelMembership.where(
user: [user_1, user_2],
chat_channel: [public_channel_1, public_channel_2],
).count
}.to 0
end
it "does not remove admin users from public channels" do
expect { result }.not_to change {
Chat::UserChatChannelMembership.where(
user: [admin_1, admin_2],
chat_channel: [public_channel_1],
).count
}
end
it "does not remove users from direct message channels" do
expect { result }.not_to change {
Chat::UserChatChannelMembership.where(chat_channel: [dm_channel_1, dm_channel_2]).count
}
end
it "enqueues a job to kick each batch of users from the channel" do
result
expect(
job_enqueued?(
job: Jobs::Chat::KickUsersFromChannel,
at: 5.seconds.from_now,
args: {
user_ids: [user_1.id, user_2.id],
channel_id: public_channel_1.id,
},
),
).to eq(true)
expect(
job_enqueued?(
job: Jobs::Chat::KickUsersFromChannel,
at: 5.seconds.from_now,
args: {
user_ids: [user_1.id, user_2.id],
channel_id: public_channel_2.id,
},
),
).to eq(true)
end
it "logs a staff action" do
result
changes =
UserHistory
.where(custom_type: "chat_auto_remove_membership")
.all
.map { |uh| uh.slice(:details, :acting_user_id) }
expect(changes).to match_array(
[
{
details:
"users_removed: 2\nchannel_id: #{public_channel_1.id}\nevent: chat_allowed_groups_changed",
acting_user_id: Discourse.system_user.id,
},
{
details:
"users_removed: 2\nchannel_id: #{public_channel_2.id}\nevent: chat_allowed_groups_changed",
acting_user_id: Discourse.system_user.id,
},
],
)
end
end
context "when new_allowed_groups includes all the users in public channels" do
let(:new_allowed_groups) { Group::AUTO_GROUPS[:trust_level_1] }
before do
public_channel_1.add(user_1)
public_channel_2.add(user_1)
end
it "does nothing" do
expect { result }.not_to change { Chat::UserChatChannelMembership.count }
expect(result).to fail_to_find_a_model(:users)
end
end
context "when new_allowed_groups includes everyone" do
let(:new_allowed_groups) { Group::AUTO_GROUPS[:everyone] }
it { is_expected.to fail_a_policy(:not_everyone_allowed) }
it "does nothing" do
expect { result }.not_to change { Chat::UserChatChannelMembership.count }
end
end
context "when some users are not in any of the new allowed groups" do
let(:new_allowed_groups) { Group::AUTO_GROUPS[:trust_level_4] }
before do
public_channel_1.add(user_1)
public_channel_1.add(user_2)
public_channel_2.add(user_1)
public_channel_2.add(user_2)
user_1.change_trust_level!(TrustLevel[2])
user_2.change_trust_level!(TrustLevel[4])
end
it "removes them from public channels" do
expect { result }.to change {
Chat::UserChatChannelMembership.where(
chat_channel: [public_channel_1, public_channel_2],
).count
}.by(-2)
end
it "does not remove them from direct message channels" do
expect { result }.not_to change {
Chat::UserChatChannelMembership.where(chat_channel: [dm_channel_2]).count
}
end
end
end
end
end

View File

@ -1,253 +0,0 @@
# frozen_string_literal: true
RSpec.describe Chat::AutoRemove::HandleDestroyedGroup do
describe described_class::Contract, type: :model do
it { is_expected.to validate_presence_of(:destroyed_group_user_ids) }
end
describe ".call" do
subject(:result) { described_class.call(params:) }
let(:params) { { destroyed_group_user_ids: [admin_1.id, admin_2.id, user_1.id, user_2.id] } }
fab!(:user_1) { Fabricate(:user, refresh_auto_groups: true) }
fab!(:user_2) { Fabricate(:user, refresh_auto_groups: true) }
fab!(:admin_1) { Fabricate(:admin) }
fab!(:admin_2) { Fabricate(:admin) }
fab!(:dm_channel_1) { Fabricate(:direct_message_channel, users: [admin_1, user_1]) }
fab!(:dm_channel_2) { Fabricate(:direct_message_channel, users: [user_1, user_2]) }
fab!(:channel_1) { Fabricate(:chat_channel) }
fab!(:channel_2) { Fabricate(:chat_channel) }
context "when chat is not enabled" do
before { SiteSetting.chat_enabled = false }
it { is_expected.to fail_a_policy(:chat_enabled) }
end
context "when chat is enabled" do
before { SiteSetting.chat_enabled = true }
context "if none of the group_user_ids users exist" do
before { User.where(id: params[:destroyed_group_user_ids]).destroy_all }
it "fails to find scoped_users model" do
expect(result).to fail_to_find_a_model(:scoped_users)
end
end
describe "step remove_users_outside_allowed_groups" do
context "when chat_allowed_groups is empty" do
before do
SiteSetting.chat_allowed_groups = ""
channel_1.add(user_1)
channel_1.add(user_2)
channel_2.add(user_1)
channel_2.add(user_2)
channel_1.add(admin_1)
channel_1.add(admin_2)
end
it { is_expected.to run_successfully }
it "removes the destroyed_group_user_ids from all public channels" do
expect { result }.to change {
Chat::UserChatChannelMembership.where(
user: [user_1, user_2],
chat_channel: [channel_1, channel_2],
).count
}.to 0
end
it "does not remove admin users from public channels" do
expect { result }.not_to change {
Chat::UserChatChannelMembership.where(
user: [admin_1, admin_2],
chat_channel: [channel_1],
).count
}
end
it "does not remove regular or admin users from direct message channels" do
expect { result }.not_to change {
Chat::UserChatChannelMembership.where(
chat_channel: [dm_channel_1, dm_channel_2],
).count
}
end
it "enqueues a job to kick each batch of users from the channel" do
freeze_time
result
expect(
job_enqueued?(
job: Jobs::Chat::KickUsersFromChannel,
at: 5.seconds.from_now,
args: {
user_ids: [user_1.id, user_2.id],
channel_id: channel_1.id,
},
),
).to eq(true)
expect(
job_enqueued?(
job: Jobs::Chat::KickUsersFromChannel,
at: 5.seconds.from_now,
args: {
user_ids: [user_1.id, user_2.id],
channel_id: channel_2.id,
},
),
).to eq(true)
end
it "logs a staff action" do
result
actions = UserHistory.where(custom_type: "chat_auto_remove_membership")
expect(actions.count).to eq(2)
expect(
actions.exists?(
details: "users_removed: 2\nchannel_id: #{channel_2.id}\nevent: destroyed_group",
acting_user_id: Discourse.system_user.id,
custom_type: "chat_auto_remove_membership",
),
).to eq(true)
expect(
actions.exists?(
details: "users_removed: 2\nchannel_id: #{channel_1.id}\nevent: destroyed_group",
acting_user_id: Discourse.system_user.id,
custom_type: "chat_auto_remove_membership",
),
).to eq(true)
end
end
context "when chat_allowed_groups includes all the users in public channels" do
before do
SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:trust_level_1]
channel_1.add(user_1)
channel_1.add(user_2)
channel_2.add(user_1)
channel_2.add(user_2)
channel_1.add(admin_1)
channel_1.add(admin_2)
end
it { is_expected.to run_successfully }
it "does not remove any memberships" do
expect { result }.not_to change { Chat::UserChatChannelMembership.count }
end
end
context "when chat_allowed_groups includes everyone" do
before do
SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
channel_1.add(user_1)
channel_1.add(user_2)
channel_2.add(user_1)
channel_2.add(user_2)
channel_1.add(admin_1)
channel_1.add(admin_2)
end
it { is_expected.to fail_a_policy(:not_everyone_allowed) }
it "does not remove any memberships" do
expect { result }.not_to change { Chat::UserChatChannelMembership.count }
end
end
end
describe "step remove_users_without_channel_permission" do
before do
channel_1.add(user_1)
channel_1.add(user_2)
channel_2.add(user_1)
channel_2.add(user_2)
channel_1.add(admin_1)
channel_1.add(admin_2)
end
context "when channel category not read_restricted with no category_groups" do
before do
channel_1.chatable.update!(read_restricted: false)
channel_1.chatable.category_groups.destroy_all
end
it "does not remove any memberships" do
expect { result }.not_to change { Chat::UserChatChannelMembership.count }
end
end
context "when category channel not read_restricted with no full/create_post permission groups" do
before do
channel_1.chatable.update!(read_restricted: false)
CategoryGroup.create!(
category: channel_1.chatable,
group_id: Group::AUTO_GROUPS[:everyone],
permission_type: CategoryGroup.permission_types[:readonly],
)
CategoryGroup.create!(
category: channel_1.chatable,
group_id: Group::AUTO_GROUPS[:trust_level_1],
permission_type: CategoryGroup.permission_types[:readonly],
)
end
it { is_expected.to run_successfully }
it "removes the destroyed_group_user_ids from the channel" do
expect { result }.to change {
Chat::UserChatChannelMembership.where(
user: [user_1, user_2],
chat_channel: [channel_1],
).count
}.to 0
end
it "does not remove any admin destroyed_group_user_ids from the channel" do
expect { result }.not_to change {
Chat::UserChatChannelMembership.where(
user: [admin_1, admin_2],
chat_channel: [channel_1],
).count
}
end
end
context "when category channel not read_restricted with at least one full/create_post permission group" do
before do
channel_1.chatable.update!(read_restricted: false)
CategoryGroup.create!(
category: channel_1.chatable,
group_id: Group::AUTO_GROUPS[:everyone],
permission_type: CategoryGroup.permission_types[:readonly],
)
CategoryGroup.create!(
category: channel_1.chatable,
group_id: Group::AUTO_GROUPS[:trust_level_2],
permission_type: CategoryGroup.permission_types[:create_post],
)
end
context "when one of the users is not in any of the groups" do
before { user_2.change_trust_level!(TrustLevel[3]) }
it { is_expected.to run_successfully }
it "removes the destroyed_group_user_ids from the channel" do
expect { result }.to change {
Chat::UserChatChannelMembership.where(
user: [user_1, user_2],
chat_channel: [channel_1],
).count
}.to 1
end
end
end
end
end
end
end

View File

@ -1,246 +0,0 @@
# frozen_string_literal: true
RSpec.describe Chat::AutoRemove::HandleUserRemovedFromGroup do
describe ".call" do
subject(:result) { described_class.call(params:) }
let(:params) { { user_id: removed_user.id } }
fab!(:removed_user) { Fabricate(:user) }
fab!(:user_1) { Fabricate(:user) }
fab!(:user_2) { Fabricate(:user) }
fab!(:dm_channel_1) { Fabricate(:direct_message_channel, users: [removed_user, user_1]) }
fab!(:dm_channel_2) { Fabricate(:direct_message_channel, users: [removed_user, user_2]) }
fab!(:public_channel_1) { Fabricate(:chat_channel) }
fab!(:public_channel_2) { Fabricate(:chat_channel) }
context "when chat is not enabled" do
before { SiteSetting.chat_enabled = false }
it { is_expected.to fail_a_policy(:chat_enabled) }
end
context "when chat is enabled" do
before { SiteSetting.chat_enabled = true }
context "if user is deleted" do
before { removed_user.destroy! }
it "fails to find the user model" do
expect(result).to fail_to_find_a_model(:user)
end
end
context "when the user is no longer in any of the chat_allowed_groups" do
before do
SiteSetting.chat_allowed_groups = Fabricate(:group).id
public_channel_1.add(removed_user)
public_channel_2.add(removed_user)
end
it "sets the service result as successful" do
expect(result).to be_a_success
end
it "removes them from public channels" do
expect { result }.to change {
Chat::UserChatChannelMembership.where(
user: [removed_user],
chat_channel: [public_channel_1, public_channel_2],
).count
}.to 0
end
it "does not remove them from direct message channels" do
expect { result }.not_to change {
Chat::UserChatChannelMembership.where(
user: [removed_user],
chat_channel: [dm_channel_1, dm_channel_2],
).count
}
end
it "enqueues a job to kick each batch of users from the channel" do
freeze_time
result
expect(
job_enqueued?(
job: Jobs::Chat::KickUsersFromChannel,
at: 5.seconds.from_now,
args: {
user_ids: [removed_user.id],
channel_id: public_channel_1.id,
},
),
).to eq(true)
expect(
job_enqueued?(
job: Jobs::Chat::KickUsersFromChannel,
at: 5.seconds.from_now,
args: {
user_ids: [removed_user.id],
channel_id: public_channel_2.id,
},
),
).to eq(true)
end
it "logs staff actions" do
result
expect(
UserHistory
.where(
acting_user_id: Discourse.system_user.id,
custom_type: "chat_auto_remove_membership",
)
.last(2)
.map(&:details),
).to contain_exactly(
"users_removed: 1\nchannel_id: #{public_channel_1.id}\nevent: user_removed_from_group",
"users_removed: 1\nchannel_id: #{public_channel_2.id}\nevent: user_removed_from_group",
)
end
context "when the user is staff" do
fab!(:removed_user) { Fabricate(:admin) }
it { is_expected.to fail_a_policy(:user_not_staff) }
it "does not remove them from public channels" do
expect { result }.not_to change {
Chat::UserChatChannelMembership.where(
user: [removed_user],
chat_channel: [public_channel_1, public_channel_2],
).count
}
end
end
context "when the only chat_allowed_group is everyone" do
before { SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] }
it { is_expected.to fail_a_policy(:not_everyone_allowed) }
it "does not remove them from public channels" do
expect { result }.not_to change {
Chat::UserChatChannelMembership.where(
user: [removed_user],
chat_channel: [public_channel_1, public_channel_2],
).count
}
end
end
end
context "for private channels" do
fab!(:group_1) { Fabricate(:group) }
fab!(:group_2) { Fabricate(:group) }
fab!(:private_category) { Fabricate(:private_category, group: group_1) }
fab!(:private_channel_1) { Fabricate(:chat_channel, chatable: private_category) }
before do
group_1.add(removed_user)
group_2.add(removed_user)
SiteSetting.chat_allowed_groups = [group_1.id, group_2.id].join("|")
CategoryGroup.create(
category: private_category,
group: group_2,
permission_type: CategoryGroup.permission_types[:full],
)
private_channel_1.add(removed_user)
end
context "when the user remains in one of the groups that can access a private channel" do
before { group_1.remove(removed_user) }
it "sets the service result as successful" do
expect(result).to be_a_success
end
it "does not remove them from that channel" do
expect { result }.not_to change {
Chat::UserChatChannelMembership.where(
user: [removed_user],
chat_channel: [private_channel_1],
).count
}
end
end
context "when the user in remains in one of the groups but that group only has readonly access to the channel" do
before do
CategoryGroup.find_by(group: group_2, category: private_category).update!(
permission_type: CategoryGroup.permission_types[:readonly],
)
group_1.remove(removed_user)
end
it "sets the service result as successful" do
expect(result).to be_a_success
end
it "removes them from that channel" do
expect { result }.to change {
Chat::UserChatChannelMembership.where(
user: [removed_user],
chat_channel: [private_channel_1],
).count
}.to 0
end
context "when the user is staff" do
fab!(:removed_user) { Fabricate(:admin) }
it { is_expected.to fail_a_policy(:user_not_staff) }
it "does not remove them from that channel" do
expect { result }.not_to change {
Chat::UserChatChannelMembership.where(
user: [removed_user],
chat_channel: [private_channel_1],
).count
}
end
end
end
context "when the user is no longer in any group that can access a private channel" do
before do
group_1.remove(removed_user)
group_2.remove(removed_user)
end
it "sets the service result as successful" do
expect(result).to be_a_success
end
it "removes them from that channel" do
expect { result }.to change {
Chat::UserChatChannelMembership.where(
user: [removed_user],
chat_channel: [private_channel_1],
).count
}.to 0
end
context "when the user is staff" do
fab!(:removed_user) { Fabricate(:admin) }
it { is_expected.to fail_a_policy(:user_not_staff) }
it "does not remove them from that channel" do
expect { result }.not_to change {
Chat::UserChatChannelMembership.where(
user: [removed_user],
chat_channel: [private_channel_1],
).count
}
end
end
end
end
end
end
end

View File

@ -1,124 +0,0 @@
# frozen_string_literal: true
describe Chat::AutoJoinChannelBatch do
describe Chat::AutoJoinChannelBatch::Contract, type: :model do
subject(:contract) { described_class.new(start_user_id: 10) }
it { is_expected.to validate_presence_of(:channel_id) }
it { is_expected.to validate_presence_of(:start_user_id) }
it { is_expected.to validate_presence_of(:end_user_id) }
it do
is_expected.to validate_comparison_of(:end_user_id).is_greater_than_or_equal_to(
:start_user_id,
)
end
describe "Backward compatibility" do
subject(:contract) { described_class.new(args) }
before { contract.valid? }
context "when providing 'chat_channel_id'" do
let(:args) { { chat_channel_id: 2 } }
it "sets 'channel_id'" do
expect(contract.channel_id).to eq(2)
end
end
context "when providing 'starts_at'" do
let(:args) { { starts_at: 5 } }
it "sets 'start_user_id'" do
expect(contract.start_user_id).to eq(5)
end
end
context "when providing 'ends_at'" do
let(:args) { { ends_at: 8 } }
it "sets 'end_user_id'" do
expect(contract.end_user_id).to eq(8)
end
end
end
end
describe ".call" do
subject(:result) { described_class.call(params:) }
fab!(:channel) { Fabricate(:chat_channel, auto_join_users: true) }
let(:channel_id) { channel.id }
let(:user_ids) { [Fabricate(:user).id] }
let(:start_user_id) { user_ids.first }
let(:end_user_id) { user_ids.last }
let(:params) do
{ channel_id: channel_id, start_user_id: start_user_id, end_user_id: end_user_id }
end
context "when arguments are invalid" do
let(:channel_id) { nil }
it { is_expected.to fail_a_contract }
end
context "when arguments are valid" do
context "when channel does not exist" do
let(:channel_id) { -1 }
it { is_expected.to fail_to_find_a_model(:channel) }
end
context "when channel is not a category channel" do
fab!(:channel) { Fabricate(:direct_message_channel, auto_join_users: true) }
it { is_expected.to fail_to_find_a_model(:channel) }
end
context "when channel is not in auto_join_users mode" do
before { channel.update!(auto_join_users: false) }
it { is_expected.to fail_to_find_a_model(:channel) }
end
context "when channel is found" do
context "when more than one membership is created" do
let(:user_ids) { Fabricate.times(2, :user).map(&:id) }
it { is_expected.to run_successfully }
it "does not recalculate user count" do
::Chat::ChannelMembershipManager.any_instance.expects(:recalculate_user_count).never
result
end
it "publishes an event for each user" do
messages =
MessageBus.track_publish(::Chat::Publisher::NEW_CHANNEL_MESSAGE_BUS_CHANNEL) do
result
end
expect(messages.length).to eq(2)
end
end
context "when only one membership is created" do
it { is_expected.to run_successfully }
it "recalculates user count" do
::Chat::ChannelMembershipManager.any_instance.expects(:recalculate_user_count).once
result
end
it "publishes an event" do
messages =
MessageBus.track_publish(::Chat::Publisher::NEW_CHANNEL_MESSAGE_BUS_CHANNEL) do
result
end
expect(messages.length).to eq(1)
end
end
end
end
end
end

View File

@ -0,0 +1,348 @@
# frozen_string_literal: true
RSpec.describe Chat::AutoJoinChannels do
describe ".call" do
subject(:result) { described_class.call(params: {}) }
let!(:previous_events) { DiscourseEvent.events.dup }
before { DiscourseEvent.events.clear }
after { previous_events.each { |event, handlers| DiscourseEvent.events[event] = handlers } }
context "when chat is disabled" do
before { SiteSetting.chat_enabled = false }
it { is_expected.to fail_a_policy(:chat_enabled?) }
end
context "when chat is enabled" do
let(:trust_level) { 1 } # SiteSetting.chat_allowed_groups defaults to admins, moderators, and TL1 users
let(:last_seen_at) { 5.minutes.ago } # Users must have been seen "recently" to be auto-joined to a channel
fab!(:public_category) { Fabricate(:category) }
fab!(:private_category) { Fabricate(:category, read_restricted: true) }
fab!(:private_group_readonly) { Fabricate(:group) }
fab!(:private_group_create_post) { Fabricate(:group) }
fab!(:private_group_full) { Fabricate(:group) }
fab!(:private_category_group_readonly) do
Fabricate(
:category_group,
category: private_category,
group: private_group_readonly,
permission_type: CategoryGroup.permission_types[:readonly],
)
end
fab!(:private_category_group_create_post) do
Fabricate(
:category_group,
category: private_category,
group: private_group_create_post,
permission_type: CategoryGroup.permission_types[:create_post],
)
end
fab!(:private_category_group_full) do
Fabricate(
:category_group,
category: private_category,
group: private_group_full,
permission_type: CategoryGroup.permission_types[:full],
)
end
before { SiteSetting.chat_enabled = true }
context "with a non-auto joinable public channel" do
fab!(:non_auto_joinable_public_channel) do
Fabricate(:chat_channel, chatable: public_category)
end
let!(:user) { Fabricate(:user, trust_level:, last_seen_at:) }
it "doesn't automatically join users" do
expect { result }.not_to change { Chat::UserChatChannelMembership.count }.from(0)
end
end
context "with an auto joinable public channel" do
fab!(:auto_joinable_public_channel) do
Fabricate(:chat_channel, chatable: public_category, auto_join_users: true)
end
it "automatically joins users" do
2.times { Fabricate(:user, trust_level:, last_seen_at:) }
expect { result }.to change { Chat::UserChatChannelMembership.count }.from(0).to(2)
end
it "automatically join users when everyone is allowed" do
SiteSetting.chat_allowed_groups = [
Group::AUTO_GROUPS[:everyone],
Group::AUTO_GROUPS[:trust_level_3],
].join(",")
Fabricate(:user, trust_level:, last_seen_at:)
expect { result }.to change { Chat::UserChatChannelMembership.count }.from(0).to(1)
end
it "always automatically joins moderators" do
SiteSetting.chat_allowed_groups = Fabricate(:group).id
Fabricate(:user, trust_level:, last_seen_at:)
Fabricate(:moderator, trust_level:, last_seen_at:)
expect { result }.to change { Chat::UserChatChannelMembership.count }.from(0).to(1)
end
it "always automatically joins admins" do
SiteSetting.chat_allowed_groups = Fabricate(:group).id
Fabricate(:user, trust_level:, last_seen_at:)
Fabricate(:admin, trust_level:, last_seen_at:)
expect { result }.to change { Chat::UserChatChannelMembership.count }.from(0).to(1)
end
it "automatically follows the channel in automatic mode" do
user = Fabricate(:user, trust_level:, last_seen_at:)
expect { result }.to change {
Chat::UserChatChannelMembership
.where(user:, chat_channel: auto_joinable_public_channel)
.where(following: true, join_mode: :automatic)
.count
}.from(0).to(1)
end
it "recalculates user count" do
Fabricate(:user, trust_level:, last_seen_at:)
::Chat::ChannelMembershipManager.any_instance.expects(:recalculate_user_count).once
result
end
it "publishes new channel to auto-joined users" do
user = Fabricate(:user, trust_level:, last_seen_at:)
::Chat::Publisher
.expects(:publish_new_channel)
.once
.with(auto_joinable_public_channel, [user.id])
result
end
it "supports filtering down to a specific user" do
user = Fabricate(:user, trust_level:, last_seen_at:)
Fabricate(:user, trust_level:, last_seen_at:)
expect { described_class.call(params: { user_id: user.id }) }.to change {
Chat::UserChatChannelMembership.count
}.from(0).to(1)
end
it "supports filtering down to a specific channel" do
Fabricate(:chat_channel, chatable: public_category, auto_join_users: true)
Fabricate(:user, trust_level:, last_seen_at:)
expect {
described_class.call(params: { channel_id: auto_joinable_public_channel.id })
}.to change { Chat::UserChatChannelMembership.count }.from(0).to(1)
end
it "supports filtering down to a specific public category" do
Fabricate(:chat_channel, chatable: Fabricate(:category), auto_join_users: true)
Fabricate(:user, trust_level:, last_seen_at:)
expect { described_class.call(params: { category_id: public_category.id }) }.to change {
Chat::UserChatChannelMembership.count
}.from(0).to(1)
end
it "doesn't automatically join users who have chat disabled" do
user = Fabricate(:user, trust_level:, last_seen_at:)
user.user_option.update!(chat_enabled: false)
expect { result }.not_to change { Chat::UserChatChannelMembership.count }.from(0)
end
it "doesn't automatically join bots" do
Fabricate(:bot, trust_level:, last_seen_at:)
expect { result }.not_to change { Chat::UserChatChannelMembership.count }.from(0)
end
it "doesn't automatically join inactive users" do
Fabricate(:user, trust_level:, last_seen_at:, active: false)
expect { result }.not_to change { Chat::UserChatChannelMembership.count }.from(0)
end
it "doesn't automatically join staged users" do
Fabricate(:user, trust_level:, last_seen_at:, staged: true)
expect { result }.not_to change { Chat::UserChatChannelMembership.count }.from(0)
end
it "doesn't automatically join suspended users" do
Fabricate(:user, trust_level:, last_seen_at:, suspended_till: 1.day.from_now)
expect { result }.not_to change { Chat::UserChatChannelMembership.count }.from(0)
end
it "doesn't automatically join silenced users" do
Fabricate(:user, trust_level:, last_seen_at:, silenced_till: 1.day.from_now)
expect { result }.not_to change { Chat::UserChatChannelMembership.count }.from(0)
end
it "doesn't automatically join anonymous users" do
user = Fabricate(:user, trust_level:, last_seen_at:)
AnonymousUser.create!(user:, master_user: user, active: true)
expect { result }.not_to change { Chat::UserChatChannelMembership.count }.from(0)
end
it "doesn't automatically join users who haven't been seen recently" do
Fabricate(:user, trust_level:, last_seen_at: 31.days.ago)
expect { result }.not_to change { Chat::UserChatChannelMembership.count }.from(0)
end
it "doesn't automatically join users who aren't in the allowed groups" do
SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:trust_level_3]
(0..4).each { |tl| Fabricate(:user, trust_level: tl, last_seen_at:) }
# TL3 + TL4 = 2 users
expect { result }.to change { Chat::UserChatChannelMembership.count }.from(0).to(2)
end
it "limits the number of users who can be auto-joined to SiteSetting.max_chat_auto_joined_users" do
SiteSetting.max_chat_auto_joined_users = 1
_user_1 = Fabricate(:user, trust_level:, last_seen_at: 10.days.ago)
user_2 = Fabricate(:user, trust_level:, last_seen_at: 5.days.ago)
expect { result }.to change { Chat::UserChatChannelMembership.count }.from(0).to(1)
expect(Chat::UserChatChannelMembership.last.user).to eq(user_2)
end
it "doesn't automatically join users on deleted channels" do
auto_joinable_public_channel.update!(deleted_at: 1.day.ago)
Fabricate(:user, trust_level:, last_seen_at:)
expect { result }.not_to change { Chat::UserChatChannelMembership.count }.from(0)
end
it "doesn't automatically join users to channels that have reached the maximum user count" do
auto_joinable_public_channel.update!(user_count: SiteSetting.max_chat_auto_joined_users)
Fabricate(:user, trust_level:, last_seen_at:)
expect { result }.not_to change { Chat::UserChatChannelMembership.count }.from(0)
end
it "doesn't create duplicate memberships" do
user = Fabricate(:user, trust_level:, last_seen_at:)
Chat::UserChatChannelMembership.create!(user:, chat_channel: auto_joinable_public_channel)
expect { result }.not_to change { Chat::UserChatChannelMembership.count }.from(1)
end
it "doesn't recalculate user count if no users were auto-joined" do
::Chat::ChannelMembershipManager.any_instance.expects(:recalculate_user_count).never
result
end
it "doesn't publish new channel if no users were auto-joined" do
::Chat::Publisher.expects(:publish_new_channel).never
result
end
end
context "with a non-auto joinable private channel" do
fab!(:non_auto_joinable_private_channel) do
Fabricate(:chat_channel, chatable: private_category)
end
it "doesn't automatically join users" do
user = Fabricate(:user, trust_level:, last_seen_at:)
private_group_full.add(user)
expect { result }.not_to change { Chat::UserChatChannelMembership.count }.from(0)
end
end
context "with an auto joinable private channel" do
fab!(:auto_joinable_private_channel) do
Fabricate(:chat_channel, chatable: private_category, auto_join_users: true)
end
it "automatically join users who have 'full' access to the category" do
user = Fabricate(:user, trust_level:, last_seen_at:)
private_group_full.add(user)
expect { result }.to change { Chat::UserChatChannelMembership.count }.from(0).to(1)
end
it "automatically join users who have 'create post' access to the category" do
user = Fabricate(:user, trust_level:, last_seen_at:)
private_group_create_post.add(user)
expect { result }.to change { Chat::UserChatChannelMembership.count }.from(0).to(1)
end
it "doesn't automatically join users who have 'readonly' access to the category" do
user = Fabricate(:user, trust_level:, last_seen_at:)
private_group_readonly.add(user)
expect { result }.not_to change { Chat::UserChatChannelMembership.count }.from(0)
end
it "doesn't automatically join moderators to an admin-only private channel" do
private_category_group_full.update!(group_id: Group::AUTO_GROUPS[:admins])
Fabricate(:moderator, trust_level:, last_seen_at:)
admin = Fabricate(:admin, trust_level:, last_seen_at:)
expect { result }.to change { Chat::UserChatChannelMembership.count }.from(0).to(1)
expect(Chat::UserChatChannelMembership.last.user).to eq(admin)
end
it "supports filtering down to a specific private category" do
another_private_category = Fabricate(:category, read_restricted: true)
another_private_group = Fabricate(:group)
Fabricate(
:category_group,
category: another_private_category,
group: another_private_group,
permission_type: CategoryGroup.permission_types[:full],
)
Fabricate(:chat_channel, chatable: another_private_category, auto_join_users: true)
user = Fabricate(:user, trust_level:, last_seen_at:)
private_group_full.add(user)
another_private_group.add(user)
expect { described_class.call(params: { category_id: private_category.id }) }.to change {
Chat::UserChatChannelMembership.count
}.from(0).to(1)
end
end
end
end
end

View File

@ -0,0 +1,155 @@
# frozen_string_literal: true
RSpec.describe Chat::AutoLeaveChannels do
describe ".call" do
subject(:result) { described_class.call(params: {}) }
let!(:previous_events) { DiscourseEvent.events.dup }
before { DiscourseEvent.events.clear }
after { previous_events.each { |event, handlers| DiscourseEvent.events[event] = handlers } }
context "when chat is disabled" do
before { SiteSetting.chat_enabled = false }
it { is_expected.to fail_a_policy(:chat_enabled?) }
end
context "when chat is enabled" do
before { SiteSetting.chat_enabled = true }
context "when users are not allowed to chat" do
fab!(:uccm_1) { Fabricate(:user_chat_channel_membership) }
fab!(:uccm_2) { Fabricate(:user_chat_channel_membership_for_dm) }
it "removes all their memberships" do
expect { result }.to change { ::Chat::UserChatChannelMembership.count }.from(2).to(0)
end
it "publishes automatically removed users" do
::Chat::Action::PublishAutoRemovedUser
.expects(:call)
.once
.with(
event: :some_event_name,
users_removed_map: {
uccm_1.chat_channel_id => [uccm_1.user_id],
uccm_2.chat_channel_id => [uccm_2.user_id],
},
)
described_class.call(params: { event: :some_event_name })
end
end
context "when everyone is allowed to chat" do
fab!(:uccm) { Fabricate(:user_chat_channel_membership) }
before { SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone] }
it "does not remove memberships" do
expect { result }.not_to change { ::Chat::UserChatChannelMembership.count }.from(1)
end
end
context "when the category's permission changes" do
fab!(:user) { Fabricate(:user, trust_level: 1) }
fab!(:group) { Fabricate(:group) }
fab!(:category) { Fabricate(:private_category, group:) }
fab!(:chat_channel) { Fabricate(:chat_channel, chatable: category) }
fab!(:uccm) { Fabricate(:user_chat_channel_membership, user:, chat_channel:) }
before { group.add(user) }
context "when there's no permission anymore" do
before { CategoryGroup.where(category:).destroy_all }
it "removes user membership" do
expect { result }.to change { ::Chat::UserChatChannelMembership.count }.from(1).to(0)
end
it "publishes automatically removed users" do
::Chat::Action::PublishAutoRemovedUser
.expects(:call)
.once
.with(event: nil, users_removed_map: { uccm.chat_channel_id => [uccm.user_id] })
result
end
it "does not remove bot membership" do
bot = Fabricate(:bot, trust_level: 1)
Fabricate(:user_chat_channel_membership, user: bot, chat_channel:)
expect { result }.not_to change {
::Chat::UserChatChannelMembership.where(user: bot).count
}.from(1)
end
it "does not remove moderator membership" do
user.update!(moderator: true)
expect { result }.not_to change { ::Chat::UserChatChannelMembership.count }.from(1)
end
it "does not remove admin membership" do
user.update!(admin: true)
expect { result }.not_to change { ::Chat::UserChatChannelMembership.count }.from(1)
end
context "with another category/channel/user" do
fab!(:user_2) { Fabricate(:user, trust_level: 1) }
fab!(:category_2) { Fabricate(:private_category, group:) }
fab!(:chat_channel_2) { Fabricate(:chat_channel, chatable: category_2) }
fab!(:uccm_2) do
Fabricate(:user_chat_channel_membership, user: user_2, chat_channel: chat_channel_2)
end
it "supports filtering by user_id" do
expect { described_class.call(params: { user_id: user.id }) }.to change {
::Chat::UserChatChannelMembership.count
}.from(2).to(1)
end
it "supports filtering by channel_id" do
expect { described_class.call(params: { channel_id: chat_channel.id }) }.to change {
::Chat::UserChatChannelMembership.count
}.from(2).to(1)
end
it "supports filtering by category_id" do
expect { described_class.call(params: { category_id: category.id }) }.to change {
::Chat::UserChatChannelMembership.count
}.from(2).to(1)
end
end
end
it "removes membership when permission is 'readonly'" do
CategoryGroup.find_by(category:, group:).update!(
permission_type: CategoryGroup.permission_types[:readonly],
)
expect { result }.to change { ::Chat::UserChatChannelMembership.count }.from(1).to(0)
end
it "does not remove membership when permission is 'create_post'" do
CategoryGroup.find_by(category:, group:).update!(
permission_type: CategoryGroup.permission_types[:create_post],
)
expect { result }.not_to change { ::Chat::UserChatChannelMembership.count }.from(1)
end
it "does not remove membership when permission is 'full'" do
CategoryGroup.find_by(category:, group:).update!(
permission_type: CategoryGroup.permission_types[:full],
)
expect { result }.not_to change { ::Chat::UserChatChannelMembership.count }.from(1)
end
end
end
end
end

View File

@ -70,10 +70,7 @@ RSpec.describe Chat::CreateCategoryChannel do
end
it "does not enforce automatic memberships" do
Chat::ChannelMembershipManager
.any_instance
.expects(:enforce_automatic_channel_memberships)
.never
Chat::AutoJoinChannels.expects(:call).never
result
end
@ -88,10 +85,7 @@ RSpec.describe Chat::CreateCategoryChannel do
let(:params) { { guardian: guardian, category_id: category_id, auto_join_users: "" } }
it "defaults to false" do
Chat::ChannelMembershipManager
.any_instance
.expects(:enforce_automatic_channel_memberships)
.never
Chat::AutoJoinChannels.expects(:call).never
result
end
end
@ -100,10 +94,7 @@ RSpec.describe Chat::CreateCategoryChannel do
let(:params) { { guardian: guardian, category_id: category_id, auto_join_users: "true" } }
it "enforces automatic memberships" do
Chat::ChannelMembershipManager
.any_instance
.expects(:enforce_automatic_channel_memberships)
.once
Chat::AutoJoinChannels.expects(:call).once
result
end
end

View File

@ -98,12 +98,8 @@ RSpec.describe Chat::UpdateChannel do
end
it "auto joins users" do
expect_enqueued_with(
job: Jobs::Chat::AutoJoinChannelMemberships,
args: {
chat_channel_id: channel.id,
},
) { result }
::Chat::AutoJoinChannels.expects(:call).with(params: { channel_id: channel.id })
result
end
end
end