Search code examples
ruby-on-railsrubyfactory-botrspec-rails

rails 4 refactor factorygirl creates inaccurate data


I have been battling a major refactor to slim down a payments controller and could use a hand. Step one I am trying to fix my factories. Right now all of the factories work great on their own, but when I try to build associations the FactoryGirl.create(:job, :purchased_with_coupon) it will setup the association correctly on the coupon but not the payment. This means that the price paid is always is always 1. I just noticed this which you can see the other section commented out. Before I start tackling the bloated controller I need to figure this out for my tests. Thoughts?

Factories

FactoryGirl.define do
  factory :job do
    category
    company
    title { FFaker::Company.position }
    location { "#{FFaker::Address.city}, #{FFaker::AddressUS.state}" }
    language_list { [FFaker::Lorem.word] }
    short_description { FFaker::Lorem.sentence }
    description { FFaker::HTMLIpsum.body }
    application_process { "Please email #{FFaker::Internet.email} about the position." }

    trait :featured do |job|
      job.is_featured true
    end

    trait :reviewed do |job|
      job.reviewed_at { Time.now }
    end

    trait :purchased do |job|
      job.reviewed_at { Time.now }
      job.start_at { Time.now }
      job.end_at { AppConfig.product['settings']['job_active_for_day_num'].day.from_now }
      job.paid_at { Time.now }
      payments { |j| [j.association(:payment)] }
    end

    trait :purchased_with_coupon do |job|
      job.reviewed_at { Time.now }
      job.start_at { Time.now }
      job.end_at { AppConfig.product['settings']['job_active_for_day_num'].day.from_now }
      job.paid_at { Time.now }
      association :coupon, factory: :coupon
      payments { |j| [j.association(:payment)] }
    end

    trait :expired do |job|
      start_at = (200..500).to_a.sample.days.ago
      job.reviewed_at { start_at }
      job.start_at { start_at }
      job.end_at { |j| j.start_at + AppConfig.product['settings']['job_active_for_day_num'].days }
      job.paid_at { start_at }
      payments { |j| [j.association(:payment)] }
    end
  end
end

FactoryGirl.define do
  factory :payment do
    job
    # price_paid { rand(100..150) }
    price_paid { 1 }
    stripe_customer_token { (0...50).map { (65 + rand(26)).chr }.join }
  end
end

FactoryGirl.define do
  factory :coupon do
    code { rand(25**10) }
    percent_discount { rand(100**1) }
    start_at { 2.days.ago }
    end_at { 30.day.from_now }

    trait :executed do |c|
      association :job, factory: [:job, :purchased]
      c.executed_at { Time.now }
    end
  end
end

Models

class Job < ActiveRecord::Base
  acts_as_paranoid
  strip_attributes

  acts_as_taggable
  acts_as_taggable_on :languages

  belongs_to :company
  before_validation :find_company
  belongs_to :category
  has_one :coupon
  has_many :payments

  before_create :create_slug, :set_price
  after_create :update_vanity_url

  accepts_attachments_for :company
  accepts_nested_attributes_for :company
  accepts_nested_attributes_for :coupon
  accepts_nested_attributes_for :payments

  validates :title,
            :location,
            :short_description,
            presence: true,
            format: { with: /\A[\w\d .,:-@]+\z/, message: :bad_format }

  validates :application_process,
            presence: true,
            format: { with: %r{\A[\w\d .,:/@&=?-]+\z}, message: :bad_format }

  validates :title, length: { minimum: 10, maximum: 45 }
  validates :location, length: { minimum: 10, maximum: 95 }
  validates :short_description, length: { minimum: 10, maximum: 245 }
  validates :application_process, length: { minimum: 10, maximum: 95 }

  validates :description,
            :category_id,
            :language_list,
            presence: true

  validates :reviewed_at,
            :start_at,
            :end_at,
            :paid_at,
            date: { allow_blank: true }

  validates :start_at, date: { before: :end_at, message: :start_at_before_end_at }, if: proc { start_at? }
  validates :end_at, date: { after: :start_at, message: :end_at_after_start_at }, if: proc { end_at? }

  scope :active, -> { where.not(reviewed_at: nil, paid_at: nil).where('end_at >= ?', Date.today) }

  def expired?
    end_at.present? && end_at < Date.today
  end

  def reviewed?
    reviewed_at.present?
  end

  def paid_for?
    reviewed? && paid_at.present?
  end

  def active?
    reviewed? && paid_at.present? && end_at <= Date.today
  end

  private

  def set_price
    self.price = AppConfig.product['settings']['job_base_price']
  end

  def create_slug
    self.slug = title.downcase.parameterize
  end

  def update_vanity_url
    self.vanity_url = '/jobs/' + company.slug + '/' + slug + '/' + id.to_s + '/'
    save
  end

  def find_company
    existing_company = Company.where(email: company.email) if company
    self.company = existing_company.first if existing_company.count > 0
  end
end

class Coupon < ActiveRecord::Base
  acts_as_paranoid
  strip_attributes

  belongs_to :job

  validates :start_at, date: { before: :end_at }
  validates :executed_at, date: { allow_blank: true }

  validates_presence_of :job, if: proc { executed_at? }
  validates_presence_of :executed_at, if: :job

  validates :code,
            presence: true,
            length: { minimum: 10, maximum: 19 },
            uniqueness: { case_sensitive: false },
            numericality: { only_integer: true }

  validates :percent_discount,
            inclusion: { in: 1..100 },
            length: { minimum: 1, maximum: 3 },
            numericality: { only_integer: true },
            presence: true

  scope :active, -> { where('start_at < ? AND end_at > ? AND executed_at IS ?', Date.today, Date.today, nil) }

  def active?
    start_at < Date.today && end_at > Date.today && executed_at.nil?
  end

  def executed?
    job_id.present?
  end
end


class Payment < ActiveRecord::Base
  belongs_to :job
  belongs_to :coupon

  validates_presence_of :job
  validate :coupon_must_be_active

  before_create :net_price

  Numeric.include CoreExtensions::Numeric::Percentage

  attr_accessor :coupon_code
  def coupon_code=(code)
    @coupon = Coupon.find_by_code(code)
  end

  def net_price
    return job.price unless @coupon
    job.price = @coupon.percent_discount.percent_of(job.price)
    self.coupon = @coupon
  end

  private

  def coupon_must_be_active
    if @coupon
      errors[:coupon] << I18n.t('flash_messages.coupons.id.inactive') unless @coupon.active?
    elsif @coupon_code.present?
      errors[:coupon_code] << I18n.t('flash_messages.coupons.id.not_found')
    end
  end
end

Solution

  • It looks like the problem is that there is logic outside of your models that is updating the price_paid column on your Payment, and possibly setting the coupon_id on it as well.

    So I would recommend duplicating any extra logic that might be coming from your controllers, service classes, etc. into an after(:create) callback on your factory.

    trait :purchased_with_coupon do
      # ...other attributes...
    
      association :coupon
    
      after(:create) do |job, evaulator|
        discount_value = 100 - job.coupon.percent_discount) / 100.0
        calculated_price_paid = job.price * discount_value
        create(:payment, price_paid: price_paid, job: job, coupon: coupon)
      end
    end
    

    Now ultimately, that code belongs in some kind of abstraction, such as a service class that can easily be tested (and used in other tests). However, you mentioned you are getting started on a refactor and want passing tests. I think this is a reasonable compromise until you're ready to abstract it. Ultimately, I would do something like this:

    class CreatePaymentWithCoupon
      attr_reader :job
    
      def initialize(job)
        @job = job
      end
    
      def call
        job.payments.create(coupon: job.coupon, price_paid: discounted_price)
      end
    
      private
    
      def discounted_price
        discount_value = (100 - job.coupon.percent_discount) / 100.0
        job.price * discount_value
      end
    end
    

    Then, in your specs:

    it "calculates discounted price" do
      coupon = create(:coupon, percent_discount: 25)
      job = create(:job, :purchased_with_coupon, price: 100)
      CreatePaymentWithCoupon.new(job).call
    
      expect(job.payments.first.price_paid).to eq(75.0)
    end