Bring models from spree_core: Spree::Product and Spree::Variant!

EPIC COMMIT ALERT :-)
This commit is contained in:
Luis Ramos
2020-08-07 19:51:10 +01:00
parent 201c9c109d
commit 8cb75fc6d8
11 changed files with 1245 additions and 0 deletions

View 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

View 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
View 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
View 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'

View 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

View 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
View 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'

View File

@@ -0,0 +1,5 @@
require 'spec_helper'
describe Spree::ProductOptionType do
end

View 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

View File

@@ -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) }

View File

@@ -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.