# frozen_string_literal: true

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.
RSpec.describe "Concurrent checkouts", concurrency: true do
  include AuthenticationHelper
  include ShopWorkflow

  let(:order_cycle) { create(:order_cycle) }
  let(:distributor) { order_cycle.distributors.first }
  let(:order) { create(:order, order_cycle:, distributor:) }
  let(:address) { create(:address) }
  let(:payment_method) { create(:payment_method, distributors: [distributor]) }
  let(:breakpoint) { Mutex.new }

  let(:address_params) { address.attributes.except("id") }
  let(:order_params) {
    {
      "payments_attributes" => [
        {
          "payment_method_id" => payment_method.id,
          "amount" => order.total
        }
      ],
      "bill_address_attributes" => address_params,
      "ship_address_attributes" => address_params,
    }
  }
  let(:path) { checkout_update_path(:summary) }
  let(:params) { { format: :json } }

  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:)

    # Transition cart to confirmation state:
    order.update(order_params)
    order.next # => address
    order.next # => delivery
    order.next # => payment
    order.next # => confirmation

    pick_order(order)
    login_as(order.user)
  end

  it "handles two concurrent orders successfully" do
    breakpoint.lock
    breakpoint_reached_counter = 0

    # Set a breakpoint after loading the order and before advancing the order's
    # state and making payments. If two requests reach this breakpoint at the
    # same time, they are in a race condition and bad things can happen.
    # Examples are processing payments twice or selling more than we have.
    allow_any_instance_of(CheckoutController).
      to receive(:advance_order_state).
      and_wrap_original do |method, *args|
      breakpoint_reached_counter += 1
      breakpoint.synchronize do
        # Wait here until the breakpoint is unlocked.
        # Hopefully only one thread gets here in that time.
        # The second thread is told by the controller to wait before
        # loading the order.
      end
      method.call(*args)
    end

    # 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.
    threads = [
      Thread.new { put(path, params:) },
      Thread.new { put(path, params:) },
    ]

    # Wait for the first thread to reach the breakpoint:
    Timeout.timeout(1) do
      sleep 0.1 while breakpoint_reached_counter < 1
    end

    # Give the second thread a chance to reach the breakpoint, too.
    # But we hope that it waits for the first thread earlier and doesn't
    # reach the breakpoint yet.
    sleep 1
    expect(breakpoint_reached_counter).to eq 1

    # Let the requests continue and finish.
    breakpoint.unlock
    threads.each(&:join)

    # Verify that the checkout happened once.
    order.reload
    expect(order.completed?).to be true
    expect(order.payments.count).to eq 1
  end
end
