Instruction:
Write RSpec test file for following ruby class
```ruby
# frozen_string_literal: true

module Deployments
  class UpdateEnvironmentService
    attr_reader :deployment
    attr_reader :deployable

    delegate :environment, to: :deployment
    delegate :variables, to: :deployable
    delegate :options, to: :deployable, allow_nil: true

    EnvironmentUpdateFailure = Class.new(StandardError)

    def initialize(deployment)
      @deployment = deployment
      @deployable = deployment.deployable
    end

    def execute
      deployment.create_ref
      deployment.invalidate_cache

      update_environment(deployment)

      deployment
    end

    def update_environment(deployment)
      ApplicationRecord.transaction do
        # Renew attributes at update
        renew_external_url
        renew_auto_stop_in
        renew_deployment_tier
        environment.fire_state_event(action)

        if environment.save
          deployment.update_merge_request_metrics! unless environment.stopped?
        else
          # If there is a validation error on environment update, such as
          # the external URL is malformed, the error message is recorded for debugging purpose.
          # We should surface the error message to users for letting them to take an action.
          # See https://gitlab.com/gitlab-org/gitlab/-/issues/21182.
          Gitlab::ErrorTracking.track_exception(
            EnvironmentUpdateFailure.new,
            project_id: deployment.project_id,
            environment_id: environment.id,
            reason: environment.errors.full_messages.to_sentence)
        end
      end
    end

    private

    def environment_options
      options&.dig(:environment) || {}
    end

    def expanded_environment_url
      return unless environment_url

      ExpandVariables.expand(environment_url, -> { variables.sort_and_expand_all })
    end

    def expanded_auto_stop_in
      return unless auto_stop_in

      ExpandVariables.expand(auto_stop_in, -> { variables.sort_and_expand_all })
    end

    def environment_url
      environment_options[:url]
    end

    def action
      environment_options[:action] || 'start'
    end

    def auto_stop_in
      deployable&.environment_auto_stop_in
    end

    def renew_external_url
      if (url = expanded_environment_url)
        environment.external_url = url
      end
    end

    def renew_auto_stop_in
      return unless deployable

      if (value = expanded_auto_stop_in)
        environment.auto_stop_in = value
      end
    end

    def renew_deployment_tier
      return unless deployable

      if (tier = deployable.environment_tier_from_options)
        environment.tier = tier
      end
    end
  end
end

Deployments::UpdateEnvironmentService.prepend_mod_with('Deployments::UpdateEnvironmentService')

```

Response:
# frozen_string_literal: true

require 'spec_helper'

RSpec.describe Deployments::UpdateEnvironmentService, feature_category: :continuous_delivery do
  let(:user) { create(:user) }
  let(:project) { create(:project, :repository) }
  let(:options) { { name: environment_name } }
  let(:pipeline) do
    create(
      :ci_pipeline,
      sha: 'b83d6e391c22777fca1ed3012fce84f633d7fed0',
      project: project
    )
  end

  let(:job) do
    create(:ci_build,
      :with_deployment,
      pipeline: pipeline,
      ref: 'master',
      tag: false,
      environment: environment_name,
      options: { environment: options },
      project: project)
  end

  let(:deployment) { job.deployment }
  let(:environment) { deployment.environment }
  let(:environment_name) { 'production' }

  subject(:service) { described_class.new(deployment) }

  before do
    allow(Deployments::LinkMergeRequestWorker).to receive(:perform_async)
    allow(Deployments::HooksWorker).to receive(:perform_async)
    job.success! # Create/Succeed deployment
  end

  describe '#execute' do
    let(:store) { Gitlab::EtagCaching::Store.new }

    it 'invalidates the environment etag cache' do
      old_value = store.get(environment.etag_cache_key)

      service.execute

      expect(store.get(environment.etag_cache_key)).not_to eq(old_value)
    end

    it 'creates ref' do
      expect_any_instance_of(Repository)
        .to receive(:create_ref)
        .with(deployment.sha, "refs/environments/production/deployments/#{deployment.iid}")

      service.execute
    end

    it 'updates merge request metrics' do
      expect_any_instance_of(Deployment)
        .to receive(:update_merge_request_metrics!)

      service.execute
    end

    it 'returns the deployment' do
      expect(subject.execute).to eq(deployment)
    end

    it 'returns the deployment when could not save the environment' do
      allow(environment).to receive(:save).and_return(false)

      expect(subject.execute).to eq(deployment)
    end

    it 'returns the deployment when environment is stopped' do
      allow(environment).to receive(:stopped?).and_return(true)

      expect(subject.execute).to eq(deployment)
    end

    context 'when deployable is bridge job' do
      let(:job) do
        create(:ci_bridge,
          :with_deployment,
          pipeline: pipeline,
          ref: 'master',
          tag: false,
          environment: environment_name,
          options: { environment: options },
          project: project)
      end

      it 'creates ref' do
        expect_any_instance_of(Repository)
          .to receive(:create_ref)
          .with(deployment.sha, "refs/environments/production/deployments/#{deployment.iid}")

        service.execute
      end
    end

    context 'when start action is defined' do
      let(:options) { { name: 'production', action: 'start' } }

      context 'and environment is stopped' do
        before do
          environment.stop_complete
        end

        it 'makes environment available' do
          service.execute

          expect(environment.reload).to be_available
        end
      end
    end

    context 'when external URL is specified and the tier is unset' do
      let(:options) { { name: 'production', url: external_url } }

      before do
        environment.update_columns(external_url: external_url, tier: nil)
        job.update!(environment: 'production')
      end

      context 'when external URL is valid' do
        let(:external_url) { 'https://google.com' }

        it 'succeeds to update the tier automatically' do
          expect { subject.execute }.to change { environment.tier }.from(nil).to('production')
        end
      end

      context 'when external URL is invalid' do
        let(:external_url) { 'javascript:alert("hello")' }

        it 'fails to update the tier due to validation error' do
          expect { subject.execute }.not_to change { environment.reload.tier }
        end

        it 'tracks an exception' do
          expect(Gitlab::ErrorTracking).to receive(:track_exception)
            .with(
              an_instance_of(described_class::EnvironmentUpdateFailure),
              project_id: project.id,
              environment_id: environment.id,
              reason: %q(External url javascript scheme is not allowed)
            )
            .once

          subject.execute
        end
      end
    end

    context 'when variables are used' do
      let(:options) do
        { name: 'review-apps/$CI_COMMIT_REF_NAME',
          url: 'http://$CI_COMMIT_REF_NAME.review-apps.gitlab.com' }
      end

      before do
        environment.update!(name: 'review-apps/master')
        job.update!(environment: 'review-apps/$CI_COMMIT_REF_NAME')
      end

      it 'does not create a new environment' do
        expect { subject.execute }.not_to change { Environment.count }
      end

      it 'updates external url' do
        subject.execute

        expect(subject.environment.name).to eq('review-apps/master')
        expect(subject.environment.external_url).to eq('http://master.review-apps.gitlab.com')
      end
    end

    context 'when auto_stop_in are used' do
      let(:options) do
        { name: 'production', auto_stop_in: '1 day' }
      end

      before do
        environment.update_attribute(:auto_stop_at, nil)
      end

      it 'renews auto stop at' do
        freeze_time do
          expect { subject.execute }
            .to change { environment.reset.auto_stop_at&.round }.from(nil).to(1.day.since.round)
        end
      end

      context 'when value is a variable' do
        let(:options) { { name: 'production', auto_stop_in: '$TTL' } }

        let(:yaml_variables) do
          [
            { key: "TTL", value: '2 days', public: true }
          ]
        end

        before do
          job.update_attribute(:yaml_variables, yaml_variables)
        end

        it 'renews auto stop at with expanded variable value' do
          freeze_time do
            expect { subject.execute }
              .to change { environment.reset.auto_stop_at&.round }.from(nil).to(2.days.since.round)
          end
        end
      end
    end

    context 'when deployment tier is specified' do
      let(:environment_name) { 'customer-portal' }
      let(:options) { { name: environment_name, deployment_tier: 'production' } }

      context 'when tier has already been set' do
        before do
          environment.update_column(:tier, Environment.tiers[:other])
        end

        it 'overwrites the guessed tier by the specified deployment tier' do
          expect { subject.execute }
            .to change { environment.reset.tier }.from('other').to('production')
        end
      end

      context 'when tier has not been set' do
        before do
          environment.update_column(:tier, nil)
        end

        it 'sets the specified deployment tier' do
          expect { subject.execute }
            .to change { environment.reset.tier }.from(nil).to('production')
        end

        context 'when deployment was created by an external CD system' do
          before do
            deployment.update_column(:deployable_id, nil)
            deployment.reload
          end

          it 'guesses the deployment tier' do
            expect { subject.execute }
              .to change { environment.reset.tier }.from(nil).to('other')
          end
        end
      end
    end

    context 'when deployment tier is not specified' do
      let(:environment_name) { 'customer-portal' }
      let(:options) { { name: environment_name } }

      it 'guesses the deployment tier' do
        environment.update_column(:tier, nil)

        expect { subject.execute }
          .to change { environment.reset.tier }.from(nil).to('other')
      end
    end
  end

  describe '#expanded_environment_url' do
    subject { service.send(:expanded_environment_url) }

    context 'when yaml environment uses $CI_COMMIT_REF_NAME' do
      let(:job) do
        create(
          :ci_build,
          :with_deployment,
          pipeline: pipeline,
          ref: 'master',
          environment: 'production',
          project: project,
          options: { environment: { name: 'production', url: 'http://review/$CI_COMMIT_REF_NAME' } }
        )
      end

      it { is_expected.to eq('http://review/master') }
    end

    context 'when yaml environment uses $CI_ENVIRONMENT_SLUG' do
      let(:job) do
        create(
          :ci_build,
          :with_deployment,
          pipeline: pipeline,
          ref: 'master',
          environment: 'prod-slug',
          project: project,
          options: { environment: { name: 'prod-slug', url: 'http://review/$CI_ENVIRONMENT_SLUG' } }
        )
      end

      it { is_expected.to eq('http://review/prod-slug') }
    end

    context 'when yaml environment uses yaml_variables containing symbol keys' do
      let(:job) do
        create(
          :ci_build,
          :with_deployment,
          pipeline: pipeline,
          yaml_variables: [{ key: :APP_HOST, value: 'host' }],
          environment: 'production',
          project: project,
          options: { environment: { name: 'production', url: 'http://review/$APP_HOST' } }
        )
      end

      it { is_expected.to eq('http://review/host') }
    end

    context 'when job variables are generated during runtime' do
      let(:job) do
        create(
          :ci_build,
          :with_deployment,
          pipeline: pipeline,
          environment: 'review/$CI_COMMIT_REF_NAME',
          project: project,
          job_variables: [job_variable],
          options: { environment: { name: 'review/$CI_COMMIT_REF_NAME', url: 'http://$DYNAMIC_ENV_URL' } }
        )
      end

      let(:job_variable) do
        build(:ci_job_variable, :dotenv_source, key: 'DYNAMIC_ENV_URL', value: 'abc.test.com')
      end

      it 'expands the environment URL from the dynamic variable' do
        is_expected.to eq('http://abc.test.com')
      end
    end

    context 'when environment url uses a nested variable' do
      let(:yaml_variables) do
        [
          { key: 'MAIN_DOMAIN', value: '${STACK_NAME}.example.com' },
          { key: 'STACK_NAME', value: 'appname-${ENVIRONMENT_NAME}' },
          { key: 'ENVIRONMENT_NAME', value: '${CI_COMMIT_REF_SLUG}' }
        ]
      end

      let(:job) do
        create(
          :ci_build,
          :with_deployment,
          pipeline: pipeline,
          ref: 'master',
          environment: 'production',
          project: project,
          yaml_variables: yaml_variables,
          options: { environment: { name: 'production', url: 'http://$MAIN_DOMAIN' } }
        )
      end

      it { is_expected.to eq('http://appname-master.example.com') }
    end

    context 'when yaml environment does not have url' do
      let(:job) { create(:ci_build, :with_deployment, pipeline: pipeline, environment: 'staging', project: project) }

      it 'returns the external_url from persisted environment' do
        is_expected.to be_nil
      end
    end
  end

  describe "merge request metrics" do
    let(:merge_request) { create(:merge_request, target_branch: 'master', source_branch: 'feature', source_project: project) }

    context "while updating the 'first_deployed_to_production_at' time" do
      before do
        merge_request.metrics.update!(merged_at: 1.hour.ago)
      end

      context "for merge requests merged before the current deploy" do
        it "sets the time if the deploy's environment is 'production'" do
          service.execute

          expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_like_time(deployment.finished_at)
        end

        context 'when job deploys to staging' do
          let(:job) do
            create(:ci_build,
              :with_deployment,
              pipeline: pipeline,
              ref: 'master',
              tag: false,
              environment: 'staging',
              options: { environment: { name: 'staging' } },
              project: project)
          end

          it "doesn't set the time if the deploy's environment is not 'production'" do
            service.execute

            expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_nil
          end
        end

        it 'does not raise errors if the merge request does not have a metrics record' do
          merge_request.metrics.destroy!

          expect(merge_request.reload.metrics).to be_nil
          expect { service.execute }.not_to raise_error
        end
      end

      context "for merge requests merged before the previous deploy" do
        context "if the 'first_deployed_to_production_at' time is already set" do
          it "does not overwrite the older 'first_deployed_to_production_at' time" do
            # Previous deploy
            service.execute

            expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_like_time(deployment.finished_at)

            # Current deploy
            travel_to(12.hours.from_now) do
              service.execute

              expect(merge_request.reload.metrics.first_deployed_to_production_at).to be_like_time(deployment.finished_at)
            end
          end
        end

        context "if the 'first_deployed_to_production_at' time is not already set" do
          it "does not overwrite the older 'first_deployed_to_production_at' time" do
            # Previous deploy
            time = 5.minutes.from_now
            travel_to(time) { service.execute }

            expect(merge_request.reload.metrics.merged_at).to be < merge_request.reload.metrics.first_deployed_to_production_at

            previous_time = merge_request.reload.metrics.first_deployed_to_production_at

            # Current deploy
            travel_to(time + 12.hours) { service.execute }

            expect(merge_request.reload.metrics.first_deployed_to_production_at).to eq(previous_time)
          end
        end
      end
    end
  end
end


Instruction:
Write RSpec test file for following ruby class
```ruby
# frozen_string_literal: true

module Deployments
  class UpdateService
    attr_reader :deployment, :params

    def initialize(deployment, params)
      @deployment = deployment
      @params = params
    end

    def execute
      deployment.update_status(params[:status])
    end
  end
end

```

Response:
# frozen_string_literal: true

require 'spec_helper'

RSpec.describe Deployments::UpdateService, feature_category: :continuous_delivery do
  let(:deploy) { create(:deployment) }

  describe '#execute' do
    it 'can update the status to running' do
      expect(described_class.new(deploy, status: 'running').execute)
        .to be_truthy

      expect(deploy).to be_running
    end

    it 'can update the status to success' do
      expect(described_class.new(deploy, status: 'success').execute)
        .to be_truthy

      expect(deploy).to be_success
    end

    it 'can update the status to failed' do
      expect(described_class.new(deploy, status: 'failed').execute)
        .to be_truthy

      expect(deploy).to be_failed
    end

    it 'can update the status to canceled' do
      expect(described_class.new(deploy, status: 'canceled').execute)
        .to be_truthy

      expect(deploy).to be_canceled
    end

    it 'does not change the state if the status is invalid' do
      expect(described_class.new(deploy, status: 'kittens').execute)
        .to be_falsy

      expect(deploy).to be_created
    end

    it 'links merge requests when changing the status to success', :sidekiq_inline do
      mr = create(
        :merge_request,
        :merged,
        target_project: deploy.project,
        source_project: deploy.project,
        target_branch: 'master',
        source_branch: 'foo'
      )

      described_class.new(deploy, status: 'success').execute

      expect(deploy.merge_requests).to eq([mr])
    end
  end
end


Instruction:
Write RSpec test file for following ruby class
```ruby
# frozen_string_literal: true

module Deployments
  class CreateService
    attr_reader :environment, :current_user, :params

    def initialize(environment, current_user, params)
      @environment = environment
      @current_user = current_user
      @params = params
    end

    def execute
      return last_deployment if last_deployment&.equal_to?(params)

      environment.deployments.build(deployment_attributes).tap do |deployment|
        # Deployment#change_status already saves the model, so we only need to
        # call #save ourselves if no status is provided.
        if (status = params[:status])
          deployment.update_status(status)
        else
          deployment.save
        end
      end
    end

    def deployment_attributes
      # We use explicit parameters here so we never by accident allow parameters
      # to be set that one should not be able to set (e.g. the row ID).
      {
        project_id: environment.project_id,
        environment_id: environment.id,
        ref: params[:ref],
        tag: params[:tag],
        sha: params[:sha],
        user: current_user,
        on_stop: params[:on_stop]
      }
    end

    private

    def last_deployment
      @environment.last_deployment
    end
  end
end

```

Response:
# frozen_string_literal: true

require 'spec_helper'

RSpec.describe Deployments::CreateService, feature_category: :continuous_delivery do
  let(:user) { create(:user) }

  describe '#execute' do
    let(:project) { create(:project, :repository) }
    let(:environment) { create(:environment, project: project) }

    it 'creates a deployment' do
      service = described_class.new(
        environment,
        user,
        sha: 'b83d6e391c22777fca1ed3012fce84f633d7fed0',
        ref: 'master',
        tag: false,
        status: 'success'
      )

      expect(Deployments::UpdateEnvironmentWorker).to receive(:perform_async)
      expect(Deployments::LinkMergeRequestWorker).to receive(:perform_async)
      expect(Deployments::HooksWorker).to receive(:perform_async)

      expect(service.execute).to be_persisted
    end

    it 'does not change the status if no status is given' do
      service = described_class.new(
        environment,
        user,
        sha: 'b83d6e391c22777fca1ed3012fce84f633d7fed0',
        ref: 'master',
        tag: false
      )

      expect(Deployments::UpdateEnvironmentWorker).not_to receive(:perform_async)
      expect(Deployments::LinkMergeRequestWorker).not_to receive(:perform_async)
      expect(Deployments::HooksWorker).not_to receive(:perform_async)

      expect(service.execute).to be_persisted
    end

    context 'when the last deployment has the same parameters' do
      let(:params) do
        {
          sha: 'b83d6e391c22777fca1ed3012fce84f633d7fed0',
          ref: 'master',
          tag: false,
          status: 'success'
        }
      end

      it 'does not create a new deployment' do
        described_class.new(environment, user, params).execute

        expect(Deployments::UpdateEnvironmentWorker).not_to receive(:perform_async)
        expect(Deployments::LinkMergeRequestWorker).not_to receive(:perform_async)
        expect(Deployments::HooksWorker).not_to receive(:perform_async)

        described_class.new(environment.reload, user, params).execute
      end
    end
  end

  describe '#deployment_attributes' do
    let(:environment) do
      double(
        :environment,
        deployment_platform: double(:platform, cluster_id: 1),
        project_id: 2,
        id: 3
      )
    end

    it 'only includes attributes that we want to persist' do
      service = described_class.new(
        environment,
        user,
        ref: 'master',
        tag: true,
        sha: '123',
        foo: 'bar',
        on_stop: 'stop'
      )

      expect(service.deployment_attributes).to eq(
        project_id: 2,
        environment_id: 3,
        ref: 'master',
        tag: true,
        sha: '123',
        user: user,
        on_stop: 'stop'
      )
    end
  end
end


Instruction:
Write RSpec test file for following ruby class
```ruby
# frozen_string_literal: true

module BulkImports
  class UploadsExportService
    include Gitlab::ImportExport::CommandLineUtil

    BATCH_SIZE = 100
    AVATAR_PATH = 'avatar'

    attr_reader :exported_objects_count

    def initialize(portable, export_path)
      @portable = portable
      @export_path = export_path
      @exported_objects_count = 0
    end

    def execute(options = {})
      relation = portable.uploads

      if options[:batch_ids]
        relation = relation.where(relation.model.primary_key => options[:batch_ids]) # rubocop:disable CodeReuse/ActiveRecord
      end

      relation.find_each(batch_size: BATCH_SIZE) do |upload| # rubocop: disable CodeReuse/ActiveRecord
        uploader = upload.retrieve_uploader

        next unless upload.exist?
        next unless uploader.file

        subdir_path = export_subdir_path(upload)
        mkdir_p(subdir_path)
        download_or_copy_upload(uploader, File.join(subdir_path, uploader.filename))
        @exported_objects_count += 1
      rescue StandardError => e
        # Do not fail entire project export if something goes wrong during file download
        # (e.g. downloaded file has filename that exceeds 255 characters).
        # Ignore raised exception, skip such upload, log the error and keep going with the export instead.
        Gitlab::ErrorTracking.log_exception(e, portable_id: portable.id, portable_class: portable.class.name, upload_id: upload.id)
      end
    end

    private

    attr_reader :portable, :export_path

    def export_subdir_path(upload)
      subdir = if upload.path == avatar_path
                 AVATAR_PATH
               else
                 upload.try(:secret).to_s
               end

      File.join(export_path, subdir)
    end

    def avatar_path
      @avatar_path ||= portable.avatar&.upload&.path
    end
  end
end

```

Response:
# frozen_string_literal: true

require 'spec_helper'

RSpec.describe BulkImports::UploadsExportService, feature_category: :importers do
  let(:export_path) { Dir.mktmpdir }
  let(:project) { create(:project, avatar: fixture_file_upload('spec/fixtures/rails_sample.png', 'image/png')) }
  let!(:upload) { create(:upload, :with_file, :issuable_upload, uploader: FileUploader, model: project) }
  let(:exported_filepath) { File.join(export_path, upload.secret, upload.retrieve_uploader.filename) }

  subject(:service) { described_class.new(project, export_path) }

  after do
    FileUtils.remove_entry(export_path) if Dir.exist?(export_path)
  end

  describe '#execute' do
    it 'exports project uploads and avatar' do
      service.execute

      expect(File).to exist(File.join(export_path, 'avatar', 'rails_sample.png'))
      expect(File).to exist(exported_filepath)
    end

    context 'when export is batched' do
      it 'exports only specified uploads' do
        service.execute(batch_ids: [upload.id])

        expect(service.exported_objects_count).to eq(1)
        expect(File).not_to exist(File.join(export_path, 'avatar', 'rails_sample.png'))
        expect(File).to exist(exported_filepath)
      end
    end

    context 'when upload has underlying file missing' do
      context 'with an upload missing its file' do
        it 'does not cause errors' do
          File.delete(upload.absolute_path)

          expect { service.execute }.not_to raise_error

          expect(File).not_to exist(exported_filepath)
        end
      end

      context 'when upload is in object storage' do
        before do
          stub_uploads_object_storage(FileUploader)
        end

        shared_examples 'export with invalid upload' do
          it 'ignores problematic upload and logs exception' do
            allow(service).to receive(:download_or_copy_upload).and_raise(exception)

            expect(Gitlab::ErrorTracking)
              .to receive(:log_exception)
              .with(
                instance_of(exception), {
                  portable_id: project.id,
                  portable_class: 'Project',
                  upload_id: upload.id
                }
              )

            expect(Gitlab::ErrorTracking)
              .to receive(:log_exception)
              .with(
                instance_of(exception), {
                  portable_id: project.id,
                  portable_class: 'Project',
                  upload_id: project.avatar.upload.id
                }
              )

            service.execute

            expect(File).not_to exist(exported_filepath)
          end
        end

        context 'when filename is too long' do
          let(:exception) { Errno::ENAMETOOLONG }

          include_examples 'export with invalid upload'
        end

        context 'when network exception occurs' do
          let(:exception) { Net::OpenTimeout }

          include_examples 'export with invalid upload'
        end
      end
    end
  end

  describe '#exported_objects_count' do
    it 'return the number of exported uploads' do
      service.execute

      expect(service.exported_objects_count).to eq(2)
    end
  end
end


Instruction:
Write RSpec test file for following ruby class
```ruby
# frozen_string_literal: true

module BulkImports
  class LfsObjectsExportService
    include Gitlab::ImportExport::CommandLineUtil

    BATCH_SIZE = 100

    attr_reader :exported_objects_count

    def initialize(portable, export_path)
      @portable = portable
      @export_path = export_path
      @lfs_json = {}
      @exported_objects_count = 0
    end

    def execute(options = {})
      relation = portable.lfs_objects

      if options[:batch_ids]
        relation = relation.where(relation.model.primary_key => options[:batch_ids]) # rubocop:disable CodeReuse/ActiveRecord
      end

      relation.find_in_batches(batch_size: BATCH_SIZE) do |batch| # rubocop: disable CodeReuse/ActiveRecord
        batch.each do |lfs_object|
          save_lfs_object(lfs_object)
          @exported_objects_count += 1
        end

        append_lfs_json_for_batch(batch)
      end

      write_lfs_json
    end

    private

    attr_reader :portable, :export_path, :lfs_json

    def save_lfs_object(lfs_object)
      destination_filepath = File.join(export_path, lfs_object.oid)

      if lfs_object.local_store?
        return unless File.exist?(lfs_object.file.path)

        copy_files(lfs_object.file.path, destination_filepath)
      else
        download(lfs_object.file.url, destination_filepath)
      end
    end

    # rubocop: disable CodeReuse/ActiveRecord
    def append_lfs_json_for_batch(lfs_objects_batch)
      lfs_objects_projects = LfsObjectsProject
        .select('lfs_objects.oid, array_agg(distinct lfs_objects_projects.repository_type) as repository_types')
        .joins(:lfs_object)
        .where(project: portable, lfs_object: lfs_objects_batch)
        .group('lfs_objects.oid')

      lfs_objects_projects.each do |group|
        oid = group.oid

        lfs_json[oid] ||= []
        lfs_json[oid] += group.repository_types
      end
    end
    # rubocop: enable CodeReuse/ActiveRecord

    def write_lfs_json
      filepath = File.join(export_path, "#{BulkImports::FileTransfer::ProjectConfig::LFS_OBJECTS_RELATION}.json")

      File.write(filepath, Gitlab::Json.dump(lfs_json))
    end
  end
end

```

Response:
# frozen_string_literal: true

require 'spec_helper'

RSpec.describe BulkImports::LfsObjectsExportService, feature_category: :importers do
  let_it_be(:project) { create(:project) }
  let_it_be(:lfs_json_filename) { "#{BulkImports::FileTransfer::ProjectConfig::LFS_OBJECTS_RELATION}.json" }
  let_it_be(:remote_url) { 'http://my-object-storage.local' }

  let(:export_path) { Dir.mktmpdir }
  let(:lfs_object) { create(:lfs_object, :with_file) }

  subject(:service) { described_class.new(project, export_path) }

  before do
    stub_lfs_object_storage

    %w[wiki design].each do |repository_type|
      create(
        :lfs_objects_project,
        project: project,
        repository_type: repository_type,
        lfs_object: lfs_object
      )
    end

    project.lfs_objects << lfs_object
  end

  after do
    FileUtils.remove_entry(export_path) if Dir.exist?(export_path)
  end

  describe '#execute' do
    it 'exports lfs objects and their repository types' do
      filepath = File.join(export_path, lfs_json_filename)

      service.execute

      expect(File).to exist(File.join(export_path, lfs_object.oid))
      expect(File).to exist(filepath)

      lfs_json = Gitlab::Json.parse(File.read(filepath))

      expect(lfs_json).to eq(
        {
          lfs_object.oid => [
            LfsObjectsProject.repository_types['wiki'],
            LfsObjectsProject.repository_types['design'],
            nil
          ]
        }
      )
    end

    context 'when export is batched' do
      it 'exports only specified lfs objects' do
        new_lfs_object = create(:lfs_object, :with_file)

        project.lfs_objects << new_lfs_object

        service.execute(batch_ids: [new_lfs_object.id])

        expect(File).to exist(File.join(export_path, new_lfs_object.oid))
        expect(File).not_to exist(File.join(export_path, lfs_object.oid))
      end
    end

    context 'when lfs object has file on disk missing' do
      it 'does not attempt to copy non-existent file' do
        FileUtils.rm(lfs_object.file.path)

        expect(service).not_to receive(:copy_files)

        service.execute

        expect(File).not_to exist(File.join(export_path, lfs_object.oid))
      end
    end

    context 'when lfs object is remotely stored' do
      let(:lfs_object) { create(:lfs_object, :object_storage) }

      it 'downloads lfs object from object storage' do
        expect_next_instance_of(LfsObjectUploader) do |instance|
          expect(instance).to receive(:url).and_return(remote_url)
        end

        expect(subject).to receive(:download).with(remote_url, File.join(export_path, lfs_object.oid))

        service.execute
      end
    end
  end

  describe '#exported_objects_count' do
    it 'return the number of exported lfs objects' do
      project.lfs_objects << create(:lfs_object, :with_file)

      service.execute

      expect(service.exported_objects_count).to eq(2)
    end
  end
end


Instruction:
Write RSpec test file for following ruby class
```ruby
# frozen_string_literal: true

# File Download Service allows remote file download into tmp directory.
#
# @param configuration [BulkImports::Configuration] Config object containing url and access token
# @param relative_url [String] Relative URL to download the file from
# @param tmpdir [String] Temp directory to store downloaded file to. Must be located under `Dir.tmpdir`.
# @param file_size_limit [Integer] Maximum allowed file size. If 0, no limit will apply.
# @param allowed_content_types [Array<String>] Allowed file content types
# @param filename [String] Name of the file to download, if known. Use remote filename if none given.
module BulkImports
  class FileDownloadService
    include ::BulkImports::FileDownloads::FilenameFetch
    include ::BulkImports::FileDownloads::Validations

    ServiceError = Class.new(StandardError)

    DEFAULT_ALLOWED_CONTENT_TYPES = %w[application/gzip application/octet-stream].freeze
    LAST_CHUNK_CONTEXT_CHAR_LIMIT = 200

    def initialize(
      configuration:,
      relative_url:,
      tmpdir:,
      file_size_limit: default_file_size_limit,
      allowed_content_types: DEFAULT_ALLOWED_CONTENT_TYPES,
      filename: nil)
      @configuration = configuration
      @relative_url = relative_url
      @filename = filename
      @tmpdir = tmpdir
      @file_size_limit = file_size_limit
      @allowed_content_types = allowed_content_types
      @remote_content_validated = false
    end

    def execute
      validate_tmpdir
      validate_filepath
      validate_url

      download_file

      validate_symlink

      filepath
    end

    private

    attr_reader :configuration, :relative_url, :tmpdir, :file_size_limit, :allowed_content_types,
      :response_headers, :last_chunk_context, :response_code

    def download_file
      File.open(filepath, 'wb') do |file|
        bytes_downloaded = 0

        http_client.stream(relative_url) do |chunk|
          next if bytes_downloaded == 0 && [301, 302, 303, 307, 308].include?(chunk.code)

          @response_code = chunk.code
          @response_headers ||= Gitlab::HTTP::Response::Headers.new(chunk.http_response.to_hash)
          @last_chunk_context = chunk.to_s.truncate(LAST_CHUNK_CONTEXT_CHAR_LIMIT)

          unless @remote_content_validated
            validate_content_type
            validate_content_length

            @remote_content_validated = true
          end

          bytes_downloaded += chunk.size

          validate_size!(bytes_downloaded)

          raise(ServiceError, "File download error #{chunk.code}") unless chunk.code == 200

          file.write(chunk)
        end
      end
    rescue StandardError => e
      FileUtils.rm_f(filepath)

      raise e
    end

    def raise_error(message)
      logger.warn(
        message: message,
        response_code: response_code,
        response_headers: response_headers,
        importer: 'gitlab_migration',
        last_chunk_context: last_chunk_context
      )

      raise ServiceError, message
    end

    def http_client
      @http_client ||= BulkImports::Clients::HTTP.new(
        url: configuration.url,
        token: configuration.access_token
      )
    end

    def allow_local_requests?
      ::Gitlab::CurrentSettings.allow_local_requests_from_web_hooks_and_services?
    end

    def validate_tmpdir
      Gitlab::PathTraversal.check_allowed_absolute_path!(tmpdir, [Dir.tmpdir])
    end

    def filepath
      @filepath ||= File.join(@tmpdir, filename)
    end

    def filename
      @filename.presence || remote_filename
    end

    def logger
      @logger ||= Logger.build
    end

    def validate_url
      ::Gitlab::UrlBlocker.validate!(
        http_client.resource_url(relative_url),
        allow_localhost: allow_local_requests?,
        allow_local_network: allow_local_requests?,
        schemes: %w[http https]
      )
    end

    def default_file_size_limit
      Gitlab::CurrentSettings.current_application_settings.bulk_import_max_download_file_size.megabytes
    end
  end
end

```

Response:
# frozen_string_literal: true

require 'spec_helper'

RSpec.describe BulkImports::FileDownloadService, feature_category: :importers do
  describe '#execute' do
    let_it_be(:allowed_content_types) { %w[application/gzip application/octet-stream] }
    let_it_be(:file_size_limit) { 5.gigabytes }
    let_it_be(:config) { build(:bulk_import_configuration) }
    let_it_be(:content_type) { 'application/octet-stream' }
    let_it_be(:content_disposition) { nil }
    let_it_be(:filename) { 'file_download_service_spec' }
    let_it_be(:tmpdir) { Dir.mktmpdir }
    let_it_be(:filepath) { File.join(tmpdir, filename) }
    let_it_be(:content_length) { 1000 }

    let(:headers) do
      {
        'content-length' => content_length,
        'content-type' => content_type,
        'content-disposition' => content_disposition
      }
    end

    let(:chunk_code) { 200 }
    let(:chunk_double) do
      double('chunk', size: 100, code: chunk_code, http_response: double(to_hash: headers), to_s: 'some chunk context')
    end

    subject(:service) do
      described_class.new(
        configuration: config,
        relative_url: '/test',
        tmpdir: tmpdir,
        filename: filename,
        file_size_limit: file_size_limit,
        allowed_content_types: allowed_content_types
      )
    end

    before do
      allow_next_instance_of(BulkImports::Clients::HTTP) do |client|
        allow(client).to receive(:stream).and_yield(chunk_double)
      end

      allow(service).to receive(:response_headers).and_return(headers)
    end

    shared_examples 'downloads file' do
      it 'downloads file' do
        subject.execute

        expect(File.exist?(filepath)).to eq(true)
        expect(File.read(filepath)).to include('chunk')
      end
    end

    include_examples 'downloads file'

    context 'when content-type is application/gzip' do
      let_it_be(:content_type) { 'application/gzip' }

      include_examples 'downloads file'
    end

    context 'when url is not valid' do
      it 'raises an error' do
        stub_application_setting(allow_local_requests_from_web_hooks_and_services: false)

        double = instance_double(BulkImports::Configuration, url: 'https://localhost', access_token: 'token')
        service = described_class.new(
          configuration: double,
          relative_url: '/test',
          tmpdir: tmpdir,
          filename: filename,
          file_size_limit: file_size_limit,
          allowed_content_types: allowed_content_types
        )

        expect { service.execute }.to raise_error(Gitlab::HTTP_V2::UrlBlocker::BlockedUrlError)
      end
    end

    context 'when content-type is not valid' do
      let(:content_type) { 'invalid' }
      let(:import_logger) { instance_double(BulkImports::Logger) }

      before do
        allow(BulkImports::Logger).to receive(:build).and_return(import_logger)
        allow(import_logger).to receive(:warn)
      end

      it 'logs and raises an error' do
        expect(import_logger).to receive(:warn).once.with(
          message: 'Invalid content type',
          response_code: chunk_code,
          response_headers: headers,
          importer: 'gitlab_migration',
          last_chunk_context: 'some chunk context'
        )

        expect { subject.execute }.to raise_error(described_class::ServiceError, 'Invalid content type')
      end
    end

    context 'when content-length is not valid' do
      context 'when content-length exceeds limit' do
        let(:file_size_limit) { 1 }

        it 'raises an error' do
          expect { subject.execute }.to raise_error(
            described_class::ServiceError,
            'File size 1000 B exceeds limit of 1 B'
          )
        end
      end

      context 'when content-length is missing' do
        let(:content_length) { nil }

        it 'raises an error' do
          expect { subject.execute }.to raise_error(
            described_class::ServiceError,
            'Missing content-length header'
          )
        end
      end
    end

    context 'when content-length is equals the file size limit' do
      let(:content_length) { 150 }
      let(:file_size_limit) { 150 }

      it 'does not raise an error' do
        expect { subject.execute }.not_to raise_error
      end
    end

    context 'when partially downloaded file exceeds limit' do
      let(:content_length) { 151 }
      let(:file_size_limit) { 150 }

      it 'raises an error' do
        expect { subject.execute }.to raise_error(
          described_class::ServiceError,
          'File size 151 B exceeds limit of 150 B'
        )
      end
    end

    context 'when chunk code is not 200' do
      let(:chunk_code) { 500 }

      it 'raises an error' do
        expect { subject.execute }.to raise_error(
          described_class::ServiceError,
          'File download error 500'
        )
      end

      context 'when chunk code is redirection' do
        let(:chunk_code) { 303 }

        it 'does not write a redirection chunk' do
          expect { subject.execute }.not_to raise_error

          expect(File.read(filepath)).not_to include('redirection')
        end

        context 'when redirection chunk appears at a later stage of the download' do
          it 'raises an error' do
            another_chunk_double = double('another redirection', size: 1000, code: 303)
            data_chunk = double('data chunk', size: 1000, code: 200, http_response: double(to_hash: {}))

            allow_next_instance_of(BulkImports::Clients::HTTP) do |client|
              allow(client)
                .to receive(:stream)
                .and_yield(chunk_double)
                .and_yield(data_chunk)
                .and_yield(another_chunk_double)
            end

            expect { subject.execute }.to raise_error(
              described_class::ServiceError,
              'File download error 303'
            )
          end
        end
      end
    end

    describe 'remote content validation' do
      context 'on redirect chunk' do
        let(:chunk_code) { 303 }

        it 'does not run content type & length validations' do
          expect(service).not_to receive(:validate_content_type)
          expect(service).not_to receive(:validate_content_length)

          service.execute
        end
      end

      context 'when there is one data chunk' do
        it 'validates content type & length' do
          expect(service).to receive(:validate_content_type)
          expect(service).to receive(:validate_content_length)

          service.execute
        end
      end

      context 'when there are multiple data chunks' do
        it 'validates content type & length only once' do
          data_chunk = double(
            'data chunk',
            size: 1000,
            code: 200,
            http_response: double(to_hash: {})
          )

          allow_next_instance_of(BulkImports::Clients::HTTP) do |client|
            allow(client)
              .to receive(:stream)
              .and_yield(chunk_double)
              .and_yield(data_chunk)
          end

          expect(service).to receive(:validate_content_type).once
          expect(service).to receive(:validate_content_length).once

          service.execute
        end
      end
    end

    context 'when file is a symlink' do
      let_it_be(:symlink) { File.join(tmpdir, 'symlink') }

      before do
        FileUtils.ln_s(File.join(tmpdir, filename), symlink, force: true)
      end

      subject do
        described_class.new(
          configuration: config,
          relative_url: '/test',
          tmpdir: tmpdir,
          filename: 'symlink',
          file_size_limit: file_size_limit,
          allowed_content_types: allowed_content_types
        )
      end

      it 'raises an error and removes the file' do
        expect { subject.execute }.to raise_error(
          described_class::ServiceError,
          'Invalid downloaded file'
        )

        expect(File.exist?(symlink)).to eq(false)
      end
    end

    context 'when file shares multiple hard links' do
      let_it_be(:hard_link) { File.join(tmpdir, 'hard_link') }

      before do
        existing_file = File.join(Dir.mktmpdir, filename)
        FileUtils.touch(existing_file)
        FileUtils.link(existing_file, hard_link)
      end

      subject do
        described_class.new(
          configuration: config,
          relative_url: '/test',
          tmpdir: tmpdir,
          filename: 'hard_link',
          file_size_limit: file_size_limit,
          allowed_content_types: allowed_content_types
        )
      end

      it 'raises an error and removes the file' do
        expect { subject.execute }.to raise_error(
          described_class::ServiceError,
          'Invalid downloaded file'
        )

        expect(File.exist?(hard_link)).to eq(false)
      end
    end

    context 'when dir is not in tmpdir' do
      subject do
        described_class.new(
          configuration: config,
          relative_url: '/test',
          tmpdir: '/etc',
          filename: filename,
          file_size_limit: file_size_limit,
          allowed_content_types: allowed_content_types
        )
      end

      it 'raises an error' do
        expect { subject.execute }.to raise_error(
          StandardError,
          'path /etc is not allowed'
        )
      end
    end

    context 'when dir path is being traversed' do
      subject do
        described_class.new(
          configuration: config,
          relative_url: '/test',
          tmpdir: File.join(Dir.mktmpdir('bulk_imports'), 'test', '..'),
          filename: filename,
          file_size_limit: file_size_limit,
          allowed_content_types: allowed_content_types
        )
      end

      it 'raises an error' do
        expect { subject.execute }.to raise_error(
          Gitlab::PathTraversal::PathTraversalAttackError,
          'Invalid path'
        )
      end
    end

    context 'when using the remote filename' do
      let_it_be(:filename) { nil }

      context 'when no filename is given' do
        it 'raises an error when the filename is not provided in the request header' do
          expect { subject.execute }.to raise_error(
            described_class::ServiceError,
            'Remote filename not provided in content-disposition header'
          )
        end
      end

      context 'with a given filename' do
        let_it_be(:content_disposition) { 'filename="avatar.png"' }

        it 'uses the given filename' do
          expect(subject.execute).to eq(File.join(tmpdir, "avatar.png"))
        end
      end

      context 'when the filename is a path' do
        let_it_be(:content_disposition) { 'filename="../../avatar.png"' }

        it 'raises an error when the filename is not provided in the request header' do
          expect(subject.execute).to eq(File.join(tmpdir, "avatar.png"))
        end
      end

      context 'when the filename is longer the the limit' do
        let_it_be(:content_disposition) { 'filename="../../xxx.b"' }

        before do
          stub_const('BulkImports::FileDownloads::FilenameFetch::FILENAME_SIZE_LIMIT', 1)
        end

        it 'raises an error when the filename is not provided in the request header' do
          expect(subject.execute).to eq(File.join(tmpdir, "x.b"))
        end
      end
    end
  end
end


Instruction:
Write RSpec test file for following ruby class
```ruby
# frozen_string_literal: true

module BulkImports
  class BatchedRelationExportService
    include Gitlab::Utils::StrongMemoize

    BATCH_SIZE = 1000
    BATCH_CACHE_KEY = 'bulk_imports/batched_relation_export/%{export_id}/%{batch_id}'
    CACHE_DURATION = 4.hours

    def self.cache_key(export_id, batch_id)
      Kernel.format(BATCH_CACHE_KEY, export_id: export_id, batch_id: batch_id)
    end

    def initialize(user, portable, relation, jid)
      @user = user
      @portable = portable
      @relation = relation
      @resolved_relation = portable.public_send(relation) # rubocop:disable GitlabSecurity/PublicSend
      @jid = jid
    end

    def execute
      return finish_export! if batches_count == 0

      start_export!
      export.batches.destroy_all # rubocop: disable Cop/DestroyAll
      enqueue_batch_exports

      FinishBatchedRelationExportWorker.perform_async(export.id)
    end

    private

    attr_reader :user, :portable, :relation, :jid, :config, :resolved_relation

    def export
      @export ||= portable.bulk_import_exports.find_or_create_by!(relation: relation) # rubocop:disable CodeReuse/ActiveRecord
    end

    def objects_count
      resolved_relation.count
    end

    def batches_count
      objects_count.fdiv(BATCH_SIZE).ceil
    end

    def start_export!
      update_export!('start')
    end

    def finish_export!
      update_export!('finish')
    end

    def update_export!(event)
      export.update!(
        status_event: event,
        total_objects_count: objects_count,
        batched: true,
        batches_count: batches_count,
        jid: jid,
        error: nil
      )
    end

    def enqueue_batch_exports
      resolved_relation.each_batch(of: BATCH_SIZE) do |batch, batch_number|
        batch_id = find_or_create_batch(batch_number).id
        ids = batch.pluck(batch.model.primary_key) # rubocop:disable CodeReuse/ActiveRecord

        Gitlab::Cache::Import::Caching.set_add(self.class.cache_key(export.id, batch_id), ids, timeout: CACHE_DURATION)

        RelationBatchExportWorker.perform_async(user.id, batch_id)
      end
    end

    def find_or_create_batch(batch_number)
      export.batches.find_or_create_by!(batch_number: batch_number) # rubocop:disable CodeReuse/ActiveRecord
    end
  end
end

```

Response:
# frozen_string_literal: true

require 'spec_helper'

RSpec.describe BulkImports::BatchedRelationExportService, feature_category: :importers do
  let_it_be(:user) { create(:user) }
  let_it_be(:portable) { create(:group) }

  let(:relation) { 'labels' }
  let(:jid) { '123' }

  subject(:service) { described_class.new(user, portable, relation, jid) }

  describe '#execute' do
    context 'when there are batches to export' do
      let_it_be(:label) { create(:group_label, group: portable) }

      it 'marks export as started' do
        service.execute

        export = portable.bulk_import_exports.first

        expect(export.reload.started?).to eq(true)
      end

      it 'removes existing batches' do
        expect_next_instance_of(BulkImports::Export) do |export|
          expect(export.batches).to receive(:destroy_all)
        end

        service.execute
      end

      it 'enqueues export jobs for each batch & caches batch record ids' do
        expect(BulkImports::RelationBatchExportWorker).to receive(:perform_async)
        expect(Gitlab::Cache::Import::Caching).to receive(:set_add)

        service.execute
      end

      it 'enqueues FinishBatchedRelationExportWorker' do
        expect(BulkImports::FinishBatchedRelationExportWorker).to receive(:perform_async)

        service.execute
      end

      context 'when there are multiple batches' do
        it 'creates a batch record for each batch of records' do
          stub_const("#{described_class.name}::BATCH_SIZE", 1)

          create_list(:group_label, 10, group: portable)

          service.execute

          export = portable.bulk_import_exports.first

          expect(export.batches.count).to eq(11)
        end
      end

      context 'when an error occurs during batches creation' do
        it 'does not enqueue FinishBatchedRelationExportWorker' do
          allow(service).to receive(:enqueue_batch_exports).and_raise(StandardError)

          expect(BulkImports::FinishBatchedRelationExportWorker).not_to receive(:perform_async)

          expect { service.execute }.to raise_error(StandardError)
        end
      end
    end

    context 'when there are no batches to export' do
      let(:relation) { 'milestones' }

      it 'marks export as finished' do
        service.execute

        export = portable.bulk_import_exports.first

        expect(export.finished?).to eq(true)
        expect(export.batches.count).to eq(0)
      end
    end
  end

  describe '.cache_key' do
    it 'returns cache key given export and batch ids' do
      expect(described_class.cache_key(1, 1)).to eq('bulk_imports/batched_relation_export/1/1')
    end
  end
end


Instruction:
Write RSpec test file for following ruby class
```ruby
# frozen_string_literal: true

module BulkImports
  class RepositoryBundleExportService
    def initialize(repository, export_path, export_filename)
      @repository = repository
      @export_path = export_path
      @export_filename = export_filename
    end

    def execute(_options = {})
      return unless repository_exists?

      repository.bundle_to_disk(bundle_filepath)
    end

    private

    attr_reader :repository, :export_path, :export_filename

    def repository_exists?
      repository.exists? && !repository.empty?
    end

    def bundle_filepath
      File.join(export_path, "#{export_filename}.bundle")
    end
  end
end

```

Response:
# frozen_string_literal: true

require 'spec_helper'

RSpec.describe BulkImports::RepositoryBundleExportService, feature_category: :importers do
  let(:project) { create(:project) }
  let(:export_path) { Dir.mktmpdir }

  subject(:service) { described_class.new(repository, export_path, export_filename) }

  after do
    FileUtils.remove_entry(export_path) if Dir.exist?(export_path)
  end

  describe '#execute' do
    shared_examples 'repository export' do
      context 'when repository exists' do
        it 'bundles repository to disk' do
          allow(repository).to receive(:exists?).and_return(true)
          allow(repository).to receive(:empty?).and_return(false)
          expect(repository).to receive(:bundle_to_disk).with(File.join(export_path, "#{export_filename}.bundle"))

          service.execute
        end
      end

      context 'when repository does not exist' do
        it 'does not bundle repository to disk' do
          allow(repository).to receive(:exists?).and_return(false)
          expect(repository).not_to receive(:bundle_to_disk)

          service.execute
        end
      end

      context 'when repository is empty' do
        it 'does not bundle repository to disk' do
          allow(repository).to receive(:empty?).and_return(true)
          expect(repository).not_to receive(:bundle_to_disk)

          service.execute
        end
      end
    end

    include_examples 'repository export' do
      let(:repository) { project.repository }
      let(:export_filename) { 'repository' }
    end

    include_examples 'repository export' do
      let(:repository) { project.design_repository }
      let(:export_filename) { 'design' }
    end
  end
end


Instruction:
Write RSpec test file for following ruby class
```ruby
# frozen_string_literal: true

module BulkImports
  class ExportService
    # @param portable [Project|Group] A project or a group to export.
    # @param user [User] A user performing the export.
    # @param batched [Boolean] Whether to export the data in batches.
    def initialize(portable:, user:, batched: false)
      @portable = portable
      @current_user = user
      @batched = batched
    end

    def execute
      validate_user_permissions!

      FileTransfer.config_for(portable).portable_relations.each do |relation|
        RelationExportWorker.perform_async(current_user.id, portable.id, portable.class.name, relation, batched)
      end

      ServiceResponse.success
    rescue StandardError => e
      ServiceResponse.error(
        message: e.class,
        http_status: :unprocessable_entity
      )
    end

    private

    attr_reader :portable, :current_user, :batched

    def validate_user_permissions!
      ability = "admin_#{portable.to_ability_name}"

      current_user.can?(ability, portable) ||
        raise(::Gitlab::ImportExport::Error.permission_error(current_user, portable))
    end
  end
end

```

Response:
# frozen_string_literal: true

require 'spec_helper'

RSpec.describe BulkImports::ExportService, feature_category: :importers do
  let_it_be(:group) { create(:group) }
  let_it_be(:user) { create(:user) }

  before do
    group.add_owner(user)
  end

  subject { described_class.new(portable: group, user: user) }

  describe '#execute' do
    let_it_be(:top_level_relations) { BulkImports::FileTransfer.config_for(group).portable_relations }

    before do
      allow(subject).to receive(:execute).and_return(ServiceResponse.success).and_call_original
    end

    context 'when export is not batched' do
      it 'schedules RelationExportWorker for each top level relation' do
        top_level_relations.each do |relation|
          expect(BulkImports::RelationExportWorker)
            .to receive(:perform_async)
            .with(user.id, group.id, group.class.name, relation, false)
        end

        subject.execute
      end
    end

    context 'when export is batched' do
      subject { described_class.new(portable: group, user: user, batched: true) }

      it 'schedules RelationExportWorker with a `batched: true` flag' do
        top_level_relations.each do |relation|
          expect(BulkImports::RelationExportWorker)
            .to receive(:perform_async)
            .with(user.id, group.id, group.class.name, relation, true)
        end

        subject.execute
      end
    end

    context 'when exception occurs' do
      it 'does not schedule RelationExportWorker' do
        service = described_class.new(portable: nil, user: user)

        expect(service)
          .to receive(:execute)
          .and_return(ServiceResponse.error(message: 'Gitlab::ImportExport::Error', http_status: :unprocessible_entity))
          .and_call_original
        expect(BulkImports::RelationExportWorker).not_to receive(:perform_async)

        service.execute
      end

      context 'when user is not allowed to perform export' do
        let(:another_user) { create(:user) }

        it 'does not schedule RelationExportWorker' do
          another_user = create(:user)
          service = described_class.new(portable: group, user: another_user)
          response = service.execute

          expect(response.status).to eq(:error)
          expect(response.message).to eq(Gitlab::ImportExport::Error)
          expect(response.http_status).to eq(:unprocessable_entity)
        end
      end
    end
  end
end


Instruction:
Write RSpec test file for following ruby class
```ruby
# frozen_string_literal: true

module BulkImports
  class ProcessService
    PERFORM_DELAY = 5.seconds
    DEFAULT_BATCH_SIZE = 5

    attr_reader :bulk_import

    def initialize(bulk_import)
      @bulk_import = bulk_import
    end

    def execute
      return unless bulk_import
      return if bulk_import.completed?
      return bulk_import.fail_op! if all_entities_failed?
      return bulk_import.finish! if all_entities_processed? && bulk_import.started?
      return re_enqueue if max_batch_size_exceeded? # Do not start more jobs if max allowed are already running

      process_bulk_import
      re_enqueue
    end

    private

    def process_bulk_import
      bulk_import.start! if bulk_import.created?

      created_entities.first(next_batch_size).each do |entity|
        create_tracker(entity)

        entity.start!

        Gitlab::ApplicationContext.with_context(bulk_import_entity_id: entity.id) do
          BulkImports::ExportRequestWorker.perform_async(entity.id)
        end
      end
    end

    def entities
      @entities ||= bulk_import.entities
    end

    def created_entities
      entities.with_status(:created)
    end

    def all_entities_processed?
      entities.all? { |entity| entity.finished? || entity.failed? }
    end

    def all_entities_failed?
      entities.all?(&:failed?)
    end

    # A new BulkImportWorker job is enqueued to either
    #   - Process the new BulkImports::Entity created during import (e.g. for the subgroups)
    #   - Or to mark the `bulk_import` as finished
    def re_enqueue
      BulkImportWorker.perform_in(PERFORM_DELAY, bulk_import.id)
    end

    def started_entities
      entities.with_status(:started)
    end

    def max_batch_size_exceeded?
      started_entities.count >= DEFAULT_BATCH_SIZE
    end

    def next_batch_size
      [DEFAULT_BATCH_SIZE - started_entities.count, 0].max
    end

    def create_tracker(entity)
      entity.class.transaction do
        entity.pipelines.each do |pipeline|
          status = skip_pipeline?(pipeline, entity) ? :skipped : :created

          entity.trackers.create!(
            stage: pipeline[:stage],
            pipeline_name: pipeline[:pipeline],
            status: BulkImports::Tracker.state_machine.states[status].value
          )
        end
      end
    end

    def skip_pipeline?(pipeline, entity)
      return false unless entity.source_version.valid?

      minimum_version, maximum_version = pipeline.values_at(:minimum_source_version, :maximum_source_version)

      if source_version_out_of_range?(minimum_version, maximum_version, entity.source_version.without_patch)
        log_skipped_pipeline(pipeline, entity, minimum_version, maximum_version)
        return true
      end

      false
    end

    def source_version_out_of_range?(minimum_version, maximum_version, non_patch_source_version)
      (minimum_version && non_patch_source_version < Gitlab::VersionInfo.parse(minimum_version)) ||
        (maximum_version && non_patch_source_version > Gitlab::VersionInfo.parse(maximum_version))
    end

    def log_skipped_pipeline(pipeline, entity, minimum_version, maximum_version)
      logger.info(
        message: 'Pipeline skipped as source instance version not compatible with pipeline',
        bulk_import_entity_id: entity.id,
        bulk_import_id: entity.bulk_import_id,
        bulk_import_entity_type: entity.source_type,
        source_full_path: entity.source_full_path,
        pipeline_class: pipeline[:pipeline],
        minimum_source_version: minimum_version,
        maximum_source_version: maximum_version,
        source_version: entity.source_version.to_s
      )
    end

    def logger
      @logger ||= Logger.build
    end
  end
end

```

Response:
# frozen_string_literal: true

require 'spec_helper'

RSpec.describe BulkImports::ProcessService, feature_category: :importers do
  describe '#execute' do
    let_it_be_with_reload(:bulk_import) { create(:bulk_import) }

    subject { described_class.new(bulk_import) }

    context 'when no bulk import is found' do
      let(:bulk_import) { nil }

      it 'does nothing' do
        expect(described_class).not_to receive(:process_bulk_import)
        subject.execute
      end
    end

    context 'when bulk import is finished' do
      it 'does nothing' do
        bulk_import.update!(status: 2)

        expect(described_class).not_to receive(:process_bulk_import)
        subject.execute
      end
    end

    context 'when bulk import is failed' do
      it 'does nothing' do
        bulk_import.update!(status: -1)

        expect(described_class).not_to receive(:process_bulk_import)
        subject.execute
      end
    end

    context 'when bulk import has timed out' do
      it 'does nothing' do
        bulk_import.update!(status: 3)

        expect(described_class).not_to receive(:process_bulk_import)
        subject.execute
      end
    end

    context 'when all entities are processed' do
      it 'marks bulk import as finished' do
        bulk_import.update!(status: 1)
        create(:bulk_import_entity, :finished, bulk_import: bulk_import)
        create(:bulk_import_entity, :failed, bulk_import: bulk_import)

        subject.execute

        expect(bulk_import.reload.finished?).to eq(true)
      end
    end

    context 'when all entities are failed' do
      it 'marks bulk import as failed' do
        bulk_import.update!(status: 1)
        create(:bulk_import_entity, :failed, bulk_import: bulk_import)
        create(:bulk_import_entity, :failed, bulk_import: bulk_import)

        subject.execute

        expect(bulk_import.reload.failed?).to eq(true)
      end
    end

    context 'when maximum allowed number of import entities in progress' do
      it 're-enqueues itself' do
        bulk_import.update!(status: 1)
        create(:bulk_import_entity, :created, bulk_import: bulk_import)
        (described_class::DEFAULT_BATCH_SIZE + 1).times do
          create(:bulk_import_entity, :started, bulk_import: bulk_import)
        end

        expect(BulkImportWorker).to receive(:perform_in).with(described_class::PERFORM_DELAY, bulk_import.id)
        expect(BulkImports::ExportRequestWorker).not_to receive(:perform_async)

        subject.execute
      end
    end

    context 'when bulk import is created' do
      it 'marks bulk import as started' do
        create(:bulk_import_entity, :created, bulk_import: bulk_import)

        subject.execute

        expect(bulk_import.reload.started?).to eq(true)
      end

      it 'creates all the required pipeline trackers' do
        entity_1 = create(:bulk_import_entity, :created, bulk_import: bulk_import)
        entity_2 = create(:bulk_import_entity, :created, bulk_import: bulk_import)

        expect { subject.execute }
          .to change { BulkImports::Tracker.count }
                .by(BulkImports::Groups::Stage.new(entity_1).pipelines.size * 2)

        expect(entity_1.trackers).not_to be_empty
        expect(entity_2.trackers).not_to be_empty
      end

      context 'when there are created entities to process' do
        before do
          stub_const("#{described_class}::DEFAULT_BATCH_SIZE", 1)
        end

        it 'marks a batch of entities as started, enqueues EntityWorker, ExportRequestWorker and reenqueues' do
          create(:bulk_import_entity, :created, bulk_import: bulk_import)
          create(:bulk_import_entity, :created, bulk_import: bulk_import)

          expect(BulkImportWorker).to receive(:perform_in).with(described_class::PERFORM_DELAY, bulk_import.id)
          expect(BulkImports::ExportRequestWorker).to receive(:perform_async).once

          subject.execute

          bulk_import.reload

          expect(bulk_import.entities.map(&:status_name)).to contain_exactly(:created, :started)
        end

        context 'when there are project entities to process' do
          it 'enqueues ExportRequestWorker' do
            create(:bulk_import_entity, :created, :project_entity, bulk_import: bulk_import)

            expect(BulkImports::ExportRequestWorker).to receive(:perform_async).once

            subject.execute
          end
        end
      end
    end

    context 'when importing a group' do
      it 'creates trackers for group entity' do
        entity = create(:bulk_import_entity, :group_entity, bulk_import: bulk_import)

        subject.execute

        expect(entity.trackers.to_a).to include(
          have_attributes(
            stage: 0, status_name: :created, relation: BulkImports::Groups::Pipelines::GroupPipeline.to_s
          ),
          have_attributes(
            stage: 1, status_name: :created, relation: BulkImports::Groups::Pipelines::GroupAttributesPipeline.to_s
          )
        )
      end
    end

    context 'when importing a project' do
      it 'creates trackers for project entity' do
        entity = create(:bulk_import_entity, :project_entity, bulk_import: bulk_import)

        subject.execute

        expect(entity.trackers.to_a).to include(
          have_attributes(
            stage: 0, status_name: :created, relation: BulkImports::Projects::Pipelines::ProjectPipeline.to_s
          ),
          have_attributes(
            stage: 1, status_name: :created, relation: BulkImports::Projects::Pipelines::RepositoryPipeline.to_s
          )
        )
      end
    end

    context 'when tracker configuration has a minimum version defined' do
      before do
        allow_next_instance_of(BulkImports::Groups::Stage) do |stage|
          allow(stage).to receive(:config).and_return(
            {
              pipeline1: { pipeline: 'PipelineClass1', stage: 0 },
              pipeline2: { pipeline: 'PipelineClass2', stage: 1, minimum_source_version: '14.10.0' },
              pipeline3: { pipeline: 'PipelineClass3', stage: 1, minimum_source_version: '15.0.0' },
              pipeline5: { pipeline: 'PipelineClass4', stage: 1, minimum_source_version: '15.1.0' },
              pipeline6: { pipeline: 'PipelineClass5', stage: 1, minimum_source_version: '16.0.0' }
            }
          )
        end
      end

      context 'when the source instance version is older than the tracker mininum version' do
        let_it_be(:entity) { create(:bulk_import_entity, :group_entity, bulk_import: bulk_import) }

        before do
          bulk_import.update!(source_version: '15.0.0')
        end

        it 'creates trackers as skipped if version requirement does not meet' do
          subject.execute

          expect(entity.trackers.collect { |tracker| [tracker.status_name, tracker.relation] }).to contain_exactly(
            [:created, 'PipelineClass1'],
            [:created, 'PipelineClass2'],
            [:created, 'PipelineClass3'],
            [:skipped, 'PipelineClass4'],
            [:skipped, 'PipelineClass5']
          )
        end

        it 'logs an info message for the skipped pipelines' do
          expect_next_instance_of(BulkImports::Logger) do |logger|
            expect(logger).to receive(:info).with(
              message: 'Pipeline skipped as source instance version not compatible with pipeline',
              bulk_import_entity_id: entity.id,
              bulk_import_id: entity.bulk_import_id,
              bulk_import_entity_type: entity.source_type,
              source_full_path: entity.source_full_path,
              pipeline_class: 'PipelineClass4',
              minimum_source_version: '15.1.0',
              maximum_source_version: nil,
              source_version: '15.0.0'
            )

            expect(logger).to receive(:info).with(
              message: 'Pipeline skipped as source instance version not compatible with pipeline',
              bulk_import_entity_id: entity.id,
              bulk_import_id: entity.bulk_import_id,
              bulk_import_entity_type: entity.source_type,
              source_full_path: entity.source_full_path,
              pipeline_class: 'PipelineClass5',
              minimum_source_version: '16.0.0',
              maximum_source_version: nil,
              source_version: '15.0.0'
            )
          end

          subject.execute
        end
      end

      context 'when the source instance version is undefined' do
        it 'creates trackers as created' do
          bulk_import.update!(source_version: nil)
          entity = create(:bulk_import_entity, :group_entity, bulk_import: bulk_import)

          subject.execute

          expect(entity.trackers.collect { |tracker| [tracker.status_name, tracker.relation] }).to contain_exactly(
            [:created, 'PipelineClass1'],
            [:created, 'PipelineClass2'],
            [:created, 'PipelineClass3'],
            [:created, 'PipelineClass4'],
            [:created, 'PipelineClass5']
          )
        end
      end
    end

    context 'when tracker configuration has a maximum version defined' do
      before do
        allow_next_instance_of(BulkImports::Groups::Stage) do |stage|
          allow(stage).to receive(:config).and_return(
            {
              pipeline1: { pipeline: 'PipelineClass1', stage: 0 },
              pipeline2: { pipeline: 'PipelineClass2', stage: 1, maximum_source_version: '14.10.0' },
              pipeline3: { pipeline: 'PipelineClass3', stage: 1, maximum_source_version: '15.0.0' },
              pipeline5: { pipeline: 'PipelineClass4', stage: 1, maximum_source_version: '15.1.0' },
              pipeline6: { pipeline: 'PipelineClass5', stage: 1, maximum_source_version: '16.0.0' }
            }
          )
        end
      end

      context 'when the source instance version is older than the tracker maximum version' do
        it 'creates trackers as skipped if version requirement does not meet' do
          bulk_import.update!(source_version: '15.0.0')
          entity = create(:bulk_import_entity, :group_entity, bulk_import: bulk_import)

          subject.execute

          expect(entity.trackers.collect { |tracker| [tracker.status_name, tracker.relation] }).to contain_exactly(
            [:created, 'PipelineClass1'],
            [:skipped, 'PipelineClass2'],
            [:created, 'PipelineClass3'],
            [:created, 'PipelineClass4'],
            [:created, 'PipelineClass5']
          )
        end
      end

      context 'when the source instance version is a patch version' do
        it 'creates trackers with the same status as the non-patch source version' do
          bulk_import_1 = create(:bulk_import, source_version: '15.0.1')
          entity_1 = create(:bulk_import_entity, :group_entity, bulk_import: bulk_import_1)

          bulk_import_2 = create(:bulk_import, source_version: '15.0.0')
          entity_2 = create(:bulk_import_entity, :group_entity, bulk_import: bulk_import_2)

          described_class.new(bulk_import_1).execute
          described_class.new(bulk_import_2).execute

          trackers_1 = entity_1.trackers.collect { |tracker| [tracker.status_name, tracker.relation] }
          trackers_2 = entity_2.trackers.collect { |tracker| [tracker.status_name, tracker.relation] }

          expect(trackers_1).to eq(trackers_2)
        end
      end
    end
  end
end


Instruction:
Write RSpec test file for following ruby class
```ruby
# frozen_string_literal: true

module BulkImports
  class RelationBatchExportService
    include Gitlab::ImportExport::CommandLineUtil

    def initialize(user, batch)
      @user = user
      @batch = batch
      @config = FileTransfer.config_for(portable)
    end

    def execute
      start_batch!

      export_service.export_batch(relation_batch_ids)
      ensure_export_file_exists!
      compress_exported_relation
      upload_compressed_file

      finish_batch!
    ensure
      FileUtils.remove_entry(export_path)
    end

    private

    attr_reader :user, :batch, :config

    delegate :export_path, to: :config
    delegate :batch_number, :export, to: :batch
    delegate :portable, :relation, to: :export
    delegate :exported_filename, :exported_objects_count, to: :export_service

    def export_service
      @export_service ||= config.export_service_for(relation).new(portable, export_path, relation, user)
    end

    def compress_exported_relation
      gzip(dir: export_path, filename: exported_filename)
    end

    def upload_compressed_file
      File.open(compressed_filename) { |file| batch_upload.export_file = file }

      batch_upload.save!
    end

    def batch_upload
      @batch_upload ||= ::BulkImports::ExportUpload.find_or_initialize_by(export_id: export.id, batch_id: batch.id) # rubocop: disable CodeReuse/ActiveRecord
    end

    def compressed_filename
      File.join(export_path, "#{exported_filename}.gz")
    end

    def relation_batch_ids
      Gitlab::Cache::Import::Caching.values_from_set(cache_key).map(&:to_i)
    end

    def cache_key
      BulkImports::BatchedRelationExportService.cache_key(export.id, batch.id)
    end

    def start_batch!
      batch.update!(status_event: 'start', objects_count: 0, error: nil)
    end

    def finish_batch!
      batch.update!(status_event: 'finish', objects_count: exported_objects_count, error: nil)
    end

    def exported_filepath
      File.join(export_path, exported_filename)
    end

    # Create empty file on disk
    # if relation is empty and nothing was exported
    def ensure_export_file_exists!
      FileUtils.touch(exported_filepath)
    end
  end
end

```

Response:
# frozen_string_literal: true

require 'spec_helper'

RSpec.describe BulkImports::RelationBatchExportService, feature_category: :importers do
  let_it_be(:project) { create(:project) }
  let_it_be(:label) { create(:label, project: project) }
  let_it_be(:user) { create(:user) }
  let_it_be(:export) { create(:bulk_import_export, :batched, project: project) }
  let_it_be(:batch) { create(:bulk_import_export_batch, export: export) }
  let_it_be(:cache_key) { BulkImports::BatchedRelationExportService.cache_key(export.id, batch.id) }

  subject(:service) { described_class.new(user, batch) }

  before_all do
    Gitlab::Cache::Import::Caching.set_add(cache_key, label.id)
  end

  after(:all) do
    Gitlab::Cache::Import::Caching.expire(cache_key, 0)
  end

  describe '#execute' do
    it 'exports relation batch' do
      expect(Gitlab::Cache::Import::Caching).to receive(:values_from_set).with(cache_key).and_call_original

      service.execute
      batch.reload

      expect(batch.finished?).to eq(true)
      expect(batch.objects_count).to eq(1)
      expect(batch.error).to be_nil
      expect(export.upload.export_file).to be_present
    end

    it 'removes exported contents after export' do
      allow(subject).to receive(:export_path).and_return('foo')
      allow(FileUtils).to receive(:remove_entry)

      expect(FileUtils).to receive(:remove_entry).with('foo')

      service.execute
    end

    context 'when relation is empty and there is nothing to export' do
      let_it_be(:export) { create(:bulk_import_export, :batched, project: project, relation: 'milestones') }
      let_it_be(:batch) { create(:bulk_import_export_batch, export: export) }

      it 'creates empty file on disk' do
        allow(subject).to receive(:export_path).and_return('foo')
        allow(FileUtils).to receive(:remove_entry)

        expect(FileUtils).to receive(:touch).with('foo/milestones.ndjson').and_call_original

        subject.execute
      end
    end
  end
end


Instruction:
Write RSpec test file for following ruby class
```ruby
# frozen_string_literal: true

# Archive Extraction Service allows extraction of contents
# from `tar` archives with an additional handling (removal)
# of file symlinks.
#
# @param tmpdir [String] A path where archive is located
# and where its contents are extracted.
# Tmpdir directory must be located under `Dir.tmpdir`.
# `BulkImports::Error` is raised if any other directory path is used.
#
# @param filename [String] Name of the file to extract contents from.
#
# @example
#   dir = Dir.mktmpdir
#   filename = 'things.tar'
#   BulkImports::ArchiveExtractionService.new(tmpdir: dir, filename: filename).execute
#   Dir.glob(File.join(dir, '**', '*'))
#   => ['/path/to/tmp/dir/extracted_file_1', '/path/to/tmp/dir/extracted_file_2', '/path/to/tmp/dir/extracted_file_3']
module BulkImports
  class ArchiveExtractionService
    include Gitlab::ImportExport::CommandLineUtil

    def initialize(tmpdir:, filename:)
      @tmpdir = tmpdir
      @filename = filename
      @filepath = File.join(@tmpdir, @filename)
    end

    def execute
      validate_tmpdir
      validate_filepath
      validate_symlink

      extract_archive
      tmpdir
    end

    private

    attr_reader :tmpdir, :filename, :filepath

    def validate_filepath
      Gitlab::PathTraversal.check_path_traversal!(filepath)
    end

    def validate_tmpdir
      Gitlab::PathTraversal.check_allowed_absolute_path!(tmpdir, [Dir.tmpdir])
    end

    def validate_symlink
      raise(BulkImports::Error, 'Invalid file') if Gitlab::Utils::FileInfo.linked?(filepath)
    end

    def extract_archive
      untar_xf(archive: filepath, dir: tmpdir)
    end
  end
end

```

Response:
# frozen_string_literal: true

require 'spec_helper'

RSpec.describe BulkImports::ArchiveExtractionService, feature_category: :importers do
  let_it_be(:tmpdir) { Dir.mktmpdir }
  let_it_be(:filename) { 'symlink_export.tar' }
  let_it_be(:filepath) { File.join(tmpdir, filename) }

  before do
    FileUtils.copy_file(File.join('spec', 'fixtures', filename), filepath)
  end

  after(:all) do
    FileUtils.remove_entry(tmpdir)
  end

  subject(:service) { described_class.new(tmpdir: tmpdir, filename: filename) }

  describe '#execute' do
    it 'extracts files from archive and removes symlinks' do
      file = File.join(tmpdir, 'project.json')
      folder = File.join(tmpdir, 'uploads')
      symlink = File.join(tmpdir, 'uploads', 'link.gitignore')

      expect(service).to receive(:untar_xf).with(archive: filepath, dir: tmpdir).and_call_original

      service.execute

      expect(File.exist?(file)).to eq(true)
      expect(Dir.exist?(folder)).to eq(true)
      expect(File.exist?(symlink)).to eq(false)
    end

    context 'when dir is not in tmpdir' do
      it 'raises an error' do
        ['/etc', '/usr', '/', '/home', '/some/other/path', Rails.root.to_s].each do |path|
          expect { described_class.new(tmpdir: path, filename: 'filename').execute }
            .to raise_error(StandardError, "path #{path} is not allowed")
        end
      end
    end

    context 'when archive file is a symlink' do
      it 'raises an error' do
        FileUtils.ln_s(filepath, File.join(tmpdir, 'symlink'))

        expect { described_class.new(tmpdir: tmpdir, filename: 'symlink').execute }
          .to raise_error(BulkImports::Error, 'Invalid file')
      end
    end

    context 'when archive file shares multiple hard links' do
      it 'raises an error' do
        FileUtils.link(filepath, File.join(tmpdir, 'hard_link'))

        expect { subject.execute }.to raise_error(BulkImports::Error, 'Invalid file')
      end
    end

    context 'when filepath is being traversed' do
      it 'raises an error' do
        expect { described_class.new(tmpdir: File.join(Dir.mktmpdir, 'test', '..'), filename: 'name').execute }
          .to raise_error(Gitlab::PathTraversal::PathTraversalAttackError, 'Invalid path')
      end
    end
  end
end


Instruction:
Write RSpec test file for following ruby class
```ruby
# frozen_string_literal: true

module BulkImports
  class FileExportService
    include Gitlab::ImportExport::CommandLineUtil

    SINGLE_OBJECT_RELATIONS = [
      FileTransfer::ProjectConfig::REPOSITORY_BUNDLE_RELATION,
      FileTransfer::ProjectConfig::DESIGN_BUNDLE_RELATION
    ].freeze

    def initialize(portable, export_path, relation, user)
      @portable = portable
      @export_path = export_path
      @relation = relation
      @user = user # not used anywhere in this class at the moment
    end

    def execute(options = {})
      export_service.execute(options)

      archive_exported_data
    end

    def export_batch(ids)
      execute(batch_ids: ids)
    end

    def exported_filename
      "#{relation}.tar"
    end

    def exported_objects_count
      case relation
      when *SINGLE_OBJECT_RELATIONS
        1
      else
        export_service.exported_objects_count
      end
    end

    private

    attr_reader :export_path, :portable, :relation

    def export_service
      @export_service ||= case relation
                          when FileTransfer::BaseConfig::UPLOADS_RELATION
                            UploadsExportService.new(portable, export_path)
                          when FileTransfer::ProjectConfig::LFS_OBJECTS_RELATION
                            LfsObjectsExportService.new(portable, export_path)
                          when FileTransfer::ProjectConfig::REPOSITORY_BUNDLE_RELATION
                            RepositoryBundleExportService.new(portable.repository, export_path, relation)
                          when FileTransfer::ProjectConfig::DESIGN_BUNDLE_RELATION
                            RepositoryBundleExportService.new(portable.design_repository, export_path, relation)
                          else
                            raise BulkImports::Error, 'Unsupported relation export type'
                          end
    end

    def archive_exported_data
      archive_file = File.join(export_path, exported_filename)

      tar_cf(archive: archive_file, dir: export_path)
    end
  end
end

```

Response:
# frozen_string_literal: true

require 'spec_helper'

RSpec.describe BulkImports::FileExportService, feature_category: :importers do
  let_it_be(:project) { create(:project) }

  let(:relations) do
    {
      'uploads' => BulkImports::UploadsExportService,
      'lfs_objects' => BulkImports::LfsObjectsExportService,
      'repository' => BulkImports::RepositoryBundleExportService,
      'design' => BulkImports::RepositoryBundleExportService
    }
  end

  describe '#execute' do
    it 'executes export service and archives exported data for each file relation' do
      relations.each do |relation, klass|
        Dir.mktmpdir do |export_path|
          service = described_class.new(project, export_path, relation, nil)

          expect_next_instance_of(klass) do |service|
            expect(service).to receive(:execute)
          end

          expect(service).to receive(:tar_cf).with(archive: File.join(export_path, "#{relation}.tar"), dir: export_path)

          service.execute
        end
      end
    end

    context 'when unsupported relation is passed' do
      it 'raises an error' do
        service = described_class.new(project, nil, 'unsupported', nil)

        expect { service.execute }.to raise_error(BulkImports::Error, 'Unsupported relation export type')
      end
    end
  end

  describe '#execute_batch' do
    it 'calls execute with provided array of record ids' do
      relations.each do |relation, klass|
        Dir.mktmpdir do |export_path|
          service = described_class.new(project, export_path, relation, nil)

          expect_next_instance_of(klass) do |service|
            expect(service).to receive(:execute).with({ batch_ids: [1, 2, 3] })
          end

          service.export_batch([1, 2, 3])
        end
      end
    end
  end

  describe '#exported_filename' do
    it 'returns filename of the exported file' do
      service = described_class.new(project, nil, 'uploads', nil)

      expect(service.exported_filename).to eq('uploads.tar')
    end
  end

  describe '#exported_objects_count' do
    context 'when relation is a collection' do
      it 'returns a number of exported relations' do
        %w[uploads lfs_objects].each do |relation|
          service = described_class.new(project, nil, relation, nil)

          allow(service).to receive_message_chain(:export_service, :exported_objects_count).and_return(10)

          expect(service.exported_objects_count).to eq(10)
        end
      end
    end

    context 'when relation is a repository' do
      it 'returns 1' do
        %w[repository design].each do |relation|
          service = described_class.new(project, nil, relation, nil)

          expect(service.exported_objects_count).to eq(1)
        end
      end
    end
  end
end