This is a combined work of Martin Brennan, Loïc Guitaut, and Joffrey Jaffeux. --- This commit implements a base service object when working in chat. The documentation is available at https://discourse.github.io/discourse/chat/backend/Chat/Service.html Generating documentation has been made as part of this commit with a bigger goal in mind of generally making it easier to dive into the chat project. Working with services generally involves 3 parts: - The service object itself, which is a series of steps where few of them are specialized (model, transaction, policy) ```ruby class UpdateAge include Chat::Service::Base model :user, :fetch_user policy :can_see_user contract step :update_age class Contract attribute :age, :integer end def fetch_user(user_id:, **) User.find_by(id: user_id) end def can_see_user(guardian:, **) guardian.can_see_user(user) end def update_age(age:, **) user.update!(age: age) end end ``` - The `with_service` controller helper, handling success and failure of the service within a service and making easy to return proper response to it from the controller ```ruby def update with_service(UpdateAge) do on_success { render_serialized(result.user, BasicUserSerializer, root: "user") } end end ``` - Rspec matchers and steps inspector, improving the dev experience while creating specs for a service ```ruby RSpec.describe(UpdateAge) do subject(:result) do described_class.call(guardian: guardian, user_id: user.id, age: age) end fab!(:user) { Fabricate(:user) } fab!(:current_user) { Fabricate(:admin) } let(:guardian) { Guardian.new(current_user) } let(:age) { 1 } it { expect(user.reload.age).to eq(age) } end ``` Note in case of unexpected failure in your spec, the output will give all the relevant information: ``` 1) UpdateAge when no channel_id is given is expected to fail to find a model named 'user' Failure/Error: it { is_expected.to fail_to_find_a_model(:user) } Expected model 'foo' (key: 'result.model.user') was not found in the result object. [1/4] [model] 'user' ❌ [2/4] [policy] 'can_see_user' [3/4] [contract] 'default' [4/4] [step] 'update_age' /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/update_age.rb:32:in `fetch_user': missing keyword: :user_id (ArgumentError) from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:202:in `instance_exec' from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:202:in `call' from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:219:in `call' from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:417:in `block in run!' from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:417:in `each' from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:417:in `run!' from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:411:in `run' from <internal:kernel>:90:in `tap' from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/app/services/base.rb:302:in `call' from /Users/joffreyjaffeux/Code/pr-discourse/plugins/chat/spec/services/update_age_spec.rb:15:in `block (3 levels) in <main>' ```
554 lines
16 KiB
Ruby
554 lines
16 KiB
Ruby
# frozen_string_literal: true
|
||
|
||
require "rails_helper"
|
||
|
||
RSpec.describe Chat::Api::ChatChannelsController do
|
||
before do
|
||
SiteSetting.chat_enabled = true
|
||
SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:everyone]
|
||
end
|
||
|
||
describe "#index" do
|
||
context "as anonymous user" do
|
||
it "returns an error" do
|
||
get "/chat/api/channels"
|
||
|
||
expect(response.status).to eq(403)
|
||
end
|
||
end
|
||
|
||
context "as disallowed user" do
|
||
fab!(:current_user) { Fabricate(:user) }
|
||
|
||
before do
|
||
SiteSetting.chat_allowed_groups = Group::AUTO_GROUPS[:staff]
|
||
sign_in(current_user)
|
||
end
|
||
|
||
it "returns an error" do
|
||
get "/chat/api/channels"
|
||
|
||
expect(response.status).to eq(403)
|
||
end
|
||
end
|
||
|
||
context "as allowed user" do
|
||
fab!(:current_user) { Fabricate(:user) }
|
||
|
||
before { sign_in(current_user) }
|
||
|
||
context "with category channels" do
|
||
context "when channel is public" do
|
||
fab!(:channel_1) { Fabricate(:category_channel) }
|
||
|
||
it "returns the channel" do
|
||
get "/chat/api/channels"
|
||
|
||
expect(response.status).to eq(200)
|
||
expect(response.parsed_body["channels"].map { |channel| channel["id"] }).to eq(
|
||
[channel_1.id],
|
||
)
|
||
end
|
||
|
||
context "when chatable is destroyed" do
|
||
before { channel_1.chatable.destroy! }
|
||
|
||
it "returns nothing" do
|
||
get "/chat/api/channels"
|
||
|
||
expect(response.status).to eq(200)
|
||
expect(response.parsed_body["channels"]).to be_blank
|
||
end
|
||
end
|
||
end
|
||
|
||
context "when channel has limited access" do
|
||
fab!(:group_1) { Fabricate(:group) }
|
||
fab!(:channel_1) { Fabricate(:private_category_channel, group: group_1) }
|
||
|
||
context "when user has access" do
|
||
before { group_1.add(current_user) }
|
||
|
||
it "returns the channel" do
|
||
get "/chat/api/channels"
|
||
|
||
expect(response.status).to eq(200)
|
||
expect(response.parsed_body["channels"].map { |channel| channel["id"] }).to eq(
|
||
[channel_1.id],
|
||
)
|
||
end
|
||
end
|
||
|
||
context "when user has no access" do
|
||
it "returns nothing" do
|
||
get "/chat/api/channels"
|
||
|
||
expect(response.status).to eq(200)
|
||
expect(response.parsed_body["channels"]).to be_blank
|
||
end
|
||
|
||
context "when user is admin" do
|
||
before { sign_in(Fabricate(:admin)) }
|
||
|
||
it "returns the channels" do
|
||
get "/chat/api/channels"
|
||
|
||
expect(response.status).to eq(200)
|
||
expect(response.parsed_body["channels"].map { |channel| channel["id"] }).to eq(
|
||
[channel_1.id],
|
||
)
|
||
end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
context "with direct message channels" do
|
||
fab!(:dm_channel_1) { Fabricate(:direct_message_channel, users: [current_user]) }
|
||
|
||
it "doesnt return direct message channels" do
|
||
get "/chat/api/channels"
|
||
expect(response.parsed_body["channels"]).to be_blank
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
describe "#show" do
|
||
context "when anonymous" do
|
||
it "returns an error" do
|
||
get "/chat/api/channels/-999"
|
||
|
||
expect(response.status).to eq(403)
|
||
end
|
||
end
|
||
|
||
context "when user cannot access channel" do
|
||
fab!(:channel_1) { Fabricate(:private_category_channel) }
|
||
|
||
before { sign_in(Fabricate(:user)) }
|
||
|
||
it "returns an error" do
|
||
get "/chat/api/channels/#{channel_1.id}"
|
||
|
||
expect(response.status).to eq(403)
|
||
end
|
||
end
|
||
|
||
context "when user can access channel" do
|
||
fab!(:current_user) { Fabricate(:user) }
|
||
|
||
before { sign_in(current_user) }
|
||
|
||
context "when channel doesn’t exist" do
|
||
it "returns an error" do
|
||
get "/chat/api/channels/-999"
|
||
|
||
expect(response.status).to eq(404)
|
||
end
|
||
end
|
||
|
||
context "when channel exists" do
|
||
fab!(:channel_1) { Fabricate(:category_channel) }
|
||
|
||
it "can find channel by id" do
|
||
get "/chat/api/channels/#{channel_1.id}"
|
||
|
||
expect(response.status).to eq(200)
|
||
expect(response.parsed_body["channel"]["id"]).to eq(channel_1.id)
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
describe "#destroy" do
|
||
fab!(:channel_1) { Fabricate(:category_channel) }
|
||
|
||
context "when user is not staff" do
|
||
fab!(:current_user) { Fabricate(:user) }
|
||
|
||
before { sign_in(current_user) }
|
||
|
||
it "returns an error" do
|
||
delete "/chat/api/channels/#{channel_1.id}"
|
||
|
||
expect(response.status).to eq(403)
|
||
end
|
||
end
|
||
|
||
context "when user is admin" do
|
||
fab!(:current_user) { Fabricate(:admin) }
|
||
|
||
before { sign_in(current_user) }
|
||
|
||
context "when the channel doesn’t exist" do
|
||
before { channel_1.destroy! }
|
||
|
||
it "returns an error" do
|
||
delete "/chat/api/channels/#{channel_1.id}"
|
||
|
||
expect(response.status).to eq(404)
|
||
end
|
||
end
|
||
|
||
context "with valid params" do
|
||
it "properly destroys the channel" do
|
||
delete "/chat/api/channels/#{channel_1.id}"
|
||
|
||
expect(response.status).to eq(200)
|
||
expect(channel_1.reload.trashed?).to eq(true)
|
||
expect(
|
||
job_enqueued?(job: :chat_channel_delete, args: { chat_channel_id: channel_1.id }),
|
||
).to eq(true)
|
||
expect(
|
||
UserHistory.exists?(
|
||
acting_user_id: current_user.id,
|
||
action: UserHistory.actions[:custom_staff],
|
||
custom_type: "chat_channel_delete",
|
||
),
|
||
).to eq(true)
|
||
end
|
||
|
||
it "generates a valid new slug to prevent collisions" do
|
||
SiteSetting.max_topic_title_length = 20
|
||
channel_1 = Fabricate(:chat_channel, name: "a" * SiteSetting.max_topic_title_length)
|
||
freeze_time(DateTime.parse("2022-07-08 09:30:00"))
|
||
old_slug = channel_1.slug
|
||
|
||
delete "/chat/api/channels/#{channel_1.id}"
|
||
|
||
expect(response.status).to eq(200)
|
||
expect(channel_1.reload.slug).to eq(
|
||
"20220708-0930-#{old_slug}-deleted".truncate(
|
||
SiteSetting.max_topic_title_length,
|
||
omission: "",
|
||
),
|
||
)
|
||
end
|
||
end
|
||
end
|
||
end
|
||
|
||
describe "#create" do
|
||
fab!(:admin) { Fabricate(:admin) }
|
||
fab!(:category) { Fabricate(:category) }
|
||
|
||
let(:params) do
|
||
{
|
||
channel: {
|
||
type: category.class.name,
|
||
chatable_id: category.id,
|
||
name: "channel name",
|
||
description: "My new channel",
|
||
},
|
||
}
|
||
end
|
||
|
||
before { sign_in(admin) }
|
||
|
||
it "creates a channel associated to a category" do
|
||
post "/chat/api/channels", params: params
|
||
|
||
new_channel = ChatChannel.last
|
||
|
||
expect(new_channel.name).to eq(params[:channel][:name])
|
||
expect(new_channel.slug).to eq("channel-name")
|
||
expect(new_channel.description).to eq(params[:channel][:description])
|
||
expect(new_channel.chatable_type).to eq(category.class.name)
|
||
expect(new_channel.chatable_id).to eq(category.id)
|
||
end
|
||
|
||
it "creates a channel using the user-provided slug" do
|
||
new_params = params.dup
|
||
new_params[:channel][:slug] = "wow-so-cool"
|
||
post "/chat/api/channels", params: new_params
|
||
|
||
new_channel = ChatChannel.last
|
||
|
||
expect(new_channel.slug).to eq("wow-so-cool")
|
||
end
|
||
|
||
it "creates a channel sets auto_join_users to false by default" do
|
||
post "/chat/api/channels", params: params
|
||
|
||
new_channel = ChatChannel.last
|
||
|
||
expect(new_channel.auto_join_users).to eq(false)
|
||
end
|
||
|
||
it "creates a channel with auto_join_users set to true" do
|
||
params[:channel][:auto_join_users] = true
|
||
post "/chat/api/channels", params: params
|
||
|
||
new_channel = ChatChannel.last
|
||
|
||
expect(new_channel.auto_join_users).to eq(true)
|
||
end
|
||
|
||
describe "triggers the auto-join process" do
|
||
fab!(:chatters_group) { Fabricate(:group) }
|
||
fab!(:user) { Fabricate(:user, last_seen_at: 15.minute.ago) }
|
||
|
||
before do
|
||
Jobs.run_immediately!
|
||
Fabricate(:category_group, category: category, group: chatters_group)
|
||
chatters_group.add(user)
|
||
end
|
||
|
||
it "joins the user when auto_join_users is true" do
|
||
params[:channel][:auto_join_users] = true
|
||
post "/chat/api/channels", params: params
|
||
|
||
created_channel_id = response.parsed_body.dig("channel", "id")
|
||
membership_exists =
|
||
UserChatChannelMembership.find_by(
|
||
user: user,
|
||
chat_channel_id: created_channel_id,
|
||
following: true,
|
||
)
|
||
|
||
expect(membership_exists).to be_present
|
||
end
|
||
|
||
it "doesn't join the user when auto_join_users is false" do
|
||
params[:channel][:auto_join_users] = false
|
||
post "/chat/api/channels", params: params
|
||
|
||
created_channel_id = response.parsed_body.dig("channel", "id")
|
||
membership_exists =
|
||
UserChatChannelMembership.find_by(
|
||
user: user,
|
||
chat_channel_id: created_channel_id,
|
||
following: true,
|
||
)
|
||
|
||
expect(membership_exists).to be_nil
|
||
end
|
||
end
|
||
end
|
||
|
||
describe "#update" do
|
||
include_examples "channel access example", :put
|
||
|
||
context "when user can’t edit channel" do
|
||
fab!(:channel) { Fabricate(:category_channel) }
|
||
|
||
before { sign_in(Fabricate(:user)) }
|
||
|
||
it "returns a 403" do
|
||
put "/chat/api/channels/#{channel.id}",
|
||
params: {
|
||
channel: {
|
||
name: "joffrey",
|
||
description: "cat owner",
|
||
},
|
||
}
|
||
|
||
expect(response.status).to eq(403)
|
||
end
|
||
end
|
||
|
||
context "when user provided invalid params" do
|
||
fab!(:channel) { Fabricate(:category_channel, user_count: 10) }
|
||
|
||
before { sign_in(Fabricate(:admin)) }
|
||
|
||
it "doesn’t change invalid properties" do
|
||
put "/chat/api/channels/#{channel.id}", params: { user_count: 40 }
|
||
|
||
expect(channel.reload.user_count).to eq(10)
|
||
end
|
||
end
|
||
|
||
context "when user provided an empty name" do
|
||
fab!(:user) { Fabricate(:admin) }
|
||
fab!(:channel) do
|
||
Fabricate(:category_channel, name: "something", description: "something else")
|
||
end
|
||
|
||
before { sign_in(user) }
|
||
|
||
it "nullifies the field and doesn’t store an empty string" do
|
||
put "/chat/api/channels/#{channel.id}", params: { channel: { name: " " } }
|
||
|
||
expect(channel.reload.name).to eq(nil)
|
||
end
|
||
|
||
it "doesn’t nullify the description" do
|
||
put "/chat/api/channels/#{channel.id}", params: { channel: { name: " " } }
|
||
|
||
expect(channel.reload.description).to eq("something else")
|
||
end
|
||
end
|
||
|
||
context "when user provides an empty description" do
|
||
fab!(:user) { Fabricate(:admin) }
|
||
fab!(:channel) do
|
||
Fabricate(:category_channel, name: "something else", description: "something")
|
||
end
|
||
|
||
before { sign_in(user) }
|
||
|
||
it "nullifies the field and doesn’t store an empty string" do
|
||
put "/chat/api/channels/#{channel.id}", params: { channel: { description: " " } }
|
||
|
||
expect(channel.reload.description).to eq(nil)
|
||
end
|
||
|
||
it "doesn’t nullify the name" do
|
||
put "/chat/api/channels/#{channel.id}", params: { channel: { description: " " } }
|
||
|
||
expect(channel.reload.name).to eq("something else")
|
||
end
|
||
end
|
||
|
||
context "when user provides an empty slug" do
|
||
fab!(:user) { Fabricate(:admin) }
|
||
fab!(:channel) do
|
||
Fabricate(:category_channel, name: "something else", description: "something")
|
||
end
|
||
|
||
before { sign_in(user) }
|
||
|
||
it "does not nullify the slug" do
|
||
put "/chat/api/channels/#{channel.id}", params: { channel: { slug: " " } }
|
||
|
||
expect(channel.reload.slug).to eq("something-else")
|
||
end
|
||
end
|
||
|
||
context "when channel is a direct message channel" do
|
||
fab!(:user) { Fabricate(:admin) }
|
||
fab!(:channel) { Fabricate(:direct_message_channel) }
|
||
|
||
before { sign_in(user) }
|
||
|
||
it "raises a 403" do
|
||
put "/chat/api/channels/#{channel.id}"
|
||
|
||
expect(response.status).to eq(403)
|
||
end
|
||
end
|
||
|
||
context "when user provides valid params" do
|
||
fab!(:user) { Fabricate(:admin) }
|
||
fab!(:channel) { Fabricate(:category_channel) }
|
||
|
||
before { sign_in(user) }
|
||
|
||
it "sets properties" do
|
||
put "/chat/api/channels/#{channel.id}",
|
||
params: {
|
||
channel: {
|
||
name: "joffrey",
|
||
slug: "cat-king",
|
||
description: "cat owner",
|
||
},
|
||
}
|
||
|
||
expect(channel.reload.name).to eq("joffrey")
|
||
expect(channel.reload.slug).to eq("cat-king")
|
||
expect(channel.reload.description).to eq("cat owner")
|
||
end
|
||
|
||
it "publishes an update" do
|
||
messages =
|
||
MessageBus.track_publish("/chat/channel-edits") do
|
||
put "/chat/api/channels/#{channel.id}",
|
||
params: {
|
||
channel: {
|
||
name: "A new cat overlord",
|
||
},
|
||
}
|
||
end
|
||
|
||
message = messages[0]
|
||
channel.reload
|
||
expect(message.data[:chat_channel_id]).to eq(channel.id)
|
||
expect(message.data[:name]).to eq(channel.name)
|
||
expect(message.data[:slug]).to eq(channel.slug)
|
||
expect(message.data[:description]).to eq(channel.description)
|
||
end
|
||
|
||
it "returns a valid chat channel" do
|
||
put "/chat/api/channels/#{channel.id}", params: { channel: { name: "A new cat is born" } }
|
||
|
||
expect(response.parsed_body["channel"]).to match_response_schema("category_chat_channel")
|
||
end
|
||
|
||
describe "when updating allow_channel_wide_mentions" do
|
||
it "sets the new value" do
|
||
put "/chat/api/channels/#{channel.id}",
|
||
params: {
|
||
channel: {
|
||
allow_channel_wide_mentions: false,
|
||
},
|
||
}
|
||
|
||
expect(response.parsed_body["channel"]["allow_channel_wide_mentions"]).to eq(false)
|
||
end
|
||
end
|
||
|
||
describe "Updating a channel to add users automatically" do
|
||
it "sets the channel to auto-update users automatically" do
|
||
put "/chat/api/channels/#{channel.id}", params: { channel: { auto_join_users: true } }
|
||
|
||
expect(response.parsed_body["channel"]["auto_join_users"]).to eq(true)
|
||
end
|
||
|
||
it "tells staff members to slow down when toggling auto-update multiple times" do
|
||
RateLimiter.enable
|
||
|
||
put "/chat/api/channels/#{channel.id}", params: { channel: { auto_join_users: true } }
|
||
put "/chat/api/channels/#{channel.id}", params: { channel: { auto_join_users: false } }
|
||
put "/chat/api/channels/#{channel.id}", params: { channel: { auto_join_users: true } }
|
||
|
||
expect(response.status).to eq(429)
|
||
end
|
||
|
||
describe "triggers the auto-join process" do
|
||
fab!(:chatters_group) { Fabricate(:group) }
|
||
fab!(:another_user) { Fabricate(:user, last_seen_at: 15.minute.ago) }
|
||
|
||
before do
|
||
Jobs.run_immediately!
|
||
Fabricate(:category_group, category: channel.chatable, group: chatters_group)
|
||
chatters_group.add(another_user)
|
||
end
|
||
|
||
it "joins the user when auto_join_users is true" do
|
||
put "/chat/api/channels/#{channel.id}", params: { channel: { auto_join_users: true } }
|
||
|
||
created_channel_id = response.parsed_body["channel"]["id"]
|
||
membership_exists =
|
||
UserChatChannelMembership.find_by(
|
||
user: another_user,
|
||
chat_channel_id: created_channel_id,
|
||
following: true,
|
||
)
|
||
|
||
expect(membership_exists).to be_present
|
||
end
|
||
|
||
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 } }
|
||
|
||
created_channel_id = response.parsed_body["channel"]["id"]
|
||
|
||
expect(created_channel_id).to be_present
|
||
|
||
membership_exists =
|
||
UserChatChannelMembership.find_by(
|
||
user: another_user,
|
||
chat_channel_id: created_channel_id,
|
||
following: true,
|
||
)
|
||
|
||
expect(membership_exists).to be_nil
|
||
end
|
||
end
|
||
end
|
||
end
|
||
end
|
||
end
|