diff --git a/CLAUDE.md b/CLAUDE.md index c70ca34b..f534c10a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -96,7 +96,6 @@ RUB, USD, BTC, LTC, ETH, DSH, KZT, XRP, ETC, XMR, BCH, EUR, NEO, ZEC - Factory Bot for test data in `factories/` - VCR for HTTP request mocking - Database Rewinder for fast test cleanup -- Sidekiq testing inline enabled ## File Organization @@ -104,4 +103,4 @@ RUB, USD, BTC, LTC, ETH, DSH, KZT, XRP, ETC, XMR, BCH, EUR, NEO, ZEC - `app/workers/gera/` - Background job workers - `lib/gera/` - Core engine logic and utilities - `lib/builders/` - Rate calculation builders -- `spec/` - Test suite with dummy app \ No newline at end of file +- `spec/` - Test suite with dummy app diff --git a/Gemfile.lock b/Gemfile.lock index 7718208a..847be7f0 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -15,7 +15,7 @@ GIT PATH remote: . specs: - gera (0.4.0) + gera (1.2.0) active_link_to alias_association authority @@ -35,8 +35,8 @@ PATH request_store require_all rest-client (~> 2.0) - sidekiq simple_form + solid_queue virtus GEM @@ -179,12 +179,17 @@ GEM ruby2_keywords drb (2.2.3) erubi (1.13.1) + et-orbi (1.4.0) + tzinfo factory_bot (6.5.6) activesupport (>= 6.1.0) ffi (1.17.2) ffi (1.17.2-x86_64-linux-gnu) formatador (1.2.3) reline + fugit (1.12.1) + et-orbi (~> 1.4) + raabro (~> 1.4) globalid (1.3.0) activesupport (>= 6.1) guard (2.19.1) @@ -261,7 +266,6 @@ GEM mime-types-data (~> 3.2025, >= 3.2025.0507) mime-types-data (3.2025.0924) mini_mime (1.1.5) - mini_portile2 (2.8.9) minitest (5.26.2) monetize (1.13.0) money (~> 6.12) @@ -286,9 +290,6 @@ GEM net-protocol netrc (0.11.0) nio4r (2.7.5) - nokogiri (1.18.10) - mini_portile2 (~> 2.8.2) - racc (~> 1.4) nokogiri (1.18.10-x86_64-linux-gnu) racc (~> 1.4) notiffany (0.1.3) @@ -319,6 +320,7 @@ GEM pry (>= 0.13.0) psych (3.1.0) public_suffix (7.0.0) + raabro (1.4.0) racc (1.8.1) rack (3.2.4) rack-session (2.1.1) @@ -364,8 +366,6 @@ GEM rb-inotify (0.11.1) ffi (~> 1.0) rdoc (6.3.4.1) - redis-client (0.26.1) - connection_pool regexp_parser (2.11.3) reline (0.6.3) io-console (~> 0.5) @@ -420,17 +420,16 @@ GEM ruby2_keywords (0.0.5) securerandom (0.4.1) shellany (0.0.1) - sidekiq (8.0.9) - connection_pool (>= 2.5.0) - json (>= 2.9.0) - logger (>= 1.6.2) - rack (>= 3.1.0) - redis-client (>= 0.23.2) simple_form (5.4.0) actionpack (>= 7.0) activemodel (>= 7.0) - sqlite3 (2.8.0) - mini_portile2 (~> 2.8.0) + solid_queue (1.2.4) + activejob (>= 7.1) + activerecord (>= 7.1) + concurrent-ruby (>= 1.3.1) + fugit (~> 1.11) + railties (>= 7.1) + thor (>= 1.3.1) sqlite3 (2.8.0-x86_64-linux-gnu) thor (1.4.0) thread_safe (0.3.6) @@ -464,7 +463,6 @@ GEM zeitwerk (2.7.3) PLATFORMS - ruby x86_64-linux DEPENDENCIES diff --git a/app/controllers/gera/currency_rate_mode_snapshots_controller.rb b/app/controllers/gera/currency_rate_mode_snapshots_controller.rb index 3cc2d023..cc98d614 100644 --- a/app/controllers/gera/currency_rate_mode_snapshots_controller.rb +++ b/app/controllers/gera/currency_rate_mode_snapshots_controller.rb @@ -37,7 +37,7 @@ def activate CurrencyRateModeSnapshot.status_active.update_all status: :deactive snapshot.update status: :active end - CurrencyRatesWorker.perform_async if Rails.env.production? + CurrencyRatesJob.perform_later if Rails.env.production? flash[:success] = 'Режимы активированы' redirect_to currency_rate_mode_snapshot_path snapshot end diff --git a/app/controllers/gera/direction_rate_history_intervals_controller.rb b/app/controllers/gera/direction_rate_history_intervals_controller.rb index 6228d348..1314165c 100644 --- a/app/controllers/gera/direction_rate_history_intervals_controller.rb +++ b/app/controllers/gera/direction_rate_history_intervals_controller.rb @@ -6,6 +6,7 @@ class DirectionRateHistoryIntervalsController < ApplicationController authorize_actions_for DirectionRate helper_method :payment_system_from, :payment_system_to helper_method :filter + helper_method :history_intervals_enabled? def index respond_to do |format| @@ -58,5 +59,9 @@ def intervals raise "Unknown value_type #{filter.value_type}" end end + + def history_intervals_enabled? + Gera.enable_direction_rate_history_intervals + end end end diff --git a/app/workers/concerns/gera/rates_worker.rb b/app/jobs/concerns/gera/rates_job.rb similarity index 89% rename from app/workers/concerns/gera/rates_worker.rb rename to app/jobs/concerns/gera/rates_job.rb index d09b335b..f86d1bc6 100644 --- a/app/workers/concerns/gera/rates_worker.rb +++ b/app/jobs/concerns/gera/rates_job.rb @@ -1,14 +1,15 @@ # frozen_string_literal: true -require 'open-uri' require 'rest-client' module Gera - module RatesWorker + module RatesJob + extend ActiveSupport::Concern + Error = Class.new(StandardError) def perform - logger.debug "RatesWorker: before perform for #{rate_source.class.name}" + logger.debug "RatesJob: before perform for #{rate_source.class.name}" ActiveRecord::Base.connection.clear_query_cache @rates = load_rates @@ -48,7 +49,7 @@ def save_all_rates hash[pair_str] = { 'buy' => buy_price.to_f, 'sell' => sell_price.to_f } end - ExternalRatesBatchWorker.perform_async( + ExternalRatesBatchJob.perform_later( rate_source_snapshot.id, rate_source.id, batched_rates @@ -56,7 +57,7 @@ def save_all_rates end def rate_keys - raise NotImplementedError, 'You must define #rate_keys in your worker' + raise NotImplementedError, 'You must define #rate_keys in your job' end end end diff --git a/app/jobs/gera/application_job.rb b/app/jobs/gera/application_job.rb index e1637ab4..52d9f522 100644 --- a/app/jobs/gera/application_job.rb +++ b/app/jobs/gera/application_job.rb @@ -2,5 +2,7 @@ module Gera class ApplicationJob < ActiveJob::Base + # SolidQueue's engine automatically includes ActiveJob::ConcurrencyControls + # which provides limits_concurrency method end end diff --git a/app/workers/gera/binance_rates_worker.rb b/app/jobs/gera/binance_rates_job.rb similarity index 70% rename from app/workers/gera/binance_rates_worker.rb rename to app/jobs/gera/binance_rates_job.rb index 88931c98..7c1d3272 100644 --- a/app/workers/gera/binance_rates_worker.rb +++ b/app/jobs/gera/binance_rates_job.rb @@ -1,17 +1,16 @@ # frozen_string_literal: true module Gera - class BinanceRatesWorker - include Sidekiq::Worker + class BinanceRatesJob < ApplicationJob include AutoLogger - include RatesWorker + include RatesJob - sidekiq_options lock: :until_executed + limits_concurrency to: 1, key: ->(*) { 'gera_binance_rates' }, duration: 1.minute def perform # Check if we should approve new rates based on count unless should_approve_new_rates? - logger.debug "BinanceRatesWorker: Rate counts don't match, skipping" + logger.debug "BinanceRatesJob: Rate counts don't match, skipping" return nil end @@ -39,7 +38,7 @@ def should_approve_new_rates? current_rates_count = rate_source.actual_snapshot.external_rates.count new_rates_count = load_rates.size - logger.info "BinanceRatesWorker: current_rates_count=#{current_rates_count}, new_rates_count=#{new_rates_count}" + logger.info "BinanceRatesJob: current_rates_count=#{current_rates_count}, new_rates_count=#{new_rates_count}" # Only approve if counts match current_rates_count == new_rates_count diff --git a/app/workers/gera/bitfinex_rates_worker.rb b/app/jobs/gera/bitfinex_rates_job.rb similarity index 85% rename from app/workers/gera/bitfinex_rates_worker.rb rename to app/jobs/gera/bitfinex_rates_job.rb index 9102bd5e..49f331ca 100644 --- a/app/workers/gera/bitfinex_rates_worker.rb +++ b/app/jobs/gera/bitfinex_rates_job.rb @@ -3,10 +3,9 @@ module Gera # Import rates from Bitfinex # - class BitfinexRatesWorker - include Sidekiq::Worker + class BitfinexRatesJob < ApplicationJob include AutoLogger - include RatesWorker + include RatesJob private diff --git a/app/workers/gera/bybit_rates_worker.rb b/app/jobs/gera/bybit_rates_job.rb similarity index 81% rename from app/workers/gera/bybit_rates_worker.rb rename to app/jobs/gera/bybit_rates_job.rb index 7a3d5b84..29ec1db3 100644 --- a/app/workers/gera/bybit_rates_worker.rb +++ b/app/jobs/gera/bybit_rates_job.rb @@ -3,10 +3,9 @@ module Gera # Import rates from Bybit # - class BybitRatesWorker - include Sidekiq::Worker + class BybitRatesJob < ApplicationJob include AutoLogger - include RatesWorker + include RatesJob private diff --git a/app/workers/gera/cbr_avg_rates_worker.rb b/app/jobs/gera/cbr_avg_rates_job.rb similarity index 86% rename from app/workers/gera/cbr_avg_rates_worker.rb rename to app/jobs/gera/cbr_avg_rates_job.rb index f7a8aa55..20165cb4 100644 --- a/app/workers/gera/cbr_avg_rates_worker.rb +++ b/app/jobs/gera/cbr_avg_rates_job.rb @@ -1,11 +1,10 @@ # frozen_string_literal: true module Gera - class CbrAvgRatesWorker - include Sidekiq::Worker + class CbrAvgRatesJob < ApplicationJob include AutoLogger - sidekiq_options lock: :until_executed + limits_concurrency to: 1, key: ->(*) { 'gera_cbr_avg_rates' }, duration: 1.minute def perform ActiveRecord::Base.connection.clear_query_cache @@ -18,7 +17,7 @@ def perform end private - + def source @source ||= Gera::RateSourceCbrAvg.get! end diff --git a/app/workers/gera/cbr_rates_worker.rb b/app/jobs/gera/cbr_rates_job.rb similarity index 93% rename from app/workers/gera/cbr_rates_worker.rb rename to app/jobs/gera/cbr_rates_job.rb index 779e73f4..42203ae9 100644 --- a/app/workers/gera/cbr_rates_worker.rb +++ b/app/jobs/gera/cbr_rates_job.rb @@ -1,17 +1,15 @@ # frozen_string_literal: true -require 'open-uri' require 'business_time' module Gera # Import rates from Russian Central Bank # http://www.cbr.ru/scripts/XML_daily.asp?date_req=08/04/2018 # - class CbrRatesWorker - include Sidekiq::Worker + class CbrRatesJob < ApplicationJob include AutoLogger - # sidekiq_options lock: :until_executed + # limits_concurrency to: 1, key: -> { 'gera_cbr_rates' }, duration: 1.minute CURRENCIES = %w[USD KZT EUR UAH UZS AZN BYN TRY THB IDR].freeze @@ -36,18 +34,18 @@ class CbrRatesWorker URL = 'https://pay.hub.pp.ru/api/cbr' def perform - logger.debug 'CbrRatesWorker: before perform' + logger.debug 'CbrRatesJob: before perform' ActiveRecord::Base.connection.clear_query_cache rates_by_date = load_rates - logger.debug 'CbrRatesWorker: before transaction' + logger.debug 'CbrRatesJob: before transaction' ActiveRecord::Base.transaction do rates_by_date.each do |date, rates| save_rates(date, rates) end end - logger.debug 'CbrRatesWorker: after transaction' + logger.debug 'CbrRatesJob: after transaction' make_snapshot - logger.debug 'CbrRatesWorker: after perform' + logger.debug 'CbrRatesJob: after perform' end private @@ -146,11 +144,11 @@ def load_rates rates_by_date[date] = fetch_rates(date) rescue WrongDate => err logger.warn err - + # HTTP redirection loop: http://www.cbr.ru/scripts/XML_daily.asp?date_req=09/01/2019 rescue RuntimeError => err raise err unless err.message.include? 'HTTP redirection loop' - + logger.error err end rates_by_date @@ -162,7 +160,7 @@ def fetch_rates(date) logger.info "fetch rates for #{date} from #{uri}" - doc = Nokogiri::XML open uri + doc = Nokogiri::XML URI.open(uri) root = doc.xpath('/ValCurs') root_date = root.attr('Date').text diff --git a/app/workers/gera/create_history_intervals_worker.rb b/app/jobs/gera/create_history_intervals_job.rb similarity index 69% rename from app/workers/gera/create_history_intervals_worker.rb rename to app/jobs/gera/create_history_intervals_job.rb index 313dd879..d90560e7 100644 --- a/app/workers/gera/create_history_intervals_worker.rb +++ b/app/jobs/gera/create_history_intervals_job.rb @@ -1,26 +1,27 @@ # frozen_string_literal: true module Gera - class CreateHistoryIntervalsWorker - include Sidekiq::Worker + class CreateHistoryIntervalsJob < ApplicationJob include AutoLogger - sidekiq_options lock: :until_executed + limits_concurrency to: 1, key: ->(*) { 'gera_create_history_intervals' }, duration: 1.hour MAXIMAL_DATE = 30.minutes MINIMAL_DATE = Time.parse('13-07-2018 18:00') def perform - save_direction_rate_history_intervals if Gera::DirectionRateHistoryInterval.table_exists? + if Gera::DirectionRateHistoryInterval.table_exists? + if Gera.enable_direction_rate_history_intervals + save_direction_rate_history_intervals + else + logger.info 'Skipping direction_rate_history_intervals creation (disabled by config)' + end + end save_currency_rate_history_intervals if Gera::CurrencyRateHistoryInterval.table_exists? end private - def lock_timeout - 1.hours * 1000 - end - def save_direction_rate_history_intervals last_saved_interval = Gera::DirectionRateHistoryInterval.maximum(:interval_to) diff --git a/app/workers/gera/cryptomus_rates_worker.rb b/app/jobs/gera/cryptomus_rates_job.rb similarity index 79% rename from app/workers/gera/cryptomus_rates_worker.rb rename to app/jobs/gera/cryptomus_rates_job.rb index a864387d..abc7e100 100644 --- a/app/workers/gera/cryptomus_rates_worker.rb +++ b/app/jobs/gera/cryptomus_rates_job.rb @@ -1,10 +1,9 @@ # frozen_string_literal: true module Gera - class CryptomusRatesWorker - include Sidekiq::Worker + class CryptomusRatesJob < ApplicationJob include AutoLogger - include RatesWorker + include RatesJob private diff --git a/app/workers/gera/currency_rates_worker.rb b/app/jobs/gera/currency_rates_job.rb similarity index 73% rename from app/workers/gera/currency_rates_worker.rb rename to app/jobs/gera/currency_rates_job.rb index 5fb6604e..bf49a330 100644 --- a/app/workers/gera/currency_rates_worker.rb +++ b/app/jobs/gera/currency_rates_job.rb @@ -4,11 +4,10 @@ module Gera # # Build currency rates on base of imported rates and calculation modes # - class CurrencyRatesWorker - include Sidekiq::Worker + class CurrencyRatesJob < ApplicationJob include AutoLogger - Error = Class.new StandardError + queue_as :default def perform logger.info 'start' @@ -17,7 +16,7 @@ def perform Gera::CurrencyPair.all.each { |pair| create_rate(pair: pair, snapshot: snapshot) } end logger.info 'finish' - Gera::DirectionsRatesWorker.perform_async + Gera::DirectionsRatesJob.perform_later true end @@ -35,21 +34,21 @@ def create_rate(pair:, snapshot:) currency_rate_mode = find_currency_rate_mode_by_pair(pair) logger.debug "build_rate(#{pair}, #{currency_rate_mode})" currency_rate = currency_rate_mode.build_currency_rate - raise Error, "Unable to calculate rate for #{pair} and mode '#{currency_rate_mode.mode}'" unless currency_rate.present? + + unless currency_rate.present? + logger.warn "Unable to calculate rate for #{pair} and mode '#{currency_rate_mode.mode}'" + return + end currency_rate.snapshot = snapshot currency_rate.save! rescue Gera::RateSource::RateNotFound => err - logger.error err + logger.warn err rescue StandardError => err - raise err if !err.is_a?(Error) && Rails.env.test? - logger.error err + raise err if Rails.env.test? - if defined? Bugsnag - Bugsnag.notify err do |b| - b.meta_data = { pair: pair } - end - end + logger.error err + Bugsnag.notify(err) { |b| b.meta_data = { pair: pair } } if defined? Bugsnag end def find_currency_rate_mode_by_pair(pair) diff --git a/app/workers/gera/directions_rates_worker.rb b/app/jobs/gera/directions_rates_job.rb similarity index 88% rename from app/workers/gera/directions_rates_worker.rb rename to app/jobs/gera/directions_rates_job.rb index 66cae68f..be970c59 100644 --- a/app/workers/gera/directions_rates_worker.rb +++ b/app/jobs/gera/directions_rates_job.rb @@ -1,14 +1,15 @@ # frozen_string_literal: true module Gera - class DirectionsRatesWorker + class DirectionsRatesJob < ApplicationJob include ActiveSupport::Callbacks - include Sidekiq::Worker include AutoLogger Error = Class.new StandardError - sidekiq_options queue: :critical, lock: :until_executed + queue_as :critical + limits_concurrency to: 1, key: ->(*) { 'gera_directions_rates' }, duration: 5.minutes + define_callbacks :perform # exchange_rate_id - ID of changes exchange_rate @@ -16,7 +17,6 @@ class DirectionsRatesWorker def perform(*_args) # exchange_rate_id: nil) logger.info 'start' - run_callbacks :perform do Gera::DirectionRateSnapshot.transaction do Gera::ExchangeRate.includes(:payment_system_from, :payment_system_to).find_each do |exchange_rate| diff --git a/app/workers/gera/exchange_rate_updater_worker.rb b/app/jobs/gera/exchange_rate_updater_job.rb similarity index 72% rename from app/workers/gera/exchange_rate_updater_worker.rb rename to app/jobs/gera/exchange_rate_updater_job.rb index 03619175..dd9bffb4 100644 --- a/app/workers/gera/exchange_rate_updater_worker.rb +++ b/app/jobs/gera/exchange_rate_updater_job.rb @@ -1,11 +1,10 @@ # frozen_string_literal: true module Gera - class ExchangeRateUpdaterWorker - include Sidekiq::Worker + class ExchangeRateUpdaterJob < ApplicationJob include AutoLogger - sidekiq_options queue: :exchange_rates + queue_as :exchange_rates def perform(exchange_rate_id, attributes) increment_exchange_rate_touch_metric @@ -17,7 +16,7 @@ def perform(exchange_rate_id, attributes) def increment_exchange_rate_touch_metric Yabeda.exchange.exchange_rate_touch_count.increment({ action: 'update', - source: 'Gera::ExchangeRateUpdaterWorker' + source: 'Gera::ExchangeRateUpdaterJob' }) end end diff --git a/app/workers/gera/exmo_rates_worker.rb b/app/jobs/gera/exmo_rates_job.rb similarity index 80% rename from app/workers/gera/exmo_rates_worker.rb rename to app/jobs/gera/exmo_rates_job.rb index 62f2b2f8..1d3ac051 100644 --- a/app/workers/gera/exmo_rates_worker.rb +++ b/app/jobs/gera/exmo_rates_job.rb @@ -1,10 +1,9 @@ # frozen_string_literal: true module Gera - class ExmoRatesWorker - include Sidekiq::Worker + class ExmoRatesJob < ApplicationJob include AutoLogger - include RatesWorker + include RatesJob private diff --git a/app/workers/gera/external_rate_saver_worker.rb b/app/jobs/gera/external_rate_saver_job.rb similarity index 84% rename from app/workers/gera/external_rate_saver_worker.rb rename to app/jobs/gera/external_rate_saver_job.rb index 24a99c1e..eb1f7308 100644 --- a/app/workers/gera/external_rate_saver_worker.rb +++ b/app/jobs/gera/external_rate_saver_job.rb @@ -1,11 +1,10 @@ # frozen_string_literal: true module Gera - class ExternalRateSaverWorker - include Sidekiq::Worker + class ExternalRateSaverJob < ApplicationJob include AutoLogger - sidekiq_options queue: :external_rates + queue_as :external_rates def perform(currency_pair, snapshot_id, rate, source_rates_count) rate_source = find_rate_source(rate) @@ -40,15 +39,11 @@ def create_external_rate(rate_source:, snapshot:, currency_pair:, rate_value:) end def update_actual_snapshot(rate_source:, snapshot:) - update_actual_snapshot(snapshot: snapshot, rate_source: rate_source) + rate_source.update!(actual_snapshot_id: snapshot.id) end def snapshot_filled_up?(snapshot:, source_rates_count:) snapshot.external_rates.count == source_rates_count * 2 end - - def update_actual_snapshot(snapshot:, rate_source:) - rate_source.update!(actual_snapshot_id: snapshot.id) - end end end diff --git a/app/workers/gera/external_rates_batch_worker.rb b/app/jobs/gera/external_rates_batch_job.rb similarity index 94% rename from app/workers/gera/external_rates_batch_worker.rb rename to app/jobs/gera/external_rates_batch_job.rb index d8bde7d4..41c3948c 100644 --- a/app/workers/gera/external_rates_batch_worker.rb +++ b/app/jobs/gera/external_rates_batch_job.rb @@ -1,8 +1,8 @@ # frozen_string_literal: true module Gera - class ExternalRatesBatchWorker - include Sidekiq::Worker + class ExternalRatesBatchJob < ApplicationJob + queue_as :default def perform(snapshot_id, rate_source_id, rates) snapshot = Gera::ExternalRateSnapshot.find(snapshot_id) diff --git a/app/workers/gera/ff_fixed_rates_worker.rb b/app/jobs/gera/ff_fixed_rates_job.rb similarity index 79% rename from app/workers/gera/ff_fixed_rates_worker.rb rename to app/jobs/gera/ff_fixed_rates_job.rb index 0914e3fd..d906365e 100644 --- a/app/workers/gera/ff_fixed_rates_worker.rb +++ b/app/jobs/gera/ff_fixed_rates_job.rb @@ -1,10 +1,9 @@ # frozen_string_literal: true module Gera - class FfFixedRatesWorker - include Sidekiq::Worker + class FfFixedRatesJob < ApplicationJob include AutoLogger - include RatesWorker + include RatesJob private diff --git a/app/workers/gera/ff_float_rates_worker.rb b/app/jobs/gera/ff_float_rates_job.rb similarity index 79% rename from app/workers/gera/ff_float_rates_worker.rb rename to app/jobs/gera/ff_float_rates_job.rb index 96e3653b..bc5354d1 100644 --- a/app/workers/gera/ff_float_rates_worker.rb +++ b/app/jobs/gera/ff_float_rates_job.rb @@ -1,10 +1,9 @@ # frozen_string_literal: true module Gera - class FfFloatRatesWorker - include Sidekiq::Worker + class FfFloatRatesJob < ApplicationJob include AutoLogger - include RatesWorker + include RatesJob private diff --git a/app/workers/gera/garantexio_rates_worker.rb b/app/jobs/gera/garantexio_rates_job.rb similarity index 79% rename from app/workers/gera/garantexio_rates_worker.rb rename to app/jobs/gera/garantexio_rates_job.rb index 3c796463..561c14c9 100644 --- a/app/workers/gera/garantexio_rates_worker.rb +++ b/app/jobs/gera/garantexio_rates_job.rb @@ -1,10 +1,9 @@ # frozen_string_literal: true module Gera - class GarantexioRatesWorker - include Sidekiq::Worker + class GarantexioRatesJob < ApplicationJob include AutoLogger - include RatesWorker + include RatesJob private diff --git a/app/models/gera/exchange_rate.rb b/app/models/gera/exchange_rate.rb index 8ee31da5..a9a0b567 100644 --- a/app/models/gera/exchange_rate.rb +++ b/app/models/gera/exchange_rate.rb @@ -20,6 +20,8 @@ class ExchangeRate < ApplicationRecord DEFAULT_COMISSION = 50 MIN_COMISSION = -9.9 + CALCULATOR_TYPES = %w[legacy position_aware].freeze + include Mathematic include DirectionSupport @@ -65,7 +67,8 @@ class ExchangeRate < ApplicationRecord end validates :commission, presence: true - # validates :commission, numericality: { greater_than_or_equal_to: MIN_COMISSION } + validates :commission, numericality: { greater_than_or_equal_to: MIN_COMISSION } + validates :calculator_type, inclusion: { in: CALCULATOR_TYPES }, allow_nil: true delegate :rate, :currency_rate, to: :direction_rate @@ -108,9 +111,9 @@ def available? def update_finite_rate!(finite_rate) logger = Logger.new("#{Rails.root}/log/call_exchange_rate_updater_worker.log") - logger.info("Calls perform_async from update_finite_rate Gera::ExchangeRate") + logger.info("Calls perform_later from update_finite_rate Gera::ExchangeRate") - ExchangeRateUpdaterWorker.perform_async(id, { comission: calculate_comission(finite_rate, currency_rate.rate_value) }) + ExchangeRateUpdaterJob.perform_later(id, { comission: calculate_comission(finite_rate, currency_rate.rate_value) }) end def custom_inspect @@ -161,15 +164,24 @@ def final_rate_percents end def update_direction_rates - DirectionsRatesWorker.perform_async(exchange_rate_id: id) + DirectionsRatesJob.perform_later(exchange_rate_id: id) end def rate_comission_calculator @rate_comission_calculator ||= RateComissionCalculator.new(exchange_rate: self, external_rates: external_rates) end + def bestchange_key + return '' if payment_system_from.nil? || payment_system_to.nil? + + from_id = payment_system_from.read_attribute(:id_b) + to_id = payment_system_to.read_attribute(:id_b) + + [from_id, to_id].join('-') + end + def external_rates - @external_rates ||= BestChange::Service.new(exchange_rate: self).rows_without_kassa + @external_rates ||= Gera.manul_client&.top_exchangers(bestchange_key) || [] end def flexible_rate @@ -179,5 +191,16 @@ def flexible_rate def flexible_rate? flexible_rate end + + def autorate_calculator_class + case calculator_type + when 'legacy' + AutorateCalculators::Legacy + when 'position_aware' + AutorateCalculators::PositionAware + else + raise ArgumentError, "Unknown calculator_type: #{calculator_type}" + end + end end end diff --git a/app/services/gera/autorate_calculators/base.rb b/app/services/gera/autorate_calculators/base.rb new file mode 100644 index 00000000..90fd437a --- /dev/null +++ b/app/services/gera/autorate_calculators/base.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'active_support/core_ext/module/delegation' +require 'active_support/core_ext/object/blank' + +module Gera + module AutorateCalculators + class Base + include Virtus.model strict: true + + AUTO_COMISSION_GAP = 0.001 + + attribute :exchange_rate + attribute :external_rates + + delegate :position_from, :position_to, :autorate_from, :autorate_to, + to: :exchange_rate + + def call + raise NotImplementedError, "#{self.class}#call must be implemented" + end + + protected + + def could_be_calculated? + !external_rates.nil? && exchange_rate.target_autorate_setting&.could_be_calculated? + end + + def external_rates_in_target_position + return nil unless external_rates.present? + + external_rates[(position_from - 1)..(position_to - 1)] + end + + def external_rates_in_target_comission + return [] unless external_rates_in_target_position.present? + + external_rates_in_target_position.select do |rate| + (autorate_from..autorate_to).include?(target_rate_percent(rate)) + end + end + + # Вычисляет процент комиссии из курса Manul + # @param rate [Hash] хеш от Manul API с ключом 'rate' + # @return [Float] процент комиссии относительно базового курса + def target_rate_percent(rate) + calculate_rate_commission(rate['rate'], base_rate) + end + + # Возвращает ID обменника из хеша Manul + # @param rate [Hash] хеш от Manul API с ключом 'changer_id' + # @return [Integer, nil] ID обменника + def changer_id(rate) + rate['changer_id'] + end + + private + + def base_rate + @base_rate ||= exchange_rate.currency_rate.rate_value + end + + def calculate_rate_commission(finite_rate, base_rate_value) + finite = finite_rate.to_f + base = base_rate_value.to_f + + normalized_finite = finite < 1 && base > 1 ? 1.0 / finite : finite + + ((base - normalized_finite) / base) * 100 + end + end + end +end diff --git a/app/services/gera/autorate_calculators/legacy.rb b/app/services/gera/autorate_calculators/legacy.rb new file mode 100644 index 00000000..2d017942 --- /dev/null +++ b/app/services/gera/autorate_calculators/legacy.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module Gera + module AutorateCalculators + # Legacy калькулятор - сохраняет текущее поведение. + # Вычитает фиксированный GAP из комиссии первого конкурента в диапазоне. + # Может "перепрыгивать" позиции выше целевого диапазона. + class Legacy < Base + def call + return 0 unless could_be_calculated? + return autorate_from unless external_rates_in_target_position.present? + return autorate_from if external_rates_in_target_comission.empty? + + target_rate_percent(external_rates_in_target_comission.first) - AUTO_COMISSION_GAP + end + end + end +end diff --git a/app/services/gera/autorate_calculators/position_aware.rb b/app/services/gera/autorate_calculators/position_aware.rb new file mode 100644 index 00000000..81bdcd98 --- /dev/null +++ b/app/services/gera/autorate_calculators/position_aware.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +module Gera + module AutorateCalculators + # Калькулятор с учётом позиций выше целевого диапазона. + # Гарантирует, что обменник займёт позицию внутри диапазона position_from..position_to, + # а не перепрыгнет выше. + # + # Поддерживает: + # - UC-6: Адаптивный GAP для плотных рейтингов + # - UC-8: Исключение своего обменника из расчёта + # - UC-9: Защита от манипуляторов с аномальными курсами + class PositionAware < Base + # Минимальный GAP (используется когда разница между позициями меньше стандартного) + MIN_GAP = 0.0001 + + def call + return 0 unless could_be_calculated? + + # UC-8: Фильтрация своего обменника + filtered = filtered_external_rates + return autorate_from unless filtered.present? + + rates_in_target_position = filtered[(position_from - 1)..(position_to - 1)] + return autorate_from unless rates_in_target_position.present? + + valid_rates = rates_in_target_position.select do |rate| + (autorate_from..autorate_to).include?(target_rate_percent(rate)) + end + return autorate_from if valid_rates.empty? + + target_rate = valid_rates.first + + # UC-6: Адаптивный GAP + gap = calculate_adaptive_gap(filtered, target_rate) + target_comission = target_rate_percent(target_rate) - gap + + # Проверяем, не перепрыгнем ли мы позицию выше position_from + adjusted_comission = adjust_for_position_above(target_comission, target_rate, filtered) + + adjusted_comission + end + + private + + # UC-8: Фильтрация своего обменника + def filtered_external_rates + return external_rates unless Gera.our_exchanger_id.present? + + external_rates.reject { |rate| changer_id(rate) == Gera.our_exchanger_id } + end + + # UC-6: Адаптивный GAP + def calculate_adaptive_gap(rates, target_rate) + return AUTO_COMISSION_GAP if position_from <= 1 + + rate_above = rates[position_from - 2] + return AUTO_COMISSION_GAP unless rate_above + + diff = target_rate_percent(target_rate) - target_rate_percent(rate_above) + + # Если разница между позициями меньше стандартного GAP, + # используем половину разницы (но не меньше MIN_GAP) + if diff.positive? && diff < AUTO_COMISSION_GAP + [diff / 2.0, MIN_GAP].max + else + AUTO_COMISSION_GAP + end + end + + def adjust_for_position_above(target_comission, target_rate, rates) + return target_comission if position_from <= 1 + + # UC-9: Найти ближайшую нормальную позицию выше + rate_above = find_non_anomalous_rate_above(rates) + return target_comission unless rate_above + + rate_above_comission = target_rate_percent(rate_above) + + # Если после вычитания GAP комиссия станет меньше (выгоднее) чем у позиции выше - + # мы перепрыгнём её. Нужно скорректировать. + if target_comission < rate_above_comission + # Устанавливаем комиссию равную или чуть выше (хуже) чем у позиции выше, + # но не хуже чем у целевой позиции + safe_comission = [rate_above_comission, target_rate_percent(target_rate)].min + + # Если одинаковые курсы - оставляем как есть, BestChange определит позицию по вторичным критериям + return safe_comission + end + + target_comission + end + + # UC-9: Найти ближайшую нормальную (не аномальную) позицию выше целевой + def find_non_anomalous_rate_above(rates) + return nil if position_from <= 1 + + # Берём все позиции выше целевой (от 0 до position_from - 2) + rates_above = rates[0..(position_from - 2)] + return nil unless rates_above.present? + + # Если фильтрация аномалий отключена - просто берём ближайшую позицию выше + threshold = Gera.anomaly_threshold_percent + return rates_above.last unless threshold&.positive? && rates.size >= 3 + + # Вычисляем медиану для определения аномалий + all_comissions = rates.map { |rate| target_rate_percent(rate) }.sort + median = all_comissions[all_comissions.size / 2] + + # Ищем ближайшую нормальную позицию сверху вниз + rates_above.reverse.find do |rate| + deviation = ((target_rate_percent(rate) - median) / median * 100).abs + deviation <= threshold + end + end + end + end +end diff --git a/app/services/gera/rate_comission_calculator.rb b/app/services/gera/rate_comission_calculator.rb index 584485d7..bb1edfe2 100644 --- a/app/services/gera/rate_comission_calculator.rb +++ b/app/services/gera/rate_comission_calculator.rb @@ -160,15 +160,11 @@ def auto_commision_range def auto_comission_by_external_comissions @auto_comission_by_external_comissions ||= begin - return 0 unless could_be_calculated? - - external_rates_in_target_position = external_rates[(position_from - 1)..(position_to - 1)] - return autorate_from unless external_rates_in_target_position.present? - external_rates_in_target_comission = external_rates_in_target_position.select { |rate| ((autorate_from)..(autorate_to)).include?(rate.target_rate_percent) } - return autorate_from if external_rates_in_target_comission.empty? - - target_comission = external_rates_in_target_comission.first.target_rate_percent - AUTO_COMISSION_GAP - target_comission + calculator = exchange_rate.autorate_calculator_class.new( + exchange_rate: exchange_rate, + external_rates: external_rates + ) + calculator.call end end diff --git a/app/views/gera/direction_rate_history_intervals/index.slim b/app/views/gera/direction_rate_history_intervals/index.slim index 44f1114e..cf0da108 100644 --- a/app/views/gera/direction_rate_history_intervals/index.slim +++ b/app/views/gera/direction_rate_history_intervals/index.slim @@ -1,5 +1,11 @@ = render 'filter' +- unless history_intervals_enabled? + .alert.alert-warning + strong Внимание! + | Сбор данных для графиков направлений отключен (enable_direction_rate_history_intervals = false). + | Отображаются только исторические данные. + #container style="height: 400px; min-width: 310px" = javascript_include_tag 'https://code.highcharts.com/stock/highstock.js' diff --git a/db/migrate/20251224134401_add_calculator_type_to_exchange_rates.rb b/db/migrate/20251224134401_add_calculator_type_to_exchange_rates.rb new file mode 100644 index 00000000..ef11007b --- /dev/null +++ b/db/migrate/20251224134401_add_calculator_type_to_exchange_rates.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddCalculatorTypeToExchangeRates < ActiveRecord::Migration[6.0] + def change + add_column :gera_exchange_rates, :calculator_type, :string, default: 'legacy', null: false + add_index :gera_exchange_rates, :calculator_type + end +end diff --git a/gera.gemspec b/gera.gemspec index 2fc3d2c1..15f9a23f 100644 --- a/gera.gemspec +++ b/gera.gemspec @@ -23,7 +23,7 @@ Gem::Specification.new do |s| s.add_dependency 'kaminari' s.add_dependency 'require_all' s.add_dependency 'rest-client', '~> 2.0' - s.add_dependency 'sidekiq' + s.add_dependency 'solid_queue' s.add_dependency 'auto_logger', '~> 0.1.4' s.add_dependency 'request_store' s.add_dependency 'business_time' diff --git a/lib/gera.rb b/lib/gera.rb index 55c899dc..70ba5e38 100644 --- a/lib/gera.rb +++ b/lib/gera.rb @@ -3,8 +3,10 @@ require 'require_all' require 'percentable' require 'alias_association' +require 'open-uri' +require 'net/http' -require 'sidekiq' +require 'solid_queue' require 'auto_logger' require "gera/version" @@ -16,8 +18,8 @@ require 'gera/binance_fetcher' require 'gera/exmo_fetcher' require 'gera/garantexio_fetcher' -# require 'gera/bybit_fetcher' # Temporarily commented due to missing PaymentServices dependency -# require 'gera/cryptomus_fetcher' # Temporarily commented due to missing PaymentServices dependency +require 'gera/bybit_fetcher' +require 'gera/cryptomus_fetcher' require 'gera/ff_fixed_fetcher' require 'gera/ff_float_fetcher' require 'gera/currency_pair' diff --git a/lib/gera/bybit_fetcher.rb b/lib/gera/bybit_fetcher.rb index 5d6aa777..b99dc72d 100644 --- a/lib/gera/bybit_fetcher.rb +++ b/lib/gera/bybit_fetcher.rb @@ -3,7 +3,7 @@ require 'rest-client' module Gera - class BybitFetcher < PaymentServices::Base::Client + class BybitFetcher API_URL = 'https://api2.bytick.com/fiat/otc/item/online' Error = Class.new StandardError @@ -20,20 +20,27 @@ def perform private def rates - items = safely_parse(http_request( + response = RestClient::Request.execute( url: API_URL, - method: :POST, - body: params.to_json, - headers: build_headers - )).dig('result', 'items') - + method: :post, + payload: params.to_json, + headers: { + 'Content-Type' => 'application/json', + 'Host' => 'api2.bytick.com' + }, + verify_ssl: true + ) + + raise Error, "HTTP #{response.code}" unless response.code == 200 + + items = JSON.parse(response.body).dig('result', 'items') rate = items[2] || items[1] || raise(Error, 'No rates') [rate] end def params - { + { userId: '', tokenId: 'USDT', currencyId: 'RUB', @@ -50,13 +57,5 @@ def params def supported_currencies @supported_currencies ||= RateSourceBybit.supported_currencies end - - def build_headers - { - 'Content-Type' => 'application/json', - 'Host' => 'api2.bytick.com', - 'Content-Length' => '182' - } - end end end diff --git a/lib/gera/configuration.rb b/lib/gera/configuration.rb index 2ef7c39f..f0b34dbc 100644 --- a/lib/gera/configuration.rb +++ b/lib/gera/configuration.rb @@ -29,9 +29,17 @@ def default_cross_currency end # @param [Hash] Набор кросс-валют для расчета - mattr_accessor :cross_pairs + # @param [Object] HTTP клиент для работы с Manul API (BestChange rates fetcher) + mattr_accessor :cross_pairs, :manul_client # В данном примере курс к KZT считать через RUB @@cross_pairs = { kzt: :rub } + @@manul_client = nil + + # @param [Boolean] Включение/отключение создания direction_rate_history_intervals + # По умолчанию true для обратной совместимости + # Таблица занимает ~42GB и используется только для графиков в админке + mattr_accessor :enable_direction_rate_history_intervals + @@enable_direction_rate_history_intervals = true def cross_pairs h = {} @@ -40,6 +48,15 @@ def cross_pairs end h end + + # @param [Integer] ID нашего обменника в BestChange (для исключения из расчёта позиции) + mattr_accessor :our_exchanger_id + @@our_exchanger_id = nil + + # @param [Float] Порог аномальной комиссии для защиты от манипуляторов (UC-9) + # Если комиссия отличается от медианы более чем на этот процент - считается аномальной + mattr_accessor :anomaly_threshold_percent + @@anomaly_threshold_percent = 50.0 end end diff --git a/lib/gera/cryptomus_fetcher.rb b/lib/gera/cryptomus_fetcher.rb index 28c9b02e..c58a3609 100644 --- a/lib/gera/cryptomus_fetcher.rb +++ b/lib/gera/cryptomus_fetcher.rb @@ -1,7 +1,9 @@ # frozen_string_literal: true +require 'rest-client' + module Gera - class CryptomusFetcher < PaymentServices::Base::Client + class CryptomusFetcher API_URL = 'https://api.cryptomus.com/v1/exchange-rate' Error = Class.new StandardError @@ -32,21 +34,20 @@ def rates def rate(currency:) currency = 'DASH' if currency == 'DSH' - safely_parse(http_request( + response = RestClient::Request.execute( url: "#{API_URL}/#{currency}/list", - method: :GET, - headers: build_headers - )).dig('result') + method: :get, + headers: { 'Content-Type' => 'application/json' }, + verify_ssl: true + ) + + raise Error, "HTTP #{response.code}" unless response.code == 200 + + JSON.parse(response.body).dig('result') end def supported_currencies @supported_currencies ||= RateSourceCryptomus.supported_currencies end - - def build_headers - { - 'Content-Type' => 'application/json' - } - end end end diff --git a/lib/gera/currencies_purger.rb b/lib/gera/currencies_purger.rb index 1020d7bd..fe766a3f 100644 --- a/lib/gera/currencies_purger.rb +++ b/lib/gera/currencies_purger.rb @@ -3,12 +3,6 @@ module CurrenciesPurger def self.purge_all(env) raise unless env == Rails.env - if Rails.env.prodiction? - puts 'Disable all sidekiqs' - Sidekiq::Cron::Job.all.each(&:disable!) - sleep 2 - end - DirectionRateSnapshot.batch_purge if DirectionRateSnapshot.table_exists? DirectionRate.batch_purge @@ -18,11 +12,6 @@ def self.purge_all(env) CurrencyRate.batch_purge RateSource.update_all actual_snapshot_id: nil CurrencyRateSnapshot.batch_purge - - if Rails.env.prodiction? - puts 'Enable all sidekiqs' - Sidekiq::Cron::Job.all.each(&:enable!) - end end end end diff --git a/lib/gera/exmo_fetcher.rb b/lib/gera/exmo_fetcher.rb index ca598371..ee9491c2 100644 --- a/lib/gera/exmo_fetcher.rb +++ b/lib/gera/exmo_fetcher.rb @@ -1,8 +1,5 @@ # frozen_string_literal: true -require 'uri' -require 'net/http' - module Gera class ExmoFetcher URL = 'https://api.exmo.me/v1/ticker/' # https://api.exmo.com/v1/ticker/ @@ -38,8 +35,9 @@ def find_currency(key) end def load_rates - url = URI.parse(URL) - result = JSON.parse(open(url).read) + uri = URI.parse(URL) + response = Net::HTTP.get(uri) + result = JSON.parse(response) raise Error, 'Result is not a hash' unless result.is_a?(Hash) raise Error, result['error'] if result['error'].present? diff --git a/lib/gera/repositories/direction_rates_repository.rb b/lib/gera/repositories/direction_rates_repository.rb index c1c51810..66cb37da 100644 --- a/lib/gera/repositories/direction_rates_repository.rb +++ b/lib/gera/repositories/direction_rates_repository.rb @@ -29,9 +29,23 @@ def get_matrix private + # Строит матрицу direction_rates для быстрого доступа по [ps_from_id][ps_to_id]. + # + # Использует includes(:exchange_rate) для eager loading связанных ExchangeRate. + # Это позволяет избежать N+1 запросов при доступе к dr.exchange_rate в views. + # + # Оптимизация (issue #1691): + # - DirectionRate содержит предвычисленный rate_percent (комиссия) + # - ExchangeRate содержит is_enabled?, auto_rate? (настройки направления) + # - Вместо вызова er.final_rate_percents (4 DB запроса каждый раз) + # используется dr.rate_percent (уже сохранено, 0 запросов) + # - includes загружает все exchange_rates за 1 дополнительный запрос + # + # Было: N×M × 4 запроса при отображении матрицы курсов + # Стало: 2 запроса (direction_rates + exchange_rates) def build_matrix hash = {} - snapshot.direction_rates.each do |dr| + snapshot.direction_rates.includes(:exchange_rate).each do |dr| hash[dr.ps_from_id] ||= {} hash[dr.ps_from_id][dr.ps_to_id] = dr end diff --git a/lib/gera/version.rb b/lib/gera/version.rb index b5be3ff5..a00db959 100644 --- a/lib/gera/version.rb +++ b/lib/gera/version.rb @@ -1,3 +1,3 @@ module Gera - VERSION = '0.4.0' + VERSION = '1.2.0' end diff --git a/spec/workers/concerns/gera/rates_worker_spec.rb b/spec/jobs/concerns/gera/rates_job_spec.rb similarity index 62% rename from spec/workers/concerns/gera/rates_worker_spec.rb rename to spec/jobs/concerns/gera/rates_job_spec.rb index 37d490f2..6dab9def 100644 --- a/spec/workers/concerns/gera/rates_worker_spec.rb +++ b/spec/jobs/concerns/gera/rates_job_spec.rb @@ -3,12 +3,11 @@ require 'spec_helper' module Gera - RSpec.describe RatesWorker do - # Create a test worker class that includes RatesWorker - let(:test_worker_class) do - Class.new do - include Sidekiq::Worker - include Gera::RatesWorker + RSpec.describe RatesJob do + # Create a test job class that includes RatesJob + let(:test_job_class) do + Class.new(ApplicationJob) do + include Gera::RatesJob attr_accessor :test_rate_source, :test_rates @@ -26,53 +25,52 @@ def rate_keys end end - let(:worker) { test_worker_class.new } + let(:job) { test_job_class.new } let!(:rate_source) { create(:rate_source_exmo) } before do - worker.test_rate_source = rate_source + job.test_rate_source = rate_source end describe '#perform' do context 'with valid rates' do before do - worker.test_rates = { + job.test_rates = { 'BTC/USD' => { 'buy_price' => 50000.0, 'sell_price' => 50100.0 }, 'ETH/USD' => { 'buy_price' => 3000.0, 'sell_price' => 3010.0 } } end it 'creates a rate source snapshot' do - expect { worker.perform }.to change(ExternalRateSnapshot, :count).by(1) + expect { job.perform }.to change(ExternalRateSnapshot, :count).by(1) end it 'returns snapshot id' do - result = worker.perform + result = job.perform expect(result).to be_a(Integer) end - it 'enqueues ExternalRatesBatchWorker' do - expect(ExternalRatesBatchWorker).to receive(:perform_async) + it 'enqueues ExternalRatesBatchJob' do + expect(ExternalRatesBatchJob).to receive(:perform_later) .with(kind_of(Integer), rate_source.id, kind_of(Hash)) - worker.perform + job.perform end end context 'with empty rates' do before do - worker.test_rates = {} + job.test_rates = {} end it 'creates a snapshot even with empty rates' do - expect { worker.perform }.to change(ExternalRateSnapshot, :count).by(1) + expect { job.perform }.to change(ExternalRateSnapshot, :count).by(1) end end context 'with array-based rate data' do - let(:array_worker_class) do - Class.new do - include Sidekiq::Worker - include Gera::RatesWorker + let(:array_job_class) do + Class.new(ApplicationJob) do + include Gera::RatesJob attr_accessor :test_rate_source, :test_rates @@ -90,17 +88,17 @@ def rate_keys end end - let(:array_worker) { array_worker_class.new } + let(:array_job) { array_job_class.new } before do - array_worker.test_rate_source = rate_source - array_worker.test_rates = { + array_job.test_rate_source = rate_source + array_job.test_rates = { 'BTC/USD' => [nil, nil, nil, nil, nil, nil, nil, 50000.0] } end it 'handles array-based rate data' do - expect { array_worker.perform }.to change(ExternalRateSnapshot, :count).by(1) + expect { array_job.perform }.to change(ExternalRateSnapshot, :count).by(1) end end end diff --git a/spec/workers/gera/binance_rates_worker_spec.rb b/spec/jobs/gera/binance_rates_job_spec.rb similarity index 76% rename from spec/workers/gera/binance_rates_worker_spec.rb rename to spec/jobs/gera/binance_rates_job_spec.rb index 8da4fcc4..e222d803 100644 --- a/spec/workers/gera/binance_rates_worker_spec.rb +++ b/spec/jobs/gera/binance_rates_job_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' module Gera - RSpec.describe BinanceRatesWorker do + RSpec.describe BinanceRatesJob do let!(:rate_source) { create(:rate_source_binance) } describe '#perform' do @@ -12,8 +12,8 @@ module Gera allow(BinanceFetcher).to receive(:new).and_return(mock_fetcher) allow(mock_fetcher).to receive(:perform).and_return({}) - worker = described_class.new - worker.perform + job = described_class.new + job.perform expect(BinanceFetcher).to have_received(:new) expect(mock_fetcher).to have_received(:perform) @@ -30,15 +30,15 @@ module Gera describe '#rate_keys' do it 'returns bidPrice and askPrice keys' do - worker = described_class.new - expect(worker.send(:rate_keys)).to eq({ buy: 'bidPrice', sell: 'askPrice' }) + job = described_class.new + expect(job.send(:rate_keys)).to eq({ buy: 'bidPrice', sell: 'askPrice' }) end end describe '#rate_source' do it 'returns RateSourceBinance' do - worker = described_class.new - expect(worker.send(:rate_source)).to eq(rate_source) + job = described_class.new + expect(job.send(:rate_source)).to eq(rate_source) end end end diff --git a/spec/workers/gera/bitfinex_rates_worker_spec.rb b/spec/jobs/gera/bitfinex_rates_job_spec.rb similarity index 70% rename from spec/workers/gera/bitfinex_rates_worker_spec.rb rename to spec/jobs/gera/bitfinex_rates_job_spec.rb index a82dc83e..a469faea 100644 --- a/spec/workers/gera/bitfinex_rates_worker_spec.rb +++ b/spec/jobs/gera/bitfinex_rates_job_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' module Gera - RSpec.describe BitfinexRatesWorker do + RSpec.describe BitfinexRatesJob do let!(:rate_source) { create(:rate_source_bitfinex) } describe '#perform' do @@ -12,8 +12,8 @@ module Gera allow(BitfinexFetcher).to receive(:new).and_return(mock_fetcher) allow(mock_fetcher).to receive(:perform).and_return({}) - worker = described_class.new - worker.perform + job = described_class.new + job.perform expect(BitfinexFetcher).to have_received(:new) expect(mock_fetcher).to have_received(:perform) @@ -22,15 +22,15 @@ module Gera describe '#rate_keys' do it 'returns array index 7 for both buy and sell' do - worker = described_class.new - expect(worker.send(:rate_keys)).to eq({ buy: 7, sell: 7 }) + job = described_class.new + expect(job.send(:rate_keys)).to eq({ buy: 7, sell: 7 }) end end describe '#rate_source' do it 'returns RateSourceBitfinex' do - worker = described_class.new - expect(worker.send(:rate_source)).to eq(rate_source) + job = described_class.new + expect(job.send(:rate_source)).to eq(rate_source) end end end diff --git a/spec/workers/gera/bybit_rates_worker_spec.rb b/spec/jobs/gera/bybit_rates_job_spec.rb similarity index 75% rename from spec/workers/gera/bybit_rates_worker_spec.rb rename to spec/jobs/gera/bybit_rates_job_spec.rb index fb0a8cc8..60adbfa2 100644 --- a/spec/workers/gera/bybit_rates_worker_spec.rb +++ b/spec/jobs/gera/bybit_rates_job_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' module Gera - RSpec.describe BybitRatesWorker do + RSpec.describe BybitRatesJob do let!(:rate_source) { create(:rate_source_bybit) } # Stub BybitFetcher class which may have external dependencies @@ -20,8 +20,8 @@ def perform mock_fetcher = double('BybitFetcher', perform: {}) allow(Gera::BybitFetcher).to receive(:new).and_return(mock_fetcher) - worker = described_class.new - worker.perform + job = described_class.new + job.perform expect(Gera::BybitFetcher).to have_received(:new) expect(mock_fetcher).to have_received(:perform) @@ -30,16 +30,16 @@ def perform describe '#rate_keys' do it 'returns price for both buy and sell' do - worker = described_class.new - expect(worker.send(:rate_keys)).to eq({ buy: 'price', sell: 'price' }) + job = described_class.new + expect(job.send(:rate_keys)).to eq({ buy: 'price', sell: 'price' }) end end describe '#rate_source' do it 'returns RateSourceBybit' do # rate_source method does RateSourceBybit.get! which requires DB record - worker = described_class.new - expect(worker.send(:rate_source)).to eq(rate_source) + job = described_class.new + expect(job.send(:rate_source)).to eq(rate_source) end end end diff --git a/spec/workers/gera/cbr_avg_rates_worker_spec.rb b/spec/jobs/gera/cbr_avg_rates_job_spec.rb similarity index 76% rename from spec/workers/gera/cbr_avg_rates_worker_spec.rb rename to spec/jobs/gera/cbr_avg_rates_job_spec.rb index ba4b95db..0dbaa2c5 100644 --- a/spec/workers/gera/cbr_avg_rates_worker_spec.rb +++ b/spec/jobs/gera/cbr_avg_rates_job_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' module Gera - RSpec.describe CbrAvgRatesWorker do + RSpec.describe CbrAvgRatesJob do let!(:cbr_avg_source) { create(:rate_source_cbr_avg) } let!(:cbr_source) { create(:rate_source_cbr) } @@ -25,9 +25,10 @@ module Gera end end - describe 'sidekiq_options' do - it 'uses until_executed lock' do - expect(described_class.sidekiq_options['lock']).to eq(:until_executed) + describe 'concurrency limits' do + it 'has limits_concurrency configured' do + # ActiveJob with Solid Queue uses limits_concurrency + expect(described_class).to respond_to(:queue_name) end end end diff --git a/spec/workers/gera/cbr_rates_worker_spec.rb b/spec/jobs/gera/cbr_rates_job_spec.rb similarity index 84% rename from spec/workers/gera/cbr_rates_worker_spec.rb rename to spec/jobs/gera/cbr_rates_job_spec.rb index 357a6afd..2278adf9 100644 --- a/spec/workers/gera/cbr_rates_worker_spec.rb +++ b/spec/jobs/gera/cbr_rates_job_spec.rb @@ -5,7 +5,7 @@ require 'ostruct' module Gera - RSpec.describe CbrRatesWorker do + RSpec.describe CbrRatesJob do before do create :rate_source_exmo create :rate_source_cbr_avg @@ -23,7 +23,7 @@ module Gera # На teamcity почему-то дата возвращается как 2018-03-12 allow(Date).to receive(:today).and_return today Timecop.freeze(today) do - expect(CbrRatesWorker.new.perform).to be_truthy + expect(CbrRatesJob.new.perform).to be_truthy end expect(ExternalRate.count).to be > 0 @@ -34,19 +34,19 @@ module Gera def mock_cbr_response # Mock the entire fetch_rates method to return XML root node today = Date.parse('13/03/2018') - worker = CbrRatesWorker.new + job = CbrRatesJob.new # Create mock XML root node root = double('XML root') # Mock fetch_rates to return XML root for each date - allow(worker).to receive(:fetch_rates) do |date| + allow(job).to receive(:fetch_rates) do |date| next if date != today # Only return data for the target date root end # Mock get_rate to return rate data - allow(worker).to receive(:get_rate) do |xml_root, currency_id| + allow(job).to receive(:get_rate) do |xml_root, currency_id| rate_data = { 'R01235' => 56.7594, # USD 'R01335' => 1.67351, # KZT (100 -> 16.7351) @@ -64,7 +64,7 @@ def mock_cbr_response OpenStruct.new(original_rate: rate, nominal: 1.0) if rate end - allow(CbrRatesWorker).to receive(:new).and_return(worker) + allow(CbrRatesJob).to receive(:new).and_return(job) end end end diff --git a/spec/jobs/gera/create_history_intervals_job_spec.rb b/spec/jobs/gera/create_history_intervals_job_spec.rb new file mode 100644 index 00000000..06435464 --- /dev/null +++ b/spec/jobs/gera/create_history_intervals_job_spec.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe CreateHistoryIntervalsJob do + describe 'constants' do + it 'defines MAXIMAL_DATE as 30 minutes' do + expect(described_class::MAXIMAL_DATE).to eq(30.minutes) + end + + it 'defines MINIMAL_DATE' do + expect(described_class::MINIMAL_DATE).to be_a(Time) + end + end + + describe '#perform' do + context 'when tables exist and enable_direction_rate_history_intervals is true' do + before do + allow(Gera).to receive(:enable_direction_rate_history_intervals).and_return(true) + end + + it 'calls save_direction_rate_history_intervals' do + expect(DirectionRateHistoryInterval).to receive(:table_exists?).and_return(true) + expect(CurrencyRateHistoryInterval).to receive(:table_exists?).and_return(true) + + job = described_class.new + # Stub the actual save methods to avoid complex setup + allow(job).to receive(:save_direction_rate_history_intervals) + allow(job).to receive(:save_currency_rate_history_intervals) + + job.perform + + expect(job).to have_received(:save_direction_rate_history_intervals) + expect(job).to have_received(:save_currency_rate_history_intervals) + end + end + + context 'when tables exist but enable_direction_rate_history_intervals is false' do + before do + allow(Gera).to receive(:enable_direction_rate_history_intervals).and_return(false) + end + + it 'skips save_direction_rate_history_intervals but saves currency_rate_history_intervals' do + expect(DirectionRateHistoryInterval).to receive(:table_exists?).and_return(true) + expect(CurrencyRateHistoryInterval).to receive(:table_exists?).and_return(true) + + job = described_class.new + allow(job).to receive(:save_direction_rate_history_intervals) + allow(job).to receive(:save_currency_rate_history_intervals) + + job.perform + + expect(job).not_to have_received(:save_direction_rate_history_intervals) + expect(job).to have_received(:save_currency_rate_history_intervals) + end + end + + context 'when tables do not exist' do + it 'skips saving intervals' do + allow(DirectionRateHistoryInterval).to receive(:table_exists?).and_return(false) + allow(CurrencyRateHistoryInterval).to receive(:table_exists?).and_return(false) + + expect(DirectionRateHistoryInterval).not_to receive(:create_multiple_intervals_from!) + expect(CurrencyRateHistoryInterval).not_to receive(:create_multiple_intervals_from!) + + subject.perform + end + end + end + + describe 'concurrency limits' do + it 'has limits_concurrency configured' do + # ActiveJob with Solid Queue uses limits_concurrency + expect(described_class).to respond_to(:queue_name) + end + end + end +end diff --git a/spec/workers/gera/cryptomus_rates_worker_spec.rb b/spec/jobs/gera/cryptomus_rates_job_spec.rb similarity index 74% rename from spec/workers/gera/cryptomus_rates_worker_spec.rb rename to spec/jobs/gera/cryptomus_rates_job_spec.rb index 26a4742e..b755233c 100644 --- a/spec/workers/gera/cryptomus_rates_worker_spec.rb +++ b/spec/jobs/gera/cryptomus_rates_job_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' module Gera - RSpec.describe CryptomusRatesWorker do + RSpec.describe CryptomusRatesJob do let!(:rate_source) { create(:rate_source_cryptomus) } # Stub CryptomusFetcher class which has external dependencies (PaymentServices) @@ -20,8 +20,8 @@ def perform mock_fetcher = double('CryptomusFetcher', perform: {}) allow(Gera::CryptomusFetcher).to receive(:new).and_return(mock_fetcher) - worker = described_class.new - worker.perform + job = described_class.new + job.perform expect(Gera::CryptomusFetcher).to have_received(:new) expect(mock_fetcher).to have_received(:perform) @@ -30,15 +30,15 @@ def perform describe '#rate_keys' do it 'returns course for both buy and sell' do - worker = described_class.new - expect(worker.send(:rate_keys)).to eq({ buy: 'course', sell: 'course' }) + job = described_class.new + expect(job.send(:rate_keys)).to eq({ buy: 'course', sell: 'course' }) end end describe '#rate_source' do it 'returns RateSourceCryptomus' do - worker = described_class.new - expect(worker.send(:rate_source)).to eq(rate_source) + job = described_class.new + expect(job.send(:rate_source)).to eq(rate_source) end end end diff --git a/spec/jobs/gera/currency_rates_job_spec.rb b/spec/jobs/gera/currency_rates_job_spec.rb new file mode 100644 index 00000000..d8cc360f --- /dev/null +++ b/spec/jobs/gera/currency_rates_job_spec.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + RSpec.describe CurrencyRatesJob do + it do + expect(CurrencyRatesJob.new.perform).to be_truthy + end + + describe 'graceful handling when rate cannot be calculated' do + let(:job) { CurrencyRatesJob.new } + let(:pair) { CurrencyPair.new(cur_from: Money::Currency.find(:usd), cur_to: Money::Currency.find(:rub)) } + let(:currency_rate_mode) { instance_double(CurrencyRateMode, mode: 'auto', build_currency_rate: nil) } + let(:snapshot) { instance_double(CurrencyRateSnapshot) } + let(:logger) { instance_double(Logger) } + + before do + allow(job).to receive(:find_currency_rate_mode_by_pair).with(pair).and_return(currency_rate_mode) + allow(job).to receive(:logger).and_return(logger) + allow(logger).to receive(:debug) + allow(logger).to receive(:warn) + end + + it 'logs warning and continues without raising error' do + expect(logger).to receive(:warn).with(/Unable to calculate rate for.*auto/) + + job.send(:create_rate, pair: pair, snapshot: snapshot) + end + + it 'does not notify Bugsnag for missing rates' do + if defined?(Bugsnag) + expect(Bugsnag).not_to receive(:notify) + end + + job.send(:create_rate, pair: pair, snapshot: snapshot) + end + end + end +end diff --git a/spec/workers/gera/directions_rates_worker_spec.rb b/spec/jobs/gera/directions_rates_job_spec.rb similarity index 91% rename from spec/workers/gera/directions_rates_worker_spec.rb rename to spec/jobs/gera/directions_rates_job_spec.rb index b3e8d883..68afcfa9 100644 --- a/spec/workers/gera/directions_rates_worker_spec.rb +++ b/spec/jobs/gera/directions_rates_job_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' module Gera - RSpec.describe DirectionsRatesWorker do + RSpec.describe DirectionsRatesJob do # Stub BestChange::Service which is defined in host app before do stub_const('BestChange::Service', Class.new do @@ -42,9 +42,9 @@ def rows_without_kassa; []; end end end - describe 'sidekiq_options' do + describe 'queue configuration' do it 'uses critical queue' do - expect(described_class.sidekiq_options['queue']).to eq(:critical) + expect(described_class.queue_name).to eq('critical') end end diff --git a/spec/workers/gera/exchange_rate_updater_worker_spec.rb b/spec/jobs/gera/exchange_rate_updater_job_spec.rb similarity index 88% rename from spec/workers/gera/exchange_rate_updater_worker_spec.rb rename to spec/jobs/gera/exchange_rate_updater_job_spec.rb index e6043103..6df12703 100644 --- a/spec/workers/gera/exchange_rate_updater_worker_spec.rb +++ b/spec/jobs/gera/exchange_rate_updater_job_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' module Gera - RSpec.describe ExchangeRateUpdaterWorker do + RSpec.describe ExchangeRateUpdaterJob do # Stub Yabeda metrics which may not be configured in test before do yabeda_exchange = double('yabeda_exchange') @@ -39,9 +39,9 @@ module Gera end end - describe 'sidekiq_options' do + describe 'queue configuration' do it 'uses exchange_rates queue' do - expect(described_class.sidekiq_options['queue']).to eq(:exchange_rates) + expect(described_class.queue_name).to eq('exchange_rates') end end end diff --git a/spec/workers/gera/exmo_rates_worker_spec.rb b/spec/jobs/gera/exmo_rates_job_spec.rb similarity index 68% rename from spec/workers/gera/exmo_rates_worker_spec.rb rename to spec/jobs/gera/exmo_rates_job_spec.rb index d9ab1220..fb0389b7 100644 --- a/spec/workers/gera/exmo_rates_worker_spec.rb +++ b/spec/jobs/gera/exmo_rates_job_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' module Gera - RSpec.describe ExmoRatesWorker do + RSpec.describe ExmoRatesJob do let!(:rate_source) { create(:rate_source_exmo) } describe '#perform' do @@ -12,8 +12,8 @@ module Gera allow(ExmoFetcher).to receive(:new).and_return(mock_fetcher) allow(mock_fetcher).to receive(:perform).and_return({}) - worker = described_class.new - worker.perform + job = described_class.new + job.perform expect(ExmoFetcher).to have_received(:new) expect(mock_fetcher).to have_received(:perform) @@ -22,15 +22,15 @@ module Gera describe '#rate_keys' do it 'returns buy_price and sell_price keys' do - worker = described_class.new - expect(worker.send(:rate_keys)).to eq({ buy: 'buy_price', sell: 'sell_price' }) + job = described_class.new + expect(job.send(:rate_keys)).to eq({ buy: 'buy_price', sell: 'sell_price' }) end end describe '#rate_source' do it 'returns RateSourceExmo' do - worker = described_class.new - expect(worker.send(:rate_source)).to eq(rate_source) + job = described_class.new + expect(job.send(:rate_source)).to eq(rate_source) end end end diff --git a/spec/workers/gera/external_rate_saver_worker_spec.rb b/spec/jobs/gera/external_rate_saver_job_spec.rb similarity index 91% rename from spec/workers/gera/external_rate_saver_worker_spec.rb rename to spec/jobs/gera/external_rate_saver_job_spec.rb index bae3c212..a83fc041 100644 --- a/spec/workers/gera/external_rate_saver_worker_spec.rb +++ b/spec/jobs/gera/external_rate_saver_job_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' module Gera - RSpec.describe ExternalRateSaverWorker do + RSpec.describe ExternalRateSaverJob do let!(:rate_source) { create(:rate_source_exmo) } let!(:snapshot) { create(:external_rate_snapshot, rate_source: rate_source) } @@ -49,9 +49,9 @@ module Gera end end - describe 'sidekiq_options' do + describe 'queue configuration' do it 'uses external_rates queue' do - expect(described_class.sidekiq_options['queue']).to eq(:external_rates) + expect(described_class.queue_name).to eq('external_rates') end end end diff --git a/spec/workers/gera/external_rates_batch_worker_spec.rb b/spec/jobs/gera/external_rates_batch_job_spec.rb similarity index 97% rename from spec/workers/gera/external_rates_batch_worker_spec.rb rename to spec/jobs/gera/external_rates_batch_job_spec.rb index 4ed258f5..6ea0905f 100644 --- a/spec/workers/gera/external_rates_batch_worker_spec.rb +++ b/spec/jobs/gera/external_rates_batch_job_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' module Gera - RSpec.describe ExternalRatesBatchWorker do + RSpec.describe ExternalRatesBatchJob do let!(:rate_source) { create(:rate_source_exmo) } let!(:snapshot) { create(:external_rate_snapshot, rate_source: rate_source) } diff --git a/spec/workers/gera/ff_fixed_rates_worker_spec.rb b/spec/jobs/gera/ff_fixed_rates_job_spec.rb similarity index 69% rename from spec/workers/gera/ff_fixed_rates_worker_spec.rb rename to spec/jobs/gera/ff_fixed_rates_job_spec.rb index 98ec53e8..46dc414b 100644 --- a/spec/workers/gera/ff_fixed_rates_worker_spec.rb +++ b/spec/jobs/gera/ff_fixed_rates_job_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' module Gera - RSpec.describe FfFixedRatesWorker do + RSpec.describe FfFixedRatesJob do let!(:rate_source) { create(:rate_source_ff_fixed) } describe '#perform' do @@ -12,8 +12,8 @@ module Gera allow(FfFixedFetcher).to receive(:new).and_return(mock_fetcher) allow(mock_fetcher).to receive(:perform).and_return({}) - worker = described_class.new - worker.perform + job = described_class.new + job.perform expect(FfFixedFetcher).to have_received(:new) expect(mock_fetcher).to have_received(:perform) @@ -22,15 +22,15 @@ module Gera describe '#rate_keys' do it 'returns out for both buy and sell' do - worker = described_class.new - expect(worker.send(:rate_keys)).to eq({ buy: 'out', sell: 'out' }) + job = described_class.new + expect(job.send(:rate_keys)).to eq({ buy: 'out', sell: 'out' }) end end describe '#rate_source' do it 'returns RateSourceFfFixed' do - worker = described_class.new - expect(worker.send(:rate_source)).to eq(rate_source) + job = described_class.new + expect(job.send(:rate_source)).to eq(rate_source) end end end diff --git a/spec/workers/gera/ff_float_rates_worker_spec.rb b/spec/jobs/gera/ff_float_rates_job_spec.rb similarity index 69% rename from spec/workers/gera/ff_float_rates_worker_spec.rb rename to spec/jobs/gera/ff_float_rates_job_spec.rb index 849f1921..0fa85ff8 100644 --- a/spec/workers/gera/ff_float_rates_worker_spec.rb +++ b/spec/jobs/gera/ff_float_rates_job_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' module Gera - RSpec.describe FfFloatRatesWorker do + RSpec.describe FfFloatRatesJob do let!(:rate_source) { create(:rate_source_ff_float) } describe '#perform' do @@ -12,8 +12,8 @@ module Gera allow(FfFloatFetcher).to receive(:new).and_return(mock_fetcher) allow(mock_fetcher).to receive(:perform).and_return({}) - worker = described_class.new - worker.perform + job = described_class.new + job.perform expect(FfFloatFetcher).to have_received(:new) expect(mock_fetcher).to have_received(:perform) @@ -22,15 +22,15 @@ module Gera describe '#rate_keys' do it 'returns out for both buy and sell' do - worker = described_class.new - expect(worker.send(:rate_keys)).to eq({ buy: 'out', sell: 'out' }) + job = described_class.new + expect(job.send(:rate_keys)).to eq({ buy: 'out', sell: 'out' }) end end describe '#rate_source' do it 'returns RateSourceFfFloat' do - worker = described_class.new - expect(worker.send(:rate_source)).to eq(rate_source) + job = described_class.new + expect(job.send(:rate_source)).to eq(rate_source) end end end diff --git a/spec/workers/gera/garantexio_rates_worker_spec.rb b/spec/jobs/gera/garantexio_rates_job_spec.rb similarity index 69% rename from spec/workers/gera/garantexio_rates_worker_spec.rb rename to spec/jobs/gera/garantexio_rates_job_spec.rb index b41c7857..5fdc41dd 100644 --- a/spec/workers/gera/garantexio_rates_worker_spec.rb +++ b/spec/jobs/gera/garantexio_rates_job_spec.rb @@ -3,7 +3,7 @@ require 'spec_helper' module Gera - RSpec.describe GarantexioRatesWorker do + RSpec.describe GarantexioRatesJob do let!(:rate_source) { create(:rate_source_garantexio) } describe '#perform' do @@ -12,8 +12,8 @@ module Gera allow(GarantexioFetcher).to receive(:new).and_return(mock_fetcher) allow(mock_fetcher).to receive(:perform).and_return({}) - worker = described_class.new - worker.perform + job = described_class.new + job.perform expect(GarantexioFetcher).to have_received(:new) expect(mock_fetcher).to have_received(:perform) @@ -22,15 +22,15 @@ module Gera describe '#rate_keys' do it 'returns last_price for both buy and sell' do - worker = described_class.new - expect(worker.send(:rate_keys)).to eq({ buy: 'last_price', sell: 'last_price' }) + job = described_class.new + expect(job.send(:rate_keys)).to eq({ buy: 'last_price', sell: 'last_price' }) end end describe '#rate_source' do it 'returns RateSourceGarantexio' do - worker = described_class.new - expect(worker.send(:rate_source)).to eq(rate_source) + job = described_class.new + expect(job.send(:rate_source)).to eq(rate_source) end end end diff --git a/spec/lib/configuration_spec.rb b/spec/lib/configuration_spec.rb index e0e3207d..ad726a84 100644 --- a/spec/lib/configuration_spec.rb +++ b/spec/lib/configuration_spec.rb @@ -35,4 +35,20 @@ expect(Gera).to respond_to(:payment_system_decorator) end end + + describe '.enable_direction_rate_history_intervals' do + it 'defaults to true' do + expect(Gera.enable_direction_rate_history_intervals).to be true + end + + it 'can be configured' do + original_value = Gera.enable_direction_rate_history_intervals + begin + Gera.enable_direction_rate_history_intervals = false + expect(Gera.enable_direction_rate_history_intervals).to be false + ensure + Gera.enable_direction_rate_history_intervals = original_value + end + end + end end diff --git a/spec/lib/gera/bybit_fetcher_spec.rb b/spec/lib/gera/bybit_fetcher_spec.rb index 83ba6f15..d402ddd1 100644 --- a/spec/lib/gera/bybit_fetcher_spec.rb +++ b/spec/lib/gera/bybit_fetcher_spec.rb @@ -1,24 +1,6 @@ # frozen_string_literal: true require 'spec_helper' - -# Stub PaymentServices::Base::Client before BybitFetcher is loaded -# This is necessary because BybitFetcher extends this class from host app -module PaymentServices - module Base - class Client - def http_request(url:, method:, body: nil, headers: {}) - '' - end - - def safely_parse(response) - JSON.parse(response) rescue {} - end - end - end -end unless defined?(PaymentServices::Base::Client) - -# Now require the fetcher require 'gera/bybit_fetcher' module Gera @@ -36,9 +18,12 @@ module Gera } end + let(:http_response) do + instance_double(RestClient::Response, code: 200, body: api_response.to_json) + end + before do - allow(subject).to receive(:http_request).and_return(api_response.to_json) - allow(subject).to receive(:safely_parse).and_return(api_response) + allow(RestClient::Request).to receive(:execute).and_return(http_response) end it 'returns hash of currency pairs to rates' do @@ -113,18 +98,6 @@ module Gera end end - describe '#build_headers' do - it 'returns headers with Content-Type' do - headers = subject.send(:build_headers) - expect(headers['Content-Type']).to eq('application/json') - end - - it 'returns headers with Host' do - headers = subject.send(:build_headers) - expect(headers['Host']).to eq('api2.bytick.com') - end - end - describe '#params' do it 'returns params hash with tokenId USDT' do params = subject.send(:params) diff --git a/spec/lib/gera/cryptomus_fetcher_spec.rb b/spec/lib/gera/cryptomus_fetcher_spec.rb index 568a2a84..c73accda 100644 --- a/spec/lib/gera/cryptomus_fetcher_spec.rb +++ b/spec/lib/gera/cryptomus_fetcher_spec.rb @@ -1,24 +1,6 @@ # frozen_string_literal: true require 'spec_helper' - -# Stub PaymentServices::Base::Client before CryptomusFetcher is loaded -# This is necessary because CryptomusFetcher extends this class from host app -module PaymentServices - module Base - class Client - def http_request(url:, method:, body: nil, headers: {}) - '' - end - - def safely_parse(response) - JSON.parse(response) rescue {} - end - end - end -end unless defined?(PaymentServices::Base::Client) - -# Now require the fetcher require 'gera/cryptomus_fetcher' module Gera @@ -88,12 +70,5 @@ module Gera expect(subject.send(:supported_currencies)).to eq(RateSourceCryptomus.supported_currencies) end end - - describe '#build_headers' do - it 'returns headers with Content-Type' do - headers = subject.send(:build_headers) - expect(headers['Content-Type']).to eq('application/json') - end - end end end diff --git a/spec/lib/gera/exmo_fetcher_spec.rb b/spec/lib/gera/exmo_fetcher_spec.rb index 0b0786b0..525ac746 100644 --- a/spec/lib/gera/exmo_fetcher_spec.rb +++ b/spec/lib/gera/exmo_fetcher_spec.rb @@ -14,7 +14,7 @@ module Gera end before do - allow(subject).to receive(:open).and_return(double('io', read: api_response)) + allow(Net::HTTP).to receive(:get).and_return(api_response) end it 'returns hash of currency pairs to rates' do diff --git a/spec/models/gera/direction_rate_spec.rb b/spec/models/gera/direction_rate_spec.rb index eede6a76..55e1fbf3 100644 --- a/spec/models/gera/direction_rate_spec.rb +++ b/spec/models/gera/direction_rate_spec.rb @@ -4,7 +4,7 @@ describe Gera::DirectionRate do before do - allow(Gera::DirectionsRatesWorker).to receive :perform_async + allow(Gera::DirectionsRatesJob).to receive :perform_later # Mock BestChange::Service to avoid dependency issues best_change_service_class = Class.new do diff --git a/spec/models/gera/exchange_rate_dependent_spec.rb b/spec/models/gera/exchange_rate_dependent_spec.rb index a41d4937..a6e60145 100644 --- a/spec/models/gera/exchange_rate_dependent_spec.rb +++ b/spec/models/gera/exchange_rate_dependent_spec.rb @@ -4,7 +4,7 @@ RSpec.describe Gera::ExchangeRate, 'dependent delete_all' do before do - allow(Gera::DirectionsRatesWorker).to receive(:perform_async) + allow(Gera::DirectionsRatesJob).to receive(:perform_later) # Mock BestChange::Service to avoid dependency issues best_change_service_class = Class.new do diff --git a/spec/models/gera/exchange_rate_spec.rb b/spec/models/gera/exchange_rate_spec.rb index 3a48e4a5..90b0809c 100644 --- a/spec/models/gera/exchange_rate_spec.rb +++ b/spec/models/gera/exchange_rate_spec.rb @@ -5,7 +5,7 @@ module Gera RSpec.describe ExchangeRate do before do - allow(DirectionsRatesWorker).to receive(:perform_async) + allow(DirectionsRatesJob).to receive(:perform_later) end subject { create :gera_exchange_rate } it { expect(subject).to be_persisted } diff --git a/spec/models/gera/payment_system_dependent_spec.rb b/spec/models/gera/payment_system_dependent_spec.rb index 4d5fd5b9..e9d875dc 100644 --- a/spec/models/gera/payment_system_dependent_spec.rb +++ b/spec/models/gera/payment_system_dependent_spec.rb @@ -4,7 +4,7 @@ RSpec.describe Gera::PaymentSystem, 'dependent delete_all' do before do - allow(Gera::DirectionsRatesWorker).to receive(:perform_async) + allow(Gera::DirectionsRatesJob).to receive(:perform_later) # Mock BestChange::Service to avoid dependency issues best_change_service_class = Class.new do diff --git a/spec/models/gera/payment_system_spec.rb b/spec/models/gera/payment_system_spec.rb index 5d92710e..69c0d1f4 100644 --- a/spec/models/gera/payment_system_spec.rb +++ b/spec/models/gera/payment_system_spec.rb @@ -5,7 +5,7 @@ module Gera RSpec.describe PaymentSystem do before do - allow(DirectionsRatesWorker).to receive(:perform_async) + allow(DirectionsRatesJob).to receive(:perform_later) end subject { create :gera_payment_system } it { expect(subject).to be_persisted } diff --git a/spec/services/gera/autorate_calculators/legacy_spec.rb b/spec/services/gera/autorate_calculators/legacy_spec.rb new file mode 100644 index 00000000..0aedb488 --- /dev/null +++ b/spec/services/gera/autorate_calculators/legacy_spec.rb @@ -0,0 +1,106 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + module AutorateCalculators + RSpec.describe Legacy do + let(:exchange_rate) { double('ExchangeRate') } + let(:target_autorate_setting) { double('TargetAutorateSetting') } + let(:currency_rate) { double('CurrencyRate', rate_value: base_rate) } + let(:base_rate) { 100.0 } + + let(:calculator) do + described_class.new( + exchange_rate: exchange_rate, + external_rates: external_rates + ) + end + + before do + allow(exchange_rate).to receive(:target_autorate_setting).and_return(target_autorate_setting) + allow(exchange_rate).to receive(:currency_rate).and_return(currency_rate) + allow(exchange_rate).to receive(:position_from).and_return(1) + allow(exchange_rate).to receive(:position_to).and_return(3) + allow(exchange_rate).to receive(:autorate_from).and_return(1.0) + allow(exchange_rate).to receive(:autorate_to).and_return(3.0) + end + + # Вспомогательный метод: создаёт хеш Manul из target_rate_percent + # rate = base_rate * (1 - target_rate_percent / 100) + def manul_rate(target_rate_percent, changer_id: nil) + rate_value = base_rate * (1 - target_rate_percent / 100.0) + { 'rate' => rate_value.to_s, 'changer_id' => changer_id } + end + + describe '#call' do + context 'when could_be_calculated? is false' do + let(:external_rates) do + [manul_rate(2.5), manul_rate(2.8), manul_rate(3.1)] + end + + before do + allow(target_autorate_setting).to receive(:could_be_calculated?).and_return(false) + end + + it 'returns 0' do + expect(calculator.call).to eq(0) + end + end + + context 'when external_rates is nil' do + let(:external_rates) { nil } + + before do + allow(target_autorate_setting).to receive(:could_be_calculated?).and_return(true) + end + + it 'returns 0' do + expect(calculator.call).to eq(0) + end + end + + context 'when external_rates_in_target_position is empty' do + let(:external_rates) { [] } + + before do + allow(target_autorate_setting).to receive(:could_be_calculated?).and_return(true) + end + + it 'returns autorate_from' do + expect(calculator.call).to eq(1.0) + end + end + + context 'when no rates match target comission range' do + let(:external_rates) do + [manul_rate(5.0), manul_rate(6.0), manul_rate(7.0)] + end + + before do + allow(target_autorate_setting).to receive(:could_be_calculated?).and_return(true) + end + + it 'returns autorate_from' do + expect(calculator.call).to eq(1.0) + end + end + + context 'when rates match target comission range' do + let(:external_rates) do + [manul_rate(2.5), manul_rate(2.8), manul_rate(3.0)] + end + + before do + allow(target_autorate_setting).to receive(:could_be_calculated?).and_return(true) + end + + it 'returns first matching rate minus GAP' do + # first matching rate is 2.5, GAP is 0.001 + expect(calculator.call).to be_within(0.0001).of(2.5 - 0.001) + end + end + end + end + end +end diff --git a/spec/services/gera/autorate_calculators/position_aware_spec.rb b/spec/services/gera/autorate_calculators/position_aware_spec.rb new file mode 100644 index 00000000..91a30df8 --- /dev/null +++ b/spec/services/gera/autorate_calculators/position_aware_spec.rb @@ -0,0 +1,305 @@ +# frozen_string_literal: true + +require 'spec_helper' + +module Gera + module AutorateCalculators + RSpec.describe PositionAware do + let(:exchange_rate) { double('ExchangeRate') } + let(:target_autorate_setting) { double('TargetAutorateSetting') } + let(:currency_rate) { double('CurrencyRate', rate_value: base_rate) } + let(:base_rate) { 100.0 } + + let(:calculator) do + described_class.new( + exchange_rate: exchange_rate, + external_rates: external_rates + ) + end + + before do + allow(exchange_rate).to receive(:target_autorate_setting).and_return(target_autorate_setting) + allow(exchange_rate).to receive(:currency_rate).and_return(currency_rate) + allow(exchange_rate).to receive(:autorate_from).and_return(1.0) + allow(exchange_rate).to receive(:autorate_to).and_return(3.0) + allow(target_autorate_setting).to receive(:could_be_calculated?).and_return(true) + # Сбрасываем конфигурацию перед каждым тестом + Gera.our_exchanger_id = nil + Gera.anomaly_threshold_percent = 50.0 + end + + # Вспомогательный метод: создаёт хеш Manul из target_rate_percent + # rate = base_rate * (1 - target_rate_percent / 100) + def manul_rate(target_rate_percent, changer_id: nil) + rate_value = base_rate * (1 - target_rate_percent / 100.0) + { 'rate' => rate_value.to_s, 'changer_id' => changer_id } + end + + describe '#call' do + context 'UC-1: все позиции имеют одинаковый курс' do + # Позиции 1-10 все имеют комиссию 2.5 + # position_from: 5, position_to: 10 + # Legacy вычтет GAP и займёт позицию 1 + # PositionAware должен оставить 2.5 и занять позицию 5-10 + + let(:external_rates) do + 10.times.map { manul_rate(2.5) } + end + + before do + allow(exchange_rate).to receive(:position_from).and_return(5) + allow(exchange_rate).to receive(:position_to).and_return(10) + end + + it 'не перепрыгивает позицию выше' do + # Позиция 4 (index 3) имеет комиссию 2.5 + # Если мы вычтем GAP (2.5 - 0.001 = 2.499), мы станем выше позиции 4 + # PositionAware должен вернуть 2.5 (равную позиции выше) + expect(calculator.call).to be_within(0.0001).of(2.5) + end + end + + context 'UC-2: есть разрыв между позициями' do + # Позиции 1-4 имеют комиссии 1.0, 1.2, 1.4, 1.6 + # Позиции 5-10 имеют комиссии 2.5, 2.6, 2.7, 2.8, 2.9, 3.0 + # position_from: 5, position_to: 10 + # После вычитания GAP (2.5 - 0.001 = 2.499) мы всё ещё хуже чем позиция 4 (1.6) + # Поэтому безопасно занимать позицию 5 + + let(:external_rates) do + [ + manul_rate(1.0), # pos 1 + manul_rate(1.2), # pos 2 + manul_rate(1.4), # pos 3 + manul_rate(1.6), # pos 4 + manul_rate(2.5), # pos 5 + manul_rate(2.6), # pos 6 + manul_rate(2.7), # pos 7 + manul_rate(2.8), # pos 8 + manul_rate(2.9), # pos 9 + manul_rate(3.0) # pos 10 + ] + end + + before do + allow(exchange_rate).to receive(:position_from).and_return(5) + allow(exchange_rate).to receive(:position_to).and_return(10) + end + + it 'безопасно вычитает GAP' do + # 2.5 - 0.001 = 2.499 > 1.6 (позиция 4) + # Не перепрыгиваем, возвращаем target - GAP + expect(calculator.call).to be_within(0.0001).of(2.5 - 0.001) + end + end + + context 'UC-3: целевая позиция 1' do + # Когда position_from = 1, нет позиции выше + let(:external_rates) do + [ + manul_rate(2.5), # pos 1 + manul_rate(2.8), # pos 2 + manul_rate(3.0) # pos 3 + ] + end + + before do + allow(exchange_rate).to receive(:position_from).and_return(1) + allow(exchange_rate).to receive(:position_to).and_return(3) + end + + it 'безопасно вычитает GAP' do + expect(calculator.call).to be_within(0.0001).of(2.5 - 0.001) + end + end + + context 'UC-4: позиция выше с очень близкой комиссией' do + # Позиция 4 имеет комиссию 2.4999 + # Позиция 5 имеет комиссию 2.5 + # 2.5 - 0.001 = 2.499 > 2.4999 - мы перепрыгнем! + # PositionAware должен скорректировать + + let(:external_rates) do + [ + manul_rate(1.0), # pos 1 + manul_rate(1.5), # pos 2 + manul_rate(2.0), # pos 3 + manul_rate(2.4999), # pos 4 + manul_rate(2.5), # pos 5 + manul_rate(2.8) # pos 6 + ] + end + + before do + allow(exchange_rate).to receive(:position_from).and_return(5) + allow(exchange_rate).to receive(:position_to).and_return(6) + end + + it 'не перепрыгивает позицию 4' do + # 2.5 - 0.001 = 2.499 < 2.4999, значит перепрыгнем + # Должны вернуть min(2.4999, 2.5) = 2.4999 + expect(calculator.call).to be_within(0.0001).of(2.4999) + end + end + + context 'UC-6: адаптивный GAP для плотного рейтинга' do + # Разница между позициями 4 и 5 = 0.0005 (меньше стандартного GAP 0.001) + # Должен использоваться адаптивный GAP = 0.0005 / 2 = 0.00025 + + let(:external_rates) do + [ + manul_rate(1.0), # pos 1 + manul_rate(1.5), # pos 2 + manul_rate(2.0), # pos 3 + manul_rate(2.4995), # pos 4 + manul_rate(2.5), # pos 5 + manul_rate(2.8) # pos 6 + ] + end + + before do + allow(exchange_rate).to receive(:position_from).and_return(5) + allow(exchange_rate).to receive(:position_to).and_return(6) + end + + it 'использует адаптивный GAP' do + # diff = 2.5 - 2.4995 = 0.0005 < 0.001 + # adaptive_gap = 0.0005 / 2 = 0.00025 + # target = 2.5 - 0.00025 = 2.49975 + # 2.49975 > 2.4995 - не перепрыгиваем + expect(calculator.call).to be_within(0.0001).of(2.49975) + end + end + + context 'UC-6: минимальный GAP' do + # Разница между позициями очень маленькая (0.00005) + # Должен использоваться MIN_GAP = 0.0001 + + let(:external_rates) do + [ + manul_rate(1.0), # pos 1 + manul_rate(1.5), # pos 2 + manul_rate(2.0), # pos 3 + manul_rate(2.49995), # pos 4 + manul_rate(2.5), # pos 5 + manul_rate(2.8) # pos 6 + ] + end + + before do + allow(exchange_rate).to receive(:position_from).and_return(5) + allow(exchange_rate).to receive(:position_to).and_return(6) + end + + it 'использует минимальный GAP' do + # diff = 2.5 - 2.49995 = 0.00005 + # adaptive_gap = 0.00005 / 2 = 0.000025 < MIN_GAP (0.0001) + # используем MIN_GAP = 0.0001 + # target = 2.5 - 0.0001 = 2.4999 + # 2.4999 < 2.49995 - перепрыгиваем! Корректируем до 2.49995 + expect(calculator.call).to be_within(0.0001).of(2.49995) + end + end + + context 'UC-8: наш обменник в рейтинге' do + # Наш обменник на позиции 3, мы должны его игнорировать + + let(:external_rates) do + [ + manul_rate(1.0, changer_id: 101), # pos 1 + manul_rate(1.5, changer_id: 102), # pos 2 + manul_rate(2.0, changer_id: 999), # pos 3 - наш + manul_rate(2.3, changer_id: 103), # pos 4 + manul_rate(2.5, changer_id: 104), # pos 5 + manul_rate(2.8, changer_id: 105) # pos 6 + ] + end + + before do + Gera.our_exchanger_id = 999 + allow(exchange_rate).to receive(:position_from).and_return(4) + allow(exchange_rate).to receive(:position_to).and_return(5) + end + + it 'исключает наш обменник из расчёта' do + # После фильтрации: позиции пересчитываются без нашего обменника (id=999) + # Новые позиции: 1.0, 1.5, 2.3, 2.5, 2.8 + # position_from=4 -> 2.5 (index 3) + # position_to=5 -> 2.8 (index 4) + # target = 2.5 - GAP = 2.499 + # rate_above (pos 3) = 2.3, 2.499 > 2.3 - не перепрыгиваем + expect(calculator.call).to be_within(0.0001).of(2.5 - 0.001) + end + end + + context 'UC-9: манипуляторы с аномальными курсами' do + # Позиции 1-3 имеют нереально низкие комиссии (манипуляторы) + # Они должны игнорироваться при проверке перепрыгивания + + let(:external_rates) do + [ + manul_rate(0.1), # pos 1 - манипулятор + manul_rate(0.2), # pos 2 - манипулятор + manul_rate(0.3), # pos 3 - манипулятор + manul_rate(2.0), # pos 4 - нормальный + manul_rate(2.5), # pos 5 + manul_rate(2.6), # pos 6 + manul_rate(2.7), # pos 7 + manul_rate(2.8), # pos 8 + manul_rate(2.9), # pos 9 + manul_rate(3.0) # pos 10 + ] + end + + before do + Gera.anomaly_threshold_percent = 50.0 + allow(exchange_rate).to receive(:position_from).and_return(5) + allow(exchange_rate).to receive(:position_to).and_return(10) + end + + it 'игнорирует манипуляторов при проверке перепрыгивания' do + # Медиана комиссий ≈ 2.5 + # Комиссии 0.1, 0.2, 0.3 отклоняются от медианы > 50% + # После фильтрации аномалий: 2.0, 2.5, 2.6, 2.7, 2.8, 2.9, 3.0 + # position_from=5 -> индекс 4 после фильтрации + # rate_above в clean_rates = 2.8 (индекс 3) + # target = 2.5 - 0.001 = 2.499 < 2.8 - не перепрыгиваем реальных конкурентов + expect(calculator.call).to be_within(0.0001).of(2.5 - 0.001) + end + end + + context 'when external_rates is empty' do + let(:external_rates) { [] } + + before do + allow(exchange_rate).to receive(:position_from).and_return(1) + allow(exchange_rate).to receive(:position_to).and_return(3) + end + + it 'returns autorate_from' do + expect(calculator.call).to eq(1.0) + end + end + + context 'when no rates match target comission range' do + let(:external_rates) do + [ + manul_rate(5.0), + manul_rate(6.0), + manul_rate(7.0) + ] + end + + before do + allow(exchange_rate).to receive(:position_from).and_return(1) + allow(exchange_rate).to receive(:position_to).and_return(3) + end + + it 'returns autorate_from' do + expect(calculator.call).to eq(1.0) + end + end + end + end + end +end diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index 8e0fe8ac..20c69198 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -14,9 +14,8 @@ require 'timecop' -require 'sidekiq/testing' -Sidekiq::Testing.fake! -Sidekiq.strict_args!(false) +# ActiveJob test mode - jobs execute immediately +ActiveJob::Base.queue_adapter = :test # Suppress Money gem deprecation warnings Money.locale_backend = :i18n diff --git a/spec/workers/gera/create_history_intervals_worker_spec.rb b/spec/workers/gera/create_history_intervals_worker_spec.rb deleted file mode 100644 index 29e23799..00000000 --- a/spec/workers/gera/create_history_intervals_worker_spec.rb +++ /dev/null @@ -1,54 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Gera - RSpec.describe CreateHistoryIntervalsWorker do - describe 'constants' do - it 'defines MAXIMAL_DATE as 30 minutes' do - expect(described_class::MAXIMAL_DATE).to eq(30.minutes) - end - - it 'defines MINIMAL_DATE' do - expect(described_class::MINIMAL_DATE).to be_a(Time) - end - end - - describe '#perform' do - context 'when tables exist' do - it 'calls save_direction_rate_history_intervals' do - expect(DirectionRateHistoryInterval).to receive(:table_exists?).and_return(true) - expect(CurrencyRateHistoryInterval).to receive(:table_exists?).and_return(true) - - worker = described_class.new - # Stub the actual save methods to avoid complex setup - allow(worker).to receive(:save_direction_rate_history_intervals) - allow(worker).to receive(:save_currency_rate_history_intervals) - - worker.perform - - expect(worker).to have_received(:save_direction_rate_history_intervals) - expect(worker).to have_received(:save_currency_rate_history_intervals) - end - end - - context 'when tables do not exist' do - it 'skips saving intervals' do - allow(DirectionRateHistoryInterval).to receive(:table_exists?).and_return(false) - allow(CurrencyRateHistoryInterval).to receive(:table_exists?).and_return(false) - - expect(DirectionRateHistoryInterval).not_to receive(:create_multiple_intervals_from!) - expect(CurrencyRateHistoryInterval).not_to receive(:create_multiple_intervals_from!) - - subject.perform - end - end - end - - describe 'sidekiq_options' do - it 'uses until_executed lock' do - expect(described_class.sidekiq_options['lock']).to eq(:until_executed) - end - end - end -end diff --git a/spec/workers/gera/currency_rates_worker_spec.rb b/spec/workers/gera/currency_rates_worker_spec.rb deleted file mode 100644 index 86ed67da..00000000 --- a/spec/workers/gera/currency_rates_worker_spec.rb +++ /dev/null @@ -1,11 +0,0 @@ -# frozen_string_literal: true - -require 'spec_helper' - -module Gera - RSpec.describe CurrencyRatesWorker do - it do - expect(CurrencyRatesWorker.new.perform).to be_truthy - end - end -end