require 'spec_helper'

describe Ci::BuildTraceChunk, :clean_gitlab_redis_shared_state do
  set(:build) { create(:ci_build, :running) }
  let(:chunk_index) { 0 }
  let(:data_store) { :redis }
  let(:raw_data) { nil }

  let(:build_trace_chunk) do
    described_class.new(build: build, chunk_index: chunk_index, data_store: data_store, raw_data: raw_data)
  end

  before do
    stub_feature_flags(ci_enable_live_trace: true)
  end

  context 'FastDestroyAll' do
    let(:parent) { create(:project) }
    let(:pipeline) { create(:ci_pipeline, project: parent) }
    let(:build) { create(:ci_build, :running, :trace_live, pipeline: pipeline, project: parent) }
    let(:subjects) { build.trace_chunks }

    it_behaves_like 'fast destroyable'

    def external_data_counter
      Gitlab::Redis::SharedState.with do |redis|
        redis.scan_each(match: "gitlab:ci:trace:*:chunks:*").to_a.size
      end
    end
  end

  describe 'CHUNK_SIZE' do
    it 'Chunk size can not be changed without special care' do
      expect(described_class::CHUNK_SIZE).to eq(128.kilobytes)
    end
  end

  describe '#data' do
    subject { build_trace_chunk.data }

    context 'when data_store is redis' do
      let(:data_store) { :redis }

      before do
        build_trace_chunk.send(:redis_set_data, 'Sample data in redis')
      end

      it { is_expected.to eq('Sample data in redis') }
    end

    context 'when data_store is database' do
      let(:data_store) { :db }
      let(:raw_data) { 'Sample data in db' }

      it { is_expected.to eq('Sample data in db') }
    end
  end

  describe '#set_data' do
    subject { build_trace_chunk.send(:set_data, value) }

    let(:value) { 'Sample data' }

    context 'when value bytesize is bigger than CHUNK_SIZE' do
      let(:value) { 'a' * (described_class::CHUNK_SIZE + 1) }

      it { expect { subject }.to raise_error('too much data') }
    end

    context 'when data_store is redis' do
      let(:data_store) { :redis }

      it do
        expect(build_trace_chunk.send(:redis_data)).to be_nil

        subject

        expect(build_trace_chunk.send(:redis_data)).to eq(value)
      end

      context 'when fullfilled chunk size' do
        let(:value) { 'a' * described_class::CHUNK_SIZE }

        it 'schedules stashing data' do
          expect(Ci::BuildTraceChunkFlushWorker).to receive(:perform_async).once

          subject
        end
      end
    end

    context 'when data_store is database' do
      let(:data_store) { :db }

      it 'sets data' do
        expect(build_trace_chunk.raw_data).to be_nil

        subject

        expect(build_trace_chunk.raw_data).to eq(value)
        expect(build_trace_chunk.persisted?).to be_truthy
      end

      context 'when raw_data is not changed' do
        it 'does not execute UPDATE' do
          expect(build_trace_chunk.raw_data).to be_nil
          build_trace_chunk.save!

          # First set
          expect(ActiveRecord::QueryRecorder.new { subject }.count).to be > 0
          expect(build_trace_chunk.raw_data).to eq(value)
          expect(build_trace_chunk.persisted?).to be_truthy

          # Second set
          build_trace_chunk.reload
          expect(ActiveRecord::QueryRecorder.new { subject }.count).to be(0)
        end
      end

      context 'when fullfilled chunk size' do
        it 'does not schedule stashing data' do
          expect(Ci::BuildTraceChunkFlushWorker).not_to receive(:perform_async)

          subject
        end
      end
    end

    context 'when data_store is others' do
      before do
        build_trace_chunk.send(:write_attribute, :data_store, -1)
      end

      it { expect { subject }.to raise_error('Unsupported data store') }
    end
  end

  describe '#truncate' do
    subject { build_trace_chunk.truncate(offset) }

    shared_examples_for 'truncates' do
      context 'when offset is negative' do
        let(:offset) { -1 }

        it { expect { subject }.to raise_error('Offset is out of range') }
      end

      context 'when offset is bigger than data size' do
        let(:offset) { data.bytesize + 1 }

        it { expect { subject }.to raise_error('Offset is out of range') }
      end

      context 'when offset is 10' do
        let(:offset) { 10 }

        it 'truncates' do
          subject

          expect(build_trace_chunk.data).to eq(data.byteslice(0, offset))
        end
      end
    end

    context 'when data_store is redis' do
      let(:data_store) { :redis }
      let(:data) { 'Sample data in redis' }

      before do
        build_trace_chunk.send(:redis_set_data, data)
      end

      it_behaves_like 'truncates'
    end

    context 'when data_store is database' do
      let(:data_store) { :db }
      let(:raw_data) { 'Sample data in db' }
      let(:data) { raw_data }

      it_behaves_like 'truncates'
    end
  end

  describe '#append' do
    subject { build_trace_chunk.append(new_data, offset) }

    let(:new_data) { 'Sample new data' }
    let(:offset) { 0 }
    let(:total_data) { data + new_data }

    shared_examples_for 'appends' do
      context 'when offset is negative' do
        let(:offset) { -1 }

        it { expect { subject }.to raise_error('Offset is out of range') }
      end

      context 'when offset is bigger than data size' do
        let(:offset) { data.bytesize + 1 }

        it { expect { subject }.to raise_error('Offset is out of range') }
      end

      context 'when offset is bigger than data size' do
        let(:new_data) { 'a' * (described_class::CHUNK_SIZE + 1) }

        it { expect { subject }.to raise_error('Chunk size overflow') }
      end

      context 'when offset is EOF' do
        let(:offset) { data.bytesize }

        it 'appends' do
          subject

          expect(build_trace_chunk.data).to eq(total_data)
        end
      end

      context 'when offset is 10' do
        let(:offset) { 10 }

        it 'appends' do
          subject

          expect(build_trace_chunk.data).to eq(data.byteslice(0, offset) + new_data)
        end
      end
    end

    context 'when data_store is redis' do
      let(:data_store) { :redis }
      let(:data) { 'Sample data in redis' }

      before do
        build_trace_chunk.send(:redis_set_data, data)
      end

      it_behaves_like 'appends'
    end

    context 'when data_store is database' do
      let(:data_store) { :db }
      let(:raw_data) { 'Sample data in db' }
      let(:data) { raw_data }

      it_behaves_like 'appends'
    end
  end

  describe '#size' do
    subject { build_trace_chunk.size }

    context 'when data_store is redis' do
      let(:data_store) { :redis }

      context 'when data exists' do
        let(:data) { 'Sample data in redis' }

        before do
          build_trace_chunk.send(:redis_set_data, data)
        end

        it { is_expected.to eq(data.bytesize) }
      end

      context 'when data exists' do
        it { is_expected.to eq(0) }
      end
    end

    context 'when data_store is database' do
      let(:data_store) { :db }

      context 'when data exists' do
        let(:raw_data) { 'Sample data in db' }
        let(:data) { raw_data }

        it { is_expected.to eq(data.bytesize) }
      end

      context 'when data does not exist' do
        it { is_expected.to eq(0) }
      end
    end
  end

  describe '#use_database!' do
    subject { build_trace_chunk.use_database! }

    context 'when data_store is redis' do
      let(:data_store) { :redis }

      context 'when data exists' do
        let(:data) { 'Sample data in redis' }

        before do
          build_trace_chunk.send(:redis_set_data, data)
        end

        it 'stashes the data' do
          expect(build_trace_chunk.data_store).to eq('redis')
          expect(build_trace_chunk.send(:redis_data)).to eq(data)
          expect(build_trace_chunk.raw_data).to be_nil

          subject

          expect(build_trace_chunk.data_store).to eq('db')
          expect(build_trace_chunk.send(:redis_data)).to be_nil
          expect(build_trace_chunk.raw_data).to eq(data)
        end
      end

      context 'when data does not exist' do
        it 'does not call UPDATE' do
          expect(ActiveRecord::QueryRecorder.new { subject }.count).to eq(0)
        end
      end
    end

    context 'when data_store is database' do
      let(:data_store) { :db }

      it 'does not call UPDATE' do
        expect(ActiveRecord::QueryRecorder.new { subject }.count).to eq(0)
      end
    end
  end

  describe 'ExclusiveLock' do
    before do
      allow_any_instance_of(Gitlab::ExclusiveLease).to receive(:try_obtain) { nil }
      stub_const('Ci::BuildTraceChunk::WRITE_LOCK_RETRY', 1)
    end

    it 'raise an error' do
      expect { build_trace_chunk.append('ABC', 0) }.to raise_error('Failed to obtain write lock')
    end
  end

  describe 'deletes data in redis after a parent record destroyed' do
    let(:project) { create(:project) }

    before do
      pipeline = create(:ci_pipeline, project: project)
      create(:ci_build, :running, :trace_live, pipeline: pipeline, project: project)
      create(:ci_build, :running, :trace_live, pipeline: pipeline, project: project)
      create(:ci_build, :running, :trace_live, pipeline: pipeline, project: project)
    end

    shared_examples_for 'deletes all build_trace_chunk and data in redis' do
      it do
        Gitlab::Redis::SharedState.with do |redis|
          expect(redis.scan_each(match: "gitlab:ci:trace:*:chunks:*").to_a.size).to eq(3)
        end

        expect(described_class.count).to eq(3)

        subject

        expect(described_class.count).to eq(0)

        Gitlab::Redis::SharedState.with do |redis|
          expect(redis.scan_each(match: "gitlab:ci:trace:*:chunks:*").to_a.size).to eq(0)
        end
      end
    end

    context 'when traces are archived' do
      let(:subject) do
        project.builds.each do |build|
          build.success!
        end
      end

      it_behaves_like 'deletes all build_trace_chunk and data in redis'
    end

    context 'when project is destroyed' do
      let(:subject) do
        project.destroy!
      end

      it_behaves_like 'deletes all build_trace_chunk and data in redis'
    end
  end
end
