From 7c15ba5709d4d3a4ceeb812b09fdcc57d8277acc Mon Sep 17 00:00:00 2001 From: Arkadiy Zabazhanov Date: Sat, 29 Nov 2025 13:41:01 +0700 Subject: [PATCH] Replace the main transaction with OmniService::Transaction --- .github/workflows/ci.yml | 20 +- .rubocop.yml | 2 +- .rubocop_todo.yml | 43 +++-- Appraisals | 3 +- README.md | 24 +-- gemfiles/rails.6.0.gemfile | 15 -- gemfiles/rails.6.1.gemfile | 15 -- gemfiles/rails.7.0.gemfile | 15 -- gemfiles/rails.7.1.gemfile | 15 -- gemfiles/rails.7.2.gemfile | 1 - gemfiles/rails.8.0.gemfile | 1 - .../{rails.5.2.gemfile => rails.8.1.gemfile} | 5 +- lib/operations.rb | 24 +-- lib/operations/command.rb | 177 ++++++++++++++++-- lib/operations/components/base.rb | 6 +- lib/operations/components/callback.rb | 55 ------ lib/operations/components/idempotency.rb | 20 +- lib/operations/components/on_failure.rb | 16 -- lib/operations/components/on_success.rb | 35 ---- lib/operations/configuration.rb | 15 -- lib/operations/convenience.rb | 8 +- lib/operations/form.rb | 4 +- lib/operations/form/base.rb | 12 +- lib/operations/form/builder.rb | 2 +- lib/operations/result.rb | 2 +- operations.gemspec | 14 +- spec/fixtures/locale.yml | 2 + spec/operations/command_spec.rb | 167 +++++++++++++---- .../operations/components/idempotency_spec.rb | 22 +-- spec/operations/components/on_failure_spec.rb | 80 -------- spec/operations/components/on_success_spec.rb | 77 -------- spec/operations/configuration_spec.rb | 16 -- spec/operations_spec.rb | 9 - 33 files changed, 357 insertions(+), 565 deletions(-) delete mode 100644 gemfiles/rails.6.0.gemfile delete mode 100644 gemfiles/rails.6.1.gemfile delete mode 100644 gemfiles/rails.7.0.gemfile delete mode 100644 gemfiles/rails.7.1.gemfile rename gemfiles/{rails.5.2.gemfile => rails.8.1.gemfile} (80%) delete mode 100644 lib/operations/components/callback.rb delete mode 100644 lib/operations/components/on_failure.rb delete mode 100644 lib/operations/components/on_success.rb delete mode 100644 lib/operations/configuration.rb delete mode 100644 spec/operations/components/on_failure_spec.rb delete mode 100644 spec/operations/components/on_success_spec.rb delete mode 100644 spec/operations/configuration_spec.rb diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ffc670f..da4f6d5 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,18 +1,20 @@ name: CI -on: [push] + +on: + push: + branches: + - main + pull_request: + jobs: rspec: strategy: fail-fast: false matrix: include: - - { ruby: '2.7', rails: '5.2' } - - { ruby: '2.7', rails: '6.0' } - - { ruby: '3.0', rails: '6.1' } - - { ruby: '3.1', rails: '7.0' } - - { ruby: '3.2', rails: '7.1' } - - { ruby: '3.3', rails: '7.2' } - - { ruby: '3.4', rails: '8.0' } + - { ruby: '3.2', rails: '7.2' } + - { ruby: '3.3', rails: '8.0' } + - { ruby: '3.4', rails: '8.1' } runs-on: ubuntu-latest env: BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/rails.${{ matrix.rails }}.gemfile @@ -30,6 +32,6 @@ jobs: - uses: actions/checkout@v2 - uses: ruby/setup-ruby@v1 with: - ruby-version: 2.7 + ruby-version: 3.2 bundler-cache: true - run: bundle exec rubocop diff --git a/.rubocop.yml b/.rubocop.yml index 86175c2..6160b4c 100644 --- a/.rubocop.yml +++ b/.rubocop.yml @@ -16,6 +16,6 @@ inherit_mode: - Exclude AllCops: - TargetRubyVersion: 2.7 + TargetRubyVersion: 3.2 Exclude: - gemfiles/* diff --git a/.rubocop_todo.yml b/.rubocop_todo.yml index 4d0f436..3dbd128 100644 --- a/.rubocop_todo.yml +++ b/.rubocop_todo.yml @@ -1,41 +1,48 @@ # This configuration was generated by -# `rubocop --auto-gen-config` -# on 2024-09-27 05:26:10 UTC using RuboCop version 1.65.0. +# `rubocop --auto-gen-config --auto-gen-only-exclude --exclude-limit 100` +# on 2025-11-29 07:18:24 UTC using RuboCop version 1.81.7. # The point is for the user to remove these configuration records # one by one as the offenses are removed from the code base. # Note that changes in the inspected code, or installation of new # versions of RuboCop, may require this file to be generated again. # Offense count: 3 -# Configuration parameters: EnforcedStyle, AllowedGems, Include. +# Configuration parameters: EnforcedStyle, AllowedGems. # SupportedStyles: Gemfile, gems.rb, gemspec -# Include: **/*.gemspec, **/Gemfile, **/gems.rb Gemspec/DevelopmentDependencies: Exclude: - 'operations.gemspec' -# Offense count: 5 -# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes. +# Offense count: 6 +# Configuration parameters: AllowedMethods, AllowedPatterns, CountRepeatedAttributes, Max. Metrics/AbcSize: - Max: 28 + Exclude: + - 'lib/operations/command.rb' + - 'lib/operations/form/base.rb' + - 'lib/operations/form/builder.rb' # Offense count: 1 -# Configuration parameters: CountComments, CountAsOne. +# Configuration parameters: CountComments, Max, CountAsOne. Metrics/ClassLength: - Max: 149 + Exclude: + - 'lib/operations/command.rb' + +# Offense count: 2 +# Configuration parameters: CountComments, Max, CountAsOne, AllowedMethods, AllowedPatterns. +Metrics/MethodLength: + Exclude: + - 'lib/operations/components/idempotency.rb' + - 'lib/operations/form/base.rb' # Offense count: 1 -# Configuration parameters: CountComments, CountAsOne. +# Configuration parameters: CountComments, Max, CountAsOne. Metrics/ModuleLength: - Max: 144 + Exclude: + - 'lib/operations/form/base.rb' # Offense count: 3 -# Configuration parameters: CountKeywordArgs, MaxOptionalParameters. +# Configuration parameters: Max, CountKeywordArgs, MaxOptionalParameters. Metrics/ParameterLists: - Max: 7 - -# Offense count: 1 -# Configuration parameters: IgnoreNameless, IgnoreSymbolicNames. -RSpec/VerifiedDoubles: Exclude: - - 'spec/operations/form_spec.rb' + - 'lib/operations/command.rb' + - 'lib/operations/form/builder.rb' diff --git a/Appraisals b/Appraisals index d0607a0..571acd7 100644 --- a/Appraisals +++ b/Appraisals @@ -1,9 +1,8 @@ # frozen_string_literal: true -%w[5.2 6.0 6.1 7.0 7.1 7.2 8.0].each do |version| +%w[7.2 8.0 8.1].each do |version| appraise "rails.#{version}" do gem "activerecord", "~> #{version}.0" gem "activesupport", "~> #{version}.0" - gem "sqlite3", version > "7.0" ? "~> 2.1" : "~> 1.4" end end diff --git a/README.md b/README.md index 464daed..fe5f8d7 100644 --- a/README.md +++ b/README.md @@ -108,7 +108,7 @@ Where `Operations::Contract` is actually a [Dry::Validation::Contract](https://d Everything in the framework is built with the composition over inheritance approach in mind. An instance of `Operations::Command` essentially runs a pipeline through the steps passed into the initializer. In this particular case, the passed parameters will be validated by the contract and if everything is good, will be passed into the operation body. -**Important:** the whole operation pipeline (except [callbacks](#callbacks-on-success-on-failure)) is wrapped within a transaction by default. This behavior can be adjusted by changing `Operations::Configuration#transaction` (see [Configuration](#configuration) section). +**Important:** the whole operation pipeline (except [callbacks](#callbacks-on-success-on-failure)) is wrapped within a transaction by default. ### Operation Result @@ -353,24 +353,6 @@ end Of course, it is possible to use [dry-auto_inject](https://dry-rb.org/gems/dry-auto_inject/) along with [dry-container](https://dry-rb.org/gems/dry-container/) to make things even fancier. -### Configuration - -The gem has a global default configuration: - -```ruby -Operations.configure( - error_reporter: -> (message, payload) { Sentry.capture_message(message, extra: payload) }, -) -``` - -But also, a configuration instance can be passed directly to a Command initializer (for example, to switch off the wrapping DB transaction for a single operation): - -```ruby -Operations::Command.new(..., configuration: Operations.default_config.new(transaction: -> {})) -``` - -It is possible to call `configuration_instance.new` to receive an updated configuration instance since it is a `Dry::Struct` - ### Preconditions When we need to check against the application state, preconditions are coming to help. Obviously, we can do all those checks in Contract rule definitions but it is great to have separate kinds of components (a separate stage in the pipeline) for this particular reason as it gives the ability to check them in isolation. @@ -701,9 +683,7 @@ Sometimes we need to run further application state modifications outside of the The key difference besides one running after operation success and another - after failure, is that `on_success` runs after the transaction commit. This means that if one operation calls another operation inside of it and the inner one has `on_success` callbacks defined - the callbacks are going to be executed only after the outermost transaction is committed successfully. -To achieve this, the framework utilizes the [after_commit_everywhere](https://github.com/Envek/after_commit_everywhere) gem and the behavior is configurable using `Operations::Configuration#after_commit` option. - -It is a good idea to use these callbacks to schedule some jobs instead of just running inline code since if callback execution fails - the failure will be ignored and the operation is still going to be successful. Though the failure from both callbacks will be reported using `Operations::Configuration#error_reporter` and using Sentry by default. +Transaction handling and callback scheduling are managed by [OmniService](https://rubygems.org/gems/omni_service), which uses Rails' native `transaction.after_commit` mechanism (requires Rails 7.2+). ```ruby class Comment::Update diff --git a/gemfiles/rails.6.0.gemfile b/gemfiles/rails.6.0.gemfile deleted file mode 100644 index be371f5..0000000 --- a/gemfiles/rails.6.0.gemfile +++ /dev/null @@ -1,15 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "bookingsync-rubocop", require: false, github: "BookingSync/bookingsync-rubocop", branch: "main" -gem "rspec" -gem "rubocop", require: false -gem "rubocop-performance", require: false -gem "rubocop-rails", require: false -gem "rubocop-rspec", require: false -gem "activerecord", "~> 6.0.0" -gem "activesupport", "~> 6.0.0" -gem "sqlite3", "~> 1.4" - -gemspec path: "../" diff --git a/gemfiles/rails.6.1.gemfile b/gemfiles/rails.6.1.gemfile deleted file mode 100644 index 70dfe7b..0000000 --- a/gemfiles/rails.6.1.gemfile +++ /dev/null @@ -1,15 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "bookingsync-rubocop", require: false, github: "BookingSync/bookingsync-rubocop", branch: "main" -gem "rspec" -gem "rubocop", require: false -gem "rubocop-performance", require: false -gem "rubocop-rails", require: false -gem "rubocop-rspec", require: false -gem "activerecord", "~> 6.1.0" -gem "activesupport", "~> 6.1.0" -gem "sqlite3", "~> 1.4" - -gemspec path: "../" diff --git a/gemfiles/rails.7.0.gemfile b/gemfiles/rails.7.0.gemfile deleted file mode 100644 index 299c385..0000000 --- a/gemfiles/rails.7.0.gemfile +++ /dev/null @@ -1,15 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "bookingsync-rubocop", require: false, github: "BookingSync/bookingsync-rubocop", branch: "main" -gem "rspec" -gem "rubocop", require: false -gem "rubocop-performance", require: false -gem "rubocop-rails", require: false -gem "rubocop-rspec", require: false -gem "activerecord", "~> 7.0.0" -gem "activesupport", "~> 7.0.0" -gem "sqlite3", "~> 1.4" - -gemspec path: "../" diff --git a/gemfiles/rails.7.1.gemfile b/gemfiles/rails.7.1.gemfile deleted file mode 100644 index 7068233..0000000 --- a/gemfiles/rails.7.1.gemfile +++ /dev/null @@ -1,15 +0,0 @@ -# This file was generated by Appraisal - -source "https://rubygems.org" - -gem "bookingsync-rubocop", require: false, github: "BookingSync/bookingsync-rubocop", branch: "main" -gem "rspec" -gem "rubocop", require: false -gem "rubocop-performance", require: false -gem "rubocop-rails", require: false -gem "rubocop-rspec", require: false -gem "activerecord", "~> 7.1.0" -gem "activesupport", "~> 7.1.0" -gem "sqlite3", "~> 2.1" - -gemspec path: "../" diff --git a/gemfiles/rails.7.2.gemfile b/gemfiles/rails.7.2.gemfile index 3cf13b3..b1d5972 100644 --- a/gemfiles/rails.7.2.gemfile +++ b/gemfiles/rails.7.2.gemfile @@ -10,6 +10,5 @@ gem "rubocop-rails", require: false gem "rubocop-rspec", require: false gem "activerecord", "~> 7.2.0" gem "activesupport", "~> 7.2.0" -gem "sqlite3", "~> 2.1" gemspec path: "../" diff --git a/gemfiles/rails.8.0.gemfile b/gemfiles/rails.8.0.gemfile index 4bc1eac..7d3d087 100644 --- a/gemfiles/rails.8.0.gemfile +++ b/gemfiles/rails.8.0.gemfile @@ -10,6 +10,5 @@ gem "rubocop-rails", require: false gem "rubocop-rspec", require: false gem "activerecord", "~> 8.0.0" gem "activesupport", "~> 8.0.0" -gem "sqlite3", "~> 2.1" gemspec path: "../" diff --git a/gemfiles/rails.5.2.gemfile b/gemfiles/rails.8.1.gemfile similarity index 80% rename from gemfiles/rails.5.2.gemfile rename to gemfiles/rails.8.1.gemfile index 644c6b6..63da158 100644 --- a/gemfiles/rails.5.2.gemfile +++ b/gemfiles/rails.8.1.gemfile @@ -8,8 +8,7 @@ gem "rubocop", require: false gem "rubocop-performance", require: false gem "rubocop-rails", require: false gem "rubocop-rspec", require: false -gem "activerecord", "~> 5.2.0" -gem "activesupport", "~> 5.2.0" -gem "sqlite3", "~> 1.4" +gem "activerecord", "~> 8.1.0" +gem "activesupport", "~> 8.1.0" gemspec path: "../" diff --git a/lib/operations.rb b/lib/operations.rb index 4253c60..de50834 100644 --- a/lib/operations.rb +++ b/lib/operations.rb @@ -8,11 +8,10 @@ require "active_support/core_ext/module/delegation" require "active_support/inflector/inflections" require "active_model/naming" -require "after_commit_everywhere" +require "omni_service" require "operations/version" require "operations/types" require "operations/inspect" -require "operations/configuration" require "operations/contract" require "operations/contract/messages_resolver" require "operations/convenience" @@ -26,24 +25,7 @@ # The root gem module module Operations - class Error < StandardError - end - - DEFAULT_ERROR_REPORTER = ->(message, payload) { Sentry.capture_message(message, extra: payload) } - DEFAULT_TRANSACTION = ->(&block) { ActiveRecord::Base.transaction(requires_new: true, &block) } - DEFAULT_AFTER_COMMIT = ->(&block) { AfterCommitEverywhere.after_commit(&block) } - - class << self - attr_reader :default_config - - def configure(configuration = nil, **options) - @default_config = (configuration || Configuration).new(**options) - end - end +end - configure( - error_reporter: DEFAULT_ERROR_REPORTER, - transaction: DEFAULT_TRANSACTION, - after_commit: DEFAULT_AFTER_COMMIT - ) +class Operations::Error < StandardError end diff --git a/lib/operations/command.rb b/lib/operations/command.rb index b2abfca..bb9215f 100644 --- a/lib/operations/command.rb +++ b/lib/operations/command.rb @@ -6,8 +6,6 @@ require "operations/components/preconditions" require "operations/components/idempotency" require "operations/components/operation" -require "operations/components/on_success" -require "operations/components/on_failure" # This is an entry point interface for every operation in # the operations layer. Every operation instance consists of 4 # components: contract, policy, preconditions and operation @@ -131,7 +129,7 @@ class Operations::Command extend Dry::Initializer include Dry::Core::Constants include Dry::Monads[:result] - include Dry::Monads::Do.for(:call_monad, :callable_monad, :validate_monad, :execute_operation) + include Dry::Monads::Do.for(:call_monad, :callable_monad, :validate_monad) include Dry::Equalizer(*COMPONENTS) # Provides message and meaningful sentry context for failed operations @@ -175,7 +173,6 @@ def error_details option :form_base, Operations::Types::Class, default: proc { ::Operations::Form::Base } option :form_class, Operations::Types::Class.optional, default: proc {}, reader: false option :form_hydrator, Operations::Types.Interface(:call), default: proc { FORM_HYDRATOR } - option :configuration, Operations::Configuration, default: proc { Operations.default_config } include Operations::Inspect.new(dry_initializer.attributes(self).keys) @@ -286,15 +283,14 @@ def possible(params = EMPTY_HASH, **context) # Returns boolean result instead of Operations::Result for validate method. # True on success and false on failure. - def valid?(*args, **kwargs) - validate(*args, **kwargs).success? + def valid?(*, **) + validate(*, **).success? end def to_hash { **main_components_to_hash, - **form_components_to_hash, - configuration: configuration + **form_components_to_hash } end @@ -328,11 +324,8 @@ def form_components_to_hash def component(identifier) (@components ||= {})[identifier] = begin component_kwargs = { - message_resolver: contract.message_resolver, - info_reporter: configuration.info_reporter, - error_reporter: configuration.error_reporter + message_resolver: contract.message_resolver } - component_kwargs[:after_commit] = configuration.after_commit if identifier == :on_success callable = send(identifier) "::Operations::Components::#{identifier.to_s.camelize}".constantize.new( @@ -343,19 +336,163 @@ def component(identifier) end def call_monad(params, context) - operation_result = unwrap_monad(execute_operation(params, context)) + OmniService.with_sync_callbacks do + omni_service_result_to_monad(transaction_component.call(params, **context)) + end + end + + def transaction_component + @transaction_component ||= OmniService::Transaction.new( + validated_operation_adapter, + on_success: on_success_adapter, + on_failure: on_failure_adapter + ) + end + + def validated_operation_adapter + @validated_operation_adapter ||= lambda { |params, **context| + validation_result = unwrap_monad(validate_monad(params, context, call_idempotency: true)) + + if validation_result.failure? || validation_result.component == :idempotency + raise OmniService::Transaction::Halt.new(operations_result_to_omni_service(validation_result)) + end + + result = component(:operation).call(validation_result.params, validation_result.context) + operations_result_to_omni_service(result) + } + end + + def on_success_adapter + on_success.map { |callback| wrap_success_callback(callback) } + end + + def on_failure_adapter + on_failure.map { |callback| wrap_failure_callback(callback) } + end + + def wrap_success_callback(callback) + component = OmniService::Component.wrap(callback) + lambda { |params, **context| + clean_context = context.except(:__original_component__) + operations_result = Operations::Result.new(component: :operation, params: params, context: clean_context) + result = call_callback(component, operations_result) + OmniService::Result.build(self, params: [params], context: context.merge(__callback_result__: result)) + } + end + + def wrap_failure_callback(callback) + component = OmniService::Component.wrap(callback) + lambda { |omni_result| + # Only invoke callbacks when component is :operation (matches original behavior) + unless omni_result.context[:__original_component__] == :operation + return OmniService::Result.build(self, params: omni_result.params, + context: omni_result.context.merge(__callback_result__: nil)) + end + + clean_context = omni_result.context.except(:__original_component__) + params = omni_result.params.first || {} + callback_context = clean_context.merge(operation_failure: omni_errors_to_hash(omni_result.errors)) + operations_result = Operations::Result.new(component: :operation, params: params, context: callback_context) + result = call_callback(component, operations_result) + OmniService::Result.build(self, params: [params], context: callback_context.merge(__callback_result__: result)) + } + end + + def call_callback(component, operations_result) + callback_result = case component.signature + in [0, true] + component.callable.call(**operations_result.context) + in [_, true] + component.callable.call(operations_result.params, **operations_result.context) + in [_, false] + component.callable.call(operations_result) + end + + omni_callback_result = OmniService::Result.process(component.callable, callback_result) + omni_service_callback_result_to_operations_result(omni_callback_result) + end + + def omni_service_callback_result_to_operations_result(omni_result) + Operations::Result.new( + component: omni_result.context[:__original_component__] || :operation, + params: omni_result.params.inject({}, :merge), + context: omni_result.context.except(:__callback_result__, :__original_component__), + errors: omni_service_errors_to_operations(omni_result.errors), + on_success: [], + on_failure: [] + ) + end + + def operations_result_to_omni_service(result) + OmniService::Result.new( + operation: self, + params: [result.params], + context: result.context.merge(__original_component__: result.component), + errors: result.success? ? [] : operations_errors_to_omni_service(result.errors) + ) + end + + def operations_errors_to_omni_service(errors) + errors.map do |error| + OmniService::Error.build( + self, + message: error.text, + path: error.path.compact, + code: error.meta[:code] + ) + end + end + + def omni_service_result_to_monad(omni_result) + omni_service_result_to_operations_result(omni_result).to_monad + end + + def omni_service_result_to_operations_result(omni_result) + Operations::Result.new( + component: omni_result.context[:__original_component__] || :operation, + params: omni_result.params.inject({}, :merge), + context: omni_result.context.except(:__callback_result__, :__original_component__), + errors: omni_service_errors_to_operations(omni_result.errors), + on_success: extract_callback_results(omni_result.on_success), + on_failure: extract_callback_results(omni_result.on_failure) + ) + end - return operation_result unless operation_result.component == :operation + def omni_service_errors_to_operations(errors) + return Dry::Validation::MessageSet.new([]).freeze if errors.empty? - component = operation_result.success? ? component(:on_success) : component(:on_failure) - component.call(operation_result) + messages = errors.map { |error| omni_error_to_message(error) } + Dry::Validation::MessageSet.new(messages).freeze end - def execute_operation(params, context) - configuration.transaction.call do - contract_result = yield validate_monad(params, context, call_idempotency: true) + def omni_error_to_message(error) + path = error.path.empty? ? [nil] : error.path + meta = { code: error.code }.compact + message = error.code || error.message + + contract.message_resolver.call(message: message, path: path, tokens: error.tokens, meta: meta) + end + + # Convert OmniService errors to Operations-style hash format { path => [messages] } + def omni_errors_to_hash(errors) + errors.group_by { |e| e.path.empty? ? nil : e.path.first }.transform_values do |group| + group.map(&:message) + end + end - yield component(:operation).call(contract_result.params, contract_result.context) + def extract_callback_results(results) + results.filter_map do |result| + callback_result = case result + when OmniService::Result + result.context[:__callback_result__] + when Concurrent::Promises::Future + result.value! + else + result + end + + # Skip nil results (callbacks that weren't invoked due to component check) + callback_result end end diff --git a/lib/operations/components/base.rb b/lib/operations/components/base.rb index a9f5459..ddacb40 100644 --- a/lib/operations/components/base.rb +++ b/lib/operations/components/base.rb @@ -19,15 +19,13 @@ class Operations::Components::Base param :callable, type: Operations::Types.Interface(:call) option :message_resolver, type: Operations::Types.Interface(:call), optional: true - option :info_reporter, type: Operations::Types::Nil | Operations::Types.Interface(:call), optional: true - option :error_reporter, type: Operations::Types::Nil | Operations::Types.Interface(:call), optional: true private - def result(**options) + def result(**) ::Operations::Result.new( component: self.class.name.demodulize.underscore.to_sym, - **options + ** ) end diff --git a/lib/operations/components/callback.rb b/lib/operations/components/callback.rb deleted file mode 100644 index 95643f7..0000000 --- a/lib/operations/components/callback.rb +++ /dev/null @@ -1,55 +0,0 @@ -# frozen_string_literal: true - -require "operations/components/base" - -# This base component handles `on_failure:` and `on_success:` callbacks -# passed to the command. Every callback entry is called outside of the -# operation transaction and any exception is rescued here so the result -# of the whole operation is not affected. Additionally, any callback -# failures will be reported with the command error reporter. -# The original operation result will be optionally passed as the second -# positional argument for the `call` method. -class Operations::Components::Callback < Operations::Components::Base - include Dry::Monads::Do.for(:call_entry) - - param :callable, type: Operations::Types::Array.of(Operations::Types.Interface(:call)) - - private - - def call_entry(entry, operation_result, **context) - result = yield(entry_result(entry, operation_result, **context)) - - Success(result) - rescue Dry::Monads::Do::Halt => e - e.result - rescue => e - Failure(e) - end - - def entry_result(entry, operation_result, **context) - args = call_args(entry, types: %i[req opt]) - kwargs = call_args(entry, types: %i[key keyreq keyrest]) - - case [args.size, kwargs.present?] - when [1, true] - entry.call(operation_result.params, **context) - when [1, false] - entry.call(operation_result) - when [0, true] - entry.call(**context) - else - raise "Invalid callback `#call` signature. Should be either `(params, **context)` or `(operation_result)`" - end - end - - def maybe_report_failure(callback_type, result) - if result.public_send(callback_type).any?(Failure) - error_reporter&.call( - "Operation #{callback_type} side-effects went sideways", - result: result.as_json(include_command: true) - ) - end - - result - end -end diff --git a/lib/operations/components/idempotency.rb b/lib/operations/components/idempotency.rb index ee947d7..048e32f 100644 --- a/lib/operations/components/idempotency.rb +++ b/lib/operations/components/idempotency.rb @@ -13,21 +13,15 @@ # to the result context in order to enrich it (the failure should # contain something that operation body would return normally # to mimic a proper operation call result). -# -# Component logs the failed check with `error_reporter`. class Operations::Components::Idempotency < Operations::Components::Prechecks def call(params, context) - failure, failed_check = process_callables(params, context) + failure, _failed_check = process_callables(params, context) if failure - new_result = result( + Failure(result( params: params, context: context.merge(failure.failure) - ) - - report_failure(new_result, failed_check) - - Failure(new_result) + )) else Success(result( params: params, @@ -59,12 +53,4 @@ def process_callables(params, context) [failure, failed_check] end - - def report_failure(result, failed_check) - info_reporter&.call( - "Idempotency check failed", - result: result.as_json(include_command: true), - failed_check: failed_check.inspect - ) - end end diff --git a/lib/operations/components/on_failure.rb b/lib/operations/components/on_failure.rb deleted file mode 100644 index 45fb35c..0000000 --- a/lib/operations/components/on_failure.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -require "operations/components/callback" - -# `on_failure` callbacks are called if a command have failed on a stage -# other than the operation itself or contract. I.e. on policies/preconditions. -class Operations::Components::OnFailure < Operations::Components::Callback - def call(operation_result) - callback_context = operation_result.context.merge(operation_failure: operation_result.errors.to_h) - results = callable.map do |entry| - call_entry(entry, operation_result, **callback_context) - end - - maybe_report_failure(:on_failure, operation_result.merge(on_failure: results)) - end -end diff --git a/lib/operations/components/on_success.rb b/lib/operations/components/on_success.rb deleted file mode 100644 index 578e663..0000000 --- a/lib/operations/components/on_success.rb +++ /dev/null @@ -1,35 +0,0 @@ -# frozen_string_literal: true - -require "operations/components/callback" - -# `on_success` callbacks are called when command was successful and implemented -# to be executed outside the outermost DB transcation (this is configurable -# but by default AfterCommitEverywhere gem is used). -# It there is a wrapping transaction (in cases when command is called inside -# of another command), the inner command result will have empty `on_success` -# component (since the callbacks will happen when the wparring command is finished). -class Operations::Components::OnSuccess < Operations::Components::Callback - option :after_commit, type: Operations::Types.Interface(:call) - - def call(operation_result) - callback_result = after_commit.call do - call_entries(operation_result) - end - - if callback_result.is_a?(Operations::Result) - callback_result - else - operation_result - end - end - - private - - def call_entries(operation_result) - results = callable.map do |entry| - call_entry(entry, operation_result, **operation_result.context) - end - - maybe_report_failure(:on_success, operation_result.merge(on_success: results)) - end -end diff --git a/lib/operations/configuration.rb b/lib/operations/configuration.rb deleted file mode 100644 index 3b0f491..0000000 --- a/lib/operations/configuration.rb +++ /dev/null @@ -1,15 +0,0 @@ -# frozen_string_literal: true - -require "dry-struct" - -# The framework's configuration shared between all the commands. -# -# @see Operations.default_config -class Operations::Configuration < Dry::Struct - schema schema.strict - - attribute :info_reporter?, Operations::Types.Interface(:call).optional - attribute :error_reporter?, Operations::Types.Interface(:call).optional - attribute :transaction, Operations::Types.Interface(:call) - attribute :after_commit, Operations::Types.Interface(:call) -end diff --git a/lib/operations/convenience.rb b/lib/operations/convenience.rb index 87eb3cb..7a3e6ca 100644 --- a/lib/operations/convenience.rb +++ b/lib/operations/convenience.rb @@ -64,10 +64,10 @@ def self.extended(mod) mod.extend Dry::Initializer end - def method_missing(name, *args, **kwargs, &block) + def method_missing(name, ...) name_without_suffix = name.to_s.delete_suffix("!").to_sym if name.to_s.end_with?("!") && respond_to?(name_without_suffix) - public_send(name_without_suffix, *args, **kwargs, &block).method(:call!) + public_send(name_without_suffix, ...).method(:call!) else super end @@ -77,10 +77,10 @@ def respond_to_missing?(name, *) (name.to_s.end_with?("!") && respond_to?(name.to_s.delete_suffix("!").to_sym)) || super end - def contract(prefix = nil, from: OperationContract, &block) + def contract(prefix = nil, from: OperationContract, &) contract = Class.new(from) contract.config.messages.namespace = name.underscore - contract.class_eval(&block) + contract.class_eval(&) const_set(:"#{prefix.to_s.camelize}Contract", contract) end diff --git a/lib/operations/form.rb b/lib/operations/form.rb index 67bcf9d..d9af587 100644 --- a/lib/operations/form.rb +++ b/lib/operations/form.rb @@ -58,10 +58,10 @@ def self.inherited(subclass) option :base_class, type: Operations::Types::Class, default: proc { ::Operations::Form::Base } end) - def initialize(command, hydrator: nil, hydrators: [], model_name: nil, **options) + def initialize(command, hydrator: nil, hydrators: [], model_name: nil, **) hydrators.push(hydrator) if hydrator.present? - super(command, hydrators: hydrators, param_key: model_name, **options) + super(command, hydrators: hydrators, param_key: model_name, **) end def build(params = EMPTY_HASH, **context) diff --git a/lib/operations/form/base.rb b/lib/operations/form/base.rb index 5d3ecd0..165daeb 100644 --- a/lib/operations/form/base.rb +++ b/lib/operations/form/base.rb @@ -60,8 +60,8 @@ def self.extended(base) end end - def attribute(name, **options) - attribute = Operations::Form::Attribute.new(name, **options) + def attribute(name, **) + attribute = Operations::Form::Attribute.new(name, **) self.attributes = attributes.merge( attribute.name => attribute @@ -100,7 +100,7 @@ def type_for_attribute(name) self.class.attributes[name.to_sym].model_type end - def has_attribute?(name) # rubocop:disable Naming/PredicateName + def has_attribute?(name) # rubocop:disable Naming/PredicatePrefix self.class.attributes.key?(name.to_sym) end @@ -117,7 +117,7 @@ def assigned_attributes end # For now we gracefully return nil for unknown methods - def method_missing(name, *args, **kwargs) + def method_missing(name, *, **) build_attribute_name = build_attribute_name(name) build_attribute = self.class.attributes[build_attribute_name] plural_build_attribute = self.class.attributes[build_attribute_name.to_s.pluralize.to_sym] @@ -125,9 +125,9 @@ def method_missing(name, *args, **kwargs) if has_attribute?(name) read_attribute(name) elsif build_attribute&.form - build_attribute.form.new(*args, **kwargs) + build_attribute.form.new(*, **) elsif plural_build_attribute&.form - plural_build_attribute.form.new(*args, **kwargs) + plural_build_attribute.form.new(*, **) elsif operation_result operation_result.context[name] end diff --git a/lib/operations/form/builder.rb b/lib/operations/form/builder.rb index 9cdd984..78eae4e 100644 --- a/lib/operations/form/builder.rb +++ b/lib/operations/form/builder.rb @@ -7,7 +7,7 @@ class Operations::Form::Builder extend Dry::Initializer - NESTED_ATTRIBUTES_SUFFIX = %r{_attributes\z}.freeze + NESTED_ATTRIBUTES_SUFFIX = %r{_attributes\z} option :base_class, Operations::Types::Instance(Class) diff --git a/lib/operations/result.rb b/lib/operations/result.rb index 7b7bb74..efb3d3a 100644 --- a/lib/operations/result.rb +++ b/lib/operations/result.rb @@ -100,7 +100,7 @@ def to_hash(include_command: false) def errors_with_code?(name, *names) names = [name] + names - (errors.map { |error| error.meta[:code] } & names).present? + errors.map { |error| error.meta[:code] }.intersect?(names) end def context_to_hash diff --git a/operations.gemspec b/operations.gemspec index 8ec16c9..6aafc21 100644 --- a/operations.gemspec +++ b/operations.gemspec @@ -12,11 +12,12 @@ Gem::Specification.new do |spec| spec.description = "Operations framework" spec.homepage = "https://github.com/BookingSync/operations" spec.license = "MIT" - spec.required_ruby_version = Gem::Requirement.new(">= 2.7.0") + spec.required_ruby_version = Gem::Requirement.new(">= 3.2.0") spec.metadata["homepage_uri"] = spec.homepage spec.metadata["source_code_uri"] = "https://github.com/BookingSync/operations" spec.metadata["changelog_uri"] = "https://github.com/BookingSync/operations" + spec.metadata["rubygems_mfa_required"] = "true" # Specify which files should be added to the gem when it is released. # The `git ls-files -z` loads the files in the RubyGem that have been added into git. @@ -29,15 +30,12 @@ Gem::Specification.new do |spec| spec.add_development_dependency "appraisal" spec.add_development_dependency "database_cleaner-active_record" - spec.add_development_dependency "sqlite3", ">= 1.4" + spec.add_development_dependency "sqlite3" - spec.add_dependency "activerecord", ">= 5.2.0" - spec.add_dependency "activesupport", ">= 5.2.0" - spec.add_dependency "after_commit_everywhere" + spec.add_dependency "activerecord", ">= 7.2.0" + spec.add_dependency "activesupport", ">= 7.2.0" spec.add_dependency "dry-monads" spec.add_dependency "dry-struct" spec.add_dependency "dry-validation" - spec.metadata = { - "rubygems_mfa_required" => "true" - } + spec.add_dependency "omni_service" end diff --git a/spec/fixtures/locale.yml b/spec/fixtures/locale.yml index 319fdb1..675957b 100644 --- a/spec/fixtures/locale.yml +++ b/spec/fixtures/locale.yml @@ -5,6 +5,8 @@ en: rules: unauthorized: "Unauthorized" + wow: "Wow error" + yay: "Yay error" fr: operations: diff --git a/spec/operations/command_spec.rb b/spec/operations/command_spec.rb index d12c183..35a87db 100644 --- a/spec/operations/command_spec.rb +++ b/spec/operations/command_spec.rb @@ -27,9 +27,9 @@ let(:policies) { [policy] } let(:preconditions) { [->(error:, **) { error }] } let(:idempotency_checks) { [] } - let(:on_success) { [->(**) { Dry::Monads::Success(:yay) }] } + let(:on_success) { [->(**) { Dry::Monads::Success(result: :yay) }] } let(:on_failure) { [on_failure_callback] } - let(:on_failure_callback) { ->(_, **) { Dry::Monads::Success(:wow) } } + let(:on_failure_callback) { ->(_, **) { Dry::Monads::Success(result: :wow) } } let(:command_options) { {} } describe ".new" do @@ -54,6 +54,7 @@ let(:operation_class) do Class.new do extend Dry::Initializer + option :repo def call; end @@ -64,6 +65,7 @@ def call; end end) const_set(:Policy, Class.new do extend Dry::Initializer + option :repo def call; end @@ -88,6 +90,7 @@ def call; end before do operation_class.const_set(:Precondition, Class.new do extend Dry::Initializer + option :repo def call; end @@ -334,7 +337,7 @@ def build(**kwargs) component: :operation, params: { name: "Batman" }, context: { admin: true, error: nil, additional: :value }, - on_success: [Dry::Monads::Success(:yay)], + on_success: [an_instance_of(Operations::Result) & have_attributes(context: { result: :yay }, errors: [])], on_failure: [], errors: be_empty ) @@ -404,7 +407,8 @@ def build(**kwargs) params: { name: "Batman" }, context: { admin: true, error: nil }, on_success: [], - on_failure: [Dry::Monads::Success(:wow)], + on_failure: [an_instance_of(Operations::Result) & have_attributes(context: { result: :wow }, + errors: [])], errors: have_attributes( to_h: { nil => ["Error"] } ) @@ -413,10 +417,6 @@ def build(**kwargs) context "when on_failure callback failed" do let(:on_failure_callback) { ->(_, **) { Dry::Monads::Failure(:wow) } } - let(:command_options) { { configuration: Operations.default_config.new(error_reporter: error_reporter) } } - let(:error_reporter) { -> {} } - - before { allow(error_reporter).to receive(:call) } it "returns a normalized operation result" do expect { call }.not_to change { User.count } @@ -428,7 +428,8 @@ def build(**kwargs) params: { name: "Batman" }, context: { admin: true, error: nil }, on_success: [], - on_failure: [Dry::Monads::Failure(:wow)], + on_failure: [an_instance_of(Operations::Result) & + have_attributes(errors: have_attributes(to_a: [have_attributes(meta: { code: :wow })]))], errors: have_attributes( to_h: { nil => ["Error"] } ) @@ -437,10 +438,6 @@ def build(**kwargs) { name: "Batman" }, { admin: true, error: nil, operation_failure: { nil => ["Error"] } } ) - expect(error_reporter).to have_received(:call).with( - "Operation on_failure side-effects went sideways", - include(:result) - ) end end end @@ -454,7 +451,7 @@ def build(**kwargs) component: :operation, params: { name: "Batman" }, context: { admin: true, error: nil, additional: :value }, - on_success: [Dry::Monads::Success(:yay)], + on_success: [an_instance_of(Operations::Result) & have_attributes(context: { result: :yay }, errors: [])], on_failure: [], errors: be_empty ) @@ -462,12 +459,8 @@ def build(**kwargs) context "when on_success callback failed but there is a wrapping transaction" do let(:on_success) { [->(**) { Dry::Monads::Failure(:yay) }] } - let(:command_options) { { configuration: Operations.default_config.new(error_reporter: error_reporter) } } - let(:error_reporter) { -> {} } - before { allow(error_reporter).to receive(:call) } - - it "returns a normalized operation result" do + it "returns a normalized operation result with callback results captured" do ActiveRecord::Base.transaction do expect { call }.to change { User.count }.by(1) expect(call) @@ -477,26 +470,17 @@ def build(**kwargs) component: :operation, params: { name: "Batman" }, context: { admin: true, error: nil, additional: :value }, - on_success: [], + on_success: [an_instance_of(Operations::Result) & + have_attributes(errors: have_attributes(to_a: [have_attributes(meta: { code: :yay })]))], on_failure: [], errors: be_empty ) - expect(error_reporter).not_to have_received(:call) end - - expect(error_reporter).to have_received(:call).with( - "Operation on_success side-effects went sideways", - include(:result) - ) end end context "when on_success callback failed" do let(:on_success) { [->(**) { Dry::Monads::Failure(:yay) }] } - let(:command_options) { { configuration: Operations.default_config.new(error_reporter: error_reporter) } } - let(:error_reporter) { -> {} } - - before { allow(error_reporter).to receive(:call) } it "returns a normalized operation result" do expect { call }.to change { User.count }.by(1) @@ -507,14 +491,120 @@ def build(**kwargs) component: :operation, params: { name: "Batman" }, context: { admin: true, error: nil, additional: :value }, - on_success: [Dry::Monads::Failure(:yay)], + on_success: [an_instance_of(Operations::Result) & + have_attributes(errors: have_attributes(to_a: [have_attributes(meta: { code: :yay })]))], on_failure: [], errors: be_empty ) - expect(error_reporter).to have_received(:call).with( - "Operation on_success side-effects went sideways", - include(:result) - ) + end + end + + context "when on_success callback returns non-monad value" do + let(:on_success) { [->(**) { :not_a_monad }] } + + it "raises an error" do + expect { call }.to raise_error(RuntimeError, %r{Invalid callable result}) + end + end + + context "when on_success callback raises an exception" do + let(:on_success) { [->(**) { raise "Callback error" }] } + + it "propagates the exception" do + expect { call }.to raise_error(RuntimeError, "Callback error") + end + end + + context "when on_failure callback raises an exception" do + let(:operation) { ->(**) { Dry::Monads::Failure("Error") } } + let(:on_failure) { [->(_, **) { raise "Failure callback error" }] } + + it "propagates the exception" do + expect { call }.to raise_error(RuntimeError, "Failure callback error") + end + end + + context "when on_success callback uses (params, **context) signature" do + let(:on_success) { [->(params, **context) { Dry::Monads::Success(params: params, keys: context.keys) }] } + + it "receives params and context" do + expect(call) + .to be_success + .and have_attributes( + on_success: [an_instance_of(Operations::Result) & have_attributes( + context: { params: { name: "Batman" }, keys: %i[admin error additional] } + )] + ) + end + end + + context "when on_success callback uses (operation_result) signature" do + let(:on_success) { [->(result) { Dry::Monads::Success(params: result.params, component: result.component) }] } + + it "receives the operation result" do + expect(call) + .to be_success + .and have_attributes( + on_success: [an_instance_of(Operations::Result) & have_attributes( + context: { params: { name: "Batman" }, component: :operation } + )] + ) + end + end + + context "when on_failure callback uses (operation_result) signature" do + let(:operation) { ->(**) { Dry::Monads::Failure("Error") } } + let(:on_failure) { [->(result) { Dry::Monads::Success(failure: result.context[:operation_failure]) }] } + + it "receives the operation result with operation_failure in context" do + expect(call) + .to be_failure + .and have_attributes( + on_failure: [an_instance_of(Operations::Result) & have_attributes( + context: { failure: { nil => ["Error"] } } + )] + ) + end + end + + context "with multiple on_success callbacks" do + let(:on_success) do + [ + ->(**) { Dry::Monads::Success(first: true) }, + ->(**) { Dry::Monads::Success(second: true) } + ] + end + + it "calls all callbacks and collects results" do + expect(call) + .to be_success + .and have_attributes( + on_success: [ + an_instance_of(Operations::Result) & have_attributes(context: { first: true }), + an_instance_of(Operations::Result) & have_attributes(context: { second: true }) + ] + ) + end + end + + context "with multiple on_failure callbacks" do + let(:operation) { ->(**) { Dry::Monads::Failure("Error") } } + let(:on_failure) do + [ + ->(_, **) { Dry::Monads::Success(first: true) }, + ->(_, **) { Dry::Monads::Success(second: true) } + ] + end + + it "calls all callbacks and collects results" do + expect(call) + .to be_failure + .and have_attributes( + on_failure: [ + an_instance_of(Operations::Result) & have_attributes(context: { first: true }), + an_instance_of(Operations::Result) & have_attributes(context: { second: true }) + ] + ) end end end @@ -998,8 +1088,7 @@ def call; end "form_base" => "DummyOperation::FormBase", "form_class" => "DummyOperation::FormClass", "form_hydrator" => "DummyOperation::FormHydrator", - "form_model_map" => { "[:attribute]" => "attribute_map" }, - "configuration" => { "after_commit" => {}, "error_reporter" => {}, "transaction" => {} } + "form_model_map" => { "[:attribute]" => "attribute_map" } ) end end @@ -1028,9 +1117,7 @@ def call; end model_class=nil, model_attribute=nil, form=nil>}>, - form_hydrator=#, - configuration=# transaction=# after_commit=#>> + form_hydrator=#> INSPECT end end diff --git a/spec/operations/components/idempotency_spec.rb b/spec/operations/components/idempotency_spec.rb index 8d8e15c..5cde7be 100644 --- a/spec/operations/components/idempotency_spec.rb +++ b/spec/operations/components/idempotency_spec.rb @@ -1,12 +1,9 @@ # frozen_string_literal: true RSpec.describe Operations::Components::Idempotency do - subject(:component) { described_class.new(idempotency_checks, info_reporter: info_reporter) } + subject(:component) { described_class.new(idempotency_checks) } let(:idempotency_checks) { [->(_, **) { Dry::Monads::Success(unused: :value) }] } - let(:info_reporter) { instance_double(Proc) } - - before { allow(info_reporter).to receive(:call) } describe "#call" do subject(:call) { component.call(params, context) } @@ -34,21 +31,6 @@ errors: be_empty ) ) - expect(info_reporter).to have_received(:call).with( - "Idempotency check failed", - { - failed_check: %r{#(params, entity:, **) { Dry::Monads::Success([entity, params]) }, - ->(**) { raise "it hits the fan" }, - ->(**) { Dry::Monads::Failure(:error) } - ] - end - let(:error_reporter) { instance_double(Proc) } - - before do - allow(error_reporter).to receive(:call) - end - - describe "#call" do - subject(:call) { component.call(operation_result) } - - let(:params) { { name: "Batman" } } - let(:context) { { subject: 42, entity: "Entity" } } - let(:errors) do - instance_double(Dry::Validation::MessageSet, - is_a?: true, empty?: false, - with: { nil => ["error"] }, - to_h: { nil => ["error"] }) - end - let(:operation_result) do - Operations::Result.new(component: :preconditions, params: params, context: context, errors: errors) - end - - context "when callback does not fail" do - let(:callable) do - [->(operation_result) { Dry::Monads::Success({ operation_errors: operation_result.errors.to_h }) }] - end - - it "doesn't report anything" do - expect(call) - .to be_failure - .and have_attributes( - component: :preconditions, - params: { name: "Batman" }, - context: { subject: 42, entity: "Entity" }, - on_failure: [ - Dry::Monads::Success({ operation_errors: { nil => ["error"] } }) - ], - errors: errors - ) - expect(error_reporter).not_to have_received(:call) - end - end - - it "returns the results of callbacks calls" do - expect(call) - .to be_failure - .and have_attributes( - component: :preconditions, - params: { name: "Batman" }, - context: { subject: 42, entity: "Entity" }, - on_failure: [ - Dry::Monads::Success(["Entity", { name: "Batman" }]), - an_instance_of(Dry::Monads::Failure) & have_attributes(failure: an_instance_of(RuntimeError)), - Dry::Monads::Failure(:error) - ], - errors: errors - ) - expect(error_reporter).to have_received(:call).with( - "Operation on_failure side-effects went sideways", - result: call.as_json(include_command: true) - ).once - end - end -end diff --git a/spec/operations/components/on_success_spec.rb b/spec/operations/components/on_success_spec.rb deleted file mode 100644 index de327cb..0000000 --- a/spec/operations/components/on_success_spec.rb +++ /dev/null @@ -1,77 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Operations::Components::OnSuccess do - subject(:component) do - described_class.new( - callable, - error_reporter: error_reporter, - after_commit: after_commit - ) - end - - let(:callable) do - [ - ->(params, entity:, **) { Dry::Monads::Success([entity, params]) }, - ->(**) { raise "it hits the fan" }, - ->(**) { Dry::Monads::Failure(:error) } - ] - end - let(:after_commit) { instance_double(Proc) } - let(:error_reporter) { instance_double(Proc) } - - before do - allow(after_commit).to receive(:call).and_yield - allow(error_reporter).to receive(:call) - end - - describe "#call" do - subject(:call) { component.call(operation_result) } - - let(:params) { { name: "Batman" } } - let(:context) { { subject: 42, entity: "Entity" } } - let(:operation_result) do - Operations::Result.new(component: :operation, params: params, context: context) - end - - context "when callback does not failure" do - let(:callable) do - [->(operation_result) { Dry::Monads::Success({ operation_params: operation_result.params }) }] - end - - it "doesn't report anything" do - expect(call) - .to be_success - .and have_attributes( - component: :operation, - params: { name: "Batman" }, - context: { subject: 42, entity: "Entity" }, - on_success: [Dry::Monads::Success({ operation_params: { name: "Batman" } })], - errors: be_empty - ) - expect(after_commit).to have_received(:call).once - expect(error_reporter).not_to have_received(:call) - end - end - - it "returns the results of callable calls and always successful" do - expect(call) - .to be_success - .and have_attributes( - component: :operation, - params: { name: "Batman" }, - context: { subject: 42, entity: "Entity" }, - on_success: [ - Dry::Monads::Success(["Entity", { name: "Batman" }]), - an_instance_of(Dry::Monads::Failure) & have_attributes(failure: an_instance_of(RuntimeError)), - Dry::Monads::Failure(:error) - ], - errors: be_empty - ) - expect(after_commit).to have_received(:call).once - expect(error_reporter).to have_received(:call).with( - "Operation on_success side-effects went sideways", - result: call.as_json(include_command: true) - ).once - end - end -end diff --git a/spec/operations/configuration_spec.rb b/spec/operations/configuration_spec.rb deleted file mode 100644 index a2746ab..0000000 --- a/spec/operations/configuration_spec.rb +++ /dev/null @@ -1,16 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe Operations::Configuration do - subject(:configuration) { described_class.new(**options) } - - let(:error_reporter) { -> {} } - let(:transaction) { -> {} } - let(:after_commit) { -> {} } - let(:options) { { error_reporter: error_reporter, transaction: transaction, after_commit: after_commit } } - - describe "#to_h" do - subject(:to_h) { configuration.to_h } - - it { is_expected.to eq(error_reporter: error_reporter, transaction: transaction, after_commit: after_commit) } - end -end diff --git a/spec/operations_spec.rb b/spec/operations_spec.rb index c241d0b..c31b851 100644 --- a/spec/operations_spec.rb +++ b/spec/operations_spec.rb @@ -4,13 +4,4 @@ it "has a version number" do expect(Operations::VERSION).not_to be_nil end - - describe "#default_config" do - it "returns the default default_config values by default" do - expect(described_class.default_config).to have_attributes( - error_reporter: Operations::DEFAULT_ERROR_REPORTER, - transaction: Operations::DEFAULT_TRANSACTION - ) - end - end end