diff --git a/CHANGELOG.md b/CHANGELOG.md index b975268..3fa1d31 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,22 @@ ## [Unreleased] +## [0.8.6] - 2024-04-23 + +### Added + +- Auction finalization configured for each type of auction; +- Add operations for winner and participant of an auction; +- Configure background job to work with unique jobs; +- Add 'sidekiq-unique-jobs' to be responsible for finishing penny auctions; + +### Changed: + +- General improvements on seed data; + +### Fixed: + +- NameError: uninitialized constant AuctionFunCore::Workers::ApplicationJob::Sidekiq (NameError) + ## [0.8.5] - 2024-04-03 ### Added diff --git a/Gemfile.lock b/Gemfile.lock index 75b505e..ac83fc1 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - auction_fun_core (0.8.5) + auction_fun_core (0.8.6) activesupport (= 7.1.3.2) bcrypt (= 3.1.20) dotenv (= 3.1.0) @@ -18,6 +18,7 @@ PATH rom (= 5.3.0) rom-sql (= 3.6.2) sidekiq (= 7.2.2) + sidekiq-unique-jobs (= 8.0.10) yard (= 0.9.36) zeitwerk (= 2.6.13) @@ -223,6 +224,10 @@ GEM connection_pool (>= 2.3.0) rack (>= 2.2.4) redis-client (>= 0.19.0) + sidekiq-unique-jobs (8.0.10) + concurrent-ruby (~> 1.0, >= 1.0.5) + sidekiq (>= 7.0.0, < 8.0.0) + thor (>= 1.0, < 3.0) simplecov (0.22.0) docile (~> 1.1) simplecov-html (~> 0.11) @@ -241,6 +246,7 @@ GEM standard-performance (1.3.1) lint_roller (~> 1.1) rubocop-performance (~> 1.20.2) + thor (1.3.1) timeout (0.4.1) transproc (1.1.1) tzinfo (2.0.6) diff --git a/auction_fun_core.gemspec b/auction_fun_core.gemspec index e1e95e8..b92b27b 100644 --- a/auction_fun_core.gemspec +++ b/auction_fun_core.gemspec @@ -49,6 +49,7 @@ Gem::Specification.new do |spec| spec.add_dependency "rom", "5.3.0" spec.add_dependency "rom-sql", "3.6.2" spec.add_dependency "sidekiq", "7.2.2" + spec.add_dependency "sidekiq-unique-jobs", "8.0.10" spec.add_dependency "yard", "0.9.36" spec.add_dependency "zeitwerk", "2.6.13" diff --git a/db/migrate/20240229143000_create_auctions.rb b/db/migrate/20240229143000_create_auctions.rb index c2ceda1..8f6a995 100644 --- a/db/migrate/20240229143000_create_auctions.rb +++ b/db/migrate/20240229143000_create_auctions.rb @@ -5,6 +5,7 @@ create_table(:auctions) do primary_key :id foreign_key :staff_id, :staffs, null: false + foreign_key :winner_id, :users column :title, String, null: false column :description, :text column :kind, :auction_kinds, null: false diff --git a/db/seeds.rb b/db/seeds.rb index e3f2aed..4e3fef1 100644 --- a/db/seeds.rb +++ b/db/seeds.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true -require "pry" require "faker" +require "pry" I18n.enforce_available_locales = false Faker::Config.locale = "pt-BR" @@ -13,7 +13,6 @@ AuctionFunCore::Application.start(:core) # Instantiate repos -auction_repository = AuctionFunCore::Repos::AuctionContext::AuctionRepository.new staff_repository = AuctionFunCore::Repos::StaffContext::StaffRepository.new # Create root staff. Create as a regular user using the normal flow and after that @@ -44,6 +43,19 @@ result.success { |staff| @staff = staff } end +# Add some users +100.times do + attributes = { + name: Faker::Name.name, email: Faker::Internet.unique.email, + phone: Faker::PhoneNumber.unique.cell_phone_in_e164, password: "password", + password_confirmation: "password" + } + AuctionFunCore::Operations::UserContext::RegistrationOperation.call(attributes) do |result| + result.failure { |failure| raise "Error to create user: #{failure}" } + result.success { |user| puts "Create user with: #{user.to_h}" } + end +end + # Create some standard auctions (1..15).each do |i| attributes = { @@ -53,7 +65,31 @@ } AuctionFunCore::Operations::AuctionContext::CreateOperation.call(attributes) do |result| result.failure { |failure| raise "Error to create standard auction: #{failure}" } - result.success { |auction| puts "Create standard auction with: #{auction.to_h}" } + result.success do |auction| + @auction = auction + puts "Create standard auction with: #{auction.to_h}" + end + end + + # Create auctions that have no bid. Multiples if 7 + next if (i % 7).zero? + + # Increase the value of each new bid by 10% + @current_bid = @auction.minimal_bid_cents + @minimal_percentage = 0.1 + # Create some bids for standard auctions based on total users + (2..100).to_a.sample(rand(1..100)).each_with_index do |user_id, index| + @current_bid = index.zero? ? @current_bid : (@current_bid + (@current_bid * @minimal_percentage)).round(half: :up) + bid_params = { + auction_id: @auction.id, + user_id: user_id, + value_cents: @current_bid + } + + AuctionFunCore::Operations::BidContext::CreateBidStandardOperation.call(bid_params) do |result| + result.failure { |failure| raise "Error to create bid: #{failure}" } + result.success { |bid| puts "Create standard bid with: #{bid.to_h}" } + end end end @@ -65,52 +101,67 @@ attributes = { staff_id: @staff.id, title: Faker::Commerce.product_name, description: Faker::Lorem.paragraph_by_chars, - kind: "penny", started_at: started_at, finished_at: finished_at, stopwatch: stopwatch + kind: "penny", started_at: started_at, finished_at: finished_at, stopwatch: stopwatch, + initial_bid_cents: (i * 100), minimal_bid_cents: (i * 100) } AuctionFunCore::Operations::AuctionContext::CreateOperation.call(attributes) do |result| result.failure { |failure| raise "Error to create penny auction: #{failure}" } - result.success { |auction| puts "Create penny auction with: #{auction.to_h}" } + result.success do |auction| + @auction = auction + puts "Create penny auction with: #{auction.to_h}" + end + end + + # Create auctions that have no bid. Multiples if 7 + next if (i % 7).zero? + + # Create some bids for penny auctions based on total users + (2..100).to_a.sample(rand(1..100)).each do |user_id| + bid_params = { + auction_id: @auction.id, + user_id: user_id, + value_cents: @auction.minimal_bid_cents + } + + AuctionFunCore::Operations::BidContext::CreateBidPennyOperation.call(bid_params) do |result| + result.failure { |failure| raise "Error to create bid: #{failure}" } + result.success { |bid| puts "Create penny bid with: #{bid.to_h}" } + end end end # Create some closed auctions -(1..3).each do |i| +(1..5).each do |i| attributes = { - staff_id: @staff.id, title: Faker::Commerce.product_name, kind: "closed", - started_at: i.hour.from_now, finished_at: i.day.from_now.end_of_day, + staff_id: @staff.id, title: Faker::Commerce.product_name, description: Faker::Lorem.paragraph_by_chars, + kind: "closed", started_at: i.hour.from_now, finished_at: i.day.from_now.end_of_day, initial_bid_cents: (i * 1000) } AuctionFunCore::Operations::AuctionContext::CreateOperation.call(attributes) do |result| result.failure { |failure| raise "Error to create closed auction: #{failure}" } - result.success { |auction| puts "Create closed auction with: #{auction.to_h}" } + result.success do |auction| + @auction = auction + puts "Create closed auction with: #{auction.to_h}" + end end -end - -# Add some users -100.times do - attributes = { - name: Faker::Name.name, email: Faker::Internet.unique.email, - phone: Faker::PhoneNumber.unique.cell_phone_in_e164, password: "password", - password_confirmation: "password" - } - AuctionFunCore::Operations::UserContext::RegistrationOperation.call(attributes) do |result| - result.failure { |failure| raise "Error to create user: #{failure}" } - result.success { |user| puts "Create user with: #{user.to_h}" } - end -end - -# Create some bids -auction_repository.all.each do |auction| - next if auction.id.even? - - bid_params = { - auction_id: auction.id, - user_id: rand(2..100), - value_cents: auction.minimal_bid_cents + (auction.minimal_bid_cents * 0.10) - } - "AuctionFunCore::Operations::BidContext::CreateBid#{auction.kind.capitalize}Operation".constantize.call(bid_params) do |result| - result.failure { |failure| raise "Error to create bid: #{failure}" } - result.success { |bid| puts "Create bid with: #{bid.to_h}" } + # Create auctions that have no bid. Pair numbers + next if i.even? + + @minimal_bid = @auction.minimal_bid_cents + # Create some bids for closed auctions based on total users + (2..100).to_a.sample(rand(1..100)).each_with_index do |user_id, index| + # Choosing a random value for the bid, obeying the rule that it has to be just greater than the minimum bid. + random_bid = index.zero? ? @minimal_bid : rand((@minimal_bid + 1)..10_000).round + bid_params = { + auction_id: @auction.id, + user_id: user_id, + value_cents: random_bid + } + + AuctionFunCore::Operations::BidContext::CreateBidClosedOperation.call(bid_params) do |result| + result.failure { |failure| raise "Error to create bid: #{failure}" } + result.success { |bid| puts "Create closed bid with: #{bid.to_h}" } + end end end diff --git a/i18n/en-US/contracts/contracts.en-US.yml b/i18n/en-US/contracts/contracts.en-US.yml index dc385de..f6c615e 100644 --- a/i18n/en-US/contracts/contracts.en-US.yml +++ b/i18n/en-US/contracts/contracts.en-US.yml @@ -70,3 +70,12 @@ en-US: auction_context: create: finished_at: "must be after started time" + post_auction: + participant: + none: "there was no participation from this user in the auction reported" + winner: + wrong: "was not the winner of this auction" + processor: + finish: + invalid_kind: "auction with invalid type" + invalid_status: "auction with invalid status" diff --git a/i18n/en-US/mail/application.en-US.yml b/i18n/en-US/mail/application.en-US.yml new file mode 100644 index 0000000..ccd6a90 --- /dev/null +++ b/i18n/en-US/mail/application.en-US.yml @@ -0,0 +1,59 @@ +en-US: + date: + abbr_day_names: + - Sun + - Mon + - Tue + - Wed + - Thu + - Fri + - Sat + abbr_month_names: + - + - Jan + - Feb + - Mar + - Apr + - May + - Jun + - Jul + - Aug + - Sep + - Oct + - Nov + - Dec + day_names: + - Sunday + - Monday + - Tuesday + - Wednesday + - Thursday + - Friday + - Saturday + formats: + default: "%Y-%m-%d" + long: "%B %d, %Y" + short: "%b %d" + month_names: + - + - January + - February + - March + - April + - May + - June + - July + - August + - September + - October + - November + - December + order: + - :year + - :month + - :day + application: + general: + hello: "Hi %{name}" + app_name: "AuctionFun" + team: "Team AuctionFun" diff --git a/i18n/en-US/mail/auction_context/post_auction/participant.en-US.yml b/i18n/en-US/mail/auction_context/post_auction/participant.en-US.yml new file mode 100644 index 0000000..ad06a58 --- /dev/null +++ b/i18n/en-US/mail/auction_context/post_auction/participant.en-US.yml @@ -0,0 +1,13 @@ +en-US: + mail: + auction_context: + post_auction: + participant_mailer: + subject: "Thank you for participating in the auction %{title}" + body: + description: "Thank you for participating in the auction %{title}! Although you weren't the winner this time, we would like to recognize your efforts and share some statistics on your performance:" + stats: + auction_total_bids: "Total auction bids: %{auction_total_bids}" + winner_bid: "Winner bid: %{winner_bid}" + auction_date: "Auction Date" + regards: "We hope you found the experience enriching and invite you to participate in our future auctions. Follow our website and social media to stay up to date with upcoming opportunities." diff --git a/i18n/en-US/mail/auction_context/post_auction/winner.en-US.yml b/i18n/en-US/mail/auction_context/post_auction/winner.en-US.yml new file mode 100644 index 0000000..f4858a8 --- /dev/null +++ b/i18n/en-US/mail/auction_context/post_auction/winner.en-US.yml @@ -0,0 +1,13 @@ +en-US: + mail: + auction_context: + post_auction: + winner_mailer: + subject: "Congratulations! You are the winner of the auction %{title}!" + body: + description: "We are very happy to inform you that you were the winner of our auction %{title}! Your bid was the highest, and you are now the new owner of item. Below is some information and statistics about the auction:" + item: + title: "Auctioned item: %{title}" + winner_bid: "Your Winning Bid: %{winner_bid}" + auction_total_bids: "Total Number of Bids: %{auction_total_bids}" + auction_date: "Auction Date" diff --git a/i18n/pt-BR/contracts/contracts.pt-BR.yml b/i18n/pt-BR/contracts/contracts.pt-BR.yml index c6a6bf4..278ef2b 100644 --- a/i18n/pt-BR/contracts/contracts.pt-BR.yml +++ b/i18n/pt-BR/contracts/contracts.pt-BR.yml @@ -70,3 +70,12 @@ pt-BR: auction_context: create: finished_at: "deve ser depois da hora de início" + post_auction: + participant: + none: "não houve nenhuma participação deste usuário no leilão informado" + winner: + wrong: "não foi o vencedor deste leilão" + processor: + finish: + invalid_kind: "leilão com tipo inválido" + invalid_status: "leilão com status inválido" diff --git a/i18n/pt-BR/mail/application.pt-BR.yml b/i18n/pt-BR/mail/application.pt-BR.yml new file mode 100644 index 0000000..bb3993d --- /dev/null +++ b/i18n/pt-BR/mail/application.pt-BR.yml @@ -0,0 +1,59 @@ +pt-BR: + date: + abbr_day_names: + - Dom + - Seg + - Ter + - Qua + - Qui + - Sex + - Sáb + abbr_month_names: + - + - Jan + - Fev + - Mar + - Abr + - Mai + - Jun + - Jul + - Ago + - Set + - Out + - Nov + - Dez + day_names: + - Domingo + - Segunda-feira + - Terça-feira + - Quarta-feira + - Quinta-feira + - Sexta-feira + - Sábado + formats: + default: "%d/%m/%Y" + long: "%d de %B de %Y" + short: "%d de %B" + month_names: + - + - Janeiro + - Fevereiro + - Março + - Abril + - Maio + - Junho + - Julho + - Agosto + - Setembro + - Outubro + - Novembro + - Dezembro + order: + - :day + - :month + - :year + application: + general: + hello: "Olá %{name}" + app_name: "AuctionFun" + team: "Time AuctionFun" diff --git a/i18n/pt-BR/mail/auction_context/post_auction/participant.pt-BR.yml b/i18n/pt-BR/mail/auction_context/post_auction/participant.pt-BR.yml new file mode 100644 index 0000000..b03b87c --- /dev/null +++ b/i18n/pt-BR/mail/auction_context/post_auction/participant.pt-BR.yml @@ -0,0 +1,13 @@ +pt-BR: + mail: + auction_context: + post_auction: + participant_mailer: + subject: "Sua participação no leilão %{title}" + body: + description: "Agradecemos por participar do leilão %{title}! Embora você não tenha sido o vencedor desta vez, gostaríamos de reconhecer o seu empenho e compartilhar algumas estatísticas do seu desempenho:" + stats: + auction_total_bids: "Total de lances do leilão: %{auction_total_bids}" + winner_bid: "Lance vencedor: %{winner_bid}" + auction_date: "Data do Leilão" + regards: "Esperamos que você tenha encontrado a experiência enriquecedora e convidamos você a participar de nossos futuros leilões. Acompanhe nosso site e redes sociais para ficar por dentro das próximas oportunidades." diff --git a/i18n/pt-BR/mail/auction_context/post_auction/winner.pt-BR.yml b/i18n/pt-BR/mail/auction_context/post_auction/winner.pt-BR.yml new file mode 100644 index 0000000..51191f2 --- /dev/null +++ b/i18n/pt-BR/mail/auction_context/post_auction/winner.pt-BR.yml @@ -0,0 +1,13 @@ +pt-BR: + mail: + auction_context: + post_auction: + winner_mailer: + subject: "Parabéns! Você é o vencedor do leilão %{title}!" + body: + description: "Estamos muito felizes em informar que você foi o vencedor do leilão %{title}! Seu lance foi o maior, e agora você é o novo proprietário do item. Abaixo estão algumas informações e estatísticas sobre o leilão:" + item: + title: "Item Arrematado: %{title}" + winner_bid: "Seu Lance Vencedor: %{winner_bid}" + auction_total_bids: "Número Total de Lances: %{auction_total_bids}" + auction_date: "Data do Leilão" diff --git a/i18n/pt-BR/mail/user_context/registration.pt-BR.yml b/i18n/pt-BR/mail/user_context/registration.pt-BR.yml index 90f48d4..fb2756b 100644 --- a/i18n/pt-BR/mail/user_context/registration.pt-BR.yml +++ b/i18n/pt-BR/mail/user_context/registration.pt-BR.yml @@ -1,9 +1,5 @@ pt-BR: mail: - general: - hello: "Olá %{name}" - app_name: "AuctionFun" - team: "Time AuctionFun" user_context: registration: subject: "Bem vindo a AuctionFun" diff --git a/lib/auction_fun_core/contracts/auction_context/post_auction/participant_contract.rb b/lib/auction_fun_core/contracts/auction_context/post_auction/participant_contract.rb new file mode 100644 index 0000000..cc6e9f5 --- /dev/null +++ b/lib/auction_fun_core/contracts/auction_context/post_auction/participant_contract.rb @@ -0,0 +1,42 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Contracts + module AuctionContext + module PostAuction + ## + # Contract class for validate participation auction. + # + class ParticipantContract < Contracts::ApplicationContract + I18N_SCOPE = "contracts.errors.custom.auction_context.post_auction.participation" + + option :auction_repository, default: proc { Repos::AuctionContext::AuctionRepository.new } + option :user_repository, default: proc { Repos::UserContext::UserRepository.new } + option :bid_repository, default: proc { Repos::BidContext::BidRepository.new } + + params do + required(:auction_id).filled(:integer) + required(:participant_id).filled(:integer) + end + + rule(:auction_id) do |context:| + context[:auction] ||= auction_repository.by_id(value) + key.failure(I18n.t("contracts.errors.custom.not_found")) unless context[:auction] + end + + rule(:participant_id) do |context:| + context[:participant] ||= user_repository.by_id(value) + key.failure(I18n.t("contracts.errors.custom.not_found")) unless context[:participant] + end + + rule(:auction_id, :participant_id) do |context:| + next if (rule_error?(:auction_id) || schema_error?(:auction_id)) || (rule_error?(:winner_id) || schema_error?(:winner_id)) + next if bid_repository.exists?(auction_id: values[:auction_id], user_id: values[:participant_id]) + + key(:participant_id).failure(I18n.t("none", scope: I18N_SCOPE)) + end + end + end + end + end +end diff --git a/lib/auction_fun_core/contracts/auction_context/post_auction/winner_contract.rb b/lib/auction_fun_core/contracts/auction_context/post_auction/winner_contract.rb new file mode 100644 index 0000000..7214892 --- /dev/null +++ b/lib/auction_fun_core/contracts/auction_context/post_auction/winner_contract.rb @@ -0,0 +1,41 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Contracts + module AuctionContext + module PostAuction + ## + # Contract class for validate winner auction. + # + class WinnerContract < Contracts::ApplicationContract + I18N_SCOPE = "contracts.errors.custom.auction_context.post_auction.winner" + + option :auction_repository, default: proc { Repos::AuctionContext::AuctionRepository.new } + option :user_repository, default: proc { Repos::UserContext::UserRepository.new } + + params do + required(:auction_id).filled(:integer) + required(:winner_id).filled(:integer) + end + + rule(:auction_id) do |context:| + context[:auction] ||= auction_repository.by_id(value) + key.failure(I18n.t("contracts.errors.custom.not_found")) unless context[:auction] + end + + rule(:winner_id) do |context:| + context[:winner] ||= user_repository.by_id(value) + key.failure(I18n.t("contracts.errors.custom.not_found")) unless context[:winner] + end + + rule(:auction_id, :winner_id) do |context:| + next if (rule_error?(:auction_id) || schema_error?(:auction_id)) || (rule_error?(:winner_id) || schema_error?(:winner_id)) + next if context[:auction].winner_id == values[:winner_id] + + key(:winner_id).failure(I18n.t("wrong", scope: I18N_SCOPE)) + end + end + end + end + end +end diff --git a/lib/auction_fun_core/contracts/auction_context/processor/finish/closed_contract.rb b/lib/auction_fun_core/contracts/auction_context/processor/finish/closed_contract.rb new file mode 100644 index 0000000..f899e3c --- /dev/null +++ b/lib/auction_fun_core/contracts/auction_context/processor/finish/closed_contract.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Contracts + module AuctionContext + module Processor + module Finish + ## + # Contract class for finishing closed auctions. + # + class ClosedContract < Contracts::ApplicationContract + I18N_SCOPE = "contracts.errors.custom.auction_context.processor.finish" + + option :auction_repository, default: proc { Repos::AuctionContext::AuctionRepository.new } + + params do + required(:auction_id).filled(:integer) + end + + # Validation for auction. + # Validates if the auction exists in the database. + rule(:auction_id) do |context:| + context[:auction] ||= auction_repository.by_id(value) + key.failure(I18n.t("contracts.errors.custom.not_found")) unless context[:auction] + end + + # Validation for kind. + # + rule do |context:| + next if context[:auction].present? && context[:auction].kind == "closed" + + key(:base).failure(I18n.t("invalid_kind", scope: I18N_SCOPE)) + end + + # Validation for status. + # + rule do |context:| + next if context[:auction].present? && context[:auction].status == "running" + + key(:base).failure(I18n.t("invalid_status", scope: I18N_SCOPE)) + end + end + end + end + end + end +end diff --git a/lib/auction_fun_core/contracts/auction_context/processor/finish/penny_contract.rb b/lib/auction_fun_core/contracts/auction_context/processor/finish/penny_contract.rb new file mode 100644 index 0000000..e6519e1 --- /dev/null +++ b/lib/auction_fun_core/contracts/auction_context/processor/finish/penny_contract.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Contracts + module AuctionContext + module Processor + module Finish + ## + # Contract class for finishing penny auctions. + # + class PennyContract < Contracts::ApplicationContract + I18N_SCOPE = "contracts.errors.custom.auction_context.processor.finish" + + option :auction_repository, default: proc { Repos::AuctionContext::AuctionRepository.new } + + params do + required(:auction_id).filled(:integer) + end + + # Validation for auction. + # Validates if the auction exists in the database. + rule(:auction_id) do |context:| + context[:auction] ||= auction_repository.by_id(value) + + key.failure(I18n.t("contracts.errors.custom.not_found")) unless context[:auction] + end + + # Validation for kind. + # + rule do |context:| + next if context[:auction].present? && context[:auction].kind == "penny" + + key(:base).failure(I18n.t("invalid_kind", scope: I18N_SCOPE)) + end + + # Validation for status. + # + rule do |context:| + next if context[:auction].present? && context[:auction].status == "running" + + key(:base).failure(I18n.t("invalid_status", scope: I18N_SCOPE)) + end + end + end + end + end + end +end diff --git a/lib/auction_fun_core/contracts/auction_context/processor/finish/standard_contract.rb b/lib/auction_fun_core/contracts/auction_context/processor/finish/standard_contract.rb new file mode 100644 index 0000000..b0d2b1d --- /dev/null +++ b/lib/auction_fun_core/contracts/auction_context/processor/finish/standard_contract.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Contracts + module AuctionContext + module Processor + module Finish + ## + # Contract class for finishing standard auctions. + # + class StandardContract < Contracts::ApplicationContract + I18N_SCOPE = "contracts.errors.custom.auction_context.processor.finish" + + option :auction_repository, default: proc { Repos::AuctionContext::AuctionRepository.new } + + params do + required(:auction_id).filled(:integer) + end + + # Validation for auction. + # Validates if the auction exists in the database. + rule(:auction_id) do |context:| + context[:auction] ||= auction_repository.by_id(value) + + key.failure(I18n.t("contracts.errors.custom.not_found")) unless context[:auction] + end + + # Validation for kind. + # + rule do |context:| + next if context[:auction].present? && context[:auction].kind == "standard" + + key(:base).failure(I18n.t("invalid_kind", scope: I18N_SCOPE)) + end + + # Validation for status. + # + rule do |context:| + next if context[:auction].present? && context[:auction].status == "running" + + key(:base).failure(I18n.t("invalid_status", scope: I18N_SCOPE)) + end + end + end + end + end + end +end diff --git a/lib/auction_fun_core/contracts/auction_context/processor/finish_contract.rb b/lib/auction_fun_core/contracts/auction_context/processor/finish_contract.rb deleted file mode 100644 index c69dbd4..0000000 --- a/lib/auction_fun_core/contracts/auction_context/processor/finish_contract.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -module AuctionFunCore - module Contracts - module AuctionContext - module Processor - ## - # Contract class for finish auctions. - # - class FinishContract < Contracts::ApplicationContract - option :auction_repo, default: proc { Repos::AuctionContext::AuctionRepository.new } - - params do - required(:auction_id).filled(:integer) - end - - # Validation for auction. - # Validates if the auction exists in the database. - rule(:auction_id) do |context:| - context[:auction] ||= auction_repo.by_id(value) - key.failure(I18n.t("contracts.errors.custom.not_found")) unless context[:auction] - end - end - end - end - end -end diff --git a/lib/auction_fun_core/entities/auction.rb b/lib/auction_fun_core/entities/auction.rb index c8ff971..df4ed71 100644 --- a/lib/auction_fun_core/entities/auction.rb +++ b/lib/auction_fun_core/entities/auction.rb @@ -5,6 +5,8 @@ module Entities # Auction Relations class. This return simple objects with attribute readers # to represent data in your auction. class Auction < ROM::Struct + INQUIRER_ATTRIBUTES = Relations::Auctions::STATUSES.values.freeze + def initial_bid Money.new(initial_bid_cents, initial_bid_currency) end @@ -12,6 +14,10 @@ def initial_bid def minimal_bid Money.new(minimal_bid_cents, minimal_bid_currency) end + + def winner? + winner_id.present? + end end end end diff --git a/lib/auction_fun_core/operations/auction_context/post_auction/participant_operation.rb b/lib/auction_fun_core/operations/auction_context/post_auction/participant_operation.rb new file mode 100644 index 0000000..533d366 --- /dev/null +++ b/lib/auction_fun_core/operations/auction_context/post_auction/participant_operation.rb @@ -0,0 +1,52 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Operations + module AuctionContext + module PostAuction + ## + # Operation class for finish auctions. + # + class ParticipantOperation < AuctionFunCore::Operations::Base + include Import["repos.user_context.user_repository"] + include Import["contracts.auction_context.post_auction.participant_contract"] + include Import["workers.services.mail.auction_context.post_auction.participant_mailer_job"] + + def self.call(attributes, &block) + operation = new.call(attributes) + + return operation unless block + + Dry::Matcher::ResultMatcher.call(operation, &block) + end + + ## @todo Add more actions + # Send email to participant with auction statistics. + def call(attributes) + auction, participant = yield validate_contract(attributes) + + user_repository.transaction do |_t| + yield send_participant_email_with_statistics_and_payment_instructions(auction.id, participant.id) + end + + Success([auction, participant]) + end + + private + + def validate_contract(attributes) + contract = participant_contract.call(attributes) + + return Failure(contract.errors.to_h) if contract.failure? + + Success([contract.context[:auction], contract.context[:participant]]) + end + + def send_participant_email_with_statistics_and_payment_instructions(auction_id, participant_id) + Success(participant_mailer_job.class.perform_async(auction_id, participant_id)) + end + end + end + end + end +end diff --git a/lib/auction_fun_core/operations/auction_context/post_auction/winner_operation.rb b/lib/auction_fun_core/operations/auction_context/post_auction/winner_operation.rb new file mode 100644 index 0000000..731e40d --- /dev/null +++ b/lib/auction_fun_core/operations/auction_context/post_auction/winner_operation.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Operations + module AuctionContext + module PostAuction + ## + # Operation class for finish auctions. + # + class WinnerOperation < AuctionFunCore::Operations::Base + include Import["repos.user_context.user_repository"] + include Import["contracts.auction_context.post_auction.winner_contract"] + include Import["workers.services.mail.auction_context.post_auction.winner_mailer_job"] + + def self.call(attributes, &block) + operation = new.call(attributes) + + return operation unless block + + Dry::Matcher::ResultMatcher.call(operation, &block) + end + + ## @todo Add doc + def call(attributes) + auction, winner = yield validate_contract(attributes) + + user_repository.transaction do |_t| + send_winner_email_with_statistics_and_payment_instructions(auction.id, winner.id) + end + + Success([auction, winner]) + end + + private + + def validate_contract(attributes) + contract = winner_contract.call(attributes) + + return Failure(contract.errors.to_h) if contract.failure? + + Success([contract.context[:auction], contract.context[:winner]]) + end + + def send_winner_email_with_statistics_and_payment_instructions(auction_id, winner_id) + Success(winner_mailer_job.class.perform_async(auction_id, winner_id)) + end + end + end + end + end +end diff --git a/lib/auction_fun_core/operations/auction_context/processor/finish/closed_operation.rb b/lib/auction_fun_core/operations/auction_context/processor/finish/closed_operation.rb new file mode 100644 index 0000000..fc2f137 --- /dev/null +++ b/lib/auction_fun_core/operations/auction_context/processor/finish/closed_operation.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Operations + module AuctionContext + module Processor + module Finish + ## + # Operation class for finalizing a closed auction. + # By default, this change auction status from 'running' to 'finished'. + # + class ClosedOperation < AuctionFunCore::Operations::Base + include Import["repos.auction_context.auction_repository"] + include Import["contracts.auction_context.processor.finish.closed_contract"] + include Import["workers.operations.auction_context.post_auction.winner_operation_job"] + include Import["workers.operations.auction_context.post_auction.participant_operation_job"] + + # @todo Add custom doc + def self.call(attributes, &block) + operation = new.call(attributes) + + return operation unless block + + Dry::Matcher::ResultMatcher.call(operation, &block) + end + + # It only performs the basic processing of completing an auction. + # It just changes the status at the database level and triggers the finished event. + # @param auction_id [Integer] Auction ID + # @return [Dry::Monads::Result::Success, Dry::Monads::Result::Failure] + def call(attributes) + auction = yield validate_contract(attributes) + summary = yield load_closed_auction_winners_and_participants(auction.id) + update_auction_attributes = yield update_finished_auction(auction, summary) + + auction_repository.transaction do |_t| + auction, _ = auction_repository.update(auction.id, update_auction_attributes) + + yield winner_operation(auction.id, summary.winner_id) + + summary.participant_ids.each do |participant_id| + yield participant_operation(auction.id, participant_id) + end + + publish_auction_finish_event(auction) + end + + Success(auction) + end + + private + + # Calls the finish contract class to perform the validation + # of the informed attributes. + # @param attributes [Hash] auction attributes + # @return [Dry::Monads::Result::Success, Dry::Monads::Result::Failure] + def validate_contract(attributes) + contract = closed_contract.call(attributes) + + return Failure(contract.errors.to_h) if contract.failure? + + Success(contract.context[:auction]) + end + + def load_closed_auction_winners_and_participants(auction_id) + summary = relation.load_closed_auction_winners_and_participants(auction_id).first + + Success(summary) + end + + def update_finished_auction(auction, summary) + attrs = {kind: auction.kind, status: "finished"} + attrs[:winner_id] = summary.winner_id if summary.winner_id.present? + + Success(attrs) + end + + def relation + AuctionFunCore::Application[:container].relations[:auctions] + end + + def winner_operation(auction_id, winner_id) + return Success() if winner_id.blank? + + Success(winner_operation_job.class.perform_async(auction_id, winner_id)) + end + + def participant_operation(auction_id, participant_id) + Success(participant_operation_job.class.perform_async(auction_id, participant_id)) + end + + def publish_auction_finish_event(auction) + Application[:event].publish("auctions.finished", auction.to_h) + end + end + end + end + end + end +end diff --git a/lib/auction_fun_core/operations/auction_context/processor/finish/penny_operation.rb b/lib/auction_fun_core/operations/auction_context/processor/finish/penny_operation.rb new file mode 100644 index 0000000..f0fa5e7 --- /dev/null +++ b/lib/auction_fun_core/operations/auction_context/processor/finish/penny_operation.rb @@ -0,0 +1,100 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Operations + module AuctionContext + module Processor + module Finish + ## + # Operation class for finalizing a penny auction. + # By default, this change auction status from 'running' to 'finished'. + # + class PennyOperation < AuctionFunCore::Operations::Base + include Import["repos.auction_context.auction_repository"] + include Import["contracts.auction_context.processor.finish.penny_contract"] + include Import["workers.operations.auction_context.post_auction.winner_operation_job"] + include Import["workers.operations.auction_context.post_auction.participant_operation_job"] + + # @todo Add custom doc + def self.call(attributes, &block) + operation = new.call(attributes) + + return operation unless block + + Dry::Matcher::ResultMatcher.call(operation, &block) + end + + # It only performs the basic processing of completing an auction. + # It just changes the status at the database level and triggers the finished event. + # @param auction_id [Integer] Auction ID + # @return [Dry::Monads::Result::Success, Dry::Monads::Result::Failure] + def call(attributes) + auction = yield validate_contract(attributes) + summary = yield load_penny_auction_winners_and_participants(auction.id) + update_auction_attributes = yield update_finished_auction(auction, summary) + + auction_repository.transaction do |_t| + auction, _ = auction_repository.update(auction.id, update_auction_attributes) + + yield winner_operation(auction.id, summary.winner_id) + + summary.participant_ids.each do |participant_id| + yield participant_operation(auction.id, participant_id) + end + + publish_auction_finish_event(auction) + end + + Success(auction) + end + + private + + # Calls the finish contract class to perform the validation + # of the informed attributes. + # @param attributes [Hash] auction attributes + # @return [Dry::Monads::Result::Success, Dry::Monads::Result::Failure] + def validate_contract(attributes) + contract = penny_contract.call(attributes) + + return Failure(contract.errors.to_h) if contract.failure? + + Success(contract.context[:auction]) + end + + def load_penny_auction_winners_and_participants(auction_id) + summary = relation.load_penny_auction_winners_and_participants(auction_id).first + + Success(summary) + end + + def update_finished_auction(auction, summary) + attrs = {kind: auction.kind, status: "finished"} + attrs[:winner_id] = summary.winner_id if summary.winner_id.present? + + Success(attrs) + end + + def relation + AuctionFunCore::Application[:container].relations[:auctions] + end + + def winner_operation(auction_id, winner_id) + return Success() if winner_id.blank? + + Success(winner_operation_job.class.perform_async(auction_id, winner_id)) + end + + def participant_operation(auction_id, participant_id) + Success(participant_operation_job.class.perform_async(auction_id, participant_id)) + end + + def publish_auction_finish_event(auction) + Application[:event].publish("auctions.finished", auction.to_h) + end + end + end + end + end + end +end diff --git a/lib/auction_fun_core/operations/auction_context/processor/finish/standard_operation.rb b/lib/auction_fun_core/operations/auction_context/processor/finish/standard_operation.rb new file mode 100644 index 0000000..e8739b6 --- /dev/null +++ b/lib/auction_fun_core/operations/auction_context/processor/finish/standard_operation.rb @@ -0,0 +1,102 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Operations + module AuctionContext + module Processor + module Finish + ## + # Operation class for finalizing a standard auction. + # By default, this change auction status from 'running' to 'finished'. + # + class StandardOperation < AuctionFunCore::Operations::Base + include Import["repos.auction_context.auction_repository"] + include Import["contracts.auction_context.processor.finish.standard_contract"] + include Import["workers.operations.auction_context.post_auction.winner_operation_job"] + include Import["workers.operations.auction_context.post_auction.participant_operation_job"] + + # @todo Add custom doc + def self.call(attributes, &block) + operation = new.call(attributes) + + return operation unless block + + Dry::Matcher::ResultMatcher.call(operation, &block) + end + + # TODO: update doc + # It only performs the basic processing of completing an auction. + # It just changes the status at the database level and triggers the finished event. + # @param attrs [Hash] auction attributes + # @option auction_id [Integer] Auction ID + # @return [Dry::Monads::Result::Success, Dry::Monads::Result::Failure] + def call(attributes) + auction = yield validate_contract(attributes) + summary = yield load_standard_auction_winners_and_participants(auction.id) + update_auction_attributes = yield update_finished_auction(auction, summary) + + auction_repository.transaction do |_t| + auction, _ = auction_repository.update(auction.id, update_auction_attributes) + + yield winner_operation(auction.id, summary.winner_id) + + summary.participant_ids.each do |participant_id| + yield participant_operation(auction.id, participant_id) + end + + publish_auction_finish_event(auction) + end + + Success(auction) + end + + private + + # Calls the finish standard contract class to perform the validation + # of the informed attributes. + # @param attributes [Hash] auction attributes + # @return [Dry::Monads::Result::Success, Dry::Monads::Result::Failure] + def validate_contract(attributes) + contract = standard_contract.call(attributes) + + return Failure(contract.errors.to_h) if contract.failure? + + Success(contract.context[:auction]) + end + + def load_standard_auction_winners_and_participants(auction_id) + summary = relation.load_standard_auction_winners_and_participants(auction_id).first + + Success(summary) + end + + def update_finished_auction(auction, summary) + attrs = {kind: auction.kind, status: "finished"} + attrs[:winner_id] = summary.winner_id if summary.winner_id.present? + + Success(attrs) + end + + def relation + AuctionFunCore::Application[:container].relations[:auctions] + end + + def winner_operation(auction_id, winner_id) + return Success() if winner_id.blank? + + Success(winner_operation_job.class.perform_async(auction_id, winner_id)) + end + + def participant_operation(auction_id, participant_id) + Success(participant_operation_job.class.perform_async(auction_id, participant_id)) + end + + def publish_auction_finish_event(auction) + Application[:event].publish("auctions.finished", auction.to_h) + end + end + end + end + end + end +end diff --git a/lib/auction_fun_core/operations/auction_context/processor/finish_operation.rb b/lib/auction_fun_core/operations/auction_context/processor/finish_operation.rb deleted file mode 100644 index 7d46aef..0000000 --- a/lib/auction_fun_core/operations/auction_context/processor/finish_operation.rb +++ /dev/null @@ -1,61 +0,0 @@ -# frozen_string_literal: true - -module AuctionFunCore - module Operations - module AuctionContext - module Processor - ## - # Operation class for dispatch finish auction. - # By default, this change auction status from 'running' to 'finished'. - # - class FinishOperation < AuctionFunCore::Operations::Base - include Import["repos.auction_context.auction_repository"] - include Import["contracts.auction_context.processor.finish_contract"] - - # @todo Add custom doc - def self.call(auction_id, &block) - operation = new.call(auction_id) - - return operation unless block - - Dry::Matcher::ResultMatcher.call(operation, &block) - end - - # It only performs the basic processing of completing an auction. - # It just changes the status at the database level and triggers the finished event. - # @param auction_id [Integer] Auction ID - # @return [Dry::Monads::Result::Success, Dry::Monads::Result::Failure] - def call(auction_id) - yield validate(auction_id: auction_id) - - auction_repository.transaction do |_t| - @auction, _ = auction_repository.update(auction_id, status: "finished") - - publish_auction_finish_event(@auction) - end - - Success(@auction) - end - - private - - # Calls the finish contract class to perform the validation - # of the informed attributes. - # @param attributes [Hash] auction attributes - # @return [Dry::Monads::Result::Success, Dry::Monads::Result::Failure] - def validate(attributes) - contract = finish_contract.call(attributes) - - return Failure(contract.errors.to_h) if contract.failure? - - Success(contract.to_h) - end - - def publish_auction_finish_event(auction) - Application[:event].publish("auctions.finished", auction.to_h) - end - end - end - end - end -end diff --git a/lib/auction_fun_core/operations/auction_context/processor/start_operation.rb b/lib/auction_fun_core/operations/auction_context/processor/start_operation.rb index b8fe616..66ae57b 100644 --- a/lib/auction_fun_core/operations/auction_context/processor/start_operation.rb +++ b/lib/auction_fun_core/operations/auction_context/processor/start_operation.rb @@ -11,7 +11,9 @@ module Processor class StartOperation < AuctionFunCore::Operations::Base include Import["repos.auction_context.auction_repository"] include Import["contracts.auction_context.processor.start_contract"] - include Import["workers.operations.auction_context.processor.finish_operation_job"] + include Import["workers.operations.auction_context.processor.finish.closed_operation_job"] + include Import["workers.operations.auction_context.processor.finish.penny_operation_job"] + include Import["workers.operations.auction_context.processor.finish.standard_operation_job"] # @todo Add custom doc def self.call(attributes, &block) @@ -28,10 +30,10 @@ def self.call(attributes, &block) # @option opts [Integer] :stopwatch auction stopwatch # @return [ROM::Struct::Auction] auction object def call(attributes) - attrs = yield validate(attributes) + auction, attrs = yield validate_contract(attributes) auction_repository.transaction do |_t| - @auction, _ = auction_repository.update(attrs[:auction_id], update_params(attrs)) + @auction, _ = auction_repository.update(auction.id, update_params(auction, attrs)) yield publish_auction_start_event(@auction) yield scheduled_finished_auction(@auction) @@ -46,36 +48,46 @@ def call(attributes) # of the informed attributes. # @param attributes [Hash] auction attributes # @return [Dry::Monads::Result::Success, Dry::Monads::Result::Failure] - def validate(attributes) + def validate_contract(attributes) contract = start_contract.call(attributes) return Failure(contract.errors.to_h) if contract.failure? - Success(contract.to_h) + Success([contract.context[:auction], contract.to_h]) end # Updates the status of the auction and depending on the type of auction, # it already sets the final date. # @param attrs [Hash] auction attributes # @return [Hash] - def update_params(attrs) - return {status: "running"} unless attrs[:kind] == "penny" + def update_params(auction, attrs) + return {kind: auction.kind, status: "running"} unless attrs[:kind] == "penny" - {status: "running", finished_at: attrs[:stopwatch].seconds.from_now} + {kind: auction.kind, status: "running", finished_at: attrs[:stopwatch].seconds.from_now} end def publish_auction_start_event(auction) Success(Application[:event].publish("auctions.started", auction.to_h)) end + # TODO: Added a small delay to perform operations (such as sending broadcasts and/or other operations). # Calls the background job class that will schedule the finish of the auction. - # Added a small delay to perform operations (such as sending broadcasts and/or other operations). + # In the case of the penny auction, the end of the auction occurs after the first timer resets. # @param auction [ROM::Struct::Auction] # @return [String] sidekiq jid def scheduled_finished_auction(auction) - return Success() if auction.kind == "penny" + perform_at = auction.finished_at - Success(finish_operation_job.class.perform_at(auction.finished_at, auction.id)) + case auction.kind + in "penny" + perform_at = auction.started_at + auction.stopwatch.seconds + + Success(penny_operation_job.class.perform_at(perform_at, auction.id)) + in "standard" + Success(standard_operation_job.class.perform_at(perform_at, auction.id)) + in "closed" + Success(closed_operation_job.class.perform_at(perform_at, auction.id)) + end end end end diff --git a/lib/auction_fun_core/operations/bid_context/create_bid_penny_operation.rb b/lib/auction_fun_core/operations/bid_context/create_bid_penny_operation.rb index 0d3c170..985c65b 100644 --- a/lib/auction_fun_core/operations/bid_context/create_bid_penny_operation.rb +++ b/lib/auction_fun_core/operations/bid_context/create_bid_penny_operation.rb @@ -7,8 +7,10 @@ module BidContext # Operation class for create new bids for penny auctions. # class CreateBidPennyOperation < AuctionFunCore::Operations::Base - include Import["contracts.bid_context.create_bid_penny_contract"] include Import["repos.bid_context.bid_repository"] + include Import["repos.auction_context.auction_repository"] + include Import["contracts.bid_context.create_bid_penny_contract"] + include Import["workers.operations.auction_context.processor.finish.penny_operation_job"] # @todo Add custom doc def self.call(attributes, &block) @@ -21,10 +23,13 @@ def self.call(attributes, &block) # @todo Add custom doc def call(attributes) - values = yield validate(attributes) + auction, values = yield validate_contract(attributes) bid_repository.transaction do |_t| @bid = yield persist(values) + updated_auction = yield update_end_auction(auction) + + yield reschedule_end_auction(updated_auction) yield publish_bid_created(@bid) end @@ -35,19 +40,42 @@ def call(attributes) # of the informed attributes. # @param attrs [Hash] bid attributes # @return [Dry::Monads::Result::Success, Dry::Monads::Result::Failure] - def validate(attrs) + def validate_contract(attrs) contract = create_bid_penny_contract.call(attrs) return Failure(contract.errors.to_h) if contract.failure? - Success(contract.to_h) + Success([contract.context[:auction], contract.to_h]) + end + + # Updates the end time of an auction if it has already started. + # + # This method checks whether an auction is currently running. If the auction is running, + # it calculates a new end time based on the current time and the duration specified + # by the auction's stopwatch. The auction's finish time is then updated in the repository. + # If the auction has not started, it returns the auction as is without any modifications. + # + # @param auction [ROM::Struct::Auction] An instance of Auction to be checked and potentially updated. + # @return [Dry::Monads::Result::Success, Dry::Monads::Result::Failure] + def update_end_auction(auction) + return Success(auction) unless started_auction?(auction) + + updated_attributes = { + finished_at: Time.current + auction.stopwatch.seconds, + kind: auction.kind, + status: auction.status + } + + updated_auction, _ = auction_repository.update(auction.id, updated_attributes) + + Success(updated_auction) end # Calls the bid repository class to persist the attributes in the database. # @param result [Hash] Bid validated attributes - # @return [ROM::Struct::Bid] - def persist(result) - Success(bid_repository.create(result)) + # @return [Dry::Monads::Result::Success, Dry::Monads::Result::Failure] + def persist(values) + Success(bid_repository.create(values)) end # Triggers the publication of event *bids.created*. @@ -58,6 +86,28 @@ def publish_bid_created(bid) Success() end + + # TODO: Added a small delay to perform operations (such as sending broadcasts and/or other operations). + # Reschedules the end time of an auction's background job if the auction has already started. + # + # This method checks if the auction is running. If so, it schedules a background job to + # execute at the auction's current finish time using the job class defined by the + # penny_operation_job attribute of the auction. If the auction has not started, it + # simply returns a Success object with no parameters. + # + # @return [Dry::Monads::Result::Success, Dry::Monads::Result::Success] + def reschedule_end_auction(auction) + # binding.pry + return Success(auction) unless started_auction?(auction) + + perform_at = auction.finished_at + + Success(penny_operation_job.class.perform_at(perform_at, auction.id)) + end + + def started_auction?(auction) + auction.status == "running" + end end end end diff --git a/lib/auction_fun_core/relations/auctions.rb b/lib/auction_fun_core/relations/auctions.rb index e8a9fb9..de4a544 100644 --- a/lib/auction_fun_core/relations/auctions.rb +++ b/lib/auction_fun_core/relations/auctions.rb @@ -14,6 +14,7 @@ class Auctions < ROM::Relation[:sql] schema(:auctions, infer: true) do attribute :id, Types::Integer attribute :staff_id, Types::ForeignKey(:staffs) + attribute :winner_id, Types::ForeignKey(:users) attribute :title, Types::String attribute :description, Types::String attribute :kind, KINDS @@ -32,6 +33,7 @@ class Auctions < ROM::Relation[:sql] associations do belongs_to :staff, as: :staff, relation: :staffs + belongs_to :winner, as: :winner, relation: :users, foreign_key: :winner_id has_many :bids, as: :bids, relation: :bids end end @@ -51,6 +53,152 @@ def info(auction_id, options = {bidders_count: 3}) read(auction_with_bid_info(auction_id, options)) end + # Retrieves the standard auction winner and other participating bidders for a specified auction. + # + # This method queries the database to fetch the winner based on the highest bid and collects an array + # of other participants who placed bids in the auction, all except for the winner. The method returns + # structured data that includes the auction ID, winner's ID, total number of bids, + # and a list of participant IDs. + # + # @return [Hash] a hash containing details about the auction, including winner and participants: + # - :id [Integer] the ID of the auction + # - :winner_id [Integer] the ID of the winning bidder + # - :total_bids [Integer] the total number of bids placed in the auction + # - :participants [Array] an array of user IDs of the other participants, excluding the winner + def load_standard_auction_winners_and_participants(auction_id) + raise "Invalid argument" unless auction_id.is_a?(Integer) + + read("SELECT a.id, a.kind, a.status, w.user_id AS winner_id, COALESCE(COUNT(b.id), 0) AS total_bids, + COALESCE( + ARRAY_REMOVE(ARRAY_AGG(DISTINCT b.user_id ORDER BY b.user_id), w.user_id), ARRAY[]::INT[] + ) AS participant_ids + FROM auctions a + LEFT JOIN bids b ON a.id = b.auction_id + LEFT JOIN ( + SELECT auction_id, user_id, MAX(value_cents) AS value_cents + FROM bids + WHERE auction_id = #{auction_id} + GROUP BY auction_id, user_id + ORDER BY value_cents DESC + LIMIT 1 + ) AS w ON a.id = w.auction_id + WHERE a.id = #{auction_id} + GROUP BY a.id, w.user_id") + end + + # Retrieves the penny auction winner and other participating bidders for a specified auction. + # + # This method queries the database to fetch the winner based on the latest bid and collects an array + # of other participants who placed bids in the auction, all except for the winner. The method returns + # structured data that includes the auction ID, winner's ID, total number of bids, + # and a list of participant IDs. + # + # @return [Hash] a hash containing details about the auction, including winner and participants: + # - :id [Integer] the ID of the auction + # - :winner_id [Integer] the ID of the winning bidder + # - :total_bids [Integer] the total number of bids placed in the auction + # - :participants [Array] an array of user IDs of the other participants, excluding the winner + def load_penny_auction_winners_and_participants(auction_id) + raise "Invalid argument" unless auction_id.is_a?(Integer) + + read("SELECT a.id, a.kind, a.status, w.user_id AS winner_id, COALESCE(COUNT(b.id), 0) AS total_bids, + COALESCE( + ARRAY_REMOVE(ARRAY_AGG(DISTINCT b.user_id ORDER BY b.user_id), w.user_id), ARRAY[]::INT[] + ) AS participant_ids + FROM auctions a + LEFT JOIN bids b ON a.id = b.auction_id + LEFT JOIN ( + SELECT auction_id, user_id + FROM bids + WHERE auction_id = #{auction_id} + ORDER BY bids.created_at DESC + LIMIT 1 + ) AS w ON a.id = w.auction_id + WHERE a.id = #{auction_id} + GROUP BY a.id, w.user_id") + end + + # Retrieves the closed auction winner and other participating bidders for a specified auction. + # + # This method queries the database to fetch the winner based on the highest bid and collects an array + # of other participants who placed bids in the auction, all except for the winner. The method returns + # structured data that includes the auction ID, winner's ID, total number of bids, + # and a list of participant IDs. + # + # @return [Hash] a hash containing details about the auction, including winner and participants: + # - :id [Integer] the ID of the auction + # - :winner_id [Integer] the ID of the winning bidder + # - :total_bids [Integer] the total number of bids placed in the auction + # - :participants [Array] an array of user IDs of the other participants, excluding the winner + def load_closed_auction_winners_and_participants(auction_id) + raise "Invalid argument" unless auction_id.is_a?(Integer) + + read("SELECT a.id, a.kind, a.status, w.user_id AS winner_id, COALESCE(COUNT(b.id), 0) AS total_bids, + COALESCE( + ARRAY_REMOVE(ARRAY_AGG(DISTINCT b.user_id ORDER BY b.user_id), w.user_id), ARRAY[]::INT[] + ) AS participant_ids + FROM auctions a + LEFT JOIN bids b ON a.id = b.auction_id + LEFT JOIN ( + SELECT auction_id, user_id, MAX(value_cents) AS value_cents + FROM bids + WHERE auction_id = #{auction_id} + GROUP BY auction_id, user_id + ORDER BY value_cents DESC + LIMIT 1 + ) AS w ON a.id = w.auction_id + WHERE a.id = #{auction_id} + GROUP BY a.id, w.user_id") + end + + def load_winner_statistics(auction_id, winner_id) + raise "Invalid argument" unless auction_id.is_a?(Integer) || winner_id.is_a?(Integer) + + read("SELECT a.id, COUNT(b.id) AS auction_total_bids, MAX(b.value_cents) AS winner_bid, + date(a.finished_at) as auction_date, + (SELECT COUNT(*) FROM bids b2 + WHERE b2.auction_id = #{auction_id} + AND b2.user_id = #{winner_id} + ) AS winner_total_bids + FROM auctions a + LEFT JOIN bids b ON a.id = b.auction_id AND a.id = #{auction_id} + LEFT JOIN users u ON u.id = b.user_id AND u.id = #{winner_id} + LEFT JOIN ( + SELECT auction_id, user_id, MAX(value_cents) AS value_cents + FROM bids + WHERE auction_id = #{auction_id} + GROUP BY auction_id, user_id + ORDER BY value_cents DESC + LIMIT 1 + ) AS w ON a.id = w.auction_id + WHERE a.id = #{auction_id} + GROUP BY a.id") + end + + def load_participant_statistics(auction_id, participant_id) + raise "Invalid argument" unless auction_id.is_a?(Integer) || participant_id.is_a?(Integer) + + read("SELECT a.id, COUNT(b.id) AS auction_total_bids, MAX(b.value_cents) AS winner_bid, + date(a.finished_at) as auction_date, + (SELECT COUNT(*) FROM bids b2 + WHERE b2.auction_id = #{auction_id} + AND b2.user_id = #{participant_id} + ) AS winner_total_bids + FROM auctions a + LEFT JOIN bids b ON a.id = b.auction_id AND a.id = #{auction_id} + LEFT JOIN users u ON u.id = b.user_id AND u.id = #{participant_id} + LEFT JOIN ( + SELECT auction_id, user_id, MAX(value_cents) AS value_cents + FROM bids + WHERE auction_id = #{auction_id} + GROUP BY auction_id, user_id + ORDER BY value_cents DESC + LIMIT 1 + ) AS w ON a.id = w.auction_id + WHERE a.id = #{auction_id} + GROUP BY a.id") + end + private def auction_with_bid_info(auction_id, options = {bidders_count: 3}) diff --git a/lib/auction_fun_core/services/mail/auction_context/post_auction/participant_mailer.rb b/lib/auction_fun_core/services/mail/auction_context/post_auction/participant_mailer.rb new file mode 100644 index 0000000..b83243f --- /dev/null +++ b/lib/auction_fun_core/services/mail/auction_context/post_auction/participant_mailer.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Services + module Mail + module AuctionContext + module PostAuction + class ParticipantMailer + include IdleMailer::Mailer + include IdleMailer::TemplateManager + + # @param auction [ROM::Struct::Auction] The auction object + # @param participant [ROM::Struct::User] The user object + # @param statistics [OpenStruct] Statistics object + def initialize(auction, participant, statistics) + @auction = auction + @participant = participant + @statistics = statistics + mail.to = participant.email + mail.subject = I18n.t("mail.auction_context.post_auction.participant_mailer.subject", title: @auction.title) + end + + def self.template_name + IdleMailer.config.templates.join("auction_context/post_auction/participant") + end + end + end + end + end + end +end diff --git a/lib/auction_fun_core/services/mail/auction_context/post_auction/winner_mailer.rb b/lib/auction_fun_core/services/mail/auction_context/post_auction/winner_mailer.rb new file mode 100644 index 0000000..83dd8bb --- /dev/null +++ b/lib/auction_fun_core/services/mail/auction_context/post_auction/winner_mailer.rb @@ -0,0 +1,31 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Services + module Mail + module AuctionContext + module PostAuction + class WinnerMailer + include IdleMailer::Mailer + include IdleMailer::TemplateManager + + # @param auction [ROM::Struct::Auction] The auction object + # @param winner [ROM::Struct::User] The user object + # @param statistics [OpenStruct] Statistics object + def initialize(auction, winner, statistics) + @auction = auction + @winner = winner + @statistics = statistics + mail.to = winner.email + mail.subject = I18n.t("mail.auction_context.post_auction.winner_mailer.subject", title: @auction.title) + end + + def self.template_name + IdleMailer.config.templates.join("auction_context/post_auction/winner") + end + end + end + end + end + end +end diff --git a/lib/auction_fun_core/services/mail/templates/auction_context/post_auction/participant.html.erb b/lib/auction_fun_core/services/mail/templates/auction_context/post_auction/participant.html.erb new file mode 100644 index 0000000..6a2eee3 --- /dev/null +++ b/lib/auction_fun_core/services/mail/templates/auction_context/post_auction/participant.html.erb @@ -0,0 +1,173 @@ + + + + +
+
+ + + + + + +
+ + + + +
+
+ + + + + + + + + +
+ + + + + + +
+ image description +
+
+
+

+ <%= I18n.t("mail.general.hello", name: @participant.name) %>, +

+
+
+
+
+
+
+
+ + + + +
+
+ + + + + + +
+ + + + +
+
+ + + + + + + + + + + + + + + +
+
+ <%= raw I18n.t("mail.auction_context.post_auction.participant_mailer.body.description", title: @auction.title) %> +
+
+
+
    +
  • <%= I18n.t("mail.auction_context.post_auction.participant_mailer.body.stats.auction_total_bids", auction_total_bids: @statistics.auction_total_bids) %>
  • +
  • <%= I18n.t("mail.auction_context.post_auction.participant_mailer.body.stats.winner_bid", winner_bid: @statistics.winner_bid) %>
  • +
  • <%= I18n.t("mail.auction_context.post_auction.participant_mailer.body.stats.auction_date") %>: <%= I18n.l(@statistics.auction_date, format: :default) %>
  • +
+
+
+ <%= I18n.t("application.general.team") %> +
+
+ + + + + + +
+ + + + + + +
+ + + + + + +
+ + twitter-logo + +
+
+
+ + + + + + +
+ + + + + + +
+ + facebook-logo + +
+
+
+ + + + + + +
+ + + + + + +
+ + instagram-logo + +
+
+
+
+
+
+
+
+
diff --git a/lib/auction_fun_core/services/mail/templates/auction_context/post_auction/winner.html.erb b/lib/auction_fun_core/services/mail/templates/auction_context/post_auction/winner.html.erb new file mode 100644 index 0000000..1226fc7 --- /dev/null +++ b/lib/auction_fun_core/services/mail/templates/auction_context/post_auction/winner.html.erb @@ -0,0 +1,174 @@ + + + + +
+
+ + + + + + +
+ + + + +
+
+ + + + + + + + + +
+ + + + + + +
+ image description +
+
+
+

+ <%= I18n.t("mail.general.hello", name: @winner.name) %>, +

+
+
+
+
+
+
+
+ + + + +
+
+ + + + + + +
+ + + + +
+
+ + + + + + + + + + + + + + + +
+
+ <%= raw I18n.t("mail.auction_context.post_auction.winner_mailer.body.description", title: @auction.title) %> +
+
+
+
    +
  • <%= raw I18n.t("mail.auction_context.post_auction.winner_mailer.body.item.title", title: @auction.title) %>
  • +
  • <%= I18n.t("mail.auction_context.post_auction.winner_mailer.body.item.winner_bid", winner_bid: @statistics.winner_bid) %>
  • +
  • <%= I18n.t("mail.auction_context.post_auction.winner_mailer.body.item.auction_total_bids", auction_total_bids: @statistics.auction_total_bids) %>
  • +
  • <%= I18n.t("mail.auction_context.post_auction.winner_mailer.body.item.auction_date") %>: <%= I18n.l(@statistics.auction_date, format: :default) %>
  • +
+
+
+ <%= I18n.t("application.general.team") %> +
+
+ + + + + + +
+ + + + + + +
+ + + + + + +
+ + twitter-logo + +
+
+
+ + + + + + +
+ + + + + + +
+ + facebook-logo + +
+
+
+ + + + + + +
+ + + + + + +
+ + instagram-logo + +
+
+
+
+
+
+
+
+
diff --git a/lib/auction_fun_core/services/mail/templates/user_context/registration.html.erb b/lib/auction_fun_core/services/mail/templates/user_context/registration.html.erb index 6585517..e060031 100644 --- a/lib/auction_fun_core/services/mail/templates/user_context/registration.html.erb +++ b/lib/auction_fun_core/services/mail/templates/user_context/registration.html.erb @@ -29,7 +29,7 @@

- <%= I18n.t("mail.general.hello", name: @user.name) %>, + <%= I18n.t("application.general.hello", name: @user.name) %>,

@@ -79,7 +79,7 @@
- <%= I18n.t("mail.general.team") %> + <%= I18n.t("application.general.team") %>
diff --git a/lib/auction_fun_core/version.rb b/lib/auction_fun_core/version.rb index 88c0395..a2580a2 100644 --- a/lib/auction_fun_core/version.rb +++ b/lib/auction_fun_core/version.rb @@ -1,7 +1,7 @@ # frozen_string_literal: true module AuctionFunCore - VERSION = "0.8.5" + VERSION = "0.8.6" # Required class module is a gem dependency class Version; end diff --git a/lib/auction_fun_core/workers/application_job.rb b/lib/auction_fun_core/workers/application_job.rb index 58da564..1192e57 100644 --- a/lib/auction_fun_core/workers/application_job.rb +++ b/lib/auction_fun_core/workers/application_job.rb @@ -1,5 +1,7 @@ # frozen_string_literal: true +require "sidekiq" + module AuctionFunCore module Workers # Abstract base class for background jobs. diff --git a/lib/auction_fun_core/workers/operations/auction_context/post_auction/participant_operation_job.rb b/lib/auction_fun_core/workers/operations/auction_context/post_auction/participant_operation_job.rb new file mode 100644 index 0000000..4847927 --- /dev/null +++ b/lib/auction_fun_core/workers/operations/auction_context/post_auction/participant_operation_job.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Workers + module Operations + module AuctionContext + module PostAuction + ## + # BackgroundJob class for call finish auction operation. + class ParticipantOperationJob < Workers::ApplicationJob + include Import["repos.user_context.user_repository"] + include Import["repos.auction_context.auction_repository"] + include Import["operations.auction_context.post_auction.participant_operation"] + + # @todo Add detailed documentation + def perform(auction_id, participant_id, retry_count = 0) + auction = auction_repository.by_id!(auction_id) + participant = user_repository.by_id!(participant_id) + + participant_operation.call(auction_id: auction.id, participant_id: participant.id) + rescue => e + capture_exception(e, {auction_id: auction_id, participant_id: participant_id, retry_count: retry_count}) + raise if retry_count >= MAX_RETRIES + + interval = backoff_exponential_job(retry_count) + self.class.perform_at(interval, auction_id, participant_id, retry_count + 1) + end + end + end + end + end + end +end diff --git a/lib/auction_fun_core/workers/operations/auction_context/post_auction/winner_operation_job.rb b/lib/auction_fun_core/workers/operations/auction_context/post_auction/winner_operation_job.rb new file mode 100644 index 0000000..12b61a8 --- /dev/null +++ b/lib/auction_fun_core/workers/operations/auction_context/post_auction/winner_operation_job.rb @@ -0,0 +1,33 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Workers + module Operations + module AuctionContext + module PostAuction + ## + # BackgroundJob class for call finish auction operation. + class WinnerOperationJob < Workers::ApplicationJob + include Import["repos.user_context.user_repository"] + include Import["repos.auction_context.auction_repository"] + include Import["operations.auction_context.post_auction.winner_operation"] + + # @todo Add detailed documentation + def perform(auction_id, winner_id, retry_count = 0) + auction = auction_repository.by_id!(auction_id) + winner = user_repository.by_id!(winner_id) + + winner_operation.call(auction_id: auction.id, winner_id: winner.id) + rescue => e + capture_exception(e, {auction_id: auction_id, winner_id: winner_id, retry_count: retry_count}) + raise if retry_count >= MAX_RETRIES + + interval = backoff_exponential_job(retry_count) + self.class.perform_at(interval, auction_id, winner_id, retry_count + 1) + end + end + end + end + end + end +end diff --git a/lib/auction_fun_core/workers/operations/auction_context/processor/finish/closed_operation_job.rb b/lib/auction_fun_core/workers/operations/auction_context/processor/finish/closed_operation_job.rb new file mode 100644 index 0000000..f7c905b --- /dev/null +++ b/lib/auction_fun_core/workers/operations/auction_context/processor/finish/closed_operation_job.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Workers + module Operations + module AuctionContext + module Processor + module Finish + ## + # BackgroundJob class for call finish closed auction operation. + # + class ClosedOperationJob < Workers::ApplicationJob + include Import["repos.auction_context.auction_repository"] + include Import["operations.auction_context.processor.finish.closed_operation"] + + # @todo Add detailed documentation + def perform(auction_id, retry_count = 0) + auction = auction_repository.by_id!(auction_id) + + closed_operation.call(auction_id: auction.id) + rescue => e + capture_exception(e, {auction_id: auction_id, retry_count: retry_count}) + raise if retry_count >= MAX_RETRIES + + interval = backoff_exponential_job(retry_count) + self.class.perform_at(interval, auction_id, retry_count + 1) + end + end + end + end + end + end + end +end diff --git a/lib/auction_fun_core/workers/operations/auction_context/processor/finish/penny_operation_job.rb b/lib/auction_fun_core/workers/operations/auction_context/processor/finish/penny_operation_job.rb new file mode 100644 index 0000000..d7ae8a0 --- /dev/null +++ b/lib/auction_fun_core/workers/operations/auction_context/processor/finish/penny_operation_job.rb @@ -0,0 +1,37 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Workers + module Operations + module AuctionContext + module Processor + module Finish + ## + # BackgroundJob class for call finish penny auction operation. + # + class PennyOperationJob < Workers::ApplicationJob + include Sidekiq::Worker + include Import["repos.auction_context.auction_repository"] + include Import["operations.auction_context.processor.finish.penny_operation"] + + sidekiq_options queue: "default", lock: :until_executed, on_conflict: :replace + + # @todo Add detailed documentation + def perform(auction_id, retry_count = 0) + auction = auction_repository.by_id!(auction_id) + + penny_operation.call(auction_id: auction.id) + rescue => e + capture_exception(e, {auction_id: auction_id, retry_count: retry_count}) + raise if retry_count >= MAX_RETRIES + + interval = backoff_exponential_job(retry_count) + self.class.perform_at(interval, auction_id, retry_count + 1) + end + end + end + end + end + end + end +end diff --git a/lib/auction_fun_core/workers/operations/auction_context/processor/finish/standard_operation_job.rb b/lib/auction_fun_core/workers/operations/auction_context/processor/finish/standard_operation_job.rb new file mode 100644 index 0000000..1fd44cd --- /dev/null +++ b/lib/auction_fun_core/workers/operations/auction_context/processor/finish/standard_operation_job.rb @@ -0,0 +1,34 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Workers + module Operations + module AuctionContext + module Processor + module Finish + ## + # BackgroundJob class for call finish standard auction operation. + # + class StandardOperationJob < Workers::ApplicationJob + include Import["repos.auction_context.auction_repository"] + include Import["operations.auction_context.processor.finish.standard_operation"] + + # @todo Add detailed documentation + def perform(auction_id, retry_count = 0) + auction = auction_repository.by_id!(auction_id) + + standard_operation.call(auction_id: auction.id) + rescue => e + capture_exception(e, {auction_id: auction_id, retry_count: retry_count}) + raise if retry_count >= MAX_RETRIES + + interval = backoff_exponential_job(retry_count) + self.class.perform_at(interval, auction_id, retry_count + 1) + end + end + end + end + end + end + end +end diff --git a/lib/auction_fun_core/workers/operations/auction_context/processor/finish_operation_job.rb b/lib/auction_fun_core/workers/operations/auction_context/processor/finish_operation_job.rb deleted file mode 100644 index c42f62f..0000000 --- a/lib/auction_fun_core/workers/operations/auction_context/processor/finish_operation_job.rb +++ /dev/null @@ -1,32 +0,0 @@ -# frozen_string_literal: true - -module AuctionFunCore - module Workers - module Operations - module AuctionContext - module Processor - ## - # BackgroundJob class for call finish auction operation. - # - class FinishOperationJob < Workers::ApplicationJob - include Import["repos.auction_context.auction_repository"] - include Import["operations.auction_context.processor.finish_operation"] - - # @todo Add detailed documentation - def perform(auction_id, retry_count = 0) - auction = auction_repository.by_id!(auction_id) - - finish_operation.call(auction.id) - rescue => e - capture_exception(e, {auction_id: auction_id, retry_count: retry_count}) - raise if retry_count >= MAX_RETRIES - - interval = backoff_exponential_job(retry_count) - self.class.perform_at(interval, auction_id, retry_count + 1) - end - end - end - end - end - end -end diff --git a/lib/auction_fun_core/workers/services/mail/auction_context/post_auction/participant_mailer_job.rb b/lib/auction_fun_core/workers/services/mail/auction_context/post_auction/participant_mailer_job.rb new file mode 100644 index 0000000..5a24fcd --- /dev/null +++ b/lib/auction_fun_core/workers/services/mail/auction_context/post_auction/participant_mailer_job.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Workers + module Services + module Mail + module AuctionContext + module PostAuction + ## + # Background job class responsible for adding emails to the queue. + # + class ParticipantMailerJob < AuctionFunCore::Workers::ApplicationJob + include Import["repos.user_context.user_repository"] + include Import["repos.auction_context.auction_repository"] + + # @param auction_id [Integer] auction ID + # @param participant_id [Integer] user ID + def perform(auction_id, participant_id, retry_count = 0) + auction = auction_repository.by_id!(auction_id) + participant = user_repository.by_id!(participant_id) + + statistics = relation.load_participant_statistics.call(auction.id, participant.id).first + + participant_mailer.new(auction, participant, statistics).deliver + rescue => e + capture_exception(e, {auction_id: auction_id, participant_id: participant_id, retry_count: retry_count}) + raise e if retry_count >= MAX_RETRIES + + interval = backoff_exponential_job(retry_count) + self.class.perform_at(interval, auction_id, participant_id, retry_count + 1) + end + + private + + # Since the shipping code structure does not follow project conventions, + # making the default injection dependency would be more complicated. + # Therefore, here I directly explain the class to be called. + def participant_mailer + AuctionFunCore::Services::Mail::AuctionContext::PostAuction::ParticipantMailer + end + + def relation + AuctionFunCore::Application[:container].relations[:auctions] + end + end + end + end + end + end + end +end diff --git a/lib/auction_fun_core/workers/services/mail/auction_context/post_auction/winner_mailer_job.rb b/lib/auction_fun_core/workers/services/mail/auction_context/post_auction/winner_mailer_job.rb new file mode 100644 index 0000000..47e1865 --- /dev/null +++ b/lib/auction_fun_core/workers/services/mail/auction_context/post_auction/winner_mailer_job.rb @@ -0,0 +1,51 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Workers + module Services + module Mail + module AuctionContext + module PostAuction + ## + # Background job class responsible for adding emails to the queue. + # + class WinnerMailerJob < AuctionFunCore::Workers::ApplicationJob + include Import["repos.user_context.user_repository"] + include Import["repos.auction_context.auction_repository"] + + # @param auction_id [Integer] auction ID + # @param winner_id [Integer] user ID + def perform(auction_id, winner_id, retry_count = 0) + auction = auction_repository.by_id!(auction_id) + winner = user_repository.by_id!(winner_id) + + statistics = relation.load_winner_statistics.call(auction_id, winner_id).first + + winner_mailer.new(auction, winner, statistics).deliver + rescue => e + capture_exception(e, {auction_id: auction_id, winner_id: winner_id, retry_count: retry_count}) + raise e if retry_count >= MAX_RETRIES + + interval = backoff_exponential_job(retry_count) + self.class.perform_at(interval, auction_id, winner_id, retry_count + 1) + end + + private + + # Since the shipping code structure does not follow project conventions, + # making the default injection dependency would be more complicated. + # Therefore, here I directly explain the class to be called. + def winner_mailer + AuctionFunCore::Services::Mail::AuctionContext::PostAuction::WinnerMailer + end + + def relation + AuctionFunCore::Application[:container].relations[:auctions] + end + end + end + end + end + end + end +end diff --git a/spec/auction_fun_core/contracts/auction_context/post_auction/participant_contract_spec.rb b/spec/auction_fun_core/contracts/auction_context/post_auction/participant_contract_spec.rb new file mode 100644 index 0000000..efa0bb3 --- /dev/null +++ b/spec/auction_fun_core/contracts/auction_context/post_auction/participant_contract_spec.rb @@ -0,0 +1,77 @@ +# frozen_string_literal: true + +RSpec.describe AuctionFunCore::Contracts::AuctionContext::PostAuction::ParticipantContract, type: :contract do + let(:auction) { Factory[:auction, :default_standard, :with_winner] } + let(:participant) { Factory[:user] } + + describe "#call" do + subject(:contract) { described_class.new.call(attributes) } + + context "when attributes are invalid" do + let(:attributes) { Dry::Core::Constants::EMPTY_HASH } + + it "expect failure with error messages" do + expect(contract.errors[:auction_id]).to include(I18n.t("contracts.errors.key?")) + expect(contract.errors[:participant_id]).to include(I18n.t("contracts.errors.key?")) + end + end + + context "when auction is not found on database" do + let(:attributes) do + { + auction_id: 2_234_231, + participant_id: participant.id + } + end + + it "expect failure with error messages" do + expect(contract.errors[:auction_id]).to include(I18n.t("contracts.errors.custom.not_found")) + end + end + + context "when participant is not found on database" do + let(:attributes) do + { + auction_id: auction.id, + participant_id: 2_234_231 + } + end + + it "expect failure with error messages" do + expect(contract.errors[:participant_id]).to include(I18n.t("contracts.errors.custom.not_found")) + end + end + + context "when the user did not bid in the auction" do + let(:attributes) do + { + auction_id: auction.id, + participant_id: participant.id + } + end + + it "expect failure with error messages" do + expect(contract.errors[:participant_id]).to include(I18n.t("none", scope: described_class::I18N_SCOPE)) + end + end + + context "when the user placed at least one bid in the auction" do + let(:attributes) do + { + auction_id: auction.id, + participant_id: participant.id + } + end + + before do + Factory[:bid, auction_id: auction.id, user_id: participant.id, value_cents: auction.minimal_bid_cents] + end + + it "expect return sucess" do + expect(contract).to be_success + expect(contract.context[:auction]).to be_a(AuctionFunCore::Entities::Auction) + expect(contract.context[:participant]).to be_a(AuctionFunCore::Entities::User) + end + end + end +end diff --git a/spec/auction_fun_core/contracts/auction_context/post_auction/winner_contract_spec.rb b/spec/auction_fun_core/contracts/auction_context/post_auction/winner_contract_spec.rb new file mode 100644 index 0000000..63e9026 --- /dev/null +++ b/spec/auction_fun_core/contracts/auction_context/post_auction/winner_contract_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +RSpec.describe AuctionFunCore::Contracts::AuctionContext::PostAuction::WinnerContract, type: :contract do + let(:auction) { Factory[:auction, :default_standard, :with_winner] } + let(:winner) { auction.winner } + + describe "#call" do + subject(:contract) { described_class.new.call(attributes) } + + context "when attributes are invalid" do + let(:attributes) { Dry::Core::Constants::EMPTY_HASH } + + it "expect failure with error messages" do + expect(contract.errors[:auction_id]).to include(I18n.t("contracts.errors.key?")) + expect(contract.errors[:winner_id]).to include(I18n.t("contracts.errors.key?")) + end + end + + context "when auction is not found on database" do + let(:attributes) do + { + auction_id: 2_234_231, + winner_id: winner.id + } + end + + it "expect failure with error messages" do + expect(contract.errors[:auction_id]).to include(I18n.t("contracts.errors.custom.not_found")) + end + end + + context "when winner is not found on database" do + let(:attributes) do + { + auction_id: auction.id, + winner_id: 2_234_231 + } + end + + it "expect failure with error messages" do + expect(contract.errors[:winner_id]).to include(I18n.t("contracts.errors.custom.not_found")) + end + end + + context "when the informed winner is different from the one set in the auction" do + let(:real_winner) { Factory[:user] } + let(:fake_winner) { Factory[:user] } + let(:auction) { Factory[:auction, :default_standard, winner_id: real_winner.id] } + let(:attributes) do + { + auction_id: auction.id, + winner_id: fake_winner.id + } + end + + it "expect failure with error messages" do + expect(contract.errors[:winner_id]).to include(I18n.t("wrong", scope: described_class::I18N_SCOPE)) + end + end + + context "when the informed winner is the same as the one set in the auction" do + let(:attributes) do + { + auction_id: auction.id, + winner_id: winner.id + } + end + + it "expect return sucess" do + expect(contract).to be_success + expect(contract.context[:auction]).to be_a(AuctionFunCore::Entities::Auction) + expect(contract.context[:winner]).to be_a(AuctionFunCore::Entities::User) + end + end + end +end diff --git a/spec/auction_fun_core/contracts/auction_context/processor/finish/closed_contract_spec.rb b/spec/auction_fun_core/contracts/auction_context/processor/finish/closed_contract_spec.rb new file mode 100644 index 0000000..eb12149 --- /dev/null +++ b/spec/auction_fun_core/contracts/auction_context/processor/finish/closed_contract_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +RSpec.describe AuctionFunCore::Contracts::AuctionContext::Processor::Finish::ClosedContract, type: :contract do + describe "#call" do + subject(:contract) { described_class.new.call(attributes) } + + context "when attributes are invalid" do + let(:attributes) { Dry::Core::Constants::EMPTY_HASH } + + it "expect failure with error messages" do + expect(contract).to be_failure + expect(contract.errors[:auction_id]).to include(I18n.t("contracts.errors.key?")) + end + end + + context "when auction is not found on database" do + let(:attributes) { {auction_id: rand(10_000..1_000_000)} } + + it "expect failure with error messages" do + expect(contract).to be_failure + expect(contract.errors[:auction_id]).to include(I18n.t("contracts.errors.custom.not_found")) + end + end + + context "when auction kind is not equal to 'closed'" do + let(:auction) { Factory[:auction, :default_running_standard] } + let(:attributes) { {auction_id: auction.id} } + + it "expect failure with error messages" do + expect(contract).to be_failure + expect(contract.errors[:base]).to include( + I18n.t("contracts.errors.custom.auction_context.processor.finish.invalid_kind") + ) + end + end + + context "when auction status is not equal to 'running'" do + let(:auction) { Factory[:auction, :default_paused_standard] } + let(:attributes) { {auction_id: auction.id} } + + it "expect failure with error messages" do + expect(contract).to be_failure + expect(contract.errors[:base]).to include( + I18n.t("contracts.errors.custom.auction_context.processor.finish.invalid_status") + ) + end + end + + context "when attributes are valid" do + let(:auction) { Factory[:auction, :default_running_closed] } + let(:attributes) { {auction_id: auction.id} } + + it "expect return success" do + expect(contract).to be_success + expect(contract.context[:auction]).to be_a(AuctionFunCore::Entities::Auction) + end + end + end +end diff --git a/spec/auction_fun_core/contracts/auction_context/processor/finish/penny_contract_spec.rb b/spec/auction_fun_core/contracts/auction_context/processor/finish/penny_contract_spec.rb new file mode 100644 index 0000000..77b60f3 --- /dev/null +++ b/spec/auction_fun_core/contracts/auction_context/processor/finish/penny_contract_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +RSpec.describe AuctionFunCore::Contracts::AuctionContext::Processor::Finish::PennyContract, type: :contract do + describe "#call" do + subject(:contract) { described_class.new.call(attributes) } + + context "when attributes are invalid" do + let(:attributes) { Dry::Core::Constants::EMPTY_HASH } + + it "expect failure with error messages" do + expect(contract).to be_failure + expect(contract.errors[:auction_id]).to include(I18n.t("contracts.errors.key?")) + end + end + + context "when auction is not found on database" do + let(:attributes) { {auction_id: rand(10_000..1_000_000)} } + + it "expect failure with error messages" do + expect(contract).to be_failure + expect(contract.errors[:auction_id]).to include(I18n.t("contracts.errors.custom.not_found")) + end + end + + context "when auction kind is not equal to 'penny'" do + let(:auction) { Factory[:auction, :default_running_standard] } + let(:attributes) { {auction_id: auction.id} } + + it "expect failure with error messages" do + expect(contract).to be_failure + expect(contract.errors[:base]).to include( + I18n.t("contracts.errors.custom.auction_context.processor.finish.invalid_kind") + ) + end + end + + context "when auction status is not equal to 'running'" do + let(:auction) { Factory[:auction, :default_paused_standard] } + let(:attributes) { {auction_id: auction.id} } + + it "expect failure with error messages" do + expect(contract).to be_failure + expect(contract.errors[:base]).to include( + I18n.t("contracts.errors.custom.auction_context.processor.finish.invalid_status") + ) + end + end + + context "when attributes are valid" do + let(:auction) { Factory[:auction, :default_running_penny] } + let(:attributes) { {auction_id: auction.id} } + + it "expect return success" do + expect(contract).to be_success + expect(contract.context[:auction]).to be_a(AuctionFunCore::Entities::Auction) + end + end + end +end diff --git a/spec/auction_fun_core/contracts/auction_context/processor/finish/standard_contract_spec.rb b/spec/auction_fun_core/contracts/auction_context/processor/finish/standard_contract_spec.rb new file mode 100644 index 0000000..2752659 --- /dev/null +++ b/spec/auction_fun_core/contracts/auction_context/processor/finish/standard_contract_spec.rb @@ -0,0 +1,59 @@ +# frozen_string_literal: true + +RSpec.describe AuctionFunCore::Contracts::AuctionContext::Processor::Finish::StandardContract, type: :contract do + describe "#call" do + subject(:contract) { described_class.new.call(attributes) } + + context "when attributes are invalid" do + let(:attributes) { Dry::Core::Constants::EMPTY_HASH } + + it "expect failure with error messages" do + expect(contract).to be_failure + expect(contract.errors[:auction_id]).to include(I18n.t("contracts.errors.key?")) + end + end + + context "when auction is not found on database" do + let(:attributes) { {auction_id: rand(10_000..1_000_000)} } + + it "expect failure with error messages" do + expect(contract).to be_failure + expect(contract.errors[:auction_id]).to include(I18n.t("contracts.errors.custom.not_found")) + end + end + + context "when auction kind is not equal to 'standard'" do + let(:auction) { Factory[:auction, :default_running_penny] } + let(:attributes) { {auction_id: auction.id} } + + it "expect failure with error messages" do + expect(contract).to be_failure + expect(contract.errors[:base]).to include( + I18n.t("contracts.errors.custom.auction_context.processor.finish.invalid_kind") + ) + end + end + + context "when auction status is not equal to 'running'" do + let(:auction) { Factory[:auction, :default_paused_standard] } + let(:attributes) { {auction_id: auction.id} } + + it "expect failure with error messages" do + expect(contract).to be_failure + expect(contract.errors[:base]).to include( + I18n.t("contracts.errors.custom.auction_context.processor.finish.invalid_status") + ) + end + end + + context "when attributes are valid" do + let(:auction) { Factory[:auction, :default_running_standard] } + let(:attributes) { {auction_id: auction.id} } + + it "expect return success" do + expect(contract).to be_success + expect(contract.context[:auction]).to be_a(AuctionFunCore::Entities::Auction) + end + end + end +end diff --git a/spec/auction_fun_core/contracts/auction_context/processor/finish_contract_spec.rb b/spec/auction_fun_core/contracts/auction_context/processor/finish_contract_spec.rb deleted file mode 100644 index 4020923..0000000 --- a/spec/auction_fun_core/contracts/auction_context/processor/finish_contract_spec.rb +++ /dev/null @@ -1,27 +0,0 @@ -# frozen_string_literal: true - -RSpec.describe AuctionFunCore::Contracts::AuctionContext::Processor::FinishContract, type: :contract do - let(:auction) { Factory[:auction, :default_running_standard] } - - describe "#call" do - subject(:contract) { described_class.new.call(attributes) } - - context "when attributes are invalid" do - let(:attributes) { Dry::Core::Constants::EMPTY_HASH } - - it "expect failure with error messages" do - expect(contract).to be_failure - expect(contract.errors[:auction_id]).to include(I18n.t("contracts.errors.key?")) - end - end - - context "when attributes are valid" do - let(:attributes) { {auction_id: auction.id} } - - it "expect return success" do - expect(contract).to be_success - expect(contract.context[:auction]).to be_a(AuctionFunCore::Entities::Auction) - end - end - end -end diff --git a/spec/auction_fun_core/entities/auction_spec.rb b/spec/auction_fun_core/entities/auction_spec.rb index 1e2f141..f8199b9 100644 --- a/spec/auction_fun_core/entities/auction_spec.rb +++ b/spec/auction_fun_core/entities/auction_spec.rb @@ -18,4 +18,22 @@ expect(auction.minimal_bid).to be_a_instance_of(Money) end end + + describe "#winner?" do + context "when there is an associated FK" do + subject(:auction) { Factory.structs[:auction, :with_winner, winner_id: 1] } + + it "expects to return true when it has a winning user associated." do + expect(auction.winner?).to be_truthy + end + end + + context "when there is no associated FK" do + subject(:auction) { Factory.structs[:auction] } + + it "expect return a user object" do + expect(auction.winner?).to be_falsey + end + end + end end diff --git a/spec/auction_fun_core/operations/auction_context/create_operation_spec.rb b/spec/auction_fun_core/operations/auction_context/create_operation_spec.rb index 7685794..b077f87 100644 --- a/spec/auction_fun_core/operations/auction_context/create_operation_spec.rb +++ b/spec/auction_fun_core/operations/auction_context/create_operation_spec.rb @@ -80,18 +80,15 @@ .to receive(:perform_at) end - it "expect return success" do - expect(operation).to be_success - expect(operation.failure).to be_nil - end - - it "expect create auction on database with correct status and dispatch event and processes" do + it "expect return success creating auction on database with correct status and dispatch event and processes" do expect { operation }.to change(auction_repository, :count).from(0).to(1) + expect(operation).to be_success expect(operation.success.status).to eq("scheduled") expect(AuctionFunCore::Application[:event]).to have_received(:publish).once expect(AuctionFunCore::Workers::Operations::AuctionContext::Processor::StartOperationJob) - .to have_received(:perform_at).once + .to have_received(:perform_at) + .once end end end diff --git a/spec/auction_fun_core/operations/auction_context/post_auction/participant_operation_spec.rb b/spec/auction_fun_core/operations/auction_context/post_auction/participant_operation_spec.rb new file mode 100644 index 0000000..77679bc --- /dev/null +++ b/spec/auction_fun_core/operations/auction_context/post_auction/participant_operation_spec.rb @@ -0,0 +1,88 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AuctionFunCore::Operations::AuctionContext::PostAuction::ParticipantOperation, type: :operation do + let(:auction_repository) { AuctionFunCore::Repos::AuctionContext::AuctionRepository.new } + let(:participant) { Factory[:user] } + let(:winner) { Factory[:user] } + let(:auction) { Factory[:auction, :default_finished_standard, winner_id: winner.id] } + + describe ".call(auction_id, &block)" do + let(:operation) { described_class } + + context "when block is given" do + context "when operation happens with success" do + let(:attributes) { {auction_id: auction.id, participant_id: participant.id} } + + before do + Factory[:bid, user_id: winner.id, auction_id: auction.id, value_cents: auction.minimal_bid_cents * 2] + Factory[:bid, user_id: participant.id, auction_id: auction.id, value_cents: auction.minimal_bid_cents] + end + + it "expect result success matching block" do + matched_success = nil + matched_failure = nil + + operation.call(attributes) do |o| + o.success { |v| matched_success = v } + o.failure { |f| matched_failure = f } + end + + expect(matched_success).to include(participant) + expect(matched_failure).to be_nil + end + end + + context "when operation happens with failure" do + let(:attributes) { Dry::Core::Constants::EMPTY_HASH } + + it "expect result matching block" do + matched_success = nil + matched_failure = nil + + operation.call(attributes) do |o| + o.success { |v| matched_success = v } + o.failure { |f| matched_failure = f } + end + + expect(matched_success).to be_nil + expect(matched_failure[:auction_id]).to include(I18n.t("contracts.errors.key?")) + expect(matched_failure[:participant_id]).to include(I18n.t("contracts.errors.key?")) + end + end + end + end + + describe "#call" do + subject(:operation) { described_class.new.call(attributes) } + + context "when contract is invalid" do + let(:attributes) { Dry::Core::Constants::EMPTY_HASH } + + it "expect return failure with error messages" do + expect(operation.failure[:auction_id]).to include(I18n.t("contracts.errors.key?")) + expect(operation.failure[:participant_id]).to include(I18n.t("contracts.errors.key?")) + end + end + + context "when contract is valid" do + let(:attributes) { {auction_id: auction.id, participant_id: participant.id} } + + before do + Factory[:bid, user_id: winner.id, auction_id: auction.id, value_cents: auction.minimal_bid_cents * 2] + Factory[:bid, user_id: participant.id, auction_id: auction.id, value_cents: auction.minimal_bid_cents] + end + + it "expect send winning email with auction statistics and payment instructions" do + allow(AuctionFunCore::Workers::Services::Mail::AuctionContext::PostAuction::ParticipantMailerJob) + .to receive(:perform_async).with(auction.id, participant.id) + + operation + + expect(AuctionFunCore::Workers::Services::Mail::AuctionContext::PostAuction::ParticipantMailerJob) + .to have_received(:perform_async).with(auction.id, participant.id).once + end + end + end +end diff --git a/spec/auction_fun_core/operations/auction_context/post_auction/winner_operation_spec.rb b/spec/auction_fun_core/operations/auction_context/post_auction/winner_operation_spec.rb new file mode 100644 index 0000000..161e6e8 --- /dev/null +++ b/spec/auction_fun_core/operations/auction_context/post_auction/winner_operation_spec.rb @@ -0,0 +1,76 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AuctionFunCore::Operations::AuctionContext::PostAuction::WinnerOperation, type: :operation do + let(:auction_repository) { AuctionFunCore::Repos::AuctionContext::AuctionRepository.new } + let(:winner) { Factory[:user] } + let(:auction) { Factory[:auction, :default_finished_standard, winner_id: winner.id] } + + describe ".call(auction_id, &block)" do + let(:operation) { described_class } + + context "when block is given" do + context "when operation happens with success" do + let(:attributes) { {auction_id: auction.id, winner_id: winner.id} } + it "expect result success matching block" do + matched_success = nil + matched_failure = nil + + operation.call(attributes) do |o| + o.success { |v| matched_success = v } + o.failure { |f| matched_failure = f } + end + + expect(matched_success).to include(winner) + expect(matched_failure).to be_nil + end + end + + context "when operation happens with failure" do + let(:attributes) { Dry::Core::Constants::EMPTY_HASH } + + it "expect result matching block" do + matched_success = nil + matched_failure = nil + + operation.call(attributes) do |o| + o.success { |v| matched_success = v } + o.failure { |f| matched_failure = f } + end + + expect(matched_success).to be_nil + expect(matched_failure[:auction_id]).to include(I18n.t("contracts.errors.key?")) + expect(matched_failure[:winner_id]).to include(I18n.t("contracts.errors.key?")) + end + end + end + end + + describe "#call" do + subject(:operation) { described_class.new.call(attributes) } + + context "when contract is invalid" do + let(:attributes) { Dry::Core::Constants::EMPTY_HASH } + + it "expect return failure with error messages" do + expect(operation.failure[:auction_id]).to include(I18n.t("contracts.errors.key?")) + expect(operation.failure[:winner_id]).to include(I18n.t("contracts.errors.key?")) + end + end + + context "when contract is valid" do + let(:attributes) { {auction_id: auction.id, winner_id: winner.id} } + + it "expect send winning email with auction statistics and payment instructions" do + allow(AuctionFunCore::Workers::Services::Mail::AuctionContext::PostAuction::WinnerMailerJob) + .to receive(:perform_async).with(auction.id, winner.id) + + operation + + expect(AuctionFunCore::Workers::Services::Mail::AuctionContext::PostAuction::WinnerMailerJob) + .to have_received(:perform_async).with(auction.id, winner.id).once + end + end + end +end diff --git a/spec/auction_fun_core/operations/auction_context/processor/finish/closed_operation_spec.rb b/spec/auction_fun_core/operations/auction_context/processor/finish/closed_operation_spec.rb new file mode 100644 index 0000000..2209551 --- /dev/null +++ b/spec/auction_fun_core/operations/auction_context/processor/finish/closed_operation_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AuctionFunCore::Operations::AuctionContext::Processor::Finish::ClosedOperation, type: :operation do + let(:auction_repository) { AuctionFunCore::Repos::AuctionContext::AuctionRepository.new } + let(:auction) { Factory[:auction, :default_running_closed, finished_at: Time.current] } + + describe ".call(auction_id, &block)" do + let(:operation) { described_class } + + context "when block is given" do + context "when operation happens with success" do + let(:attributes) { {auction_id: auction.id} } + it "expect result success matching block" do + matched_success = nil + matched_failure = nil + + operation.call(attributes) do |o| + o.success { |v| matched_success = v } + o.failure { |f| matched_failure = f } + end + + expect(matched_success.id).to eq(auction.id) + expect(matched_success.status).to eq("finished") + expect(matched_failure).to be_nil + end + end + + context "when operation happens with failure" do + let(:attributes) { Dry::Core::Constants::EMPTY_HASH } + + it "expect result matching block" do + matched_success = nil + matched_failure = nil + + operation.call(attributes) do |o| + o.success { |v| matched_success = v } + o.failure { |f| matched_failure = f } + end + + expect(matched_success).to be_nil + expect(matched_failure[:auction_id]).to include(I18n.t("contracts.errors.key?")) + end + end + end + end + + describe "#call" do + subject(:operation) { described_class.new.call(attributes) } + + context "when contract is invalid" do + let(:attributes) { Dry::Core::Constants::EMPTY_HASH } + + it "expect not change auction status" do + expect { operation }.not_to change { auction_repository.by_id(auction.id).status } + end + + it "expect return failure with error messages" do + expect(operation.failure[:auction_id]).to include(I18n.t("contracts.errors.key?")) + end + end + + context "when contract is valid" do + let(:attributes) { {auction_id: auction.id} } + + it "expect update status auction record on database" do + expect { operation } + .to change { auction_repository.by_id(auction.id).status } + .from("running") + .to("finished") + end + + it "expect publish the auction finish event" do + allow(AuctionFunCore::Application[:event]).to receive(:publish) + + operation + + expect(AuctionFunCore::Application[:event]).to have_received(:publish).once + end + + context "when the auction has a winning bid with other participations" do + let(:participant) { Factory[:user] } + let(:winner) { Factory[:user] } + + before do + Factory[:bid, auction: auction, user: winner, value_cents: (auction.minimal_bid_cents * 2)] + Factory[:bid, auction: auction, user: participant, value_cents: auction.minimal_bid_cents] + end + + it "expect call winner operation and participant operation jobs" do + allow(AuctionFunCore::Workers::Operations::AuctionContext::PostAuction::WinnerOperationJob) + .to receive(:perform_async).with(auction.id, winner.id) + allow(AuctionFunCore::Workers::Operations::AuctionContext::PostAuction::ParticipantOperationJob) + .to receive(:perform_async).with(auction.id, participant.id) + + operation + + expect(AuctionFunCore::Workers::Operations::AuctionContext::PostAuction::WinnerOperationJob) + .to have_received(:perform_async).with(auction.id, winner.id).once + expect(AuctionFunCore::Workers::Operations::AuctionContext::PostAuction::ParticipantOperationJob) + .to have_received(:perform_async).with(auction.id, participant.id).once + end + end + end + end +end diff --git a/spec/auction_fun_core/operations/auction_context/processor/finish/penny_operation_spec.rb b/spec/auction_fun_core/operations/auction_context/processor/finish/penny_operation_spec.rb new file mode 100644 index 0000000..c858ac8 --- /dev/null +++ b/spec/auction_fun_core/operations/auction_context/processor/finish/penny_operation_spec.rb @@ -0,0 +1,107 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AuctionFunCore::Operations::AuctionContext::Processor::Finish::PennyOperation, type: :operation do + let(:auction_repository) { AuctionFunCore::Repos::AuctionContext::AuctionRepository.new } + let(:auction) { Factory[:auction, :default_running_penny, finished_at: Time.current] } + + describe ".call(auction_id, &block)" do + let(:operation) { described_class } + + context "when block is given" do + context "when operation happens with success" do + let(:attributes) { {auction_id: auction.id} } + it "expect result success matching block" do + matched_success = nil + matched_failure = nil + + operation.call(attributes) do |o| + o.success { |v| matched_success = v } + o.failure { |f| matched_failure = f } + end + + expect(matched_success.id).to eq(auction.id) + expect(matched_success.status).to eq("finished") + expect(matched_failure).to be_nil + end + end + + context "when operation happens with failure" do + let(:attributes) { Dry::Core::Constants::EMPTY_HASH } + + it "expect result matching block" do + matched_success = nil + matched_failure = nil + + operation.call(attributes) do |o| + o.success { |v| matched_success = v } + o.failure { |f| matched_failure = f } + end + + expect(matched_success).to be_nil + expect(matched_failure[:auction_id]).to include(I18n.t("contracts.errors.key?")) + end + end + end + end + + describe "#call" do + subject(:operation) { described_class.new.call(attributes) } + + context "when contract is invalid" do + let(:attributes) { Dry::Core::Constants::EMPTY_HASH } + + it "expect not change auction status" do + expect { operation }.not_to change { auction_repository.by_id(auction.id).status } + end + + it "expect return failure with error messages" do + expect(operation.failure[:auction_id]).to include(I18n.t("contracts.errors.key?")) + end + end + + context "when contract is valid" do + let(:attributes) { {auction_id: auction.id} } + + it "expect update status auction record on database" do + expect { operation } + .to change { auction_repository.by_id(auction.id).status } + .from("running") + .to("finished") + end + + it "expect publish the auction finish event" do + allow(AuctionFunCore::Application[:event]).to receive(:publish) + + operation + + expect(AuctionFunCore::Application[:event]).to have_received(:publish).once + end + + context "when the auction has a winning bid with other participations" do + let(:participant) { Factory[:user] } + let(:winner) { Factory[:user] } + + before do + Factory[:bid, auction: auction, user: participant, value_cents: auction.minimal_bid_cents] + Factory[:bid, auction: auction, user: winner, value_cents: auction.minimal_bid_cents] + end + + it "expect call winner operation and participant operation jobs" do + allow(AuctionFunCore::Workers::Operations::AuctionContext::PostAuction::WinnerOperationJob) + .to receive(:perform_async).with(auction.id, winner.id) + allow(AuctionFunCore::Workers::Operations::AuctionContext::PostAuction::ParticipantOperationJob) + .to receive(:perform_async).with(auction.id, participant.id) + + operation + + expect(AuctionFunCore::Workers::Operations::AuctionContext::PostAuction::WinnerOperationJob) + .to have_received(:perform_async).with(auction.id, winner.id).once + expect(AuctionFunCore::Workers::Operations::AuctionContext::PostAuction::ParticipantOperationJob) + .to have_received(:perform_async).with(auction.id, participant.id).once + end + end + end + end +end diff --git a/spec/auction_fun_core/operations/auction_context/processor/finish_operation_spec.rb b/spec/auction_fun_core/operations/auction_context/processor/finish/standard_operation_spec.rb similarity index 58% rename from spec/auction_fun_core/operations/auction_context/processor/finish_operation_spec.rb rename to spec/auction_fun_core/operations/auction_context/processor/finish/standard_operation_spec.rb index cb3d765..ea9743b 100644 --- a/spec/auction_fun_core/operations/auction_context/processor/finish_operation_spec.rb +++ b/spec/auction_fun_core/operations/auction_context/processor/finish/standard_operation_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" -RSpec.describe AuctionFunCore::Operations::AuctionContext::Processor::FinishOperation, type: :operation do +RSpec.describe AuctionFunCore::Operations::AuctionContext::Processor::Finish::StandardOperation, type: :operation do let(:auction_repository) { AuctionFunCore::Repos::AuctionContext::AuctionRepository.new } let(:auction) { Factory[:auction, :default_running_standard, finished_at: Time.current] } @@ -11,11 +11,12 @@ context "when block is given" do context "when operation happens with success" do + let(:attributes) { {auction_id: auction.id} } it "expect result success matching block" do matched_success = nil matched_failure = nil - operation.call(auction.id) do |o| + operation.call(attributes) do |o| o.success { |v| matched_success = v } o.failure { |f| matched_failure = f } end @@ -27,41 +28,41 @@ end context "when operation happens with failure" do - let(:auction_id) { nil } + let(:attributes) { Dry::Core::Constants::EMPTY_HASH } it "expect result matching block" do matched_success = nil matched_failure = nil - operation.call(auction_id) do |o| + operation.call(attributes) do |o| o.success { |v| matched_success = v } o.failure { |f| matched_failure = f } end expect(matched_success).to be_nil - expect(matched_failure[:auction_id]).to include(I18n.t("contracts.errors.filled?")) + expect(matched_failure[:auction_id]).to include(I18n.t("contracts.errors.key?")) end end end end describe "#call" do - subject(:operation) { described_class.new.call(auction_id) } + subject(:operation) { described_class.new.call(attributes) } context "when contract is invalid" do - let(:auction_id) { nil } + let(:attributes) { Dry::Core::Constants::EMPTY_HASH } it "expect not change auction status" do expect { operation }.not_to change { auction_repository.by_id(auction.id).status } end it "expect return failure with error messages" do - expect(operation.failure[:auction_id]).to include(I18n.t("contracts.errors.filled?")) + expect(operation.failure[:auction_id]).to include(I18n.t("contracts.errors.key?")) end end context "when contract is valid" do - let(:auction_id) { auction.id } + let(:attributes) { {auction_id: auction.id} } it "expect update status auction record on database" do expect { operation } @@ -77,6 +78,29 @@ expect(AuctionFunCore::Application[:event]).to have_received(:publish).once end + + context "when the auction has a winning bid with other participations" do + let(:winner) { Factory[:user] } + + before do + Factory[:bid, auction: auction, value_cents: auction.minimal_bid_cents] + Factory[:bid, auction: auction, user: winner, value_cents: (auction.minimal_bid_cents * 2)] + end + + it "expect call winner operation and participant operation jobs" do + allow(AuctionFunCore::Workers::Operations::AuctionContext::PostAuction::WinnerOperationJob) + .to receive(:perform_async) + allow(AuctionFunCore::Workers::Operations::AuctionContext::PostAuction::ParticipantOperationJob) + .to receive(:perform_async) + + operation + + expect(AuctionFunCore::Workers::Operations::AuctionContext::PostAuction::WinnerOperationJob) + .to have_received(:perform_async).once + expect(AuctionFunCore::Workers::Operations::AuctionContext::PostAuction::ParticipantOperationJob) + .to have_received(:perform_async).once + end + end end end end diff --git a/spec/auction_fun_core/operations/auction_context/processor/start_operation_spec.rb b/spec/auction_fun_core/operations/auction_context/processor/start_operation_spec.rb index 18893f4..c933541 100644 --- a/spec/auction_fun_core/operations/auction_context/processor/start_operation_spec.rb +++ b/spec/auction_fun_core/operations/auction_context/processor/start_operation_spec.rb @@ -76,30 +76,41 @@ } end - it "expect update status auction record on database" do + it "expect update status auction record on database and publish the auction start event" do + allow(AuctionFunCore::Application[:event]).to receive(:publish) + expect { operation }.to change { auction_repository.by_id(auction.id).status }.from("scheduled").to("running") + + expect(AuctionFunCore::Application[:event]).to have_received(:publish).once end - it "expect create a new job to finish the auction" do - allow(AuctionFunCore::Workers::Operations::AuctionContext::Processor::FinishOperationJob).to receive(:perform_at) + context "when auction kind is equal to 'standard'" do + it "expect create a new job to finish the standard auction" do + allow(AuctionFunCore::Workers::Operations::AuctionContext::Processor::Finish::StandardOperationJob).to receive(:perform_at) - operation + operation - expect(AuctionFunCore::Workers::Operations::AuctionContext::Processor::FinishOperationJob) - .to have_received(:perform_at) - .with(auction.finished_at, auction.id) - .once + expect(AuctionFunCore::Workers::Operations::AuctionContext::Processor::Finish::StandardOperationJob) + .to have_received(:perform_at) + .once + end end - it "expect publish the auction start event" do - allow(AuctionFunCore::Application[:event]).to receive(:publish) + context "when auction kind is equal to 'closed'" do + let(:auction) { Factory[:auction, :default_closed, started_at: Time.current] } - operation + it "expect create a new job to finish the closed auction" do + allow(AuctionFunCore::Workers::Operations::AuctionContext::Processor::Finish::ClosedOperationJob).to receive(:perform_at) - expect(AuctionFunCore::Application[:event]).to have_received(:publish).once + operation + + expect(AuctionFunCore::Workers::Operations::AuctionContext::Processor::Finish::ClosedOperationJob) + .to have_received(:perform_at) + .once + end end - context "when auction kind is penny" do + context "when auction kind is equal to 'penny'" do let(:stopwatch) { 45 } let(:old_finished_at) { auction.finished_at.strftime("%Y-%m-%d %H:%M:%S") } let(:new_finished_at) { stopwatch.seconds.from_now.strftime("%Y-%m-%d %H:%M:%S") } @@ -119,6 +130,16 @@ .from(old_finished_at) .to(new_finished_at) end + + it "expect call the finish penny auction job" do + allow(AuctionFunCore::Workers::Operations::AuctionContext::Processor::Finish::PennyOperationJob) + .to receive(:perform_at) + + operation + + expect(AuctionFunCore::Workers::Operations::AuctionContext::Processor::Finish::PennyOperationJob) + .to have_received(:perform_at) + end end end end diff --git a/spec/auction_fun_core/operations/bid_context/create_bid_penny_operation_spec.rb b/spec/auction_fun_core/operations/bid_context/create_bid_penny_operation_spec.rb index 58a67a3..fdb3209 100644 --- a/spec/auction_fun_core/operations/bid_context/create_bid_penny_operation_spec.rb +++ b/spec/auction_fun_core/operations/bid_context/create_bid_penny_operation_spec.rb @@ -3,6 +3,7 @@ require "spec_helper" RSpec.describe AuctionFunCore::Operations::BidContext::CreateBidPennyOperation, type: :operation do + let(:auction_repository) { AuctionFunCore::Repos::AuctionContext::AuctionRepository.new } let(:bid_repository) { AuctionFunCore::Repos::BidContext::BidRepository.new } describe ".call(attributes, &block)" do @@ -81,13 +82,52 @@ expect(operation.failure).to be_blank end - it "expect persist new bid on database and dispatch event" do + it "expect persist new bid on database" do + expect { operation }.to change(bid_repository, :count).from(0).to(1) + end + + it "expect dispatch event" do allow(AuctionFunCore::Application[:event]).to receive(:publish) - expect { operation }.to change(bid_repository, :count).from(0).to(1) + operation expect(AuctionFunCore::Application[:event]).to have_received(:publish).once end + + context "when an auction has not started" do + it "expects not to update the auction's 'finished_at' field" do + expect { operation }.not_to change { auction_repository.by_id(auction.id).finished_at } + end + + it "expect not reschedule the end of the auction" do + allow(AuctionFunCore::Workers::Operations::AuctionContext::Processor::Finish::PennyOperationJob) + .to receive(:perform_at).with(Time, auction.id) + + operation + + expect(AuctionFunCore::Workers::Operations::AuctionContext::Processor::Finish::PennyOperationJob) + .not_to have_received(:perform_at).with(Time, auction.id) + end + end + + context "when an auction was started" do + let(:auction) { Factory[:auction, :default_running_penny] } + let(:user) { Factory[:user, :with_balance] } + + it "expects to update the auction's 'finished_at' field and reschedule the end of the auction" do + expect { operation }.to change { auction_repository.by_id(auction.id).finished_at } + end + + it "expect reschedule the end of the auction" do + allow(AuctionFunCore::Workers::Operations::AuctionContext::Processor::Finish::PennyOperationJob) + .to receive(:perform_at).with(Time, auction.id) + + operation + + expect(AuctionFunCore::Workers::Operations::AuctionContext::Processor::Finish::PennyOperationJob) + .to have_received(:perform_at).with(Time, auction.id).once + end + end end end end diff --git a/spec/auction_fun_core/services/mail/auction_context/post_auction/participant_mailer_spec.rb b/spec/auction_fun_core/services/mail/auction_context/post_auction/participant_mailer_spec.rb new file mode 100644 index 0000000..c3a1a1a --- /dev/null +++ b/spec/auction_fun_core/services/mail/auction_context/post_auction/participant_mailer_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AuctionFunCore::Services::Mail::AuctionContext::PostAuction::ParticipantMailer, type: :mailer do + let(:default_email_system) { AuctionFunCore::Application[:settings].default_email_system } + + describe "#deliver" do + subject(:mailer) { described_class.new(auction, participant, statistics) } + + context "when participant has invalid data" do + let(:participant) { Factory.structs[:user, id: 1, email: nil] } + let(:auction) { Factory.structs[:auction, id: 1] } + let(:statistics) { OpenStruct.new(auction_date: Date.current) } + + it "expect raise error" do + expect { mailer.deliver }.to raise_error( + ArgumentError, "SMTP To address may not be blank: []" + ) + end + end + + context "when participant has valid data" do + let(:winner) { Factory[:user] } + let(:participant) { Factory[:user] } + let(:auction) { Factory[:auction, :default_finished_standard, winner_id: winner.id] } + let(:statistics) do + OpenStruct.new( + auction_total_bids: 2, winner_bid: (auction.minimal_bid_cents * 2), winner_total_bids: 1, + auction_date: Date.current + ) + end + + subject(:mailer) { described_class.new(auction, participant, statistics).deliver } + + it "expect send email with correct data" do + expect(mailer).to be_a_instance_of(Mail::Message) + expect(mail_from(default_email_system)).to be_truthy + expect( + sent_mail_to?( + participant.email, + I18n.t("mail.auction_context.post_auction.participant_mailer.subject", title: auction.title) + ) + ).to be_truthy + end + end + end +end diff --git a/spec/auction_fun_core/services/mail/auction_context/post_auction/winner_mailer_spec.rb b/spec/auction_fun_core/services/mail/auction_context/post_auction/winner_mailer_spec.rb new file mode 100644 index 0000000..8ea7584 --- /dev/null +++ b/spec/auction_fun_core/services/mail/auction_context/post_auction/winner_mailer_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AuctionFunCore::Services::Mail::AuctionContext::PostAuction::WinnerMailer, type: :mailer do + let(:default_email_system) { AuctionFunCore::Application[:settings].default_email_system } + + describe "#deliver" do + subject(:mailer) { described_class.new(auction, winner, statistics) } + + context "when winner has invalid data" do + let(:winner) { Factory.structs[:user, email: nil] } + let(:auction) { Factory.structs[:auction, id: 1] } + let(:statistics) { OpenStruct.new(auction_date: Date.current) } + + it "expect raise error" do + expect { mailer.deliver }.to raise_error( + ArgumentError, "SMTP To address may not be blank: []" + ) + end + end + + context "when winner has valid data" do + let(:winner) { Factory[:user] } + let(:auction) { Factory[:auction, :default_finished_standard, winner_id: winner.id] } + let(:bid) { Factory[:bid, auction_id: auction.id, user_id: winner.id, value_cents: auction.minimal_bid_cents] } + let(:statistics) do + OpenStruct.new( + auction_total_bids: 1, winner_bid: auction.minimal_bid_cents, winner_total_bids: 1, + auction_date: Date.current + ) + end + + subject(:mailer) { described_class.new(auction, winner, statistics).deliver } + + it "expect send email with correct data" do + expect(mailer).to be_a_instance_of(Mail::Message) + expect(mail_from(default_email_system)).to be_truthy + expect( + sent_mail_to?( + winner.email, + I18n.t("mail.auction_context.post_auction.winner_mailer.subject", title: auction.title) + ) + ).to be_truthy + end + end + end +end diff --git a/spec/auction_fun_core/workers/operations/auction_context/post_auction/participation_operation_job_spec.rb b/spec/auction_fun_core/workers/operations/auction_context/post_auction/participation_operation_job_spec.rb new file mode 100644 index 0000000..933e3ad --- /dev/null +++ b/spec/auction_fun_core/workers/operations/auction_context/post_auction/participation_operation_job_spec.rb @@ -0,0 +1,54 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AuctionFunCore::Workers::Operations::AuctionContext::PostAuction::ParticipantOperationJob, type: :worker do + let(:auction_repository) { AuctionFunCore::Repos::AuctionContext::AuctionRepository.new } + let(:participant) { Factory[:user] } + let(:auction) { Factory[:auction, :default_standard, :with_winner] } + + describe "#perform" do + subject(:worker) { described_class.new } + + context "when params are valid" do + before do + Factory[:bid, auction_id: auction.id, user_id: auction.winner_id] + Factory[:bid, auction_id: auction.id, user_id: participant.id] + end + + it "expect execute participant mailer service" do + allow(AuctionFunCore::Workers::Services::Mail::AuctionContext::PostAuction::ParticipantMailerJob).to receive(:perform_async) + + worker.perform(auction.id, participant.id) + + expect(AuctionFunCore::Workers::Services::Mail::AuctionContext::PostAuction::ParticipantMailerJob).to have_received(:perform_async).once + end + end + + context "when an exception occours but retry limit is not reached" do + before do + stub_const("::AuctionFunCore::Workers::ApplicationJob::MAX_RETRIES", 1) + allow(AuctionFunCore::Application[:logger]).to receive(:error) + end + + it "expect rescue/capture exception and reschedule job" do + expect { worker.perform(nil, nil) }.to change(described_class.jobs, :size).from(0).to(1) + + expect(AuctionFunCore::Application[:logger]).to have_received(:error).at_least(:once) + end + end + + context "when the exception reaches the retry limit" do + before do + stub_const("::AuctionFunCore::Workers::ApplicationJob::MAX_RETRIES", 0) + allow(AuctionFunCore::Application[:logger]).to receive(:error) + end + + it "expect raise exception and stop retry" do + expect { worker.perform(nil, nil) }.to raise_error(ROM::TupleCountMismatchError) + + expect(AuctionFunCore::Application[:logger]).to have_received(:error).at_least(:once) + end + end + end +end diff --git a/spec/auction_fun_core/workers/operations/auction_context/post_auction/winner_operation_job_spec.rb b/spec/auction_fun_core/workers/operations/auction_context/post_auction/winner_operation_job_spec.rb new file mode 100644 index 0000000..6f2f893 --- /dev/null +++ b/spec/auction_fun_core/workers/operations/auction_context/post_auction/winner_operation_job_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AuctionFunCore::Workers::Operations::AuctionContext::PostAuction::WinnerOperationJob, type: :worker do + let(:auction_repository) { AuctionFunCore::Repos::AuctionContext::AuctionRepository.new } + let(:auction) { Factory[:auction, :default_standard, :with_winner] } + + describe "#perform" do + subject(:worker) { described_class.new } + + context "when params are valid" do + it "expect execute winner mailer service" do + allow(AuctionFunCore::Workers::Services::Mail::AuctionContext::PostAuction::WinnerMailerJob).to receive(:perform_async) + + worker.perform(auction.id, auction.winner_id) + + expect(AuctionFunCore::Workers::Services::Mail::AuctionContext::PostAuction::WinnerMailerJob).to have_received(:perform_async).once + end + end + + context "when an exception occours but retry limit is not reached" do + before do + stub_const("::AuctionFunCore::Workers::ApplicationJob::MAX_RETRIES", 1) + allow(AuctionFunCore::Application[:logger]).to receive(:error) + end + + it "expect rescue/capture exception and reschedule job" do + expect { worker.perform(nil, nil) }.to change(described_class.jobs, :size).from(0).to(1) + + expect(AuctionFunCore::Application[:logger]).to have_received(:error).at_least(:once) + end + end + + context "when the exception reaches the retry limit" do + before do + stub_const("::AuctionFunCore::Workers::ApplicationJob::MAX_RETRIES", 0) + allow(AuctionFunCore::Application[:logger]).to receive(:error) + end + + it "expect raise exception and stop retry" do + expect { worker.perform(nil, nil) }.to raise_error(ROM::TupleCountMismatchError) + + expect(AuctionFunCore::Application[:logger]).to have_received(:error).at_least(:once) + end + end + end +end diff --git a/spec/auction_fun_core/workers/operations/auction_context/processor/finish/closed_operation_job_spec.rb b/spec/auction_fun_core/workers/operations/auction_context/processor/finish/closed_operation_job_spec.rb new file mode 100644 index 0000000..fd53f0a --- /dev/null +++ b/spec/auction_fun_core/workers/operations/auction_context/processor/finish/closed_operation_job_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AuctionFunCore::Workers::Operations::AuctionContext::Processor::Finish::ClosedOperationJob, type: :worker do + let(:auction_repository) { AuctionFunCore::Repos::AuctionContext::AuctionRepository.new } + let(:auction) { Factory[:auction, :default_running_closed] } + + describe "#perform" do + subject(:worker) { described_class.new } + + context "when params are valid" do + it "expect execute auction start operation" do + expect { worker.perform(auction.id) } + .to change { auction_repository.by_id(auction.id).status } + .from("running") + .to("finished") + end + end + + context "when an exception occours but retry limit is not reached" do + before do + stub_const("::AuctionFunCore::Workers::ApplicationJob::MAX_RETRIES", 1) + allow(AuctionFunCore::Application[:logger]).to receive(:error) + end + + it "expect rescue/capture exception and reschedule job" do + expect { worker.perform(nil) }.to change(described_class.jobs, :size).from(0).to(1) + + expect(AuctionFunCore::Application[:logger]).to have_received(:error).at_least(:once) + end + end + + context "when the exception reaches the retry limit" do + before do + stub_const("::AuctionFunCore::Workers::ApplicationJob::MAX_RETRIES", 0) + allow(AuctionFunCore::Application[:logger]).to receive(:error) + end + + it "expect raise exception and stop retry" do + expect { worker.perform(nil) }.to raise_error(ROM::TupleCountMismatchError) + + expect(AuctionFunCore::Application[:logger]).to have_received(:error).at_least(:once) + end + end + end +end diff --git a/spec/auction_fun_core/workers/operations/auction_context/processor/finish/penny_operation_job_spec.rb b/spec/auction_fun_core/workers/operations/auction_context/processor/finish/penny_operation_job_spec.rb new file mode 100644 index 0000000..aa6e2f6 --- /dev/null +++ b/spec/auction_fun_core/workers/operations/auction_context/processor/finish/penny_operation_job_spec.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AuctionFunCore::Workers::Operations::AuctionContext::Processor::Finish::PennyOperationJob, type: :worker do + let(:auction_repository) { AuctionFunCore::Repos::AuctionContext::AuctionRepository.new } + let(:auction) { Factory[:auction, :default_running_penny] } + + describe "#perform" do + subject(:worker) { described_class.new } + + context "when params are valid" do + it "expect execute auction start operation" do + expect { worker.perform(auction.id) } + .to change { auction_repository.by_id(auction.id).status } + .from("running") + .to("finished") + end + end + + context "when an exception occours but retry limit is not reached" do + before do + stub_const("::AuctionFunCore::Workers::ApplicationJob::MAX_RETRIES", 1) + allow(AuctionFunCore::Application[:logger]).to receive(:error) + end + + it "expect rescue/capture exception and reschedule job" do + expect { worker.perform(nil) }.to change(described_class.jobs, :size).from(0).to(1) + + expect(AuctionFunCore::Application[:logger]).to have_received(:error).at_least(:once) + end + end + + context "when the exception reaches the retry limit" do + before do + stub_const("::AuctionFunCore::Workers::ApplicationJob::MAX_RETRIES", 0) + allow(AuctionFunCore::Application[:logger]).to receive(:error) + end + + it "expect raise exception and stop retry" do + expect { worker.perform(nil) }.to raise_error(ROM::TupleCountMismatchError) + + expect(AuctionFunCore::Application[:logger]).to have_received(:error).at_least(:once) + end + end + end +end diff --git a/spec/auction_fun_core/workers/operations/auction_context/processor/finish_operation_job_spec.rb b/spec/auction_fun_core/workers/operations/auction_context/processor/finish/standard_operation_job_spec.rb similarity index 96% rename from spec/auction_fun_core/workers/operations/auction_context/processor/finish_operation_job_spec.rb rename to spec/auction_fun_core/workers/operations/auction_context/processor/finish/standard_operation_job_spec.rb index 84ad179..9a4d5ff 100644 --- a/spec/auction_fun_core/workers/operations/auction_context/processor/finish_operation_job_spec.rb +++ b/spec/auction_fun_core/workers/operations/auction_context/processor/finish/standard_operation_job_spec.rb @@ -2,7 +2,7 @@ require "spec_helper" -RSpec.describe AuctionFunCore::Workers::Operations::AuctionContext::Processor::FinishOperationJob, type: :worker do +RSpec.describe AuctionFunCore::Workers::Operations::AuctionContext::Processor::Finish::StandardOperationJob, type: :worker do let(:auction_repository) { AuctionFunCore::Repos::AuctionContext::AuctionRepository.new } let(:auction) { Factory[:auction, :default_running_standard] } diff --git a/spec/auction_fun_core/workers/services/mail/auction_context/post_auction/participant_mailer_job_spec.rb b/spec/auction_fun_core/workers/services/mail/auction_context/post_auction/participant_mailer_job_spec.rb new file mode 100644 index 0000000..1e8937f --- /dev/null +++ b/spec/auction_fun_core/workers/services/mail/auction_context/post_auction/participant_mailer_job_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AuctionFunCore::Workers::Services::Mail::AuctionContext::PostAuction::ParticipantMailerJob, type: :worker do + let(:auction_repository) { AuctionFunCore::Repos::AuctionContext::AuctionRepository.new } + let(:user_repository) { AuctionFunCore::Repos::UserContext::UserRepository.new } + let(:relation) { AuctionFunCore::Application[:container].relations[:auctions] } + let(:participant) { Factory[:user] } + let(:auction) { Factory[:auction, :default_finished_standard, :with_winner] } + let(:statistics) { ROM::OpenStruct.new(id: auction.id, auction_total_bids: 0, winner_bid: nil, winner_total_bids: 0) } + let(:participant_mailer) { AuctionFunCore::Services::Mail::AuctionContext::PostAuction::ParticipantMailer } + let(:mailer) { participant_mailer.new(auction, participant, statistics) } + + describe "#perform" do + subject(:worker) { described_class.new } + + context "when attributes are valid" do + before do + allow(AuctionFunCore::Repos::AuctionContext::AuctionRepository).to receive(:new).and_return(auction_repository) + allow(AuctionFunCore::Repos::UserContext::UserRepository).to receive(:new).and_return(user_repository) + allow(auction_repository).to receive(:by_id!).with(auction.id).and_return(auction) + allow(user_repository).to receive(:by_id!).with(participant.id).and_return(participant) + allow(relation).to receive_message_chain("load_participant_statistics.call.first").and_return(statistics) + allow(participant_mailer).to receive(:new).with(auction, participant, statistics).and_return(mailer) + allow(mailer).to receive(:deliver).and_return(true) + end + + it "expect trigger registration mailer service" do + worker.perform(auction.id, participant.id) + + expect(mailer).to have_received(:deliver).once + end + end + + context "when an exception occours but retry limit is not reached" do + before do + stub_const("::AuctionFunCore::Workers::ApplicationJob::MAX_RETRIES", 1) + allow(AuctionFunCore::Application[:logger]).to receive(:error) + end + + it "expect rescue/capture exception and reschedule job" do + expect { worker.perform(nil, nil) }.to change(described_class.jobs, :size).from(0).to(1) + + expect(AuctionFunCore::Application[:logger]).to have_received(:error).at_least(:once) + end + end + + context "when the exception reaches the retry limit" do + before do + stub_const("::AuctionFunCore::Workers::ApplicationJob::MAX_RETRIES", 0) + allow(AuctionFunCore::Application[:logger]).to receive(:error) + end + + it "expect raise exception and stop retry" do + expect { worker.perform(nil, nil) }.to raise_error(ROM::TupleCountMismatchError) + + expect(AuctionFunCore::Application[:logger]).to have_received(:error).at_least(:once) + end + end + end +end diff --git a/spec/auction_fun_core/workers/services/mail/auction_context/post_auction/winner_mailer_job_spec.rb b/spec/auction_fun_core/workers/services/mail/auction_context/post_auction/winner_mailer_job_spec.rb new file mode 100644 index 0000000..eb8e73a --- /dev/null +++ b/spec/auction_fun_core/workers/services/mail/auction_context/post_auction/winner_mailer_job_spec.rb @@ -0,0 +1,62 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AuctionFunCore::Workers::Services::Mail::AuctionContext::PostAuction::WinnerMailerJob, type: :worker do + let(:auction_repository) { AuctionFunCore::Repos::AuctionContext::AuctionRepository.new } + let(:user_repository) { AuctionFunCore::Repos::UserContext::UserRepository.new } + let(:relation) { AuctionFunCore::Application[:container].relations[:auctions] } + let(:winner) { Factory[:user] } + let(:auction) { Factory[:auction, :default_finished_standard, :with_winner] } + let(:statistics) { ROM::OpenStruct.new(id: auction.id, auction_total_bids: 1, winner_bid: auction.minimal_bid_cents, winner_total_bids: 1) } + let(:winner_mailer) { AuctionFunCore::Services::Mail::AuctionContext::PostAuction::WinnerMailer } + let(:mailer) { winner_mailer.new(auction, winner, statistics) } + + describe "#perform" do + subject(:worker) { described_class.new } + + context "when attributes are valid" do + before do + allow(AuctionFunCore::Repos::AuctionContext::AuctionRepository).to receive(:new).and_return(auction_repository) + allow(AuctionFunCore::Repos::UserContext::UserRepository).to receive(:new).and_return(user_repository) + allow(auction_repository).to receive(:by_id!).with(auction.id).and_return(auction) + allow(user_repository).to receive(:by_id!).with(winner.id).and_return(winner) + allow(relation).to receive_message_chain("load_winner_statistics.call.first").and_return(statistics) + allow(winner_mailer).to receive(:new).with(auction, winner, statistics).and_return(mailer) + allow(mailer).to receive(:deliver).and_return(true) + end + + it "expect trigger registration mailer service" do + worker.perform(auction.id, winner.id) + + expect(mailer).to have_received(:deliver).once + end + end + + context "when an exception occours but retry limit is not reached" do + before do + stub_const("::AuctionFunCore::Workers::ApplicationJob::MAX_RETRIES", 1) + allow(AuctionFunCore::Application[:logger]).to receive(:error) + end + + it "expect rescue/capture exception and reschedule job" do + expect { worker.perform(nil, nil) }.to change(described_class.jobs, :size).from(0).to(1) + + expect(AuctionFunCore::Application[:logger]).to have_received(:error).at_least(:once) + end + end + + context "when the exception reaches the retry limit" do + before do + stub_const("::AuctionFunCore::Workers::ApplicationJob::MAX_RETRIES", 0) + allow(AuctionFunCore::Application[:logger]).to receive(:error) + end + + it "expect raise exception and stop retry" do + expect { worker.perform(nil, nil) }.to raise_error(ROM::TupleCountMismatchError) + + expect(AuctionFunCore::Application[:logger]).to have_received(:error).at_least(:once) + end + end + end +end diff --git a/spec/auction_fun_core_spec.rb b/spec/auction_fun_core_spec.rb index 7213020..db22df5 100644 --- a/spec/auction_fun_core_spec.rb +++ b/spec/auction_fun_core_spec.rb @@ -2,6 +2,6 @@ RSpec.describe AuctionFunCore do it "has a version number" do - expect(AuctionFunCore::VERSION).to eq("0.8.5") + expect(AuctionFunCore::VERSION).to eq("0.8.6") end end diff --git a/spec/support/factories/auctions.rb b/spec/support/factories/auctions.rb index a109494..e136d13 100644 --- a/spec/support/factories/auctions.rb +++ b/spec/support/factories/auctions.rb @@ -12,6 +12,14 @@ f.trait :with_minimal_bid do |t| end + f.trait :with_winner do |t| + f.association(:winner) + end + + f.trait :with_participants do |t| + f.association(:winner) + end + f.trait :with_kind_standard do |t| t.kind { "standard" } end @@ -101,6 +109,7 @@ t.stopwatch { AuctionFunCore::Business::Configuration::AUCTION_STOPWATCH_MIN_VALUE } t.started_at { 1.hour.from_now } + t.finished_at { 1.hour.from_now + AuctionFunCore::Business::Configuration::AUCTION_STOPWATCH_MIN_VALUE } end f.trait :default_running_penny do |t| diff --git a/system/providers/background_job.rb b/system/providers/background_job.rb index 229839e..be4b104 100644 --- a/system/providers/background_job.rb +++ b/system/providers/background_job.rb @@ -3,13 +3,32 @@ AuctionFunCore::Application.register_provider(:background_job) do prepare do require "sidekiq" + require "sidekiq-unique-jobs" end start do Sidekiq.configure_server do |config| + config.redis = {url: target[:settings].redis_url} config.logger = target[:settings].logger config.average_scheduled_poll_interval = 3 + + config.client_middleware do |chain| + chain.add SidekiqUniqueJobs::Middleware::Client + end + + config.server_middleware do |chain| + chain.add SidekiqUniqueJobs::Middleware::Server + end + + SidekiqUniqueJobs::Server.configure(config) + end + + Sidekiq.configure_client do |config| config.redis = {url: target[:settings].redis_url} + + config.client_middleware do |chain| + chain.add SidekiqUniqueJobs::Middleware::Client + end end end end