require 'spec_helper' # This is the first example of testing concurrency in the Open Food Network. # If we want to do this more often, we should look at: # # https://github.com/forkbreak/fork_break # # The concurrency flag enables multiple threads to see the same database # without isolated transactions. describe CheckoutController, concurrency: true, type: :controller do let(:order_cycle) { create(:order_cycle) } let(:distributor) { order_cycle.distributors.first } let(:order) { create(:order, order_cycle: order_cycle, distributor: distributor) } let(:address) { create(:address) } let(:payment_method) { create(:payment_method, distributors: [distributor]) } let(:breakpoint) { Mutex.new } before do # Create a valid order ready for checkout: create(:shipping_method, distributors: [distributor]) variant = order_cycle.variants_distributed_by(distributor).first order.line_items << create(:line_item, variant: variant) # Set up controller environment: session[:order_id] = order.id allow(controller).to receive(:spree_current_user).and_return(order.user) allow(controller).to receive(:current_distributor).and_return(order.distributor) allow(controller).to receive(:current_order_cycle).and_return(order.order_cycle) # New threads start running straight away. The breakpoint is after loading # the order and before advancing the order's state and making payments. breakpoint.lock expect(controller).to receive(:fire_event).with("spree.checkout.update") do breakpoint.synchronize {} # This is what fire_event does. # I did not find out how to call the original code otherwise. ActiveSupport::Notifications.instrument("spree.checkout.update") end end it "waits for concurrent checkouts" do # Basic data the user submits during checkout: address_params = address.attributes.except("id") order_params = { "payments_attributes" => [ { "payment_method_id" => payment_method.id, "amount" => order.total } ], "bill_address_attributes" => address_params, "ship_address_attributes" => address_params, } # Starting two checkout threads. The controller code will determine if # these two threads are synchronised correctly or run into a race condition. # # 1. If the controller synchronises correctly: # The first thread locks required resources and then waits at the # breakpoint. The second thread waits for the first one. # 2. If the controller fails to prevent the race condition: # Both threads load required resources and wait at the breakpoint to do # the same checkout action. This will lead to (random) errors. # # I observed: # ActiveRecord::RecordNotUnique: duplicate key value violates unique # constraint "index_spree_shipments_on_order_id" # on `INSERT INTO "spree_shipments" ...`. # # Or: # ActiveRecord::InvalidForeignKey: insert or update on table # "spree_orders" violates foreign key constraint # "spree_orders_customer_id_fk" threads = [ Thread.new { spree_post :update, format: :json, order: order_params }, Thread.new { spree_post :update, format: :json, order: order_params }, ] # Let the threads run again. They should not be in a race condition. breakpoint.unlock # Wait for both threads to finish. threads.each(&:join) order.reload # When the spec passes, both threads have the same result. The user should # see the order page. This is basically verifying a "double click" # scenario. expect(response.status).to eq(200) expect(response.body).to eq({ path: spree.order_path(order) }.to_json) expect(order.payments.count).to eq 1 expect(order.completed?).to be true end end