# frozen_string_literal: true
# rubocop:todo all

require 'spec_helper'

# The tests raise OperationFailure in socket reads. This is done for
# convenience to make the tests uniform between socket errors and operation
# failures; in reality a socket read will never raise OperationFailure as
# wire protocol parsing code raises this exception. For the purposes of
# testing retryable writes, it is acceptable to raise OperationFailure in
# socket reads because both exceptions end up getting handled in the same
# place by retryable writes code. The SDAM error handling test specifically
# checks server state (i.e. being marked unknown) and scanning behavior
# that is performed by the wire protocol code; this test omits scan assertions
# as otherwise it quickly becomes unwieldy.
describe 'Retryable writes integration tests' do
  include PrimarySocket

  require_wired_tiger_on_36

  # These tests override server selector, which fails if there are multiple
  # eligible servers as would be the case in a multi-shard sharded cluster
  require_no_multi_mongos

  # Note: these tests are deprecated in favor of the tests in the file
  # spec/integration/retryable_writes/retryable_writes_40_and_newer_spec.rb
  # If you are changing functionality in the driver that only impacts server
  # versions 4.0 or newer, test that functionality in the other test file.
  max_server_fcv '3.6'

  before do
    authorized_collection.delete_many
  end

  let(:check_collection) do
    # Verify data in the collection using another client instance to avoid
    # having the verification read trigger cluster scans on the writing client
    root_authorized_client[TEST_COLL]
  end

  let(:primary_connection) do
    client.database.command(ping: 1)
    expect(primary_server.pool.size).to eq(1)
    expect(primary_server.pool.available_count).to eq(1)
    primary_server.pool.instance_variable_get('@available_connections').last
  end

  shared_examples_for 'an operation that is retried' do

    context 'when the operation fails on the first attempt and succeeds on the second attempt' do

      before do
        wait_for_all_servers(client.cluster)

        allow(primary_socket).to receive(:do_write).and_raise(error.dup)
      end

      context 'when the error is retryable' do

        before do
          expect(Mongo::Logger.logger).to receive(:warn).once.and_call_original
        end

        context 'when the error is a socket error' do

          let(:error) do
            IOError.new('first error')
          end

          it 'retries writes' do
            operation
            expect(expectation).to eq(successful_retry_value)
          end
        end

        context 'when the error is a socket timeout error' do

          let(:error) do
            Errno::ETIMEDOUT.new
          end

          it 'retries writes' do
            operation
            expect(expectation).to eq(successful_retry_value)
          end
        end

        context 'when the error is a retryable OperationFailure' do

          let(:error) do
            Mongo::Error::OperationFailure.new('not master')
          end

          let(:reply) do
            make_not_master_reply
          end

          it 'retries writes' do
            operation
            expect(expectation).to eq(successful_retry_value)
          end
        end
      end

      context 'when the error is not retryable' do

        context 'when the error is a non-retryable OperationFailure' do

          let(:error) do
            Mongo::Error::OperationFailure.new('other error', code: 123)
          end

          it 'does not retry writes' do
            expect do
              operation
            end.to raise_error(Mongo::Error::OperationFailure, /other error/)
            expect(expectation).to eq(unsuccessful_retry_value)
          end

          it 'indicates server used for operation' do
            expect do
              operation
            end.to raise_error(Mongo::Error::OperationFailure, /on #{ClusterConfig.instance.primary_address_str}/)
          end

          it 'indicates first attempt' do
            expect do
              operation
            end.to raise_error(Mongo::Error::OperationFailure, /attempt 1/)
          end
        end
      end
    end

    context 'when the operation fails on the first attempt and again on the second attempt' do

      before do
        allow(primary_socket).to receive(:do_write).and_raise(error.dup)
      end

      context 'when the selected server does not support retryable writes' do

        before do
          legacy_primary = double('legacy primary', :retry_writes? => false)
          expect(collection).to receive(:select_server).and_return(primary_server, legacy_primary)
          expect(primary_socket).to receive(:do_write).and_raise(error.dup)
        end

        context 'when the error is a socket error' do

          let(:error) do
            IOError.new('first error')
          end

          let(:exposed_error_class) do
            Mongo::Error::SocketError
          end

          it 'does not retry writes and raises the original error' do
            expect do
              operation
            end.to raise_error(exposed_error_class, /first error/)
            expect(expectation).to eq(unsuccessful_retry_value)
          end
        end

        context 'when the error is a socket timeout error' do

          let(:error) do
            Errno::ETIMEDOUT.new('first error')
          end

          it 'does not retry writes and raises the original error' do
            expect do
              operation
            # The exception message is different because of added diagnostics.
            end.to raise_error(Mongo::Error::SocketTimeoutError, /first error/)
            expect(expectation).to eq(unsuccessful_retry_value)
          end
        end

        context 'when the error is a retryable OperationFailure' do

          let(:error) do
            Mongo::Error::OperationFailure.new('not master')
          end

          it 'does not retry writes and raises the original error' do
            expect do
              operation
            end.to raise_error(Mongo::Error::OperationFailure, /not master/)
            expect(expectation).to eq(unsuccessful_retry_value)
          end
        end
      end

      [
        [IOError, 'first error', Mongo::Error::SocketError],
        [Errno::ETIMEDOUT, 'first error', Mongo::Error::SocketTimeoutError],
        [Mongo::Error::OperationFailure, 'first error: not master', Mongo::Error::OperationFailure],
        [Mongo::Error::OperationFailure, 'first error: node is recovering', Mongo::Error::OperationFailure],
      ].each do |error_cls, error_msg, exposed_first_error_class|
        # Note: actual exception instances must be different between tests

        context "when the first error is a #{error_cls}/#{error_msg}" do

          let(:error) do
            error_cls.new(error_msg)
          end

          before do
            wait_for_all_servers(client.cluster)
            bad_socket = primary_connection.address.socket(primary_connection.socket_timeout,
                                                           primary_connection.send(:ssl_options))
            good_socket = primary_connection.address.socket(primary_connection.socket_timeout,
                                                            primary_connection.send(:ssl_options))
            allow(bad_socket).to receive(:do_write).and_raise(second_error.dup)
            allow(primary_connection.address).to receive(:socket).and_return(bad_socket, good_socket)
          end

          context 'when the second error is a socket error' do

            let(:second_error) do
              IOError.new('second error')
            end

            let(:exposed_error_class) do
              Mongo::Error::SocketError
            end

            it 'raises the second error' do
              expect do
                operation
              end.to raise_error(exposed_error_class, /second error/)
              expect(expectation).to eq(unsuccessful_retry_value)
            end

            it 'indicates server used for operation' do
              expect do
                operation
              end.to raise_error(Mongo::Error, /on #{ClusterConfig.instance.primary_address_str}/)
            end

            it 'indicates second attempt' do
              expect do
                operation
              end.to raise_error(Mongo::Error, /attempt 2/)
            end
          end

          context 'when the second error is a socket timeout error' do

            let(:second_error) do
              Errno::ETIMEDOUT.new('second error')
            end

            let(:exposed_error_class) do
              Mongo::Error::SocketTimeoutError
            end

            it 'raises the second error' do
              expect do
                operation
              end.to raise_error(exposed_error_class, /second error/)
              expect(expectation).to eq(unsuccessful_retry_value)
            end
          end

          context 'when the second error is a retryable OperationFailure' do

            let(:second_error) do
              Mongo::Error::OperationFailure.new('second error: not master')
            end

            it 'raises the second error' do
              expect do
                operation
              end.to raise_error(Mongo::Error, /second error: not master/)
              expect(expectation).to eq(unsuccessful_retry_value)
            end
          end

          context 'when the second error is a non-retryable OperationFailure' do

            let(:second_error) do
              Mongo::Error::OperationFailure.new('other error')
            end

            it 'does not retry writes and raises the first error' do
              expect do
                operation
              end.to raise_error(exposed_first_error_class, /first error/)
              expect(expectation).to eq(unsuccessful_retry_value)
            end
          end

          # The driver shouldn't be producing non-Mongo::Error derived errors,
          # but if those are produced (like ArgumentError), they would be
          # immediately propagated to the application.
          context 'when the second error is another error' do

            let(:second_error) do
              StandardError.new('second error')
            end

            it 'raises the second error' do
              expect do
                operation
              end.to raise_error(StandardError, /second error/)
              expect(expectation).to eq(unsuccessful_retry_value)
            end
          end
        end
      end
    end
  end

  shared_examples_for 'an operation that is not retried' do

    let!(:client) do
      authorized_client_without_retry_writes
    end

    before do
      expect(primary_socket).to receive(:do_write).exactly(:once).and_raise(Mongo::Error::SocketError)
    end

    it 'does not retry writes' do
      expect do
        operation
      end.to raise_error(Mongo::Error::SocketError)
      expect(expectation).to eq(unsuccessful_retry_value)
    end
  end

  shared_examples_for 'an operation that does not support retryable writes' do

    let!(:client) do
      authorized_client_with_retry_writes
    end

    let!(:collection) do
      client[TEST_COLL]
    end

    before do
      expect(primary_socket).to receive(:do_write).and_raise(Mongo::Error::SocketError)
    end

    it 'does not retry writes' do
      expect do
        operation
      end.to raise_error(Mongo::Error::SocketError)
      expect(expectation).to eq(unsuccessful_retry_value)
    end
  end

  shared_examples_for 'operation that is retried when server supports retryable writes' do
    context 'when the server supports retryable writes' do
      min_server_fcv '3.6'

      before do
        allow(primary_server).to receive(:retry_writes?).and_return(true)
      end

      context 'standalone' do
        require_topology :single

        it_behaves_like 'an operation that is not retried'
      end

      context 'replica set or sharded cluster' do
        require_topology :replica_set, :sharded

        it_behaves_like 'an operation that is retried'
      end
    end

    context 'when the server does not support retryable writes' do

      before do
        allow(primary_server).to receive(:retry_writes?).and_return(false)
      end

      it_behaves_like 'an operation that is not retried'
    end
  end

  shared_examples_for 'supported retryable writes' do

    context 'when the client has retry_writes set to true' do

      let!(:client) do
        authorized_client_with_retry_writes
      end

      context 'when the collection has write concern acknowledged' do

        let!(:collection) do
          client[TEST_COLL, write: {w: :majority}]
        end

        it_behaves_like 'operation that is retried when server supports retryable writes'
      end

      context 'when the collection has write concern unacknowledged' do

        let!(:collection) do
          client[TEST_COLL, write: { w: 0 }]
        end

        it_behaves_like 'an operation that is not retried'
      end
    end

    context 'when the client has retry_writes set to false' do

      let!(:client) do
        authorized_client_without_retry_writes
      end

      context 'when the collection has write concern acknowledged' do

        let!(:collection) do
          client[TEST_COLL, write: {w: :majority}]
        end

        it_behaves_like 'an operation that is not retried'
      end

      context 'when the collection has write concern unacknowledged' do

        let!(:collection) do
          client[TEST_COLL, write: { w: 0 }]
        end

        it_behaves_like 'an operation that is not retried'
      end

      context 'when the collection has write concern not set' do

        let!(:collection) do
          client[TEST_COLL]
        end

        it_behaves_like 'an operation that is not retried'
      end
    end
  end

  context 'when the operation is insert_one' do

    let(:operation) do
      collection.insert_one(a:1)
    end

    let(:expectation) do
      check_collection.find(a: 1).count
    end

    let(:successful_retry_value) do
      1
    end

    let(:unsuccessful_retry_value) do
      0
    end

    it_behaves_like 'supported retryable writes'
  end

  context 'when the operation is update_one' do

    before do
      # Account for when the collection has unacknowledged write concern and use authorized_collection here.
      authorized_collection.insert_one(a:0)
    end

    let(:operation) do
      collection.update_one({ a: 0 }, { '$set' => { a: 1 } })
    end

    let(:expectation) do
      check_collection.find(a: 1).count
    end

    let(:successful_retry_value) do
      1
    end

    let(:unsuccessful_retry_value) do
      0
    end

    it_behaves_like 'supported retryable writes'
  end

  context 'when the operation is replace_one' do

    before do
      # Account for when the collection has unacknowledged write concern and use authorized_collection here.
      authorized_collection.insert_one(a:0)
    end

    let(:operation) do
      collection.replace_one({ a: 0 }, { a: 1 })
    end

    let(:expectation) do
      check_collection.find(a: 1).count
    end

    let(:successful_retry_value) do
      1
    end

    let(:unsuccessful_retry_value) do
      0
    end

    it_behaves_like 'supported retryable writes'
  end

  context 'when the operation is delete_one' do

    before do
      # Account for when the collection has unacknowledged write concern and use authorized_collection here.
      authorized_collection.insert_one(a:1)
    end

    let(:operation) do
      collection.delete_one(a:1)
    end

    let(:expectation) do
      check_collection.find(a: 1).count
    end

    let(:successful_retry_value) do
      0
    end

    let(:unsuccessful_retry_value) do
      1
    end

    it_behaves_like 'supported retryable writes'
  end

  context 'when the operation is find_one_and_update' do

    before do
      # Account for when the collection has unacknowledged write concern and use authorized_collection here.
      authorized_collection.insert_one(a:0)
    end

    let(:operation) do
      collection.find_one_and_update({ a: 0 }, { '$set' => { a: 1 } })
    end

    let(:expectation) do
      check_collection.find(a: 1).count
    end

    let(:successful_retry_value) do
      1
    end

    let(:unsuccessful_retry_value) do
      0
    end

    it_behaves_like 'supported retryable writes'
  end

  context 'when the operation is find_one_and_replace' do

    before do
      # Account for when the collection has unacknowledged write concern and use authorized_collection here.
      authorized_collection.insert_one(a:0)
    end

    let(:operation) do
      collection.find_one_and_replace({ a: 0 }, { a: 3 })
    end

    let(:expectation) do
      check_collection.find(a: 3).count
    end

    let(:successful_retry_value) do
      1
    end

    let(:unsuccessful_retry_value) do
      0
    end

    it_behaves_like 'supported retryable writes'
  end

  context 'when the operation is find_one_and_delete' do

    before do
      # Account for when the collection has unacknowledged write concern and use authorized_collection here.
      authorized_collection.insert_one(a:1)
    end

    let(:operation) do
      collection.find_one_and_delete({ a: 1 })
    end

    let(:expectation) do
      check_collection.find(a: 1).count
    end

    let(:successful_retry_value) do
      0
    end

    let(:unsuccessful_retry_value) do
      1
    end

    it_behaves_like 'supported retryable writes'
  end

  context 'when the operation is update_many' do

    before do
      # Account for when the collection has unacknowledged write concern and use authorized_collection here.
      authorized_collection.insert_one(a:0)
      authorized_collection.insert_one(a:0)
    end

    let(:operation) do
      collection.update_many({ a: 0 }, { '$set' => { a: 1 } })
    end

    let(:expectation) do
      check_collection.find(a: 1).count
    end

    let(:unsuccessful_retry_value) do
      0
    end

    it_behaves_like 'an operation that does not support retryable writes'
  end

  context 'when the operation is delete_many' do

    before do
      # Account for when the collection has unacknowledged write concern and use authorized_collection here.
      authorized_collection.insert_one(a:1)
      authorized_collection.insert_one(a:1)
    end

    let(:operation) do
      collection.delete_many(a: 1)
    end

    let(:expectation) do
      check_collection.find(a: 1).count
    end

    let(:unsuccessful_retry_value) do
      2
    end

    it_behaves_like 'an operation that does not support retryable writes'
  end

  context 'when the operation is a bulk write' do

    before do
      # Account for when the collection has unacknowledged write concern and use authorized_collection here.
      authorized_collection.insert_one(a: 1)
    end

    let(:operation) do
      collection.bulk_write([{ delete_one: { filter: { a: 1 } } },
                             { insert_one: { a: 1 } },
                             { insert_one: { a: 1 } }])
    end

    let(:expectation) do
      check_collection.find(a: 1).count
    end

    let(:successful_retry_value) do
      2
    end

    let(:unsuccessful_retry_value) do
      1
    end

    it_behaves_like 'supported retryable writes'
  end

  context 'when the operation is bulk write including delete_many' do

    before do
      # Account for when the collection has unacknowledged write concern and use authorized_collection here.
      authorized_collection.insert_one(a:1)
      authorized_collection.insert_one(a:1)
    end

    let(:operation) do
      collection.bulk_write([{ delete_many: { filter: { a: 1 } } }])
    end

    let(:expectation) do
      check_collection.find(a: 1).count
    end

    let(:unsuccessful_retry_value) do
      2
    end

    it_behaves_like 'an operation that does not support retryable writes'
  end

  context 'when the operation is bulk write including update_many' do

    before do
      # Account for when the collection has unacknowledged write concern and use authorized_collection here.
      authorized_collection.insert_one(a:0)
      authorized_collection.insert_one(a:0)
    end

    let(:operation) do
      collection.bulk_write([{ update_many: { filter: { a: 0 }, update: { "$set" => { a: 1 } } } }])
    end

    let(:expectation) do
      check_collection.find(a: 1).count
    end

    let(:unsuccessful_retry_value) do
      0
    end

    it_behaves_like 'an operation that does not support retryable writes'
  end

  context 'when the operation is database#command' do

    let(:operation) do
      collection.database.command(ping: 1)
    end

    let(:expectation) do
      0
    end

    let(:unsuccessful_retry_value) do
      0
    end

    it_behaves_like 'an operation that does not support retryable writes'
  end
end
