From 8cb75fc6d8e87fad1e57e20574cee41f03680e15 Mon Sep 17 00:00:00 2001 From: Luis Ramos Date: Fri, 7 Aug 2020 19:51:10 +0100 Subject: [PATCH] Bring models from spree_core: Spree::Product and Spree::Variant! EPIC COMMIT ALERT :-) --- app/models/spree/option_type.rb | 12 + app/models/spree/option_value.rb | 9 + app/models/spree/price.rb | 48 +++ app/models/spree/product.rb | 250 +++++++++++++ app/models/spree/product_option_type.rb | 7 + app/models/spree/product_property.rb | 25 ++ app/models/spree/variant.rb | 189 ++++++++++ spec/models/spree/product_option_type_spec.rb | 5 + spec/models/spree/product_property_spec.rb | 14 + spec/models/spree/product_spec.rb | 344 ++++++++++++++++++ spec/models/spree/variant_spec.rb | 342 +++++++++++++++++ 11 files changed, 1245 insertions(+) create mode 100644 app/models/spree/option_type.rb create mode 100644 app/models/spree/option_value.rb create mode 100644 app/models/spree/price.rb create mode 100755 app/models/spree/product.rb create mode 100644 app/models/spree/product_option_type.rb create mode 100644 app/models/spree/product_property.rb create mode 100644 app/models/spree/variant.rb create mode 100644 spec/models/spree/product_option_type_spec.rb create mode 100644 spec/models/spree/product_property_spec.rb diff --git a/app/models/spree/option_type.rb b/app/models/spree/option_type.rb new file mode 100644 index 0000000000..5246243809 --- /dev/null +++ b/app/models/spree/option_type.rb @@ -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 diff --git a/app/models/spree/option_value.rb b/app/models/spree/option_value.rb new file mode 100644 index 0000000000..dade69e534 --- /dev/null +++ b/app/models/spree/option_value.rb @@ -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 diff --git a/app/models/spree/price.rb b/app/models/spree/price.rb new file mode 100644 index 0000000000..ed85b5675e --- /dev/null +++ b/app/models/spree/price.rb @@ -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 + diff --git a/app/models/spree/product.rb b/app/models/spree/product.rb new file mode 100755 index 0000000000..14e01acfa1 --- /dev/null +++ b/app/models/spree/product.rb @@ -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' diff --git a/app/models/spree/product_option_type.rb b/app/models/spree/product_option_type.rb new file mode 100644 index 0000000000..2a63434c48 --- /dev/null +++ b/app/models/spree/product_option_type.rb @@ -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 diff --git a/app/models/spree/product_property.rb b/app/models/spree/product_property.rb new file mode 100644 index 0000000000..aa947d878d --- /dev/null +++ b/app/models/spree/product_property.rb @@ -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 diff --git a/app/models/spree/variant.rb b/app/models/spree/variant.rb new file mode 100644 index 0000000000..9b9fb1f329 --- /dev/null +++ b/app/models/spree/variant.rb @@ -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' diff --git a/spec/models/spree/product_option_type_spec.rb b/spec/models/spree/product_option_type_spec.rb new file mode 100644 index 0000000000..8bf1870013 --- /dev/null +++ b/spec/models/spree/product_option_type_spec.rb @@ -0,0 +1,5 @@ +require 'spec_helper' + +describe Spree::ProductOptionType do + +end diff --git a/spec/models/spree/product_property_spec.rb b/spec/models/spree/product_property_spec.rb new file mode 100644 index 0000000000..e979efb769 --- /dev/null +++ b/spec/models/spree/product_property_spec.rb @@ -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 diff --git a/spec/models/spree/product_spec.rb b/spec/models/spree/product_spec.rb index 6bae30417c..e5c0aa072c 100644 --- a/spec/models/spree/product_spec.rb +++ b/spec/models/spree/product_spec.rb @@ -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) } diff --git a/spec/models/spree/variant_spec.rb b/spec/models/spree/variant_spec.rb index 3b68572281..6db5047062 100644 --- a/spec/models/spree/variant_spec.rb +++ b/spec/models/spree/variant_spec.rb @@ -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.