DEV: Chat service object initial implementation (#19814)

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>'
```
This commit is contained in:
Martin Brennan
2023-02-13 13:09:57 +01:00
committed by GitHub
parent 81a4d75f06
commit 60ad836313
85 changed files with 14567 additions and 932 deletions
+3 -48
View File
@@ -1,54 +1,9 @@
:warning: This plugin is still in active development and may change frequently
This plugin is still in active development and may change frequently
## Documentation
The Discourse Chat plugin adds chat functionality to your Discourse so it can natively support both long-form and short-form communication needs of your online community.
For documentation, see [Discourse Chat](https://meta.discourse.org/t/discourse-chat/230881)
For user documentation, see [Discourse Chat](https://meta.discourse.org/t/discourse-chat/230881).
## Plugin API
### registerChatComposerButton
#### Usage
```javascript
api.registerChatComposerButton({ id: "foo", ... });
```
#### Options
Every option accepts a `value` or a `function`, when passing a function `this` will be the `chat-composer` component instance. Example of an option using a function:
```javascript
api.registerChatComposerButton({
id: "foo",
displayed() {
return this.site.mobileView && this.canAttachUploads;
},
});
```
##### Required
- `id` unique, used to identify your button, eg: "gifs"
- `action` callback when the button is pressed, can be an action name or an anonymous function, eg: "onFooClicked" or `() => { console.log("clicked") }`
A button requires at least an icon or a label:
- `icon`, eg: "times"
- `label`, text displayed on the button, a translatable key, eg: "foo.bar"
- `translatedLabel`, text displayed on the button, a string, eg: "Add gifs"
##### Optional
- `position`, can be "inline" or "dropdown", defaults to "inline"
- `title`, title attribute of the button, a translatable key, eg: "foo.bar"
- `translatedTitle`, title attribute of the button, a string, eg: "Add gifs"
- `ariaLabel`, aria-label attribute of the button, a translatable key, eg: "foo.bar"
- `translatedAriaLabel`, aria-label attribute of the button, a string, eg: "Add gifs"
- `classNames`, additional names to add to the buttons class attribute, eg: ["foo", "bar"]
- `displayed`, hide/or show the button, expects a boolean
- `disabled`, sets the disabled attribute on the button, expects a boolean
- `priority`, an integer defining the order of the buttons, higher comes first, eg: `700`
- `dependentKeys`, list of property names which should trigger a refresh of the buttons when changed, eg: `["foo.bar", "bar.baz"]`
For developer documentation, see [Discourse Documentation](https://discourse.github.io/discourse/).
@@ -29,37 +29,9 @@ class Chat::Api::ChatChannelsController < Chat::Api
end
def destroy
confirmation = params.require(:channel).require(:name_confirmation)&.downcase
guardian.ensure_can_delete_chat_channel!
if channel_from_params.title(current_user).downcase != confirmation
raise Discourse::InvalidParameters.new(:name_confirmation)
with_service Chat::Service::TrashChannel do
on_model_not_found(:channel) { raise ActiveRecord::RecordNotFound }
end
begin
ChatChannel.transaction do
channel_from_params.update!(
slug:
"#{Time.now.strftime("%Y%m%d-%H%M")}-#{channel_from_params.slug}-deleted".truncate(
SiteSetting.max_topic_title_length,
omission: "",
),
)
channel_from_params.trash!(current_user)
StaffActionLogger.new(current_user).log_custom(
"chat_channel_delete",
{
chat_channel_id: channel_from_params.id,
chat_channel_name: channel_from_params.title(current_user),
},
)
end
rescue ActiveRecord::Rollback
return render_json_error(I18n.t("chat.errors.delete_channel_failed"))
end
Jobs.enqueue(:chat_channel_delete, { chat_channel_id: channel_from_params.id })
render json: success_json
end
def create
@@ -118,37 +90,25 @@ class Chat::Api::ChatChannelsController < Chat::Api
end
def update
guardian.ensure_can_edit_chat_channel!
if channel_from_params.direct_message_channel?
raise Discourse::InvalidParameters.new(
I18n.t("chat.errors.cant_update_direct_message_channel"),
)
end
params_to_edit = editable_params(params, channel_from_params)
params_to_edit.each { |k, v| params_to_edit[k] = nil if params_to_edit[k].blank? }
if ActiveRecord::Type::Boolean.new.deserialize(params_to_edit[:auto_join_users])
auto_join_limiter(channel_from_params).performed!
end
channel_from_params.update!(params_to_edit)
ChatPublisher.publish_chat_channel_edit(channel_from_params, current_user)
if channel_from_params.category_channel? && channel_from_params.auto_join_users
Chat::ChatChannelMembershipManager.new(
channel_from_params,
).enforce_automatic_channel_memberships
with_service(Chat::Service::UpdateChannel, **params_to_edit) do
on_success do
render_serialized(
result.channel,
ChatChannelSerializer,
root: "channel",
membership: result.channel.membership_for(current_user),
)
end
on_model_not_found(:channel) { raise ActiveRecord::RecordNotFound }
on_failed_policy(:check_channel_permission) { raise Discourse::InvalidAccess }
on_failed_policy(:no_direct_message_channel) { raise Discourse::InvalidAccess }
end
render_serialized(
channel_from_params,
ChatChannelSerializer,
root: "channel",
membership: channel_from_params.membership_for(current_user),
)
end
private
@@ -2,17 +2,10 @@
class Chat::Api::ChatChannelsStatusController < Chat::Api::ChatChannelsController
def update
status = params.require(:status)
# we only want to use this endpoint for open/closed status changes,
# the others are more "special" and are handled by the archive endpoint
if !ChatChannel.statuses.keys.include?(status) || status == "read_only" || status == "archive"
raise Discourse::InvalidParameters
with_service(Chat::Service::UpdateChannelStatus) do
on_success { render_serialized(result.channel, ChatChannelSerializer, root: "channel") }
on_model_not_found(:channel) { raise ActiveRecord::RecordNotFound }
on_failed_policy(:check_channel_permission) { raise Discourse::InvalidAccess }
end
guardian.ensure_can_change_channel_status!(channel_from_params, status.to_sym)
channel_from_params.public_send("#{status}!", current_user)
render_serialized(channel_from_params, ChatChannelSerializer, root: "channel")
end
end
@@ -4,6 +4,8 @@ class Chat::Api < Chat::ChatBaseController
before_action :ensure_logged_in
before_action :ensure_can_chat
include Chat::WithServiceHelper
private
def ensure_can_chat
@@ -0,0 +1,37 @@
# frozen_string_literal: true
module Chat
module WithServiceHelper
def result
@_result
end
def with_service(service, default_actions: true, **dependencies, &block)
controller = self
merged_block =
proc do
instance_eval(&controller.default_actions_for_service) if default_actions
instance_eval(&(block || proc {}))
end
Chat::Endpoint.call(service, controller, **dependencies, &merged_block)
end
def run_service(service, dependencies)
@_result = service.call(params.to_unsafe_h.merge(guardian: guardian, **dependencies.to_h))
end
def default_actions_for_service
proc do
on_success { render(json: success_json) }
on_failure { render(json: failed_json, status: 422) }
on_failed_policy(:invalid_access) { raise Discourse::InvalidAccess }
on_failed_contract do
render(
json:
failed_json.merge(errors: result[:"result.contract.default"].errors.full_messages),
status: 400,
)
end
end
end
end
end
+4
View File
@@ -36,6 +36,10 @@ class ChatChannel < ActiveRecord::Base
delegate :empty?, to: :chat_messages, prefix: true
class << self
def editable_statuses
statuses.filter { |k, _| !%w[read_only archived].include?(k) }
end
def public_channel_chatable_types
["Category"]
end
+427
View File
@@ -0,0 +1,427 @@
# frozen_string_literal: true
module Chat
module Service
# Module to be included to provide steps DSL to any class. This allows to
# create easy to understand services as the whole service cycle is visible
# simply by reading the beginning of its class.
#
# Steps are executed in the order theyre defined. They will use their name
# to execute the corresponding method defined in the service class.
#
# Currently, there are 5 types of steps:
#
# * +model(name = :model)+: used to instantiate a model (either by building
# it or fetching it from the DB). If a falsy value is returned, then the
# step will fail. Otherwise the resulting object will be assigned in
# +context[name]+ (+context[:model]+ by default).
# * +policy(name = :default)+: used to perform a check on the state of the
# system. Typically used to run guardians. If a falsy value is returned,
# the step will fail.
# * +contract(name = :default)+: used to validate the input parameters,
# typically provided by a user calling an endpoint. A special embedded
# +Contract+ class has to be defined to holds the validations. If the
# validations fail, the step will fail. Otherwise, the resulting contract
# will be available in +context[:contract]+.
# * +step(name)+: used to run small snippets of arbitrary code. The step
# doesnt care about its return value, so to mark the service as failed,
# {#fail!} has to be called explicitly.
# * +transaction+: used to wrap other steps inside a DB transaction.
#
# The methods defined on the service are automatically provided with
# the whole context passed as keyword arguments. This allows to define in a
# very explicit way what dependencies are used by the method. If for
# whatever reason a key isnt found in the current context, then Ruby will
# raise an exception when the method is called.
#
# Regarding contract classes, they have automatically {ActiveModel} modules
# included so all the {ActiveModel} API is available.
#
# @example An example from the {TrashChannel} service
# class TrashChannel
# include Base
#
# model :channel, :fetch_channel
# policy :invalid_access
# transaction do
# step :prevents_slug_collision
# step :soft_delete_channel
# step :log_channel_deletion
# end
# step :enqueue_delete_channel_relations_job
#
# private
#
# def fetch_channel(channel_id:, **)
# ChatChannel.find_by(id: channel_id)
# end
#
# def invalid_access(guardian:, channel:, **)
# guardian.can_preview_chat_channel?(channel) && guardian.can_delete_chat_channel?
# end
#
# def prevents_slug_collision(channel:, **)
# …
# end
#
# def soft_delete_channel(guardian:, channel:, **)
# …
# end
#
# def log_channel_deletion(guardian:, channel:, **)
# …
# end
#
# def enqueue_delete_channel_relations_job(channel:, **)
# …
# end
# end
# @example An example from the {UpdateChannelStatus} service which uses a contract
# class UpdateChannelStatus
# include Base
#
# model :channel, :fetch_channel
# contract
# policy :check_channel_permission
# step :change_status
#
# class Contract
# attribute :status
# validates :status, inclusion: { in: ChatChannel.editable_statuses.keys }
# end
#
# …
# end
module Base
extend ActiveSupport::Concern
# The only exception that can be raised by a service.
class Failure < StandardError
# @return [Context]
attr_reader :context
# @!visibility private
def initialize(context = nil)
@context = context
super
end
end
# Simple structure to hold the context of the service during its whole lifecycle.
class Context < OpenStruct
# @return [Boolean] returns +true+ if the conext is set as successful (default)
def success?
!failure?
end
# @return [Boolean] returns +true+ if the context is set as failed
# @see #fail!
# @see #fail
def failure?
@failure || false
end
# Marks the context as failed.
# @param context [Hash, Context] the context to merge into the current one
# @example
# context.fail!("failure": "something went wrong")
# @return [Context]
def fail!(context = {})
fail(context)
raise Failure, self
end
# Marks the context as failed without raising an exception.
# @param context [Hash, Context] the context to merge into the current one
# @example
# context.fail("failure": "something went wrong")
# @return [Context]
def fail(context = {})
merge(context)
@failure = true
self
end
# Merges the given context into the current one.
# @!visibility private
def merge(other_context = {})
other_context.each { |key, value| self[key.to_sym] = value }
self
end
private
def self.build(context = {})
self === context ? context : new(context)
end
end
# Internal module to define available steps as DSL
# @!visibility private
module StepsHelpers
def model(name = :model, step_name = :"fetch_#{name}")
steps << ModelStep.new(name, step_name)
end
def contract(name = :default, class_name: self::Contract, default_values_from: nil)
steps << ContractStep.new(
name,
class_name: class_name,
default_values_from: default_values_from,
)
end
def policy(name = :default)
steps << PolicyStep.new(name)
end
def step(name)
steps << Step.new(name)
end
def transaction(&block)
steps << TransactionStep.new(&block)
end
end
# @!visibility private
class Step
attr_reader :name, :method_name, :class_name
def initialize(name, method_name = name, class_name: nil)
@name = name
@method_name = method_name
@class_name = class_name
end
def call(instance, context)
method = instance.method(method_name)
args = {}
args = context.to_h unless method.arity.zero?
context[result_key] = Context.build
instance.instance_exec(**args, &method)
end
private
def type
self.class.name.split("::").last.downcase.sub(/^(\w+)step$/, "\\1")
end
def result_key
"result.#{type}.#{name}"
end
end
# @!visibility private
class ModelStep < Step
def call(instance, context)
context[name] = super
raise ArgumentError, "Model not found" unless context[name]
rescue ArgumentError => exception
context[result_key].fail(exception: exception)
context.fail!
end
end
# @!visibility private
class PolicyStep < Step
def call(instance, context)
unless super
context[result_key].fail
context.fail!
end
end
end
# @!visibility private
class ContractStep < Step
attr_reader :default_values_from
def initialize(name, method_name = name, class_name: nil, default_values_from: nil)
super(name, method_name, class_name: class_name)
@default_values_from = default_values_from
end
def call(instance, context)
attributes = class_name.attribute_names.map(&:to_sym)
default_values = {}
default_values = context[default_values_from].slice(*attributes) if default_values_from
contract = class_name.new(default_values.merge(context.to_h.slice(*attributes)))
context[contract_name] = contract
context[result_key] = Context.build
unless contract.valid?
context[result_key].fail(errors: contract.errors)
context.fail!
end
end
private
def contract_name
return :contract if name.to_sym == :default
:"#{name}_contract"
end
end
# @!visibility private
class TransactionStep < Step
include StepsHelpers
attr_reader :steps
def initialize(&block)
@steps = []
instance_exec(&block)
end
def call(instance, context)
ActiveRecord::Base.transaction { steps.each { |step| step.call(instance, context) } }
end
end
included do
# The global context which is available from any step.
attr_reader :context
# @!visibility private
# Internal class used to setup the base contract of the service.
self::Contract =
Class.new do
include ActiveModel::API
include ActiveModel::Attributes
include ActiveModel::AttributeMethods
include ActiveModel::Validations::Callbacks
end
end
class_methods do
include StepsHelpers
def call(context = {})
new(context).tap(&:run).context
end
def call!(context = {})
new(context).tap(&:run!).context
end
def steps
@steps ||= []
end
end
# @!scope class
# @!method model(name = :model, step_name = :"fetch_#{name}")
# @param name [Symbol] name of the model
# @param step_name [Symbol] name of the method to call for this step
# Evaluates arbitrary code to build or fetch a model (typically from the
# DB). If the step returns a falsy value, then the step will fail.
#
# It stores the resulting model in +context[:model]+ by default (can be
# customized by providing the +name+ argument).
#
# @example
# model :channel, :fetch_channel
#
# private
#
# def fetch_channel(channel_id:, **)
# ChatChannel.find_by(id: channel_id)
# end
# @!scope class
# @!method policy(name = :default)
# @param name [Symbol] name for this policy
# Performs checks related to the state of the system. If the
# step doesnt return a truthy value, then the policy will fail.
#
# @example
# policy :no_direct_message_channel
#
# private
#
# def no_direct_message_channel(channel:, **)
# !channel.direct_message_channel?
# end
# @!scope class
# @!method contract(name = :default, class_name: self::Contract, default_values_from: nil)
# @param name [Symbol] name for this contract
# @param class_name [Class] a class defining the contract
# @param default_values_from [Symbol] name of the model to get default values from
# Checks the validity of the input parameters.
# Implements ActiveModel::Validations and ActiveModel::Attributes.
#
# It stores the resulting contract in +context[:contract]+ by default
# (can be customized by providing the +name+ argument).
#
# @example
# contract
#
# class Contract
# attribute :name
# validates :name, presence: true
# end
# @!scope class
# @!method step(name)
# @param name [Symbol] the name of this step
# Runs arbitrary code. To mark a step as failed, a call to {#fail!} needs
# to be made explicitly.
#
# @example
# step :update_channel
#
# private
#
# def update_channel(channel:, params_to_edit:, **)
# channel.update!(params_to_edit)
# end
# @example using {#fail!} in a step
# step :save_channel
#
# private
#
# def save_channel(channel:, **)
# fail!("something went wrong") unless channel.save
# end
# @!scope class
# @!method transaction(&block)
# @param block [Proc] a block containing steps to be run inside a transaction
# Runs steps inside a DB transaction.
#
# @example
# transaction do
# step :prevents_slug_collision
# step :soft_delete_channel
# step :log_channel_deletion
# end
# @!visibility private
def initialize(initial_context = {})
@initial_context = initial_context.with_indifferent_access
@context = Context.build(initial_context.merge(__steps__: self.class.steps))
end
private
def run
run!
rescue Failure => exception
raise if context.object_id != exception.context.object_id
end
def run!
self.class.steps.each { |step| step.call(self, context) }
end
def fail!(message)
step_name = caller_locations(1, 1)[0].label
context["result.step.#{step_name}"].fail(error: message)
context.fail!
end
end
end
end
@@ -0,0 +1,66 @@
# frozen_string_literal: true
module Chat
module Service
# Service responsible for trashing a chat channel.
# Note the slug is modified to prevent collisions.
#
# @example
# Chat::Service::TrashChannel.call(channel_id: 2, guardian: guardian)
#
class TrashChannel
include Base
# @!method call(channel_id:, guardian:)
# @param [Integer] channel_id
# @param [Guardian] guardian
# @return [Chat::Service::Base::Context]
DELETE_CHANNEL_LOG_KEY = "chat_channel_delete"
model :channel, :fetch_channel
policy :invalid_access
transaction do
step :prevents_slug_collision
step :soft_delete_channel
step :log_channel_deletion
end
step :enqueue_delete_channel_relations_job
private
def fetch_channel(channel_id:, **)
ChatChannel.find_by(id: channel_id)
end
def invalid_access(guardian:, channel:, **)
guardian.can_preview_chat_channel?(channel) && guardian.can_delete_chat_channel?
end
def prevents_slug_collision(channel:, **)
channel.update!(
slug:
"#{Time.current.strftime("%Y%m%d-%H%M")}-#{channel.slug}-deleted".truncate(
SiteSetting.max_topic_title_length,
omission: "",
),
)
end
def soft_delete_channel(guardian:, channel:, **)
channel.trash!(guardian.user)
end
def log_channel_deletion(guardian:, channel:, **)
StaffActionLogger.new(guardian.user).log_custom(
DELETE_CHANNEL_LOG_KEY,
{ chat_channel_id: channel.id, chat_channel_name: channel.title(guardian.user) },
)
end
def enqueue_delete_channel_relations_job(channel:, **)
Jobs.enqueue(:chat_channel_delete, chat_channel_id: channel.id)
end
end
end
end
@@ -0,0 +1,88 @@
# frozen_string_literal: true
module Chat
module Service
# Service responsible for updating a chat channel's name, slug, and description.
#
# For a CategoryChannel, the settings for auto_join_users and allow_channel_wide_mentions
# are also editable.
#
# @example
# Chat::Service::UpdateChannel.call(
# channel_id: 2,
# guardian: guardian,
# name: "SuperChannel",
# description: "This is the best channel",
# slug: "super-channel",
# )
#
class UpdateChannel
include Base
# @!method call(channel_id:, guardian:, **params_to_edit)
# @param [Integer] channel_id
# @param [Guardian] guardian
# @param [Hash] params_to_edit
# @option params_to_edit [String,nil] name
# @option params_to_edit [String,nil] description
# @option params_to_edit [String,nil] slug
# @option params_to_edit [Boolean] auto_join_users Only valid for {CategoryChannel}. Whether active users
# with permission to see the category should automatically join the channel.
# @option params_to_edit [Boolean] allow_channel_wide_mentions Allow the use of @here and @all in the channel.
# @return [Chat::Service::Base::Context]
model :channel, :fetch_channel
policy :no_direct_message_channel
policy :check_channel_permission
contract default_values_from: :channel
step :update_channel
step :publish_channel_update
step :auto_join_users_if_needed
# @!visibility private
class Contract
attribute :name, :string
attribute :description, :string
attribute :slug, :string
attribute :auto_join_users, :boolean, default: false
attribute :allow_channel_wide_mentions, :boolean, default: true
before_validation do
assign_attributes(
attributes
.symbolize_keys
.slice(:name, :description, :slug)
.transform_values(&:presence),
)
end
end
private
def fetch_channel(channel_id:, **)
ChatChannel.find_by(id: channel_id)
end
def no_direct_message_channel(channel:, **)
!channel.direct_message_channel?
end
def check_channel_permission(guardian:, channel:, **)
guardian.can_preview_chat_channel?(channel) && guardian.can_edit_chat_channel?
end
def update_channel(channel:, contract:, **)
channel.update!(contract.attributes)
end
def publish_channel_update(channel:, guardian:, **)
ChatPublisher.publish_chat_channel_edit(channel, guardian.user)
end
def auto_join_users_if_needed(channel:, **)
return unless channel.auto_join_users?
Chat::ChatChannelMembershipManager.new(channel).enforce_automatic_channel_memberships
end
end
end
end
@@ -0,0 +1,46 @@
# frozen_string_literal: true
module Chat
module Service
# Service responsible for updating a chat channel status.
#
# @example
# Chat::Service::UpdateChannelStatus.call(channel_id: 2, guardian: guardian, status: "open")
#
class UpdateChannelStatus
include Base
# @!method call(channel_id:, guardian:, status:)
# @param [Integer] channel_id
# @param [Guardian] guardian
# @param [String] status
# @return [Chat::Service::Base::Context]
model :channel, :fetch_channel
contract
policy :check_channel_permission
step :change_status
# @!visibility private
class Contract
attribute :status
validates :status, inclusion: { in: ChatChannel.editable_statuses.keys }
end
private
def fetch_channel(channel_id:, **)
ChatChannel.find_by(id: channel_id)
end
def check_channel_permission(guardian:, channel:, status:, **)
guardian.can_preview_chat_channel?(channel) &&
guardian.can_change_channel_status?(channel, status.to_sym)
end
def change_status(channel:, status:, guardian:, **)
channel.public_send("#{status}!", guardian.user)
end
end
end
end
@@ -0,0 +1,79 @@
# frozen_string_literal: true
module Chat
module Service
# Service responsible for updating the last read message id of a membership.
#
# @example
# Chat::Service::UpdateUserLastRead.call(user_id: 1, channel_id: 2, message_id: 3, guardian: guardian)
#
class UpdateUserLastRead
include Base
# @!method call(user_id:, channel_id:, message_id:, guardian:)
# @param [Integer] user_id
# @param [Integer] channel_id
# @param [Integer] message_id
# @param [Guardian] guardian
# @return [Chat::Service::Base::Context]
model :membership, :fetch_active_membership
policy :invalid_access
contract
policy :ensure_message_id_recency
policy :ensure_message_exists
step :update_last_read_message_id
step :mark_associated_mentions_as_read
step :publish_new_last_read_to_clients
# @!visibility private
class Contract
attribute :message_id, :integer
attribute :user_id, :integer
attribute :channel_id, :integer
end
private
def fetch_active_membership(user_id:, channel_id:, **)
UserChatChannelMembership.includes(:user, :chat_channel).find_by(
user_id: user_id,
chat_channel_id: channel_id,
following: true,
)
end
def invalid_access(guardian:, membership:, **)
guardian.can_join_chat_channel?(membership.chat_channel)
end
def ensure_message_id_recency(message_id:, membership:, **)
!membership.last_read_message_id || message_id >= membership.last_read_message_id
end
def ensure_message_exists(channel_id:, message_id:, **)
ChatMessage.with_deleted.exists?(chat_channel_id: channel_id, id: message_id)
end
def update_last_read_message_id(message_id:, membership:, **)
membership.update!(last_read_message_id: message_id)
end
def mark_associated_mentions_as_read(membership:, message_id:, **)
Notification
.where(notification_type: Notification.types[:chat_mention])
.where(user: membership.user)
.where(read: false)
.joins("INNER JOIN chat_mentions ON chat_mentions.notification_id = notifications.id")
.joins("INNER JOIN chat_messages ON chat_mentions.chat_message_id = chat_messages.id")
.where("chat_messages.id <= ?", message_id)
.where("chat_messages.chat_channel_id = ?", membership.chat_channel.id)
.update_all(read: true)
end
def publish_new_last_read_to_clients(guardian:, channel_id:, message_id:, **)
ChatPublisher.publish_user_tracking_state(guardian.user, channel_id, message_id)
end
end
end
end
@@ -1,5 +1,3 @@
/** @module Collection */
import { ajax } from "discourse/lib/ajax";
import { tracked } from "@glimmer/tracking";
import { bind } from "discourse-common/utils/decorators";
@@ -7,19 +5,12 @@ import { Promise } from "rsvp";
/**
* Handles a paginated API response.
*
* @class
*/
export default class Collection {
@tracked items = [];
@tracked meta = {};
@tracked loading = false;
/**
* Create a Collection instance
* @param {string} resourceURL - the API endpoint to call
* @param {callback} handler - anonymous function used to handle the response
*/
constructor(resourceURL, handler) {
this._resourceURL = resourceURL;
this._handler = handler;
@@ -5,6 +5,66 @@ import {
} from "discourse/plugins/chat/discourse/components/chat-message";
import { registerChatComposerButton } from "discourse/plugins/chat/discourse/lib/chat-composer-buttons";
/**
* Class exposing the javascript API available to plugins and themes.
* @class PluginApi
*/
/**
* Callback used to decorate a chat message
*
* @callback PluginApi~decorateChatMessageCallback
* @param {ChatMessage} chatMessage - model
* @param {HTMLElement} messageContainer - DOM node
* @param {ChatChannel} chatChannel - model
*/
/**
* Decorate a chat message
*
* @memberof PluginApi
* @instance
* @function decorateChatMessage
* @param {PluginApi~decorateChatMessageCallback} decorator
* @example
*
* api.decorateChatMessage((chatMessage, messageContainer) => {
* messageContainer.dataset.foo = chatMessage.id;
* });
*/
/**
* Register a button in the chat composer
*
* @memberof PluginApi
* @instance
* @function registerChatComposerButton
* @param {Object} options
* @param {number} options.id - The id of the button
* @param {function} options.action - An action name or an anonymous function called when the button is pressed, eg: "onFooClicked" or `() => { console.log("clicked") }`
* @param {string} options.icon - A valid font awesome icon name, eg: "far fa-image"
* @param {string} options.label - Text displayed on the button, a translatable key, eg: "foo.bar"
* @param {string} options.translatedLabel - Text displayed on the button, a string, eg: "Add gifs"
* @param {string} [options.position] - Can be "inline" or "dropdown", defaults to "inline"
* @param {string} [options.title] - Title attribute of the button, a translatable key, eg: "foo.bar"
* @param {string} [options.translatedTitle] - Title attribute of the button, a string, eg: "Add gifs"
* @param {string} [options.ariaLabel] - aria-label attribute of the button, a translatable key, eg: "foo.bar"
* @param {string} [options.translatedAriaLabel] - aria-label attribute of the button, a string, eg: "Add gifs"
* @param {string} [options.classNames] - Additional names to add to the buttons class attribute, eg: ["foo", "bar"]
* @param {boolean} [options.displayed] - Hide or show the button
* @param {boolean} [options.disabled] - Sets the disabled attribute on the button
* @param {number} [options.priority] - An integer defining the order of the buttons, higher comes first, eg: `700`
* @param {Array.<string>} [options.dependentKeys] - List of property names which should trigger a refresh of the buttons when changed, eg: `["foo.bar", "bar.baz"]`
* @example
*
* api.registerChatComposerButton({
* id: "foo",
* displayed() {
* return this.site.mobileView && this.canAttachUploads;
* }
* });
*/
export default {
name: "chat-plugin-api",
after: "inject-discourse-objects",
@@ -14,7 +14,8 @@ export default function withChatChannel(extendedClass) {
this.controllerFor("chat-channel").set("targetMessageId", null);
this.chat.activeChannel = model;
let { messageId } = this.paramsFor(this.routeName);
let { messageId, channelTitle } = this.paramsFor(this.routeName);
// messageId query param backwards-compatibility
if (messageId) {
this.router.replaceWith(
@@ -24,7 +25,6 @@ export default function withChatChannel(extendedClass) {
);
}
const { channelTitle } = this.paramsFor("chat.channel");
if (channelTitle && channelTitle !== model.slugifiedTitle) {
const nearMessageParams = this.paramsFor("chat.channel.near-message");
if (nearMessageParams.messageId) {
@@ -1,5 +1,3 @@
/** @module ChatApi */
import Service, { inject as service } from "@ember/service";
import { ajax } from "discourse/lib/ajax";
import UserChatChannelMembership from "discourse/plugins/chat/discourse/models/user-chat-channel-membership";
@@ -8,7 +6,7 @@ import Collection from "../lib/collection";
/**
* Chat API service. Provides methods to interact with the chat API.
*
* @class
* @module ChatApi
* @implements {@ember/service}
*/
export default class ChatApi extends Service {
@@ -31,7 +29,7 @@ export default class ChatApi extends Service {
/**
* List all accessible category channels of the current user.
* @returns {module:Collection}
* @returns {Collection}
*
* @example
*
@@ -70,17 +68,14 @@ export default class ChatApi extends Service {
/**
* Destroys a channel.
* @param {number} channelId - The ID of the channel.
* @param {string} channelName - The name of the channel to be destroyed, used as confirmation.
* @returns {Promise}
*
* @example
*
* this.chatApi.destroyChannel(1, "foo").then(() => { ... })
* this.chatApi.destroyChannel(1).then(() => { ... })
*/
destroyChannel(channelId, channelName) {
return this.#deleteRequest(`/channels/${channelId}`, {
channel: { name_confirmation: channelName },
});
destroyChannel(channelId) {
return this.#deleteRequest(`/channels/${channelId}`);
}
/**
@@ -174,7 +169,7 @@ export default class ChatApi extends Service {
/**
* Lists members of a channel.
* @param {number} channelId - The ID of the channel.
* @returns {module:Collection}
* @returns {Collection}
*/
listChannelMemberships(channelId) {
return new Collection(
-352
View File
@@ -1,352 +0,0 @@
## Modules
<dl>
<dt><a href="#module_Collection">Collection</a></dt>
<dd></dd>
<dt><a href="#module_ChatApi">ChatApi</a></dt>
<dd></dd>
</dl>
<a name="module_Collection"></a>
## Collection
* [Collection](#module_Collection)
* [module.exports](#exp_module_Collection--module.exports) ⏏
* [new module.exports(resourceURL, handler)](#new_module_Collection--module.exports_new)
* [.load()](#module_Collection--module.exports+load) ⇒ <code>Promise</code>
* [.loadMore()](#module_Collection--module.exports+loadMore) ⇒ <code>Promise</code>
* * *
<a name="exp_module_Collection--module.exports"></a>
### module.exports ⏏
Handles a paginated API response.
**Kind**: Exported class
* * *
<a name="new_module_Collection--module.exports_new"></a>
#### new module.exports(resourceURL, handler)
Create a Collection instance
| Param | Type | Description |
| --- | --- | --- |
| resourceURL | <code>string</code> | the API endpoint to call |
| handler | <code>callback</code> | anonymous function used to handle the response |
* * *
<a name="module_Collection--module.exports+load"></a>
#### module.exports.load() ⇒ <code>Promise</code>
Loads first batch of results
**Kind**: instance method of [<code>module.exports</code>](#exp_module_Collection--module.exports)
* * *
<a name="module_Collection--module.exports+loadMore"></a>
#### module.exports.loadMore() ⇒ <code>Promise</code>
Attempts to load more results
**Kind**: instance method of [<code>module.exports</code>](#exp_module_Collection--module.exports)
* * *
<a name="module_ChatApi"></a>
## ChatApi
* [ChatApi](#module_ChatApi)
* [module.exports](#exp_module_ChatApi--module.exports) ⏏
* [.channel(channelId)](#module_ChatApi--module.exports+channel) ⇒ <code>Promise</code>
* [.channels()](#module_ChatApi--module.exports+channels) ⇒ [<code>module.exports</code>](#exp_module_Collection--module.exports)
* [.moveChannelMessages(channelId, data)](#module_ChatApi--module.exports+moveChannelMessages) ⇒ <code>Promise</code>
* [.destroyChannel(channelId, channelName)](#module_ChatApi--module.exports+destroyChannel) ⇒ <code>Promise</code>
* [.createChannel(data)](#module_ChatApi--module.exports+createChannel) ⇒ <code>Promise</code>
* [.categoryPermissions(categoryId)](#module_ChatApi--module.exports+categoryPermissions) ⇒ <code>Promise</code>
* [.sendMessage(channelId, data)](#module_ChatApi--module.exports+sendMessage) ⇒ <code>Promise</code>
* [.createChannelArchive(channelId, data)](#module_ChatApi--module.exports+createChannelArchive) ⇒ <code>Promise</code>
* [.updateChannel(channelId, data)](#module_ChatApi--module.exports+updateChannel) ⇒ <code>Promise</code>
* [.updateChannelStatus(channelId, status)](#module_ChatApi--module.exports+updateChannelStatus) ⇒ <code>Promise</code>
* [.listChannelMemberships(channelId)](#module_ChatApi--module.exports+listChannelMemberships) ⇒ [<code>module.exports</code>](#exp_module_Collection--module.exports)
* [.listCurrentUserChannels()](#module_ChatApi--module.exports+listCurrentUserChannels) ⇒ <code>Promise</code>
* [.followChannel(channelId)](#module_ChatApi--module.exports+followChannel) ⇒ <code>Promise</code>
* [.unfollowChannel(channelId)](#module_ChatApi--module.exports+unfollowChannel) ⇒ <code>Promise</code>
* [.updateCurrentUserChannelNotificationsSettings(channelId, data)](#module_ChatApi--module.exports+updateCurrentUserChannelNotificationsSettings) ⇒ <code>Promise</code>
* * *
<a name="exp_module_ChatApi--module.exports"></a>
### module.exports ⏏
Chat API service. Provides methods to interact with the chat API.
**Kind**: Exported class
**Implements**: <code>{@ember/service}</code>
* * *
<a name="module_ChatApi--module.exports+channel"></a>
#### module.exports.channel(channelId) ⇒ <code>Promise</code>
Get a channel by its ID.
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
| Param | Type | Description |
| --- | --- | --- |
| channelId | <code>number</code> | The ID of the channel. |
**Example**
```js
this.chatApi.channel(1).then(channel => { ... })
```
* * *
<a name="module_ChatApi--module.exports+channels"></a>
#### module.exports.channels() ⇒ [<code>module.exports</code>](#exp_module_Collection--module.exports)
List all accessible category channels of the current user.
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
**Example**
```js
this.chatApi.channels.then(channels => { ... })
```
* * *
<a name="module_ChatApi--module.exports+moveChannelMessages"></a>
#### module.exports.moveChannelMessages(channelId, data) ⇒ <code>Promise</code>
Moves messages from one channel to another.
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
| Param | Type | Description |
| --- | --- | --- |
| channelId | <code>number</code> | The ID of the original channel. |
| data | <code>object</code> | Params of the move. |
| data.message_ids | <code>Array.&lt;number&gt;</code> | IDs of the moved messages. |
| data.destination_channel_id | <code>number</code> | ID of the channel where the messages are moved to. |
**Example**
```js
this.chatApi
.moveChannelMessages(1, {
message_ids: [2, 3],
destination_channel_id: 4,
}).then(() => { ... })
```
* * *
<a name="module_ChatApi--module.exports+destroyChannel"></a>
#### module.exports.destroyChannel(channelId, channelName) ⇒ <code>Promise</code>
Destroys a channel.
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
| Param | Type | Description |
| --- | --- | --- |
| channelId | <code>number</code> | The ID of the channel. |
| channelName | <code>string</code> | The name of the channel to be destroyed, used as confirmation. |
**Example**
```js
this.chatApi.destroyChannel(1, "foo").then(() => { ... })
```
* * *
<a name="module_ChatApi--module.exports+createChannel"></a>
#### module.exports.createChannel(data) ⇒ <code>Promise</code>
Creates a channel.
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
| Param | Type | Description |
| --- | --- | --- |
| data | <code>object</code> | Params of the channel. |
| data.name | <code>string</code> | The name of the channel. |
| data.chatable_id | <code>string</code> | The category of the channel. |
| data.description | <code>string</code> | The description of the channel. |
| [data.auto_join_users] | <code>boolean</code> | Should users join this channel automatically. |
**Example**
```js
this.chatApi
.createChannel({ name: "foo", chatable_id: 1, description "bar" })
.then((channel) => { ... })
```
* * *
<a name="module_ChatApi--module.exports+categoryPermissions"></a>
#### module.exports.categoryPermissions(categoryId) ⇒ <code>Promise</code>
Lists chat permissions for a category.
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
| Param | Type | Description |
| --- | --- | --- |
| categoryId | <code>number</code> | ID of the category. |
* * *
<a name="module_ChatApi--module.exports+sendMessage"></a>
#### module.exports.sendMessage(channelId, data) ⇒ <code>Promise</code>
Sends a message.
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
| Param | Type | Description |
| --- | --- | --- |
| channelId | <code>number</code> | ID of the channel. |
| data | <code>object</code> | Params of the message. |
| data.message | <code>string</code> | The raw content of the message in markdown. |
| data.cooked | <code>string</code> | The cooked content of the message. |
| [data.in_reply_to_id] | <code>number</code> | The ID of the replied-to message. |
| [data.staged_id] | <code>number</code> | The staged ID of the message before it was persisted. |
| [data.upload_ids] | <code>Array.&lt;number&gt;</code> | Array of upload ids linked to the message. |
* * *
<a name="module_ChatApi--module.exports+createChannelArchive"></a>
#### module.exports.createChannelArchive(channelId, data) ⇒ <code>Promise</code>
Creates a channel archive.
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
| Param | Type | Description |
| --- | --- | --- |
| channelId | <code>number</code> | The ID of the channel. |
| data | <code>object</code> | Params of the archive. |
| data.selection | <code>string</code> | "new_topic" or "existing_topic". |
| [data.title] | <code>string</code> | Title of the topic when creating a new topic. |
| [data.category_id] | <code>string</code> | ID of the category used when creating a new topic. |
| [data.tags] | <code>Array.&lt;string&gt;</code> | tags used when creating a new topic. |
| [data.topic_id] | <code>string</code> | ID of the topic when using an existing topic. |
* * *
<a name="module_ChatApi--module.exports+updateChannel"></a>
#### module.exports.updateChannel(channelId, data) ⇒ <code>Promise</code>
Updates a channel.
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
| Param | Type | Description |
| --- | --- | --- |
| channelId | <code>number</code> | The ID of the channel. |
| data | <code>object</code> | Params of the archive. |
| [data.description] | <code>string</code> | Description of the channel. |
| [data.name] | <code>string</code> | Name of the channel. |
* * *
<a name="module_ChatApi--module.exports+updateChannelStatus"></a>
#### module.exports.updateChannelStatus(channelId, status) ⇒ <code>Promise</code>
Updates the status of a channel.
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
| Param | Type | Description |
| --- | --- | --- |
| channelId | <code>number</code> | The ID of the channel. |
| status | <code>string</code> | The new status, can be "open" or "closed". |
* * *
<a name="module_ChatApi--module.exports+listChannelMemberships"></a>
#### module.exports.listChannelMemberships(channelId) ⇒ [<code>module.exports</code>](#exp_module_Collection--module.exports)
Lists members of a channel.
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
| Param | Type | Description |
| --- | --- | --- |
| channelId | <code>number</code> | The ID of the channel. |
* * *
<a name="module_ChatApi--module.exports+listCurrentUserChannels"></a>
#### module.exports.listCurrentUserChannels() ⇒ <code>Promise</code>
Lists public and direct message channels of the current user.
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
* * *
<a name="module_ChatApi--module.exports+followChannel"></a>
#### module.exports.followChannel(channelId) ⇒ <code>Promise</code>
Makes current user follow a channel.
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
| Param | Type | Description |
| --- | --- | --- |
| channelId | <code>number</code> | The ID of the channel. |
* * *
<a name="module_ChatApi--module.exports+unfollowChannel"></a>
#### module.exports.unfollowChannel(channelId) ⇒ <code>Promise</code>
Makes current user unfollow a channel.
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
| Param | Type | Description |
| --- | --- | --- |
| channelId | <code>number</code> | The ID of the channel. |
* * *
<a name="module_ChatApi--module.exports+updateCurrentUserChannelNotificationsSettings"></a>
#### module.exports.updateCurrentUserChannelNotificationsSettings(channelId, data) ⇒ <code>Promise</code>
Update notifications settings of current user for a channel.
**Kind**: instance method of [<code>module.exports</code>](#exp_module_ChatApi--module.exports)
| Param | Type | Description |
| --- | --- | --- |
| channelId | <code>number</code> | The ID of the channel. |
| data | <code>object</code> | The settings to modify. |
| [data.muted] | <code>boolean</code> | Mutes the channel. |
| [data.desktop_notification_level] | <code>string</code> | Notifications level on desktop: never, mention or always. |
| [data.mobile_notification_level] | <code>string</code> | Notifications level on mobile: never, mention or always. |
* * *
+109
View File
@@ -0,0 +1,109 @@
# frozen_string_literal: true
#
# = Chat::Endpoint
#
# This class is to be used via its helper +with_service+ in a controller. Its
# main purpose is to ease how actions can be run upon a service completion.
# Since a service will likely return the same kind of things over and over,
# this allows us to not have to repeat the same boilerplate code in every
# controller.
#
# There are several available actions and we can add new ones very easily:
#
# * +on_success+: will execute the provided block if the service succeeds
# * +on_failure+: will execute the provided block if the service fails
# * +on_failed_policy(name)+: will execute the provided block if the policy
# named `name` fails
# * +on_failed_contract(name)+: will execute the provided block if the contract
# named `name` fails
# * +on_model_not_found(name)+: will execute the provided block if the service
# fails and its model is not present
#
# @example
# # in a controller
# def create
# with_service MyService do
# on_success do
# flash[:notice] = "Success!"
# redirect_to a_path
# end
# on_failed_policy(:a_named_policy) { redirect_to root_path }
# on_failure { render :new }
# end
# end
#
# The actions will be evaluated in the order they appear. So even if the
# service will ultimately fail with a failed policy, in this example only the
# +on_failed_policy+ action will be executed and not the +on_failure+ one.
# The only exception to this being +on_failure+ as it will always be executed
# last.
#
class Chat::Endpoint
# @!visibility private
NULL_RESULT = OpenStruct.new(failure?: false)
# @!visibility private
AVAILABLE_ACTIONS = {
on_success: -> { result.success? },
on_failure: -> { result.failure? },
on_failed_policy: ->(name = "default") { failure_for?("result.policy.#{name}") },
on_failed_contract: ->(name = "default") { failure_for?("result.contract.#{name}") },
on_model_not_found: ->(name = "model") { failure_for?("result.model.#{name}") },
}.with_indifferent_access.freeze
# @!visibility private
attr_reader :service, :controller, :dependencies
delegate :result, to: :controller
# @!visibility private
def initialize(service, controller, **dependencies)
@service = service
@controller = controller
@dependencies = dependencies
@actions = {}
end
# @param service [Class] a class including {Chat::Service::Base}
# @param block [Proc] a block containing the steps to match on
# @return [void]
def self.call(service, controller, **dependencies, &block)
new(service, controller, **dependencies).call(&block)
end
# @!visibility private
def call(&block)
instance_eval(&block)
controller.run_service(service, dependencies)
# Always have `on_failure` as the last action
(
actions
.except(:on_failure)
.merge(actions.slice(:on_failure))
.detect { |name, (condition, _)| condition.call } || [-> {}]
).flatten.last.call
end
private
attr_reader :actions
def failure_for?(key)
(controller.result[key] || NULL_RESULT).failure?
end
def add_action(name, *args, &block)
actions[[name, *args].join("_").to_sym] = [
-> { instance_exec(*args, &AVAILABLE_ACTIONS[name]) },
-> { controller.instance_eval(&block) },
]
end
def method_missing(method_name, *args, &block)
return super unless AVAILABLE_ACTIONS[method_name]
add_action(method_name, *args, &block)
end
def respond_to_missing?(method_name, include_private = false)
AVAILABLE_ACTIONS[method_name] || super
end
end
+1
View File
@@ -61,6 +61,7 @@ module Chat::GuardianExtensions
return false if chat_channel.status.to_sym == target_status.to_sym
return false if !is_staff?
# FIXME: This logic shouldn't be handled in guardian
case target_status
when :closed
chat_channel.open?
+112
View File
@@ -0,0 +1,112 @@
# frozen_string_literal: true
module Chat
# = Chat::StepsInspector
#
# This class takes a {Chat::Service::Base::Context} object and inspects it.
# It will output a list of steps and what is their known state.
class StepsInspector
# @!visibility private
class Step
attr_reader :step, :result, :nesting_level
delegate :name, to: :step
delegate :failure?, :success?, :error, to: :step_result, allow_nil: true
def self.for(step, result, nesting_level: 0)
class_name =
"#{module_parent_name}::#{step.class.name.split("::").last.sub(/^(\w+)Step$/, "\\1")}"
class_name.constantize.new(step, result, nesting_level: nesting_level)
end
def initialize(step, result, nesting_level: 0)
@step = step
@result = result
@nesting_level = nesting_level
end
def type
self.class.name.split("::").last.downcase
end
def emoji
return "" if failure?
return "" if success?
""
end
def steps
[self]
end
def inspect
"#{" " * nesting_level}[#{type}] '#{name}' #{emoji}"
end
private
def step_result
result["result.#{type}.#{name}"]
end
end
# @!visibility private
class Model < Step
def error
step_result.exception.full_message
end
end
# @!visibility private
class Contract < Step
def error
step_result.errors.inspect
end
end
# @!visibility private
class Policy < Step
end
# @!visibility private
class Transaction < Step
def steps
[self, *step.steps.map { Step.for(_1, result, nesting_level: nesting_level + 1).steps }]
end
def inspect
"#{" " * nesting_level}[#{type}]"
end
def step_result
nil
end
end
attr_reader :steps, :result
def initialize(result)
@steps = result.__steps__.map { Step.for(_1, result).steps }.flatten
@result = result
end
# Inspect the provided result object.
# Example output:
# [1/4] [model] 'channel' ✅
# [2/4] [contract] 'default' ✅
# [3/4] [policy] 'check_channel_permission' ❌
# [4/4] [step] 'change_status'
# @return [String] the steps of the result object with their state
def inspect
steps
.map
.with_index { |step, index| "[#{index + 1}/#{steps.size}] #{step.inspect}" }
.join("\n")
end
# @return [String, nil] the first available error, if any.
def error
steps.detect(&:failure?)&.error
end
end
end
-13
View File
@@ -1,13 +0,0 @@
# frozen_string_literal: true
task "chat:doc" do
destination = File.join(Rails.root, "plugins/chat/docs/FRONTEND.md")
config = File.join(Rails.root, ".jsdoc")
files = %w[
plugins/chat/assets/javascripts/discourse/lib/collection.js
plugins/chat/assets/javascripts/discourse/services/chat-api.js
]
`yarn --silent jsdoc2md --separators -c #{config} -f #{files.join(" ")} > #{destination}`
end
+9
View File
@@ -91,6 +91,7 @@ require_relative "app/core_ext/plugin_instance.rb"
GlobalSetting.add_default(:allow_unsecure_chat_uploads, false)
after_initialize do
# Namespace for classes and modules parts of chat plugin
module ::Chat
PLUGIN_NAME = "chat"
HAS_CHAT_ENABLED = "has_chat_enabled"
@@ -119,6 +120,7 @@ after_initialize do
"../app/controllers/admin/admin_incoming_chat_webhooks_controller.rb",
__FILE__,
)
load File.expand_path("../app/helpers/with_service_helper.rb", __FILE__)
load File.expand_path("../app/controllers/chat_base_controller.rb", __FILE__)
load File.expand_path("../app/controllers/chat_controller.rb", __FILE__)
load File.expand_path("../app/controllers/emojis_controller.rb", __FILE__)
@@ -163,6 +165,7 @@ after_initialize do
load File.expand_path("../app/serializers/admin_chat_index_serializer.rb", __FILE__)
load File.expand_path("../app/serializers/user_chat_message_bookmark_serializer.rb", __FILE__)
load File.expand_path("../app/serializers/reviewable_chat_message_serializer.rb", __FILE__)
load File.expand_path("../app/services/base.rb", __FILE__)
load File.expand_path("../lib/chat_channel_fetcher.rb", __FILE__)
load File.expand_path("../lib/chat_channel_hashtag_data_source.rb", __FILE__)
load File.expand_path("../lib/chat_mailer.rb", __FILE__)
@@ -191,6 +194,8 @@ after_initialize do
load File.expand_path("../lib/slack_compatibility.rb", __FILE__)
load File.expand_path("../lib/post_notification_handler.rb", __FILE__)
load File.expand_path("../lib/secure_uploads_compatibility.rb", __FILE__)
load File.expand_path("../lib/endpoint.rb", __FILE__)
load File.expand_path("../lib/steps_inspector.rb", __FILE__)
load File.expand_path("../app/jobs/regular/auto_manage_channel_memberships.rb", __FILE__)
load File.expand_path("../app/jobs/regular/auto_join_channel_batch.rb", __FILE__)
load File.expand_path("../app/jobs/regular/process_chat_message.rb", __FILE__)
@@ -207,7 +212,11 @@ after_initialize do
load File.expand_path("../app/jobs/scheduled/auto_join_users.rb", __FILE__)
load File.expand_path("../app/jobs/scheduled/chat_periodical_updates.rb", __FILE__)
load File.expand_path("../app/services/chat_publisher.rb", __FILE__)
load File.expand_path("../app/services/trash_channel.rb", __FILE__)
load File.expand_path("../app/services/update_channel.rb", __FILE__)
load File.expand_path("../app/services/update_channel_status.rb", __FILE__)
load File.expand_path("../app/services/chat_message_destroyer.rb", __FILE__)
load File.expand_path("../app/services/update_user_last_read.rb", __FILE__)
load File.expand_path("../app/controllers/api_controller.rb", __FILE__)
load File.expand_path("../app/controllers/api/chat_channels_controller.rb", __FILE__)
load File.expand_path("../app/controllers/api/chat_current_user_channels_controller.rb", __FILE__)
@@ -59,9 +59,50 @@ Fabricator(:chat_message) do
end
Fabricator(:chat_mention) do
chat_message { Fabricate(:chat_message) }
transient read: false
transient high_priority: true
transient identifier: :direct_mentions
user { Fabricate(:user) }
notification { Fabricate(:notification) }
chat_message { Fabricate(:chat_message) }
notification do |attrs|
# All this setup should be in a service we could just call here
# At the moment the logic is all split in a job
channel = attrs[:chat_message].chat_channel
payload = {
is_direct_message_channel: channel.direct_message_channel?,
mentioned_by_username: attrs[:chat_message].user.username,
chat_channel_id: channel.id,
chat_message_id: attrs[:chat_message].id,
}
if channel.direct_message_channel?
payload[:chat_channel_title] = channel.title(membership.user)
payload[:chat_channel_slug] = channel.slug
end
unless attrs[:identifier] == :direct_mentions
case attrs[:identifier]
when :here_mentions
payload[:identifier] = "here"
when :global_mentions
payload[:identifier] = "all"
else
payload[:identifier] = attrs[:identifier] if attrs[:identifier]
payload[:is_group_mention] = true
end
end
Fabricate(
:notification,
notification_type: Notification.types[:chat_mention],
user: attrs[:user],
data: payload.to_json,
read: attrs[:read],
high_priority: attrs[:high_priority],
)
end
end
Fabricator(:chat_message_reaction) do
+242
View File
@@ -0,0 +1,242 @@
# frozen_string_literal: true
RSpec.describe Chat::Endpoint do
class SuccessService
include Chat::Service::Base
end
class FailureService
include Chat::Service::Base
step :fail_step
def fail_step
fail!("error")
end
end
class FailedPolicyService
include Chat::Service::Base
policy :test
def test
false
end
end
class SuccessPolicyService
include Chat::Service::Base
policy :test
def test
true
end
end
class FailedContractService
include Chat::Service::Base
class Contract
attribute :test
validates :test, presence: true
end
contract
end
class SuccessContractService
include Chat::Service::Base
contract
end
class FailureWithModelService
include Chat::Service::Base
model :fake_model, :fetch_fake_model
private
def fetch_fake_model
nil
end
end
class SuccessWithModelService
include Chat::Service::Base
model :fake_model, :fetch_fake_model
private
def fetch_fake_model
:model_found
end
end
describe ".call(service, &block)" do
subject(:endpoint) { described_class.call(service, controller, &actions_block) }
let(:result) { controller.result }
let(:actions_block) { controller.instance_eval(actions) }
let(:service) { SuccessService }
let(:actions) { "proc {}" }
let(:controller) do
Class
.new(Chat::Api) do
def request
OpenStruct.new
end
def params
ActionController::Parameters.new
end
def guardian
end
end
.new
end
it "runs the provided service in the context of a controller" do
endpoint
expect(result).to be_a Chat::Service::Base::Context
expect(result).to be_a_success
end
context "when using the on_success action" do
let(:actions) { <<-BLOCK }
proc do
on_success { :success }
end
BLOCK
context "when the service succeeds" do
it "runs the provided block" do
expect(endpoint).to eq :success
end
end
context "when the service does not succeed" do
let(:service) { FailureService }
it "does not run the provided block" do
expect(endpoint).not_to eq :success
end
end
end
context "when using the on_failure action" do
let(:actions) { <<-BLOCK }
proc do
on_failure { :fail }
end
BLOCK
context "when the service fails" do
let(:service) { FailureService }
it "runs the provided block" do
expect(endpoint).to eq :fail
end
end
context "when the service does not fail" do
let(:service) { SuccessService }
it "does not run the provided block" do
expect(endpoint).not_to eq :fail
end
end
end
context "when using the on_failed_policy action" do
let(:actions) { <<-BLOCK }
proc do
on_failed_policy(:test) { :policy_failure }
end
BLOCK
context "when the service policy fails" do
let(:service) { FailedPolicyService }
it "runs the provided block" do
expect(endpoint).to eq :policy_failure
end
end
context "when the service policy does not fail" do
let(:service) { SuccessPolicyService }
it "does not run the provided block" do
expect(endpoint).not_to eq :policy_failure
end
end
end
context "when using the on_failed_contract action" do
let(:actions) { <<-BLOCK }
proc do
on_failed_contract { :contract_failure }
end
BLOCK
context "when the service contract fails" do
let(:service) { FailedContractService }
it "runs the provided block" do
expect(endpoint).to eq :contract_failure
end
end
context "when the service contract does not fail" do
let(:service) { SuccessContractService }
it "does not run the provided block" do
expect(endpoint).not_to eq :contract_failure
end
end
end
context "when using the on_model_not_found action" do
let(:actions) { <<-BLOCK }
->(*) do
on_model_not_found(:fake_model) { :no_model }
end
BLOCK
context "when the service failed without a model" do
let(:service) { FailureWithModelService }
it "runs the provided block" do
expect(endpoint).to eq :no_model
end
end
context "when the service does not fail with a model" do
let(:service) { SuccessWithModelService }
it "does not run the provided block" do
expect(endpoint).not_to eq :no_model
end
end
end
context "when using several actions together" do
let(:service) { FailureService }
let(:actions) { <<-BLOCK }
proc do
on_success { :success }
on_failure { :failure }
on_failed_policy { :policy_failure }
end
BLOCK
it "runs the first matching action" do
expect(endpoint).to eq :failure
end
end
end
end
@@ -0,0 +1,175 @@
# frozen_string_literal: true
RSpec.describe Chat::StepsInspector do
class DummyService
include Chat::Service::Base
model :model
policy :policy
contract
transaction do
step :in_transaction_step_1
step :in_transaction_step_2
end
step :final_step
class Contract
attribute :parameter
validates :parameter, presence: true
end
end
subject(:inspector) { described_class.new(result) }
let(:parameter) { "present" }
let(:result) { DummyService.call(parameter: parameter) }
before do
class DummyService
%i[fetch_model policy in_transaction_step_1 in_transaction_step_2 final_step].each do |name|
define_method(name) { true }
end
end
end
describe "#inspect" do
subject(:output) { inspector.inspect }
context "when service runs without error" do
it "outputs all the steps of the service" do
expect(output).to eq <<~OUTPUT.chomp
[1/7] [model] 'model'
[2/7] [policy] 'policy'
[3/7] [contract] 'default'
[4/7] [transaction]
[5/7] [step] 'in_transaction_step_1'
[6/7] [step] 'in_transaction_step_2'
[7/7] [step] 'final_step'
OUTPUT
end
end
context "when the model step is failing" do
before do
class DummyService
def fetch_model
false
end
end
end
it "shows the failing step" do
expect(output).to eq <<~OUTPUT.chomp
[1/7] [model] 'model'
[2/7] [policy] 'policy'
[3/7] [contract] 'default'
[4/7] [transaction]
[5/7] [step] 'in_transaction_step_1'
[6/7] [step] 'in_transaction_step_2'
[7/7] [step] 'final_step'
OUTPUT
end
end
context "when the policy step is failing" do
before do
class DummyService
def policy
false
end
end
end
it "shows the failing step" do
expect(output).to eq <<~OUTPUT.chomp
[1/7] [model] 'model'
[2/7] [policy] 'policy'
[3/7] [contract] 'default'
[4/7] [transaction]
[5/7] [step] 'in_transaction_step_1'
[6/7] [step] 'in_transaction_step_2'
[7/7] [step] 'final_step'
OUTPUT
end
end
context "when the contract step is failing" do
let(:parameter) { nil }
it "shows the failing step" do
expect(output).to eq <<~OUTPUT.chomp
[1/7] [model] 'model'
[2/7] [policy] 'policy'
[3/7] [contract] 'default'
[4/7] [transaction]
[5/7] [step] 'in_transaction_step_1'
[6/7] [step] 'in_transaction_step_2'
[7/7] [step] 'final_step'
OUTPUT
end
end
context "when a common step is failing" do
before do
class DummyService
def in_transaction_step_2
fail!("step error")
end
end
end
it "shows the failing step" do
expect(output).to eq <<~OUTPUT.chomp
[1/7] [model] 'model'
[2/7] [policy] 'policy'
[3/7] [contract] 'default'
[4/7] [transaction]
[5/7] [step] 'in_transaction_step_1'
[6/7] [step] 'in_transaction_step_2'
[7/7] [step] 'final_step'
OUTPUT
end
end
end
describe "#error" do
subject(:error) { inspector.error }
context "when there are no errors" do
it "returns nothing" do
expect(error).to be_blank
end
end
context "when the model step is failing" do
before do
class DummyService
def fetch_model
false
end
end
end
it "returns an error related to the model" do
expect(error).to match(/Model not found/)
end
end
context "when the contract step is failing" do
let(:parameter) { nil }
it "returns an error related to the contract" do
expect(error).to match(/ActiveModel::Error attribute=parameter, type=blank, options={}/)
end
end
context "when a common step is failing" do
before { result["result.step.final_step"].fail(error: "my error") }
it "returns an error related to the step" do
expect(error).to eq("my error")
end
end
end
end
+4 -1
View File
@@ -22,4 +22,7 @@ module ChatSystemHelpers
end
end
RSpec.configure { |config| config.include ChatSystemHelpers, type: :system }
RSpec.configure do |config|
config.include ChatSystemHelpers, type: :system
config.include Chat::ServiceMatchers
end
@@ -170,12 +170,7 @@ RSpec.describe Chat::Api::ChatChannelsController do
before { sign_in(current_user) }
it "returns an error" do
delete "/chat/api/channels/#{channel_1.id}",
params: {
channel: {
name_confirmation: channel_1.title(current_user),
},
}
delete "/chat/api/channels/#{channel_1.id}"
expect(response.status).to eq(403)
end
@@ -190,38 +185,15 @@ RSpec.describe Chat::Api::ChatChannelsController do
before { channel_1.destroy! }
it "returns an error" do
delete "/chat/api/channels/#{channel_1.id}",
params: {
channel: {
name_confirmation: channel_1.title(current_user),
},
}
delete "/chat/api/channels/#{channel_1.id}"
expect(response.status).to eq(404)
end
end
context "when the confirmation doesnt match the channel name" do
it "returns an error" do
delete "/chat/api/channels/#{channel_1.id}",
params: {
channel: {
name_confirmation: channel_1.title(current_user) + "foo",
},
}
expect(response.status).to eq(400)
end
end
context "with valid params" do
it "properly destroys the channel" do
delete "/chat/api/channels/#{channel_1.id}",
params: {
channel: {
name_confirmation: channel_1.title(current_user),
},
}
delete "/chat/api/channels/#{channel_1.id}"
expect(response.status).to eq(200)
expect(channel_1.reload.trashed?).to eq(true)
@@ -243,14 +215,7 @@ RSpec.describe Chat::Api::ChatChannelsController do
freeze_time(DateTime.parse("2022-07-08 09:30:00"))
old_slug = channel_1.slug
delete(
"/chat/api/channels/#{channel_1.id}",
params: {
channel: {
name_confirmation: channel_1.title(current_user),
},
},
)
delete "/chat/api/channels/#{channel_1.id}"
expect(response.status).to eq(200)
expect(channel_1.reload.slug).to eq(
@@ -371,7 +336,13 @@ RSpec.describe Chat::Api::ChatChannelsController do
before { sign_in(Fabricate(:user)) }
it "returns a 403" do
put "/chat/api/channels/#{channel.id}"
put "/chat/api/channels/#{channel.id}",
params: {
channel: {
name: "joffrey",
description: "cat owner",
},
}
expect(response.status).to eq(403)
end
@@ -400,7 +371,7 @@ RSpec.describe Chat::Api::ChatChannelsController do
it "nullifies the field and doesnt store an empty string" do
put "/chat/api/channels/#{channel.id}", params: { channel: { name: " " } }
expect(channel.reload.name).to be_nil
expect(channel.reload.name).to eq(nil)
end
it "doesnt nullify the description" do
@@ -421,7 +392,7 @@ RSpec.describe Chat::Api::ChatChannelsController do
it "nullifies the field and doesnt store an empty string" do
put "/chat/api/channels/#{channel.id}", params: { channel: { description: " " } }
expect(channel.reload.description).to be_nil
expect(channel.reload.description).to eq(nil)
end
it "doesnt nullify the name" do
@@ -0,0 +1,54 @@
# frozen_string_literal: true
RSpec.describe(Chat::Service::TrashChannel) do
subject(:result) { described_class.call(guardian: guardian) }
let(:guardian) { Guardian.new(current_user) }
context "when channel_id is not provided" do
fab!(:current_user) { Fabricate(:admin) }
it { is_expected.to fail_to_find_a_model(:channel) }
end
context "when channel_id is provided" do
subject(:result) { described_class.call(channel_id: channel.id, guardian: guardian) }
fab!(:channel) { Fabricate(:chat_channel) }
context "when user is not allowed to perform the action" do
fab!(:current_user) { Fabricate(:user) }
it { is_expected.to fail_a_policy(:invalid_access) }
end
context "when user is allowed to perform the action" do
fab!(:current_user) { Fabricate(:admin) }
it "sets the service result as successful" do
expect(result).to be_a_success
end
it "trashes the channel" do
expect(result[:channel]).to be_trashed
end
it "logs the action" do
expect { result }.to change { UserHistory.count }.by(1)
expect(UserHistory.last).to have_attributes(
custom_type: "chat_channel_delete",
details:
"chat_channel_id: #{result[:channel].id}\nchat_channel_name: #{result[:channel].title(guardian.user)}",
)
end
it "changes the slug to prevent colisions" do
expect(result[:channel].slug).to include("deleted")
end
it "queues a job to delete channel relations" do
expect { result }.to change(Jobs::ChatChannelDelete.jobs, :size).by(1)
end
end
end
end
@@ -0,0 +1,107 @@
# frozen_string_literal: true
RSpec.describe Chat::Service::UpdateChannel do
subject(:result) { described_class.call(guardian: guardian, channel_id: channel.id, **params) }
fab!(:channel) { Fabricate(:chat_channel) }
fab!(:current_user) { Fabricate(:admin) }
let(:guardian) { Guardian.new(current_user) }
let(:params) do
{
name: "cool channel",
description: "a channel description",
slug: "snail",
allow_channel_wide_mentions: true,
auto_join_users: false,
}
end
context "when the user cannot edit the channel" do
fab!(:current_user) { Fabricate(:user) }
it { is_expected.to fail_a_policy(:check_channel_permission) }
end
context "when the user tries to edit a DM channel" do
fab!(:channel) { Fabricate(:direct_message_channel, users: [current_user, Fabricate(:user)]) }
it { is_expected.to fail_a_policy(:no_direct_message_channel) }
end
context "when channel is a category one" do
context "when a valid user provides valid params" do
let(:message) do
MessageBus.track_publish(ChatPublisher::CHANNEL_EDITS_MESSAGE_BUS_CHANNEL) { result }.first
end
it "sets the service result as successful" do
expect(result).to be_a_success
end
it "updates the channel accordingly" do
result
expect(channel.reload).to have_attributes(
name: "cool channel",
slug: "snail",
description: "a channel description",
allow_channel_wide_mentions: true,
auto_join_users: false,
)
end
it "publishes a MessageBus message" do
expect(message.data).to eq(
{
chat_channel_id: channel.id,
name: "cool channel",
description: "a channel description",
slug: "snail",
},
)
end
context "when the name is blank" do
before { params[:name] = "" }
it "nils out the name" do
result
expect(channel.reload.name).to be_nil
end
end
context "when the description is blank" do
before do
channel.update!(description: "something")
params[:description] = ""
end
it "nils out the description" do
result
expect(channel.reload.description).to be_nil
end
end
context "when auto_join_users is set to 'true'" do
before do
channel.update!(auto_join_users: false)
params[:auto_join_users] = true
end
it "updates the model accordingly" do
result
expect(channel.reload).to have_attributes(auto_join_users: true)
end
it "auto joins users" do
expect_enqueued_with(
job: :auto_manage_channel_memberships,
args: {
chat_channel_id: channel.id,
},
) { result }
end
end
end
end
end
@@ -0,0 +1,54 @@
# frozen_string_literal: true
RSpec.describe(Chat::Service::UpdateChannelStatus) do
subject(:result) do
described_class.call(guardian: guardian, channel_id: channel.id, status: status)
end
fab!(:channel) { Fabricate(:chat_channel) }
fab!(:current_user) { Fabricate(:admin) }
let(:guardian) { Guardian.new(current_user) }
let(:status) { "open" }
context "when no channel_id is given" do
subject(:result) { described_class.call(guardian: guardian, status: status) }
it { is_expected.to fail_to_find_a_model(:channel) }
end
context "when user is not allowed to change channel status" do
fab!(:current_user) { Fabricate(:user) }
it { is_expected.to fail_a_policy(:check_channel_permission) }
end
context "when status is not allowed" do
(ChatChannel.statuses.keys - ChatChannel.editable_statuses.keys).each do |na_status|
context "when status is '#{na_status}'" do
let(:status) { na_status }
it { is_expected.to fail_a_contract }
end
end
end
context "when new status is the same than the existing one" do
let(:status) { channel.status }
it { is_expected.to fail_a_policy(:check_channel_permission) }
end
context "when status is allowed" do
let(:status) { "closed" }
it "sets the service result as successful" do
expect(result).to be_a_success
end
it "changes the status" do
result
expect(channel.reload).to be_closed
end
end
end
@@ -0,0 +1,118 @@
# frozen_string_literal: true
RSpec.describe(Chat::Service::UpdateUserLastRead) do
subject(:result) { described_class.call(params) }
fab!(:current_user) { Fabricate(:user) }
fab!(:channel) { Fabricate(:chat_channel) }
fab!(:membership) do
Fabricate(:user_chat_channel_membership, user: current_user, chat_channel: channel)
end
fab!(:message_1) { Fabricate(:chat_message, chat_channel: membership.chat_channel) }
let(:guardian) { Guardian.new(current_user) }
let(:params) do
{
guardian: guardian,
user_id: current_user.id,
channel_id: channel.id,
message_id: message_1.id,
}
end
context "when channel_id is not provided" do
before { params.delete(:channel_id) }
it { is_expected.to fail_to_find_a_model(:membership) }
end
context "when user_id is not provided" do
before { params.delete(:user_id) }
it { is_expected.to fail_to_find_a_model(:membership) }
end
context "when user has no membership" do
before { membership.destroy! }
it { is_expected.to fail_to_find_a_model(:membership) }
end
context "when user cant access the channel" do
fab!(:membership) do
Fabricate(
:user_chat_channel_membership,
user: current_user,
chat_channel: Fabricate(:private_category_channel),
)
end
before { params[:channel_id] = membership.chat_channel.id }
it { is_expected.to fail_a_policy(:invalid_access) }
end
context "when message_id is older than membership's last_read_message_id" do
before do
params[:message_id] = -2
membership.update!(last_read_message_id: -1)
end
it { is_expected.to fail_a_policy(:ensure_message_id_recency) }
end
context "when message doesnt exist" do
before do
params[:message_id] = 2
membership.update!(last_read_message_id: 1)
end
it { is_expected.to fail_a_policy(:ensure_message_exists) }
end
context "when params are valid" do
before { Jobs.run_immediately! }
it "sets the service result as successful" do
expect(result).to be_a_success
end
it "updates the last_read message id" do
expect { result }.to change { membership.reload.last_read_message_id }.to(message_1.id)
end
it "marks existing notifications related to the message as read" do
expect {
notification =
Fabricate(
:notification,
notification_type: Notification.types[:chat_mention],
user: current_user,
)
# FIXME: we need a better way to create proper chat mention
ChatMention.create!(notification: notification, user: current_user, chat_message: message_1)
}.to change {
Notification.where(
notification_type: Notification.types[:chat_mention],
user: current_user,
read: false,
).count
}.by(1)
expect { result }.to change {
Notification.where(
notification_type: Notification.types[:chat_mention],
user: current_user,
read: false,
).count
}.by(-1)
end
it "publishes new last read to clients" do
messages = MessageBus.track_publish { result }
expect(messages.map(&:channel)).to include("/chat/user-tracking-state/#{current_user.id}")
end
end
end
@@ -0,0 +1,135 @@
# frozen_string_literal: true
module Chat
module ServiceMatchers
class FailStep
attr_reader :name, :result
def initialize(name)
@name = name
end
def matches?(result)
@result = result
step_exists? && step_failed? && service_failed?
end
def failure_message
message =
if !step_exists?
"Expected #{type} '#{name}' (key: '#{step}') was not found in the result object."
elsif !step_failed?
"Expected #{type} '#{name}' (key: '#{step}') to fail but it succeeded."
else
"expected the service to fail but it succeeded."
end
error_message_with_inspection(message)
end
def failure_message_when_negated
message = "Expected #{type} '#{name}' (key: '#{step}') to succeed but it failed."
error_message_with_inspection(message)
end
private
def step_exists?
result[step].present?
end
def step_failed?
result[step].failure?
end
def service_failed?
result.failure?
end
def type
"step"
end
def error_message_with_inspection(message)
inspector = StepsInspector.new(result)
"#{message}\n\n#{inspector.inspect}\n\n#{inspector.error}"
end
end
class FailContract < FailStep
attr_reader :error_message
def step
"result.contract.#{name}"
end
def type
"contract"
end
def matches?(service)
super && has_error?
end
def has_error?
result[step].errors.present?
end
def failure_message
return "expected contract '#{step}' to have errors" unless has_error?
super
end
def description
"fail a contract named '#{name}'"
end
end
class FailPolicy < FailStep
def type
"policy"
end
def step
"result.policy.#{name}"
end
def description
"fail a policy named '#{name}'"
end
end
class FailToFindModel < FailStep
def type
"model"
end
def step
"result.model.#{name}"
end
def description
"fail to find a model named '#{name}'"
end
end
def fail_a_policy(name)
FailPolicy.new(name)
end
def fail_a_contract(name = "default")
FailContract.new(name)
end
def fail_to_find_a_model(name = "model")
FailToFindModel.new(name)
end
def inspect_steps(result)
inspector = Chat::StepsInspector.new(result)
puts "Steps:"
puts inspector.inspect
puts "\nFirst error:"
puts inspector.error
end
end
end