diff --git a/CHANGELOG.md b/CHANGELOG.md index 76b85b6..518a41e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,16 @@ ## [Unreleased] +## [0.8.0] - 2024-03-06 + +### Added + +- Bid entity and your context to handle bid creation business rules. +- Internal Processor module in bids to handle specific types. + +### Fixed + +- auction migration name fix. + ## [0.7.0] - 2024-02-29 ### Added diff --git a/Gemfile.lock b/Gemfile.lock index eabb9b9..79b034f 100644 --- a/Gemfile.lock +++ b/Gemfile.lock @@ -1,7 +1,7 @@ PATH remote: . specs: - auction_fun_core (0.7.0) + auction_fun_core (0.8.0) GEM remote: https://rubygems.org/ diff --git a/db/migrate/20240229143000_enable_auctions.rb b/db/migrate/20240229143000_create_auctions.rb similarity index 100% rename from db/migrate/20240229143000_enable_auctions.rb rename to db/migrate/20240229143000_create_auctions.rb diff --git a/db/migrate/20240304144422_create_bids.rb b/db/migrate/20240304144422_create_bids.rb new file mode 100644 index 0000000..a6787c2 --- /dev/null +++ b/db/migrate/20240304144422_create_bids.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +ROM::SQL.migration do + change do + create_table :bids do + primary_key :id + foreign_key :user_id, :users, null: false + foreign_key :auction_id, :auctions, null: false + + column :value_cents, Integer, null: false, default: 0 + column :value_currency, String, null: false, default: "USD" + + column :created_at, DateTime, null: false + column :updated_at, DateTime, null: false + end + + add_index :bids, %i[auction_id user_id], name: "idx_user_auction" + end +end diff --git a/lib/auction_fun_core/commands/bid_context/create_bid.rb b/lib/auction_fun_core/commands/bid_context/create_bid.rb new file mode 100644 index 0000000..be3572c --- /dev/null +++ b/lib/auction_fun_core/commands/bid_context/create_bid.rb @@ -0,0 +1,19 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Commands + module BidContext + ## + # Abstract base class for insert new tuples on bids table. + # @abstract + class CreateBid < ROM::Commands::Create[:sql] + relation :bids + register_as :create + result :one + + use :timestamps + timestamp :created_at, :updated_at + end + end + end +end diff --git a/lib/auction_fun_core/commands/bid_context/delete_bid.rb b/lib/auction_fun_core/commands/bid_context/delete_bid.rb new file mode 100644 index 0000000..09a5776 --- /dev/null +++ b/lib/auction_fun_core/commands/bid_context/delete_bid.rb @@ -0,0 +1,15 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Commands + module BidContext + ## + # Abstract base class for removes tuples in bids table. + # @abstract + class DeleteBid < ROM::Commands::Delete[:sql] + relation :bids + register_as :delete + end + end + end +end diff --git a/lib/auction_fun_core/commands/bid_context/update_bid.rb b/lib/auction_fun_core/commands/bid_context/update_bid.rb new file mode 100644 index 0000000..18f5dc2 --- /dev/null +++ b/lib/auction_fun_core/commands/bid_context/update_bid.rb @@ -0,0 +1,18 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Commands + module BidContext + ## + # Abstract base class for updates all tuples in its bids table with new attributes + # @abstract + class UpdateBid < ROM::Commands::Update[:sql] + relation :bids + register_as :update + + use :timestamps + timestamp :updated_at + end + end + end +end diff --git a/lib/auction_fun_core/contracts/bid_context/create_bid_closed_contract.rb b/lib/auction_fun_core/contracts/bid_context/create_bid_closed_contract.rb new file mode 100644 index 0000000..e66c8e8 --- /dev/null +++ b/lib/auction_fun_core/contracts/bid_context/create_bid_closed_contract.rb @@ -0,0 +1,79 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Contracts + module BidContext + # Contract class to create new bids. + class CreateBidClosedContract < Contracts::ApplicationContract + option :user_repository, default: proc { Repos::UserContext::UserRepository.new } + option :auction_repository, default: proc { Repos::AuctionContext::AuctionRepository.new } + option :bid_repository, default: proc { Repos::BidContext::BidRepository.new } + + # @param [Hash] opts Sets an allowed list of parameters, as well as some initial validations. + params do + required(:auction_id).filled(:integer) + required(:user_id).filled(:integer) + required(:value_cents).filled(:integer) + + # Keys with a blank value are discarded. + before(:value_coercer) do |result| + result.to_h.compact + end + end + + # Validation for auction. + # validate whether the given auction is valid at the database level. + # validate if the auction is open to receive bids + rule(:auction_id) do |context:| + context[:auction] ||= auction_repository.by_id(value) + + if context[:auction] + if context[:auction].kind != "closed" + key.failure(I18n.t("contracts.errors.custom.bids.invalid_kind", kind: "closed")) + end + + unless %w[scheduled running].include?(context[:auction].status) + key.failure( + I18n.t("contracts.errors.custom.bids.invalid_status", status: context[:auction].status) + ) + end + else + key.failure(I18n.t("contracts.errors.custom.not_found")) + end + end + + # Validation for user. + # Validate whether the given user is valid at the database level. + # Validates if user has already placed a bid + rule(:user_id) do |context:| + context[:user] ||= user_repository.by_id(value) + + if context[:user] + if bid_repository.exists?(auction_id: values[:auction_id], user_id: value) + key.failure(I18n.t("contracts.errors.custom.bids.already_bidded")) + end + else + key.failure(I18n.t("contracts.errors.custom.not_found")) + end + end + + # Validation for value bid. + # The bid amount must be greater than or equal to the starting bid. + rule(:value_cents) do |context:| + unless rule_error?(:user_id) + closed_auction_bid_value_is_gteq_initial_bid?(key, value, context[:auction].initial_bid_cents) + end + end + + private + + # Checks if bid amount must be greater than or equal to the starting bid. + def closed_auction_bid_value_is_gteq_initial_bid?(key, value_cents, minimal_bid_cents) + return unless value_cents < minimal_bid_cents + + key.failure(I18n.t("contracts.errors.gteq?", num: Money.new(minimal_bid_cents).to_f)) + end + end + end + end +end diff --git a/lib/auction_fun_core/contracts/bid_context/create_bid_penny_contract.rb b/lib/auction_fun_core/contracts/bid_context/create_bid_penny_contract.rb new file mode 100644 index 0000000..6dd6694 --- /dev/null +++ b/lib/auction_fun_core/contracts/bid_context/create_bid_penny_contract.rb @@ -0,0 +1,69 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Contracts + module BidContext + # Contract class to create new bids. + class CreateBidPennyContract < Contracts::ApplicationContract + option :user_repo, default: proc { Repos::UserContext::UserRepository.new } + option :auction_repo, default: proc { Repos::AuctionContext::AuctionRepository.new } + + # @param [Hash] opts Sets an allowed list of parameters, as well as some initial validations. + params do + required(:auction_id).filled(:integer) + required(:user_id).filled(:integer) + + # Keys with a blank value are discarded. + before(:value_coercer) do |result| + result.to_h.compact + end + end + + # Validation for auction. + # validate whether the given auction is valid at the database level. + # validate if the auction is open to receive bids + rule(:auction_id) do |context:| + context[:auction] ||= auction_repo.by_id(value) + + if context[:auction] + if context[:auction].kind != "penny" + key.failure(I18n.t("contracts.errors.custom.bids.invalid_kind", kind: "penny")) + end + + unless %w[scheduled running].include?(context[:auction].status) + key.failure( + I18n.t("contracts.errors.custom.bids.invalid_status", status: context[:auction].status) + ) + end + else + key.failure(I18n.t("contracts.errors.custom.not_found")) + end + end + + # Validation for user. + # Validate whether the given user is valid at the database level. + # Validates if user has enough balance to bid. + rule(:user_id) do |context:| + context[:user] ||= user_repo.by_id(value) + + if context[:user] + unless rule_error?(:auction_id) + penny_auction_check_user_has_balance?( + key, context[:auction].initial_bid_cents, context[:user].balance_cents + ) + end + else + key.failure(I18n.t("contracts.errors.custom.not_found")) + end + end + + private + + # Checks if user has enough balance to bid. + def penny_auction_check_user_has_balance?(key, auction_bid_cents, balance_cents) + key.failure(I18n.t("contracts.errors.custom.bids.insufficient_balance")) if balance_cents < auction_bid_cents + end + end + end + end +end diff --git a/lib/auction_fun_core/contracts/bid_context/create_bid_standard_contract.rb b/lib/auction_fun_core/contracts/bid_context/create_bid_standard_contract.rb new file mode 100644 index 0000000..c9db5f8 --- /dev/null +++ b/lib/auction_fun_core/contracts/bid_context/create_bid_standard_contract.rb @@ -0,0 +1,68 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Contracts + module BidContext + # Contract class to create new bids. + class CreateBidStandardContract < Contracts::ApplicationContract + option :user_repo, default: proc { Repos::UserContext::UserRepository.new } + option :auction_repo, default: proc { Repos::AuctionContext::AuctionRepository.new } + + # @param [Hash] opts Sets an allowed list of parameters, as well as some initial validations. + params do + required(:auction_id).filled(:integer) + required(:user_id).filled(:integer) + required(:value_cents).filled(:integer) + + # Keys with a blank value are discarded. + before(:value_coercer) do |result| + result.to_h.compact + end + end + + # Validation for auction. + # validate whether the given auction is valid at the database level. + # validate if the auction is open to receive bids + rule(:auction_id) do |context:| + context[:auction] ||= auction_repo.by_id(value) + + if context[:auction] + if context[:auction].kind != "standard" + key.failure(I18n.t("contracts.errors.custom.bids.invalid_kind", kind: "standard")) + end + + unless %w[scheduled running].include?(context[:auction].status) + key.failure( + I18n.t("contracts.errors.custom.bids.invalid_status", status: context[:auction].status) + ) + end + else + key.failure(I18n.t("contracts.errors.custom.not_found")) + end + end + + # Validation for user. + # Validate whether the given user is valid at the database level. + rule(:user_id) do |context:| + context[:user] ||= user_repo.by_id(value) + key.failure(I18n.t("contracts.errors.custom.not_found")) unless context[:user] + end + + # Validation for value bid. + # must be greater than or equal to the auction's minimal bid. + rule(:value_cents) do |context:| + standard_auction_valid_bid?(key, value, context[:auction].minimal_bid_cents) + end + + private + + # Checks if the bid amount is greather than or equal to minimum bid. + def standard_auction_valid_bid?(key, value_cents, minimal_bid_cents) + return if value_cents >= minimal_bid_cents + + key.failure(I18n.t("contracts.errors.gteq?", num: Money.new(minimal_bid_cents).to_f)) + end + end + end + end +end diff --git a/lib/auction_fun_core/entities/bid.rb b/lib/auction_fun_core/entities/bid.rb new file mode 100644 index 0000000..9791e0f --- /dev/null +++ b/lib/auction_fun_core/entities/bid.rb @@ -0,0 +1,10 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Entities + # Bid Relations class. This return simple objects with attribute readers + # to represent data in your bid. + class Bid < ROM::Struct + end + end +end diff --git a/lib/auction_fun_core/events/app.rb b/lib/auction_fun_core/events/app.rb index 9cfa0ae..2008f4a 100644 --- a/lib/auction_fun_core/events/app.rb +++ b/lib/auction_fun_core/events/app.rb @@ -12,6 +12,8 @@ class App register_event("auctions.started") register_event("auctions.finished") + register_event("bids.created") + register_event("staffs.authentication") register_event("staffs.registration") diff --git a/lib/auction_fun_core/events/listener.rb b/lib/auction_fun_core/events/listener.rb index cdc9ac9..4ea4a55 100644 --- a/lib/auction_fun_core/events/listener.rb +++ b/lib/auction_fun_core/events/listener.rb @@ -23,6 +23,12 @@ def on_auctions_finished(auction) logger("Finished auction: #{auction.to_h}") end + # Listener for to *bids.created* event. + # @param event [Integer] Auction ID + def on_bids_created(bid) + logger("Create bid with: #{bid.to_h}") + end + # Listener for to *staffs.authentication* event. # @param attributes [Hash] Authentication attributes # @option staff_id [Integer] Staff ID diff --git a/lib/auction_fun_core/operations/bid_context/create_bid_closed_operation.rb b/lib/auction_fun_core/operations/bid_context/create_bid_closed_operation.rb new file mode 100644 index 0000000..3ac0707 --- /dev/null +++ b/lib/auction_fun_core/operations/bid_context/create_bid_closed_operation.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Operations + module BidContext + ## + # Operation class for create new bids for closed auctions. + # + class CreateBidClosedOperation < AuctionFunCore::Operations::Base + include Import["contracts.bid_context.create_bid_closed_contract"] + include Import["repos.bid_context.bid_repository"] + + # @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 Add custom doc + def call(attributes) + values = yield validate(attributes) + + bid_repository.transaction do |_t| + @bid = yield persist(values) + yield publish_bid_created(@bid) + end + + Success(@bid) + end + + # Calls the bid creation contract class to perform the validation + # of the informed attributes. + # @param attrs [Hash] bid attributes + # @return [Dry::Monads::Result::Success, Dry::Monads::Result::Failure] + def validate(attrs) + contract = create_bid_closed_contract.call(attrs) + + return Failure(contract.errors.to_h) if contract.failure? + + Success(contract.to_h) + 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)) + end + + # Triggers the publication of event *bids.created*. + # @param bid [ROM::Struct::Bid] Bid object + # @return [Dry::Monads::Result::Success] + def publish_bid_created(bid) + Application[:event].publish("bids.created", bid.to_h) + + Success() + end + 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 new file mode 100644 index 0000000..0d3c170 --- /dev/null +++ b/lib/auction_fun_core/operations/bid_context/create_bid_penny_operation.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Operations + 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"] + + # @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 Add custom doc + def call(attributes) + values = yield validate(attributes) + + bid_repository.transaction do |_t| + @bid = yield persist(values) + yield publish_bid_created(@bid) + end + + Success(@bid) + end + + # Calls the bid creation contract class to perform the validation + # of the informed attributes. + # @param attrs [Hash] bid attributes + # @return [Dry::Monads::Result::Success, Dry::Monads::Result::Failure] + def validate(attrs) + contract = create_bid_penny_contract.call(attrs) + + return Failure(contract.errors.to_h) if contract.failure? + + Success(contract.to_h) + 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)) + end + + # Triggers the publication of event *bids.created*. + # @param bid [ROM::Struct::Bid] Bid Object + # @return [Dry::Monads::Result::Success] + def publish_bid_created(bid) + Application[:event].publish("bids.created", bid.to_h) + + Success() + end + end + end + end +end diff --git a/lib/auction_fun_core/operations/bid_context/create_bid_standard_operation.rb b/lib/auction_fun_core/operations/bid_context/create_bid_standard_operation.rb new file mode 100644 index 0000000..185d276 --- /dev/null +++ b/lib/auction_fun_core/operations/bid_context/create_bid_standard_operation.rb @@ -0,0 +1,74 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Operations + module BidContext + ## + # Operation class for create new bids for standard auctions. + # + class CreateBidStandardOperation < AuctionFunCore::Operations::Base + INCREASE = 0.1 + include Import["contracts.bid_context.create_bid_standard_contract"] + include Import["repos.auction_context.auction_repository"] + include Import["repos.bid_context.bid_repository"] + + # @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 Add custom doc + def call(attributes) + values = yield validate(attributes) + + bid_repository.transaction do |_t| + @bid = yield persist(values) + yield calculate_standard_auction_new_minimal_bid_value(@bid.auction_id, @bid.value_cents) + yield publish_bid_created(@bid) + end + + Success(@bid) + end + + # Calls the bid creation contract class to perform the validation + # of the informed attributes. + # @param attrs [Hash] bid attributes + # @return [Dry::Monads::Result::Success, Dry::Monads::Result::Failure] + def validate(attrs) + contract = create_bid_standard_contract.call(attrs) + + return Failure(contract.errors.to_h) if contract.failure? + + Success(contract.to_h) + end + + # Calculates the new minimum next bid amount. + def calculate_standard_auction_new_minimal_bid_value(auction_id, bid_cents) + minimal_bid_cents = (bid_cents + (bid_cents * INCREASE)) + + Success(auction_repository.update(auction_id, minimal_bid_cents: minimal_bid_cents)) + 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)) + end + + # Triggers the publication of event *bids.created*. + # @param bid [ROM::Struct::Bid] Bid Object + # @return [Dry::Monads::Result::Success] + def publish_bid_created(bid) + Application[:event].publish("bids.created", bid.to_h) + + Success() + end + end + end + end +end diff --git a/lib/auction_fun_core/relations/auctions.rb b/lib/auction_fun_core/relations/auctions.rb index 5e4250b..049dabf 100644 --- a/lib/auction_fun_core/relations/auctions.rb +++ b/lib/auction_fun_core/relations/auctions.rb @@ -32,6 +32,7 @@ class Auctions < ROM::Relation[:sql] associations do belongs_to :staff, as: :staff, relation: :staffs + has_many :bids, as: :bids, relation: :bids end end diff --git a/lib/auction_fun_core/relations/bids.rb b/lib/auction_fun_core/relations/bids.rb new file mode 100644 index 0000000..0f74b16 --- /dev/null +++ b/lib/auction_fun_core/relations/bids.rb @@ -0,0 +1,28 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Relations + # SQL relation for bids + # @see https://rom-rb.org/5.2/learn/sql/relations/ + class Bids < ROM::Relation[:sql] + use :pagination, per_page: 3 + + schema(:bids, infer: true) do + attribute :id, Types::Integer + + attribute :auction_id, Types::ForeignKey(:auctions) + attribute :user_id, Types::ForeignKey(:users) + + associations do + belongs_to :auction + belongs_to :user + end + + primary_key :id + end + + struct_namespace Entities + auto_struct(true) + end + end +end diff --git a/lib/auction_fun_core/repos/bid_context/bid_repository.rb b/lib/auction_fun_core/repos/bid_context/bid_repository.rb new file mode 100644 index 0000000..08227a0 --- /dev/null +++ b/lib/auction_fun_core/repos/bid_context/bid_repository.rb @@ -0,0 +1,27 @@ +# frozen_string_literal: true + +module AuctionFunCore + module Repos + module BidContext + # SQL repository for bids. + class BidRepository < ROM::Repository[:bids] + include Import["container"] + + struct_namespace Entities + commands :create, update: :by_pk, delete: :by_pk + + # Returns the total number of bids in database. + # @return [Integer] + def count + bids.count + end + + # @param conditions [Hash] DSL Dataset + # @return [Boolean] + def exists?(conditions) + bids.exist?(conditions) + end + end + end + end +end diff --git a/lib/auction_fun_core/version.rb b/lib/auction_fun_core/version.rb index 6d1e7db..86fa388 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.7.0" + VERSION = "0.8.0" # Required class module is a gem dependency class Version; end diff --git a/spec/auction_fun_core/contracts/bid_context/create_bid_closed_contract_spec.rb b/spec/auction_fun_core/contracts/bid_context/create_bid_closed_contract_spec.rb new file mode 100644 index 0000000..3692912 --- /dev/null +++ b/spec/auction_fun_core/contracts/bid_context/create_bid_closed_contract_spec.rb @@ -0,0 +1,130 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AuctionFunCore::Contracts::BidContext::CreateBidClosedContract, type: :contract do + describe "#call" do + subject(:contract) { described_class.new.call(attributes) } + + context "when params are blank" do + let(:attributes) { {} } + + it "expect failure with error messages" do + expect(contract).to be_failure + + expect(contract.errors[:auction_id]).to include(I18n.t("contracts.errors.key?")) + expect(contract.errors[:user_id]).to include(I18n.t("contracts.errors.key?")) + end + end + + context "with foreign key user_id param" do + context "when is a invalid type" do + let(:attributes) { {user_id: "wrongvalue"} } + + it "expect failure with error messages" do + expect(contract).to be_failure + + expect(contract.errors[:user_id]).to include(I18n.t("contracts.errors.int?")) + end + end + + context "when is not found on database" do + let(:attributes) { {user_id: rand(10_000..1_000_000)} } + + it "expect failure with error messages" do + expect(contract).to be_failure + + expect(contract.errors[:user_id]).to include(I18n.t("contracts.errors.custom.not_found")) + end + end + + context "when user has already placed a bid on the auction" do + let(:user) { Factory[:user] } + let(:auction) { Factory[:auction, :default_running_closed] } + let(:bid) { Factory[:bid, user: user, auction: auction, value_cents: auction.initial_bid_cents] } + let(:attributes) { {auction_id: auction.id, user_id: user.id, value_cents: (bid.value_cents * 2)} } + + it "expect failure with error messages" do + expect(contract).to be_failure + + expect(contract.errors[:user_id]).to include(I18n.t("contracts.errors.custom.bids.already_bidded")) + end + end + end + + context "with foreign key auction_id param" do + context "when a invalid type" do + let(:attributes) { {auction_id: "wrongvalue"} } + + it "expect failure with error messages" do + expect(contract).to be_failure + + expect(contract.errors[:auction_id]).to include(I18n.t("contracts.errors.int?")) + end + end + + context "when 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 different of "closed"' do + let(:auction) { Factory[:auction, :default_penny] } + let(:attributes) { {auction_id: auction.id} } + + it "expect failure with error messages" do + expect(contract).to be_failure + + expect(contract.errors[:auction_id]).to include( + I18n.t("contracts.errors.custom.bids.invalid_kind", kind: "closed") + ) + end + end + + context 'when auction status is not "scheduled" or "running"' do + let(:auction) { Factory[:auction, :default_finished_standard] } + let(:attributes) { {auction_id: auction.id} } + + it "expect failure with error messages" do + expect(contract).to be_failure + + expect(contract.errors[:auction_id]).to include( + I18n.t("contracts.errors.custom.bids.invalid_status", status: auction.status) + ) + end + end + end + + context "with value_cents param" do + let(:user) { Factory[:user] } + let(:auction) { Factory[:auction, :default_running_closed] } + + context "when value_cents is less than to auction initial bid" do + let(:value) { Money.new(auction.initial_bid_cents - 1) } + let(:attributes) { {auction_id: auction.id, user_id: user.id, value_cents: value.cents} } + + it "expect failure with error messages" do + expect(contract).to be_failure + + expect(contract.errors[:value_cents]).to include( + I18n.t("contracts.errors.gteq?", num: Money.new(auction.initial_bid_cents).to_f) + ) + end + end + + context "when value_cents is greater than or equal to auction initial bid" do + let(:value) { Money.new(auction.initial_bid_cents) } + let(:attributes) { {auction_id: auction.id, user_id: user.id, value_cents: value.cents} } + + it "expect return success without error messages" do + expect(contract).to be_success + end + end + end + end +end diff --git a/spec/auction_fun_core/contracts/bid_context/create_bid_penny_contract_spec.rb b/spec/auction_fun_core/contracts/bid_context/create_bid_penny_contract_spec.rb new file mode 100644 index 0000000..72417bd --- /dev/null +++ b/spec/auction_fun_core/contracts/bid_context/create_bid_penny_contract_spec.rb @@ -0,0 +1,118 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AuctionFunCore::Contracts::BidContext::CreateBidPennyContract, type: :contract do + describe "#call" do + subject(:contract) { described_class.new.call(attributes) } + + context "when params are blank" do + let(:attributes) { {} } + + it "expect failure with error messages" do + expect(contract).to be_failure + + expect(contract.errors[:auction_id]).to include(I18n.t("contracts.errors.key?")) + expect(contract.errors[:user_id]).to include(I18n.t("contracts.errors.key?")) + end + end + + context "with foreign key user_id param" do + context "when is a invalid type" do + let(:attributes) { {user_id: "wrongvalue"} } + + it "expect failure with error messages" do + expect(contract).to be_failure + + expect(contract.errors[:user_id]).to include(I18n.t("contracts.errors.int?")) + end + end + + context "when is not found on database" do + let(:attributes) { {user_id: rand(10_000..1_000_000)} } + + it "expect failure with error messages" do + expect(contract).to be_failure + + expect(contract.errors[:user_id]).to include(I18n.t("contracts.errors.custom.not_found")) + end + end + end + + context "with foreign key auction_id param" do + context "when a invalid type" do + let(:attributes) { {auction_id: "wrongvalue"} } + + it "expect failure with error messages" do + expect(contract).to be_failure + + expect(contract.errors[:auction_id]).to include(I18n.t("contracts.errors.int?")) + end + end + + context "when 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 different of "penny"' do + let(:auction) { Factory[:auction, :default_standard] } + let(:attributes) { {auction_id: auction.id} } + + it "expect failure with error messages" do + expect(contract).to be_failure + + expect(contract.errors[:auction_id]).to include( + I18n.t("contracts.errors.custom.bids.invalid_kind", kind: "penny") + ) + end + end + + context 'when auction status is not "scheduled" or "running"' do + let(:auction) { Factory[:auction, :default_finished_standard] } + let(:attributes) { {auction_id: auction.id} } + + it "expect failure with error messages" do + expect(contract).to be_failure + + expect(contract.errors[:auction_id]).to include( + I18n.t("contracts.errors.custom.bids.invalid_status", status: auction.status) + ) + end + end + end + + context "with user balance" do + let(:bid_cents) { 10_000 } + let(:user) { Factory[:user] } + let(:auction) { Factory[:auction, :default_running_penny, initial_bid_cents: bid_cents] } + let(:attributes) { {auction_id: auction.id, user_id: user.id} } + + context "when does not have enough balance to bid" do + it "expect failure with error messages" do + expect(contract).to be_failure + expect(contract.errors[:user_id]).to include( + I18n.t("contracts.errors.custom.bids.insufficient_balance") + ) + end + end + + context "when has enough balance to bid" do + before do + AuctionFunCore::Repos::UserContext::UserRepository.new.update( + user.id, balance_cents: (bid_cents * 10) + ) + end + + it "expect return success" do + expect(contract).to be_success + end + end + end + end +end diff --git a/spec/auction_fun_core/contracts/bid_context/create_bid_standard_contract_spec.rb b/spec/auction_fun_core/contracts/bid_context/create_bid_standard_contract_spec.rb new file mode 100644 index 0000000..08d9949 --- /dev/null +++ b/spec/auction_fun_core/contracts/bid_context/create_bid_standard_contract_spec.rb @@ -0,0 +1,154 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AuctionFunCore::Contracts::BidContext::CreateBidStandardContract, type: :contract do + let(:auction_repo) { described_class.new.auction_repo } + + describe "#call" do + subject(:contract) { described_class.new.call(attributes) } + + context "when params are blank" do + let(:attributes) { {} } + + it "expect failure with error messages" do + expect(contract).to be_failure + + expect(contract.errors[:auction_id]).to include(I18n.t("contracts.errors.key?")) + expect(contract.errors[:user_id]).to include(I18n.t("contracts.errors.key?")) + expect(contract.errors[:value_cents]).to include(I18n.t("contracts.errors.key?")) + end + end + + context "with foreign key user_id param" do + context "when is a invalid type" do + let(:attributes) { {user_id: "wrongvalue"} } + + it "expect failure with error messages" do + expect(contract).to be_failure + + expect(contract.errors[:user_id]).to include(I18n.t("contracts.errors.int?")) + end + end + + context "when is not found on database" do + let(:attributes) { {user_id: rand(10_000..1_000_000)} } + + it "expect failure with error messages" do + expect(contract).to be_failure + + expect(contract.errors[:user_id]).to include(I18n.t("contracts.errors.custom.not_found")) + end + end + end + + context "with foreign key auction_id param" do + context "when a invalid type" do + let(:attributes) { {auction_id: "wrongvalue"} } + + it "expect failure with error messages" do + expect(contract).to be_failure + + expect(contract.errors[:auction_id]).to include(I18n.t("contracts.errors.int?")) + end + end + + context "when 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 different of "standard"' do + let(:auction) { Factory[:auction, :default_penny] } + let(:attributes) { {auction_id: auction.id} } + + it "expect failure with error messages" do + expect(contract).to be_failure + + expect(contract.errors[:auction_id]).to include( + I18n.t("contracts.errors.custom.bids.invalid_kind", kind: "standard") + ) + end + end + + context 'when auction status is not "scheduled" or "running"' do + let(:auction) { Factory[:auction, :default_finished_standard] } + let(:attributes) { {auction_id: auction.id} } + + it "expect failure with error messages" do + expect(contract).to be_failure + + expect(contract.errors[:auction_id]).to include( + I18n.t("contracts.errors.custom.bids.invalid_status", status: auction.status) + ) + end + end + end + + context "with value_cents param" do + let(:user) { Factory[:user] } + let(:auction) { Factory[:auction, :default_running_standard] } + + context "when auction has no bids" do + context "when value_cents is less than to auction minimal initial bid" do + let(:value) { Money.new(auction.initial_bid_cents - 1) } + let(:attributes) { {auction_id: auction.id, value_cents: value.cents} } + + it "expect failure with error messages" do + expect(contract).to be_failure + + expect(contract.errors[:value_cents]).to include( + I18n.t("contracts.errors.gteq?", num: Money.new(auction.initial_bid_cents).to_f) + ) + end + end + + context "when value_cents is greather than or equal to to minimal bid" do + let(:value) { Money.new(auction.initial_bid_cents) } + let(:attributes) { {auction_id: auction.id, user_id: user.id, value_cents: value.cents} } + + it "expect return success" do + expect(contract).to be_success + end + end + end + + context "when auction has bids" do + let(:last_bid) { Money.new(auction.minimal_bid_cents) } + let!(:bid) { Factory[:bid, auction: auction, value_cents: last_bid.cents] } + let(:required_minimal_bid) { (last_bid.cents + (last_bid.cents * 0.1)) } + + before do + auction_repo.update(auction.id, {minimal_bid_cents: required_minimal_bid.to_i}) + end + + context "when value_cents is less than to auction minimal bid value" do + let(:value_cents) { last_bid.cents - 1 } + let(:attributes) { {auction_id: auction.id, value_cents: value_cents} } + let(:required_minimal_bid) { (last_bid.cents + (last_bid.cents * 0.1)) } + + it "expect failure with error messages" do + expect(contract).to be_failure + expect(contract.errors[:value_cents]).to include( + I18n.t("contracts.errors.gteq?", num: Money.new(required_minimal_bid).to_f) + ) + end + end + + context "when value_cents is greather than or equal to ten percent of auction minimal bid value" do + let(:value_cents) { 1_100 } + let(:attributes) { {auction_id: auction.id, user_id: user.id, value_cents: value_cents} } + + it "expect return success" do + expect(contract).to be_success + end + end + end + end + end +end diff --git a/spec/auction_fun_core/entities/bid_spec.rb b/spec/auction_fun_core/entities/bid_spec.rb new file mode 100644 index 0000000..1474f9f --- /dev/null +++ b/spec/auction_fun_core/entities/bid_spec.rb @@ -0,0 +1,7 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AuctionFunCore::Entities::Bid, type: :entity do + pending "add some examples to (or delete) #{__FILE__}" +end diff --git a/spec/auction_fun_core/operations/bid_context/create_bid_closed_operation_spec.rb b/spec/auction_fun_core/operations/bid_context/create_bid_closed_operation_spec.rb new file mode 100644 index 0000000..1590029 --- /dev/null +++ b/spec/auction_fun_core/operations/bid_context/create_bid_closed_operation_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AuctionFunCore::Operations::BidContext::CreateBidClosedOperation, type: :operation do + let(:bid_repository) { AuctionFunCore::Repos::BidContext::BidRepository.new } + + describe ".call(attributes, &block)" do + let(:operation) { described_class } + + context "when block is given" do + context "when operation happens with success" do + let(:auction) { Factory[:auction, :default_closed] } + let(:user) { Factory[:user] } + let(:attributes) do + Factory.structs[:bid, user: user, auction: auction] + .to_h.except(:id, :created_at, :updated_at, :admin, :user) + .merge!(value_cents: auction.initial_bid_cents * 2) + 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 be_a(AuctionFunCore::Entities::Bid) + 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 are not valid" do + let(:attributes) { {} } + + it "expect not persist new bid on database" do + expect(bid_repository.count).to be_zero + + expect { operation }.not_to change(bid_repository, :count) + end + + it "expect return failure with error messages" do + expect(operation).to be_failure + expect(operation.failure[:auction_id]).to include(I18n.t("contracts.errors.key?")) + end + end + + context "when contract are valid" do + let(:auction) { Factory[:auction, :default_closed] } + let(:user) { Factory[:user] } + let(:attributes) do + Factory.structs[:bid, user: user, auction: auction] + .to_h.except(:id, :created_at, :updated_at, :staff, :user) + .merge!(value_cents: auction.initial_bid_cents * 2) + end + + it "expect return success without error messages" do + expect(operation).to be_success + expect(operation.failure).to be_blank + end + + it "expect persist new bid on database and dispatch event" do + allow(AuctionFunCore::Application[:event]).to receive(:publish) + + expect { operation }.to change(bid_repository, :count).from(0).to(1) + + expect(AuctionFunCore::Application[:event]).to have_received(:publish).once + 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 new file mode 100644 index 0000000..58a67a3 --- /dev/null +++ b/spec/auction_fun_core/operations/bid_context/create_bid_penny_operation_spec.rb @@ -0,0 +1,93 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AuctionFunCore::Operations::BidContext::CreateBidPennyOperation, type: :operation do + let(:bid_repository) { AuctionFunCore::Repos::BidContext::BidRepository.new } + + describe ".call(attributes, &block)" do + let(:operation) { described_class } + + context "when block is given" do + context "when operation happens with success" do + let(:auction) { Factory[:auction, :default_penny] } + let(:user) { Factory[:user] } + let(:attributes) do + Factory.structs[:bid, user: user, auction: auction] + .to_h.except(:id, :created_at, :updated_at, :staff, :user) + 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 be_a(AuctionFunCore::Entities::Bid) + 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 are not valid" do + let(:attributes) { Dry::Core::Constants::EMPTY_HASH } + + it "expect not persist new bid on database" do + expect(bid_repository.count).to be_zero + + expect { operation }.not_to change(bid_repository, :count) + end + + it "expect return failure with error messages" do + expect(operation).to be_failure + expect(operation.failure[:auction_id]).to include(I18n.t("contracts.errors.key?")) + end + end + + context "when contract are valid" do + let(:auction) { Factory[:auction, :default_penny] } + let(:user) { Factory[:user] } + let(:attributes) do + Factory.structs[:bid, user: user, auction: auction] + .to_h.except(:id, :created_at, :updated_at, :staff, :user) + end + + it "expect return success without error messages" do + expect(operation).to be_success + expect(operation.failure).to be_blank + end + + it "expect persist new bid on database and dispatch event" do + allow(AuctionFunCore::Application[:event]).to receive(:publish) + + expect { operation }.to change(bid_repository, :count).from(0).to(1) + + expect(AuctionFunCore::Application[:event]).to have_received(:publish).once + end + end + end +end diff --git a/spec/auction_fun_core/operations/bid_context/create_bid_standard_operation_spec.rb b/spec/auction_fun_core/operations/bid_context/create_bid_standard_operation_spec.rb new file mode 100644 index 0000000..b3331c7 --- /dev/null +++ b/spec/auction_fun_core/operations/bid_context/create_bid_standard_operation_spec.rb @@ -0,0 +1,95 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AuctionFunCore::Operations::BidContext::CreateBidStandardOperation, type: :operation do + let(:bid_repository) { AuctionFunCore::Repos::BidContext::BidRepository.new } + + describe ".call(attributes, &block)" do + let(:operation) { described_class } + + context "when block is given" do + context "when operation happens with success" do + let(:auction) { Factory[:auction, :default_standard] } + let(:user) { Factory[:user] } + let(:attributes) do + Factory.structs[:bid, user: user, auction: auction] + .to_h.except(:id, :created_at, :updated_at, :staff, :user) + .merge!(value_cents: auction.initial_bid_cents * 2) + 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 be_a(AuctionFunCore::Entities::Bid) + 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 are not valid" do + let(:attributes) { {} } + + it "expect return failure with error messages" do + expect(operation).to be_failure + expect(operation.failure[:auction_id]).to include(I18n.t("contracts.errors.key?")) + end + + it "expect not persist new bid on database" do + expect(bid_repository.count).to be_zero + + expect { operation }.not_to change(bid_repository, :count) + end + end + + context "when contract are valid" do + let(:auction) { Factory[:auction, :default_standard] } + let(:user) { Factory[:user] } + let(:attributes) do + Factory.structs[:bid, user: user, auction: auction] + .to_h.except(:id, :created_at, :updated_at, :staff, :user) + .merge!(value_cents: auction.initial_bid_cents * 2) + end + + it "expect persist new bid on database" do + allow(AuctionFunCore::Application[:event]).to receive(:publish) + + expect { operation }.to change(bid_repository, :count).from(0).to(1) + + expect(AuctionFunCore::Application[:event]).to have_received(:publish).once + end + + it "expect return success without error messages" do + expect(operation).to be_success + expect(operation.failure).to be_blank + end + end + end +end diff --git a/spec/auction_fun_core/repos/bid_context/bid_repository_spec.rb b/spec/auction_fun_core/repos/bid_context/bid_repository_spec.rb new file mode 100644 index 0000000..bde5637 --- /dev/null +++ b/spec/auction_fun_core/repos/bid_context/bid_repository_spec.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +require "spec_helper" + +RSpec.describe AuctionFunCore::Repos::BidContext::BidRepository, type: :repo do + subject(:repo) { described_class.new } + + describe "#create" do + let(:auction) { Factory[:auction, :default_standard] } + let(:user) { Factory[:user] } + + let(:bid) do + repo.create( + auction_id: auction.id, + user_id: user.id, + value_cents: auction.minimal_bid_cents + ) + end + + it "expect create a new bid on repository" do + expect(bid).to be_a(AuctionFunCore::Entities::Bid) + expect(bid.auction_id).to eq(auction.id) + expect(bid.user_id).to eq(user.id) + expect(bid.value_cents).to eq(auction.minimal_bid_cents) + expect(bid.created_at).not_to be_blank + expect(bid.updated_at).not_to be_blank + end + end + + describe "#count" do + context "when has not bids on repository" do + it "expect return zero" do + expect(repo.count).to be_zero + end + end + + context "when has bids on repository" do + let!(:bid) { Factory[:bid] } + + it "expect return total" do + expect(repo.count).to eq(1) + end + end + end + + describe "#exists?(conditions)" do + context "when conditions finds any record" do + let(:bid) { Factory[:bid] } + let(:conditions) { {auction_id: bid.auction_id, user_id: bid.user_id} } + + it "expect return true" do + expect(repo).to exist(conditions) + end + end + + context "when conditions does not find any record" do + let(:conditions) { {id: -1} } + + it "expect return false" do + expect(repo).not_to exist(conditions) + end + end + end +end diff --git a/spec/auction_fun_core_spec.rb b/spec/auction_fun_core_spec.rb index 72038a4..c43a6a5 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.7.0") + expect(AuctionFunCore::VERSION).to eq("0.8.0") end end diff --git a/spec/support/factories/bids.rb b/spec/support/factories/bids.rb new file mode 100644 index 0000000..eb1438c --- /dev/null +++ b/spec/support/factories/bids.rb @@ -0,0 +1,6 @@ +# frozen_string_literal: true + +Factory.define(:bid, struct_namespace: AuctionFunCore::Entities) do |f| + f.association(:auction, :default_standard) + f.association(:user) +end