From d8a96c9d34cfd1df47b0c3e3e9b02eff6fb5f849 Mon Sep 17 00:00:00 2001 From: Luis Ramos Date: Tue, 28 Jul 2020 19:19:46 +0100 Subject: [PATCH] Bring order checkout workflow and some of its specs from spree_core --- app/models/spree/order/checkout.rb | 182 +++++++++++++ spec/models/spree/order/checkout_spec.rb | 331 +++++++++++++++++++++++ 2 files changed, 513 insertions(+) create mode 100644 app/models/spree/order/checkout.rb diff --git a/app/models/spree/order/checkout.rb b/app/models/spree/order/checkout.rb new file mode 100644 index 0000000000..0b70dda37a --- /dev/null +++ b/app/models/spree/order/checkout.rb @@ -0,0 +1,182 @@ +module Spree + class Order < ActiveRecord::Base + module Checkout + def self.included(klass) + klass.class_eval do + class_attribute :next_event_transitions + class_attribute :previous_states + class_attribute :checkout_flow + class_attribute :checkout_steps + class_attribute :removed_transitions + + def self.checkout_flow(&block) + if block_given? + @checkout_flow = block + define_state_machine! + else + @checkout_flow + end + end + + def self.define_state_machine! + self.checkout_steps = {} + self.next_event_transitions = [] + self.previous_states = [:cart] + self.removed_transitions = [] + + # Build the checkout flow using the checkout_flow defined either + # within the Order class, or a decorator for that class. + # + # This method may be called multiple times depending on if the + # checkout_flow is re-defined in a decorator or not. + instance_eval(&checkout_flow) + + klass = self + + # To avoid a ton of warnings when the state machine is re-defined + StateMachine::Machine.ignore_method_conflicts = true + # To avoid multiple occurrences of the same transition being defined + # On first definition, state_machines will not be defined + state_machines.clear if respond_to?(:state_machines) + state_machine :state, :initial => :cart do + klass.next_event_transitions.each { |t| transition(t.merge(:on => :next)) } + + # Persist the state on the order + after_transition do |order| + order.state = order.state + order.save + end + + event :cancel do + transition :to => :canceled, :if => :allow_cancel? + end + + event :return do + transition :to => :returned, :from => :awaiting_return, :unless => :awaiting_returns? + end + + event :resume do + transition :to => :resumed, :from => :canceled, :if => :allow_resume? + end + + event :authorize_return do + transition :to => :awaiting_return + end + + if states[:payment] + before_transition :to => :complete do |order| + order.process_payments! if order.payment_required? + end + end + + before_transition :from => :cart, :do => :ensure_line_items_present + + before_transition :to => :delivery, :do => :create_proposed_shipments + before_transition :to => :delivery, :do => :ensure_available_shipping_rates + + after_transition :to => :complete, :do => :finalize! + after_transition :to => :delivery, :do => :create_tax_charge! + after_transition :to => :resumed, :do => :after_resume + after_transition :to => :canceled, :do => :after_cancel + end + end + + def self.go_to_state(name, options={}) + self.checkout_steps[name] = options + previous_states.each do |state| + add_transition({:from => state, :to => name}.merge(options)) + end + if options[:if] + self.previous_states << name + else + self.previous_states = [name] + end + end + + def self.insert_checkout_step(name, options = {}) + before = options.delete(:before) + after = options.delete(:after) unless before + after = self.checkout_steps.keys.last unless before || after + + cloned_steps = self.checkout_steps.clone + cloned_removed_transitions = self.removed_transitions.clone + self.checkout_flow do + cloned_steps.each_pair do |key, value| + self.go_to_state(name, options) if key == before + self.go_to_state(key, value) + self.go_to_state(name, options) if key == after + end + cloned_removed_transitions.each do |transition| + self.remove_transition(transition) + end + end + end + + def self.remove_checkout_step(name) + cloned_steps = self.checkout_steps.clone + cloned_removed_transitions = self.removed_transitions.clone + self.checkout_flow do + cloned_steps.each_pair do |key, value| + self.go_to_state(key, value) unless key == name + end + cloned_removed_transitions.each do |transition| + self.remove_transition(transition) + end + end + end + + def self.remove_transition(options={}) + self.removed_transitions << options + self.next_event_transitions.delete(find_transition(options)) + end + + def self.find_transition(options={}) + return nil if options.nil? || !options.include?(:from) || !options.include?(:to) + self.next_event_transitions.detect do |transition| + transition[options[:from].to_sym] == options[:to].to_sym + end + end + + def self.next_event_transitions + @next_event_transitions ||= [] + end + + def self.checkout_steps + @checkout_steps ||= {} + end + + def self.add_transition(options) + self.next_event_transitions << { options.delete(:from) => options.delete(:to) }.merge(options) + end + + def checkout_steps + steps = self.class.checkout_steps.each_with_object([]) { |(step, options), checkout_steps| + next if options.include?(:if) && !options[:if].call(self) + checkout_steps << step + }.map(&:to_s) + # Ensure there is always a complete step + steps << "complete" unless steps.include?("complete") + steps + end + + def has_checkout_step?(step) + step.present? ? self.checkout_steps.include?(step) : false + end + + def checkout_step_index(step) + self.checkout_steps.index(step) + end + + def self.removed_transitions + @removed_transitions ||= [] + end + + def can_go_to_state?(state) + return false unless self.state.present? && has_checkout_step?(state) && has_checkout_step?(self.state) + checkout_step_index(state) > checkout_step_index(self.state) + end + end + end + end + end +end diff --git a/spec/models/spree/order/checkout_spec.rb b/spec/models/spree/order/checkout_spec.rb index 8eed383c6a..4cc9ca5101 100644 --- a/spec/models/spree/order/checkout_spec.rb +++ b/spec/models/spree/order/checkout_spec.rb @@ -1,6 +1,337 @@ require 'spec_helper' describe Spree::Order do + let(:order) { Spree::Order.new } + + context "with default state machine" do + let(:transitions) do + [ + { :address => :delivery }, + { :delivery => :payment }, + { :payment => :confirm }, + { :confirm => :complete }, + { :payment => :complete }, + { :delivery => :complete } + ] + end + + it "has the following transitions" do + transitions.each do |transition| + transition = Spree::Order.find_transition(:from => transition.keys.first, :to => transition.values.first) + transition.should_not be_nil + end + end + + it "does not have a transition from delivery to confirm" do + transition = Spree::Order.find_transition(:from => :delivery, :to => :confirm) + transition.should be_nil + end + + it '.find_transition when contract was broken' do + Spree::Order.find_transition({foo: :bar, baz: :dog}).should be_false + end + + it '.remove_transition' do + options = {:from => transitions.first.keys.first, :to => transitions.first.values.first} + Spree::Order.stub(:next_event_transition).and_return([options]) + Spree::Order.remove_transition(options).should be_true + end + + it '.remove_transition when contract was broken' do + Spree::Order.remove_transition(nil).should be_false + end + + context "#checkout_steps" do + context "when confirmation not required" do + before do + order.stub :confirmation_required? => false + order.stub :payment_required? => true + end + + specify do + order.checkout_steps.should == %w(address delivery payment complete) + end + end + + context "when confirmation required" do + before do + order.stub :confirmation_required? => true + order.stub :payment_required? => true + end + + specify do + order.checkout_steps.should == %w(address delivery payment confirm complete) + end + end + + context "when payment not required" do + before { order.stub :payment_required? => false } + specify do + order.checkout_steps.should == %w(address delivery complete) + end + end + + context "when payment required" do + before { order.stub :payment_required? => true } + specify do + order.checkout_steps.should == %w(address delivery payment complete) + end + end + end + + it "starts out at cart" do + order.state.should == "cart" + end + + it "transitions to address" do + order.line_items << FactoryGirl.create(:line_item) + order.email = "user@example.com" + order.next! + order.state.should == "address" + end + + it "cannot transition to address without any line items" do + order.line_items.should be_blank + lambda { order.next! }.should raise_error(StateMachine::InvalidTransition, /#{Spree.t(:there_are_no_items_for_this_order)}/) + end + + context "from address" do + before do + order.state = 'address' + order.stub(:has_available_payment) + shipment = FactoryGirl.create(:shipment, :order => order) + order.email = "user@example.com" + order.save! + end + + it "transitions to delivery" do + order.stub(:ensure_available_shipping_rates => true) + order.next! + order.state.should == "delivery" + end + + context "cannot transition to delivery" do + context "if there are no shipping rates for any shipment" do + specify do + transition = lambda { order.next! } + transition.should raise_error(StateMachine::InvalidTransition, /#{Spree.t(:items_cannot_be_shipped)}/) + end + end + end + end + + context "from delivery" do + before do + order.state = 'delivery' + end + + context "with payment required" do + before do + order.stub :payment_required? => true + end + + it "transitions to payment" do + order.next! + order.state.should == 'payment' + end + end + + context "without payment required" do + before do + order.stub :payment_required? => false + end + + it "transitions to complete" do + order.next! + order.state.should == "complete" + end + end + end + + context "from payment" do + before do + order.state = 'payment' + end + + context "with confirmation required" do + before do + order.stub :confirmation_required? => true + end + + it "transitions to confirm" do + order.next! + order.state.should == "confirm" + end + end + + context "without confirmation required" do + before do + order.stub :confirmation_required? => false + order.stub :payment_required? => true + end + + it "transitions to complete" do + order.should_receive(:process_payments!).once.and_return true + order.next! + order.state.should == "complete" + end + end + + # Regression test for #2028 + context "when payment is not required" do + before do + order.stub :payment_required? => false + end + + it "does not call process payments" do + order.should_not_receive(:process_payments!) + order.next! + order.state.should == "complete" + end + end + end + end + + context "subclassed order" do + # This causes another test above to fail, but fixing this test should make + # the other test pass + class SubclassedOrder < Spree::Order + checkout_flow do + go_to_state :payment + go_to_state :complete + end + end + + it "should only call default transitions once when checkout_flow is redefined" do + order = SubclassedOrder.new + order.stub :payment_required? => true + order.should_receive(:process_payments!).once + order.state = "payment" + order.next! + order.state.should == "complete" + end + end + + context "re-define checkout flow" do + before do + @old_checkout_flow = Spree::Order.checkout_flow + Spree::Order.class_eval do + checkout_flow do + go_to_state :payment + go_to_state :complete + end + end + end + + after do + Spree::Order.checkout_flow(&@old_checkout_flow) + end + + it "should not keep old event transitions when checkout_flow is redefined" do + Spree::Order.next_event_transitions.should == [{:cart=>:payment}, {:payment=>:complete}] + end + + it "should not keep old events when checkout_flow is redefined" do + state_machine = Spree::Order.state_machine + state_machine.states.any? { |s| s.name == :address }.should be_false + known_states = state_machine.events[:next].branches.map(&:known_states).flatten + known_states.should_not include(:address) + known_states.should_not include(:delivery) + known_states.should_not include(:confirm) + end + end + + # Regression test for #3665 + context "with only a complete step" do + before do + @old_checkout_flow = Spree::Order.checkout_flow + Spree::Order.class_eval do + checkout_flow do + go_to_state :complete + end + end + end + + after do + Spree::Order.checkout_flow(&@old_checkout_flow) + end + + it "does not attempt to process payments" do + order.stub_chain(:line_items, :present?).and_return(true) + order.should_not_receive(:payment_required?) + order.should_not_receive(:process_payments!) + order.next! + end + + end + + context "insert checkout step" do + before do + @old_checkout_flow = Spree::Order.checkout_flow + Spree::Order.class_eval do + insert_checkout_step :new_step, before: :address + end + end + + after do + Spree::Order.checkout_flow(&@old_checkout_flow) + end + + it "should maintain removed transitions" do + transition = Spree::Order.find_transition(:from => :delivery, :to => :confirm) + transition.should be_nil + end + + context "before" do + before do + Spree::Order.class_eval do + insert_checkout_step :before_address, before: :address + end + end + + specify do + order = Spree::Order.new + order.checkout_steps.should == %w(new_step before_address address delivery complete) + end + end + + context "after" do + before do + Spree::Order.class_eval do + insert_checkout_step :after_address, after: :address + end + end + + specify do + order = Spree::Order.new + order.checkout_steps.should == %w(new_step address after_address delivery complete) + end + end + end + + context "remove checkout step" do + before do + @old_checkout_flow = Spree::Order.checkout_flow + Spree::Order.class_eval do + remove_checkout_step :address + end + end + + after do + Spree::Order.checkout_flow(&@old_checkout_flow) + end + + it "should maintain removed transitions" do + transition = Spree::Order.find_transition(:from => :delivery, :to => :confirm) + transition.should be_nil + end + + specify do + order = Spree::Order.new + order.checkout_steps.should == %w(delivery complete) + end + end + describe 'event :restart_checkout' do let(:order) { create(:order) }