diff --git a/Gemfile b/Gemfile index b7e77f5..eb10b43 100644 --- a/Gemfile +++ b/Gemfile @@ -17,3 +17,5 @@ gem "folio", github: "sinfin/folio", branch: "master" # To use a debugger # gem 'byebug', group: [:development, :test] + +gem "rails-i18n", "~> 6" diff --git a/Gemfile.lock b/Gemfile.lock index 065da46..b6acdea 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -431,9 +431,9 @@ GEM nokogiri (>= 1.6) rails-html-sanitizer (1.4.2) loofah (~> 2.3) - rails-i18n (7.0.1) + rails-i18n (6.0.0) i18n (>= 0.7, < 2) - railties (>= 6.0.0, < 8) + railties (>= 6.0.0, < 7) railties (6.1.4.4) actionpack (= 6.1.4.4) activesupport (= 6.1.4.4) @@ -579,6 +579,7 @@ DEPENDENCIES pry-byebug pry-rails puma + rails-i18n (~> 6) rubocop rubocop-minitest rubocop-performance diff --git a/app/controllers/auctify/api/v1/auctions_controller.rb b/app/controllers/auctify/api/v1/auctions_controller.rb index 49b7d6f..2c6a7ec 100644 --- a/app/controllers/auctify/api/v1/auctions_controller.rb +++ b/app/controllers/auctify/api/v1/auctions_controller.rb @@ -33,6 +33,36 @@ def bids end end + def close_manually + with_authorized_account do + if @auction.close_manually(by: current_account, price_check: params[:current_price]) + render_record @auction.reload + else + render_record @auction.reload, status: 400 + end + end + end + + def lock_bidding + with_authorized_account do + if @auction.lock_bidding(by: current_account) + render_record @auction.reload + else + render_record @auction.reload, status: 400 + end + end + end + + def unlock_bidding + with_authorized_account do + if @auction.unlock_bidding(by: current_account) + render_record @auction.reload + else + render_record @auction.reload, status: 400 + end + end + end + private def find_auction @auction = Auctify::Sale::Auction.find(params[:id]) @@ -79,6 +109,14 @@ def overbid_by_limit?(new_bid) winning_bid != new_bid end + + def with_authorized_account(&block) + if current_account + block.call + else + head 403 + end + end end end end diff --git a/app/jobs/auctify/bidding_closer_job.rb b/app/jobs/auctify/bidding_closer_job.rb index 463d7b5..8eb53dc 100644 --- a/app/jobs/auctify/bidding_closer_job.rb +++ b/app/jobs/auctify/bidding_closer_job.rb @@ -12,7 +12,11 @@ def perform(auction_id:) return end - return if Time.current < auction.currently_ends_at + if auction.must_be_closed_manually? + return unless auction.manually_closed_at? + else + return if Time.current < auction.currently_ends_at + end Auctify::Sale::Auction.with_advisory_lock("closing_auction_#{auction_id}") do # can wait unitl other BCJob release lock and than continue! diff --git a/app/jobs/auctify/ensure_auctions_closing_job.rb b/app/jobs/auctify/ensure_auctions_closing_job.rb index d589099..8b2548c 100644 --- a/app/jobs/auctify/ensure_auctions_closing_job.rb +++ b/app/jobs/auctify/ensure_auctions_closing_job.rb @@ -6,7 +6,9 @@ class EnsureAuctionsClosingJob < ApplicationJob def perform auctions = ::Auctify::Sale::Auction.in_sale - .where("currently_ends_at <= ?", Time.current + checking_period_to_future) + .closable_automatically + .where("currently_ends_at <= ?", Time.current + checking_period_to_future) + auctions.each do |auction| if auction.currently_ends_at <= Time.current Auctify::BiddingCloserJob.perform_later(auction_id: auction.id) diff --git a/app/models/auctify/bid.rb b/app/models/auctify/bid.rb index 1ff3345..7eface7 100644 --- a/app/models/auctify/bid.rb +++ b/app/models/auctify/bid.rb @@ -78,7 +78,7 @@ def round_it_to(amount, smallest_amount) # Table name: auctify_bids # # id :bigint(8) not null, primary key -# registration_id :integer not null +# registration_id :bigint(8) not null # price :decimal(12, 2) not null # max_price :decimal(12, 2) # created_at :datetime not null diff --git a/app/models/auctify/bidder_registration.rb b/app/models/auctify/bidder_registration.rb index 24bfbd2..491c01a 100644 --- a/app/models/auctify/bidder_registration.rb +++ b/app/models/auctify/bidder_registration.rb @@ -83,8 +83,8 @@ def auction_is_in_allowed_state # # id :bigint(8) not null, primary key # bidder_type :string not null -# bidder_id :integer not null -# auction_id :integer not null +# bidder_id :bigint(8) not null +# auction_id :bigint(8) not null # aasm_state :string default("pending"), not null # handled_at :datetime # created_at :datetime not null diff --git a/app/models/auctify/sale/auction.rb b/app/models/auctify/sale/auction.rb index 4db6966..108a311 100644 --- a/app/models/auctify/sale/auction.rb +++ b/app/models/auctify/sale/auction.rb @@ -13,6 +13,7 @@ class Auction < Auctify::Sale::Base } attr_accessor :winning_bid + attr_accessor :manually_closed_price_check has_many :bidder_registrations, dependent: :destroy has_many :bids, through: :bidder_registrations, dependent: :restrict_with_error # destroy them manually first @@ -23,11 +24,16 @@ class Auction < Auctify::Sale::Base belongs_to :winner, polymorphic: true, optional: true belongs_to :current_winner, polymorphic: true, optional: true + belongs_to :manually_closed_by, polymorphic: true, optional: true + belongs_to :bidding_locked_by, polymorphic: true, optional: true validates :ends_at, presence: true scope :where_current_winner_is, ->(bidder) { where(current_winner: bidder) } + scope :closable_automatically, -> do + where(must_be_closed_manually: false) + end aasm do state :offered, initial: true, color: "red" @@ -115,6 +121,8 @@ class Auction < Auctify::Sale::Base validate :buyer_vs_bidding_consistence validate :forbidden_changes + validate :validate_manually_closed + validate :validate_bidding_locked after_create :autoregister_bidders after_save :create_jobs @@ -225,7 +233,13 @@ def current_max_price_for(bidder, bids_array: nil) end def open_for_bids? - in_sale? && Time.current <= currently_ends_at + return false if bidding_locked_at? + + if must_be_closed_manually? + !manually_closed_at && in_sale? + else + in_sale? && Time.current <= currently_ends_at + end end def opening_price @@ -249,6 +263,23 @@ def auction_prolonging_limit_in_seconds pack&.auction_prolonging_limit_in_seconds || Auctify.configuration.auction_prolonging_limit_in_seconds end + def close_manually(by:, price_check:) + if manually_closed_at.nil? && update(manually_closed_at: Time.current, manually_closed_by: by, manually_closed_price_check: price_check) + Auctify::BiddingCloserJob.perform_later(auction_id: id) + true + else + false + end + end + + def lock_bidding(by:) + !!update(bidding_locked_at: Time.current, bidding_locked_by: by) + end + + def unlock_bidding(by:) + !!update(bidding_locked_at: nil, bidding_locked_by: nil) + end + private def buyer_vs_bidding_consistence return true if buyer.blank? && sold_price.blank? @@ -364,6 +395,38 @@ def create_jobs(force = false) end end end + + def validate_manually_closed + return unless will_save_change_to_manually_closed_at? + + if manually_closed_at + unless bidding_locked_at? + errors.add(:base, :need_to_lock_bidding_first) + end + + if !manually_closed_price_check || manually_closed_price_check.to_i != current_price + errors.add(:base, :current_price_doesnt_match_closing) + end + + unless must_be_closed_manually? + errors.add(:base, :sales_not_closed_manually) + end + + unless manually_closed_by.is_a?(Folio::Account) + errors.add(:manually_closed_by, :not_allowed) + end + end + end + + def validate_bidding_locked + return unless will_save_change_to_bidding_locked_at? + + if bidding_locked_at? + unless bidding_locked_by.is_a?(Folio::Account) + errors.add(:bidding_locked_by, :not_allowed) + end + end + end end end end @@ -376,16 +439,16 @@ def create_jobs(force = false) # seller_type :string # seller_id :integer # buyer_type :string -# buyer_id :integer -# item_id :integer not null +# buyer_id :bigint(8) +# item_id :bigint(8) not null # created_at :datetime not null # updated_at :datetime not null # type :string default("Auctify::Sale::Base") # aasm_state :string default("offered"), not null -# offered_price :decimal(, ) -# current_price :decimal(, ) -# sold_price :decimal(, ) -# bid_steps_ladder :json +# offered_price :decimal(12, 2) +# current_price :decimal(12, 2) +# sold_price :decimal(12, 2) +# bid_steps_ladder :jsonb # reserve_price :decimal(, ) # pack_id :bigint(8) # ends_at :datetime @@ -404,12 +467,23 @@ def create_jobs(force = false) # current_winner_id :bigint(8) # buyer_commission_in_percent :integer # featured :integer +# manually_closed_at :datetime +# manually_closed_by_type :string +# manually_closed_by_id :bigint(8) +# must_be_closed_manually :boolean default(FALSE) +# bidding_locked_at :datetime +# bidding_locked_by_type :string +# bidding_locked_by_id :bigint(8) # # Indexes # +# index_auctify_sales_on_aasm_state (aasm_state) +# index_auctify_sales_on_bidding_locked_by (bidding_locked_by_type,bidding_locked_by_id) # index_auctify_sales_on_buyer_type_and_buyer_id (buyer_type,buyer_id) # index_auctify_sales_on_currently_ends_at (currently_ends_at) # index_auctify_sales_on_featured (featured) +# index_auctify_sales_on_manually_closed_by (manually_closed_by_type,manually_closed_by_id) +# index_auctify_sales_on_must_be_closed_manually (must_be_closed_manually) # index_auctify_sales_on_pack_id (pack_id) # index_auctify_sales_on_position (position) # index_auctify_sales_on_published (published) diff --git a/app/models/auctify/sale/base.rb b/app/models/auctify/sale/base.rb index 6c8d7c0..045adda 100644 --- a/app/models/auctify/sale/base.rb +++ b/app/models/auctify/sale/base.rb @@ -34,13 +34,50 @@ class Base < ApplicationRecord scope :not_sold, -> { where(sold_price: nil) } scope :ordered, -> { order(currently_ends_at: :asc, id: :asc) } - # need auction scopes here because of has_many :sales, class_name: "Auctify::Sale::Base" scope :auctions_open_for_bids, -> do - where(aasm_state: "in_sale").where("currently_ends_at > ?", Time.current) + sql = <<~SQL + ( + auctify_sales.must_be_closed_manually = ? + AND + auctify_sales.aasm_state = ? + ) OR ( + auctify_sales.must_be_closed_manually = ? + AND + auctify_sales.aasm_state = ? + AND + auctify_sales.currently_ends_at > ? + ) + SQL + + where(sql, + true, + "in_sale", + false, + "in_sale", + Time.current) end scope :auctions_finished, -> do - where.not(aasm_state: %w[offered accepted refused]).where("currently_ends_at < ?", Time.current) + sql = <<~SQL + ( + auctify_sales.must_be_closed_manually = ? + AND + auctify_sales.aasm_state IN (?) + ) OR ( + auctify_sales.must_be_closed_manually = ? + AND + auctify_sales.aasm_state NOT IN (?) + AND + auctify_sales.currently_ends_at < ? + ) + SQL + + where(sql, + true, + %w[bidding_ended auctioned_successfully auctioned_unsuccessfully sold not_sold], + false, + %w[offered accepted refused], + Time.current) end scope :latest_published_by_item, -> { joins(latest_published_sales_by_item_subtable.join_sources) } @@ -172,16 +209,16 @@ def slug_candidates # seller_type :string # seller_id :integer # buyer_type :string -# buyer_id :integer -# item_id :integer not null +# buyer_id :bigint(8) +# item_id :bigint(8) not null # created_at :datetime not null # updated_at :datetime not null # type :string default("Auctify::Sale::Base") # aasm_state :string default("offered"), not null -# offered_price :decimal(, ) -# current_price :decimal(, ) -# sold_price :decimal(, ) -# bid_steps_ladder :json +# offered_price :decimal(12, 2) +# current_price :decimal(12, 2) +# sold_price :decimal(12, 2) +# bid_steps_ladder :jsonb # reserve_price :decimal(, ) # pack_id :bigint(8) # ends_at :datetime @@ -200,12 +237,23 @@ def slug_candidates # current_winner_id :bigint(8) # buyer_commission_in_percent :integer # featured :integer +# manually_closed_at :datetime +# manually_closed_by_type :string +# manually_closed_by_id :bigint(8) +# must_be_closed_manually :boolean default(FALSE) +# bidding_locked_at :datetime +# bidding_locked_by_type :string +# bidding_locked_by_id :bigint(8) # # Indexes # +# index_auctify_sales_on_aasm_state (aasm_state) +# index_auctify_sales_on_bidding_locked_by (bidding_locked_by_type,bidding_locked_by_id) # index_auctify_sales_on_buyer_type_and_buyer_id (buyer_type,buyer_id) # index_auctify_sales_on_currently_ends_at (currently_ends_at) # index_auctify_sales_on_featured (featured) +# index_auctify_sales_on_manually_closed_by (manually_closed_by_type,manually_closed_by_id) +# index_auctify_sales_on_must_be_closed_manually (must_be_closed_manually) # index_auctify_sales_on_pack_id (pack_id) # index_auctify_sales_on_position (position) # index_auctify_sales_on_published (published) diff --git a/app/models/auctify/sale/retail.rb b/app/models/auctify/sale/retail.rb index 61d802b..6507ada 100644 --- a/app/models/auctify/sale/retail.rb +++ b/app/models/auctify/sale/retail.rb @@ -61,16 +61,16 @@ class Retail < Auctify::Sale::Base # seller_type :string # seller_id :integer # buyer_type :string -# buyer_id :integer -# item_id :integer not null +# buyer_id :bigint(8) +# item_id :bigint(8) not null # created_at :datetime not null # updated_at :datetime not null # type :string default("Auctify::Sale::Base") # aasm_state :string default("offered"), not null -# offered_price :decimal(, ) -# current_price :decimal(, ) -# sold_price :decimal(, ) -# bid_steps_ladder :json +# offered_price :decimal(12, 2) +# current_price :decimal(12, 2) +# sold_price :decimal(12, 2) +# bid_steps_ladder :jsonb # reserve_price :decimal(, ) # pack_id :bigint(8) # ends_at :datetime @@ -89,12 +89,23 @@ class Retail < Auctify::Sale::Base # current_winner_id :bigint(8) # buyer_commission_in_percent :integer # featured :integer +# manually_closed_at :datetime +# manually_closed_by_type :string +# manually_closed_by_id :bigint(8) +# must_be_closed_manually :boolean default(FALSE) +# bidding_locked_at :datetime +# bidding_locked_by_type :string +# bidding_locked_by_id :bigint(8) # # Indexes # +# index_auctify_sales_on_aasm_state (aasm_state) +# index_auctify_sales_on_bidding_locked_by (bidding_locked_by_type,bidding_locked_by_id) # index_auctify_sales_on_buyer_type_and_buyer_id (buyer_type,buyer_id) # index_auctify_sales_on_currently_ends_at (currently_ends_at) # index_auctify_sales_on_featured (featured) +# index_auctify_sales_on_manually_closed_by (manually_closed_by_type,manually_closed_by_id) +# index_auctify_sales_on_must_be_closed_manually (must_be_closed_manually) # index_auctify_sales_on_pack_id (pack_id) # index_auctify_sales_on_position (position) # index_auctify_sales_on_published (published) diff --git a/app/services/auctify/bids_appender.rb b/app/services/auctify/bids_appender.rb index ba09dec..06f5855 100644 --- a/app/services/auctify/bids_appender.rb +++ b/app/services/auctify/bids_appender.rb @@ -157,8 +157,14 @@ def check_same_bidder end def check_auction_state - # comparing time with seconds precision, use `.to_i` - return if auction.in_sale? && bid.created_at.to_i <= auction.currently_ends_at.to_i + if auction.in_sale? && !auction.bidding_locked_at? + if auction.must_be_closed_manually? + return unless auction.manually_closed_at? + elsif bid.created_at.to_i <= auction.currently_ends_at.to_i + # comparing time with seconds precision, use `.to_i` + return + end + end bid.errors.add(:auction, :auction_is_not_accepting_bids_now) end diff --git a/config/locales/auctify.cs.yml b/config/locales/auctify.cs.yml index f77582f..6e30586 100644 --- a/config/locales/auctify.cs.yml +++ b/config/locales/auctify.cs.yml @@ -68,6 +68,9 @@ cs: bids: Příhozy winner: Vítěz dražby current_winner: Aktuální výherce + manually_closed_at: Ruční uzavírání aukcí + manually_closed_by: Uzavíratel + must_be_closed_manually: Uzavírat ručně auctify/bid: price: Výše příhozu max_price: Limit příhozů @@ -107,6 +110,15 @@ cs: there_is_a_buyer_for_not_sold_auction: "Aukci nelze označit za neprodanou, neboť má kupce (%{winner})" sold_price: sold_price_is_not_from_bidding: "Prodejní cena %{sold_price} neodpovídá výherní ceně z aukce %{won_price}" + base: + sales_not_closed_manually: "Aukci nelze uzavřít ručně" + current_price_doesnt_match_closing: "Aukce nebyla uzavřena, protože byla před uzavřením přihozena vyšší částka" + need_to_lock_bidding_first: "U aukce je potřeba nejprve uzamknout příhozy" + manually_closed_by: + not_allowed: "není oprávněn uzavřít tuto aukci" + bidding_locked_by: + not_allowed: "není oprávněn uzamknout přihozy pro tuto aukci" + you_cannot_delete_auction_with_bids: "Není možné mazat aukční položku, která má příhozy" auctify/sales_pack: attributes: diff --git a/config/routes.rb b/config/routes.rb index 6763fdf..64812ca 100644 --- a/config/routes.rb +++ b/config/routes.rb @@ -11,6 +11,9 @@ resources :auctions do member do post :bids + post :close_manually + post :lock_bidding + post :unlock_bidding end end diff --git a/db/migrate/20220304111030_add_manual_closing.rb b/db/migrate/20220304111030_add_manual_closing.rb new file mode 100644 index 0000000..abb3fa5 --- /dev/null +++ b/db/migrate/20220304111030_add_manual_closing.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class AddManualClosing < ActiveRecord::Migration[6.1] + def change + add_column :auctify_sales_packs, :sales_closed_manually, :boolean, default: false + + add_column :auctify_sales, :manually_closed_at, :datetime + add_reference :auctify_sales, :manually_closed_by, polymorphic: true + end +end diff --git a/db/migrate/20220308101021_set_manual_closing_per_sale.rb b/db/migrate/20220308101021_set_manual_closing_per_sale.rb new file mode 100644 index 0000000..db02109 --- /dev/null +++ b/db/migrate/20220308101021_set_manual_closing_per_sale.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +class SetManualClosingPerSale < ActiveRecord::Migration[6.1] + def change + remove_column :auctify_sales_packs, :sales_closed_manually, :boolean, default: false + + add_column :auctify_sales, :must_be_closed_manually, :boolean, default: false + add_index :auctify_sales, :must_be_closed_manually + end +end diff --git a/db/migrate/20220308111330_add_aasm_state_sales_index.rb b/db/migrate/20220308111330_add_aasm_state_sales_index.rb new file mode 100644 index 0000000..c78e2e7 --- /dev/null +++ b/db/migrate/20220308111330_add_aasm_state_sales_index.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +class AddAasmStateSalesIndex < ActiveRecord::Migration[6.1] + def change + add_index :auctify_sales, :aasm_state + end +end diff --git a/db/migrate/20220317064149_add_biddings_locked_to_sales.rb b/db/migrate/20220317064149_add_biddings_locked_to_sales.rb new file mode 100644 index 0000000..1a8cd95 --- /dev/null +++ b/db/migrate/20220317064149_add_biddings_locked_to_sales.rb @@ -0,0 +1,8 @@ +# frozen_string_literal: true + +class AddBiddingsLockedToSales < ActiveRecord::Migration[6.1] + def change + add_column :auctify_sales, :bidding_locked_at, :datetime + add_reference :auctify_sales, :bidding_locked_by, polymorphic: true + end +end diff --git a/test/controllers/auctify/api/v1/auctions_controller_test.rb b/test/controllers/auctify/api/v1/auctions_controller_test.rb index 642e017..c12cfde 100644 --- a/test/controllers/auctify/api/v1/auctions_controller_test.rb +++ b/test/controllers/auctify/api/v1/auctions_controller_test.rb @@ -8,6 +8,7 @@ module V1 class AuctionsControllerTest < ActionDispatch::IntegrationTest include Engine.routes.url_helpers include Auctify::AuctionHelpers + include ActiveJob::TestHelper attr_reader :auction, :adam, :lucifer @@ -199,6 +200,132 @@ class AuctionsControllerTest < ActionDispatch::IntegrationTest "detail" => "Dražitel Není možné přehazovat své příhozy" } end + test "POST /api/auctions/:id/bids, works correctly when time has passed based on must_be_closed_manually" do + auction.update!(currently_ends_at: 1.minute.ago) + + # adam is winning + sign_in lucifer + post api_path_for("/auctions/#{auction.id}/bids"), params: { confirmation: "1", bid: { price: 1_500.0 } } + assert_response 400 + assert_equal "Položka aukce je momentálně uzavřena pro přihazování", response_json["errors"][0]["detail"] + assert_not_equal 1_500, auction.reload.current_price + + auction.update!(must_be_closed_manually: true) + + sign_in lucifer + post api_path_for("/auctions/#{auction.id}/bids"), params: { confirmation: "1", bid: { price: 1_500.0 } } + assert_response :ok + + assert_equal 1_500, auction.reload.current_price + end + + test "POST /api/auctions/:id/close_manually will return an error when not signed in as a Folio::Account" do + perform_enqueued_jobs do + sign_in lucifer + + assert_equal "in_sale", auction.aasm_state + + auction.update!(must_be_closed_manually: false) + + post api_path_for("/auctions/#{auction.id}/close_manually"), params: { current_price: auction.current_price } + assert_response 403 + end + end + + test "POST /api/auctions/:id/close_manually will work when signed in as a Folio::Account" do + perform_enqueued_jobs do + sign_in lucifer + + assert_equal "in_sale", auction.aasm_state + + auction.update!(must_be_closed_manually: true) + + account = Folio::Account.create!(email: "close@manually.com", + first_name: "close", + last_name: "manually", + role: "superuser", + password: "Password123.") + + sign_in(account) + + assert_nil auction.manually_closed_at + + + post api_path_for("/auctions/#{auction.id}/close_manually"), params: { current_price: auction.current_price } + assert_response 400 + + error_messages = response_json["errors"].map { |h| h["detail"] }.sort + expected_messages = [ + "U aukce je potřeba nejprve uzamknout příhozy", + ].sort + assert_equal expected_messages, error_messages + + auction.lock_bidding(by: account) + + post api_path_for("/auctions/#{auction.id}/close_manually"), params: { current_price: auction.current_price } + assert_response 200 + + auction.reload + assert auction.manually_closed_at + assert_equal "bidding_ended", auction.aasm_state + end + end + + test "POST /api/auctions/:id/lock_bidding" do + post api_path_for("/auctions/#{auction.id}/lock_bidding") + assert_response 403 + + account = Folio::Account.create!(email: "close@manually.com", + first_name: "close", + last_name: "manually", + role: "superuser", + password: "Password123.") + + sign_in(account) + + post api_path_for("/auctions/#{auction.id}/lock_bidding") + assert_response 200 + + auction.reload + assert auction.bidding_locked_at? + + # idempotent + post api_path_for("/auctions/#{auction.id}/lock_bidding") + assert_response 200 + + auction.reload + assert auction.bidding_locked_at? + end + + test "POST /api/auctions/:id/unlock_bidding" do + post api_path_for("/auctions/#{auction.id}/unlock_bidding") + assert_response 403 + + account = Folio::Account.create!(email: "close@manually.com", + first_name: "close", + last_name: "manually", + role: "superuser", + password: "Password123.") + + assert auction.lock_bidding(by: account) + assert auction.reload.bidding_locked_at? + + sign_in(account) + + post api_path_for("/auctions/#{auction.id}/unlock_bidding") + assert_response 200 + + auction.reload + assert_not auction.bidding_locked_at? + + # idempotent + post api_path_for("/auctions/#{auction.id}/unlock_bidding") + assert_response 200 + + auction.reload + assert_not auction.bidding_locked_at? + end + private def assert_auction_json_response(success: nil, overbid_by_limit: nil) assert_equal auction.id, response_json["data"]["id"].to_i diff --git a/test/dummy/app/cells/dummy/auctify/auctions/form_cell.rb b/test/dummy/app/cells/dummy/auctify/auctions/form_cell.rb index af32054..0ea63c6 100644 --- a/test/dummy/app/cells/dummy/auctify/auctions/form_cell.rb +++ b/test/dummy/app/cells/dummy/auctify/auctions/form_cell.rb @@ -2,16 +2,10 @@ class Dummy::Auctify::Auctions::FormCell < ApplicationCell def serialized_model - if options[:bid] && options[:bid].errors - { - errors: options[:bid].errors.full_messages.map do |msg| - { - status: 400, - title: "ActiveRecord::RecordInvalid", - detail: msg, - } - end - }.to_json + if options[:bid] && options[:bid].errors.present? + serialize_errors(options[:bid].errors) + elsif model && model.errors.present? + serialize_errors(model.errors) else { success: options[:success] ? 1 : 0, @@ -19,4 +13,16 @@ def serialized_model }.merge(Auctify::Sale::AuctionSerializer.new(model).serializable_hash).to_json end end + + def serialize_errors(errors) + { + errors: errors.full_messages.map do |msg| + { + status: 400, + title: "ActiveRecord::RecordInvalid", + detail: msg, + } + end + }.to_json + end end diff --git a/test/dummy/db/schema.rb b/test/dummy/db/schema.rb index 48b5fd0..3e5834b 100644 --- a/test/dummy/db/schema.rb +++ b/test/dummy/db/schema.rb @@ -10,7 +10,7 @@ # # It's strongly recommended that you check this file into your version control system. -ActiveRecord::Schema.define(version: 2021_12_03_064934) do +ActiveRecord::Schema.define(version: 2022_03_17_064149) do # These are extensions that must be enabled in order to support this database enable_extension "plpgsql" @@ -20,8 +20,8 @@ create_table "auctify_bidder_registrations", force: :cascade do |t| t.string "bidder_type", null: false - t.integer "bidder_id", null: false - t.integer "auction_id", null: false + t.bigint "bidder_id", null: false + t.bigint "auction_id", null: false t.string "aasm_state", default: "pending", null: false t.datetime "handled_at" t.datetime "created_at", precision: 6, null: false @@ -33,7 +33,7 @@ end create_table "auctify_bids", force: :cascade do |t| - t.integer "registration_id", null: false + t.bigint "registration_id", null: false t.decimal "price", precision: 12, scale: 2, null: false t.decimal "max_price", precision: 12, scale: 2 t.datetime "created_at", precision: 6, null: false @@ -48,16 +48,16 @@ t.string "seller_type" t.integer "seller_id" t.string "buyer_type" - t.integer "buyer_id" - t.integer "item_id", null: false + t.bigint "buyer_id" + t.bigint "item_id", null: false t.datetime "created_at", precision: 6, null: false t.datetime "updated_at", precision: 6, null: false t.string "type", default: "Auctify::Sale::Base" t.string "aasm_state", default: "offered", null: false - t.decimal "offered_price" - t.decimal "current_price" - t.decimal "sold_price" - t.json "bid_steps_ladder" + t.decimal "offered_price", precision: 12, scale: 2 + t.decimal "current_price", precision: 12, scale: 2 + t.decimal "sold_price", precision: 12, scale: 2 + t.jsonb "bid_steps_ladder" t.decimal "reserve_price" t.bigint "pack_id" t.datetime "ends_at" @@ -76,9 +76,20 @@ t.bigint "current_winner_id" t.integer "buyer_commission_in_percent" t.integer "featured" + t.datetime "manually_closed_at" + t.string "manually_closed_by_type" + t.bigint "manually_closed_by_id" + t.boolean "must_be_closed_manually", default: false + t.datetime "bidding_locked_at" + t.string "bidding_locked_by_type" + t.bigint "bidding_locked_by_id" + t.index ["aasm_state"], name: "index_auctify_sales_on_aasm_state" + t.index ["bidding_locked_by_type", "bidding_locked_by_id"], name: "index_auctify_sales_on_bidding_locked_by" t.index ["buyer_type", "buyer_id"], name: "index_auctify_sales_on_buyer_type_and_buyer_id" t.index ["currently_ends_at"], name: "index_auctify_sales_on_currently_ends_at" t.index ["featured"], name: "index_auctify_sales_on_featured" + t.index ["manually_closed_by_type", "manually_closed_by_id"], name: "index_auctify_sales_on_manually_closed_by" + t.index ["must_be_closed_manually"], name: "index_auctify_sales_on_must_be_closed_manually" t.index ["pack_id"], name: "index_auctify_sales_on_pack_id" t.index ["position"], name: "index_auctify_sales_on_position" t.index ["published"], name: "index_auctify_sales_on_published" diff --git a/test/jobs/auctify/bidding_closer_job_test.rb b/test/jobs/auctify/bidding_closer_job_test.rb index 9c29977..78de12d 100644 --- a/test/jobs/auctify/bidding_closer_job_test.rb +++ b/test/jobs/auctify/bidding_closer_job_test.rb @@ -14,7 +14,7 @@ class BiddingCloserJobTest < ActiveSupport::TestCase @auction.start_sale! end - test "closes bidding when auction.currently_ends_at passed" do + test "closes bidding when auction.currently_ends_at passed when not closing manually" do Time.stub(:current, auction.currently_ends_at) do assert auction.reload.in_sale? diff --git a/test/jobs/auctify/ensure_auctions_closing_job_test.rb b/test/jobs/auctify/ensure_auctions_closing_job_test.rb index 7b02134..8388fd0 100644 --- a/test/jobs/auctify/ensure_auctions_closing_job_test.rb +++ b/test/jobs/auctify/ensure_auctions_closing_job_test.rb @@ -11,7 +11,8 @@ class EnsureAuctionsClosingJobTest < ActiveSupport::TestCase :now_ending_closed_auction, :auction_ending_in_1_second, :auction_ending_at_the_end_of_time_frame, - :auction_ending_after_time_frame + :auction_ending_after_time_frame, + :pack include ActiveJob::TestHelper @@ -19,6 +20,11 @@ class EnsureAuctionsClosingJobTest < ActiveSupport::TestCase @time_now = Time.current interval = Auctify.configuration.auction_prolonging_limit_in_seconds + @pack = Auctify::SalesPack.create!(title: "EnsureAuctionsClosingJob pack", + start_date: 1.day.ago, + end_date: 1.day.from_now, + published: true) + @old_open_auction = create_open_auction_ending_at(time_now - 1.second) @old_closed_auction = create_open_auction_ending_at(time_now - 1.second) @old_closed_auction.update!(aasm_state: :bidding_ended) @@ -47,6 +53,18 @@ class EnsureAuctionsClosingJobTest < ActiveSupport::TestCase end end + test "skips BiddingCloserJobs for manually-closed future close-to-end auctions" do + assert_enqueued_jobs 0, only: job_class + + pack.sales.update_all(must_be_closed_manually: true) + + Time.stub(:current, time_now) do + job_class.perform_now + end + + assert_enqueued_jobs 0, only: job_class + end + test "creates BiddingCloserJobs for auctions which should be closed now already" do assert_enqueued_jobs 0, only: job_class @@ -109,7 +127,7 @@ def job_class end def create_open_auction_ending_at(time) - a = Auctify::Sale::Auction.create!(ends_at: time, item: Thing.all.sample) + a = Auctify::Sale::Auction.create!(ends_at: time, item: Thing.create!(name: "thing"), pack: pack) a.accept_offer a.start_sale! a diff --git a/test/models/auctify/sale/auction_test.rb b/test/models/auctify/sale/auction_test.rb index 7536200..b092cb9 100644 --- a/test/models/auctify/sale/auction_test.rb +++ b/test/models/auctify/sale/auction_test.rb @@ -189,6 +189,150 @@ class AuctionTest < ActiveSupport::TestCase adam_auctions = Auctify::Sale::Auction.in_sale.where_current_winner_is(adam) assert_equal [auction], adam_auctions.to_a end + + test "closable_automatically" do + auction = auctify_sales(:auction_in_progress) + + assert Auctify::Sale::Auction.closable_automatically.exists?(id: auction.id) + + auction.update!(must_be_closed_manually: true) + + assert_not Auctify::Sale::Auction.closable_automatically.exists?(id: auction.id) + end + + test "close_manually" do + auction = auctify_sales(:auction_in_progress) + adam = users(:adam) + lucifer = users(:lucifer) + + allow_bids_for([lucifer], auction) + + assert auction.bid!(bid_for(lucifer, 2_000)) + + perform_enqueued_jobs do + assert_not auction.close_manually(by: adam, price_check: auction.current_price) + + assert_equal "in_sale", auction.aasm_state + + auction.reload.update!(must_be_closed_manually: true) + + assert_not auction.close_manually(by: adam, price_check: auction.current_price) + + auction.reload + assert_equal "in_sale", auction.aasm_state + + account = Folio::Account.create!(email: "close@manually.com", + first_name: "close", + last_name: "manually", + role: "superuser", + password: "Password123.") + + assert_not auction.close_manually(by: account, price_check: auction.current_price) + + auction.reload + assert_equal "in_sale", auction.aasm_state + + assert auction.lock_bidding(by: account) + auction.reload + + assert auction.close_manually(by: account, price_check: auction.current_price) + + auction.reload + assert_equal "bidding_ended", auction.aasm_state + end + end + + test "close_manually price_check" do + auction = auctify_sales(:auction_in_progress) + lucifer = users(:lucifer) + + account = Folio::Account.create!(email: "close@manually.com", + first_name: "close", + last_name: "manually", + role: "superuser", + password: "Password123.") + + auction.update!(must_be_closed_manually: true) + + allow_bids_for([lucifer], auction) + + assert auction.bid!(bid_for(lucifer, 2_000)) + + perform_enqueued_jobs do + assert_equal "in_sale", auction.aasm_state + + assert_not auction.close_manually(by: account, price_check: 1_500) + assert_includes auction.errors[:base], "Aukce nebyla uzavřena, protože byla před uzavřením přihozena vyšší částka" + assert_includes auction.errors[:base], "U aukce je potřeba nejprve uzamknout příhozy" + auction.reload + assert_equal "in_sale", auction.aasm_state + + assert_not auction.close_manually(by: account, price_check: 2_000) + assert_includes auction.errors[:base], "U aukce je potřeba nejprve uzamknout příhozy" + auction.reload + assert_equal "in_sale", auction.aasm_state + + assert auction.lock_bidding(by: account) + auction.reload + + assert auction.close_manually(by: account, price_check: 2_000) + auction.reload + assert_equal "bidding_ended", auction.aasm_state + end + end + + test "disallow bids when manually closed and job is still pending" do + auction = auctify_sales(:auction_in_progress) + lucifer = users(:lucifer) + adam = users(:adam) + + auction.update!(must_be_closed_manually: true) + + allow_bids_for([lucifer, adam], auction) + + assert auction.bid!(bid_for(lucifer, 2_000)) + + account = Folio::Account.create!(email: "close@manually.com", + first_name: "close", + last_name: "manually", + role: "superuser", + password: "Password123.") + + assert auction.lock_bidding(by: account) + assert auction.close_manually(by: account, price_check: 2_000) + # job didn't run yet + assert_equal "in_sale", auction.reload.aasm_state + # but cannot bid anyways + assert_not auction.bid!(bid_for(adam, 3_000)) + + assert_equal 2_000, auction.reload.current_price + end + + test "open_for_bids? false when bidding_locked_at?" do + auction = auctify_sales(:auction_in_progress) + assert auction.open_for_bids? + + adam = users(:adam) + lucifer = users(:lucifer) + + allow_bids_for([adam, lucifer], auction) + + bid = bid_for(lucifer, 5_000) + assert auction.reload.bid!(bid) + + account = Folio::Account.create!(email: "close@manually.com", + first_name: "close", + last_name: "manually", + role: "superuser", + password: "Password123.") + + assert auction.lock_bidding(by: account) + assert_not auction.open_for_bids? + + bid = bid_for(adam, 6_000) + assert_not auction.reload.bid!(bid) + assert_equal ["Položka aukce je momentálně uzavřena pro přihazování"], bid.errors.full_messages + end end end end diff --git a/test/models/auctify/sale/base_test.rb b/test/models/auctify/sale/base_test.rb index a02ebfb..0f445b0 100644 --- a/test/models/auctify/sale/base_test.rb +++ b/test/models/auctify/sale/base_test.rb @@ -159,6 +159,26 @@ class SaleTest < ActiveSupport::TestCase assert new_sale.invalid? assert_equal ["předmět je již jednou nabízen v rámci Aukce `#{sales_pack.title}`"], new_sale.errors[:item] end + + test "auctions_open_for_bids / auctions_finished" do + sale = auctify_sales(:auction_in_progress) + + sale.update!(must_be_closed_manually: true, currently_ends_at: 1.day.from_now) + assert Auctify::Sale::Base.auctions_open_for_bids.exists?(id: sale.id) + assert_not Auctify::Sale::Base.auctions_finished.exists?(id: sale.id) + + sale.update!(must_be_closed_manually: false, currently_ends_at: 1.day.from_now) + assert Auctify::Sale::Base.auctions_open_for_bids.exists?(id: sale.id) + assert_not Auctify::Sale::Base.auctions_finished.exists?(id: sale.id) + + sale.update!(must_be_closed_manually: false, currently_ends_at: 1.day.ago) + assert_not Auctify::Sale::Base.auctions_open_for_bids.exists?(id: sale.id) + assert Auctify::Sale::Base.auctions_finished.exists?(id: sale.id) + + sale.update!(must_be_closed_manually: true, currently_ends_at: 1.day.ago) + assert Auctify::Sale::Base.auctions_open_for_bids.exists?(id: sale.id) + assert_not Auctify::Sale::Base.auctions_finished.exists?(id: sale.id) + end end end end