diff --git a/app/helpers/with_service_helper.rb b/app/helpers/with_service_helper.rb new file mode 100644 index 00000000000..c7e36ee5f66 --- /dev/null +++ b/app/helpers/with_service_helper.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module WithServiceHelper + def result + @_result + end + + # @param service [Class] A class including {Service::Base} + # @param dependencies [kwargs] Any additional params to load into the service context, + # in addition to controller @params. + def with_service(service, **dependencies, &block) + object = self + ServiceRunner.call( + service, + object, + **dependencies, + &proc { instance_exec(&(block || proc {})) } + ) + end + + def run_service(service, dependencies) + params = self.try(:params) || ActionController::Parameters.new + + @_result = + service.call(params.to_unsafe_h.merge(guardian: self.try(:guardian) || nil, **dependencies)) + end +end diff --git a/plugins/chat/app/services/service.rb b/app/services/service.rb similarity index 100% rename from plugins/chat/app/services/service.rb rename to app/services/service.rb diff --git a/plugins/chat/app/services/service/base.rb b/app/services/service/base.rb similarity index 99% rename from plugins/chat/app/services/service/base.rb rename to app/services/service/base.rb index dab2f1403e2..6c8f608b7fe 100644 --- a/plugins/chat/app/services/service/base.rb +++ b/app/services/service/base.rb @@ -61,7 +61,7 @@ module Service end def inspect_steps - Chat::StepsInspector.new(self) + StepsInspector.new(self) end private diff --git a/lib/steps_inspector.rb b/lib/steps_inspector.rb new file mode 100644 index 00000000000..80b5438563d --- /dev/null +++ b/lib/steps_inspector.rb @@ -0,0 +1,124 @@ +# frozen_string_literal: true + +# = StepsInspector +# +# This class takes a {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 + "#{result_emoji}#{unexpected_result_emoji}" + end + + def steps + [self] + end + + def inspect + "#{" " * nesting_level}[#{type}] '#{name}' #{emoji}".rstrip + end + + private + + def step_result + result["result.#{type}.#{name}"] + end + + def result_emoji + return "❌" if failure? + return "✅" if success? + "" + end + + def unexpected_result_emoji + " ⚠️#{unexpected_result_text}" if step_result.try(:[], "spec.unexpected_result") + end + + def unexpected_result_text + return " <= expected to return true but got false instead" if failure? + " <= expected to return false but got true instead" + end + end + + # @!visibility private + class Model < Step + def error + return result[name].errors.inspect if step_result.invalid + 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 + def error + step_result.reason + end + 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 diff --git a/plugins/chat/app/controllers/chat/api_controller.rb b/plugins/chat/app/controllers/chat/api_controller.rb index a3d0018d5ea..96ac3e9bcdd 100644 --- a/plugins/chat/app/controllers/chat/api_controller.rb +++ b/plugins/chat/app/controllers/chat/api_controller.rb @@ -2,6 +2,6 @@ module Chat class ApiController < ::Chat::BaseController - include Chat::WithServiceHelper + include WithServiceHelper end end diff --git a/plugins/chat/app/controllers/chat/incoming_webhooks_controller.rb b/plugins/chat/app/controllers/chat/incoming_webhooks_controller.rb index 2180da1fca1..df97db10270 100644 --- a/plugins/chat/app/controllers/chat/incoming_webhooks_controller.rb +++ b/plugins/chat/app/controllers/chat/incoming_webhooks_controller.rb @@ -2,7 +2,7 @@ module Chat class IncomingWebhooksController < ::ApplicationController - include Chat::WithServiceHelper + include WithServiceHelper requires_plugin Chat::PLUGIN_NAME diff --git a/plugins/chat/app/helpers/chat/with_service_helper.rb b/plugins/chat/app/helpers/chat/with_service_helper.rb deleted file mode 100644 index 7d273a2ac1e..00000000000 --- a/plugins/chat/app/helpers/chat/with_service_helper.rb +++ /dev/null @@ -1,28 +0,0 @@ -# frozen_string_literal: true -module Chat - module WithServiceHelper - def result - @_result - end - - # @param service [Class] A class including {Chat::Service::Base} - # @param dependencies [kwargs] Any additional params to load into the service context, - # in addition to controller @params. - def with_service(service, **dependencies, &block) - object = self - ServiceRunner.call( - service, - object, - **dependencies, - &proc { instance_exec(&(block || proc {})) } - ) - end - - def run_service(service, dependencies) - params = self.try(:params) || ActionController::Parameters.new - - @_result = - service.call(params.to_unsafe_h.merge(guardian: self.try(:guardian) || nil, **dependencies)) - end - end -end diff --git a/plugins/chat/app/jobs/service_job.rb b/plugins/chat/app/jobs/service_job.rb index e2af50f8641..3f2ca9aa598 100644 --- a/plugins/chat/app/jobs/service_job.rb +++ b/plugins/chat/app/jobs/service_job.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true class ServiceJob < ::Jobs::Base - include Chat::WithServiceHelper + include WithServiceHelper def run_service(service, dependencies) @_result = service.call(dependencies) diff --git a/plugins/chat/lib/chat/steps_inspector.rb b/plugins/chat/lib/chat/steps_inspector.rb deleted file mode 100644 index 2cc40a0c1f6..00000000000 --- a/plugins/chat/lib/chat/steps_inspector.rb +++ /dev/null @@ -1,129 +0,0 @@ -# frozen_string_literal: true - -module Chat - # = Chat::StepsInspector - # - # This class takes a {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 - "#{result_emoji}#{unexpected_result_emoji}" - end - - def steps - [self] - end - - def inspect - "#{" " * nesting_level}[#{type}] '#{name}' #{emoji}".rstrip - end - - private - - def step_result - result["result.#{type}.#{name}"] - end - - def result_emoji - return "❌" if failure? - return "✅" if success? - "" - end - - def unexpected_result_emoji - " ⚠️#{unexpected_result_text}" if step_result.try(:[], "spec.unexpected_result") - end - - def unexpected_result_text - return " <= expected to return true but got false instead" if failure? - " <= expected to return false but got true instead" - end - end - - # @!visibility private - class Model < Step - def error - return result[name].errors.inspect if step_result.invalid - 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 - def error - step_result.reason - end - 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 diff --git a/plugins/chat/lib/chat_sdk/channel.rb b/plugins/chat/lib/chat_sdk/channel.rb index 65740b5d365..23b23e75110 100644 --- a/plugins/chat/lib/chat_sdk/channel.rb +++ b/plugins/chat/lib/chat_sdk/channel.rb @@ -2,7 +2,7 @@ module ChatSDK class Channel - include Chat::WithServiceHelper + include WithServiceHelper # Retrieves messages from a specified channel. # diff --git a/plugins/chat/lib/chat_sdk/message.rb b/plugins/chat/lib/chat_sdk/message.rb index ea6c7f8889f..aeac2c03036 100644 --- a/plugins/chat/lib/chat_sdk/message.rb +++ b/plugins/chat/lib/chat_sdk/message.rb @@ -2,7 +2,7 @@ module ChatSDK class Message - include Chat::WithServiceHelper + include WithServiceHelper # Creates a new message in a chat channel. # @@ -159,7 +159,7 @@ module ChatSDK end class StreamHelper - include Chat::WithServiceHelper + include WithServiceHelper attr_reader :message attr_reader :guardian diff --git a/plugins/chat/lib/chat_sdk/thread.rb b/plugins/chat/lib/chat_sdk/thread.rb index 14e08c45bc7..f7aabbd895d 100644 --- a/plugins/chat/lib/chat_sdk/thread.rb +++ b/plugins/chat/lib/chat_sdk/thread.rb @@ -2,7 +2,7 @@ module ChatSDK class Thread - include Chat::WithServiceHelper + include WithServiceHelper # Updates the title of a specified chat thread. # diff --git a/plugins/chat/spec/plugin_helper.rb b/plugins/chat/spec/plugin_helper.rb index f14c71429f5..670edb0a680 100644 --- a/plugins/chat/spec/plugin_helper.rb +++ b/plugins/chat/spec/plugin_helper.rb @@ -130,8 +130,8 @@ end RSpec.configure do |config| config.include ChatSystemHelpers, type: :system config.include ChatSpecHelpers - config.include Chat::WithServiceHelper - config.include Chat::ServiceMatchers + config.include WithServiceHelper + config.include ServiceMatchers config.expect_with :rspec do |c| # Or a very large value, if you do want to truncate at some point diff --git a/plugins/chat/spec/support/chat_service_matcher.rb b/plugins/chat/spec/support/chat_service_matcher.rb deleted file mode 100644 index 77c0cbd262e..00000000000 --- a/plugins/chat/spec/support/chat_service_matcher.rb +++ /dev/null @@ -1,153 +0,0 @@ -# frozen_string_literal: true - -module Chat - module ServiceMatchers - class RunServiceSuccessfully - attr_reader :result - - def matches?(result) - @result = result - result.success? - end - - def failure_message - inspector = StepsInspector.new(result) - "Expected to run the service sucessfully but failed:\n\n#{inspector.inspect}\n\n#{inspector.error}" - end - end - - 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 - set_unexpected_result - 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 - set_unexpected_result - message = "Expected #{type} '#{name}' (key: '#{step}') to succeed but it failed." - error_message_with_inspection(message) - end - - def description - "fail a #{type} named '#{name}'" - end - - private - - def step_exists? - result[step].present? - end - - def step_failed? - result[step].failure? - end - - def service_failed? - result.failure? - end - - def type - self.class.name.split("::").last.sub("Fail", "").downcase - end - - def step - "result.#{type}.#{name}" - end - - def error_message_with_inspection(message) - inspector = StepsInspector.new(result) - "#{message}\n\n#{inspector.inspect}\n\n#{inspector.error}" - end - - def set_unexpected_result - return unless result[step] - result[step]["spec.unexpected_result"] = true - end - end - - class FailContract < FailStep - end - - class FailPolicy < FailStep - end - - class FailToFindModel < FailStep - def type - "model" - end - - def description - "fail to find a model named '#{name}'" - end - - def step_failed? - super && result[name].blank? - end - end - - class FailWithInvalidModel < FailStep - def type - "model" - end - - def description - "fail to have a valid model named '#{name}'" - end - - def step_failed? - super && result[step].invalid - end - end - - 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 fail_with_an_invalid_model(name = "model") - FailWithInvalidModel.new(name) - end - - def fail_a_step(name = "model") - FailStep.new(name) - end - - def run_service_successfully - RunServiceSuccessfully.new - end - - def inspect_steps(result) - inspector = Chat::StepsInspector.new(result) - puts "Steps:" - puts inspector.inspect - puts "\nFirst error:" - puts inspector.error - end - end -end diff --git a/plugins/chat/spec/lib/chat/steps_inspector_spec.rb b/spec/lib/steps_inspector_spec.rb similarity index 99% rename from plugins/chat/spec/lib/chat/steps_inspector_spec.rb rename to spec/lib/steps_inspector_spec.rb index 51b956d9cb6..534d0356140 100644 --- a/plugins/chat/spec/lib/chat/steps_inspector_spec.rb +++ b/spec/lib/steps_inspector_spec.rb @@ -1,6 +1,6 @@ # frozen_string_literal: true -RSpec.describe Chat::StepsInspector do +RSpec.describe StepsInspector do class DummyService include Service::Base diff --git a/spec/support/service_matchers.rb b/spec/support/service_matchers.rb new file mode 100644 index 00000000000..e75e22ea350 --- /dev/null +++ b/spec/support/service_matchers.rb @@ -0,0 +1,151 @@ +# frozen_string_literal: true + +module ServiceMatchers + class RunServiceSuccessfully + attr_reader :result + + def matches?(result) + @result = result + result.success? + end + + def failure_message + inspector = StepsInspector.new(result) + "Expected to run the service sucessfully but failed:\n\n#{inspector.inspect}\n\n#{inspector.error}" + end + end + + 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 + set_unexpected_result + 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 + set_unexpected_result + message = "Expected #{type} '#{name}' (key: '#{step}') to succeed but it failed." + error_message_with_inspection(message) + end + + def description + "fail a #{type} named '#{name}'" + end + + private + + def step_exists? + result[step].present? + end + + def step_failed? + result[step].failure? + end + + def service_failed? + result.failure? + end + + def type + self.class.name.split("::").last.sub("Fail", "").downcase + end + + def step + "result.#{type}.#{name}" + end + + def error_message_with_inspection(message) + inspector = StepsInspector.new(result) + "#{message}\n\n#{inspector.inspect}\n\n#{inspector.error}" + end + + def set_unexpected_result + return unless result[step] + result[step]["spec.unexpected_result"] = true + end + end + + class FailContract < FailStep + end + + class FailPolicy < FailStep + end + + class FailToFindModel < FailStep + def type + "model" + end + + def description + "fail to find a model named '#{name}'" + end + + def step_failed? + super && result[name].blank? + end + end + + class FailWithInvalidModel < FailStep + def type + "model" + end + + def description + "fail to have a valid model named '#{name}'" + end + + def step_failed? + super && result[step].invalid + end + end + + 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 fail_with_an_invalid_model(name = "model") + FailWithInvalidModel.new(name) + end + + def fail_a_step(name = "model") + FailStep.new(name) + end + + def run_service_successfully + RunServiceSuccessfully.new + end + + def inspect_steps(result) + inspector = StepsInspector.new(result) + puts "Steps:" + puts inspector.inspect + puts "\nFirst error:" + puts inspector.error + end +end