DEV: Move channel creation for category into service (#21167)

This commit moves the category channel creation out
of the Chat::Api::Channel controller and into a
dedicated CreateCategoryChannel service. A follow up
commit will move the DM channel creation out of
the old DirectMessageChannelCreator service.

Also includes a new on_model_errors helper
for chat service class usage, that collects model
validation errors to present in a nice way.

---------

Co-authored-by: Loïc Guitaut <loic@discourse.org>
This commit is contained in:
Martin Brennan
2023-04-24 09:15:16 +10:00
committed by GitHub
parent f0bdb2ee9a
commit 21f93731a3
12 changed files with 345 additions and 54 deletions

View File

@ -38,46 +38,33 @@ class Chat::Api::ChannelsController < Chat::ApiController
channel_params = channel_params =
params.require(:channel).permit(:chatable_id, :name, :slug, :description, :auto_join_users) params.require(:channel).permit(:chatable_id, :name, :slug, :description, :auto_join_users)
guardian.ensure_can_create_chat_channel! # NOTE: We don't allow creating channels for anything but category chatable types
if channel_params[:name].length > SiteSetting.max_topic_title_length # at the moment. This may change in future, at which point we will need to pass in
raise Discourse::InvalidParameters.new(:name) # a chatable_type param as well and switch to the correct service here.
end with_service(
Chat::CreateCategoryChannel,
if Chat::Channel.exists?( **channel_params.merge(category_id: channel_params[:chatable_id]),
chatable_type: "Category", ) do
chatable_id: channel_params[:chatable_id], on_success do
name: channel_params[:name], render_serialized(
result.channel,
Chat::ChannelSerializer,
root: "channel",
membership: result.membership,
) )
end
on_model_not_found(:category) { raise ActiveRecord::RecordNotFound }
on_failed_policy(:can_create_channel) { raise Discourse::InvalidAccess }
on_failed_policy(:category_channel_does_not_exist) do
raise Discourse::InvalidParameters.new(I18n.t("chat.errors.channel_exists_for_category")) raise Discourse::InvalidParameters.new(I18n.t("chat.errors.channel_exists_for_category"))
end end
on_model_errors(:channel) do
chatable = Category.find_by(id: channel_params[:chatable_id]) render_json_error(result.channel, type: :record_invalid, status: 422)
raise Discourse::NotFound unless chatable end
on_model_errors(:membership) do
auto_join_users = render_json_error(result.membership, type: :record_invalid, status: 422)
ActiveRecord::Type::Boolean.new.deserialize(channel_params[:auto_join_users]) || false end
channel =
chatable.create_chat_channel!(
name: channel_params[:name],
slug: channel_params[:slug],
description: channel_params[:description],
user_count: 1,
auto_join_users: auto_join_users,
)
channel.user_chat_channel_memberships.create!(user: current_user, following: true)
if channel.auto_join_users
Chat::ChannelMembershipManager.new(channel).enforce_automatic_channel_memberships
end end
render_serialized(
channel,
Chat::ChannelSerializer,
membership: channel.membership_for(current_user),
root: "channel",
)
end end
def show def show

View File

@ -12,6 +12,10 @@ module Chat
channel_class.create!(args.merge(chatable: self)) channel_class.create!(args.merge(chatable: self))
end end
def create_chat_channel(**args)
channel_class.create(args.merge(chatable: self))
end
private private
def channel_class def channel_class

View File

@ -0,0 +1,85 @@
# frozen_string_literal: true
module Chat
# Service responsible for creating a new category chat channel.
#
# @example
# Service::Chat::CreateCategoryChannel.call(
# guardian: guardian,
# name: "SuperChannel",
# description: "This is the best channel",
# slug: "super-channel",
# category_id: category.id,
# )
#
class CreateCategoryChannel
include Service::Base
# @!method call(guardian:, **params_to_create)
# @param [Guardian] guardian
# @param [Hash] params_to_create
# @option params_to_create [String] name
# @option params_to_create [String] description
# @option params_to_create [String] slug
# @option params_to_create [Boolean] auto_join_users
# @option params_to_create [Integer] category_id
# @return [Service::Base::Context]
policy :can_create_channel
contract
model :category, :fetch_category
policy :category_channel_does_not_exist
transaction do
model :channel, :create_channel
model :membership, :create_membership
end
step :enforce_automatic_channel_memberships
# @!visibility private
class Contract
attribute :name, :string
attribute :description, :string
attribute :slug, :string
attribute :category_id, :integer
attribute :auto_join_users, :boolean, default: false
before_validation { self.auto_join_users = auto_join_users.presence || false }
validates :category_id, presence: true
validates :name, length: { maximum: SiteSetting.max_topic_title_length }
end
private
def can_create_channel(guardian:, **)
guardian.can_create_chat_channel?
end
def fetch_category(contract:, **)
Category.find_by(id: contract.category_id)
end
def category_channel_does_not_exist(category:, contract:, **)
!Chat::Channel.exists?(chatable: category, name: contract.name)
end
def create_channel(category:, contract:, **)
category.create_chat_channel(
name: contract.name,
slug: contract.slug,
description: contract.description,
user_count: 1,
auto_join_users: contract.auto_join_users,
)
end
def create_membership(channel:, guardian:, **)
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
end
end
end

View File

@ -127,6 +127,10 @@ module Service
def call(instance, context) def call(instance, context)
context[name] = super context[name] = super
raise ArgumentError, "Model not found" if context[name].blank? raise ArgumentError, "Model not found" if context[name].blank?
if context[name].try(:invalid?)
context[result_key].fail(invalid: true)
context.fail!
end
rescue ArgumentError => exception rescue ArgumentError => exception
context[result_key].fail(exception: exception) context[result_key].fail(exception: exception)
context.fail! context.fail!

View File

@ -34,7 +34,7 @@ export default class CreateChannelController extends Controller.extend(
autoGeneratedSlug = ""; autoGeneratedSlug = "";
description = ""; description = "";
categoryPermissionsHint = null; categoryPermissionsHint = null;
autoJoinUsers = null; autoJoinUsers = false;
autoJoinWarning = ""; autoJoinWarning = "";
loadingPermissionHint = false; loadingPermissionHint = false;

View File

@ -66,6 +66,7 @@ module Chat
# @!visibility private # @!visibility private
class Model < Step class Model < Step
def error def error
return result[name].errors.inspect if step_result.invalid
step_result.exception.full_message step_result.exception.full_message
end end
end end

View File

@ -55,9 +55,15 @@ class ServiceRunner
AVAILABLE_ACTIONS = { AVAILABLE_ACTIONS = {
on_success: -> { result.success? }, on_success: -> { result.success? },
on_failure: -> { result.failure? }, on_failure: -> { result.failure? },
on_failed_step: ->(name) { failure_for?("result.step.#{name}") },
on_failed_policy: ->(name = "default") { failure_for?("result.policy.#{name}") }, on_failed_policy: ->(name = "default") { failure_for?("result.policy.#{name}") },
on_failed_contract: ->(name = "default") { failure_for?("result.contract.#{name}") }, on_failed_contract: ->(name = "default") { failure_for?("result.contract.#{name}") },
on_model_not_found: ->(name = "model") { failure_for?("result.model.#{name}") }, on_model_not_found: ->(name = "model") do
failure_for?("result.model.#{name}") && result[name].blank?
end,
on_model_errors: ->(name = "model") do
failure_for?("result.model.#{name}") && result["result.model.#{name}"].invalid
end,
}.with_indifferent_access.freeze }.with_indifferent_access.freeze
# @!visibility private # @!visibility private

View File

@ -184,6 +184,7 @@ RSpec.describe Chat::StepsInspector do
end end
context "when the model step is failing" do context "when the model step is failing" do
context "when the model is missing" do
before do before do
class DummyService class DummyService
def fetch_model def fetch_model
@ -197,6 +198,21 @@ RSpec.describe Chat::StepsInspector do
end end
end end
context "when the model has errors" do
before do
class DummyService
def fetch_model
OpenStruct.new(invalid?: true, errors: ActiveModel::Errors.new(nil))
end
end
end
it "returns an error related to the model" do
expect(error).to match(/ActiveModel::Errors \[\]/)
end
end
end
context "when the contract step is failing" do context "when the contract step is failing" do
let(:parameter) { nil } let(:parameter) { nil }

View File

@ -64,6 +64,18 @@ RSpec.describe ServiceRunner do
end end
end end
class FailureWithModelErrorsService
include Service::Base
model :fake_model, :fetch_fake_model
private
def fetch_fake_model
OpenStruct.new(invalid?: true)
end
end
class SuccessWithModelService class SuccessWithModelService
include Service::Base include Service::Base
@ -76,6 +88,18 @@ RSpec.describe ServiceRunner do
end end
end end
class SuccessWithModelErrorsService
include Service::Base
model :fake_model, :fetch_fake_model
private
def fetch_fake_model
OpenStruct.new
end
end
class FailureWithCollectionModelService class FailureWithCollectionModelService
include Service::Base include Service::Base
@ -268,6 +292,30 @@ RSpec.describe ServiceRunner do
end end
end end
context "when using the on_model_errors action" do
let(:actions) { <<-BLOCK }
->(*) do
on_model_errors(:fake_model) { :model_errors }
end
BLOCK
context "when the service fails with a model containing errors" do
let(:service) { FailureWithModelErrorsService }
it "runs the provided block" do
expect(runner).to eq :model_errors
end
end
context "when the service does not fail with a model containing errors" do
let(:service) { SuccessWithModelErrorsService }
it "does not run the provided block" do
expect(runner).not_to eq :model_errors
end
end
end
context "when using several actions together" do context "when using several actions together" do
let(:service) { FailureService } let(:service) { FailureService }
let(:actions) { <<-BLOCK } let(:actions) { <<-BLOCK }

View File

@ -155,7 +155,7 @@ RSpec.describe Chat::Api::ChannelsController do
get "/chat/api/channels/#{channel_1.id}" get "/chat/api/channels/#{channel_1.id}"
expect(response.status).to eq(200) expect(response.status).to eq(200)
expect(response.parsed_body["channel"]["id"]).to eq(channel_1.id) expect(response.parsed_body.dig("channel", "id")).to eq(channel_1.id)
end end
end end
end end
@ -248,8 +248,9 @@ RSpec.describe Chat::Api::ChannelsController do
it "creates a channel associated to a category" do it "creates a channel associated to a category" do
post "/chat/api/channels", params: params post "/chat/api/channels", params: params
expect(response.status).to eq(200)
new_channel = Chat::Channel.last new_channel = Chat::Channel.find(response.parsed_body.dig("channel", "id"))
expect(new_channel.name).to eq(params[:channel][:name]) expect(new_channel.name).to eq(params[:channel][:name])
expect(new_channel.slug).to eq("channel-name") expect(new_channel.slug).to eq("channel-name")
@ -262,16 +263,31 @@ RSpec.describe Chat::Api::ChannelsController do
new_params = params.dup new_params = params.dup
new_params[:channel][:slug] = "wow-so-cool" new_params[:channel][:slug] = "wow-so-cool"
post "/chat/api/channels", params: new_params post "/chat/api/channels", params: new_params
expect(response.status).to eq(200)
new_channel = Chat::Channel.last new_channel = Chat::Channel.find(response.parsed_body.dig("channel", "id"))
expect(new_channel.slug).to eq("wow-so-cool") expect(new_channel.slug).to eq("wow-so-cool")
end end
context "when the user-provided slug already exists for a channel" do
before do
params[:channel][:slug] = "wow-so-cool"
post "/chat/api/channels", params: params
params[:channel][:name] = "new name"
end
it "returns an error" do
post "/chat/api/channels", params: params
expect(response).to have_http_status :unprocessable_entity
end
end
it "creates a channel sets auto_join_users to false by default" do it "creates a channel sets auto_join_users to false by default" do
post "/chat/api/channels", params: params post "/chat/api/channels", params: params
expect(response.status).to eq(200)
new_channel = Chat::Channel.last new_channel = Chat::Channel.find(response.parsed_body.dig("channel", "id"))
expect(new_channel.auto_join_users).to eq(false) expect(new_channel.auto_join_users).to eq(false)
end end
@ -279,8 +295,9 @@ RSpec.describe Chat::Api::ChannelsController do
it "creates a channel with auto_join_users set to true" do it "creates a channel with auto_join_users set to true" do
params[:channel][:auto_join_users] = true params[:channel][:auto_join_users] = true
post "/chat/api/channels", params: params post "/chat/api/channels", params: params
expect(response.status).to eq(200)
new_channel = Chat::Channel.last new_channel = Chat::Channel.find(response.parsed_body.dig("channel", "id"))
expect(new_channel.auto_join_users).to eq(true) expect(new_channel.auto_join_users).to eq(true)
end end
@ -298,6 +315,7 @@ RSpec.describe Chat::Api::ChannelsController do
it "joins the user when auto_join_users is true" do it "joins the user when auto_join_users is true" do
params[:channel][:auto_join_users] = true params[:channel][:auto_join_users] = true
post "/chat/api/channels", params: params post "/chat/api/channels", params: params
expect(response.status).to eq(200)
created_channel_id = response.parsed_body.dig("channel", "id") created_channel_id = response.parsed_body.dig("channel", "id")
membership_exists = membership_exists =
@ -313,6 +331,7 @@ RSpec.describe Chat::Api::ChannelsController do
it "doesn't join the user when auto_join_users is false" do it "doesn't join the user when auto_join_users is false" do
params[:channel][:auto_join_users] = false params[:channel][:auto_join_users] = false
post "/chat/api/channels", params: params post "/chat/api/channels", params: params
expect(response.status).to eq(200)
created_channel_id = response.parsed_body.dig("channel", "id") created_channel_id = response.parsed_body.dig("channel", "id")
membership_exists = membership_exists =
@ -519,7 +538,7 @@ RSpec.describe Chat::Api::ChannelsController do
it "joins the user when auto_join_users is true" do it "joins the user when auto_join_users is true" do
put "/chat/api/channels/#{channel.id}", params: { channel: { auto_join_users: true } } put "/chat/api/channels/#{channel.id}", params: { channel: { auto_join_users: true } }
created_channel_id = response.parsed_body["channel"]["id"] created_channel_id = response.parsed_body.dig("channel", "id")
membership_exists = membership_exists =
Chat::UserChatChannelMembership.find_by( Chat::UserChatChannelMembership.find_by(
user: another_user, user: another_user,
@ -533,7 +552,7 @@ RSpec.describe Chat::Api::ChannelsController do
it "doesn't join the user when auto_join_users is false" do it "doesn't join the user when auto_join_users is false" do
put "/chat/api/channels/#{channel.id}", params: { channel: { auto_join_users: false } } put "/chat/api/channels/#{channel.id}", params: { channel: { auto_join_users: false } }
created_channel_id = response.parsed_body["channel"]["id"] created_channel_id = response.parsed_body.dig("channel", "id")
expect(created_channel_id).to be_present expect(created_channel_id).to be_present

View File

@ -0,0 +1,99 @@
# frozen_string_literal: true
RSpec.describe Chat::CreateCategoryChannel do
describe Chat::CreateCategoryChannel::Contract, type: :model do
it { is_expected.to validate_presence_of :category_id }
it { is_expected.to validate_length_of(:name).is_at_most(SiteSetting.max_topic_title_length) }
end
describe ".call" do
subject(:result) { described_class.call(params) }
fab!(:current_user) { Fabricate(:admin) }
fab!(:category) { Fabricate(:category) }
let(:category_id) { category.id }
let(:guardian) { Guardian.new(current_user) }
let(:params) { { guardian: guardian, category_id: category_id, name: "cool channel" } }
context "when the current user cannot make a channel" do
fab!(:current_user) { Fabricate(:user) }
it { is_expected.to fail_a_policy(:can_create_channel) }
end
context "when the current user can make a channel" do
context "when there is already a channel for the category with the same name" do
fab!(:old_channel) { Fabricate(:chat_channel, chatable: category, name: "old channel") }
let(:params) { { guardian: guardian, category_id: category_id, name: "old channel" } }
it { is_expected.to fail_a_policy(:category_channel_does_not_exist) }
end
context "when the category does not exist" do
before { category.destroy! }
it { is_expected.to fail_to_find_a_model(:category) }
end
context "when all steps pass" do
it "creates the channel" do
expect { result }.to change { Chat::Channel.count }.by(1)
expect(result.channel).to have_attributes(
chatable: category,
name: "cool channel",
slug: "cool-channel",
)
end
it "creates a membership for the user" do
expect { result }.to change { Chat::UserChatChannelMembership.count }.by(1)
expect(result.membership).to have_attributes(
user: current_user,
chat_channel: result.channel,
following: true,
)
end
it "does not enforce automatic memberships" do
Chat::ChannelMembershipManager
.any_instance
.expects(:enforce_automatic_channel_memberships)
.never
result
end
context "when the slug is already in use" do
fab!(:channel) { Fabricate(:chat_channel, chatable: category, slug: "in-use") }
let(:params) { { guardian: guardian, category_id: category_id, slug: "in-use" } }
it { is_expected.to fail_with_an_invalid_model(:channel) }
end
context "if auto_join_users is blank" 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
result
end
end
context "if auto_join_users is true" 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
result
end
end
end
end
end
end

View File

@ -84,6 +84,24 @@ module Chat
def description def description
"fail to find a model named '#{name}'" "fail to find a model named '#{name}'"
end end
def step_failed?
super && result[name].blank?
end
end
class FailWithInvalidModel < FailStep
def type
"model"
end
def description
"fail to have a valid model named '#{name}'"
end
def step_failed?
super && result[step].invalid
end
end end
def fail_a_policy(name) def fail_a_policy(name)
@ -98,6 +116,10 @@ module Chat
FailToFindModel.new(name) FailToFindModel.new(name)
end end
def fail_with_an_invalid_model(name = "model")
FailWithInvalidModel.new(name)
end
def inspect_steps(result) def inspect_steps(result)
inspector = Chat::StepsInspector.new(result) inspector = Chat::StepsInspector.new(result)
puts "Steps:" puts "Steps:"