mirror of
https://github.com/openfoodfoundation/openfoodnetwork
synced 2026-02-06 22:36:07 +00:00
Bring models from spree_core: Spree::Product and Spree::Variant!
EPIC COMMIT ALERT :-)
This commit is contained in:
12
app/models/spree/option_type.rb
Normal file
12
app/models/spree/option_type.rb
Normal file
@@ -0,0 +1,12 @@
|
||||
module Spree
|
||||
class OptionType < ActiveRecord::Base
|
||||
has_many :option_values, -> { order(:position) }, dependent: :destroy
|
||||
has_many :product_option_types, dependent: :destroy
|
||||
has_and_belongs_to_many :prototypes, join_table: 'spree_option_types_prototypes'
|
||||
|
||||
validates :name, :presentation, presence: true
|
||||
default_scope -> { order("#{self.table_name}.position") }
|
||||
|
||||
accepts_nested_attributes_for :option_values, reject_if: lambda { |ov| ov[:name].blank? || ov[:presentation].blank? }, allow_destroy: true
|
||||
end
|
||||
end
|
||||
9
app/models/spree/option_value.rb
Normal file
9
app/models/spree/option_value.rb
Normal file
@@ -0,0 +1,9 @@
|
||||
module Spree
|
||||
class OptionValue < ActiveRecord::Base
|
||||
belongs_to :option_type
|
||||
acts_as_list scope: :option_type
|
||||
has_and_belongs_to_many :variants, join_table: 'spree_option_values_variants', class_name: "Spree::Variant"
|
||||
|
||||
validates :name, :presentation, presence: true
|
||||
end
|
||||
end
|
||||
48
app/models/spree/price.rb
Normal file
48
app/models/spree/price.rb
Normal file
@@ -0,0 +1,48 @@
|
||||
module Spree
|
||||
class Price < ActiveRecord::Base
|
||||
belongs_to :variant, class_name: 'Spree::Variant'
|
||||
|
||||
validate :check_price
|
||||
validates :amount, numericality: { greater_than_or_equal_to: 0 }, allow_nil: true
|
||||
|
||||
def display_amount
|
||||
money
|
||||
end
|
||||
alias :display_price :display_amount
|
||||
|
||||
def money
|
||||
Spree::Money.new(amount || 0, { currency: currency })
|
||||
end
|
||||
|
||||
def price
|
||||
amount
|
||||
end
|
||||
|
||||
def price=(price)
|
||||
self[:amount] = parse_price(price)
|
||||
end
|
||||
|
||||
private
|
||||
def check_price
|
||||
raise "Price must belong to a variant" if variant.nil?
|
||||
|
||||
if currency.nil?
|
||||
self.currency = Spree::Config[:currency]
|
||||
end
|
||||
end
|
||||
|
||||
# strips all non-price-like characters from the price, taking into account locale settings
|
||||
def parse_price(price)
|
||||
return price unless price.is_a?(String)
|
||||
|
||||
separator, delimiter = I18n.t([:'number.currency.format.separator', :'number.currency.format.delimiter'])
|
||||
non_price_characters = /[^0-9\-#{separator}]/
|
||||
price.gsub!(non_price_characters, '') # strip everything else first
|
||||
price.gsub!(separator, '.') unless separator == '.' # then replace the locale-specific decimal separator with the standard separator if necessary
|
||||
|
||||
price.to_d
|
||||
end
|
||||
|
||||
end
|
||||
end
|
||||
|
||||
250
app/models/spree/product.rb
Executable file
250
app/models/spree/product.rb
Executable file
@@ -0,0 +1,250 @@
|
||||
# PRODUCTS
|
||||
# Products represent an entity for sale in a store.
|
||||
# Products can have variations, called variants
|
||||
# Products properties include description, permalink, availability,
|
||||
# shipping category, etc. that do not change by variant.
|
||||
#
|
||||
# MASTER VARIANT
|
||||
# Every product has one master variant, which stores master price and sku, size and weight, etc.
|
||||
# The master variant does not have option values associated with it.
|
||||
# Price, SKU, size, weight, etc. are all delegated to the master variant.
|
||||
# Contains on_hand inventory levels only when there are no variants for the product.
|
||||
#
|
||||
# VARIANTS
|
||||
# All variants can access the product properties directly (via reverse delegation).
|
||||
# Inventory units are tied to Variant.
|
||||
# The master variant can have inventory units, but not option values.
|
||||
# All other variants have option values and may have inventory units.
|
||||
# Sum of on_hand each variant's inventory level determine "on_hand" level for the product.
|
||||
#
|
||||
|
||||
module Spree
|
||||
class Product < ActiveRecord::Base
|
||||
acts_as_paranoid
|
||||
has_many :product_option_types, dependent: :destroy
|
||||
has_many :option_types, through: :product_option_types
|
||||
has_many :product_properties, dependent: :destroy
|
||||
has_many :properties, through: :product_properties
|
||||
|
||||
has_many :classifications, dependent: :delete_all
|
||||
has_many :taxons, through: :classifications
|
||||
has_and_belongs_to_many :promotion_rules, join_table: :spree_products_promotion_rules
|
||||
|
||||
belongs_to :tax_category, class_name: 'Spree::TaxCategory'
|
||||
belongs_to :shipping_category, class_name: 'Spree::ShippingCategory'
|
||||
|
||||
has_one :master,
|
||||
-> { where is_master: true },
|
||||
class_name: 'Spree::Variant',
|
||||
dependent: :destroy
|
||||
|
||||
has_many :variants,
|
||||
-> { where(is_master: false).order("#{::Spree::Variant.quoted_table_name}.position ASC") },
|
||||
class_name: 'Spree::Variant'
|
||||
|
||||
has_many :variants_including_master,
|
||||
-> { order("#{::Spree::Variant.quoted_table_name}.position ASC") },
|
||||
class_name: 'Spree::Variant',
|
||||
dependent: :destroy
|
||||
|
||||
has_many :prices, -> { order('spree_variants.position, spree_variants.id, currency') }, through: :variants
|
||||
|
||||
has_many :stock_items, through: :variants
|
||||
|
||||
delegate_belongs_to :master, :sku, :price, :currency, :display_amount, :display_price, :weight, :height, :width, :depth, :is_master, :has_default_price?, :cost_currency, :price_in, :amount_in
|
||||
delegate_belongs_to :master, :cost_price if Variant.table_exists? && Variant.column_names.include?('cost_price')
|
||||
|
||||
after_create :set_master_variant_defaults
|
||||
after_create :add_properties_and_option_types_from_prototype
|
||||
after_create :build_variants_from_option_values_hash, if: :option_values_hash
|
||||
after_save :save_master
|
||||
|
||||
delegate :images, to: :master, prefix: true
|
||||
alias_method :images, :master_images
|
||||
|
||||
has_many :variant_images, -> { order(:position) }, source: :images, through: :variants_including_master
|
||||
|
||||
accepts_nested_attributes_for :variants, allow_destroy: true
|
||||
|
||||
validates :name, presence: true
|
||||
validates :permalink, presence: true
|
||||
validates :price, presence: true, if: proc { Spree::Config[:require_master_price] }
|
||||
validates :shipping_category_id, presence: true
|
||||
|
||||
attr_accessor :option_values_hash
|
||||
|
||||
accepts_nested_attributes_for :product_properties, allow_destroy: true, reject_if: lambda { |pp| pp[:property_name].blank? }
|
||||
|
||||
make_permalink order: :name
|
||||
|
||||
alias :options :product_option_types
|
||||
|
||||
after_initialize :ensure_master
|
||||
|
||||
before_destroy :punch_permalink
|
||||
|
||||
def to_param
|
||||
permalink.present? ? permalink : (permalink_was || name.to_s.to_url)
|
||||
end
|
||||
|
||||
# the master variant is not a member of the variants array
|
||||
def has_variants?
|
||||
variants.any?
|
||||
end
|
||||
|
||||
def tax_category
|
||||
if self[:tax_category_id].nil?
|
||||
TaxCategory.where(is_default: true).first
|
||||
else
|
||||
TaxCategory.find(self[:tax_category_id])
|
||||
end
|
||||
end
|
||||
|
||||
# Adding properties and option types on creation based on a chosen prototype
|
||||
attr_reader :prototype_id
|
||||
def prototype_id=(value)
|
||||
@prototype_id = value.to_i
|
||||
end
|
||||
|
||||
# Ensures option_types and product_option_types exist for keys in option_values_hash
|
||||
def ensure_option_types_exist_for_values_hash
|
||||
return if option_values_hash.nil?
|
||||
option_values_hash.keys.map(&:to_i).each do |id|
|
||||
self.option_type_ids << id unless option_type_ids.include?(id)
|
||||
product_option_types.create(option_type_id: id) unless product_option_types.pluck(:option_type_id).include?(id)
|
||||
end
|
||||
end
|
||||
|
||||
# for adding products which are closely related to existing ones
|
||||
# define "duplicate_extra" for site-specific actions, eg for additional fields
|
||||
def duplicate
|
||||
duplicator = ProductDuplicator.new(self)
|
||||
duplicator.duplicate
|
||||
end
|
||||
|
||||
# use deleted? rather than checking the attribute directly. this
|
||||
# allows extensions to override deleted? if they want to provide
|
||||
# their own definition.
|
||||
def deleted?
|
||||
!!deleted_at
|
||||
end
|
||||
|
||||
def available?
|
||||
!(available_on.nil? || available_on.future?)
|
||||
end
|
||||
|
||||
# split variants list into hash which shows mapping of opt value onto matching variants
|
||||
# eg categorise_variants_from_option(color) => {"red" -> [...], "blue" -> [...]}
|
||||
def categorise_variants_from_option(opt_type)
|
||||
return {} unless option_types.include?(opt_type)
|
||||
variants.active.group_by { |v| v.option_values.detect { |o| o.option_type == opt_type} }
|
||||
end
|
||||
|
||||
def self.like_any(fields, values)
|
||||
where fields.map { |field|
|
||||
values.map { |value|
|
||||
arel_table[field].matches("%#{value}%")
|
||||
}.inject(:or)
|
||||
}.inject(:or)
|
||||
end
|
||||
|
||||
# Suitable for displaying only variants that has at least one option value.
|
||||
# There may be scenarios where an option type is removed and along with it
|
||||
# all option values. At that point all variants associated with only those
|
||||
# values should not be displayed to frontend users. Otherwise it breaks the
|
||||
# idea of having variants
|
||||
def variants_and_option_values(current_currency = nil)
|
||||
variants.includes(:option_values).active(current_currency).select do |variant|
|
||||
variant.option_values.any?
|
||||
end
|
||||
end
|
||||
|
||||
def empty_option_values?
|
||||
options.empty? || options.any? do |opt|
|
||||
opt.option_type.option_values.empty?
|
||||
end
|
||||
end
|
||||
|
||||
def property(property_name)
|
||||
return nil unless prop = properties.find_by(name: property_name)
|
||||
product_properties.find_by(property: prop).try(:value)
|
||||
end
|
||||
|
||||
def set_property(property_name, property_value)
|
||||
ActiveRecord::Base.transaction do
|
||||
property = Property.where(name: property_name).first_or_create!(presentation: property_name)
|
||||
product_property = ProductProperty.where(product: self, property: property).first_or_initialize
|
||||
product_property.value = property_value
|
||||
product_property.save!
|
||||
end
|
||||
end
|
||||
|
||||
def possible_promotions
|
||||
promotion_ids = promotion_rules.map(&:activator_id).uniq
|
||||
Spree::Promotion.advertised.where(id: promotion_ids).reject(&:expired?)
|
||||
end
|
||||
|
||||
def total_on_hand
|
||||
if Spree::Config.track_inventory_levels
|
||||
self.stock_items.sum(&:count_on_hand)
|
||||
else
|
||||
Float::INFINITY
|
||||
end
|
||||
end
|
||||
|
||||
# Master variant may be deleted (i.e. when the product is deleted)
|
||||
# which would make AR's default finder return nil.
|
||||
# This is a stopgap for that little problem.
|
||||
def master
|
||||
super || variants_including_master.with_deleted.where(is_master: true).first
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
# Builds variants from a hash of option types & values
|
||||
def build_variants_from_option_values_hash
|
||||
ensure_option_types_exist_for_values_hash
|
||||
values = option_values_hash.values
|
||||
values = values.inject(values.shift) { |memo, value| memo.product(value).map(&:flatten) }
|
||||
|
||||
values.each do |ids|
|
||||
variant = variants.create(
|
||||
option_value_ids: ids,
|
||||
price: master.price
|
||||
)
|
||||
end
|
||||
save
|
||||
end
|
||||
|
||||
def add_properties_and_option_types_from_prototype
|
||||
if prototype_id && prototype = Spree::Prototype.find_by(id: prototype_id)
|
||||
prototype.properties.each do |property|
|
||||
product_properties.create(property: property)
|
||||
end
|
||||
self.option_types = prototype.option_types
|
||||
end
|
||||
end
|
||||
|
||||
# ensures the master variant is flagged as such
|
||||
def set_master_variant_defaults
|
||||
master.is_master = true
|
||||
end
|
||||
|
||||
# there's a weird quirk with the delegate stuff that does not automatically save the delegate object
|
||||
# when saving so we force a save using a hook.
|
||||
def save_master
|
||||
master.save if master && (master.changed? || master.new_record? || (master.default_price && (master.default_price.changed || master.default_price.new_record)))
|
||||
end
|
||||
|
||||
def ensure_master
|
||||
return unless new_record?
|
||||
self.master ||= Variant.new
|
||||
end
|
||||
|
||||
def punch_permalink
|
||||
update_attribute :permalink, "#{Time.now.to_i}_#{permalink}" # punch permalink with date prefix
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
require_dependency 'spree/product/scopes'
|
||||
7
app/models/spree/product_option_type.rb
Normal file
7
app/models/spree/product_option_type.rb
Normal file
@@ -0,0 +1,7 @@
|
||||
module Spree
|
||||
class ProductOptionType < ActiveRecord::Base
|
||||
belongs_to :product, class_name: 'Spree::Product'
|
||||
belongs_to :option_type, class_name: 'Spree::OptionType'
|
||||
acts_as_list scope: :product
|
||||
end
|
||||
end
|
||||
25
app/models/spree/product_property.rb
Normal file
25
app/models/spree/product_property.rb
Normal file
@@ -0,0 +1,25 @@
|
||||
module Spree
|
||||
class ProductProperty < ActiveRecord::Base
|
||||
belongs_to :product, class_name: 'Spree::Product'
|
||||
belongs_to :property, class_name: 'Spree::Property'
|
||||
|
||||
validates :property, presence: true
|
||||
validates :value, length: { maximum: 255 }
|
||||
|
||||
default_scope -> { order("#{self.table_name}.position") }
|
||||
|
||||
# virtual attributes for use with AJAX completion stuff
|
||||
def property_name
|
||||
property.name if property
|
||||
end
|
||||
|
||||
def property_name=(name)
|
||||
unless name.blank?
|
||||
unless property = Property.find_by(name: name)
|
||||
property = Property.create(name: name, presentation: name)
|
||||
end
|
||||
self.property = property
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
189
app/models/spree/variant.rb
Normal file
189
app/models/spree/variant.rb
Normal file
@@ -0,0 +1,189 @@
|
||||
module Spree
|
||||
class Variant < ActiveRecord::Base
|
||||
acts_as_paranoid
|
||||
|
||||
belongs_to :product, touch: true, class_name: 'Spree::Product'
|
||||
|
||||
delegate_belongs_to :product, :name, :description, :permalink, :available_on,
|
||||
:tax_category_id, :shipping_category_id, :meta_description,
|
||||
:meta_keywords, :tax_category, :shipping_category
|
||||
|
||||
has_many :inventory_units
|
||||
has_many :line_items
|
||||
|
||||
has_many :stock_items, dependent: :destroy
|
||||
has_many :stock_locations, through: :stock_items
|
||||
has_many :stock_movements
|
||||
|
||||
has_and_belongs_to_many :option_values, join_table: :spree_option_values_variants
|
||||
has_many :images, -> { order(:position) }, as: :viewable, dependent: :destroy, class_name: "Spree::Image"
|
||||
|
||||
has_one :default_price,
|
||||
-> { where currency: Spree::Config[:currency] },
|
||||
class_name: 'Spree::Price',
|
||||
dependent: :destroy
|
||||
|
||||
delegate_belongs_to :default_price, :display_price, :display_amount, :price, :price=, :currency if Spree::Price.table_exists?
|
||||
|
||||
has_many :prices,
|
||||
class_name: 'Spree::Price',
|
||||
dependent: :destroy
|
||||
|
||||
validate :check_price
|
||||
validates :price, numericality: { greater_than_or_equal_to: 0 }, presence: true, if: proc { Spree::Config[:require_master_price] }
|
||||
validates :cost_price, numericality: { greater_than_or_equal_to: 0, allow_nil: true } if self.table_exists? && self.column_names.include?('cost_price')
|
||||
|
||||
before_validation :set_cost_currency
|
||||
after_save :save_default_price
|
||||
after_create :create_stock_items
|
||||
after_create :set_position
|
||||
|
||||
# default variant scope only lists non-deleted variants
|
||||
scope :deleted, lambda { where('deleted_at IS NOT NULL') }
|
||||
|
||||
def self.active(currency = nil)
|
||||
joins(:prices).where(deleted_at: nil).where('spree_prices.currency' => currency || Spree::Config[:currency]).where('spree_prices.amount IS NOT NULL')
|
||||
end
|
||||
|
||||
def cost_price=(price)
|
||||
self[:cost_price] = parse_price(price) if price.present?
|
||||
end
|
||||
|
||||
# returns number of units currently on backorder for this variant.
|
||||
def on_backorder
|
||||
inventory_units.with_state('backordered').size
|
||||
end
|
||||
|
||||
def options_text
|
||||
values = self.option_values.joins(:option_type).order("#{Spree::OptionType.table_name}.position asc")
|
||||
|
||||
values.map! do |ov|
|
||||
"#{ov.option_type.presentation}: #{ov.presentation}"
|
||||
end
|
||||
|
||||
values.to_sentence({ words_connector: ", ", two_words_connector: ", " })
|
||||
end
|
||||
|
||||
def gross_profit
|
||||
cost_price.nil? ? 0 : (price - cost_price)
|
||||
end
|
||||
|
||||
# use deleted? rather than checking the attribute directly. this
|
||||
# allows extensions to override deleted? if they want to provide
|
||||
# their own definition.
|
||||
def deleted?
|
||||
deleted_at
|
||||
end
|
||||
|
||||
def set_option_value(opt_name, opt_value)
|
||||
# no option values on master
|
||||
return if self.is_master
|
||||
|
||||
option_type = Spree::OptionType.where(name: opt_name).first_or_initialize do |o|
|
||||
o.presentation = opt_name
|
||||
o.save!
|
||||
end
|
||||
|
||||
current_value = self.option_values.detect { |o| o.option_type.name == opt_name }
|
||||
|
||||
unless current_value.nil?
|
||||
return if current_value.name == opt_value
|
||||
self.option_values.delete(current_value)
|
||||
else
|
||||
# then we have to check to make sure that the product has the option type
|
||||
unless self.product.option_types.include? option_type
|
||||
self.product.option_types << option_type
|
||||
self.product.save
|
||||
end
|
||||
end
|
||||
|
||||
option_value = Spree::OptionValue.where(option_type_id: option_type.id, name: opt_value).first_or_initialize do |o|
|
||||
o.presentation = opt_value
|
||||
o.save!
|
||||
end
|
||||
|
||||
self.option_values << option_value
|
||||
self.save
|
||||
end
|
||||
|
||||
def option_value(opt_name)
|
||||
self.option_values.detect { |o| o.option_type.name == opt_name }.try(:presentation)
|
||||
end
|
||||
|
||||
def has_default_price?
|
||||
!self.default_price.nil?
|
||||
end
|
||||
|
||||
def price_in(currency)
|
||||
prices.select{ |price| price.currency == currency }.first || Spree::Price.new(variant_id: self.id, currency: currency)
|
||||
end
|
||||
|
||||
def amount_in(currency)
|
||||
price_in(currency).try(:amount)
|
||||
end
|
||||
|
||||
def name_and_sku
|
||||
"#{name} - #{sku}"
|
||||
end
|
||||
|
||||
# Product may be created with deleted_at already set,
|
||||
# which would make AR's default finder return nil.
|
||||
# This is a stopgap for that little problem.
|
||||
def product
|
||||
Spree::Product.unscoped { super }
|
||||
end
|
||||
|
||||
def in_stock?(quantity=1)
|
||||
Spree::Stock::Quantifier.new(self).can_supply?(quantity)
|
||||
end
|
||||
|
||||
def total_on_hand
|
||||
Spree::Stock::Quantifier.new(self).total_on_hand
|
||||
end
|
||||
|
||||
private
|
||||
# strips all non-price-like characters from the price, taking into account locale settings
|
||||
def parse_price(price)
|
||||
return price unless price.is_a?(String)
|
||||
|
||||
separator, delimiter = I18n.t([:'number.currency.format.separator', :'number.currency.format.delimiter'])
|
||||
non_price_characters = /[^0-9\-#{separator}]/
|
||||
price.gsub!(non_price_characters, '') # strip everything else first
|
||||
price.gsub!(separator, '.') unless separator == '.' # then replace the locale-specific decimal separator with the standard separator if necessary
|
||||
|
||||
price.to_d
|
||||
end
|
||||
|
||||
# Ensures a new variant takes the product master price when price is not supplied
|
||||
def check_price
|
||||
if price.nil? && Spree::Config[:require_master_price]
|
||||
raise 'No master variant found to infer price' unless (product && product.master)
|
||||
raise 'Must supply price for variant or master.price for product.' if self == product.master
|
||||
self.price = product.master.price
|
||||
end
|
||||
if currency.nil?
|
||||
self.currency = Spree::Config[:currency]
|
||||
end
|
||||
end
|
||||
|
||||
def save_default_price
|
||||
default_price.save if default_price && (default_price.changed? || default_price.new_record?)
|
||||
end
|
||||
|
||||
def set_cost_currency
|
||||
self.cost_currency = Spree::Config[:currency] if cost_currency.nil? || cost_currency.empty?
|
||||
end
|
||||
|
||||
def create_stock_items
|
||||
StockLocation.all.each do |stock_location|
|
||||
stock_location.propagate_variant(self) if stock_location.propagate_all_variants?
|
||||
end
|
||||
end
|
||||
|
||||
def set_position
|
||||
self.update_column(:position, product.variants.maximum(:position).to_i + 1)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
require_dependency 'spree/variant/scopes'
|
||||
5
spec/models/spree/product_option_type_spec.rb
Normal file
5
spec/models/spree/product_option_type_spec.rb
Normal file
@@ -0,0 +1,5 @@
|
||||
require 'spec_helper'
|
||||
|
||||
describe Spree::ProductOptionType do
|
||||
|
||||
end
|
||||
14
spec/models/spree/product_property_spec.rb
Normal file
14
spec/models/spree/product_property_spec.rb
Normal file
@@ -0,0 +1,14 @@
|
||||
require 'spec_helper'
|
||||
|
||||
describe Spree::ProductProperty do
|
||||
|
||||
context "validations" do
|
||||
it "should validate length of value" do
|
||||
pp = create(:product_property)
|
||||
pp.value = "x" * 256
|
||||
pp.should_not be_valid
|
||||
end
|
||||
|
||||
end
|
||||
|
||||
end
|
||||
@@ -2,6 +2,350 @@ require 'spec_helper'
|
||||
|
||||
module Spree
|
||||
describe Product do
|
||||
context 'product instance' do
|
||||
let(:product) { create(:product) }
|
||||
|
||||
context '#duplicate' do
|
||||
before do
|
||||
product.stub :taxons => [create(:taxon)]
|
||||
end
|
||||
|
||||
it 'duplicates product' do
|
||||
clone = product.duplicate
|
||||
clone.name.should == 'COPY OF ' + product.name
|
||||
clone.master.sku.should == 'COPY OF ' + product.master.sku
|
||||
clone.taxons.should == product.taxons
|
||||
clone.images.size.should == product.images.size
|
||||
end
|
||||
|
||||
it 'calls #duplicate_extra' do
|
||||
Spree::Product.class_eval do
|
||||
def duplicate_extra(old_product)
|
||||
self.name = old_product.name.reverse
|
||||
end
|
||||
end
|
||||
|
||||
clone = product.duplicate
|
||||
clone.name.should == product.name.reverse
|
||||
end
|
||||
end
|
||||
|
||||
context "product has no variants" do
|
||||
context "#destroy" do
|
||||
it "should set deleted_at value" do
|
||||
product.destroy
|
||||
product.deleted_at.should_not be_nil
|
||||
product.master.deleted_at.should_not be_nil
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "product has variants" do
|
||||
before do
|
||||
create(:variant, :product => product)
|
||||
end
|
||||
|
||||
context "#destroy" do
|
||||
it "should set deleted_at value" do
|
||||
product.destroy
|
||||
product.deleted_at.should_not be_nil
|
||||
product.variants_including_master.all? { |v| !v.deleted_at.nil? }.should be_true
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "#price" do
|
||||
# Regression test for Spree #1173
|
||||
it 'strips non-price characters' do
|
||||
product.price = "$10"
|
||||
product.price.should == 10.0
|
||||
end
|
||||
end
|
||||
|
||||
context "#display_price" do
|
||||
before { product.price = 10.55 }
|
||||
|
||||
context "with display_currency set to true" do
|
||||
before { Spree::Config[:display_currency] = true }
|
||||
|
||||
it "shows the currency" do
|
||||
product.display_price.to_s.should == "$10.55 USD"
|
||||
end
|
||||
end
|
||||
|
||||
context "with display_currency set to false" do
|
||||
before { Spree::Config[:display_currency] = false }
|
||||
|
||||
it "does not include the currency" do
|
||||
product.display_price.to_s.should == "$10.55"
|
||||
end
|
||||
end
|
||||
|
||||
context "with currency set to JPY" do
|
||||
before do
|
||||
product.master.default_price.currency = 'JPY'
|
||||
product.master.default_price.save!
|
||||
Spree::Config[:currency] = 'JPY'
|
||||
end
|
||||
|
||||
it "displays the currency in yen" do
|
||||
product.display_price.to_s.should == "¥11"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "#available?" do
|
||||
it "should be available if date is in the past" do
|
||||
product.available_on = 1.day.ago
|
||||
product.should be_available
|
||||
end
|
||||
|
||||
it "should not be available if date is nil or in the future" do
|
||||
product.available_on = nil
|
||||
product.should_not be_available
|
||||
|
||||
product.available_on = 1.day.from_now
|
||||
product.should_not be_available
|
||||
end
|
||||
end
|
||||
|
||||
context "variants_and_option_values" do
|
||||
let!(:high) { create(:variant, product: product) }
|
||||
let!(:low) { create(:variant, product: product) }
|
||||
|
||||
before { high.option_values.destroy_all }
|
||||
|
||||
it "returns only variants with option values" do
|
||||
product.variants_and_option_values.should == [low]
|
||||
end
|
||||
end
|
||||
|
||||
describe 'Variants sorting' do
|
||||
context 'without master variant' do
|
||||
it 'sorts variants by position' do
|
||||
product.variants.to_sql.should match(/ORDER BY (\`|\")spree_variants(\`|\").position ASC/)
|
||||
end
|
||||
end
|
||||
|
||||
context 'with master variant' do
|
||||
it 'sorts variants by position' do
|
||||
product.variants_including_master.to_sql.should match(/ORDER BY (\`|\")spree_variants(\`|\").position ASC/)
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "has stock movements" do
|
||||
let(:product) { create(:product) }
|
||||
let(:variant) { product.master }
|
||||
let(:stock_item) { variant.stock_items.first }
|
||||
|
||||
it "doesnt raise ReadOnlyRecord error" do
|
||||
Spree::StockMovement.create!(stock_item: stock_item, quantity: 1)
|
||||
expect { product.destroy }.not_to raise_error
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "permalink" do
|
||||
context "build product with similar name" do
|
||||
let!(:other) { create(:product, :name => 'foo bar') }
|
||||
let(:product) { build(:product, :name => 'foo') }
|
||||
|
||||
before { product.valid? }
|
||||
|
||||
it "increments name" do
|
||||
product.permalink.should == 'foo-1'
|
||||
end
|
||||
end
|
||||
|
||||
context "build permalink with quotes" do
|
||||
it "saves quotes" do
|
||||
product = create(:product, :name => "Joe's", :permalink => "joe's")
|
||||
product.permalink.should == "joe's"
|
||||
end
|
||||
end
|
||||
|
||||
context "permalinks must be unique" do
|
||||
before do
|
||||
@product1 = create(:product, :name => 'foo')
|
||||
end
|
||||
|
||||
it "cannot create another product with the same permalink" do
|
||||
pending '[Spree build] Failing spec'
|
||||
@product2 = create(:product, :name => 'foo')
|
||||
lambda do
|
||||
@product2.update_attributes(:permalink => @product1.permalink)
|
||||
end.should raise_error(ActiveRecord::RecordNotUnique)
|
||||
end
|
||||
end
|
||||
|
||||
it "supports Chinese" do
|
||||
create(:product, :name => "你好").permalink.should == "ni-hao"
|
||||
end
|
||||
|
||||
context "manual permalink override" do
|
||||
let(:product) { create(:product, :name => "foo") }
|
||||
|
||||
it "calling save_permalink with a parameter" do
|
||||
product.name = "foobar"
|
||||
product.save
|
||||
product.permalink.should == "foo"
|
||||
|
||||
product.save_permalink(product.name)
|
||||
product.permalink.should == "foobar"
|
||||
end
|
||||
end
|
||||
|
||||
context "override permalink of deleted product" do
|
||||
let(:product) { create(:product, :name => "foo") }
|
||||
|
||||
it "should create product with same permalink from name like deleted product" do
|
||||
product.permalink.should == "foo"
|
||||
product.destroy
|
||||
|
||||
new_product = create(:product, :name => "foo")
|
||||
new_product.permalink.should == "foo"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "properties" do
|
||||
let(:product) { create(:product) }
|
||||
|
||||
it "should properly assign properties" do
|
||||
product.set_property('the_prop', 'value1')
|
||||
product.property('the_prop').should == 'value1'
|
||||
|
||||
product.set_property('the_prop', 'value2')
|
||||
product.property('the_prop').should == 'value2'
|
||||
end
|
||||
|
||||
it "should not create duplicate properties when set_property is called" do
|
||||
expect {
|
||||
product.set_property('the_prop', 'value2')
|
||||
product.save
|
||||
product.reload
|
||||
}.not_to change(product.properties, :length)
|
||||
|
||||
expect {
|
||||
product.set_property('the_prop_new', 'value')
|
||||
product.save
|
||||
product.reload
|
||||
product.property('the_prop_new').should == 'value'
|
||||
}.to change { product.properties.length }.by(1)
|
||||
end
|
||||
|
||||
# Regression test for #2455
|
||||
it "should not overwrite properties' presentation names" do
|
||||
Spree::Property.where(:name => 'foo').first_or_create!(:presentation => "Foo's Presentation Name")
|
||||
product.set_property('foo', 'value1')
|
||||
product.set_property('bar', 'value2')
|
||||
Spree::Property.where(:name => 'foo').first.presentation.should == "Foo's Presentation Name"
|
||||
Spree::Property.where(:name => 'bar').first.presentation.should == "bar"
|
||||
end
|
||||
end
|
||||
|
||||
context '#create' do
|
||||
let!(:prototype) { create(:prototype) }
|
||||
let!(:product) { Spree::Product.new(name: "Foo", price: 1.99, shipping_category_id: create(:shipping_category).id) }
|
||||
|
||||
before { product.prototype_id = prototype.id }
|
||||
|
||||
context "when prototype is supplied" do
|
||||
it "should create properties based on the prototype" do
|
||||
product.save
|
||||
product.properties.count.should == 1
|
||||
end
|
||||
end
|
||||
|
||||
context "when prototype with option types is supplied" do
|
||||
def build_option_type_with_values(name, values)
|
||||
ot = create(:option_type, :name => name)
|
||||
values.each do |val|
|
||||
ot.option_values.create(:name => val.downcase, :presentation => val)
|
||||
end
|
||||
ot
|
||||
end
|
||||
|
||||
let(:prototype) do
|
||||
size = build_option_type_with_values("size", %w(Small Medium Large))
|
||||
create(:prototype, :name => "Size", :option_types => [ size ])
|
||||
end
|
||||
|
||||
let(:option_values_hash) do
|
||||
hash = {}
|
||||
prototype.option_types.each do |i|
|
||||
hash[i.id.to_s] = i.option_value_ids
|
||||
end
|
||||
hash
|
||||
end
|
||||
|
||||
it "should create option types based on the prototype" do
|
||||
product.save
|
||||
product.option_type_ids.length.should == 1
|
||||
product.option_type_ids.should == prototype.option_type_ids
|
||||
end
|
||||
|
||||
it "should create product option types based on the prototype" do
|
||||
product.save
|
||||
product.product_option_types.pluck(:option_type_id).should == prototype.option_type_ids
|
||||
end
|
||||
|
||||
it "should create variants from an option values hash with one option type" do
|
||||
product.option_values_hash = option_values_hash
|
||||
product.save
|
||||
product.variants.length.should == 3
|
||||
end
|
||||
|
||||
it "should still create variants when option_values_hash is given but prototype id is nil" do
|
||||
product.option_values_hash = option_values_hash
|
||||
product.prototype_id = nil
|
||||
product.save
|
||||
product.option_type_ids.length.should == 1
|
||||
product.option_type_ids.should == prototype.option_type_ids
|
||||
product.variants.length.should == 3
|
||||
end
|
||||
|
||||
it "should create variants from an option values hash with multiple option types" do
|
||||
color = build_option_type_with_values("color", %w(Red Green Blue))
|
||||
logo = build_option_type_with_values("logo", %w(Ruby Rails Nginx))
|
||||
option_values_hash[color.id.to_s] = color.option_value_ids
|
||||
option_values_hash[logo.id.to_s] = logo.option_value_ids
|
||||
product.option_values_hash = option_values_hash
|
||||
product.save
|
||||
product.reload
|
||||
product.option_type_ids.length.should == 3
|
||||
product.variants.length.should == 27
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Regression tests for Spree #2352
|
||||
context "classifications and taxons" do
|
||||
it "is joined through classifications" do
|
||||
reflection = Spree::Product.reflect_on_association(:taxons)
|
||||
reflection.options[:through] = :classifications
|
||||
end
|
||||
|
||||
it "will delete all classifications" do
|
||||
reflection = Spree::Product.reflect_on_association(:classifications)
|
||||
reflection.options[:dependent] = :delete_all
|
||||
end
|
||||
end
|
||||
|
||||
describe '#total_on_hand' do
|
||||
it 'should be infinite if track_inventory_levels is false' do
|
||||
Spree::Config[:track_inventory_levels] = false
|
||||
build(:product).total_on_hand.should eql(Float::INFINITY)
|
||||
end
|
||||
|
||||
it 'should return sum of stock items count_on_hand' do
|
||||
product = build(:product)
|
||||
product.stub stock_items: [double(Spree::StockItem, count_on_hand: 5)]
|
||||
product.total_on_hand.should eql(5)
|
||||
end
|
||||
end
|
||||
|
||||
describe "associations" do
|
||||
it { is_expected.to belong_to(:supplier) }
|
||||
it { is_expected.to belong_to(:primary_taxon) }
|
||||
|
||||
@@ -3,6 +3,348 @@ require 'variant_units/option_value_namer'
|
||||
|
||||
module Spree
|
||||
describe Variant do
|
||||
let!(:variant) { create(:variant) }
|
||||
|
||||
context "validations" do
|
||||
it "should validate price is greater than 0" do
|
||||
variant.price = -1
|
||||
variant.should be_invalid
|
||||
end
|
||||
|
||||
it "should validate price is 0" do
|
||||
variant.price = 0
|
||||
variant.should be_valid
|
||||
end
|
||||
end
|
||||
|
||||
context "after create" do
|
||||
let!(:product) { create(:product) }
|
||||
|
||||
it "propagate to stock items" do
|
||||
Spree::StockLocation.any_instance.should_receive(:propagate_variant)
|
||||
product.variants.create(:name => "Foobar")
|
||||
end
|
||||
|
||||
context "stock location has disable propagate all variants" do
|
||||
before { Spree::StockLocation.any_instance.stub(propagate_all_variants?: false) }
|
||||
|
||||
it "propagate to stock items" do
|
||||
Spree::StockLocation.any_instance.should_not_receive(:propagate_variant)
|
||||
product.variants.create(:name => "Foobar")
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "product has other variants" do
|
||||
describe "option value accessors" do
|
||||
before {
|
||||
@multi_variant = FactoryGirl.create :variant, :product => variant.product
|
||||
variant.product.reload
|
||||
}
|
||||
|
||||
let(:multi_variant) { @multi_variant }
|
||||
|
||||
it "should set option value" do
|
||||
multi_variant.option_value('media_type').should be_nil
|
||||
|
||||
multi_variant.set_option_value('media_type', 'DVD')
|
||||
multi_variant.option_value('media_type').should == 'DVD'
|
||||
|
||||
multi_variant.set_option_value('media_type', 'CD')
|
||||
multi_variant.option_value('media_type').should == 'CD'
|
||||
end
|
||||
|
||||
it "should not duplicate associated option values when set multiple times" do
|
||||
multi_variant.set_option_value('media_type', 'CD')
|
||||
|
||||
expect {
|
||||
multi_variant.set_option_value('media_type', 'DVD')
|
||||
}.to_not change(multi_variant.option_values, :count)
|
||||
|
||||
expect {
|
||||
multi_variant.set_option_value('coolness_type', 'awesome')
|
||||
}.to change(multi_variant.option_values, :count).by(1)
|
||||
end
|
||||
end
|
||||
|
||||
context "product has other variants" do
|
||||
describe "option value accessors" do
|
||||
before {
|
||||
@multi_variant = create(:variant, :product => variant.product)
|
||||
variant.product.reload
|
||||
}
|
||||
|
||||
let(:multi_variant) { @multi_variant }
|
||||
|
||||
it "should set option value" do
|
||||
multi_variant.option_value('media_type').should be_nil
|
||||
|
||||
multi_variant.set_option_value('media_type', 'DVD')
|
||||
multi_variant.option_value('media_type').should == 'DVD'
|
||||
|
||||
multi_variant.set_option_value('media_type', 'CD')
|
||||
multi_variant.option_value('media_type').should == 'CD'
|
||||
end
|
||||
|
||||
it "should not duplicate associated option values when set multiple times" do
|
||||
multi_variant.set_option_value('media_type', 'CD')
|
||||
|
||||
expect {
|
||||
multi_variant.set_option_value('media_type', 'DVD')
|
||||
}.to_not change(multi_variant.option_values, :count)
|
||||
|
||||
expect {
|
||||
multi_variant.set_option_value('coolness_type', 'awesome')
|
||||
}.to change(multi_variant.option_values, :count).by(1)
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "price parsing" do
|
||||
before(:each) do
|
||||
I18n.locale = I18n.default_locale
|
||||
I18n.backend.store_translations(:de, { :number => { :currency => { :format => { :delimiter => '.', :separator => ',' } } } })
|
||||
end
|
||||
|
||||
after do
|
||||
I18n.locale = I18n.default_locale
|
||||
end
|
||||
|
||||
context "price=" do
|
||||
context "with decimal point" do
|
||||
it "captures the proper amount for a formatted price" do
|
||||
variant.price = '1,599.99'
|
||||
variant.price.should == 1599.99
|
||||
end
|
||||
end
|
||||
|
||||
context "with decimal comma" do
|
||||
it "captures the proper amount for a formatted price" do
|
||||
I18n.locale = :de
|
||||
variant.price = '1.599,99'
|
||||
variant.price.should == 1599.99
|
||||
end
|
||||
end
|
||||
|
||||
context "with a numeric price" do
|
||||
it "uses the price as is" do
|
||||
I18n.locale = :de
|
||||
variant.price = 1599.99
|
||||
variant.price.should == 1599.99
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "cost_price=" do
|
||||
context "with decimal point" do
|
||||
it "captures the proper amount for a formatted price" do
|
||||
variant.cost_price = '1,599.99'
|
||||
variant.cost_price.should == 1599.99
|
||||
end
|
||||
end
|
||||
|
||||
context "with decimal comma" do
|
||||
it "captures the proper amount for a formatted price" do
|
||||
I18n.locale = :de
|
||||
variant.cost_price = '1.599,99'
|
||||
variant.cost_price.should == 1599.99
|
||||
end
|
||||
end
|
||||
|
||||
context "with a numeric price" do
|
||||
it "uses the price as is" do
|
||||
I18n.locale = :de
|
||||
variant.cost_price = 1599.99
|
||||
variant.cost_price.should == 1599.99
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context "#currency" do
|
||||
it "returns the globally configured currency" do
|
||||
variant.currency.should == "USD"
|
||||
end
|
||||
end
|
||||
|
||||
context "#display_amount" do
|
||||
it "returns a Spree::Money" do
|
||||
variant.price = 21.22
|
||||
variant.display_amount.to_s.should == "$21.22"
|
||||
end
|
||||
end
|
||||
|
||||
context "#cost_currency" do
|
||||
context "when cost currency is nil" do
|
||||
before { variant.cost_currency = nil }
|
||||
it "populates cost currency with the default value on save" do
|
||||
variant.save!
|
||||
variant.cost_currency.should == "USD"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.price_in' do
|
||||
before do
|
||||
variant.prices << create(:price, :variant => variant, :currency => "EUR", :amount => 33.33)
|
||||
end
|
||||
subject { variant.price_in(currency).display_amount }
|
||||
|
||||
context "when currency is not specified" do
|
||||
let(:currency) { nil }
|
||||
|
||||
it "returns 0" do
|
||||
subject.to_s.should == "$0.00"
|
||||
end
|
||||
end
|
||||
|
||||
context "when currency is EUR" do
|
||||
let(:currency) { 'EUR' }
|
||||
|
||||
it "returns the value in the EUR" do
|
||||
subject.to_s.should == "€33.33"
|
||||
end
|
||||
end
|
||||
|
||||
context "when currency is USD" do
|
||||
let(:currency) { 'USD' }
|
||||
|
||||
it "returns the value in the USD" do
|
||||
subject.to_s.should == "$19.99"
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '.amount_in' do
|
||||
before do
|
||||
variant.prices << create(:price, :variant => variant, :currency => "EUR", :amount => 33.33)
|
||||
end
|
||||
|
||||
subject { variant.amount_in(currency) }
|
||||
|
||||
context "when currency is not specified" do
|
||||
let(:currency) { nil }
|
||||
|
||||
it "returns nil" do
|
||||
subject.should be_nil
|
||||
end
|
||||
end
|
||||
|
||||
context "when currency is EUR" do
|
||||
let(:currency) { 'EUR' }
|
||||
|
||||
it "returns the value in the EUR" do
|
||||
subject.should == 33.33
|
||||
end
|
||||
end
|
||||
|
||||
context "when currency is USD" do
|
||||
let(:currency) { 'USD' }
|
||||
|
||||
it "returns the value in the USD" do
|
||||
subject.should == 19.99
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
# Regression test for #2432
|
||||
describe 'options_text' do
|
||||
before do
|
||||
option_type = double("OptionType", :presentation => "Foo")
|
||||
option_values = [double("OptionValue", :option_type => option_type, :presentation => "bar")]
|
||||
variant.stub(:option_values).and_return(option_values)
|
||||
end
|
||||
|
||||
it "orders options correctly" do
|
||||
variant.option_values.should_receive(:joins).with(:option_type).and_return(scope = double)
|
||||
scope.should_receive(:order).with('spree_option_types.position asc').and_return(variant.option_values)
|
||||
variant.options_text
|
||||
end
|
||||
end
|
||||
|
||||
# Regression test for #2744
|
||||
describe "set_position" do
|
||||
it "sets variant position after creation" do
|
||||
variant = create(:variant)
|
||||
variant.position.should_not be_nil
|
||||
end
|
||||
end
|
||||
|
||||
describe '#in_stock?' do
|
||||
before do
|
||||
Spree::Config.track_inventory_levels = true
|
||||
end
|
||||
|
||||
context 'when stock_items are not backorderable' do
|
||||
before do
|
||||
Spree::StockItem.any_instance.stub(backorderable: false)
|
||||
end
|
||||
|
||||
context 'when stock_items in stock' do
|
||||
before do
|
||||
Spree::StockItem.any_instance.stub(count_on_hand: 10)
|
||||
end
|
||||
|
||||
it 'returns true if stock_items in stock' do
|
||||
variant.in_stock?.should be_true
|
||||
end
|
||||
end
|
||||
|
||||
context 'when stock_items out of stock' do
|
||||
before do
|
||||
Spree::StockItem.any_instance.stub(backorderable: false)
|
||||
Spree::StockItem.any_instance.stub(count_on_hand: 0)
|
||||
end
|
||||
|
||||
it 'return false if stock_items out of stock' do
|
||||
variant.in_stock?.should be_false
|
||||
end
|
||||
end
|
||||
|
||||
context 'when providing quantity param' do
|
||||
before do
|
||||
variant.stock_items.first.update_attribute(:count_on_hand, 10)
|
||||
end
|
||||
|
||||
it 'returns correctt value' do
|
||||
variant.in_stock?.should be_true
|
||||
variant.in_stock?(2).should be_true
|
||||
variant.in_stock?(10).should be_true
|
||||
variant.in_stock?(11).should be_false
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
context 'when stock_items are backorderable' do
|
||||
before do
|
||||
Spree::StockItem.any_instance.stub(backorderable: true)
|
||||
end
|
||||
|
||||
context 'when stock_items out of stock' do
|
||||
before do
|
||||
Spree::StockItem.any_instance.stub(count_on_hand: 0)
|
||||
end
|
||||
|
||||
it 'returns true if stock_items in stock' do
|
||||
variant.in_stock?.should be_true
|
||||
end
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
describe '#total_on_hand' do
|
||||
it 'should be infinite if track_inventory_levels is false' do
|
||||
Spree::Config[:track_inventory_levels] = false
|
||||
build(:variant).total_on_hand.should eql(Float::INFINITY)
|
||||
end
|
||||
|
||||
it 'should match quantifier total_on_hand' do
|
||||
variant = build(:variant)
|
||||
expect(variant.total_on_hand).to eq(Spree::Stock::Quantifier.new(variant).total_on_hand)
|
||||
end
|
||||
end
|
||||
|
||||
describe "double loading" do
|
||||
# app/models/spree/variant_decorator.rb may be double-loaded in delayed job environment,
|
||||
# so we need to be able to do so without error.
|
||||
|
||||
Reference in New Issue
Block a user